diff --git a/lib/mix/tasks/usage_rules.sync.ex b/lib/mix/tasks/usage_rules.sync.ex index 3f726dd..790ad75 100644 --- a/lib/mix/tasks/usage_rules.sync.ex +++ b/lib/mix/tasks/usage_rules.sync.ex @@ -809,18 +809,14 @@ if Code.ensure_loaded?(Igniter) do # Resolve which packages to include in this skill (supports atoms and regexes) all_expanded = expand_dep_specs(usage_rule_specs, all_deps) + package_refs = build_package_refs(igniter, all_expanded) - resolved_packages = - Enum.filter(all_expanded, fn {_pkg_name, package_path, _mode} -> - package_has_usage_rules?(igniter, package_path) - end) - - if Enum.any?(resolved_packages) do + if Enum.any?(package_refs) do generate_built_skill( igniter, skill_name, skill_dir, - resolved_packages, + package_refs, all_expanded, custom_description ) @@ -829,16 +825,52 @@ if Code.ensure_loaded?(Igniter) do end end + # Per-package reference model, ordered to match usage_rules: config order. + # Packages that contribute neither a main rule nor sub-rules are omitted. + defp build_package_refs(igniter, all_expanded) do + all_expanded + |> Enum.map(fn {pkg_name, package_path, _mode} -> + %{ + package: pkg_name, + pkg_dir: to_string(pkg_name), + path: package_path, + main: Igniter.exists?(igniter, Path.join(package_path, "usage-rules.md")), + subs: find_available_sub_rules(igniter, package_path) + } + end) + |> Enum.filter(fn %{main: main, subs: subs} -> main or Enum.any?(subs) end) + end + + # All reference file paths this skill should contain, as {pkg_ref, ref_path}. + # Single source of truth for both the writer and the stale-cleanup scanner. + defp reference_paths(skill_dir, package_refs) do + Enum.flat_map(package_refs, fn %{package: pkg_name, pkg_dir: pkg_dir} = pkg_ref -> + main_paths = + if pkg_ref.main, + do: [ + {pkg_ref, :main, Path.join([skill_dir, "references", pkg_dir, "#{pkg_name}.md"])} + ], + else: [] + + sub_paths = + Enum.map(pkg_ref.subs, fn sub -> + {pkg_ref, {:sub, sub}, Path.join([skill_dir, "references", pkg_dir, "#{sub}.md"])} + end) + + main_paths ++ sub_paths + end) + end + defp generate_built_skill( igniter, skill_name, skill_dir, - resolved_packages, + package_refs, all_expanded, custom_description ) do skill_md = - build_skill_md(igniter, skill_name, resolved_packages, all_expanded, custom_description) + build_skill_md(skill_name, package_refs, all_expanded, custom_description) igniter = Igniter.create_or_update_file( @@ -852,45 +884,90 @@ if Code.ensure_loaded?(Igniter) do end ) - # Reference files for sub-rules and main rules from all packages - Enum.reduce(resolved_packages, igniter, fn {pkg_name, package_path, _mode}, acc -> - # Create reference file for main usage-rules.md - acc = - case read_dep_content(acc, Path.join(package_path, "usage-rules.md")) do - "" -> - acc - - content -> - ref_path = Path.join([skill_dir, "references", "#{pkg_name}.md"]) - - Igniter.create_or_update_file( - acc, - ref_path, - content, - fn source -> update_source_content(source, content) end - ) - end + ref_entries = reference_paths(skill_dir, package_refs) + igniter = remove_stale_references(igniter, skill_dir, ref_entries) - sub_rules = find_available_sub_rules(acc, package_path) + # Write reference files under references// to avoid cross-package collisions. + Enum.reduce(ref_entries, igniter, fn + {pkg_ref, :main, ref_path}, acc -> + content = read_dep_content(acc, Path.join(pkg_ref.path, "usage-rules.md")) - Enum.reduce(sub_rules, acc, fn sub_rule, inner_acc -> - sub_path = Path.join([package_path, "usage-rules", "#{sub_rule}.md"]) - content = read_dep_content(inner_acc, sub_path) - ref_path = Path.join([skill_dir, "references", "#{sub_rule}.md"]) + Igniter.create_or_update_file( + acc, + ref_path, + content, + fn source -> update_source_content(source, content) end + ) + + {pkg_ref, {:sub, sub_rule}, ref_path}, acc -> + content = + read_dep_content(acc, Path.join([pkg_ref.path, "usage-rules", "#{sub_rule}.md"])) Igniter.create_or_update_file( - inner_acc, + acc, ref_path, content, fn source -> update_source_content(source, content) end ) - end) end) end - defp build_skill_md(igniter, skill_name, resolved_packages, all_expanded, custom_description) do + # Remove reference files that aren't part of the new per-package layout. + # Covers flat-layout files from older syncs and orphaned per-package dirs. + defp remove_stale_references(igniter, skill_dir, ref_entries) do + refs_dir = Path.join(skill_dir, "references") + expected = MapSet.new(ref_entries, fn {_pkg_ref, _tag, path} -> path end) + + # Igniter may have files created in this sync that don't exist on disk yet, + # so we union rewrite sources with a disk walk to catch both. + source_paths = + igniter.rewrite.sources + |> Enum.map(&elem(&1, 0)) + |> Enum.filter(fn path -> + String.starts_with?(path, refs_dir <> "/") && String.ends_with?(path, ".md") + end) + + # Files present on disk but not yet loaded — walk one level of + # subdirectories so both flat and nested layouts are caught. + disk_paths = + case File.ls(refs_dir) do + {:ok, entries} -> + Enum.flat_map(entries, fn entry -> + full = Path.join(refs_dir, entry) + + cond do + File.regular?(full) and String.ends_with?(entry, ".md") -> + [full] + + File.dir?(full) -> + case File.ls(full) do + {:ok, sub_entries} -> + sub_entries + |> Enum.filter(&String.ends_with?(&1, ".md")) + |> Enum.map(&Path.join(full, &1)) + + {:error, _} -> + [] + end + + true -> + [] + end + end) + + {:error, _} -> + [] + end + + (source_paths ++ disk_paths) + |> Enum.uniq() + |> Enum.reject(&MapSet.member?(expected, &1)) + |> Enum.reduce(igniter, fn path, acc -> Igniter.rm(acc, path) end) + end + + defp build_skill_md(skill_name, package_refs, all_expanded, custom_description) do description = - (custom_description || build_skill_description(skill_name, resolved_packages)) + (custom_description || build_skill_description(skill_name, package_refs)) |> truncate_description() formatted_description = format_yaml_string(description) @@ -906,7 +983,7 @@ if Code.ensure_loaded?(Igniter) do """ |> String.trim_trailing() - body = build_skill_body(igniter, skill_name, resolved_packages, all_expanded) + body = build_skill_body(skill_name, package_refs, all_expanded) frontmatter <> "\n\n" <> @@ -915,8 +992,8 @@ if Code.ensure_loaded?(Igniter) do "\n" end - defp build_skill_description(skill_name, resolved_packages) do - package_names = Enum.map(resolved_packages, &elem(&1, 0)) + defp build_skill_description(skill_name, package_refs) do + package_names = Enum.map(package_refs, & &1.package) descriptions = package_names @@ -931,38 +1008,30 @@ if Code.ensure_loaded?(Igniter) do end end - defp build_skill_body(igniter, _skill_name, resolved_packages, all_expanded) do + defp build_skill_body(_skill_name, package_refs, all_expanded) do sections = [] - # Sub-rules as references (only from packages with usage rules) - all_sub_rules = - Enum.flat_map(resolved_packages, fn {_pkg_name, package_path, _mode} -> - find_available_sub_rules(igniter, package_path) - end) + # Group main + sub-rule references per package so users (and LLMs) can + # tell which library each reference came from, and so that same-named + # sub-rules from different packages don't collide on disk. + package_blocks = + Enum.map(package_refs, fn %{package: pkg_name, pkg_dir: pkg_dir} = pkg_ref -> + main_line = + if pkg_ref.main, + do: ["- [#{pkg_name}](references/#{pkg_dir}/#{pkg_name}.md)"], + else: [] + + sub_lines = + Enum.map(pkg_ref.subs, fn sub -> + "- [#{sub}](references/#{pkg_dir}/#{sub}.md)" + end) - # Only include main rule links for packages that have a main usage-rules.md - # (a package may pass the filter via sub-rules alone, with no main file) - all_main_rules = - resolved_packages - |> Enum.filter(fn {_pkg_name, package_path, _mode} -> - read_dep_content(igniter, Path.join(package_path, "usage-rules.md")) != "" + Enum.join(["### #{pkg_name}", "" | main_line ++ sub_lines], "\n") end) - |> Enum.map(fn {pkg_name, _path, _mode} -> pkg_name end) - - all_references = - Enum.map(all_sub_rules, fn sub_rule -> - "- [#{sub_rule}](references/#{sub_rule}.md)" - end) ++ - Enum.map(all_main_rules, fn pkg_name -> - "- [#{pkg_name}](references/#{pkg_name}.md)" - end) - - all_references = Enum.uniq(all_references) sections = - if Enum.any?(all_references) do - ref_lines = Enum.join(all_references, "\n") - sections ++ ["## Additional References\n\n#{ref_lines}"] + if Enum.any?(package_blocks) do + sections ++ ["## Additional References\n\n" <> Enum.join(package_blocks, "\n\n")] else sections end diff --git a/test/mix/tasks/usage_rules.sync_test.exs b/test/mix/tasks/usage_rules.sync_test.exs index 2e9a68f..241aba5 100644 --- a/test/mix/tasks/usage_rules.sync_test.exs +++ b/test/mix/tasks/usage_rules.sync_test.exs @@ -705,16 +705,17 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") assert content =~ "---" assert content =~ "name: use-foo" assert content =~ "managed-by: usage-rules" - assert content =~ "[foo](references/foo.md)" + assert content =~ "### foo" + assert content =~ "[foo](references/foo/foo.md)" assert content =~ "mix usage_rules.search_docs" - ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo.md") + ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") assert ref_content =~ "Foo Usage" end @@ -735,14 +736,18 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/foo-and-bar/SKILL.md") - |> assert_creates(".claude/skills/foo-and-bar/references/foo.md") - |> assert_creates(".claude/skills/foo-and-bar/references/bar.md") + |> assert_creates(".claude/skills/foo-and-bar/references/foo/foo.md") + |> assert_creates(".claude/skills/foo-and-bar/references/bar/bar.md") content = file_content(igniter, ".claude/skills/foo-and-bar/SKILL.md") - assert content =~ "[foo](references/foo.md)" - assert content =~ "[bar](references/bar.md)" + assert content =~ "### foo" + assert content =~ "### bar" + assert content =~ "[foo](references/foo/foo.md)" + assert content =~ "[bar](references/bar/bar.md)" assert content =~ "-p foo" assert content =~ "-p bar" + + assert :binary.match(content, "### foo") < :binary.match(content, "### bar") end test "builds skill with custom location" do @@ -775,18 +780,19 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") - |> assert_creates(".claude/skills/use-foo/references/testing.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/testing.md") skill_content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") assert skill_content =~ "Additional References" - assert skill_content =~ "[foo](references/foo.md)" - assert skill_content =~ "[testing](references/testing.md)" + assert skill_content =~ "### foo" + assert skill_content =~ "[foo](references/foo/foo.md)" + assert skill_content =~ "[testing](references/foo/testing.md)" - ref_content = file_content(igniter, ".claude/skills/use-foo/references/testing.md") + ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/testing.md") assert ref_content =~ "Testing Guide" - main_ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo.md") + main_ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") assert main_ref_content =~ "Foo Usage" end @@ -871,11 +877,11 @@ defmodule Mix.Tasks.UsageRules.SyncTest do content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") assert content =~ "My custom instructions" - assert content =~ "[foo](references/foo.md)" + assert content =~ "[foo](references/foo/foo.md)" assert content =~ "" refute content =~ "Old body" - ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo.md") + ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") assert ref_content =~ "Updated content." end @@ -934,14 +940,14 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/use-ash/SKILL.md") - |> assert_creates(".claude/skills/use-ash/references/ash.md") - |> assert_creates(".claude/skills/use-ash/references/ash_postgres.md") - |> assert_creates(".claude/skills/use-ash/references/ash_json_api.md") + |> assert_creates(".claude/skills/use-ash/references/ash/ash.md") + |> assert_creates(".claude/skills/use-ash/references/ash_postgres/ash_postgres.md") + |> assert_creates(".claude/skills/use-ash/references/ash_json_api/ash_json_api.md") content = file_content(igniter, ".claude/skills/use-ash/SKILL.md") - assert content =~ "[ash](references/ash.md)" - assert content =~ "[ash_postgres](references/ash_postgres.md)" - assert content =~ "[ash_json_api](references/ash_json_api.md)" + assert content =~ "[ash](references/ash/ash.md)" + assert content =~ "[ash_postgres](references/ash_postgres/ash_postgres.md)" + assert content =~ "[ash_json_api](references/ash_json_api/ash_json_api.md)" refute content =~ "Req" end @@ -962,21 +968,22 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/phoenix-framework/SKILL.md") - |> assert_creates(".claude/skills/phoenix-framework/references/ecto.md") - |> assert_creates(".claude/skills/phoenix-framework/references/liveview.md") + |> assert_creates(".claude/skills/phoenix-framework/references/phoenix/ecto.md") + |> assert_creates(".claude/skills/phoenix-framework/references/phoenix/liveview.md") content = file_content(igniter, ".claude/skills/phoenix-framework/SKILL.md") - # Sub-rule references are included - assert content =~ "[ecto](references/ecto.md)" - assert content =~ "[liveview](references/liveview.md)" + # Sub-rule references are included under the phoenix package heading + assert content =~ "### phoenix" + assert content =~ "[ecto](references/phoenix/ecto.md)" + assert content =~ "[liveview](references/phoenix/liveview.md)" - # Deps without usage rules should NOT have reference links - refute content =~ "references/phoenix_ecto.md" - refute content =~ "references/phoenix_html.md" + # Deps without usage rules should NOT have reference links or package dirs + refute content =~ "references/phoenix_ecto/" + refute content =~ "references/phoenix_html/" # Package with only sub-rules (no main usage-rules.md) should NOT have a main reference link - refute content =~ "[phoenix](references/phoenix.md)" + refute content =~ "[phoenix](references/phoenix/phoenix.md)" # But search docs should include ALL matched deps (they have hexdocs regardless) assert content =~ "-p phoenix_ecto" @@ -1003,20 +1010,23 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/ash-framework/SKILL.md") - |> assert_creates(".claude/skills/ash-framework/references/ash.md") - |> assert_creates(".claude/skills/ash-framework/references/migrations.md") + |> assert_creates(".claude/skills/ash-framework/references/ash/ash.md") + |> assert_creates(".claude/skills/ash-framework/references/ash_postgres/migrations.md") content = file_content(igniter, ".claude/skills/ash-framework/SKILL.md") - # ash has main usage-rules.md → gets a reference link - assert content =~ "[ash](references/ash.md)" + # ash has main usage-rules.md → gets its own H3 + main reference link + assert content =~ "### ash" + assert content =~ "[ash](references/ash/ash.md)" - # ash_postgres has sub-rules → sub-rule link present, but no main link - assert content =~ "[migrations](references/migrations.md)" - refute content =~ "[ash_postgres](references/ash_postgres.md)" + # ash_postgres has sub-rules → H3 present with sub-rule link, but no main link + assert content =~ "### ash_postgres" + assert content =~ "[migrations](references/ash_postgres/migrations.md)" + refute content =~ "[ash_postgres](references/ash_postgres/ash_postgres.md)" - # ash_oban has no usage rules → no reference link at all - refute content =~ "references/ash_oban.md" + # ash_oban has no usage rules → no H3, no reference dir + refute content =~ "### ash_oban" + refute content =~ "references/ash_oban/" # Search docs include all matched deps assert content =~ "-p ash" @@ -1116,16 +1126,203 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/my-skill/SKILL.md") - |> assert_creates(".claude/skills/my-skill/references/phoenix.md") - |> assert_creates(".claude/skills/my-skill/references/liveview.md") + |> assert_creates(".claude/skills/my-skill/references/phoenix/phoenix.md") + |> assert_creates(".claude/skills/my-skill/references/phoenix/liveview.md") skill_content = file_content(igniter, ".claude/skills/my-skill/SKILL.md") assert skill_content =~ "Additional References" - assert skill_content =~ "[liveview](references/liveview.md)" + assert skill_content =~ "### phoenix" + assert skill_content =~ "[liveview](references/phoenix/liveview.md)" + + # phoenix sub-rule appears exactly once (package has no main usage-rules.md, + # so there's no package-level [phoenix] link to collide with the sub-rule) + assert [_] = + Regex.scan( + ~r"\[phoenix\]\(references/phoenix/phoenix\.md\)", + skill_content + ) + end + + test "cross-package sub-rule name collision keeps both references" do + igniter = + project_with_deps(%{ + "deps/ash/usage-rules.md" => "# Ash Core", + "deps/ash/usage-rules/multitenancy.md" => "# Ash Multitenancy\n\nAsh's take.", + "deps/ash_oban/usage-rules.md" => "# Ash Oban", + "deps/ash_oban/usage-rules/multitenancy.md" => "# Oban Multitenancy\n\nOban's take." + }) + |> sync( + skills: [ + location: ".claude/skills", + build: [ + "ash-framework": [usage_rules: [:ash, :ash_oban]] + ] + ] + ) + |> assert_creates(".claude/skills/ash-framework/SKILL.md") + |> assert_creates(".claude/skills/ash-framework/references/ash/multitenancy.md") + |> assert_creates(".claude/skills/ash-framework/references/ash_oban/multitenancy.md") + + # Both files exist with distinct content — the silent overwrite is gone + ash_multi = + file_content(igniter, ".claude/skills/ash-framework/references/ash/multitenancy.md") + + oban_multi = + file_content(igniter, ".claude/skills/ash-framework/references/ash_oban/multitenancy.md") + + assert ash_multi =~ "Ash's take." + assert oban_multi =~ "Oban's take." + refute ash_multi == oban_multi + + content = file_content(igniter, ".claude/skills/ash-framework/SKILL.md") + assert content =~ "### ash" + assert content =~ "### ash_oban" + assert content =~ "[multitenancy](references/ash/multitenancy.md)" + assert content =~ "[multitenancy](references/ash_oban/multitenancy.md)" + end + + test "package with only sub-rules still gets an H3 heading" do + igniter = + project_with_deps(%{ + "deps/foo/usage-rules/testing.md" => "# Testing" + }) + |> sync( + skills: [ + location: ".claude/skills", + build: [ + "use-foo": [usage_rules: [:foo]] + ] + ] + ) + |> assert_creates(".claude/skills/use-foo/SKILL.md") + |> assert_creates(".claude/skills/use-foo/references/foo/testing.md") + + content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") + assert content =~ "### foo" + assert content =~ "[testing](references/foo/testing.md)" + # No main rule link because deps/foo/usage-rules.md does not exist + refute content =~ "[foo](references/foo/foo.md)" + end + + test "H3 headings appear in the config order of usage_rules" do + igniter = + project_with_deps(%{ + "deps/zebra/usage-rules.md" => "# Zebra", + "deps/alpha/usage-rules.md" => "# Alpha", + "deps/middle/usage-rules.md" => "# Middle" + }) + |> sync( + skills: [ + location: ".claude/skills", + build: [ + ordered: [usage_rules: [:zebra, :alpha, :middle]] + ] + ] + ) + |> assert_creates(".claude/skills/ordered/SKILL.md") + + content = file_content(igniter, ".claude/skills/ordered/SKILL.md") - # phoenix sub-rule + phoenix package should produce only one reference - assert [_] = Regex.scan(~r"\[phoenix\]\(references/phoenix\.md\)", skill_content) + # Headings appear in exactly the order declared in the config. + {zebra_idx, _} = :binary.match(content, "### zebra") + {alpha_idx, _} = :binary.match(content, "### alpha") + {middle_idx, _} = :binary.match(content, "### middle") + + assert zebra_idx < alpha_idx + assert alpha_idx < middle_idx + end + + test "stale flat-layout reference files are cleaned up on re-sync" do + # Pre-seed an old-layout reference file as if an earlier version of + # usage_rules had written it. The sync should remove it and write the + # new per-package layout instead. + stale_skill_md = + "---\nname: use-foo\ndescription: \"Foo skill\"\nmetadata:\n managed-by: usage-rules\n---\n\n\n## Additional References\n\n- [foo](references/foo.md)\n" + + igniter = + project_with_deps(%{ + "deps/foo/usage-rules.md" => "# Foo Rules", + ".claude/skills/use-foo/SKILL.md" => stale_skill_md, + ".claude/skills/use-foo/references/foo.md" => "# Old flat-layout content" + }) + |> sync( + skills: [ + location: ".claude/skills", + build: [ + "use-foo": [usage_rules: [:foo]] + ] + ] + ) + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") + + # Old flat-layout file removed + refute Map.has_key?( + igniter.rewrite.sources, + ".claude/skills/use-foo/references/foo.md" + ) + + # New nested content written with fresh package content + ref = + file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") + + assert ref =~ "Foo Rules" + + # SKILL.md points at the new location + content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") + assert content =~ "[foo](references/foo/foo.md)" + refute content =~ "[foo](references/foo.md)" + end + + test "per-package reference directory is removed when package leaves the skill" do + # First sync: build a skill containing both foo and bar + config_both = [ + skills: [ + location: ".claude/skills", + build: [ + combo: [usage_rules: [:foo, :bar]] + ] + ] + ] + + config_foo_only = [ + skills: [ + location: ".claude/skills", + build: [ + combo: [usage_rules: [:foo]] + ] + ] + ] + + igniter = + project_with_deps(%{ + "deps/foo/usage-rules.md" => "# Foo", + "deps/bar/usage-rules.md" => "# Bar" + }) + |> sync(config_both) + |> assert_creates(".claude/skills/combo/references/foo/foo.md") + |> assert_creates(".claude/skills/combo/references/bar/bar.md") + |> apply_igniter!() + + # Second sync drops bar from the build + igniter = + igniter + |> sync(config_foo_only) + + # bar's reference file is gone; foo's remains + refute Map.has_key?( + igniter.rewrite.sources, + ".claude/skills/combo/references/bar/bar.md" + ) + + assert Map.has_key?( + igniter.rewrite.sources, + ".claude/skills/combo/references/foo/foo.md" + ) + + content = file_content(igniter, ".claude/skills/combo/SKILL.md") + refute content =~ "### bar" + refute content =~ "references/bar/" end end @@ -1137,14 +1334,14 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(skills: [location: ".claude/skills", deps: [:foo]]) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") assert content =~ "name: use-foo" assert content =~ "managed-by: usage-rules" - assert content =~ "[foo](references/foo.md)" + assert content =~ "[foo](references/foo/foo.md)" - ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo.md") + ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") assert ref_content =~ "Foo Usage" end @@ -1177,8 +1374,8 @@ defmodule Mix.Tasks.UsageRules.SyncTest do |> assert_creates(".claude/skills/foo-and-bar/SKILL.md") combo_content = file_content(igniter, ".claude/skills/foo-and-bar/SKILL.md") - assert combo_content =~ "[foo](references/foo.md)" - assert combo_content =~ "[bar](references/bar.md)" + assert combo_content =~ "[foo](references/foo/foo.md)" + assert combo_content =~ "[bar](references/bar/bar.md)" end test "supports regex to match multiple deps" do @@ -1247,7 +1444,7 @@ defmodule Mix.Tasks.UsageRules.SyncTest do assert agents_content =~ "bar" skill_content = file_content(igniter, ".claude/skills/use-bar/SKILL.md") - assert skill_content =~ "[bar](references/bar.md)" + assert skill_content =~ "[bar](references/bar/bar.md)" end end @@ -1278,21 +1475,21 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/my-skill/SKILL.md") - |> assert_creates(".claude/skills/my-skill/references/foo.md") - |> assert_creates(".claude/skills/my-skill/references/bar.md") + |> assert_creates(".claude/skills/my-skill/references/foo/foo.md") + |> assert_creates(".claude/skills/my-skill/references/bar/bar.md") end) assert output =~ "deprecated in usage_rules skill config" skill_content = file_content(igniter, ".claude/skills/my-skill/SKILL.md") - # Both foo and bar should be reference links - assert skill_content =~ "[foo](references/foo.md)" - assert skill_content =~ "[bar](references/bar.md)" + # Both foo and bar should be reference links under per-package dirs + assert skill_content =~ "[foo](references/foo/foo.md)" + assert skill_content =~ "[bar](references/bar/bar.md)" - foo_ref = file_content(igniter, ".claude/skills/my-skill/references/foo.md") + foo_ref = file_content(igniter, ".claude/skills/my-skill/references/foo/foo.md") assert foo_ref =~ "Foo Rules" - bar_ref = file_content(igniter, ".claude/skills/my-skill/references/bar.md") + bar_ref = file_content(igniter, ".claude/skills/my-skill/references/bar/bar.md") assert bar_ref =~ "Bar Rules" end @@ -1313,17 +1510,17 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/ash-expert/SKILL.md") - |> assert_creates(".claude/skills/ash-expert/references/ash.md") - |> assert_creates(".claude/skills/ash-expert/references/ash_postgres.md") - |> assert_creates(".claude/skills/ash-expert/references/ash_json_api.md") + |> assert_creates(".claude/skills/ash-expert/references/ash/ash.md") + |> assert_creates(".claude/skills/ash-expert/references/ash_postgres/ash_postgres.md") + |> assert_creates(".claude/skills/ash-expert/references/ash_json_api/ash_json_api.md") end) assert output =~ "deprecated in usage_rules skill config" skill_content = file_content(igniter, ".claude/skills/ash-expert/SKILL.md") - assert skill_content =~ "[ash](references/ash.md)" - assert skill_content =~ "[ash_postgres](references/ash_postgres.md)" - assert skill_content =~ "[ash_json_api](references/ash_json_api.md)" + assert skill_content =~ "[ash](references/ash/ash.md)" + assert skill_content =~ "[ash_postgres](references/ash_postgres/ash_postgres.md)" + assert skill_content =~ "[ash_json_api](references/ash_json_api/ash_json_api.md)" end test "deps config with {:dep, :reference} still works" do @@ -1334,15 +1531,15 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(skills: [location: ".claude/skills", deps: [{:foo, :reference}]]) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") end) assert output =~ "deprecated in usage_rules skill config" skill_content = file_content(igniter, ".claude/skills/use-foo/SKILL.md") - assert skill_content =~ "[foo](references/foo.md)" + assert skill_content =~ "[foo](references/foo/foo.md)" - ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo.md") + ref_content = file_content(igniter, ".claude/skills/use-foo/references/foo/foo.md") assert ref_content =~ "Foo Rules" end @@ -1355,9 +1552,13 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(skills: [location: ".claude/skills", deps: [{~r/^ash_/, :reference}]]) |> assert_creates(".claude/skills/use-ash-postgres/SKILL.md") - |> assert_creates(".claude/skills/use-ash-postgres/references/ash_postgres.md") + |> assert_creates( + ".claude/skills/use-ash-postgres/references/ash_postgres/ash_postgres.md" + ) |> assert_creates(".claude/skills/use-ash-json-api/SKILL.md") - |> assert_creates(".claude/skills/use-ash-json-api/references/ash_json_api.md") + |> assert_creates( + ".claude/skills/use-ash-json-api/references/ash_json_api/ash_json_api.md" + ) end) assert output =~ "deprecated in usage_rules skill config" @@ -1400,15 +1601,17 @@ defmodule Mix.Tasks.UsageRules.SyncTest do ] ) |> assert_creates(".claude/skills/my-skill/SKILL.md") - |> assert_creates(".claude/skills/my-skill/references/foo.md") - |> assert_creates(".claude/skills/my-skill/references/testing.md") - |> assert_creates(".claude/skills/my-skill/references/bar.md") + |> assert_creates(".claude/skills/my-skill/references/foo/foo.md") + |> assert_creates(".claude/skills/my-skill/references/foo/testing.md") + |> assert_creates(".claude/skills/my-skill/references/bar/bar.md") skill_content = file_content(igniter, ".claude/skills/my-skill/SKILL.md") assert skill_content =~ "Additional References" - assert skill_content =~ "[foo](references/foo.md)" - assert skill_content =~ "[testing](references/testing.md)" - assert skill_content =~ "[bar](references/bar.md)" + assert skill_content =~ "### foo" + assert skill_content =~ "### bar" + assert skill_content =~ "[foo](references/foo/foo.md)" + assert skill_content =~ "[testing](references/foo/testing.md)" + assert skill_content =~ "[bar](references/bar/bar.md)" end end @@ -1625,8 +1828,8 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(config) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") - |> assert_creates(".claude/skills/use-foo/references/bar.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") + |> assert_creates(".claude/skills/use-foo/references/bar/bar.md") |> apply_igniter!() |> simulate_disk_roundtrip() @@ -1694,8 +1897,8 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(config) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") - |> assert_creates(".claude/skills/use-foo/references/testing.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/testing.md") |> apply_igniter!() |> simulate_disk_roundtrip() @@ -1724,7 +1927,7 @@ defmodule Mix.Tasks.UsageRules.SyncTest do }) |> sync(config) |> assert_creates(".claude/skills/use-foo/SKILL.md") - |> assert_creates(".claude/skills/use-foo/references/foo.md") + |> assert_creates(".claude/skills/use-foo/references/foo/foo.md") |> apply_igniter!() # Inject custom content between frontmatter and managed section