Coverage for scripts/check_prompt_wiring.py: 100%
67 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
1#!/usr/bin/env python3
2"""Check that the plugin's internal procedures under ``prompts/`` stay wired up.
4``commands/*.md`` are slash commands the user invokes. ``prompts/*.md`` are
5**internal procedures** a command reaches with the ``Read`` tool — deliberately
6outside ``commands/`` so they cannot be invoked directly. Nothing at runtime
7verifies that arrangement, so a rename or a stray file would only surface as a
8command failing mid-run, in front of a user. This is that check.
10It enforces five 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 ``commands/``;
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.
23Usage:
24 uv run --python 3.12 --no-project python \
25 scripts/check_prompt_wiring.py [--root DIR]
27Exits 0 when the wiring is sound, 1 (listing every violation) otherwise.
28"""
30from __future__ import annotations
32import argparse
33import re
34import sys
35from pathlib import Path
37_NOT_A_COMMAND = "Not a slash command"
38_FRONTMATTER_KEYS = ("allowed-tools:", "argument-hint:")
39_PROMPT_REF = re.compile(r"prompts/([a-zA-Z0-9_-]+)\.md")
40_SKILL_INVOCATION = re.compile(r"`([a-zA-Z0-9_-]+)` command via the Skill tool")
43def _names(directory: Path) -> list[str]:
44 """Return the sorted stems of the markdown files directly in *directory*."""
45 if not directory.is_dir():
46 return []
47 return sorted(p.stem for p in directory.glob("*.md"))
50def _prose_files(root: Path) -> list[Path]:
51 """Return every markdown file whose prompt references should resolve."""
52 return sorted(
53 [
54 *(root / "commands").glob("*.md"),
55 *(root / "prompts").glob("*.md"),
56 *root.glob("*.md"),
57 ]
58 )
61def check_declares_internal(prompts_dir: Path) -> list[str]:
62 """Rule 1: each procedure states that it is not a slash command."""
63 return [
64 f"prompts/{name}.md does not say {_NOT_A_COMMAND!r}"
65 for name in _names(prompts_dir)
66 if _NOT_A_COMMAND not in (prompts_dir / f"{name}.md").read_text()
67 ]
70def check_no_command_frontmatter(prompts_dir: Path) -> list[str]:
71 """Rule 2: procedures carry no command frontmatter."""
72 violations = []
73 for name in _names(prompts_dir):
74 text = (prompts_dir / f"{name}.md").read_text()
75 if text.startswith("---"):
76 violations.append(f"prompts/{name}.md opens with command frontmatter")
77 violations += [
78 f"prompts/{name}.md declares {key!r}, which only applies to commands"
79 for key in _FRONTMATTER_KEYS
80 if key in text
81 ]
82 return violations
85def check_no_name_collisions(commands_dir: Path, prompts_dir: Path) -> list[str]:
86 """Rule 3: a name is either a command or a procedure, never both."""
87 both = sorted(set(_names(commands_dir)) & set(_names(prompts_dir)))
88 return [f"{name!r} exists as both a command and a procedure" for name in both]
91def check_references_resolve(root: Path) -> list[str]:
92 """Rule 4: every ``prompts/<name>.md`` mentioned in prose exists."""
93 violations = []
94 for path in _prose_files(root):
95 for name in sorted(set(_PROMPT_REF.findall(path.read_text()))):
96 if not (root / "prompts" / f"{name}.md").is_file():
97 rel = path.relative_to(root)
98 violations.append(f"{rel} references missing prompts/{name}.md")
99 return violations
102def check_no_orphans_and_no_skill_calls(root: Path) -> list[str]:
103 """Rule 5: every procedure is referenced, and none is invoked as a command."""
104 prompts = set(_names(root / "prompts"))
105 referenced: set[str] = set()
106 violations = []
108 for path in _prose_files(root):
109 text = path.read_text()
110 # A file referencing itself doesn't make it reachable.
111 own = path.stem if path.parent.name == "prompts" else None
112 referenced |= {name for name in _PROMPT_REF.findall(text) if name != own}
113 for name in _SKILL_INVOCATION.findall(text):
114 if name in prompts:
115 rel = path.relative_to(root)
116 violations.append(
117 f"{rel} invokes {name!r} via the Skill tool, but it is a procedure, "
118 "not a command — reach it with Read"
119 )
121 violations += [
122 f"prompts/{name}.md is never referenced — no command can reach it"
123 for name in sorted(prompts - referenced)
124 ]
125 return violations
128def check_wiring(root: Path) -> list[str]:
129 """Run every rule against *root*; return all violations."""
130 commands_dir, prompts_dir = root / "commands", root / "prompts"
131 return [
132 *check_declares_internal(prompts_dir),
133 *check_no_command_frontmatter(prompts_dir),
134 *check_no_name_collisions(commands_dir, prompts_dir),
135 *check_references_resolve(root),
136 *check_no_orphans_and_no_skill_calls(root),
137 ]
140def main(argv: list[str] | None = None) -> int:
141 """Entry point: check the wiring and return an exit code."""
142 parser = argparse.ArgumentParser(description="Check the plugin's prompt wiring.")
143 parser.add_argument("--root", default=".", help="Plugin root (default: current directory).")
144 args = parser.parse_args(argv)
146 root = Path(args.root).resolve()
147 violations = check_wiring(root)
148 if violations:
149 print("Prompt-wiring check failed:", file=sys.stderr)
150 for violation in violations:
151 print(f" ✗ {violation}", file=sys.stderr)
152 return 1
154 count = len(_names(root / "prompts"))
155 print(f"prompt wiring is sound ({count} internal procedure(s))")
156 return 0
159if __name__ == "__main__":
160 raise SystemExit(main())