Artifact cc12cb49ddb675c281dfe783acf0b7ee14d3c4b40233a15e2fa0ee9b273cc3ef:
- Executable file
scripts/check-citations.py
— part of check-in
[8951bc9d1e]
at
2026-08-23 20:45:11
on branch landing-bare-store
— landing: accept against a bare store, refusing conflicts
accept -R merges in a working area it creates and removes, materializing only the files both sides changed -- sparse-cone 4.4 resolves the rest into the record without writing them. The check-in is minted by the same commit path a checkout uses, so its F, P and R cards match a dense merge exactly.
A landing that does not merge cleanly exits 5 with the store byte-unchanged and each conflicting path named. A forge never holds a half-finished merge and never commits conflict markers; the author resolves it from their own checkout.
runMergeIn/runCommitIn take the checkout as a parameter instead of reading the process's cwd, which is what makes an ephemeral tree addressable without mutating global state. (user: dmestas, size: 11242) [annotate] [blame] [check-ins using] [more...]
#!/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", # sparse-cone was cited in the tree before it was listed here, so every # `sparse-cone SS4.4` fell through to the all-drafts path and cleared # against whichever draft happened to contain a matching section number -- # the silent weakening this map's comment warns about, against its own # draft. Listed now so those citations are actually checked. "sparse-cone": "draft-fossil-sparse-cone-00.txt", "sparse": "draft-fossil-sparse-cone-00.txt", "sparse-cones": "draft-fossil-sparse-cone-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. # The section sign is not the only anchor in the tree: `App. A.2 L3102-3114` # and `ABNF App. B.7 L3566-3575` cite an appendix by word. Without these forms # the range binds to whatever section sits further left -- or to nothing -- # so a citation could be reported against a section it never named. SEC_RE = re.compile( r"(?:([A-Za-z][A-Za-z-]*)\s+)?(?:§|App\.\s*|Appendix\s+)" r"([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())