#!/usr/bin/env python3 """Fails the build on a bare integer in prose in an .astro file. Why this exists: /standards/ tells the reader "Every figure is computed from the published dataset at build time, not typed into prose. This site has already been wrong once because a count was hardcoded; that route is now closed." Until this script existed, nothing closed it. The claim was true only for as long as whoever edited a page happened to remember it. The failure mode it guards against is not a typo. It is drift: a count typed into copy is correct on the day it is typed and silently wrong every day after, and the corpus has grown from 77 books to 210 during this project. A number in prose that tracks the dataset must come from the dataset. Scope is .astro only — page and component prose. Study and article bodies (.md) legitimately carry hundreds of page citations and per-book measurements; those are evidence, not site copy, and are out of scope. Not every integer in this range is a drifting count. A per-book measurement ("167 instances of Muslim"), a pipeline threshold ("under 200 characters") and a UI label ("90 days") are all stable facts that will not change when a book is added. Those are allowed, but only by an explicit entry in the allowlist with a reason — so the exception is a decision someone made and signed, not an accident. Usage: python3 scripts/check_numbers.py [--fix-report] Exit 1 on any unlisted bare integer in range. """ from __future__ import annotations import json import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parent.parent SRC = ROOT / "site" / "src" ALLOWLIST = ROOT / "analysis" / "number-allowlist.json" # The band the corpus counts live in. Below 70 is nearly all headings, years, # axis bands and ordinary small numbers; above 250 is page counts and print runs, # which are per-book facts rather than corpus totals. LOW, HIGH = 70, 250 # A bare integer: not glued to a word, not part of a decimal, thousands-separated # number, date, ratio, percentage or path. BARE_INT = re.compile(r"(? str: """Remove JSX expressions, innermost first, so nesting does not leave residue. Anything inside {} is computed at build time — that is precisely the correct way to put a number on the page, so it must not be flagged. """ prev = None while prev != s: prev = s s = re.sub(r"\{[^{}]*\}", " ", s) return s def prose_of(text: str) -> str: """Reduce an .astro file to its rendered prose.""" # Component frontmatter is code, not prose. if text.startswith("---"): end = text.find("\n---", 3) if end != -1: text = text[end + 4 :] text = re.sub(r"|", " ", text, flags=re.S) text = strip_braces(text) text = re.sub(r"", " ", text, flags=re.S) # Attributes are dropped with their tags, with one exception: the handful that carry # text a person reads. A count in a meta description is prose that happens to live in # an attribute — /database/ advertised "All 77 books" to search engines and social # cards while the dataset held 210, and stripping the tag whole is what hid it. prose_attrs = " ".join( m.group(2) for m in re.finditer(TEXT_ATTR, text) ) text = re.sub(r"<[^>]+>", " ", text) # tags and their remaining attributes return text + " " + prose_attrs def load_allowlist() -> dict[str, dict[str, str]]: if not ALLOWLIST.exists(): return {} raw = json.loads(ALLOWLIST.read_text(encoding="utf-8")) return raw.get("allow", raw) def main() -> int: allow = load_allowlist() violations = [] allowed_seen = 0 for path in sorted(SRC.rglob("*.astro")): rel = path.relative_to(ROOT).as_posix() prose = prose_of(path.read_text(encoding="utf-8")) for m in BARE_INT.finditer(prose): n = int(m.group(1)) if not (LOW <= n <= HIGH): continue if str(n) in allow.get(rel, {}): allowed_seen += 1 continue ctx = " ".join(prose[max(0, m.start() - 70) : m.end() + 50].split()) violations.append((rel, n, ctx)) if violations: print(f"check_numbers: {len(violations)} hardcoded number(s) in prose\n") for rel, n, ctx in violations: print(f" {rel}") print(f" {n} …{ctx}…\n") print( "Derive the figure from the dataset at build time — put it in an\n" "expression, e.g. {books.length}. If the number is genuinely stable\n" "(a per-book measurement, a pipeline threshold, a UI label), add it to\n" f"{ALLOWLIST.relative_to(ROOT)} with a reason." ) return 1 print( f"check_numbers: clean " f"({allowed_seen} allowlisted, range {LOW}-{HIGH}, .astro prose)" ) return 0 if __name__ == "__main__": sys.exit(main())