Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Mix.Task behaviour info not available in dialyzer context
{"lib/mix/tasks/jido_shell.ex", :callback_info_missing},
{"lib/mix/tasks/jido_shell.ex", :unknown_function},
{"lib/mix/tasks/jido_shell.guardrails.ex", :callback_info_missing},
{"lib/mix/tasks/jido_shell.guardrails.ex", :unknown_function},
{"lib/mix/tasks/jido_shell.install.ex", :callback_info_missing},
{"lib/mix/tasks/jido_shell.install.ex", :unknown_function}
]
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ concurrency:
cancel-in-progress: true

jobs:
guardrails:
name: Guardrails
uses: agentjido/github-actions/.github/workflows/elixir-test.yml@main
with:
otp_versions: '["27"]'
elixir_versions: '["1.18"]'
test_command: mix jido_shell.guardrails

lint:
name: Lint
uses: agentjido/github-actions/.github/workflows/elixir-lint.yml@main
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mix compile --warnings-as-errors
mix test
mix test --include flaky
mix coveralls
mix jido_shell.guardrails
mix quality
mix docs
mix jido_shell
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ mix test
Run before opening a PR:

```bash
mix jido_shell.guardrails
mix quality
mix test
mix test --include flaky
mix coveralls
```

`mix quality` runs `mix jido_shell.guardrails` first, so namespace/layout regressions fail early in scripted workflows.

## Common Commands

```bash
Expand All @@ -38,6 +41,7 @@ mix docs
2. Add tests for behavior changes.
3. Keep docs and examples in sync.
4. Use conventional commits.
5. If module namespace/layout conventions change, update guardrails and guardrail tests in the same PR.

Examples:

Expand Down
70 changes: 70 additions & 0 deletions lib/jido/shell/guardrails.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Jido.Shell.Guardrails do
@moduledoc """
Runs namespace and layout guardrails for the `jido_shell` codebase.
"""

alias Jido.Shell.Guardrails.Violation

@default_rules [
Jido.Shell.Guardrails.Rules.ForbiddenPaths,
Jido.Shell.Guardrails.Rules.RequiredFiles,
Jido.Shell.Guardrails.Rules.NamespacePrefixes
]

@type options :: [
root: String.t(),
rules: [module()]
]

@spec check(options()) :: :ok | {:error, [Violation.t()]}
def check(opts \\ []) do
context = %{root: normalize_root(opts)}

violations =
opts
|> rules()
|> Enum.flat_map(&run_rule(&1, context))
|> Enum.sort_by(fn %Violation{file: file, message: message} ->
{file || "", message}
end)

case violations do
[] -> :ok
_ -> {:error, violations}
end
end

@spec default_rules() :: [module()]
def default_rules, do: @default_rules

@spec format_violations([Violation.t()]) :: String.t()
def format_violations(violations) do
Enum.map_join(violations, "\n", fn %Violation{rule: rule, file: file, message: message} ->
location = if file, do: " (#{file})", else: ""
"[#{inspect(rule)}]#{location} #{message}"
end)
end

defp normalize_root(opts) do
opts
|> Keyword.get(:root, File.cwd!())
|> Path.expand()
end

defp rules(opts) do
Keyword.get(opts, :rules, @default_rules)
end

defp run_rule(rule, context) when is_atom(rule) do
Code.ensure_loaded(rule)

unless function_exported?(rule, :check, 1) do
raise ArgumentError, "guardrail rule #{inspect(rule)} must define check/1"
end

case rule.check(context) do
:ok -> []
violations when is_list(violations) -> violations
end
end
end
13 changes: 13 additions & 0 deletions lib/jido/shell/guardrails/rule.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Jido.Shell.Guardrails.Rule do
@moduledoc """
Behaviour for guardrail rules.
"""

alias Jido.Shell.Guardrails.Violation

@type context :: %{
root: String.t()
}

@callback check(context()) :: :ok | [Violation.t()]
end
25 changes: 25 additions & 0 deletions lib/jido/shell/guardrails/rules/forbidden_paths.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Jido.Shell.Guardrails.Rules.ForbiddenPaths do
@moduledoc false
@behaviour Jido.Shell.Guardrails.Rule

alias Jido.Shell.Guardrails.Violation

@forbidden_paths [
"lib/kodo",
"test/kodo",
"lib/mix/tasks/kodo.ui.ex"
]

@impl true
def check(%{root: root}) do
@forbidden_paths
|> Enum.filter(&(root |> Path.join(&1) |> File.exists?()))
|> Enum.map(fn path ->
%Violation{
rule: __MODULE__,
file: path,
message: "legacy namespace path exists: #{path}"
}
end)
end
end
61 changes: 61 additions & 0 deletions lib/jido/shell/guardrails/rules/namespace_prefixes.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
defmodule Jido.Shell.Guardrails.Rules.NamespacePrefixes do
@moduledoc false
@behaviour Jido.Shell.Guardrails.Rule

alias Jido.Shell.Guardrails.Violation

@module_pattern ~r/^\s*defmodule\s+([A-Za-z0-9_.]+)\s+do/m

@file_prefix_rules [
{"lib/jido/shell/**/*.ex", "Jido.Shell"},
{"lib/mix/tasks/jido_shell*.ex", "Mix.Tasks.JidoShell"}
]

@impl true
def check(%{root: root}) do
Enum.flat_map(@file_prefix_rules, fn {glob, prefix} ->
root
|> Path.join(glob)
|> Path.wildcard()
|> Enum.flat_map(fn file ->
relative = Path.relative_to(file, root)
file_namespace_violations(file, relative, prefix)
end)
end)
end

defp file_namespace_violations(file, relative, prefix) do
modules =
file
|> File.read!()
|> modules_in_file()

case modules do
[] ->
[
%Violation{
rule: __MODULE__,
file: relative,
message: "no module definition found for namespace check"
}
]

_ ->
modules
|> Enum.reject(&String.starts_with?(&1, prefix))
|> Enum.map(fn module_name ->
%Violation{
rule: __MODULE__,
file: relative,
message: "module #{module_name} does not use expected prefix #{prefix}"
}
end)
end
end

defp modules_in_file(contents) do
@module_pattern
|> Regex.scan(contents, capture: :all_but_first)
|> Enum.map(fn [module_name] -> module_name end)
end
end
59 changes: 59 additions & 0 deletions lib/jido/shell/guardrails/rules/required_files.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
defmodule Jido.Shell.Guardrails.Rules.RequiredFiles do
@moduledoc false
@behaviour Jido.Shell.Guardrails.Rule

alias Jido.Shell.Guardrails.Violation

@required_content %{
"lib/jido/shell/shell_session.ex" => [
"defmodule Jido.Shell.ShellSession do"
],
"lib/jido/shell/shell_session_server.ex" => [
"defmodule Jido.Shell.ShellSessionServer do"
],
"lib/jido/shell/shell_session/state.ex" => [
"defmodule Jido.Shell.ShellSession.State do"
],
"lib/jido/shell/session.ex" => [
"defmodule Jido.Shell.Session do",
"@moduledoc deprecated:"
],
"lib/jido/shell/session_server.ex" => [
"defmodule Jido.Shell.SessionServer do",
"@moduledoc deprecated:"
],
"lib/jido/shell/session/state.ex" => [
"defmodule Jido.Shell.Session.State do",
"@moduledoc deprecated:"
]
}

@impl true
def check(%{root: root}) do
Enum.flat_map(@required_content, fn {relative_path, snippets} ->
full_path = Path.join(root, relative_path)

case File.read(full_path) do
{:ok, contents} ->
snippets
|> Enum.reject(&String.contains?(contents, &1))
|> Enum.map(fn snippet ->
%Violation{
rule: __MODULE__,
file: relative_path,
message: "missing expected content: #{snippet}"
}
end)

{:error, _reason} ->
[
%Violation{
rule: __MODULE__,
file: relative_path,
message: "missing required file"
}
]
end
end)
end
end
14 changes: 14 additions & 0 deletions lib/jido/shell/guardrails/violation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Jido.Shell.Guardrails.Violation do
@moduledoc """
Represents a single guardrail violation.
"""

@enforce_keys [:rule, :message]
defstruct [:rule, :message, :file]

@type t :: %__MODULE__{
rule: module(),
message: String.t(),
file: String.t() | nil
}
end
39 changes: 39 additions & 0 deletions lib/mix/tasks/jido_shell.guardrails.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule Mix.Tasks.JidoShell.Guardrails do
@moduledoc """
Verifies namespace and file layout guardrails for this repository.

## Usage

mix jido_shell.guardrails
mix jido_shell.guardrails --root /path/to/repo
"""

use Mix.Task

@shortdoc "Check namespace/layout guardrails"

@impl Mix.Task
def run(args) do
{opts, _argv, _invalid} =
OptionParser.parse(args,
strict: [root: :string]
)

root = Keyword.get(opts, :root, File.cwd!())

case Jido.Shell.Guardrails.check(root: root) do
:ok ->
Mix.shell().info("jido_shell guardrails: ok")
:ok

{:error, violations} ->
formatted = Jido.Shell.Guardrails.format_violations(violations)

Mix.raise("""
jido_shell guardrails failed:

#{formatted}
""")
end
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ defmodule Jido.Shell.MixProject do
test: "test --exclude flaky",
q: ["quality"],
quality: [
"jido_shell.guardrails",
"format --check-formatted",
"compile --warnings-as-errors",
"credo --min-priority higher",
Expand Down
Loading