Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ 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`, so namespace/layout regressions fail early in scripted workflows.
See [GUARDRAILS.md](GUARDRAILS.md) for extension guidance.

## Common Commands

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

Examples:

Expand Down
48 changes: 48 additions & 0 deletions GUARDRAILS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Guardrails Guide

`jido_shell` guardrails protect namespace and file-layout conventions so regressions fail early in development and CI.

Run guardrails directly:

```bash
mix jido_shell.guardrails
```

`mix quality` runs this task automatically.

## Current Conventions

Guardrails currently enforce:

- no collapsed namespace modules (for example `JidoShell.Foo`)
- no legacy `lib/jido/shell` layout paths

## Extending Guardrails

When conventions evolve, update guardrails and tests in the same PR.

1. Add a rule module implementing `Jido.Shell.Guardrails.Rule`.
2. Return `:ok` or a list of `Jido.Shell.Guardrails.violation()` tuples from `check/1`.
3. Register the rule by either:
4. Adding it to `Jido.Shell.Guardrails.default_rules/0`.
5. Configuring it via `config :jido_shell, :guardrail_rules, [MyRule]`.
6. Add tests showing both pass and fail behavior for the new convention.

Example:

```elixir
defmodule Jido.Shell.Guardrails.Rules.MyConvention do
@behaviour Jido.Shell.Guardrails.Rule

@impl true
def check(project_root) do
path = Path.join(project_root, "some/required/path")

if File.exists?(path) do
:ok
else
[{:legacy_layout_path, %{path: "some/required/path"}}]
end
end
end
```
101 changes: 60 additions & 41 deletions lib/jido_shell/guardrails.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
defmodule Jido.Shell.Guardrails do
@moduledoc false
@moduledoc """
Guardrails that enforce `jido_shell` namespace and layout conventions.

@collapsed_namespace_regex ~r/^\s*defmodule\s+(Jido[A-Z][\w\.]*)/m
Extension rules can be configured with:

config :jido_shell, :guardrail_rules, [
MyApp.CustomGuardrailRule
]
"""

@type violation ::
{:collapsed_namespace_module, %{path: String.t(), line: pos_integer(), module: String.t()}}
| {:legacy_layout_path, %{path: String.t()}}

@spec check(String.t()) :: :ok | {:error, [violation()]}
def check(project_root \\ File.cwd!()) when is_binary(project_root) do
violations = collapsed_namespace_violations(project_root) ++ legacy_layout_violations(project_root)
@default_rules [
Jido.Shell.Guardrails.Rules.CollapsedNamespace,
Jido.Shell.Guardrails.Rules.LegacyLayout
]

@type option :: {:rules, [module()]}
@type options :: [option()]

@spec check(String.t(), options()) :: :ok | {:error, [violation()]}
def check(project_root \\ File.cwd!(), opts \\ []) when is_binary(project_root) and is_list(opts) do
violations =
opts
|> rules()
|> Enum.flat_map(&run_rule(&1, project_root))

if violations == [], do: :ok, else: {:error, violations}
end

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

@spec configured_rules() :: [module()]
def configured_rules do
@default_rules
|> Kernel.++(configured_extension_rules())
|> Enum.uniq()
end

@spec format_violations([violation()]) :: String.t()
def format_violations(violations) when is_list(violations) do
header =
Expand All @@ -29,48 +56,40 @@ defmodule Jido.Shell.Guardrails do
|> Enum.join("\n")
end

defp collapsed_namespace_violations(project_root) do
lib_paths = Path.wildcard(Path.join([project_root, "lib", "**", "*.ex"]))

Enum.flat_map(lib_paths, fn path ->
path
|> File.read!()
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.flat_map(fn {line, index} ->
case Regex.run(@collapsed_namespace_regex, line) do
[_, module] ->
[
{:collapsed_namespace_module,
%{
path: relative_path(project_root, path),
line: index,
module: module
}}
]

_ ->
[]
end
end)
end)
defp rules(opts) do
opts
|> Keyword.get(:rules, configured_rules())
|> normalize_rules!()
end

defp legacy_layout_violations(project_root) do
patterns = [
Path.join([project_root, "lib", "jido", "shell.ex"]),
Path.join([project_root, "lib", "jido", "shell", "**", "*.ex"])
]
defp configured_extension_rules do
:jido_shell
|> Application.get_env(:guardrail_rules, [])
|> List.wrap()
|> normalize_rules!()
end

patterns
|> Enum.flat_map(&Path.wildcard/1)
|> Enum.map(fn path ->
{:legacy_layout_path, %{path: relative_path(project_root, path)}}
defp normalize_rules!(rules) when is_list(rules) do
Enum.map(rules, fn rule ->
if is_atom(rule) do
rule
else
raise ArgumentError, "guardrail rule #{inspect(rule)} must be a module atom"
end
end)
end

defp relative_path(project_root, path) do
Path.relative_to(path, project_root)
defp run_rule(rule, project_root) 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(project_root) do
:ok -> []
violations when is_list(violations) -> violations
end
end

defp format_violation({:collapsed_namespace_module, %{path: path, line: line, module: module}}) do
Expand Down
9 changes: 9 additions & 0 deletions lib/jido_shell/guardrails/rule.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Jido.Shell.Guardrails.Rule do
@moduledoc """
Behaviour for guardrail rules.
"""

alias Jido.Shell.Guardrails

@callback check(project_root :: String.t()) :: :ok | [Guardrails.violation()]
end
34 changes: 34 additions & 0 deletions lib/jido_shell/guardrails/rules/collapsed_namespace.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule Jido.Shell.Guardrails.Rules.CollapsedNamespace do
@moduledoc false
@behaviour Jido.Shell.Guardrails.Rule

@collapsed_namespace_regex ~r/^\s*defmodule\s+(Jido[A-Z][\w\.]*)/m

@impl true
def check(project_root) do
lib_paths = Path.wildcard(Path.join([project_root, "lib", "**", "*.ex"]))

Enum.flat_map(lib_paths, fn path ->
path
|> File.read!()
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.flat_map(fn {line, index} ->
case Regex.run(@collapsed_namespace_regex, line) do
[_, module] ->
[
{:collapsed_namespace_module,
%{
path: Path.relative_to(path, project_root),
line: index,
module: module
}}
]

_ ->
[]
end
end)
end)
end
end
18 changes: 18 additions & 0 deletions lib/jido_shell/guardrails/rules/legacy_layout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule Jido.Shell.Guardrails.Rules.LegacyLayout do
@moduledoc false
@behaviour Jido.Shell.Guardrails.Rule

@impl true
def check(project_root) do
patterns = [
Path.join([project_root, "lib", "jido", "shell.ex"]),
Path.join([project_root, "lib", "jido", "shell", "**", "*.ex"])
]

patterns
|> Enum.flat_map(&Path.wildcard/1)
|> Enum.map(fn path ->
{:legacy_layout_path, %{path: Path.relative_to(path, project_root)}}
end)
end
end
12 changes: 10 additions & 2 deletions lib/mix/tasks/jido_shell.guardrails.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ defmodule Mix.Tasks.JidoShell.Guardrails do
@shortdoc "Validate Jido.Shell namespace/layout guardrails"

@impl Mix.Task
def run(_args) do
case Jido.Shell.Guardrails.check(File.cwd!()) do
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) do
:ok ->
Mix.shell().info("jido_shell guardrails: ok")
:ok

{:error, violations} ->
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ defmodule Jido.Shell.MixProject do
defp package do
[
files:
~w(lib mix.exs LICENSE README.md MIGRATION.md CHANGELOG.md CONTRIBUTING.md AGENTS.md usage-rules.md .formatter.exs),
~w(lib mix.exs LICENSE README.md MIGRATION.md CHANGELOG.md CONTRIBUTING.md GUARDRAILS.md AGENTS.md usage-rules.md .formatter.exs),
maintainers: ["Mike Hostetler"],
licenses: ["Apache-2.0"],
links: %{
Expand All @@ -130,6 +130,7 @@ defmodule Jido.Shell.MixProject do
"MIGRATION.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"GUARDRAILS.md",
"LICENSE"
],
groups_for_modules: [
Expand Down
Loading
Loading