264 lines
8.6 KiB
Python
Executable File
264 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify that hardcoded counts in README.md match catalog.json totals.
|
|
|
|
Requires Python 3.10+. Stdlib only.
|
|
|
|
catalog.json is filesystem-truth (rebuilt by scripts/build_catalog.py and
|
|
checked in CI). The README, however, sprinkles hardcoded counts ("428
|
|
lessons", "373 skills, 99 prompts, ...") that drift every time the
|
|
curriculum grows or shrinks. This script pins each hardcoded count to a
|
|
field in catalog.json's `totals` block and fails when they disagree.
|
|
|
|
Usage:
|
|
python3 scripts/check_readme_counts.py # exit 1 on any drift
|
|
python3 scripts/check_readme_counts.py --json # machine-readable report
|
|
python3 scripts/check_readme_counts.py --fix # rewrite README to match catalog
|
|
|
|
The --fix flag is opt-in. CI runs the script without --fix and fails the
|
|
build on any mismatch, surfacing the drift in the workflow log.
|
|
|
|
Patterns are deliberately anchored to README context (badge URLs, alt
|
|
attributes, specific prose) so per-phase counts like `<code>22 lessons</code>`
|
|
in the Contents table are NOT touched. Each pattern declares its catalog
|
|
field and a short human description; mismatches are reported with line
|
|
numbers and surrounding text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CATALOG_PATH = ROOT / "catalog.json"
|
|
README_PATH = ROOT / "README.md"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CountPattern:
|
|
"""A single hardcoded count in README pinned to a catalog totals field."""
|
|
|
|
regex: re.Pattern[str]
|
|
field: str # totals.<field>
|
|
description: str
|
|
|
|
|
|
PATTERNS: tuple[CountPattern, ...] = (
|
|
CountPattern(
|
|
regex=re.compile(r"lessons-(\d+)-3553ff"),
|
|
field="lessons",
|
|
description="lesson-count badge URL",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r'alt="(\d+) lessons"'),
|
|
field="lessons",
|
|
description="lesson-count badge alt text",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"^> (\d+) lessons\. \d+ phases\.", re.MULTILINE),
|
|
field="lessons",
|
|
description="hero blockquote lesson count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"^> \d+ lessons\. (\d+) phases\.", re.MULTILINE),
|
|
field="phases",
|
|
description="hero blockquote phase count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"This curriculum is the spine\. (\d+) phases,"),
|
|
field="phases",
|
|
description="'spine' prose phase count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"This curriculum is the spine\. \d+ phases, (\d+) lessons,"),
|
|
field="lessons",
|
|
description="'spine' prose lesson count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"phases-(\d+)-3553ff"),
|
|
field="phases",
|
|
description="phase-count badge URL",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r'alt="(\d+) phases"'),
|
|
field="phases",
|
|
description="phase-count badge alt text",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"portfolio of (\d+) artifacts"),
|
|
field="lessons",
|
|
description="'portfolio of N artifacts' (one artifact per lesson)",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"The repo ships (\d+) skills"),
|
|
field="skills",
|
|
description="toolkit section skill count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"The repo ships \d+ skills and (\d+) prompts"),
|
|
field="prompts",
|
|
description="toolkit section prompt count",
|
|
),
|
|
CountPattern(
|
|
regex=re.compile(r"MIT-licensed, (\d+) lessons\."),
|
|
field="lessons",
|
|
description="sponsor section lesson count",
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Mismatch:
|
|
pattern: CountPattern
|
|
found: int
|
|
expected: int
|
|
line: int
|
|
snippet: str
|
|
|
|
|
|
def load_totals() -> dict[str, int]:
|
|
with CATALOG_PATH.open(encoding="utf-8") as fh:
|
|
catalog = json.load(fh)
|
|
totals = catalog.get("totals")
|
|
if not isinstance(totals, dict):
|
|
raise SystemExit("catalog.json is missing the 'totals' block")
|
|
return totals
|
|
|
|
|
|
def line_for(text: str, offset: int) -> int:
|
|
return text.count("\n", 0, offset) + 1
|
|
|
|
|
|
def snippet_for(text: str, offset: int, end: int) -> str:
|
|
line_start = text.rfind("\n", 0, offset) + 1
|
|
line_end = text.find("\n", end)
|
|
if line_end == -1:
|
|
line_end = len(text)
|
|
return text[line_start:line_end].strip()
|
|
|
|
|
|
def find_mismatches(readme_text: str, totals: dict[str, int]) -> list[Mismatch]:
|
|
mismatches: list[Mismatch] = []
|
|
for pattern in PATTERNS:
|
|
expected = totals.get(pattern.field)
|
|
if expected is None:
|
|
raise SystemExit(f"catalog.json totals is missing field: {pattern.field}")
|
|
matched_any = False
|
|
for match in pattern.regex.finditer(readme_text):
|
|
matched_any = True
|
|
found = int(match.group(1))
|
|
if found != expected:
|
|
mismatches.append(
|
|
Mismatch(
|
|
pattern=pattern,
|
|
found=found,
|
|
expected=expected,
|
|
line=line_for(readme_text, match.start()),
|
|
snippet=snippet_for(readme_text, match.start(), match.end()),
|
|
)
|
|
)
|
|
if not matched_any:
|
|
raise SystemExit(
|
|
f"pattern did not match README at all: {pattern.description} "
|
|
f"({pattern.regex.pattern!r}). The README structure has changed; "
|
|
f"update scripts/check_readme_counts.py."
|
|
)
|
|
return mismatches
|
|
|
|
|
|
def apply_fixes(readme_text: str, totals: dict[str, int]) -> str:
|
|
for pattern in PATTERNS:
|
|
expected = totals[pattern.field]
|
|
|
|
def replace(match: re.Match[str], expected: int = expected) -> str:
|
|
whole = match.group(0)
|
|
old = match.group(1)
|
|
start = match.start(1) - match.start()
|
|
return whole[:start] + str(expected) + whole[start + len(old):]
|
|
|
|
readme_text = pattern.regex.sub(replace, readme_text)
|
|
return readme_text
|
|
|
|
|
|
def render_text_report(mismatches: list[Mismatch]) -> str:
|
|
if not mismatches:
|
|
return "README.md counts match catalog.json totals.\n"
|
|
out = [f"README.md drift detected: {len(mismatches)} mismatch(es).\n"]
|
|
for m in mismatches:
|
|
out.append(
|
|
f" README.md:{m.line} {m.pattern.description}\n"
|
|
f" expected totals.{m.pattern.field} = {m.expected}, found {m.found}\n"
|
|
f" >>> {m.snippet}\n"
|
|
)
|
|
out.append(
|
|
"\nRun `python3 scripts/check_readme_counts.py --fix` to update README.md.\n"
|
|
)
|
|
return "".join(out)
|
|
|
|
|
|
def render_json_report(mismatches: list[Mismatch], totals: dict[str, int]) -> str:
|
|
payload = {
|
|
"ok": not mismatches,
|
|
"totals": totals,
|
|
"mismatches": [
|
|
{
|
|
"line": m.line,
|
|
"field": m.pattern.field,
|
|
"description": m.pattern.description,
|
|
"expected": m.expected,
|
|
"found": m.found,
|
|
"snippet": m.snippet,
|
|
}
|
|
for m in mismatches
|
|
],
|
|
}
|
|
return json.dumps(payload, indent=2) + "\n"
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--json", action="store_true", help="emit JSON report on stdout")
|
|
parser.add_argument(
|
|
"--fix",
|
|
action="store_true",
|
|
help="rewrite README.md so hardcoded counts match catalog.json",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
totals = load_totals()
|
|
readme_text = README_PATH.read_text(encoding="utf-8")
|
|
|
|
if args.fix:
|
|
initial_mismatches = find_mismatches(readme_text, totals)
|
|
if not initial_mismatches:
|
|
if args.json:
|
|
sys.stdout.write(render_json_report([], totals))
|
|
else:
|
|
sys.stdout.write("README.md already matches catalog.json totals.\n")
|
|
return 0
|
|
new_text = apply_fixes(readme_text, totals)
|
|
README_PATH.write_text(new_text, encoding="utf-8")
|
|
remaining = find_mismatches(new_text, totals)
|
|
if args.json:
|
|
sys.stdout.write(render_json_report(remaining, totals))
|
|
else:
|
|
sys.stdout.write("README.md updated to match catalog.json totals.\n")
|
|
if remaining:
|
|
sys.stdout.write(render_text_report(remaining))
|
|
return 1 if remaining else 0
|
|
|
|
mismatches = find_mismatches(readme_text, totals)
|
|
if args.json:
|
|
sys.stdout.write(render_json_report(mismatches, totals))
|
|
else:
|
|
sys.stdout.write(render_text_report(mismatches))
|
|
return 1 if mismatches else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|