"""Generate and check the tool inventory in the documentation. Usage: uv run python scripts/check_docs.py # check mode (CI job ``docs-check``) uv run python scripts/check_docs.py --write # regenerate docs/tools.md and the README block Check mode fails (exit code 1) when: * ``docs/tools.md`` differs from what the catalog generates; * the generated block of ``README.md`` (between ```` and ````) differs from what the catalog generates; * ``README.md`` or a top-level ``docs/*.md`` file (``docs/adr``, ``docs/research`` and ``docs/audit.md`` are excluded) mentions a ``gitea_*`` tool name that is not registered. The inventory comes from ``load_catalog()`` plus the hand-written raw tool ``gitea_api_request``. Output is deterministic. The logic is importable so tests can drive it against a temporary tree. """ from __future__ import annotations import argparse import difflib import re import sys from collections import Counter from collections.abc import Iterable, Sequence from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "src")) from aegis_gitea_mcp.catalog import Catalog, Operation, load_catalog # noqa: E402 from aegis_gitea_mcp.gitea import RAW_TOOL_NAME # noqa: E402 README_NAME = "README.md" TOOLS_DOC = Path("docs") / "tools.md" BLOCK_START = "" BLOCK_END = "" #: Documentation that may name tools that no longer exist (history, research, audit). EXCLUDED_DOCS = frozenset({"audit.md"}) REGEN_HINT = "run: uv run python scripts/check_docs.py --write" RAW_DOMAIN = "misc" RAW_SUMMARY = "Raw request to any Gitea endpoint, for endpoints newer than the pinned spec" _TOOL_MENTION = re.compile(r"(? tuple[str, ...]: flags: list[str] = [] if operation.read_only: flags.append("read-only") if operation.destructive: flags.append("destructive") if operation.paginated: flags.append("paginated") if any(parameter.is_file for parameter in operation.parameters): flags.append("file upload") if operation.domain == "admin": flags.append("admin") return tuple(flags) def build_rows(catalog: Catalog) -> list[ToolRow]: """Return every tool (catalog operations plus the raw tool), sorted by domain then name.""" rows = [ ToolRow( name=op.tool_name, domain=op.domain, method=op.method, path=op.path, summary=op.summary, flags=_flags(op), ) for op in catalog.operations ] rows.append( ToolRow( name=RAW_TOOL_NAME, domain=RAW_DOMAIN, method="any", path="(caller supplied)", summary=RAW_SUMMARY, flags=("destructive",), ) ) return sorted(rows, key=lambda row: (row.domain, row.name)) def registered_names(catalog: Catalog) -> frozenset[str]: """Return every registered tool name, including the raw tool.""" return frozenset({op.tool_name for op in catalog.operations} | {RAW_TOOL_NAME}) def _cell(text: str) -> str: """Make ``text`` safe inside a Markdown table cell.""" return " ".join(text.split()).replace("|", "\\|") def _domain_order(catalog: Catalog, rows: Iterable[ToolRow]) -> list[str]: present = {row.domain for row in rows} ordered = [domain for domain in catalog.domains() if domain in present] ordered.extend(sorted(present - set(ordered))) return ordered def render_summary_table(catalog: Catalog) -> str: """Render the per-domain count table (also used inside the README block).""" rows = build_rows(catalog) totals: Counter[str] = Counter(row.domain for row in rows) read_only: Counter[str] = Counter(row.domain for row in rows if "read-only" in row.flags) lines = ["| Domain | Tools | Read-only | Mutating |", "|---|---:|---:|---:|"] for domain in _domain_order(catalog, rows): total = totals[domain] lines.append(f"| {domain} | {total} | {read_only[domain]} | {total - read_only[domain]} |") total_all = len(rows) read_all = sum(read_only.values()) lines.append(f"| **all** | **{total_all}** | **{read_all}** | **{total_all - read_all}** |") return "\n".join(lines) def render_readme_block(catalog: Catalog) -> str: """Render the text that sits between the README markers (without the markers).""" rows = build_rows(catalog) generated = len(rows) - 1 body = ( f"{len(rows)} tools: {generated} generated from the Gitea {catalog.spec_version} " f"swagger (one per operation) plus `{RAW_TOOL_NAME}`. The raw tool is counted " "under misc.\n\n" f"{render_summary_table(catalog)}\n\n" "The full list with HTTP method, path and flags is in [docs/tools.md](docs/tools.md)." ) return f"\n{body}\n" def render_tools_md(catalog: Catalog) -> str: """Render ``docs/tools.md`` completely.""" rows = build_rows(catalog) generated = len(rows) - 1 parts: list[str] = [ "# Tool inventory\n", "This file is generated. Do not edit it by hand: run " "`uv run python scripts/check_docs.py --write` after changing the catalog. CI fails " "when it is out of date.\n", f"There are {len(rows)} tools: {generated} generated from the pinned Gitea " f"{catalog.spec_version} swagger (`spec/gitea-swagger.v1.json`, one tool per " f"operation) plus the hand-written `{RAW_TOOL_NAME}`, which is listed under misc.\n", "Naming: `gitea__`, lowercase snake_case, at most 64 characters. The " "domain comes from the request path, the action from the swagger operationId. Names " "are stable for a given spec version.\n", "Every tool also accepts an optional `sudo` argument (a Gitea login). With an admin " "token the call then runs as that user. Paths are relative to `/api/v1`.\n", "Filters: `TOOLSETS` limits the exposed domains, `READ_ONLY` exposes only read-only " f"operations (`{RAW_TOOL_NAME}` then accepts only GET), `DISABLED_TOOLS` hides " "single tools by name. A filtered tool is neither listed nor callable. See " "[configuration.md](configuration.md).\n", "Flags: read-only (maps to the MCP `readOnlyHint`), destructive (`destructiveHint`: " "deletes and calls that replace state), paginated (accepts `page` and `limit`), file " "upload (takes base64 file content), admin (needs a site-admin token).\n", "## Summary\n", render_summary_table(catalog) + "\n", ] by_domain: dict[str, list[ToolRow]] = {} for row in rows: by_domain.setdefault(row.domain, []).append(row) for domain in _domain_order(catalog, rows): domain_rows = by_domain[domain] parts.append(f"## {domain}\n") blurb = DOMAIN_BLURBS.get(domain) if blurb: parts.append(f"{blurb} {len(domain_rows)} tools.\n") table = [ "| Tool | Method | Path | Summary | Flags |", "|---|---|---|---|---|", ] for row in domain_rows: table.append( f"| `{row.name}` | {row.method} | `{_cell(row.path)}` | {_cell(row.summary)} " f"| {', '.join(row.flags)} |" ) parts.append("\n".join(table) + "\n") return "\n".join(parts) # --------------------------------------------------------------------------- # README block handling # --------------------------------------------------------------------------- def extract_block(readme_text: str) -> str | None: """Return the text between the README markers, or None when a marker is missing.""" start = readme_text.find(BLOCK_START) end = readme_text.find(BLOCK_END) if start == -1 or end == -1 or end < start: return None return readme_text[start + len(BLOCK_START) : end] def replace_block(readme_text: str, block: str) -> str: """Return ``readme_text`` with the generated block replaced (markers must exist).""" start = readme_text.find(BLOCK_START) end = readme_text.find(BLOCK_END) if start == -1 or end == -1 or end < start: raise ValueError(f"README.md must contain {BLOCK_START} and {BLOCK_END} (in that order)") return readme_text[: start + len(BLOCK_START)] + block + readme_text[end:] # --------------------------------------------------------------------------- # Tool name mentions # --------------------------------------------------------------------------- def doc_files(root: Path) -> list[Path]: """Return the Markdown files scanned for tool names (README plus top-level docs).""" files: list[Path] = [] readme = root / README_NAME if readme.is_file(): files.append(readme) docs = root / "docs" if docs.is_dir(): files.extend( path for path in sorted(docs.glob("*.md")) if path.name not in EXCLUDED_DOCS and path.is_file() ) return files def is_known_mention(mention: str, names: frozenset[str]) -> bool: """Return True for a registered tool name or a prefix such as ``gitea_admin_``.""" if mention in names: return True return mention.endswith("_") and any(name.startswith(mention) for name in names) def find_unknown_mentions(text: str, names: frozenset[str]) -> list[tuple[int, str]]: """Return ``(line number, name)`` for every mention that is not a registered tool.""" unknown: list[tuple[int, str]] = [] for number, line in enumerate(text.splitlines(), start=1): for match in _TOOL_MENTION.finditer(line): mention = match.group(0) if not is_known_mention(mention, names): unknown.append((number, mention)) return unknown # --------------------------------------------------------------------------- # check and write # --------------------------------------------------------------------------- def _diff_summary(label: str, current: str, expected: str) -> str: diff = list( difflib.unified_diff( current.splitlines(), expected.splitlines(), fromfile=f"{label} (committed)", tofile=f"{label} (generated)", lineterm="", n=0, ) ) shown = diff[:_MAX_DIFF_LINES] if len(diff) > len(shown): shown.append(f"... {len(diff) - len(shown)} more diff lines") return "\n".join(shown) def check(root: Path, catalog: Catalog | None = None) -> list[str]: """Run every drift rule against the tree at ``root`` and return the problems found.""" catalog = catalog or load_catalog() problems: list[str] = [] tools_path = root / TOOLS_DOC expected_tools = render_tools_md(catalog) if not tools_path.is_file(): problems.append(f"{TOOLS_DOC} is missing. {REGEN_HINT}") else: current = tools_path.read_text(encoding="utf-8") if current != expected_tools: problems.append( f"{TOOLS_DOC} is out of date. {REGEN_HINT}\n" + _diff_summary(str(TOOLS_DOC), current, expected_tools) ) readme_path = root / README_NAME if not readme_path.is_file(): problems.append(f"{README_NAME} is missing") else: readme = readme_path.read_text(encoding="utf-8") block = extract_block(readme) expected_block = render_readme_block(catalog) if block is None: problems.append( f"{README_NAME} has no generated block: add {BLOCK_START} and {BLOCK_END}, " "then run: uv run python scripts/check_docs.py --write" ) elif block != expected_block: problems.append( f"the tool summary in {README_NAME} is out of date. {REGEN_HINT}\n" + _diff_summary(f"{README_NAME} tool summary", block, expected_block) ) names = registered_names(catalog) for path in doc_files(root): text = path.read_text(encoding="utf-8") for number, mention in find_unknown_mentions(text, names): problems.append( f"{path.relative_to(root)}:{number}: '{mention}' is not a registered tool name" ) return problems def write(root: Path, catalog: Catalog | None = None) -> list[Path]: """Regenerate ``docs/tools.md`` and the README block. Returns the files written.""" catalog = catalog or load_catalog() written: list[Path] = [] tools_path = root / TOOLS_DOC tools_path.parent.mkdir(parents=True, exist_ok=True) tools_path.write_text(render_tools_md(catalog), encoding="utf-8") written.append(tools_path) readme_path = root / README_NAME readme = readme_path.read_text(encoding="utf-8") updated = replace_block(readme, render_readme_block(catalog)) if updated != readme: readme_path.write_text(updated, encoding="utf-8") written.append(readme_path) return written def main(argv: Sequence[str] | None = None, root: Path = REPO_ROOT) -> int: """Run the script and return the process exit code.""" parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--write", action="store_true", help="regenerate docs/tools.md and the README tool summary instead of checking", ) args = parser.parse_args(argv) if args.write: try: written = write(root) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 for path in written: print(f"wrote {path.relative_to(root)}") return 0 problems = check(root) if problems: print(f"docs check failed with {len(problems)} problem(s):", file=sys.stderr) for problem in problems: print(f"- {problem}", file=sys.stderr) return 1 print("docs check passed") return 0 if __name__ == "__main__": raise SystemExit(main())