Artifact abb56366aff07609222a622136ae7ad5128994b9dba1aaa2e1e72f6274d8ef01:
- Executable file
scripts/check-citations.py
— part of check-in
[43c89e841f]
at
2026-08-06 12:33:27
on branch sub-manifest
— citations: check every range under a section, not just the first
A citation may carry several ranges: `§9.3 L3278/L3320-3322`, `§7.7 L1465/L1469-1471`, `§4.6.4-m6 (L1014)`. Matching only `§SEC Lnnn` saw the first and was blind to the rest, so half a compound citation could be re-keyed while the other half stayed rotted -- on the same line, looking equally verified. An adversarial reviewer caught that during a re-key pass; the tool could not have.
342 line references sat outside the checker's view. Each now binds to the NEAREST PRECEDING section on its own line, which is the rule that makes this safe: a range written BEFORE any section on the line -- `L545-547 hands clone to §8.1-§8.4, and §8.2 L1504-1506` -- belongs to earlier prose, not to a section further right, and is left alone rather than misattributed. 122 became checkable; 12 leading ranges were correctly skipped. Ranges are never carried across a line break for the same reason.
This also surfaced §10.5, which the old pattern could not see at all because a requirement id sits between section and range (`§10.5 r43 L2477-2479`). §10 is Error Handling with only §10.1; the send-private and client-url claims are §9.5 Private-Content and Discovery Pragmas (L1983-1985: "send-private enables private sync only with x ... Clients repeat it every private round"). Same -1 downshift already verified for §10.3 and §10.4. 21 sites renumbered, and not-a-section is back to 0.
Baseline 80 to 173: 93 defects that were always there and are now visible. The population it measures grew 821 to 943.
test 3495/3495, fmt. Issue #18. (user: dmestas, size: 9010) [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. # Appendices are cited too (`§A.1 L2448`) and are headed either `Appendix A. # Examples` or `A.1. Basic ...`, so both forms are recognised. Without them 36 # citations were simply invisible to this checker -- unverified while looking # checked, which is the failure this script exists to prevent. SECTION_RE = re.compile(r"^(?:Appendix\s+)?([0-9]+(?:\.[0-9]+)*|[A-Z](?:\.[0-9]+)*)\.\s\s+\S") # `submanifests §6.2 L677-690`, `§3.3 L527`, `§A.1 L2448`. 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+)?§([0-9]+(?:\.[0-9]+)*|[A-Z](?:\.[0-9]+)*)\s+L(\d+)(?:-(\d+))?") # A citation may carry SEVERAL ranges under one section -- `§9.3 L3278/L3320-3322`, # or `§7.7 L1465/L1469-1471`, or a parenthesised `§4.6.4-m6 (L1014)`. Matching only # `§SEC Lnnn` sees the first range and is blind to the rest, so half a compound # citation could be re-keyed while the other half stayed rotted -- on the same # line, looking equally verified. 342 line references sat outside the checker's # view for that reason. # # Each L-range binds to the NEAREST PRECEDING section on its own line. That rule # is what makes it safe: a range written BEFORE any section on the line (`L545-547 # hands clone to §8.1-§8.4, and §8.2 L1504-1506`) belongs to earlier prose, not to # a section further right, and is left alone rather than misattributed. Ranges are # never carried across a line break for the same reason. SEC_RE = re.compile(r"(?:([A-Za-z][A-Za-z-]*)\s+)?§([0-9]+(?:\.[0-9]+)*|[A-Z](?:\.[0-9]+)*)") RANGE_RE = re.compile(r"\bL(\d{2,5})(?:-(\d{2,5}))?") def citations_in(text): """Yield (draft_word, section, lo, hi) for every L-range on one line.""" secs = [(m.start(), m.group(1), m.group(2)) for m in SEC_RE.finditer(text)] if not secs: return for m in RANGE_RE.finditer(text): prior = [s for s in secs if s[0] < m.start()] if not prior: continue _, word, sec = prior[-1] lo = int(m.group(1)) yield word, sec, lo, int(m.group(2)) if m.group(2) else lo 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 word, sec, lo, hi in citations_in(text): 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())