|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +check_doc_counts.py — verify skill/command counts in docs match what's on disk. |
| 4 | +
|
| 5 | +The bundle's skill/command counts have grown several times (skills: 51 -> 71 -> |
| 6 | +82; hunt-* skills: 24 -> 48 -> 57) and each time, at least one doc's hardcoded |
| 7 | +number didn't get updated along with the rest (see the 71/48/24 stale-count |
| 8 | +fixes). This script computes the real counts from skills/ and commands/ on |
| 9 | +disk, then checks every doc location that asserts one of those numbers in |
| 10 | +prose, plus two places where a count is derived by summing a table/section |
| 11 | +breakdown. |
| 12 | +
|
| 13 | +It deliberately does NOT try to reconcile every document's bespoke |
| 14 | +sub-categorization (e.g. docs/architecture.md's narrower "enterprise-platform" |
| 15 | +grouping vs. docs/skills.md's generated one) — only the three ground-truth |
| 16 | +totals: all skills, hunt-* skills, and slash commands. |
| 17 | +
|
| 18 | +If a doc's wording changes, update CHECKS to match — a "pattern not found" |
| 19 | +error is a prompt to update this script, not necessarily a doc bug. |
| 20 | +
|
| 21 | +Exit code 0 = all counts match, 1 = at least one mismatch. |
| 22 | +Stdlib only — no pip install needed in CI. |
| 23 | +
|
| 24 | +Usage: |
| 25 | + python3 scripts/check_doc_counts.py |
| 26 | +""" |
| 27 | +import os |
| 28 | +import re |
| 29 | +import sys |
| 30 | + |
| 31 | +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 32 | +SKILLS_DIR = os.path.join(REPO, "skills") |
| 33 | +COMMANDS_DIR = os.path.join(REPO, "commands") |
| 34 | + |
| 35 | + |
| 36 | +def count_skills(): |
| 37 | + total = 0 |
| 38 | + hunt = 0 |
| 39 | + for d in sorted(os.listdir(SKILLS_DIR)): |
| 40 | + if os.path.isfile(os.path.join(SKILLS_DIR, d, "SKILL.md")): |
| 41 | + total += 1 |
| 42 | + if d.startswith("hunt-"): |
| 43 | + hunt += 1 |
| 44 | + return total, hunt |
| 45 | + |
| 46 | + |
| 47 | +def count_commands(): |
| 48 | + return len([f for f in os.listdir(COMMANDS_DIR) if f.endswith(".md")]) |
| 49 | + |
| 50 | + |
| 51 | +def read(path): |
| 52 | + with open(os.path.join(REPO, path), encoding="utf-8") as fh: |
| 53 | + return fh.read() |
| 54 | + |
| 55 | + |
| 56 | +# (file, regex with one capture group per checked number, key or tuple of keys |
| 57 | +# into the `actual` dict, human label for error messages) |
| 58 | +CHECKS = [ |
| 59 | + ("README.md", r"\*\*(\d+) skills\*\* · 15 slash commands", "total", "hero line skill count"), |
| 60 | + ("README.md", r"skills\*\* · (\d+) slash commands ·", "commands", "hero line command count"), |
| 61 | + ("README.md", r"\| (\d+) skills \+ (\d+) slash commands \|", ("total", "commands"), "install-path comparison table"), |
| 62 | + ("README.md", r"\*\*(\d+) skills\*\*, auto-loaded", "total", "'What's inside' intro"), |
| 63 | + ("README.md", r"(\d+) `hunt-\*` skills curated from", "hunt", "'Hunt webapps' bullet"), |
| 64 | + ("README.md", r"Also ships \*\*(\d+) slash commands\*\*", "commands", "documentation table footer"), |
| 65 | + ("README.md", r"\(8 of (\d+) skills \+ (\d+) slash commands\)", ("total", "commands"), "vendored-foundation credit"), |
| 66 | + ("SECURITY.md", r"installing (\d+) `SKILL\.md` files", "total", "supply-chain-trust intro"), |
| 67 | + ("USAGE.md", r"the (\d+)-skill Claude-BugHunter bundle", "total", "doc intro"), |
| 68 | + ("USAGE.md", r"copies (\d+) skills \+ (\d+) commands into Claude Code", ("total", "commands"), "quickstart code block"), |
| 69 | + ("USAGE.md", r"(\d+) `hunt-\*` skills \+ \d+ enterprise-platform skills", "hunt", "phase-3 architecture table row"), |
| 70 | + ("USAGE.md", r"### Hunt — (\d+) per-class web skills", "hunt", "skill-inventory section header"), |
| 71 | + ("USAGE.md", r"installs all (\d+) skills, (\d+) commands", ("total", "commands"), "setup-for-someone-new summary"), |
| 72 | + ("INSTALL.md", r"All (\d+) skills →", "total", "what-gets-installed list"), |
| 73 | + ("docs/skills.md", r"All \*\*(\d+) skills\*\* in the bundle", "total", "catalog intro"), |
| 74 | + ("docs/skills.md", r"## Hunt — web app vuln classes \((\d+)\)", "hunt", "catalog Hunt section header"), |
| 75 | + ("docs/credits.md", r"\| \*\*Total\*\* \| (\d+) skills \+ (\d+) commands \|", ("total", "commands"), "credits breakdown total row"), |
| 76 | + ("docs/architecture.md", r"(\d+) skills mapped to 6 phases", "total", "doc intro"), |
| 77 | + ("docs/architecture.md", r"a (\d+)-skill `hunt-\*` sub-stack", "hunt", "doc intro"), |
| 78 | + ("docs/architecture.md", r"Of (\d+) skills: \d+ original", "total", "source-breakdown sentence"), |
| 79 | + ("docs/architecture.md", r"\*\*(\d+) `hunt-\*` skills\*\* \| original \+ community", "hunt", "phase-3 detail table"), |
| 80 | +] |
| 81 | + |
| 82 | + |
| 83 | +def check_assertions(actual): |
| 84 | + errors = [] |
| 85 | + for file, pattern, key, label in CHECKS: |
| 86 | + text = read(file) |
| 87 | + m = re.search(pattern, text) |
| 88 | + if not m: |
| 89 | + errors.append( |
| 90 | + f"{file}: pattern not found for '{label}' ({pattern}) — " |
| 91 | + f"doc wording changed, update scripts/check_doc_counts.py" |
| 92 | + ) |
| 93 | + continue |
| 94 | + keys = key if isinstance(key, tuple) else (key,) |
| 95 | + for i, k in enumerate(keys): |
| 96 | + got = int(m.group(i + 1)) |
| 97 | + want = actual[k] |
| 98 | + if got != want: |
| 99 | + errors.append(f"{file}: '{label}' says {got} but actual {k} count is {want}") |
| 100 | + return errors |
| 101 | + |
| 102 | + |
| 103 | +def check_readme_table_sum(errors, actual): |
| 104 | + """README's 'What's inside' category table must sum to the total skill count.""" |
| 105 | + text = read("README.md") |
| 106 | + m = re.search(r"\| Category \| # \| Examples \|\n\|[-| ]+\n((?:\|.*\n)+)", text) |
| 107 | + if not m: |
| 108 | + errors.append("README.md: could not locate 'What's inside' category table to sum") |
| 109 | + return |
| 110 | + total = 0 |
| 111 | + for row in m.group(1).strip("\n").split("\n"): |
| 112 | + cells = [c.strip() for c in row.strip("|").split("|")] |
| 113 | + if len(cells) >= 2 and cells[1].isdigit(): |
| 114 | + total += int(cells[1]) |
| 115 | + if total != actual["total"]: |
| 116 | + errors.append(f"README.md: 'What's inside' category table sums to {total}, actual total is {actual['total']}") |
| 117 | + |
| 118 | + |
| 119 | +def check_catalog_section_sum(errors, actual): |
| 120 | + """docs/skills.md's generated section headers ('## Name (N)') must sum to the total.""" |
| 121 | + text = read("docs/skills.md") |
| 122 | + counts = [int(n) for n in re.findall(r"^## .+\((\d+)\)\s*$", text, re.MULTILINE)] |
| 123 | + total = sum(counts) |
| 124 | + if total != actual["total"]: |
| 125 | + errors.append(f"docs/skills.md: section headers sum to {total}, actual total is {actual['total']}") |
| 126 | + |
| 127 | + |
| 128 | +def main(): |
| 129 | + total, hunt = count_skills() |
| 130 | + commands = count_commands() |
| 131 | + actual = {"total": total, "hunt": hunt, "commands": commands} |
| 132 | + |
| 133 | + errors = check_assertions(actual) |
| 134 | + check_readme_table_sum(errors, actual) |
| 135 | + check_catalog_section_sum(errors, actual) |
| 136 | + |
| 137 | + for e in errors: |
| 138 | + print(f"::error:: {e}" if os.environ.get("GITHUB_ACTIONS") else f"ERROR {e}") |
| 139 | + |
| 140 | + print(f"\nGround truth: {total} skills ({hunt} hunt-*), {commands} slash commands.") |
| 141 | + print(f"Checked {len(CHECKS)} doc assertions + 2 structural sums: {len(errors)} error(s).") |
| 142 | + return 1 if errors else 0 |
| 143 | + |
| 144 | + |
| 145 | +if __name__ == "__main__": |
| 146 | + sys.exit(main()) |
0 commit comments