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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ mix coveralls

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

See [GUARDRAILS.md](GUARDRAILS.md) for convention details and extension rules.

## Common Commands

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

`jido_shell` guardrails protect namespace and file-layout conventions so regressions are blocked before review.

Run guardrails directly:

```bash
mix jido_shell.guardrails
```

`mix quality` runs this task automatically.

## Current Conventions

Guardrails currently enforce:

- no legacy `kodo` namespace paths in `lib/` or `test/`
- canonical session module file layout
- deprecated `Session*` compatibility shims remain present and marked deprecated
- module prefixes align with their path roots (`Jido.Shell*`, `Mix.Tasks.JidoShell*`)

## Extending Guardrails

When namespace conventions evolve, update guardrails in the same PR.

1. Add a rule module that implements `Jido.Shell.Guardrails.Rule`.
2. Return `:ok` or a list of `%Jido.Shell.Guardrails.Violation{}` from `check/1`.
3. Register the rule by either:
4. Adding it to `Jido.Shell.Guardrails.default_rules/0`.
5. Configuring it with `config :jido_shell, :guardrail_rules, [MyRule]`.
6. Add tests that demonstrate both pass and fail behavior for the new convention.

Example rule skeleton:

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

@impl true
def check(%{root: root}) do
if File.exists?(Path.join(root, "some/required/path")) do
:ok
else
[%Violation{rule: __MODULE__, file: "some/required/path", message: "missing required path"}]
end
end
end
```
34 changes: 33 additions & 1 deletion lib/jido/shell/guardrails.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
defmodule Jido.Shell.Guardrails do
@moduledoc """
Runs namespace and layout guardrails for the `jido_shell` codebase.

Extension rules can be configured with:

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

alias Jido.Shell.Guardrails.Violation
Expand Down Expand Up @@ -37,6 +43,13 @@ defmodule Jido.Shell.Guardrails do
@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.t()]) :: String.t()
def format_violations(violations) do
Enum.map_join(violations, "\n", fn %Violation{rule: rule, file: file, message: message} ->
Expand All @@ -52,7 +65,26 @@ defmodule Jido.Shell.Guardrails do
end

defp rules(opts) do
Keyword.get(opts, :rules, @default_rules)
opts
|> Keyword.get(:rules, configured_rules())
|> normalize_rules!()
end

defp configured_extension_rules do
:jido_shell
|> Application.get_env(:guardrail_rules, [])
|> List.wrap()
|> normalize_rules!()
end

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 run_rule(rule, context) when is_atom(rule) do
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,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 @@ -129,6 +129,7 @@ defmodule Jido.Shell.MixProject do
"MIGRATION.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"GUARDRAILS.md",
"LICENSE"
],
groups_for_modules: [
Expand Down
60 changes: 60 additions & 0 deletions test/jido/shell/guardrails_extension_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Jido.Shell.GuardrailsExtensionTest do
use ExUnit.Case, async: false

alias Jido.Shell.Guardrails
alias Jido.Shell.Guardrails.Rules.ForbiddenPaths
alias Jido.Shell.Guardrails.Violation

defmodule CustomConventionRule do
@behaviour Jido.Shell.Guardrails.Rule

@impl true
def check(_context) do
[
%Violation{
rule: __MODULE__,
message: "custom convention violation"
}
]
end
end

setup do
previous_rules = Application.get_env(:jido_shell, :guardrail_rules)

on_exit(fn ->
if is_nil(previous_rules) do
Application.delete_env(:jido_shell, :guardrail_rules)
else
Application.put_env(:jido_shell, :guardrail_rules, previous_rules)
end
end)

:ok
end

test "configured extension rules are appended to default rules" do
Application.put_env(:jido_shell, :guardrail_rules, [CustomConventionRule])

assert {:error, violations} = Guardrails.check(root: File.cwd!())

assert Enum.any?(violations, fn
%Violation{rule: CustomConventionRule, message: "custom convention violation"} -> true
_ -> false
end)
end

test "explicit rules option bypasses configured extension rules for targeted checks" do
Application.put_env(:jido_shell, :guardrail_rules, [CustomConventionRule])

assert :ok = Guardrails.check(root: File.cwd!(), rules: [ForbiddenPaths])
end

test "invalid configured extension rules raise a clear error" do
Application.put_env(:jido_shell, :guardrail_rules, ["not-a-module"])

assert_raise ArgumentError, ~r/must be a module atom/, fn ->
Guardrails.check(root: File.cwd!())
end
end
end
Loading