Artifact b0afb4790893526ff011e5310d9ad253db308e60c1cf07465d7dee593ae394c6:
- Executable file
scripts/check-citations.py
— part of check-in
[0f80982412]
at
2026-08-05 19:41:15
on branch sub-manifest
— citations: add a containment checker and pin it in CI
Most `§X.Y Lnnn` citations no longer point at the text they name. Line numbers rot each time a draft is re-rendered for an erratum, and a citation resolving to the wrong paragraph is worse than none: it looks authoritative.
scripts/check-citations.py parses section spans out of each draft and asserts every cited range falls inside the section it names. It reproduces the survey in #18 -- 65 sections-not-found exactly, 674 vs 667 checked, the delta being citations a wider regex catches.
It splits on "\n" rather than using splitlines(), because drafts carry form-feed page separators that splitlines() also breaks on, drifting every line number further the deeper into the draft it sits. That bug makes correct citations look broken, and it is why this is a script with a reproduced baseline rather than a one-off measurement.
CI pins the count at 512 and fails in BOTH directions, so a new broken citation and a fixed-but-unlowered baseline are each caught. Re-keying the backlog stays separate work; this stops it rotting further.
Also drops parseGimmeCard's assert restating the check above it. parseIgotCard's remaining assert tests a different property and stays.
test 3495/3495, fmt. Issue #18. (user: dmestas, size: 7071) [annotate] [blame] [check-ins using]
#!/usr/bin/env python3 """Verify that every `§X.Y Lnnn` citation under src/ lands inside the section it names. The codebase cites the drafts by section AND line range. Line numbers rot every time a draft is re-rendered to apply an erratum, and a citation that resolves to the wrong paragraph is worse than no citation: it looks authoritative, so the next person checking a normative claim against it is told the wrong thing. This does not check that a citation supports the claim beside it -- no tool can. It checks the mechanical half: that the cited lines fall within the cited section. That is enough to catch rot, and it is the half that can be enforced. Exit status: 0 when every resolved citation is contained, 1 otherwise. `--baseline N` fails instead when the broken count differs from N, which is how this runs in CI while a known backlog is still being worked down. """ import argparse import pathlib import re import sys # A draft-name token that may prefix a citation, mapped to its file. Anything # else in that position (`the §4`, `per §7`) is English, not a draft name. DRAFTS = { "sync": "draft-fossil-sync-protocol-00.txt", "artifact": "draft-fossil-artifact-format-00.txt", "artifact-format": "draft-fossil-artifact-format-00.txt", "submanifests": "draft-fossil-submanifests-00.txt", "repostate": "draft-fossil-repo-state-00.txt", "repo-state": "draft-fossil-repo-state-00.txt", "delta": "draft-fossil-delta-format-00.txt", } # Which draft a citation with no draft name means. Only files whose subject is # unambiguous get an entry; elsewhere a bare citation is checked against every # draft and counted broken only when no draft can contain it, so ambiguity # never manufactures a failure. BARE_DEFAULT = { "sync.zig": "draft-fossil-sync-protocol-00.txt", "sync_test.zig": "draft-fossil-sync-protocol-00.txt", "cmd/sync.zig": "draft-fossil-sync-protocol-00.txt", } # `4.7.16. Card Order` -- number, full stop, two spaces, title, at column 0. SECTION_RE = re.compile(r"^(\d+(?:\.\d+)*)\.\s\s+\S") # `submanifests §6.2 L677-690`, `§3.3 L527`. The draft name is optional and the # end of the range is optional. CITE_RE = re.compile(r"(?:([A-Za-z][A-Za-z-]*)\s+)?§(\d+(?:\.\d+)*)\s+L(\d+)(?:-(\d+))?") def section_spans(path): """Map section number -> (first line, last line), 1-based and inclusive. A section runs to the next header that is not one of its own descendants, so a parent's span contains its subsections -- which is what makes a citation of `§10` against text inside `§10.1` resolve correctly. """ # split("\n"), NOT splitlines(): an RFC draft carries FORM FEED page # separators, and splitlines() breaks on \f too, inventing lines that do # not exist. Every line number downstream would then drift further the # deeper into the draft it sat -- silently, and in the direction that makes # correct citations look broken. lines = path.read_text(encoding="utf-8", errors="replace").split("\n") heads = [ (m.group(1), i + 1) for i, line in enumerate(lines) if (m := SECTION_RE.match(line)) ] spans = {} for idx, (num, start) in enumerate(heads): end = len(lines) for later_num, later_start in heads[idx + 1 :]: if not later_num.startswith(num + "."): end = later_start - 1 break spans[num] = (start, end) return spans def main(): ap = argparse.ArgumentParser() ap.add_argument("--root", default=".", type=pathlib.Path) ap.add_argument("--only", default=None, help="substring filter on source paths") ap.add_argument("--baseline", type=int, default=None, help="expected broken count; nonzero exit when it differs") ap.add_argument("--list", action="store_true", help="print every broken citation") args = ap.parse_args() rfcs = args.root / "docs" / "rfcs" spans = {name: section_spans(rfcs / name) for name in set(DRAFTS.values())} ok = outside = missing = 0 broken = [] for src in sorted((args.root / "src").rglob("*.zig")): rel = str(src.relative_to(args.root / "src")) if args.only and args.only not in rel: continue default = BARE_DEFAULT.get(rel) for lineno, text in enumerate(src.read_text(encoding="utf-8", errors="replace").split("\n"), 1): for m in CITE_RE.finditer(text): word, sec, lo_s, hi_s = m.groups() lo = int(lo_s) hi = int(hi_s) if hi_s else lo named = DRAFTS.get((word or "").lower()) # An unnamed citation is given every draft rather than a guess: # containment anywhere clears it, so only a range no draft can # hold is reported. candidates = [named] if named else ([default] if default else list(spans)) fits = False # Remember a draft that HAS the section, so a miss can report # the real range rather than dereferencing a draft that never # defined it -- the bare-citation case, where the first # candidate is arbitrary. witness = None for draft in candidates: span = spans[draft].get(sec) if span is None: continue if witness is None: witness = (draft, span) if span[0] <= lo and hi <= span[1]: fits = True break if fits: ok += 1 elif witness is not None: outside += 1 draft, span = witness broken.append(f"{rel}:{lineno}: §{sec} L{lo}-{hi} outside {span[0]}-{span[1]} ({draft})") else: missing += 1 where = candidates[0] if len(candidates) == 1 else "any draft" broken.append(f"{rel}:{lineno}: §{sec} not a section of {where}") total = ok + outside + missing print(f"citations checked {total}") print(f" resolve correctly {ok} ({0 if not total else round(100*ok/total)}%)") print(f" point outside the cited section {outside} ({0 if not total else round(100*outside/total)}%)") print(f" section not found in the draft {missing} ({0 if not total else round(100*missing/total)}%)") if args.list: for b in broken: print(b) bad = outside + missing if args.baseline is not None: if bad != args.baseline: direction = "above" if bad > args.baseline else "below" print(f"\nFAIL: {bad} broken citations, {direction} the baseline of {args.baseline}.") print("Re-key the offending citations, or lower the baseline if you fixed some.") return 1 return 0 return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())