Artifact f8abbea43a05d51c8eadf41f4d7cd2052a0fb162f08a54d3af0b0462e98041d7:
- Executable file
scripts/check-citations.py
— part of check-in
[a9b1ee8fb4]
at
2026-08-06 13:54:28
on branch sub-manifest
— citations: catch reversed ranges, and repair two live sites
The containment test asked only that lo be at or after the section start and hi at or before its end. A REVERSED range satisfies both halves independently, so it cleared. sync.zig carried a range running from 1878 back to 1443 -- digits that cannot both be right -- and it had passed as correct since this checker was written. The test now also requires lo to be at or before hi.
That site is repaired rather than deferred. The digits were unrecoverable but the claim was not: section 8.2 L1517-1520 states it outright, "An accepting clone reply emits zero or more clone cfile cards in RID order ... then clone_seqno NEXT, push SERVERCODE PROJECTCODE, and final comment", with L1504-1505 for iterating from the supplied RID.
6682409ff0 added a section 8.2 range at L1915-1924 for "the store grew". Section 8.2 ends at L1595; L1915 is config-push payload text. The claim is section 3.7(a) L577-578, "file/cfile, including dangling delta, stored content or igot created a phantom" -- which is what artifact_count_grew measures, and the sibling condition on the line above already cites 3.7(b). Left unfixed this failed the ratchet at 174.
Two checker comments asserted things the code does not do: BARE_DEFAULT claimed ambiguity "never manufactures a failure" -- true, while it manufactures roughly 79 silent CLEARANCES -- and the witnesses comment claimed completeness when the loop breaks on first fit. Five draft aliases added so an unrecognised spelling cannot fall through to the all-drafts path unnoticed.
945 citations, 773 correct, 173 broken, 0 unresolvable sections. Baseline holds at 173 while absorbing a new defect and a newly-detectable class.
test 3500/3500, fmt. Issue #18. (user: dmestas, size: 10376) [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", # Spellings seen in the tree. A name that is NOT listed here falls through # to the all-drafts path and clears against whichever draft happens to fit, # so an unrecognised alias is a silent weakening rather than an error. "sync-protocol": "draft-fossil-sync-protocol-00.txt", "delta-format": "draft-fossil-delta-format-00.txt", "submanifest": "draft-fossil-submanifests-00.txt", "artifacts": "draft-fossil-artifact-format-00.txt", "repo": "draft-fossil-repo-state-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. # # That is generous in one direction and it costs something: ambiguity never # manufactures a FAILURE, but it does manufacture silent CLEARANCES -- a bare # `§4.1` clears if any of five drafts happens to hold that range, so the draft # the author meant is never proven. Roughly 79 citations pass on that basis. 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 -- for a citation that # FAILS. The loop breaks on the first fit, so a passing citation # stops early and this list is deliberately incomplete there; # it exists to make the broken report name the right document. # 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)) # `lo <= hi` is load-bearing, not decoration: without it a # REVERSED range satisfies both halves independently and # clears. src/sync.zig carried `§8.2 L1878-1443` doing # exactly that -- a citation whose digits cannot both be # right, passing as correct. if span[0] <= lo <= 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())