Skip to content

Commit 579410a

Browse files
TMHSDigitalclaude
andcommitted
ci: validate-counts checks every stated copy of every count, incl. CLAUDE.md
The old check passed on `f"{n} examples" in readme`: one correct copy among README's three satisfied it, "164 examples" satisfied "64", and CLAUDE.md (the file agents read first) and docs/new-example-prompt.md were never scanned. tests/check_counts.py replaces the inline README checks. It derives skills, rules, templates, snippets, examples, showcase pieces and gallery-rendered examples from the tree and fails on any stated number that disagrees, in README.md, CLAUDE.md, AGENTS.md, CONTRIBUTING.md and docs/new-example-prompt.md: - prose "16 skills" / "76 showcase pieces" (word-bounded) - CLAUDE.md "## Skills (16)" headings and "... 16 total" architecture lines - "56 of the 64 ship a render" (gallery count and example count) README must still state every count at least once; the 5-75 line snippet cap moved over too. Exclusions are explicit: README per-category <details> summaries, and the phrase "404 templates". CLAUDE.md: "all 89 gallery pages" (now ~133) reworded to "every gallery page". Falsified: README:44 "63 examples" plus CLAUDE.md "## Snippets (28)" -> both reported, exit 1; restored, exit 0. Closes #323 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 422c0e5 commit 579410a

3 files changed

Lines changed: 157 additions & 38 deletions

File tree

‎.github/workflows/validate.yml‎

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ jobs:
280280
steps:
281281
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
282282

283-
- name: Check content counts match README
283+
- name: Check content counts match the repo
284284
env:
285285
GH_TOKEN: ${{ github.token }}
286286
run: |
@@ -320,44 +320,14 @@ jobs:
320320
and os.path.exists(os.path.join('showcase', d, 'README.md'))
321321
])
322322
323-
readme = open('README.md').read()
324-
if f'{skill_count} skills' not in readme:
325-
errors.append(f'README skill count mismatch (expected "{skill_count} skills" substring)')
326-
if f'{rule_count} rules' not in readme:
327-
errors.append(f'README rule count mismatch (expected "{rule_count} rules" substring)')
328-
template_word = 'template' if template_count == 1 else 'templates'
329-
if f'{template_count} {template_word}' not in readme:
330-
errors.append(f'README template count mismatch (expected "{template_count} {template_word}" substring)')
331-
if f'{snippet_count} snippets' not in readme:
332-
errors.append(f'README snippet count mismatch (expected "{snippet_count} snippets" substring)')
333-
if f'{example_count} examples' not in readme:
334-
errors.append(f'README example count mismatch (expected "{example_count} examples" substring)')
335-
showcase_word = 'piece' if showcase_count == 1 else 'pieces'
336-
showcase_needle = f'{showcase_count} showcase {showcase_word}'
337-
if showcase_needle not in readme:
338-
errors.append(
339-
f'README showcase count mismatch (expected "{showcase_needle}" substring)'
340-
)
341-
342-
# Documented snippet length cap (README, CLAUDE.md, CONTRIBUTING.md).
343-
for f in sorted(os.listdir('snippets')):
344-
if f.endswith('.py'):
345-
n = len(open(os.path.join('snippets', f), encoding='utf-8').read().splitlines())
346-
if not 5 <= n <= 75:
347-
errors.append(f'snippets/{f} is {n} lines (documented range is 5 to 75)')
348-
349-
# Secondary docs must not carry stale aggregate counts.
350-
import re
351-
for doc in ('AGENTS.md', 'CONTRIBUTING.md'):
352-
text = open(doc, encoding='utf-8').read()
353-
for kind, actual in (('examples', example_count), ('showcase pieces', showcase_count)):
354-
for m in re.finditer(r'(\d+) ' + kind, text):
355-
if int(m.group(1)) != actual:
356-
errors.append(f'{doc} says "{m.group(0)}" but the repo has {actual}')
323+
# Every stated copy of every count, across README, CLAUDE.md, AGENTS.md,
324+
# CONTRIBUTING.md and docs/new-example-prompt.md, plus the snippet cap.
325+
import subprocess
326+
if subprocess.run([sys.executable, 'tests/check_counts.py']).returncode:
327+
errors.append('tests/check_counts.py found stale counts (see above)')
357328
358329
# The GitHub About text drifts silently otherwise. Warn only: it is
359330
# repo metadata edited by hand and must not block content PRs.
360-
import subprocess
361331
try:
362332
about = subprocess.run(
363333
['gh', 'repo', 'view', '--json', 'description', '--jq', '.description'],
@@ -374,7 +344,7 @@ jobs:
374344
print(f'::error::{e}', file=sys.stderr)
375345
sys.exit(1)
376346
377-
print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} {template_word}, {snippet_count} snippets, {example_count} examples, {showcase_needle}')
347+
print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} templates, {snippet_count} snippets, {example_count} examples, {showcase_count} showcase pieces')
378348
PYEOF
379349
380350
validate-harness:

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ touching `examples/`; the landing page builds from `scripts/site/` at deploy tim
195195
- **One palette:** `scripts/site/tokens.css` is the single `:root` for every published
196196
page — the landing and 404 templates `{% include %}` it and `build_gallery.py` inlines
197197
it. Change colors or type there, never in a hand-copied `:root`; a token edit changes
198-
all 89 gallery pages, so regenerate and commit `docs/gallery/`.
198+
every gallery page, so regenerate and commit `docs/gallery/`.
199199
- **Link gate:** after building both (`python scripts/site/build_site.py --repo-root . --out docs`
200200
then `python scripts/build_gallery.py`), run `python tests/check_site_links.py`. It resolves
201201
every internal `href`/`src`/`#anchor` across all pages and requires `alt` on every `<img>`;

‎tests/check_counts.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""Inventory counts in the docs must match the repo, every copy of them.
3+
4+
Counts skills, rules, templates, snippets, examples, showcase pieces and
5+
gallery-rendered examples from the tree, then scans every user- or
6+
agent-facing doc that states them (DOCS below) for each form they appear in:
7+
8+
"16 skills", "76 showcase pieces" prose and badge lines
9+
"## Skills (16)" CLAUDE.md section headings
10+
"skills/<skill-name>/... 16 total" CLAUDE.md architecture tree
11+
"56 of the 64 ship a render" gallery-rendered vs all examples
12+
13+
Any stated number that disagrees is an error; the old substring check passed
14+
as long as one copy anywhere was right. README must also state every count at
15+
least once. Also enforces the documented 5-75 line snippet cap.
16+
17+
Run: python tests/check_counts.py (exit 0 ok, 1 on mismatch)
18+
"""
19+
from __future__ import annotations
20+
21+
import json
22+
import re
23+
import sys
24+
from pathlib import Path
25+
26+
ROOT = Path(__file__).resolve().parent.parent
27+
28+
DOCS = (
29+
"README.md",
30+
"CLAUDE.md",
31+
"AGENTS.md",
32+
"CONTRIBUTING.md",
33+
"docs/new-example-prompt.md",
34+
)
35+
36+
# Phrases that match the count pattern but are not inventory counts.
37+
NOT_COUNTS = {
38+
"404 templates", # CLAUDE.md: "the landing and 404 templates"
39+
}
40+
41+
# README per-category <details> summaries ("— 7 examples") count a category,
42+
# not the whole set.
43+
CATEGORY_SUMMARY = re.compile(r"^<summary><strong>[^<]+</strong> — \d+ examples?</summary>$")
44+
45+
PROSE = re.compile(
46+
r"\b(\d+) (skills?|rules?|templates?|snippets?|examples?|showcase pieces?)\b"
47+
)
48+
HEADING = re.compile(r"^#+ (Skills|Rules|Templates|Snippets|Examples) \((\d+)\)\s*$")
49+
TREE = re.compile(r"^(skills|rules|templates|snippets|examples)/\S*\s+- .*?(\d+) total")
50+
GALLERY = re.compile(r"\b(\d+) of the (\d+) ship a render")
51+
52+
KIND = {
53+
"skill": "skills", "rule": "rules", "template": "templates",
54+
"snippet": "snippets", "example": "examples", "showcase piece": "showcase",
55+
}
56+
57+
58+
def actual_counts(root: Path) -> dict[str, int]:
59+
def dirs_with(parent: str, marker: str | None) -> int:
60+
p = root / parent
61+
if not p.is_dir():
62+
return 0
63+
return sum(
64+
1 for d in p.iterdir()
65+
if d.is_dir() and (marker is None or (d / marker).exists())
66+
)
67+
68+
gallery = json.loads((root / "examples" / "gallery.json").read_text(encoding="utf-8"))
69+
entries = gallery["examples"] if isinstance(gallery, dict) else gallery
70+
return {
71+
"skills": dirs_with("skills", "SKILL.md"),
72+
"rules": len(list((root / "rules").glob("*.mdc"))),
73+
"templates": dirs_with("templates", None),
74+
"snippets": len(list((root / "snippets").glob("*.py"))),
75+
"examples": dirs_with("examples", "README.md"),
76+
"showcase": dirs_with("showcase", "README.md"),
77+
"gallery": len(entries),
78+
}
79+
80+
81+
def kind_of(word: str) -> str:
82+
w = word.lower()
83+
return KIND[w[:-1] if w.endswith("s") else w]
84+
85+
86+
def check(root: Path) -> list[str]:
87+
counts = actual_counts(root)
88+
errors: list[str] = []
89+
90+
def expect(doc: str, line_no: int, kind: str, stated: int, phrase: str) -> None:
91+
if stated != counts[kind]:
92+
errors.append(f'{doc}:{line_no} says "{phrase}" but the repo has {counts[kind]}')
93+
94+
for doc in DOCS:
95+
path = root / doc
96+
if not path.exists():
97+
continue
98+
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
99+
if CATEGORY_SUMMARY.match(line.strip()):
100+
continue
101+
for m in PROSE.finditer(line):
102+
if m.group(0) in NOT_COUNTS:
103+
continue
104+
expect(doc, i, kind_of(m.group(2)), int(m.group(1)), m.group(0))
105+
m = HEADING.match(line)
106+
if m:
107+
expect(doc, i, kind_of(m.group(1)), int(m.group(2)), m.group(0).strip())
108+
m = TREE.match(line)
109+
if m:
110+
expect(doc, i, m.group(1), int(m.group(2)), m.group(0).strip())
111+
for m in GALLERY.finditer(line):
112+
expect(doc, i, "gallery", int(m.group(1)), m.group(0))
113+
expect(doc, i, "examples", int(m.group(2)), m.group(0))
114+
115+
readme = (root / "README.md").read_text(encoding="utf-8")
116+
plural = {"templates": "template", "showcase": "showcase piece"}
117+
for kind in ("skills", "rules", "templates", "snippets", "examples", "showcase"):
118+
n = counts[kind]
119+
word = {"showcase": "showcase pieces"}.get(kind, kind)
120+
if n == 1:
121+
word = plural.get(kind, kind[:-1])
122+
if not re.search(rf"\b{n} {word}\b", readme):
123+
errors.append(f'README.md never states "{n} {word}"')
124+
125+
for f in sorted((root / "snippets").glob("*.py")):
126+
n = len(f.read_text(encoding="utf-8").splitlines())
127+
if not 5 <= n <= 75:
128+
errors.append(f"snippets/{f.name} is {n} lines (documented range is 5 to 75)")
129+
130+
return errors
131+
132+
133+
def main() -> int:
134+
errors = check(ROOT)
135+
if errors:
136+
for e in errors:
137+
print(f"::error::{e}", file=sys.stderr)
138+
return 1
139+
c = actual_counts(ROOT)
140+
print(
141+
f"Counts verified in {len(DOCS)} docs: {c['skills']} skills, {c['rules']} rules, "
142+
f"{c['templates']} templates, {c['snippets']} snippets, {c['examples']} examples "
143+
f"({c['gallery']} rendered), {c['showcase']} showcase pieces"
144+
)
145+
return 0
146+
147+
148+
if __name__ == "__main__":
149+
sys.exit(main())

0 commit comments

Comments
 (0)