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
« 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.
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.
10It enforces six rules:
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.
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.
32Usage:
33 uv run --python 3.12 --no-project python \
34 scripts/check_prompt_wiring.py [--root DIR]
36Exits 0 when the wiring is sound, 1 (listing every violation) otherwise.
37"""
39from __future__ import annotations
41import argparse
42import re
43import sys
44from pathlib import Path
46from _rhiza_layout import PLUGIN_DIR, PROMPTS_DIR, command_files
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")
53_DISCOVERY_DIRS = ("agents", "bin", "commands", "hooks", "monitors", "skills")
54"""The directory names Claude Code scans at a plugin root.
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"""
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*-->")
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"))
75def _shipped_prose(root: Path) -> list[Path]:
76 """Return every markdown file the plugin ships — the text a model reads at runtime.
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")])
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 )
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 ]
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
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.
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]
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
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 ]
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 = []
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)
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
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 ]
185def check_no_dead_layout_paths(root: Path) -> list[str]:
186 """Rule 6: shipped prose never names a discovery location the plugin lacks.
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.
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
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 ]
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)
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
230 count = len(_names(root / PROMPTS_DIR))
231 print(f"prompt wiring is sound ({count} internal procedure(s))")
232 return 0
235if __name__ == "__main__":
236 raise SystemExit(main())