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"