From 51c035ba7fced3fc68cdfc34d280f8c316c1e031 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:44:07 -0600 Subject: [PATCH 1/2] feat(guardrails): formalize extension path for convention updates --- CONTRIBUTING.md | 2 + GUARDRAILS.md | 49 +++++++++++++++ lib/jido/shell/guardrails.ex | 34 ++++++++++- mix.exs | 3 +- test/jido/shell/guardrails_extension_test.exs | 60 +++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 GUARDRAILS.md create mode 100644 test/jido/shell/guardrails_extension_test.exs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b00bbd1..779dc40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/GUARDRAILS.md b/GUARDRAILS.md new file mode 100644 index 0000000..cb98df6 --- /dev/null +++ b/GUARDRAILS.md @@ -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: +4. Add it to `Jido.Shell.Guardrails.default_rules/0`, or +5. Configure 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 +``` diff --git a/lib/jido/shell/guardrails.ex b/lib/jido/shell/guardrails.ex index 63350a1..a6e7244 100644 --- a/lib/jido/shell/guardrails.ex +++ b/lib/jido/shell/guardrails.ex @@ -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 @@ -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} -> @@ -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 diff --git a/mix.exs b/mix.exs index 3020023..baaaefa 100644 --- a/mix.exs +++ b/mix.exs @@ -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: %{ @@ -129,6 +129,7 @@ defmodule Jido.Shell.MixProject do "MIGRATION.md", "CHANGELOG.md", "CONTRIBUTING.md", + "GUARDRAILS.md", "LICENSE" ], groups_for_modules: [ diff --git a/test/jido/shell/guardrails_extension_test.exs b/test/jido/shell/guardrails_extension_test.exs new file mode 100644 index 0000000..80a3901 --- /dev/null +++ b/test/jido/shell/guardrails_extension_test.exs @@ -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 From d3c40fce70b4f76493796300b1ca8e45e6cdb924 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:46:00 -0600 Subject: [PATCH 2/2] docs(guardrails): clarify extension registration steps --- GUARDRAILS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GUARDRAILS.md b/GUARDRAILS.md index cb98df6..85e1917 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -25,9 +25,9 @@ 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: -4. Add it to `Jido.Shell.Guardrails.default_rules/0`, or -5. Configure it with `config :jido_shell, :guardrail_rules, [MyRule]`. +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: