Coverage for plugin/scripts/check_docs_nav.py: 100%

66 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Check that every command and procedure has a docs page, wired into the nav. 

3 

4``CONTRIBUTING.md`` has always required it — "give the command a page under 

5``docs/skills/<name>.md`` and add it to the ``nav`` in ``mkdocs.yml``" — and nothing 

6checked it. That left the one documented rule in the contributing guide with no 

7enforcement behind it, in a repo whose stated position is that prose is gated exactly 

8like code. 

9 

10``mkdocs build --strict`` is not the same check. It fails on a nav entry pointing at a 

11*missing page*, which is the rarer direction. What it cannot see is the likelier one: a 

12new command whose page was never written, or a page that exists but was never added to 

13the nav and so ships as an orphan the site never links to. 

14 

15The four rules, checked in both directions: 

16 

171. **Page exists** — every command has ``docs/skills/<name>.md`` and every 

18 ``prompts/<name>.md`` has ``docs/internals/<name>.md``. The page is named for the 

19 *command*, not for the file behind it, so renaming that file never orphans its page. 

202. **Page is navigable** — each of those pages appears in ``mkdocs.yml``'s ``nav``. 

213. **No orphan page** — nothing under ``docs/skills/`` or ``docs/internals/`` without 

22 a backing command or procedure. A page for a command that was renamed or retired 

23 goes on serving stale instructions long after the command stopped existing. 

244. **No dangling nav entry** — every ``nav`` target that names a file under those two 

25 directories resolves. Rules 2 and 4 accept the same two spellings of a target 

26 (``skills/x.md`` and ``docs/skills/x.md``), which has to hold on both sides: while only 

27 rule 2 normalised them, an entry spelled the long way at a page that was never written 

28 counted as wiring and was then never checked for existence. 

29 

30The ``nav`` is read by collecting ``*.md`` targets out of the block rather than by 

31parsing YAML. The bundled subset parser in ``_rhiza_yaml.py`` was written for the flat 

32shape of ``template.yml`` and ``template.lock``; a mkdocs ``nav`` is a list of nested 

33single-key mappings, which is exactly the shape it does not promise to handle. For a 

34parity check the set of referenced paths is the whole question, so extracting it 

35directly is both sufficient and harder to get wrong. 

36 

37Usage: 

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

39 scripts/check_docs_nav.py [--root DIR] 

40 

41Exits 0 when every page is present and wired up, 1 (listing each violation) otherwise. 

42""" 

43 

44from __future__ import annotations 

45 

46import argparse 

47import re 

48import sys 

49from pathlib import Path 

50 

51from _rhiza_layout import DOCS_INTERNALS_DIR, DOCS_SKILLS_DIR, PROMPTS_DIR, command_files 

52 

53_DOCS_DIRS = (DOCS_SKILLS_DIR, DOCS_INTERNALS_DIR) 

54# A top-level `nav:` key, and the next top-level key that ends the block. 

55_NAV_START = re.compile(r"^nav:\s*$", re.M) 

56_TOP_LEVEL_KEY = re.compile(r"^[A-Za-z_]", re.M) 

57# Any `*.md` target inside the nav, quoted or bare. 

58_MD_TARGET = re.compile(r"[\w./-]+\.md") 

59 

60 

61def nav_targets(mkdocs: Path) -> set[str]: 

62 """Return every ``*.md`` path referenced in *mkdocs*'s ``nav`` block. 

63 

64 A missing file, or one with no ``nav:`` key, yields an empty set — the caller 

65 reports that as every page being unwired rather than as a crash. 

66 """ 

67 if not mkdocs.is_file(): 

68 return set() 

69 text = mkdocs.read_text(encoding="utf-8") 

70 start = _NAV_START.search(text) 

71 if start is None: 

72 return set() 

73 rest = text[start.end() :] 

74 end = _TOP_LEVEL_KEY.search(rest) 

75 block = rest[: end.start()] if end else rest 

76 return set(_MD_TARGET.findall(block)) 

77 

78 

79def _stems(directory: Path) -> set[str]: 

80 """Return the stems of the markdown files directly in *directory*.""" 

81 if not directory.is_dir(): 

82 return set() 

83 return {path.stem for path in directory.glob("*.md")} 

84 

85 

86def check_mirror(root: Path, sources: dict[str, str], docs: str, targets: set[str]) -> list[str]: 

87 """Apply all four rules to one source/docs pair. 

88 

89 *sources* maps each name to the repo-relative path that defines it, so a violation 

90 can name the real file. For commands that path is either layout's; the page name is 

91 the command name in both cases. 

92 """ 

93 violations = [] 

94 source_stems = set(sources) 

95 page_stems = _stems(root / docs) 

96 

97 for stem in sorted(source_stems - page_stems): 

98 violations.append(f"{sources[stem]} has no page at {docs}/{stem}.md") 

99 for stem in sorted(page_stems - source_stems): 

100 violations.append(f"{docs}/{stem}.md has no command or procedure behind it — orphan page") 

101 

102 # The nav is written relative to docs/, so `docs/skills/x.md` appears as 

103 # `skills/x.md`. Accept the full path too, so a repo that spells it out isn't 

104 # reported as unwired for a cosmetic difference. 

105 relative = docs.removeprefix("docs/") 

106 for stem in sorted(source_stems & page_stems): 

107 if not {f"{relative}/{stem}.md", f"{docs}/{stem}.md"} & targets: 

108 violations.append(f"{docs}/{stem}.md exists but is not in mkdocs.yml's nav") 

109 

110 # Rule 4 has to normalise the *same* two spellings, or the asymmetry is a hole: rule 2 

111 # accepts `docs/<dir>/x.md` as wiring, while a check that only matched `<dir>/x.md` 

112 # never went on to confirm that file exists. A nav entry spelled the long way at a 

113 # page that was never written therefore passed both rules. Stripping the prefix first 

114 # also fixes the path that gets resolved — `root/docs/docs/<dir>/x.md` is nobody's file. 

115 # Deduplicated, so a nav naming both spellings reports the violation once. 

116 normalised = {t.removeprefix("docs/") for t in targets} 

117 for target in sorted(t for t in normalised if t.startswith(f"{relative}/")): 

118 if not (root / "docs" / target).is_file(): 

119 violations.append(f"mkdocs.yml nav points at docs/{target}, which does not exist") 

120 return violations 

121 

122 

123def _sources(root: Path) -> tuple[dict[str, str], dict[str, str]]: 

124 """The commands and the procedures at *root*, each as ``name -> relative path``.""" 

125 commands = {name: path.relative_to(root).as_posix() for name, path in command_files(root)} 

126 procedures = { 

127 path.stem: path.relative_to(root).as_posix() for path in (root / PROMPTS_DIR).glob("*.md") 

128 } 

129 return commands, procedures 

130 

131 

132def check_docs_nav(root: Path) -> list[str]: 

133 """Run the rules over both mirrors at *root*; return all violations.""" 

134 targets = nav_targets(root / "mkdocs.yml") 

135 violations: list[str] = [] 

136 # strict: the two source groups and the two docs directories are a fixed pairing, so 

137 # a mismatch is a bug here rather than something to silently truncate. 

138 for sources, docs in zip(_sources(root), _DOCS_DIRS, strict=True): 

139 violations += check_mirror(root, sources, docs, targets) 

140 return violations 

141 

142 

143def main(argv: list[str] | None = None) -> int: 

144 """Entry point: check docs/nav parity and return an exit code.""" 

145 parser = argparse.ArgumentParser(description="Check command/procedure docs and nav parity.") 

146 parser.add_argument("--root", default=".", help="Plugin root (default: current directory).") 

147 args = parser.parse_args(argv) 

148 

149 root = Path(args.root).resolve() 

150 violations = check_docs_nav(root) 

151 if violations: 

152 print("Docs/nav parity check failed:", file=sys.stderr) 

153 for violation in violations: 

154 print(f"{violation}", file=sys.stderr) 

155 return 1 

156 

157 pages = sum(len(_stems(root / docs)) for docs in _DOCS_DIRS) 

158 print(f"docs and nav are in parity ({pages} page(s) checked)") 

159 return 0 

160 

161 

162if __name__ == "__main__": 

163 raise SystemExit(main())