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

1#!/usr/bin/env python3 

2"""Generate the reference block on every command and procedure docs page. 

3 

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. 

8 

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: 

11 

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. 

21 

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. 

29 

30Usage: 

31 uv run --python 3.12 --no-project python \ 

32 plugin/scripts/render_command_docs.py [--root DIR] [--check] 

33 

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""" 

37 

38from __future__ import annotations 

39 

40import argparse 

41import re 

42import sys 

43from pathlib import Path 

44 

45from _rhiza_layout import DOCS_INTERNALS_DIR, DOCS_SKILLS_DIR, PROMPTS_DIR, command_files 

46 

47_BEGIN = "<!-- generated:begin — rendered by plugin/scripts/render_command_docs.py; do not edit -->" 

48_END = "<!-- generated:end -->" 

49 

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) 

53 

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") 

56 

57_BLOCK = re.compile(re.escape(_BEGIN) + r".*?" + re.escape(_END), re.S) 

58 

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/") 

62 

63 

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))} 

70 

71 

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 

77 

78 

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_" 

83 

84 

85def command_block(name: str, meta: dict[str, str], source: str) -> str: 

86 """The reference table for a slash command. 

87 

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) 

106 

107 

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) 

128 

129 

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 ] 

136 

137 

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}" 

142 

143 

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) 

158 

159 

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" 

165 

166 

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 

182 

183 

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) 

192 

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") 

200 

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 

212 

213 

214if __name__ == "__main__": 

215 sys.exit(main())