From 55b8e66fea50dc5000d74f35dcbf3e36ea842306 Mon Sep 17 00:00:00 2001 From: EASmith <131812879+ESmithByui@users.noreply.github.com> Date: Thu, 7 May 2026 11:59:38 -0600 Subject: [PATCH 1/3] Added except functionality, added tests for functionality, tried to change CRLF to LF, and added in mix usage_rules.list as well as tests for it. --- lib/mix/tasks/usage_rules.list.ex | 156 ++++++++++++++++++ lib/mix/tasks/usage_rules.sync.ex | 73 +++++++- test/mix/tasks/usage_rules.list_test.exs | 201 +++++++++++++++++++++++ test/mix/tasks/usage_rules.sync_test.exs | 88 ++++++++++ 4 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 lib/mix/tasks/usage_rules.list.ex create mode 100644 test/mix/tasks/usage_rules.list_test.exs diff --git a/lib/mix/tasks/usage_rules.list.ex b/lib/mix/tasks/usage_rules.list.ex new file mode 100644 index 0000000..fef391c --- /dev/null +++ b/lib/mix/tasks/usage_rules.list.ex @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: 2025 usage_rules contributors +# +# SPDX-License-Identifier: MIT + +defmodule Mix.Tasks.UsageRules.List do + use Mix.Task + + @shortdoc "Lists usage-rules.md and sub-rules (usage-rules/*.md) for dependencies" + + @moduledoc """ + Prints each dependency that ships usage rules, and which sub-rule files exist. + + Sub-rules are the basenames of `deps//usage-rules/*.md` (excluding `skills/` trees), + consistent with `mix usage_rules.sync`. + + ## Examples + + $ mix usage_rules.list + + $ mix usage_rules.list ash + $ mix usage_rules.list :phoenix + """ + + @impl Mix.Task + def run(argv) do + {_opts, argv} = OptionParser.parse!(argv, strict: []) + + filter = package_filter(argv) + pairs = discover_dep_pairs() + + pairs = + case filter do + nil -> + pairs + + f -> + found = Enum.filter(pairs, fn {dep, _} -> dep_matches?(dep, f) end) + + if found == [] do + Mix.raise("No dependency matching #{inspect(f)} in this project") + else + found + end + end + + infos = + pairs + |> Enum.map(fn {dep, path} -> {dep, path, package_usage_rules_info(path)} end) + |> Enum.filter(fn {_dep, _path, info} -> info.main? or info.subs != [] end) + + cond do + infos == [] && filter -> + Mix.shell().info("Dependency \"#{filter}\" has no usage-rules.md or usage-rules/*.md.") + + infos == [] -> + Mix.shell().info( + "No dependencies include usage rules (no usage-rules.md or usage-rules/*.md under deps/)." + ) + + true -> + Mix.shell().info(format_report(infos)) + end + + Mix.shell().info(builtin_note()) + end + + defp package_filter([]), do: nil + + defp package_filter([one]) do + one + |> String.trim() + |> String.trim_leading(":") + end + + defp package_filter(_) do + Mix.raise("mix usage_rules.list accepts at most one package name") + end + + defp dep_matches?(dep, filter) do + String.downcase(to_string(dep)) == String.downcase(filter) + end + + defp discover_dep_pairs do + top_level_deps = + Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0)) + + umbrella_deps = + (Mix.Project.apps_paths() || []) + |> Enum.flat_map(fn {app, path} -> + Mix.Project.in_project(app, path, fn _ -> + Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0)) + end) + end) + + all_dep_names = Enum.uniq(top_level_deps ++ umbrella_deps) + + # Prefer the converger over `Mix.Project.deps_paths/0`: the deps cache can be + # empty when another Mix project is still on the stack (e.g. an umbrella + # app or tests using `Mix.Project.in_project/4`), which would hide path deps. + Mix.Dep.Converger.converge(env: Mix.env(), target: Mix.target()) + |> Enum.filter(fn %{app: app} -> app in all_dep_names end) + |> Enum.map(fn %{app: app, opts: opts} -> + dest = Keyword.fetch!(opts, :dest) + {app, Path.relative_to_cwd(dest)} + end) + |> Enum.sort_by(&elem(&1, 0)) + end + + defp package_usage_rules_info(package_path) when is_binary(package_path) do + main? = File.regular?(Path.join(package_path, "usage-rules.md")) + subs = sub_rule_basenames(package_path) + %{main?: main?, subs: subs} + end + + defp sub_rule_basenames(package_path) do + dir = Path.join(package_path, "usage-rules") + + case File.ls(dir) do + {:ok, entries} -> + entries + |> Enum.reject(&(&1 == "skills")) + |> Enum.filter(&String.ends_with?(&1, ".md")) + |> Enum.map(&Path.rootname/1) + |> Enum.sort() + + {:error, _} -> + [] + end + end + + defp format_report(infos) do + header = "Dependencies with usage rules:\n" + + body = + Enum.map_join(infos, "\n", fn {dep, path, %{main?: main?, subs: subs}} -> + [ + " #{inspect(dep)} (#{path})", + " usage-rules.md: " <> if(main?, do: "yes", else: "no"), + " sub-rules: " <> subs_line(subs) + ] + |> Enum.join("\n") + end) + + header <> body + end + + defp subs_line([]), do: "(none)" + defp subs_line(subs), do: Enum.join(subs, ", ") + + defp builtin_note do + """ + Built-in config atoms :elixir and :otp pull :usage_rules sub-rules \"elixir\" and \"otp\" (see mix usage_rules.sync docs). + """ + |> String.trim_trailing() + end +end diff --git a/lib/mix/tasks/usage_rules.sync.ex b/lib/mix/tasks/usage_rules.sync.ex index 790ad75..057b0ca 100644 --- a/lib/mix/tasks/usage_rules.sync.ex +++ b/lib/mix/tasks/usage_rules.sync.ex @@ -185,26 +185,48 @@ if Code.ensure_loaded?(Igniter) do #{__MODULE__.Docs.code_sample(4)} """} - (link_error = validate_link_options(config[:usage_rules])) != nil -> - {:error, link_error} + (spec_error = validate_usage_rules_specs(config[:usage_rules])) != nil -> + {:error, spec_error} true -> :ok end end + defp validate_usage_rules_specs(usage_rules) do + validate_link_options(usage_rules) + end + defp validate_link_options(nil), do: nil defp validate_link_options(:all), do: nil - defp validate_link_options({:all, opts}) when is_list(opts), do: validate_link_option(opts) + defp validate_link_options({:all, opts}) when is_list(opts) do + validate_spec_options(opts) + end defp validate_link_options(specs) when is_list(specs) do - Enum.find_value(specs, fn - {_inner, opts} when is_list(opts) -> validate_link_option(opts) - _ -> nil + Enum.find_value(specs, fn spec -> + case spec do + {_inner, opts} when is_list(opts) -> + validate_spec_options(opts) + + _ -> + case extract_regex_spec(spec) do + {_regex, opts} when is_list(opts) -> validate_spec_options(opts) + _ -> nil + end + end end) end + defp validate_spec_options(opts) when is_list(opts) do + cond do + (err = validate_link_option(opts)) != nil -> err + (err = validate_except_option(opts)) != nil -> err + true -> nil + end + end + defp validate_link_option(opts) do case opts[:link] do nil -> nil @@ -213,6 +235,23 @@ if Code.ensure_loaded?(Igniter) do end end + defp validate_except_option(opts) do + case Keyword.get(opts, :except) do + nil -> + nil + + list when is_list(list) -> + if Enum.all?(list, &(is_binary(&1) or is_atom(&1))) do + nil + else + "usage_rules :except must be a list of string or atom sub-rule names, got: #{inspect(list)}" + end + + other -> + "usage_rules :except must be a list or omitted, got: #{inspect(other)}" + end + end + defp include_dep_sources(igniter) do igniter |> Igniter.include_glob("deps/*/usage-rules.md") @@ -388,6 +427,7 @@ if Code.ensure_loaded?(Igniter) do sub_rules = find_available_sub_rules(igniter, package_path) + |> filter_sub_rules_by_except(opts) |> Enum.map(fn sub_rule_name -> {package_name, package_path, sub_rule_name, opts} end) @@ -445,10 +485,11 @@ if Code.ensure_loaded?(Igniter) do case sub_rules_opt do :all -> find_available_sub_rules(igniter, package_path) + |> filter_sub_rules_by_except(opts) |> Enum.map(fn sr -> {package_name, package_path, sr, opts} end) list when is_list(list) -> - Enum.flat_map(list, fn sr -> + Enum.flat_map(filter_sub_rules_by_except(list, opts), fn sr -> sub_path = Path.join([package_path, "usage-rules", "#{sr}.md"]) if Igniter.exists?(igniter, sub_path) do @@ -489,6 +530,7 @@ if Code.ensure_loaded?(Igniter) do :all -> subs = find_available_sub_rules(igniter, package_path) + |> filter_sub_rules_by_except(opts) |> Enum.map(fn sr -> {package_name, package_path, sr, opts} end) found = main ++ subs @@ -505,7 +547,9 @@ if Code.ensure_loaded?(Igniter) do list when is_list(list) -> {subs, sub_errors} = - Enum.reduce(list, {[], []}, fn sr, {found_acc, err_acc} -> + Enum.reduce(filter_sub_rules_by_except(list, opts), {[], []}, fn sr, + {found_acc, + err_acc} -> sub_path = Path.join([package_path, "usage-rules", "#{sr}.md"]) if Igniter.exists?(igniter, sub_path) do @@ -566,6 +610,19 @@ if Code.ensure_loaded?(Igniter) do # Sub-rule discovery # ------------------------------------------------------------------- + defp except_sub_rule_set(opts) do + case Keyword.get(opts, :except) do + nil -> MapSet.new() + list when is_list(list) -> MapSet.new(Enum.map(list, &to_string/1)) + _ -> MapSet.new() + end + end + + defp filter_sub_rules_by_except(names, opts) when is_list(names) do + ex = except_sub_rule_set(opts) + Enum.reject(names, &MapSet.member?(ex, to_string(&1))) + end + defp find_available_sub_rules(igniter, package_path) do usage_rules_dir = Path.join(package_path, "usage-rules") diff --git a/test/mix/tasks/usage_rules.list_test.exs b/test/mix/tasks/usage_rules.list_test.exs new file mode 100644 index 0000000..2f2f90a --- /dev/null +++ b/test/mix/tasks/usage_rules.list_test.exs @@ -0,0 +1,201 @@ +# SPDX-FileCopyrightText: 2025 usage_rules contributors +# +# SPDX-License-Identifier: MIT + +defmodule Mix.Tasks.UsageRules.ListTest do + use ExUnit.Case + + import ExUnit.CaptureIO + + alias Mix.Tasks.UsageRules.List + + @builtin_note_prefix "Built-in config atoms :elixir and :otp pull :usage_rules sub-rules" + + describe "mix usage_rules.list (fixture project)" do + setup do + dir = + Path.join(System.tmp_dir!(), "usage_rules_list_#{:erlang.unique_integer([:positive])}") + + File.rm_rf(dir) + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + {:ok, tmp_dir: dir} + end + + test "lists main file and sub-rules (discovers distinct sub-rule names)", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules, plain: :main_only) + + output = + Mix.Project.in_project(app, dir, fn _ -> + capture_io(fn -> List.run([]) end) + end) + + assert output =~ "Dependencies with usage rules:" + assert output =~ ":phx_style" + assert output =~ "usage-rules.md: yes" + assert output =~ "sub-rules: channels, routing" + assert output =~ ":plain" + assert output =~ "sub-rules: (none)" + assert output =~ @builtin_note_prefix + end + + test "filters by dependency name (colon, case)", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules) + + for argv <- [["phx_style"], ["Phx_Style"], [":phx_style"]] do + output = + Mix.Project.in_project(app, dir, fn _ -> + capture_io(fn -> List.run(argv) end) + end) + + assert output =~ ":phx_style" + assert output =~ "sub-rules: channels, routing" + refute output =~ ":plain" + end + end + + test "reports when filtered dependency has no usage rules", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules, bare: :empty) + + output = + Mix.Project.in_project(app, dir, fn _ -> + capture_io(fn -> List.run(["bare"]) end) + end) + + assert output =~ "Dependency \"bare\" has no usage-rules.md or usage-rules/*.md." + assert output =~ @builtin_note_prefix + end + + test "lists dependency with only sub-rules (no usage-rules.md)", %{tmp_dir: dir} do + app = fixture_app!(dir, only_subs: :subs_only) + + output = + Mix.Project.in_project(app, dir, fn _ -> + capture_io(fn -> List.run([]) end) + end) + + assert output =~ ":only_subs" + assert output =~ "usage-rules.md: no" + assert output =~ "sub-rules: live_view" + end + end + + describe "mix usage_rules.list (errors)" do + setup do + dir = + Path.join( + System.tmp_dir!(), + "usage_rules_list_err_#{:erlang.unique_integer([:positive])}" + ) + + File.rm_rf(dir) + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + {:ok, tmp_dir: dir} + end + + test "raises when package is not a dependency", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules) + + assert_raise Mix.Error, ~r/No dependency matching/, fn -> + Mix.Project.in_project(app, dir, fn _ -> + List.run(["not_a_real_dependency_xyz"]) + end) + end + end + + test "accepts at most one package name", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules) + + assert_raise Mix.Error, ~r/at most one package name/, fn -> + Mix.Project.in_project(app, dir, fn _ -> + List.run(["phx_style", "bare"]) + end) + end + end + end + + defp fixture_app!(dir, fixtures) do + app = :"lr_lst_#{System.unique_integer([:positive])}" + mod = Module.concat([Macro.camelize(Atom.to_string(app))]) + + deps = + fixtures + |> Enum.map(fn {dep, kind} -> + write_dep!(dir, dep, kind) + {dep, "deps/#{dep}"} + end) + + write_root_mix!(dir, mod, app, deps) + app + end + + defp write_root_mix!(dir, mod, app, deps) do + dep_lines = + Enum.map_join(deps, ",\n", fn {name, path} -> + " {#{inspect(name)}, path: #{inspect(path)}}" + end) + + mix = """ + defmodule #{inspect(mod)}.MixProject do + use Mix.Project + + def project do + [app: #{inspect(app)}, version: "0.1.0", elixir: "~> 1.17", deps: deps()] + end + + def application, do: [extra_applications: [:logger]] + + defp deps do + [ + #{dep_lines} + ] + end + end + """ + + File.write!(Path.join(dir, "mix.exs"), mix) + end + + defp write_dep!(dir, dep, kind) do + base = Path.join([dir, "deps", to_string(dep)]) + File.mkdir_p!(base) + + File.write!(Path.join(base, "mix.exs"), dep_mix(dep)) + + usage_dir = Path.join(base, "usage-rules") + + case kind do + :sub_rules -> + File.write!(Path.join(base, "usage-rules.md"), "# #{dep} main\n") + File.mkdir_p!(usage_dir) + File.mkdir_p!(Path.join(usage_dir, "skills")) + File.write!(Path.join(usage_dir, "routing.md"), "# routes\n") + File.write!(Path.join(usage_dir, "channels.md"), "# channels\n") + File.write!(Path.join([usage_dir, "skills", "ignored.md"]), "# not top-level\n") + + :main_only -> + File.write!(Path.join(base, "usage-rules.md"), "# plain\n") + + :empty -> + :ok + + :subs_only -> + File.mkdir_p!(usage_dir) + File.write!(Path.join(usage_dir, "live_view.md"), "# lv\n") + end + end + + defp dep_mix(dep) do + mod = Module.concat([Macro.camelize(Atom.to_string(dep))]) + + """ + defmodule #{inspect(mod)}.MixProject do + use Mix.Project + def project do + [app: #{inspect(dep)}, version: "0.1.0", deps: []] + end + end + """ + end +end diff --git a/test/mix/tasks/usage_rules.sync_test.exs b/test/mix/tasks/usage_rules.sync_test.exs index 241aba5..781a759 100644 --- a/test/mix/tasks/usage_rules.sync_test.exs +++ b/test/mix/tasks/usage_rules.sync_test.exs @@ -66,6 +66,22 @@ defmodule Mix.Tasks.UsageRules.SyncTest do String.contains?(issue, "link must be :at or :markdown") end) end + + test "errors on invalid except option" do + project_with_deps() + |> sync(file: "AGENTS.md", usage_rules: [{:foo, except: "not_a_list"}]) + |> assert_has_issue(fn issue -> + String.contains?(issue, ":except must be a list or omitted") + end) + end + + test "errors on invalid except list elements" do + project_with_deps() + |> sync(file: "AGENTS.md", usage_rules: [{:foo, except: [1, 2]}]) + |> assert_has_issue(fn issue -> + String.contains?(issue, ":except must be a list of string or atom") + end) + end end describe "inline sync" do @@ -364,6 +380,62 @@ defmodule Mix.Tasks.UsageRules.SyncTest do refute content =~ "[foo usage rules]" assert content =~ "[foo:ecto usage rules](deps/foo/usage-rules/ecto.md)" end + + test "except option drops named sub-rules but keeps main and other subs" do + igniter = + project_with_deps(%{ + "deps/foo/usage-rules.md" => "# Foo Main", + "deps/foo/usage-rules/ecto.md" => "# Foo Ecto", + "deps/foo/usage-rules/testing.md" => "# Foo Testing" + }) + |> sync( + file: "AGENTS.md", + usage_rules: [{:foo, except: ["testing"]}] + ) + |> assert_creates("AGENTS.md") + + content = file_content(igniter, "AGENTS.md") + assert content =~ "Foo Main" + assert content =~ "Foo Ecto" + refute content =~ "Foo Testing" + end + + test "except applies to explicit sub_rules list" do + igniter = + project_with_deps(%{ + "deps/foo/usage-rules.md" => "# Foo Main", + "deps/foo/usage-rules/ecto.md" => "# Foo Ecto", + "deps/foo/usage-rules/testing.md" => "# Foo Testing" + }) + |> sync( + file: "AGENTS.md", + usage_rules: [{:foo, sub_rules: ["ecto", "testing"], except: ["ecto"]}] + ) + |> assert_creates("AGENTS.md") + + content = file_content(igniter, "AGENTS.md") + assert content =~ "Foo Main" + refute content =~ "Foo Ecto" + assert content =~ "Foo Testing" + end + + test "{:all, except: ...} applies to every package" do + igniter = + project_with_deps(%{ + "deps/foo/usage-rules.md" => "# Foo Main", + "deps/foo/usage-rules/ecto.md" => "# Foo Ecto", + "deps/bar/usage-rules.md" => "# Bar Main", + "deps/bar/usage-rules/ecto.md" => "# Bar Ecto" + }) + |> sync(file: "AGENTS.md", usage_rules: {:all, except: ["ecto"]}) + |> assert_creates("AGENTS.md") + + content = file_content(igniter, "AGENTS.md") + assert content =~ "Foo Main" + assert content =~ "Bar Main" + refute content =~ "Foo Ecto" + refute content =~ "Bar Ecto" + end end describe "regex in usage_rules" do @@ -479,6 +551,22 @@ defmodule Mix.Tasks.UsageRules.SyncTest do refute content =~ "Ash Testing" end + test "regex with except option excludes sub-rules" do + igniter = + project_with_deps(%{ + "deps/ash/usage-rules.md" => "# Ash Core", + "deps/ash/usage-rules/ecto.md" => "# Ash Ecto", + "deps/ash/usage-rules/testing.md" => "# Ash Testing" + }) + |> sync(file: "AGENTS.md", usage_rules: [{~r/^ash/, except: ["testing"]}]) + |> assert_creates("AGENTS.md") + + content = file_content(igniter, "AGENTS.md") + assert content =~ "Ash Core" + assert content =~ "Ash Ecto" + refute content =~ "Ash Testing" + end + test "regex produces sections in stable sorted order" do igniter = project_with_deps(%{ From 683d9b05a857f5b01a4bd3f1df3117cae4d104f2 Mon Sep 17 00:00:00 2001 From: EASmith <131812879+ESmithByui@users.noreply.github.com> Date: Fri, 8 May 2026 09:37:13 -0600 Subject: [PATCH 2/3] Added details about mix.raise cases and removed stripping leading colons. --- lib/mix/tasks/usage_rules.list.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/usage_rules.list.ex b/lib/mix/tasks/usage_rules.list.ex index fef391c..2efdf63 100644 --- a/lib/mix/tasks/usage_rules.list.ex +++ b/lib/mix/tasks/usage_rules.list.ex @@ -13,12 +13,15 @@ defmodule Mix.Tasks.UsageRules.List do Sub-rules are the basenames of `deps//usage-rules/*.md` (excluding `skills/` trees), consistent with `mix usage_rules.sync`. + Two errors can be raised normally. First is if the dependency is not in the project, + then the "No dependency matching..." error will occur. + The second error is if you have more than one argument. Only one dependency may be filtered at one time. + ## Examples $ mix usage_rules.list $ mix usage_rules.list ash - $ mix usage_rules.list :phoenix """ @impl Mix.Task @@ -69,7 +72,6 @@ defmodule Mix.Tasks.UsageRules.List do defp package_filter([one]) do one |> String.trim() - |> String.trim_leading(":") end defp package_filter(_) do From a34f47df45dd11cbd5e9c1004b33397176445f58 Mon Sep 17 00:00:00 2001 From: EASmith <131812879+ESmithByui@users.noreply.github.com> Date: Fri, 8 May 2026 09:41:08 -0600 Subject: [PATCH 3/3] Upded usage_rules.list_test to remove atom filtering. --- test/mix/tasks/usage_rules.list_test.exs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/mix/tasks/usage_rules.list_test.exs b/test/mix/tasks/usage_rules.list_test.exs index 2f2f90a..75ad288 100644 --- a/test/mix/tasks/usage_rules.list_test.exs +++ b/test/mix/tasks/usage_rules.list_test.exs @@ -39,10 +39,10 @@ defmodule Mix.Tasks.UsageRules.ListTest do assert output =~ @builtin_note_prefix end - test "filters by dependency name (colon, case)", %{tmp_dir: dir} do + test "filters by dependency name (case-insensitive string)", %{tmp_dir: dir} do app = fixture_app!(dir, phx_style: :sub_rules) - for argv <- [["phx_style"], ["Phx_Style"], [":phx_style"]] do + for argv <- [["phx_style"], ["Phx_Style"]] do output = Mix.Project.in_project(app, dir, fn _ -> capture_io(fn -> List.run(argv) end) @@ -104,6 +104,16 @@ defmodule Mix.Tasks.UsageRules.ListTest do end end + test "raises when filter uses atom-style colon prefix (not accepted)", %{tmp_dir: dir} do + app = fixture_app!(dir, phx_style: :sub_rules) + + assert_raise Mix.Error, ~r/No dependency matching/, fn -> + Mix.Project.in_project(app, dir, fn _ -> + List.run([":phx_style"]) + end) + end + end + test "accepts at most one package name", %{tmp_dir: dir} do app = fixture_app!(dir, phx_style: :sub_rules)