Coverage for plugin/scripts/render_command_docs.py: 100%
93 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Generate the reference block on every command and procedure docs page.
4``check_docs_nav.py`` already asserts that a page *exists* and is *navigable*. That is
5the weakest thing that can go wrong. The likelier drift is **content**: a page that
6still describes a flag, a tool permission or a caller that the command no longer has.
7Nothing checked that, and prose-to-prose comparison cannot.
9So the facts that drift mechanically are generated from the source of truth — the
10command and procedure files themselves — and spliced into each page between markers:
12- **Invocation** — the name plus its ``argument-hint``, so a renamed argument cannot
13 linger in the docs.
14- **Allowed tools** — the ``allowed-tools`` frontmatter, which appeared **nowhere** in
15 the docs site before this. It is the single most security-relevant fact about a
16 command and it was undocumented.
17- **Model-invocable** — whether the command carries ``disable-model-invocation``.
18 ``check_command_contracts.py`` asserts the policy holds; this publishes it.
19- **Read by** (procedures) — which commands actually ``Read`` this procedure, derived
20 by scanning for the reference rather than trusted to a human.
22**What is deliberately not generated: the prose.** An early sketch of this had the whole
23page rendered from the command body. That is wrong, and the page sizes say why —
24``docs/skills/maffay.md`` is *longer* than the ``SKILL.md`` it documents. The command body is
25instructions to a model; the docs page explains the command to a person. They are
26different documents with different audiences, and generating one from the other would
27have deleted real documentation. The generated block is therefore **additive**: it
28appends a reference table and never touches a hand-written line.
30Usage:
31 uv run --python 3.12 --no-project python \
32 plugin/scripts/render_command_docs.py [--root DIR] [--check]
34Writes the blocks by default. With ``--check`` it writes nothing and exits 1 if any
35page is out of date, which is how the pre-commit hook runs it.
36"""
38from __future__ import annotations
40import argparse
41import re
42import sys
43from pathlib import Path
45from _rhiza_layout import DOCS_INTERNALS_DIR, DOCS_SKILLS_DIR, PROMPTS_DIR, command_files
47_BEGIN = "<!-- generated:begin — rendered by plugin/scripts/render_command_docs.py; do not edit -->"
48_END = "<!-- generated:end -->"
50# The frontmatter block, and one `key: value` line inside it.
51_FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
52_FIELD = re.compile(r"^([a-z-]+):[ \t]*(.*)$", re.M)
54# A `prompts/<name>.md` reference, used to work out which commands read a procedure.
55_PROMPT_REF = re.compile(r"prompts/([a-zA-Z0-9_-]+)\.md")
57_BLOCK = re.compile(re.escape(_BEGIN) + r".*?" + re.escape(_END), re.S)
59# A link from `docs/internals/` to a command page is relative to `docs/`, so it needs the
60# directory name alone. Derived, not spelled out, so the two cannot disagree.
61_SKILLS_URL = DOCS_SKILLS_DIR.removeprefix("docs/")
64def frontmatter(text: str) -> dict[str, str]:
65 """Parse a command's frontmatter into a flat mapping; empty when there is none."""
66 match = _FRONTMATTER.match(text)
67 if match is None:
68 return {}
69 return {key: value.strip() for key, value in _FIELD.findall(match.group(1))}
72def _unquote(value: str) -> str:
73 """Strip the surrounding quotes an ``argument-hint`` usually carries."""
74 if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
75 return value[1:-1]
76 return value
79def _tools(value: str) -> str:
80 """Render an ``allowed-tools`` list as inline code, comma separated."""
81 tools = [tool.strip() for tool in value.split(",") if tool.strip()]
82 return ", ".join(f"`{tool}`" for tool in tools) if tools else "_none declared_"
85def command_block(name: str, meta: dict[str, str], source: str) -> str:
86 """The reference table for a slash command.
88 *source* is passed in rather than derived from *name*: a command is either
89 ``commands/<name>.md`` or ``skills/<name>/SKILL.md``, and publishing the wrong one
90 would send a reader to a file that isn't there.
91 """
92 hint = _unquote(meta.get("argument-hint", "")).strip()
93 invocation = f"/rhiza:{name} {hint}".strip()
94 invocable = (
95 "no — excluded from model invocation"
96 if meta.get("disable-model-invocation") == "true"
97 else "yes"
98 )
99 rows = [
100 ("Source", f"`{source}`"),
101 ("Invocation", f"`{invocation}`"),
102 ("Model-invocable", invocable),
103 ("Allowed tools", _tools(meta.get("allowed-tools", ""))),
104 ]
105 return _table(rows)
108def procedure_block(name: str, readers: list[str]) -> str:
109 """The reference table for an internal procedure."""
110 if readers:
111 read_by = ", ".join(
112 f"[`/rhiza:{reader}`](../{_SKILLS_URL}/{reader}.md)"
113 if kind == "command"
114 else f"[`{reader}`]({reader}.md)"
115 for kind, reader in _classify(readers)
116 )
117 else:
118 # check_prompt_wiring.py rule 5 forbids an orphan, so this should be
119 # unreachable in this repo — but rendering "nothing" beats rendering an
120 # empty cell if that gate is ever loosened.
121 read_by = "_nothing — this procedure is an orphan_"
122 rows = [
123 ("Source", f"`{PROMPTS_DIR}/{name}.md`"),
124 ("Invocation", "**not a slash command** — reached with `Read`, never invoked"),
125 ("Read by", read_by),
126 ]
127 return _table(rows)
130def _classify(readers: list[str]) -> list[tuple[str, str]]:
131 """Tag each reader as a command or a procedure, for link building."""
132 return [
133 ("command" if kind == "commands" else "procedure", stem)
134 for kind, stem in (reader.split("/", 1) for reader in readers)
135 ]
138def _table(rows: list[tuple[str, str]]) -> str:
139 """Render label/value pairs as a headerless two-column markdown table."""
140 body = "\n".join(f"| **{label}** | {value} |" for label, value in rows)
141 return f"{_BEGIN}\n\n## Reference\n\n| | |\n| --- | --- |\n{body}\n\n{_END}"
144def readers_of(name: str, root: Path) -> list[str]:
145 """Which commands and procedures reference ``prompts/<name>.md``, sorted."""
146 found = set()
147 # The prefix is the *kind* ("commands"/"prompts"), not the directory — _classify keys
148 # off it to build the right relative link, and neither the `plugin/` segment nor a
149 # command's layout has any place in a docs URL.
150 candidates = [("commands", stem, path) for stem, path in command_files(root)]
151 candidates += [("prompts", path.stem, path) for path in (root / PROMPTS_DIR).glob("*.md")]
152 for kind, stem, path in sorted(candidates):
153 if stem == name:
154 continue
155 if name in _PROMPT_REF.findall(path.read_text(encoding="utf-8")):
156 found.add(f"{kind}/{stem}")
157 return sorted(found)
160def splice(page: str, block: str) -> str:
161 """Replace the page's generated block, or append one when it has none."""
162 if _BLOCK.search(page):
163 return _BLOCK.sub(lambda _: block, page, count=1)
164 return f"{page.rstrip()}\n\n{block}\n"
167def render(root: Path) -> dict[Path, str]:
168 """Map every docs page to the text it should have."""
169 wanted: dict[Path, str] = {}
170 for name, path in command_files(root):
171 page = root / DOCS_SKILLS_DIR / f"{name}.md"
172 if page.is_file():
173 meta = frontmatter(path.read_text(encoding="utf-8"))
174 block = command_block(name, meta, path.relative_to(root).as_posix())
175 wanted[page] = splice(page.read_text(encoding="utf-8"), block)
176 for path in sorted((root / PROMPTS_DIR).glob("*.md")):
177 page = root / DOCS_INTERNALS_DIR / f"{path.stem}.md"
178 if page.is_file():
179 block = procedure_block(path.stem, readers_of(path.stem, root))
180 wanted[page] = splice(page.read_text(encoding="utf-8"), block)
181 return wanted
184def main(argv: list[str] | None = None) -> int:
185 """Write or verify every generated block; exit 1 under ``--check`` when stale."""
186 parser = argparse.ArgumentParser(description=__doc__)
187 parser.add_argument("--root", default=".", help="Repository root (default: cwd).")
188 parser.add_argument(
189 "--check", action="store_true", help="Verify only; write nothing. Exit 1 if stale."
190 )
191 args = parser.parse_args(argv)
193 stale = []
194 for page, text in render(Path(args.root)).items():
195 if page.read_text(encoding="utf-8") == text:
196 continue
197 stale.append(page)
198 if not args.check:
199 page.write_text(text, encoding="utf-8")
201 if not stale:
202 print("docs reference blocks are up to date")
203 return 0
204 if args.check:
205 print("Stale generated block(s) — run scripts/render_command_docs.py:", file=sys.stderr)
206 for page in stale:
207 print(f" ✗ {page}", file=sys.stderr)
208 return 1
209 for page in stale:
210 print(f" updated {page}")
211 return 0
214if __name__ == "__main__":
215 sys.exit(main())