Files
bdeshiandClaude Opus 5 e602e7e631 Link audit: fix three plate links from elided ranges
CLAUDE.md requires re-auditing the cross-reference rule after any change
to it, and polish.py changed twice this session. tools/linkaudit.py does
it: three classes checked mechanically, the fourth printed to be read.

  dangling targets                      0
  plate page mismatch (listed vs set)   0   (all 178)
  chapter page mismatch                 0
  `p. N` self-references               25   all read, all sound

The finding: an elided range end is not a plate number of its own.
plate_sub ran re.sub(r"\d+") over the whole range, so "pls. 146-8" linked
its 8 to plate 8 (orig. p.35) and "pls. 142-5" linked its 5 to plate 5
(p.23). Three links, all pointing at the wrong plate — invisible to every
other check, since the targets exist and the text is correct. The end is
now expanded against the leading digits of the number it is elided
against: 146-8 -> plate 148 (p.334), 142-5 -> plate 145 (p.329), both
confirmed against the List of Plates and against where \plateop sets
them.

Worth noting what the audit also confirmed: in two footnotes the rule
discriminates a citation page from a self-reference in the same sentence
— "(London, 1962), p. 43; also see below, p. 63" links only the second.

NOT YET BUILT: the Docker daemon is down, so make verify has not run
against this change. polish.py was run on the host and the audit re-run
against its output, but the PDF does not yet carry the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:00:13 +06:00

92 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Audit every hyperlink in the edition. CLAUDE.md requires this after any change
to polish.py's cross-reference rule.
Three classes are checkable mechanically:
1. dangling — a link to page.N with no \\hypertarget{page.N} planted anywhere
2. plates — the List of Plates says plate `no` is on page P; the plate is
actually set by \\plateop{N}{no}. P and N must agree.
3. chapters — a `chapter N` link must land on that chapter's opening page.
The fourth, `p. N` self-references, is a judgement the script cannot make: those
are printed with context to be read, which is what the rule asks for.
"""
import re, glob, sys
from collections import defaultdict
SRC, WORK = "src/pages", "work/pages"
targets = set() # every page.N that exists
plate_set = {} # plate no -> orig page it is set on
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
t = open(f, encoding="utf-8").read()
for m in re.finditer(r"\\origpage\{(\d+)\}", t):
targets.add(int(m.group(1)))
for m in re.finditer(r"\\plateop\{(\d+)\}\{(\d+)\}", t):
targets.add(int(m.group(1))); plate_set[int(m.group(2))] = int(m.group(1))
plate_listed, chap_listed = {}, {} # what the front matter claims
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
t = open(f, encoding="utf-8").read()
for no, pg in re.findall(r"\\pl\{(\d+)\}\{.*?\}\{(\d+)\}", t):
plate_listed[int(no)] = int(pg)
for no, pg in re.findall(r"\\plx\{(\d+)\}\{.*?\}\{.*?\\pg\{(\d+)\}", t):
plate_listed[int(no)] = int(pg)
for lbl, pg in re.findall(r"\\tocl\{[^}]*\}\{(\d+)\}\{.*?\}\{(\d+)\}", t):
chap_listed[int(lbl)] = int(pg)
chap_actual = {}
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
t = open(f, encoding="utf-8").read()
m = re.search(r"\\chapnum\{Chapter (\d+)\}", t)
o = re.search(r"\\origpage\{(\d+)\}", t)
if m and o: chap_actual.setdefault(int(m.group(1)), int(o.group(1)))
links = [] # (file, target, shown, context)
for f in sorted(glob.glob(f"{WORK}/p*.tex")):
t = open(f, encoding="utf-8").read()
for m in re.finditer(r"\\(?:xref|hyperlink)\{page\.(\d+)\}\{((?:[^{}]|\{[^{}]*\})*)\}", t):
before = re.sub(r"\s+", " ", t[max(0, m.start() - 90):m.start()])
after = re.sub(r"\s+", " ", t[m.end():m.end() + 30])
links.append((f[-8:-4], int(m.group(1)), m.group(2), before, after))
for m in re.finditer(r"\\pg\{(\d+)\}", t):
links.append((f[-8:-4], int(m.group(1)), "<pg>", "", ""))
print(f"{len(links)} links, {len(targets)} distinct page targets, "
f"{len(plate_set)} plates set, {len(plate_listed)} plates listed\n")
dangling = sorted({(n, f) for f, n, s, b, a in links if n not in targets})
print(f"1. DANGLING TARGETS: {len(dangling)}")
for n, f in dangling[:20]: print(f" page.{n} linked from {f} — no target")
bad_plate = [(no, plate_listed[no], plate_set.get(no)) for no in sorted(plate_listed)
if plate_set.get(no) is not None and plate_listed[no] != plate_set[no]]
print(f"\n2. PLATE PAGE MISMATCH (listed vs where set): {len(bad_plate)}")
for no, listed, actual in bad_plate[:20]:
print(f" plate {no}: List of Plates says p.{listed}, \\plateop sets it on p.{actual}")
missing = sorted(set(plate_listed) - set(plate_set))
print(f" plates listed but never set: {missing if missing else 'none'}")
bad_chap = [(n, chap_listed[n], chap_actual.get(n)) for n in sorted(chap_listed)
if chap_actual.get(n) is not None and chap_listed[n] != chap_actual[n]]
print(f"\n3. CHAPTER PAGE MISMATCH (Contents vs \\chapnum): {len(bad_chap)}")
for n, listed, actual in bad_chap[:20]:
print(f" chapter {n}: Contents says p.{listed}, chapter opens on p.{actual}")
# polish.py wraps only the numeral, so the link TEXT is "63" — the kind is
# carried by the word before it ("p.~", "pl.~", "chapter~").
kind = lambda c: ("plate" if re.search(r"\bpls?\.[~ ]$", c) else
"page" if re.search(r"\bpp?\.[~ ]$", c) else
"chapter" if re.search(r"\bchapters?[~ ]$", c) else
"bare-number" if re.search(r"[-,] ?$", c) else "other")
tally = defaultdict(int); page_links = []
for f, n, shown, before, after in links:
if shown == "<pg>": tally["contents/plates page number"] += 1; continue
k = kind(before)
tally[k] += 1
if k == "page": page_links.append((f, n, before[-72:], after))
print("\n4. LINK KINDS")
for k, v in sorted(tally.items(), key=lambda kv: -kv[1]): print(f" {k:30} {v}")
print(f"\n5. `p. N` SELF-REFERENCE LINKS — READ THESE ({len(page_links)}):")
for f, n, before, after in page_links:
print(f" {f} -> p.{n:<4} …{before.strip()} [LINK] {after.strip()[:22]}…")