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

85 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 the plugin's internal procedures under ``prompts/`` stay wired up. 

3 

4``commands/*.md`` and ``skills/*/SKILL.md`` are slash commands the user invokes. 

5``prompts/*.md`` are **internal procedures** a command reaches with the ``Read`` tool — 

6deliberately outside *both* discovery locations so they cannot be invoked directly. 

7Nothing at runtime verifies that arrangement, so a rename or a stray file would only 

8surface as a command failing mid-run, in front of a user. This is that check. 

9 

10It enforces six rules: 

11 

121. every procedure announces itself as **not a slash command**, so a reader (human 

13 or model) opening one mid-task knows it isn't user-facing; 

142. no procedure carries command frontmatter (``allowed-tools``/``argument-hint``), 

15 which would be misleading and hints the file belongs in a discovery location; 

163. no procedure name collides with a command name; 

174. every ``prompts/<name>.md`` path mentioned in the repo's prose resolves — a 

18 dangling reference is a command that breaks when it reaches that step; 

195. no procedure is orphaned — each is referenced by at least one command or 

20 procedure, and none is invoked "via the Skill tool", which only works for real 

21 commands; 

226. no shipped prose names a discovery location this plugin does not have. 

23 

24Rule 6 is the one that guards the *reasoning* rather than the wiring. Rules 1–5 assert 

25that a procedure declares itself un-invocable and stays reachable; none of them reads 

26*why* the prose says it is un-invocable. When all eight commands moved to ``skills/`` 

27and ``commands/`` stopped existing, eleven shipped files still explained themselves as 

28"in ``prompts/``, not ``commands/``" — an argument from a directory that is no longer 

29there, which a model can reasonably read as a constraint that no longer binds. Rule 6 

30fails the build on that class of claim. 

31 

32Usage: 

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

34 scripts/check_prompt_wiring.py [--root DIR] 

35 

36Exits 0 when the wiring is sound, 1 (listing every violation) otherwise. 

37""" 

38 

39from __future__ import annotations 

40 

41import argparse 

42import re 

43import sys 

44from pathlib import Path 

45 

46from _rhiza_layout import PLUGIN_DIR, PROMPTS_DIR, command_files 

47 

48_NOT_A_COMMAND = "Not a slash command" 

49_FRONTMATTER_KEYS = ("allowed-tools:", "argument-hint:") 

50_PROMPT_REF = re.compile(r"prompts/([a-zA-Z0-9_-]+)\.md") 

51_SKILL_INVOCATION = re.compile(r"`([a-zA-Z0-9_-]+)` command via the Skill tool") 

52 

53_DISCOVERY_DIRS = ("agents", "bin", "commands", "hooks", "monitors", "skills") 

54"""The directory names Claude Code scans at a plugin root. 

55 

56Deliberately not "every directory the prose mentions": `.rhiza/`, `tests/`, `docs/` and 

57`scripts/` name a *managed repo's* layout far more often than this plugin's, so gating 

58them would be noise. These six are unambiguous — prose naming one is making a claim 

59about where Claude Code looks for components. 

60""" 

61 

62_PATH_CHARS = r"[\w${}./-]" 

63_LAYOUT_PATH = re.compile(rf"(?<!{_PATH_CHARS})({_PATH_CHARS}*?)({'|'.join(_DISCOVERY_DIRS)})/") 

64_PLUGIN_PREFIXES = ("", f"{PLUGIN_DIR}/", "${CLAUDE_PLUGIN_ROOT}/") 

65_LAYOUT_EXEMPT = re.compile(r"<!--\s*rhiza-layout-exempt:\s*([a-z]+)/\s+(\S[^>]*?)\s*-->") 

66 

67 

68def _names(directory: Path) -> list[str]: 

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

70 if not directory.is_dir(): 

71 return [] 

72 return sorted(p.stem for p in directory.glob("*.md")) 

73 

74 

75def _shipped_prose(root: Path) -> list[Path]: 

76 """Return every markdown file the plugin ships — the text a model reads at runtime. 

77 

78 Repo-level prose is deliberately excluded. ``CLAUDE.md`` narrates this layout 

79 *including which directories are gone*, so "``plugin/commands/`` no longer exists" 

80 is a true and useful sentence there and a rule-6 violation here. The distinction is 

81 the point: rule 6 governs claims a model acts on mid-task, not claims a maintainer 

82 reads while changing the layout. 

83 """ 

84 return sorted([*(path for _, path in command_files(root)), *(root / PROMPTS_DIR).glob("*.md")]) 

85 

86 

87def _prose_files(root: Path) -> list[Path]: 

88 """Return every markdown file whose prompt references should resolve.""" 

89 return sorted( 

90 [ 

91 *(path for _, path in command_files(root)), 

92 *(root / PROMPTS_DIR).glob("*.md"), 

93 *root.glob("*.md"), 

94 ] 

95 ) 

96 

97 

98def check_declares_internal(prompts_dir: Path) -> list[str]: 

99 """Rule 1: each procedure states that it is not a slash command.""" 

100 return [ 

101 f"prompts/{name}.md does not say {_NOT_A_COMMAND!r}" 

102 for name in _names(prompts_dir) 

103 if _NOT_A_COMMAND not in (prompts_dir / f"{name}.md").read_text(encoding="utf-8") 

104 ] 

105 

106 

107def check_no_command_frontmatter(prompts_dir: Path) -> list[str]: 

108 """Rule 2: procedures carry no command frontmatter.""" 

109 violations = [] 

110 for name in _names(prompts_dir): 

111 text = (prompts_dir / f"{name}.md").read_text(encoding="utf-8") 

112 if text.startswith("---"): 

113 violations.append(f"prompts/{name}.md opens with command frontmatter") 

114 violations += [ 

115 f"prompts/{name}.md declares {key!r}, which only applies to commands" 

116 for key in _FRONTMATTER_KEYS 

117 if key in text 

118 ] 

119 return violations 

120 

121 

122def check_no_name_collisions(root: Path, prompts_dir: Path) -> list[str]: 

123 """Rule 3: a name is either a command or a procedure, never both. 

124 

125 The command side spans both layouts, so moving a command into ``skills/`` cannot 

126 quietly free up its name for a procedure to take. 

127 """ 

128 commands = {name for name, _ in command_files(root)} 

129 both = sorted(commands & set(_names(prompts_dir))) 

130 return [f"{name!r} exists as both a command and a procedure" for name in both] 

131 

132 

133def check_references_resolve(root: Path) -> list[str]: 

134 """Rule 4: every ``prompts/<name>.md`` mentioned in prose exists.""" 

135 violations = [] 

136 for path in _prose_files(root): 

137 for name in sorted(set(_PROMPT_REF.findall(path.read_text(encoding="utf-8")))): 

138 if not (root / PROMPTS_DIR / f"{name}.md").is_file(): 

139 rel = path.relative_to(root).as_posix() 

140 violations.append(f"{rel} references missing prompts/{name}.md") 

141 return violations 

142 

143 

144def _skill_call_violations(root: Path, path: Path, text: str, prompts: set[str]) -> list[str]: 

145 """Return the 'invoked as a command' violations in one prose file.""" 

146 return [ 

147 f"{path.relative_to(root).as_posix()} invokes {name!r} via the Skill tool, but it is a " 

148 "procedure, not a command — reach it with Read" 

149 for name in _SKILL_INVOCATION.findall(text) 

150 if name in prompts 

151 ] 

152 

153 

154def check_no_orphans_and_no_skill_calls(root: Path) -> list[str]: 

155 """Rule 5: every procedure is referenced, and none is invoked as a command.""" 

156 prompts = set(_names(root / PROMPTS_DIR)) 

157 referenced: set[str] = set() 

158 violations = [] 

159 

160 for path in _prose_files(root): 

161 text = path.read_text(encoding="utf-8") 

162 # A file referencing itself doesn't make it reachable. 

163 own = path.stem if path.parent.name == "prompts" else None 

164 referenced |= {name for name in _PROMPT_REF.findall(text) if name != own} 

165 violations += _skill_call_violations(root, path, text, prompts) 

166 

167 violations += [ 

168 f"prompts/{name}.md is never referenced — no command can reach it" 

169 for name in sorted(prompts - referenced) 

170 ] 

171 return violations 

172 

173 

174def _dead_layout_paths(root: Path, path: Path, text: str) -> list[str]: 

175 """Return the absent-directory references in one shipped prose file.""" 

176 exempt = {name for name, _ in _LAYOUT_EXEMPT.findall(text)} 

177 found = {name for prefix, name in _LAYOUT_PATH.findall(text) if prefix in _PLUGIN_PREFIXES} 

178 return [ 

179 f"{path.relative_to(root).as_posix()} refers to {name}/, which this plugin does not have" 

180 for name in sorted(found - exempt) 

181 if not (root / PLUGIN_DIR / name).is_dir() 

182 ] 

183 

184 

185def check_no_dead_layout_paths(root: Path) -> list[str]: 

186 """Rule 6: shipped prose never names a discovery location the plugin lacks. 

187 

188 A reference is only checked when it is unprefixed, or reached the way a skill 

189 actually reaches the plugin root — ``plugin/`` in a source checkout, 

190 ``${CLAUDE_PLUGIN_ROOT}/`` at runtime. ``docs/skills/`` is a site path and passes. 

191 

192 The escape hatch is an HTML comment naming the directory and giving a reason: 

193 ``<!-- rhiza-layout-exempt: commands/ the repo under assessment, not this plugin -->``. 

194 It is scoped to that directory in that file, and the reason is mandatory — a 

195 pragma without one does not match, so the violation stands. 

196 """ 

197 violations = [] 

198 for path in _shipped_prose(root): 

199 violations += _dead_layout_paths(root, path, path.read_text(encoding="utf-8")) 

200 return violations 

201 

202 

203def check_wiring(root: Path) -> list[str]: 

204 """Run every rule against *root*; return all violations.""" 

205 prompts_dir = root / PROMPTS_DIR 

206 return [ 

207 *check_declares_internal(prompts_dir), 

208 *check_no_command_frontmatter(prompts_dir), 

209 *check_no_name_collisions(root, prompts_dir), 

210 *check_references_resolve(root), 

211 *check_no_orphans_and_no_skill_calls(root), 

212 *check_no_dead_layout_paths(root), 

213 ] 

214 

215 

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

217 """Entry point: check the wiring and return an exit code.""" 

218 parser = argparse.ArgumentParser(description="Check the plugin's prompt wiring.") 

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

220 args = parser.parse_args(argv) 

221 

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

223 violations = check_wiring(root) 

224 if violations: 

225 print("Prompt-wiring check failed:", file=sys.stderr) 

226 for violation in violations: 

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

228 return 1 

229 

230 count = len(_names(root / PROMPTS_DIR)) 

231 print(f"prompt wiring is sound ({count} internal procedure(s))") 

232 return 0 

233 

234 

235if __name__ == "__main__": 

236 raise SystemExit(main())