Skip to content
zit Artifact [d966af59e7]
Login

Artifact [d966af59e7]

Artifact d966af59e77c0a20f43dc193f3873069c1d04f8e518ee579e4d9dc4be0ecfcc0:


#!/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())