blob: 03851a24e8deab99054a7f1b5eb2efcb29515e6d [file]
# 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.
"""Generate a compact routing inventory for the spec-loop.
The inventory is intentionally shallow: enough for an agent to choose the
next relevant files, not enough to replace direct verification. It is
stdlib-only so the loop can run it cheaply through ``uv``.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
SPECS_DIR = Path("tools/spec-loop/specs")
SKILLS_DIR = Path("skills")
TOOLS_DIR = Path("tools")
SKIP_SPEC_FILES = frozenset({"README.md", "overview.md"})
SKIP_TOOL_DIRS = frozenset({"spec-loop"})
_HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
_FENCED_CODE_RE = re.compile(r"^ {0,3}```(?:\w+)?\n([\s\S]*?)^ {0,3}```", re.MULTILINE)
_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
_SCRIPT_RE = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*=\s*\"([^\"]+)\"", re.MULTILINE)
_SECTION_RE = re.compile(r"^\[([^\]]+)\]\s*$")
_YAML_BLOCK_SCALAR_HEADERS = frozenset({"|", ">", "|-", "|+", ">-", ">+"})
@dataclass
class SpecSummary:
file: str
title: str
status: str
mode: str
kind: str
where: list[str]
validation: list[str]
known_gaps: list[str]
@dataclass
class SkillSummary:
file: str
name: str
mode: str
capability: str
organization: str
description: str
@dataclass
class ToolSummary:
path: str
has_pyproject: bool
has_tests: bool
scripts: list[str]
@dataclass
class Inventory:
specs: list[SpecSummary]
skills: list[SkillSummary]
tools: list[ToolSummary]
def find_repo_root(start: Path) -> Path:
"""Return the nearest parent containing .git."""
path = start.resolve()
while path != path.parent:
if (path / ".git").exists():
return path
path = path.parent
raise RuntimeError(f"Could not find repo root (.git) from {start}")
def _frontmatter_bounds(text: str) -> tuple[int, int] | None:
idx = text.find("---\n")
if idx == -1:
return None
if _HTML_COMMENT_RE.sub("", text[:idx]).strip():
return None
try:
end = text.index("\n---\n", idx + 4)
except ValueError:
return None
return (idx + 4, end)
def parse_frontmatter(text: str) -> dict[str, str]:
"""Parse top-level YAML-ish frontmatter without external dependencies."""
bounds = _frontmatter_bounds(text)
if bounds is None:
return {}
block = text[bounds[0] : bounds[1]]
result: dict[str, str] = {}
current_key: str | None = None
current_value_lines: list[str] = []
for raw_line in block.splitlines():
line = raw_line.rstrip()
if line == "":
if current_key is not None:
current_value_lines.append("")
continue
if not line.startswith((" ", "\t")) and ":" in line:
if current_key is not None:
result[current_key] = "\n".join(current_value_lines).strip()
key, _, value = line.partition(":")
current_key = key.strip()
inline = value.strip()
current_value_lines = [inline] if inline and inline not in _YAML_BLOCK_SCALAR_HEADERS else []
continue
if current_key is not None:
stripped = line[2:] if line.startswith(" ") else line.strip()
current_value_lines.append(stripped)
if current_key is not None:
result[current_key] = "\n".join(current_value_lines).strip()
return result
def _body_after_frontmatter(text: str) -> str:
bounds = _frontmatter_bounds(text)
if bounds is None:
return text
return text[bounds[1] + 5 :]
def get_section_body(text: str, section: str) -> str:
"""Return a ## section body, or an empty string."""
body = _body_after_frontmatter(text)
headings = list(_HEADING_RE.finditer(body))
for index, match in enumerate(headings):
if match.group(1).strip() != section:
continue
start = match.end()
end = headings[index + 1].start() if index + 1 < len(headings) else len(body)
return body[start:end].strip()
return ""
def _compact_line(line: str) -> str:
return " ".join(line.strip().split())
def _truncate(text: str, limit: int = 180) -> str:
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def section_bullets(text: str, section: str, limit: int) -> list[str]:
"""Extract top-level bullets from a section."""
body = get_section_body(text, section)
bullets: list[str] = []
current: list[str] = []
def flush_current() -> None:
if current and len(bullets) < limit:
bullets.append(_truncate(_compact_line(" ".join(current))))
current.clear()
for line in body.splitlines():
if len(bullets) >= limit:
break
stripped = line.strip()
if stripped.startswith(("- ", "* ")):
flush_current()
current.append(stripped[2:])
continue
if current and stripped and not stripped.startswith(("```", "#")):
current.append(stripped)
flush_current()
return bullets
def validation_commands(text: str, limit: int) -> list[str]:
"""Extract compact command lines from fenced blocks in Validation."""
body = get_section_body(text, "Validation")
commands: list[str] = []
for block in _FENCED_CODE_RE.findall(body):
for line in block.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
commands.append(_truncate(_compact_line(stripped), 220))
if len(commands) >= limit:
return commands
return commands
def first_description_line(description: str) -> str:
for line in description.splitlines():
compact = _compact_line(line)
if compact:
return compact
return ""
def compact_metadata_value(value: str) -> str:
"""Compact scalar or simple YAML-list frontmatter values for one-line output."""
parts: list[str] = []
for line in value.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("- "):
stripped = stripped[2:].strip()
parts.append(stripped)
return ", ".join(parts)
def load_specs(repo_root: Path, *, max_where: int, max_validation: int, max_gaps: int) -> list[SpecSummary]:
specs_dir = repo_root / SPECS_DIR
entries: list[SpecSummary] = []
for path in sorted(specs_dir.glob("*.md")):
if path.name in SKIP_SPEC_FILES:
continue
text = path.read_text()
fm = parse_frontmatter(text)
if not fm:
continue
entries.append(
SpecSummary(
file=str(path.relative_to(repo_root)),
title=fm.get("title", path.stem),
status=fm.get("status", ""),
mode=fm.get("mode", ""),
kind=fm.get("kind", ""),
where=section_bullets(text, "Where it lives", max_where),
validation=validation_commands(text, max_validation),
known_gaps=section_bullets(text, "Known gaps", max_gaps),
)
)
return entries
def load_skills(repo_root: Path) -> list[SkillSummary]:
skills_dir = repo_root / SKILLS_DIR
entries: list[SkillSummary] = []
for path in sorted(skills_dir.glob("*/SKILL.md")):
text = path.read_text()
fm = parse_frontmatter(text)
if not fm.get("name"):
continue
entries.append(
SkillSummary(
file=str(path.relative_to(repo_root)),
name=fm.get("name", path.parent.name),
mode=compact_metadata_value(fm.get("mode", "")),
capability=compact_metadata_value(fm.get("capability", "")),
organization=compact_metadata_value(fm.get("organization", "")),
description=first_description_line(fm.get("description", "")),
)
)
return entries
def parse_project_scripts(pyproject_text: str) -> list[str]:
"""Return script names from the [project.scripts] table only."""
scripts: list[str] = []
in_scripts = False
for line in pyproject_text.splitlines():
section_match = _SECTION_RE.match(line.strip())
if section_match:
in_scripts = section_match.group(1) == "project.scripts"
continue
if not in_scripts:
continue
script_match = _SCRIPT_RE.match(line)
if script_match:
scripts.append(script_match.group(1))
return scripts
def load_tools(repo_root: Path) -> list[ToolSummary]:
tools_dir = repo_root / TOOLS_DIR
entries: list[ToolSummary] = []
for path in sorted(p for p in tools_dir.iterdir() if p.is_dir() and not p.name.startswith(".")):
if path.name in SKIP_TOOL_DIRS:
continue
pyproject = path / "pyproject.toml"
readme = path / "README.md"
tests = path / "tests"
if not pyproject.exists() and not readme.exists() and not tests.exists():
continue
scripts: list[str] = []
if pyproject.exists():
scripts = parse_project_scripts(pyproject.read_text())
entries.append(
ToolSummary(
path=str(path.relative_to(repo_root)),
has_pyproject=pyproject.exists(),
has_tests=tests.is_dir(),
scripts=scripts[:5],
)
)
return entries
def build_inventory(repo_root: Path, *, max_where: int, max_validation: int, max_gaps: int) -> Inventory:
return Inventory(
specs=load_specs(repo_root, max_where=max_where, max_validation=max_validation, max_gaps=max_gaps),
skills=load_skills(repo_root),
tools=load_tools(repo_root),
)
def _join(items: list[str]) -> str:
return "; ".join(items) if items else "-"
def format_markdown(inventory: Inventory, *, brief: bool = False) -> str:
lines = [
"## Compact repository inventory",
"",
"Deterministic routing aid generated from local files. Use it to choose",
"what to inspect next, but verify claims with direct file reads or code",
"search before recording behaviour as present or absent.",
"",
"### Specs",
"",
]
for spec in inventory.specs:
lines.append(f"- `{spec.file}` — {spec.title} [{spec.status}, {spec.mode}, {spec.kind}]")
lines.append(f" where: {_join(spec.where)}")
lines.append(f" validation: {_join(spec.validation)}")
lines.append(f" known gaps: {_join(spec.known_gaps)}")
lines.extend(["", "### Skills", ""])
for skill in inventory.skills:
org = f", {skill.organization}" if skill.organization else ""
summary = f"- `{skill.file}` — {skill.name} [{skill.mode}, {skill.capability}{org}]"
if not brief and skill.description:
summary += f": {skill.description}"
lines.append(summary)
lines.extend(["", "### Tools", ""])
for tool in inventory.tools:
markers = []
if tool.has_pyproject:
markers.append("pyproject")
if tool.has_tests:
markers.append("tests")
script_suffix = f"; scripts: {', '.join(tool.scripts)}" if tool.scripts else ""
lines.append(f"- `{tool.path}` — {', '.join(markers) if markers else 'metadata'}{script_suffix}")
lines.append("")
return "\n".join(lines)
def format_json(inventory: Inventory) -> str:
return json.dumps(asdict(inventory), indent=2)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate compact routing inventory for spec-loop prompts.")
parser.add_argument(
"--repo-root", type=Path, default=None, help="Repository root (default: nearest .git parent)."
)
parser.add_argument("--json", dest="as_json", action="store_true", help="Emit JSON instead of Markdown.")
parser.add_argument("--brief", action="store_true", help="Omit skill descriptions from Markdown output.")
parser.add_argument("--max-where", type=int, default=3, help="Max Where-it-lives bullets per spec.")
parser.add_argument("--max-validation", type=int, default=3, help="Max validation commands per spec.")
parser.add_argument("--max-gaps", type=int, default=2, help="Max known-gap bullets per spec.")
args = parser.parse_args()
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)
inventory = build_inventory(
repo_root,
max_where=args.max_where,
max_validation=args.max_validation,
max_gaps=args.max_gaps,
)
if args.as_json:
print(format_json(inventory))
else:
print(format_markdown(inventory, brief=args.brief), end="")