Artifact 50ffa68619fb0f25fb8e951aeb4d48a3df89667467a8d075dfa145736105b188:
- Executable file
scripts/check-citations.py
— part of check-in
[2fa7460b8f]
at
2026-08-05 21:02:07
on branch sub-manifest
— citations: name every candidate draft in the report
A bare `§4.1` exists in five drafts, and the report named whichever was witnessed first -- so container.zig's container-format citations were reported against submanifests when they mean sync-protocol. The verdict was right and the message sent a re-keyer to the wrong document.
The report IS the worklist, so that is a defect. Baseline unchanged. (user: dmestas, size: 7290) [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 # EVERY draft that defines the section, not the first one seen. # A bare `§4.1` exists in more than one draft, so naming a # single arbitrary witness sends whoever re-keys it to the wrong # document -- and the report is the worklist, so a misleading # message is a defect even when the verdict is right. witnesses = [] for draft in candidates: span = spans[draft].get(sec) if span is None: continue witnesses.append((draft, span)) if span[0] <= lo and hi <= span[1]: fits = True break if fits: ok += 1 elif witnesses: outside += 1 where = "; ".join( f"{d.removeprefix('draft-fossil-').removesuffix('-00.txt')} §{sec}={sp[0]}-{sp[1]}" for d, sp in witnesses ) broken.append(f"{rel}:{lineno}: §{sec} L{lo}-{hi} -> [{where}]") 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())