From 5e518222704dd172ebc26753df039be8fce9f290 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Mon, 13 Jul 2026 14:02:07 +1000 Subject: [PATCH 1/2] feat(spec-loop): add spec-scope deterministic mapper for the update beat Add a spec-scope entry point to tools/spec-inventory that maps a list of changed file paths to the spec files most likely relevant to those changes. The update beat now pipes the output of git diff --name-only through spec-scope so the agent receives a pre-computed list of specs to inspect instead of deriving the mapping itself -- closing the known gap in specs/spec-loop-runner.md. Pattern extraction reads each spec Where it lives section: backtick-quoted tokens containing a slash become path-prefix patterns, and Skill: name bullets produce skills/ patterns. .claude/skills/magpie- symlink entries are expanded to skills/ aliases so skill-name patterns resolve correctly. Ships 18 new unit tests covering extract_path_patterns, _changed_path_candidates, scope_map, and the scope_main CLI. loop.sh is updated to call spec-scope inside update_scope_context and include the result in the context block for the update beat prompt. Failures from the mapper are non-fatal (advisory only). Generated-by: Claude (claude-sonnet-4-6) --- tools/spec-inventory/README.md | 40 +++- tools/spec-inventory/pyproject.toml | 1 + .../src/spec_inventory/__init__.py | 119 +++++++++++ tools/spec-inventory/tests/test_scope_map.py | 189 ++++++++++++++++++ tools/spec-loop/loop.sh | 18 ++ 5 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 tools/spec-inventory/tests/test_scope_map.py diff --git a/tools/spec-inventory/README.md b/tools/spec-inventory/README.md index 33ebc5872..553de36e6 100644 --- a/tools/spec-inventory/README.md +++ b/tools/spec-inventory/README.md @@ -1,8 +1,13 @@ + + **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - [spec-inventory](#spec-inventory) + - [`spec-inventory`](#spec-inventory-1) + - [`spec-scope`](#spec-scope) - [Prerequisites](#prerequisites) - [Usage](#usage) - [Run tests](#run-tests) @@ -18,15 +23,32 @@ **Harness:** agnostic -A deterministic `uv` tool that emits a compact routing inventory for the -spec-loop prompts. It summarizes spec frontmatter, where-it-lives hints, -validation commands, known gaps, skill frontmatter, and tool/test -presence so agents can choose what to read next without first scanning -the whole repository. +Two deterministic `uv` tools that help the spec-loop navigate the +repository without scanning it from scratch each iteration. + +## `spec-inventory` + +Emits a compact routing inventory for the spec-loop prompts. It +summarizes spec frontmatter, where-it-lives hints, validation commands, +known gaps, skill frontmatter, and tool/test presence so agents can +choose what to read next without first scanning the whole repository. The output is a routing aid, not proof. Prompts still require direct file reads or code search before declaring behaviour present or absent. +## `spec-scope` + +Maps a list of changed file paths to the spec files most likely relevant +to those changes. Used by the spec-loop `update` beat to focus the agent +on the specs whose subjects appear in the diff, rather than requiring +the agent to derive the mapping itself. + +Path patterns are extracted from each spec's `## Where it lives` section: +backtick-quoted path tokens (containing `/`) are treated as prefix +patterns, and `Skill: \`\`` bullets produce a `skills/` +pattern. `.claude/skills/magpie-` symlink entries are expanded to +`skills/` aliases so that skill-name patterns resolve correctly. + ## Prerequisites - **Runtime:** Python 3.11+ run via `uv`; stdlib-only (no runtime @@ -39,9 +61,17 @@ reads or code search before declaring behaviour present or absent. ## Usage ```bash +# Routing inventory uv run --project tools/spec-inventory spec-inventory uv run --project tools/spec-inventory spec-inventory --brief --max-where 1 --max-validation 1 --max-gaps 1 uv run --project tools/spec-inventory spec-inventory --json + +# Scope mapper — pipe changed paths from git diff, get relevant specs back +git diff --name-only HEAD~1..HEAD -- .claude/skills tools docs/modes.md | \ + uv run --project tools/spec-inventory spec-scope + +# Or pass paths as positional arguments +uv run --project tools/spec-inventory spec-scope tools/gmail/tool.md .claude/skills/magpie-pr-management-triage ``` ## Run tests diff --git a/tools/spec-inventory/pyproject.toml b/tools/spec-inventory/pyproject.toml index 76d25e7ae..a5d665604 100644 --- a/tools/spec-inventory/pyproject.toml +++ b/tools/spec-inventory/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [] [project.scripts] spec-inventory = "spec_inventory:main" +spec-scope = "spec_inventory:scope_main" [tool.hatch.build.targets.wheel] packages = ["src/spec_inventory"] diff --git a/tools/spec-inventory/src/spec_inventory/__init__.py b/tools/spec-inventory/src/spec_inventory/__init__.py index 03851a24e..71ce18778 100644 --- a/tools/spec-inventory/src/spec_inventory/__init__.py +++ b/tools/spec-inventory/src/spec_inventory/__init__.py @@ -45,6 +45,10 @@ _SECTION_RE = re.compile(r"^\[([^\]]+)\]\s*$") _YAML_BLOCK_SCALAR_HEADERS = frozenset({"|", ">", "|-", "|+", ">-", ">+"}) +# Scope-map patterns — used by the spec-scope entry point. +_BACKTICK_RE = re.compile(r"`([^`]+)`") +_SKILL_KEYWORD_RE = re.compile(r"\bSkill:\s*`([^`]+)`") + @dataclass class SpecSummary: @@ -370,6 +374,121 @@ def format_json(inventory: Inventory) -> str: return json.dumps(asdict(inventory), indent=2) +def extract_path_patterns(where_bullets: list[str]) -> frozenset[str]: + """Extract normalised path prefixes from 'Where it lives' bullets. + + Returns a frozenset of path strings (no leading/trailing slashes) that + can be used as prefix tests against changed-file paths. Patterns come + from two sources in each bullet: + + - Explicit skill names — ``Skill: ```` bullets produce the pattern + ``skills/`` so that changes to ``skills//SKILL.md`` resolve + to the right spec. + - Backtick-quoted path tokens — any backtick-quoted token that contains + a slash is treated as a path prefix directly. + """ + patterns: set[str] = set() + for bullet in where_bullets: + for m in _SKILL_KEYWORD_RE.finditer(bullet): + name = m.group(1).strip().rstrip("/") + if "/" not in name: + patterns.add(f"skills/{name}") + for m in _BACKTICK_RE.finditer(bullet): + token = m.group(1).strip().rstrip("/") + if "/" in token: + patterns.add(token.strip("/")) + return frozenset(patterns) + + +def _changed_path_candidates(changed: str) -> list[str]: + """Expand a changed path into one or more patterns to test against spec patterns. + + For ``.claude/skills/magpie-`` symlink entries the runner records, + also synthesise a ``skills/`` alias so that skill-name patterns + extracted from 'Skill: ``' bullets resolve correctly. + """ + normalised = changed.lstrip("/") + candidates = [normalised] + parts = normalised.split("/") + if len(parts) >= 3 and parts[0] == ".claude" and parts[1] == "skills" and parts[2].startswith("magpie-"): + skill_name = parts[2][len("magpie-") :] + candidates.append("/".join(["skills", skill_name, *parts[3:]])) + return candidates + + +def scope_map(changed_paths: list[str], repo_root: Path) -> list[str]: + """Return spec file paths likely relevant to the given changed file paths. + + For each spec, path patterns are extracted from its 'Where it lives' + bullets. A spec is included if any of its patterns is a prefix of any + candidate path derived from the changed list. The result is sorted and + deduplicated. + + Intended use: the spec-loop update beat pipes the output of + ``git diff --name-only`` through ``spec-scope`` to focus the agent on + the specs most likely to need re-inspection, rather than asking the + agent to do that mapping from scratch. + """ + specs = load_specs(repo_root, max_where=50, max_validation=0, max_gaps=0) + spec_patterns: list[tuple[str, frozenset[str]]] = [ + (spec.file, extract_path_patterns(spec.where)) for spec in specs + ] + relevant: set[str] = set() + for changed in changed_paths: + for candidate in _changed_path_candidates(changed): + for spec_file, patterns in spec_patterns: + if spec_file in relevant: + continue + for pattern in patterns: + if candidate == pattern or candidate.startswith(pattern + "/"): + relevant.add(spec_file) + break + return sorted(relevant) + + +def scope_main(argv: list[str] | None = None) -> None: + """CLI entry point: map changed paths to relevant spec files. + + Reads file paths from positional arguments or stdin (one per line) and + writes the relevant spec file paths to stdout, one per line. Empty + input produces no output. Exit code is always 0 so a pipeline that + finds no matches does not fail a shell script. + """ + parser = argparse.ArgumentParser( + description=( + "Map changed file paths to likely relevant spec files for the " + "spec-loop update beat. Reads paths from positional arguments " + "or stdin (one per line) and writes matching spec file paths to " + "stdout." + ) + ) + parser.add_argument("paths", nargs="*", help="Changed file paths.") + parser.add_argument( + "--repo-root", + type=Path, + default=None, + help="Repository root (default: nearest .git parent).", + ) + args = parser.parse_args(argv) + + if args.paths: + changed = [p for p in args.paths if p] + else: + changed = [line.rstrip("\n") for line in sys.stdin if line.strip()] + + if not changed: + return + + try: + repo_root = args.repo_root.resolve() if args.repo_root else find_repo_root(Path.cwd()) + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + for spec in scope_map(changed, repo_root): + print(spec) + + def main() -> None: parser = argparse.ArgumentParser(description="Generate compact routing inventory for spec-loop prompts.") parser.add_argument( diff --git a/tools/spec-inventory/tests/test_scope_map.py b/tools/spec-inventory/tests/test_scope_map.py new file mode 100644 index 000000000..f8dc85530 --- /dev/null +++ b/tools/spec-inventory/tests/test_scope_map.py @@ -0,0 +1,189 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the spec-scope deterministic mapper.""" + +from __future__ import annotations + +from pathlib import Path + +from spec_inventory import ( + _changed_path_candidates, + extract_path_patterns, + scope_main, + scope_map, +) + + +def _spec_text(where_bullets: str) -> str: + return ( + "\n---\ntitle: Test Spec\nstatus: experimental\nkind: feature\n" + "mode: infra\nsource: tests\n---\n\n## Where it lives\n\n" + where_bullets + "\n" + ) + + +def test_extract_path_patterns_tool_path() -> None: + bullets = ["`tools/gmail/` — private-mail read/draft."] + assert "tools/gmail" in extract_path_patterns(bullets) + + +def test_extract_path_patterns_skill_name() -> None: + bullets = ["Skill: `pr-management-triage` — first-pass sweep of the PR queue."] + patterns = extract_path_patterns(bullets) + assert "skills/pr-management-triage" in patterns + + +def test_extract_path_patterns_doc_path() -> None: + bullets = ["`docs/education/` — landing page for the education stream."] + assert "docs/education" in extract_path_patterns(bullets) + + +def test_extract_path_patterns_ignores_no_slash_tokens() -> None: + # A backtick-quoted token with no slash is not treated as a path. + bullets = ["Some bullet with `SKILL.md` and `README.md` tokens."] + patterns = extract_path_patterns(bullets) + assert "SKILL.md" not in patterns + assert "README.md" not in patterns + + +def test_extract_path_patterns_strips_trailing_slash() -> None: + bullets = ["`tools/example/` — example tool directory."] + patterns = extract_path_patterns(bullets) + # trailing slash must be stripped so prefix matching works + assert "tools/example" in patterns + assert "tools/example/" not in patterns + + +def test_extract_path_patterns_empty() -> None: + assert extract_path_patterns([]) == frozenset() + + +def test_extract_path_patterns_multiple_bullets() -> None: + bullets = [ + "`tools/gmail/` — private-mail read.", + "Skill: `pr-management-triage` — sweep.", + "`docs/modes.md` — modes documentation.", + ] + patterns = extract_path_patterns(bullets) + assert "tools/gmail" in patterns + assert "skills/pr-management-triage" in patterns + assert "docs/modes.md" in patterns + + +def test_changed_path_candidates_normal() -> None: + assert _changed_path_candidates("tools/gmail/tool.md") == ["tools/gmail/tool.md"] + + +def test_changed_path_candidates_leading_slash_stripped() -> None: + candidates = _changed_path_candidates("/tools/gmail/tool.md") + assert "tools/gmail/tool.md" in candidates + + +def test_changed_path_candidates_skill_symlink() -> None: + candidates = _changed_path_candidates(".claude/skills/magpie-pr-management-triage") + assert ".claude/skills/magpie-pr-management-triage" in candidates + assert "skills/pr-management-triage" in candidates + + +def test_changed_path_candidates_skill_symlink_non_magpie_prefix_unchanged() -> None: + # A .claude/skills/ entry without the magpie- prefix is not synthesised. + candidates = _changed_path_candidates(".claude/skills/some-other-skill") + assert len(candidates) == 1 + assert candidates[0] == ".claude/skills/some-other-skill" + + +def test_scope_map_matches_tool_path(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "adapters.md").write_text( + _spec_text("- `tools/gmail/` — private-mail read/draft.\n- `tools/ponymail/` — archive.\n") + ) + (specs_dir / "other.md").write_text(_spec_text("- `tools/something-else/`\n")) + + result = scope_map(["tools/gmail/tool.md"], tmp_path) + + assert "tools/spec-loop/specs/adapters.md" in result + assert "tools/spec-loop/specs/other.md" not in result + + +def test_scope_map_matches_skill_symlink(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "pr-management-family.md").write_text( + _spec_text("- Skill: `pr-management-triage` — sweep.\n") + ) + + result = scope_map([".claude/skills/magpie-pr-management-triage"], tmp_path) + + assert "tools/spec-loop/specs/pr-management-family.md" in result + + +def test_scope_map_empty_paths(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "example.md").write_text(_spec_text("- `tools/example/`\n")) + + assert scope_map([], tmp_path) == [] + + +def test_scope_map_no_match(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "example.md").write_text(_spec_text("- `tools/example/`\n")) + + assert scope_map(["tools/other-tool/main.py"], tmp_path) == [] + + +def test_scope_map_returns_sorted_deduplicated(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "aaa.md").write_text(_spec_text("- `tools/shared/`\n")) + (specs_dir / "zzz.md").write_text(_spec_text("- `tools/shared/`\n")) + + # Two paths that both match the same two specs — result must be sorted and deduplicated. + result = scope_map(["tools/shared/a.py", "tools/shared/b.py"], tmp_path) + assert result == sorted(result) + assert len(result) == len(set(result)) + assert len(result) == 2 + + +def test_scope_map_docs_modes_md(tmp_path: Path) -> None: + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "triage-mode.md").write_text(_spec_text("- `docs/modes.md` — modes index.\n")) + + result = scope_map(["docs/modes.md"], tmp_path) + assert "tools/spec-loop/specs/triage-mode.md" in result + + +def test_scope_main_cli_reads_stdin(tmp_path: Path, monkeypatch: object) -> None: + """scope_main reads paths from positional arguments and prints matching specs.""" + specs_dir = tmp_path / "tools" / "spec-loop" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "example.md").write_text(_spec_text("- `tools/example/`\n")) + + monkeypatch.chdir(tmp_path) # type: ignore[attr-defined] + import io + import sys as _sys + + captured = io.StringIO() + monkeypatch.setattr(_sys, "stdout", captured) # type: ignore[attr-defined] + + scope_main(["tools/example/main.py", "--repo-root", str(tmp_path)]) + + output = captured.getvalue().strip() + assert "tools/spec-loop/specs/example.md" in output diff --git a/tools/spec-loop/loop.sh b/tools/spec-loop/loop.sh index fb5da88a5..0b3693892 100755 --- a/tools/spec-loop/loop.sh +++ b/tools/spec-loop/loop.sh @@ -285,6 +285,24 @@ update_scope_context() { echo "git diff --name-only $prev..$BASE_HEAD -- .claude/skills tools docs/modes.md" echo '```' echo "" + # Deterministic spec-scope mapping: pipe the diff through spec-scope so the + # agent receives a pre-computed list of likely-relevant specs rather than + # deriving the mapping itself. Failures are non-fatal (mapping is advisory). + local _changed_files _relevant_specs + _changed_files="$(git diff --name-only "$prev..$BASE_HEAD" -- .claude/skills tools docs/modes.md 2>/dev/null)" + if [ -n "$_changed_files" ]; then + _relevant_specs="$(printf '%s\n' "$_changed_files" | \ + uv run --project tools/spec-inventory spec-scope 2>/dev/null)" || true + if [ -n "$_relevant_specs" ]; then + echo "The deterministic scope mapper (\`spec-scope\`) identified these specs" + echo "as likely relevant to the changed paths above:" + echo "" + echo '```' + printf '%s\n' "$_relevant_specs" + echo '```' + echo "" + fi + fi echo "Skills, tools, and \`docs/modes.md\` rows untouched in that range are still" echo "in sync as of the previous run — skip them. Only re-inspect specs whose" echo "subjects appear in the diff. If the diff is empty there is nothing to" From c3e55d8054a76c27bd0ff290004ca43d81b18cf5 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 1 Aug 2026 23:59:14 +0200 Subject: [PATCH 2/2] fixup: match plural 'Skills:' bullets and their suffix shorthands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mapper silently returned no patterns for four specs. _SKILL_KEYWORD_RE required a colon directly after 'Skill', so the plural form 'Skills:' never matched. Those bullets list bare skill names with no slash, so the backtick branch skipped them too — no error, just a missing spec, which is what the mapper exists to prevent. Affected: adoption-and-setup.md:38, agent-isolation-sandbox.md:58, meta-and-quality-tooling.md:79, security-issue-lifecycle.md:32. All 18 original tests used the singular form, so the suite passed while the feature was broken for the shape that actually occurs in the tree. Within a skill bullet, a token starting with '-' is shorthand for a sibling of the preceding name, and the tree needs both expansions: setup-isolated-setup-install + -update is setup-isolated-setup-update (last segment replaced), while security-issue-import + -from-pr is security-issue-import-from-pr (appended). Both are emitted; a pattern matching no real path is inert, whereas picking one rule drops half the skills. Verified against the live tree — all four specs now resolve: skills/security-issue-import -> security-issue-lifecycle.md skills/setup-isolated-setup-verify -> adoption-and-setup.md, agent-isolation-sandbox.md skills/write-skill -> meta-and-quality-tooling.md, skill-reconciler.md Adds six tests; the four covering the plural forms fail on the previous implementation with an empty frozenset. The two bare-top-level-directory tests pin existing correct behaviour rather than fixing a defect: the trailing slash is stripped before the slash test, so a bare 'tools/' is already dropped. Reordering those steps would regress it. Also corrects the scope_main docstring, which claimed the exit code is always 0 while carrying a sys.exit(1) for an unresolvable repo root. Generated-by: Claude Code (Opus 5) --- .../src/spec_inventory/__init__.py | 48 ++++++++++--- tools/spec-inventory/tests/test_scope_map.py | 68 +++++++++++++++++++ 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/tools/spec-inventory/src/spec_inventory/__init__.py b/tools/spec-inventory/src/spec_inventory/__init__.py index 71ce18778..fccdd4a2c 100644 --- a/tools/spec-inventory/src/spec_inventory/__init__.py +++ b/tools/spec-inventory/src/spec_inventory/__init__.py @@ -47,7 +47,7 @@ # Scope-map patterns — used by the spec-scope entry point. _BACKTICK_RE = re.compile(r"`([^`]+)`") -_SKILL_KEYWORD_RE = re.compile(r"\bSkill:\s*`([^`]+)`") +_SKILL_KEYWORD_RE = re.compile(r"\bSkills?:") @dataclass @@ -381,18 +381,42 @@ def extract_path_patterns(where_bullets: list[str]) -> frozenset[str]: can be used as prefix tests against changed-file paths. Patterns come from two sources in each bullet: - - Explicit skill names — ``Skill: ```` bullets produce the pattern - ``skills/`` so that changes to ``skills//SKILL.md`` resolve - to the right spec. + - Explicit skill names — ``Skill: ```` and ``Skills: ``, ```` + bullets produce ``skills/`` patterns, so that changes to + ``skills//SKILL.md`` resolve to the right spec. Both the + singular and plural forms occur in the tree. - Backtick-quoted path tokens — any backtick-quoted token that contains - a slash is treated as a path prefix directly. + a slash is treated as a path prefix directly. A trailing slash is + stripped before the test, so a bare ``tools/`` becomes ``tools``, + fails the slash test, and is correctly ignored rather than + prefix-matching every path beneath it. + + Within a skill bullet, a token beginning with ``-`` is shorthand for a + sibling of the preceding full name. Two expansions are plausible and + the tree contains both: ``setup-isolated-setup-install`` + ``-update`` + means ``setup-isolated-setup-update`` (last segment replaced), while + ``security-issue-import`` + ``-from-pr`` means + ``security-issue-import-from-pr`` (appended). Both are emitted — a + pattern that matches no real path is inert, whereas guessing one rule + silently drops half the skills. """ patterns: set[str] = set() for bullet in where_bullets: - for m in _SKILL_KEYWORD_RE.finditer(bullet): - name = m.group(1).strip().rstrip("/") - if "/" not in name: - patterns.add(f"skills/{name}") + if _SKILL_KEYWORD_RE.search(bullet): + previous: str | None = None + for m in _BACKTICK_RE.finditer(bullet): + token = m.group(1).strip().rstrip("/") + if not token or "/" in token: + continue + if token.startswith("-"): + if previous is not None: + patterns.add(f"skills/{previous}{token}") + base = previous.rsplit("-", 1)[0] + if base != previous: + patterns.add(f"skills/{base}{token}") + else: + patterns.add(f"skills/{token}") + previous = token for m in _BACKTICK_RE.finditer(bullet): token = m.group(1).strip().rstrip("/") if "/" in token: @@ -451,8 +475,10 @@ def scope_main(argv: list[str] | None = None) -> None: Reads file paths from positional arguments or stdin (one per line) and writes the relevant spec file paths to stdout, one per line. Empty - input produces no output. Exit code is always 0 so a pipeline that - finds no matches does not fail a shell script. + input produces no output and exits 0, so a pipeline that finds no + matches does not fail a shell script. The only non-zero exit is when + the repository root cannot be resolved, which is a caller error rather + than an empty result. """ parser = argparse.ArgumentParser( description=( diff --git a/tools/spec-inventory/tests/test_scope_map.py b/tools/spec-inventory/tests/test_scope_map.py index f8dc85530..bc0a5399c 100644 --- a/tools/spec-inventory/tests/test_scope_map.py +++ b/tools/spec-inventory/tests/test_scope_map.py @@ -187,3 +187,71 @@ def test_scope_main_cli_reads_stdin(tmp_path: Path, monkeypatch: object) -> None output = captured.getvalue().strip() assert "tools/spec-loop/specs/example.md" in output + + +def test_extract_path_patterns_plural_skills_bullet() -> None: + # Four specs in the tree write "Skills:" (plural), not "Skill:". The + # singular-only form silently produced no patterns for them. + bullets = ["Skills: `write-skill` (author/update a skill), `optimize-skill`"] + patterns = extract_path_patterns(bullets) + assert "skills/write-skill" in patterns + assert "skills/optimize-skill" in patterns + + +def test_extract_path_patterns_suffix_shorthand_replaces_last_segment() -> None: + # agent-isolation-sandbox.md: `-update` means setup-isolated-setup-update. + bullets = ["Skills: `setup-isolated-setup-install`, `-update`, `-verify`, `-doctor`"] + patterns = extract_path_patterns(bullets) + assert "skills/setup-isolated-setup-install" in patterns + assert "skills/setup-isolated-setup-update" in patterns + assert "skills/setup-isolated-setup-verify" in patterns + assert "skills/setup-isolated-setup-doctor" in patterns + + +def test_extract_path_patterns_suffix_shorthand_appends() -> None: + # security-issue-lifecycle.md: `-from-pr` means security-issue-import-from-pr. + bullets = ["Skills: `security-issue-import` (+ `-from-pr`, `-from-md`)"] + patterns = extract_path_patterns(bullets) + assert "skills/security-issue-import" in patterns + assert "skills/security-issue-import-from-pr" in patterns + assert "skills/security-issue-import-from-md" in patterns + + +def test_extract_path_patterns_bare_top_level_dir_is_not_a_pattern() -> None: + # Guard, not a bug fix: the trailing slash is stripped *before* the + # slash test, so `tools/` becomes `tools`, fails the test, and is + # dropped. Reordering those two steps would make this spec match every + # path under tools/, so pin the behaviour. Real case: + # skill-reconciler.md has "a `uv` tool under `tools/`" in Where it lives. + bullets = ["Optional deterministic helper (not yet built): a `uv` tool under `tools/`"] + patterns = extract_path_patterns(bullets) + assert "tools" not in patterns + + +def test_scope_map_bare_top_level_dir_does_not_over_match(tmp_path: Path) -> None: + # End-to-end companion to the guard above: a spec whose only path token + # is a bare top-level directory must not be returned for every change + # beneath it. + specs = tmp_path / "tools" / "spec-loop" / "specs" + specs.mkdir(parents=True) + (tmp_path / ".git").mkdir() + (specs / "broad.md").write_text(_spec_text("- a helper under `tools/`\n")) + (specs / "narrow.md").write_text(_spec_text("- `tools/gmail/` — mail bridge.\n")) + + result = scope_map(["tools/gmail/client.py"], tmp_path) + + assert "tools/spec-loop/specs/narrow.md" in result + assert "tools/spec-loop/specs/broad.md" not in result + + +def test_scope_map_matches_plural_skills_bullet(tmp_path: Path) -> None: + specs = tmp_path / "tools" / "spec-loop" / "specs" + specs.mkdir(parents=True) + (tmp_path / ".git").mkdir() + (specs / "sandbox.md").write_text( + _spec_text("- Skills: `setup-isolated-setup-install`, `-update`, `-verify`\n") + ) + + result = scope_map(["skills/setup-isolated-setup-verify/SKILL.md"], tmp_path) + + assert "tools/spec-loop/specs/sandbox.md" in result