#!/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 two dozen 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",
# The sync server, its transport and container layers, and the tests that
# drive them cite only the sync draft. serve.zig was absent from this map,
# so its bare `§4` citations cleared against artifact-format §4 after sync
# §4 moved -- rot that read as verified.
"serve.zig": "draft-fossil-sync-protocol-00.txt",
"serve_fuzz.zig": "draft-fossil-sync-protocol-00.txt",
"transport.zig": "draft-fossil-sync-protocol-00.txt",
"container.zig": "draft-fossil-sync-protocol-00.txt",
"container_test.zig": "draft-fossil-sync-protocol-00.txt",
"pack.zig": "draft-fossil-sync-protocol-00.txt",
"e2e.zig": "draft-fossil-sync-protocol-00.txt",
"smoke.zig": "draft-fossil-sync-protocol-00.txt",
"sim/sync_sweep.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())