mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-16 23:24:55 -05:00
497 lines
20 KiB
Python
497 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Comprehensive bib/qmd link-integrity check.
|
|
|
|
Catches four failure modes that the standard `binder check refs --scope
|
|
citations` does NOT cover:
|
|
|
|
1. **Unresolved cites** — `[@key]` appears in a `.qmd` but is not
|
|
defined in *any* bib in the repo.
|
|
2. **Scope violations** — a source cites a key that is defined somewhere
|
|
in the repo, but not in that source tree's allowed bibliography. The
|
|
book volumes intentionally share one bibliography; companion projects
|
|
still keep their own bibliography boundaries.
|
|
3. **Out-of-tree qmd files** — `interviews/**/*.qmd`, `tinytorch/**/*.qmd`,
|
|
`mlsysim/**/*.qmd`, `design-grammar/**/*.qmd` are skipped by the
|
|
per-volume check entirely.
|
|
4. **Orphan bib entries** — keys defined in any `references.bib` but
|
|
not cited by any `.qmd` anywhere. These are wasted maintenance
|
|
surface.
|
|
|
|
The static scope mapping (SCOPES, below) is the contract: each tree of
|
|
`.qmd` files is allowed to resolve only against a specific list of bib
|
|
files. Vol1, Vol2, and shared book-level frontmatter/backmatter resolve
|
|
against the book's shared bibliography; the companion papers each resolve
|
|
against their own bibliography.
|
|
|
|
Usage:
|
|
python3 book/tools/scripts/check_bib_qmd_integrity.py
|
|
python3 book/tools/scripts/check_bib_qmd_integrity.py --json
|
|
python3 book/tools/scripts/check_bib_qmd_integrity.py --orphans-only
|
|
python3 book/tools/scripts/check_bib_qmd_integrity.py --strict # exit 1 on orphans too
|
|
python3 book/tools/scripts/check_bib_qmd_integrity.py --no-scope-check # pool all bibs (legacy)
|
|
|
|
Exit codes:
|
|
0 — every cited key resolves within its scope's allowed bibs
|
|
1 — unresolved citation (in no bib) OR scope violation (in wrong-scope bib)
|
|
2 — --strict mode and orphan bib entries exist
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
# Repo root (this script lives at book/tools/scripts/)
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
# Static scope mapping: each citation source (qmd or tex) is matched to the
|
|
# first scope whose `paths` prefix matches the file's repo-relative path.
|
|
# That scope's `bibs` list is the *only* set of bib files allowed to resolve
|
|
# the source's citations. Order matters: more-specific prefixes must come before
|
|
# less-specific ones (vol1/vol2 before the shared book-level front/backmatter).
|
|
SCOPES = [
|
|
{
|
|
"name": "vol1",
|
|
"paths": ["book/quarto/contents/vol1/"],
|
|
"bibs": ["book/quarto/contents/references.bib"],
|
|
},
|
|
{
|
|
"name": "vol2",
|
|
"paths": ["book/quarto/contents/vol2/"],
|
|
"bibs": ["book/quarto/contents/references.bib"],
|
|
},
|
|
{
|
|
# Book-level frontmatter and backmatter share the book bibliography.
|
|
"name": "book-shared",
|
|
"paths": [
|
|
"book/quarto/contents/frontmatter/",
|
|
"book/quarto/contents/backmatter/",
|
|
],
|
|
"bibs": ["book/quarto/contents/references.bib"],
|
|
},
|
|
{
|
|
"name": "interviews",
|
|
"paths": ["interviews/"],
|
|
"bibs": ["interviews/paper/references.bib"],
|
|
},
|
|
{
|
|
"name": "tinytorch",
|
|
"paths": ["tinytorch/"],
|
|
"bibs": ["tinytorch/paper/references.bib"],
|
|
},
|
|
{
|
|
"name": "mlsysim",
|
|
"paths": ["mlsysim/"],
|
|
"bibs": ["mlsysim/paper/references.bib", "mlsysim/docs/references.bib"],
|
|
},
|
|
{
|
|
"name": "design-grammar",
|
|
"paths": ["design-grammar/"],
|
|
"bibs": ["design-grammar/paper/references.bib"],
|
|
},
|
|
]
|
|
|
|
# Flat list, derived from SCOPES — the union of every bib file referenced
|
|
# by any scope. Used for orphan detection and for the legacy --no-scope-check
|
|
# pooled mode.
|
|
BIB_FILES = sorted({b for s in SCOPES for b in s["bibs"]})
|
|
|
|
# Where .qmd source files live (scanned recursively)
|
|
QMD_ROOTS = [
|
|
"book/quarto/contents",
|
|
"interviews",
|
|
"tinytorch",
|
|
"mlsysim",
|
|
"design-grammar",
|
|
]
|
|
|
|
# Companion paper LaTeX sources. Each falls inside the scope of its
|
|
# parent directory (e.g. interviews/paper/paper.tex → "interviews" scope).
|
|
TEX_FILES = [
|
|
"interviews/paper/paper.tex",
|
|
"tinytorch/paper/paper.tex",
|
|
"mlsysim/paper/paper.tex",
|
|
"design-grammar/paper/paper.tex",
|
|
]
|
|
|
|
# Excluded path tokens (build artifacts, deps, etc.)
|
|
EXCLUDE = ("_build", "_site", "node_modules", ".git", "__pycache__",
|
|
".venv", "venv", ".aiconfigs-local")
|
|
|
|
# Quarto cross-reference prefixes — these aren't bibliography citations
|
|
NON_CITE_PREFIXES = (
|
|
"sec-", "fig-", "tbl-", "eq-", "lst-", "exr-", "exm-",
|
|
"thm-", "cor-", "cnj-", "def-", "prp-", "rem-", "prf-", "alg-",
|
|
"algo-", "ch-", "nb-",
|
|
)
|
|
|
|
# Keys that look like CSS / JS / Python decorators / emails — false positives
|
|
# (CSS at-rules: @media, @keyframes, @import; emails: @eecs.harvard.edu;
|
|
# Python decorators: @grad, @staticmethod; etc.)
|
|
KNOWN_FALSE_POSITIVE_KEYS = {
|
|
"media", "keyframes", "import", "supports", "page", "font-face",
|
|
"charset", "namespace", "document", # CSS at-rules
|
|
"grad", "staticmethod", "classmethod", "property", "abstractmethod",
|
|
"dataclass", "cached_property", "wraps", # Python decorators
|
|
"eecs", # email tail
|
|
}
|
|
|
|
# A bib key starts with a letter and contains word chars / colons / hyphens.
|
|
# Lookbehind excludes `=@` / `(@` / `,@` patterns that aren't real citations
|
|
# (e.g., Python decorators `func(@x)` or matmul notation `A@B`).
|
|
CITE_RE = re.compile(r"(?<![=,(])\[?@([A-Za-z][\w:.-]*)\b")
|
|
|
|
# LaTeX citations: \cite{key}, \citep{key1,key2}, \citet{key}, etc.
|
|
TEX_CITE_RE = re.compile(r"\\cite[a-z]*\*?\{([^}]+)\}")
|
|
|
|
# Single-uppercase-letter "keys" (e.g., A, B, X) are matrix/variable names
|
|
# in math-style markdown like `A@B` (matmul). Not bib citations.
|
|
SINGLE_LETTER_RE = re.compile(r"^[A-Z](\.[A-Z])*$")
|
|
|
|
|
|
def parse_bib_keys(text: str) -> set[str]:
|
|
"""Extract all @entry_type{KEY,...} keys from a bib file."""
|
|
return set(re.findall(r"^@\w+\s*\{\s*([\w:_-]+)\s*,", text, re.M))
|
|
|
|
|
|
def extract_qmd_cites(text: str) -> set[str]:
|
|
"""Extract all citation keys from .qmd content, skipping code/yaml/inline-backticks."""
|
|
# Strip YAML frontmatter
|
|
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)
|
|
# Strip HTML comments BEFORE fence detection. A commented-out figure can
|
|
# legitimately contain a stray ``` (e.g., a leftover code-fence wrapper
|
|
# around a TikZ block) that would otherwise pair with the next real fence
|
|
# and eat the prose in between. Both `<!-- ... -->` and the looser
|
|
# `<!--- ... -->` (Quarto-flavored four-dash open) are handled.
|
|
text = re.sub(r"<!--+.*?-->", "", text, flags=re.S)
|
|
# Strip fenced code blocks. Both fence markers must sit at the start of a
|
|
# line (modulo leading whitespace). The earlier `r"```.*?```"` was buggy:
|
|
# it paired *any* two triple-backticks, including mid-line occurrences,
|
|
# and silently ate prose between unrelated fences. A real Quarto/markdown
|
|
# fence is line-anchored.
|
|
text = re.sub(
|
|
r"(?m)^[ \t]*```[^\n]*\n.*?\n[ \t]*```[^\n]*$",
|
|
"", text, flags=re.S,
|
|
)
|
|
# Strip HTML style/script
|
|
text = re.sub(r"<(style|script)\b[^>]*>.*?</\1>", "", text, flags=re.S)
|
|
# Strip inline backticks
|
|
text = re.sub(r"`[^`]+`", "", text)
|
|
|
|
keys = set()
|
|
for m in CITE_RE.finditer(text):
|
|
k = m.group(1).rstrip(".,;:)")
|
|
if not k:
|
|
continue
|
|
if k.lower().startswith(NON_CITE_PREFIXES):
|
|
continue
|
|
if k in KNOWN_FALSE_POSITIVE_KEYS:
|
|
continue
|
|
# Skip single uppercase letters or short variable patterns (math notation)
|
|
if SINGLE_LETTER_RE.match(k):
|
|
continue
|
|
# Skip purely-numeric trailing tokens like "1.5"
|
|
if re.match(r"^\d+\.\d+", k):
|
|
continue
|
|
keys.add(k)
|
|
return keys
|
|
|
|
|
|
def extract_tex_cites(text: str) -> set[str]:
|
|
"""Extract all citation keys from LaTeX content."""
|
|
# Strip comments
|
|
text = re.sub(r"(?<!\\)%.*", "", text)
|
|
keys = set()
|
|
for m in TEX_CITE_RE.finditer(text):
|
|
# Handle comma-separated lists: \cite{key1, key2}
|
|
for k in m.group(1).split(","):
|
|
k = k.strip()
|
|
if k:
|
|
keys.add(k)
|
|
return keys
|
|
|
|
|
|
def discover_qmds() -> list[Path]:
|
|
"""All .qmd files under QMD_ROOTS, minus excluded path components."""
|
|
out = []
|
|
for root in QMD_ROOTS:
|
|
p = REPO_ROOT / root
|
|
if not p.exists():
|
|
continue
|
|
for q in p.rglob("*.qmd"):
|
|
if any(part in EXCLUDE for part in q.parts):
|
|
continue
|
|
out.append(q)
|
|
return sorted(out)
|
|
|
|
|
|
def find_scope(rel_path: str) -> dict | None:
|
|
"""Return the first scope whose path prefix matches `rel_path`, or None.
|
|
|
|
`rel_path` is the file's path relative to REPO_ROOT, with forward slashes.
|
|
Order in SCOPES matters: more-specific prefixes (vol1/, vol2/) must come
|
|
before less-specific ones (frontmatter/, backmatter/).
|
|
"""
|
|
for scope in SCOPES:
|
|
for prefix in scope["paths"]:
|
|
if rel_path.startswith(prefix):
|
|
return scope
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--json", action="store_true",
|
|
help="Emit machine-readable JSON")
|
|
parser.add_argument("--orphans-only", action="store_true",
|
|
help="Only report orphan bib entries (cited-nowhere)")
|
|
parser.add_argument("--strict", action="store_true",
|
|
help="Exit nonzero on orphan bib entries too")
|
|
parser.add_argument("--no-scope-check", action="store_true",
|
|
help="Pool every bib together (legacy mode); skip "
|
|
"component-scope enforcement")
|
|
parser.add_argument("--show-false-positives", action="store_true",
|
|
help="Show suppressed false-positive keys")
|
|
args = parser.parse_args()
|
|
|
|
# Load all bib keys, tracking which file(s) each lives in.
|
|
# `bib_keys_by_file[bibfile] -> set(keys)` lets us answer scope questions
|
|
# ("is key K defined in bib file B?") without rescanning.
|
|
all_bib_keys: dict[str, list[str]] = defaultdict(list)
|
|
bib_keys_by_file: dict[str, set[str]] = {}
|
|
bib_total = 0
|
|
for bp in BIB_FILES:
|
|
path = REPO_ROOT / bp
|
|
if not path.exists():
|
|
print(f"warn: {bp} missing", file=sys.stderr)
|
|
bib_keys_by_file[bp] = set()
|
|
continue
|
|
keys = parse_bib_keys(path.read_text(encoding="utf-8"))
|
|
bib_keys_by_file[bp] = keys
|
|
for k in keys:
|
|
all_bib_keys[k].append(bp)
|
|
bib_total += len(keys)
|
|
|
|
# Per-citation records: for each (file, key) pair, we'll determine its
|
|
# scope and classify it as resolved / scope-violation / unresolved.
|
|
# `all_cites` keeps the cite-key → list-of-files mapping for the
|
|
# legacy pooled view and the orphan computation.
|
|
all_cites: dict[str, list[str]] = defaultdict(list)
|
|
citation_records: list[dict] = [] # each: {file, key, scope}
|
|
files_outside_scope: set[str] = set()
|
|
|
|
qmds = discover_qmds()
|
|
for q in qmds:
|
|
try:
|
|
text = q.read_text(encoding="utf-8")
|
|
except (UnicodeDecodeError, OSError):
|
|
continue
|
|
cites = extract_qmd_cites(text)
|
|
rel = str(q.relative_to(REPO_ROOT))
|
|
scope = find_scope(rel)
|
|
if scope is None and cites:
|
|
files_outside_scope.add(rel)
|
|
for k in cites:
|
|
all_cites[k].append(rel)
|
|
citation_records.append({"file": rel, "key": k, "scope": scope})
|
|
|
|
# Load all tex citations (same scope-by-path logic)
|
|
tex_files_found = 0
|
|
for tp in TEX_FILES:
|
|
path = REPO_ROOT / tp
|
|
if not path.exists():
|
|
continue
|
|
tex_files_found += 1
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (UnicodeDecodeError, OSError):
|
|
continue
|
|
cites = extract_tex_cites(text)
|
|
rel = str(path.relative_to(REPO_ROOT))
|
|
scope = find_scope(rel)
|
|
if scope is None and cites:
|
|
files_outside_scope.add(rel)
|
|
for k in cites:
|
|
all_cites[k].append(rel)
|
|
citation_records.append({"file": rel, "key": k, "scope": scope})
|
|
|
|
cited_keys = set(all_cites.keys())
|
|
bib_keys = set(all_bib_keys.keys())
|
|
orphans = sorted(bib_keys - cited_keys)
|
|
|
|
# Classify each citation record. Three buckets:
|
|
# - resolved : key is in one of the scope's allowed bibs
|
|
# - scope_violation : key is defined somewhere, but NOT in scope's bibs
|
|
# - unresolved : key is in no bib file at all
|
|
# In --no-scope-check mode, scope_violation collapses into resolved.
|
|
unresolved_records: list[dict] = []
|
|
scope_violation_records: list[dict] = []
|
|
for rec in citation_records:
|
|
k = rec["key"]
|
|
scope = rec["scope"]
|
|
if k not in bib_keys:
|
|
unresolved_records.append(rec)
|
|
continue
|
|
if args.no_scope_check or scope is None:
|
|
continue # legacy / unscoped: any bib counts
|
|
allowed_bibs = set(scope["bibs"])
|
|
defining_bibs = set(all_bib_keys[k])
|
|
if defining_bibs & allowed_bibs:
|
|
continue # resolved within scope
|
|
scope_violation_records.append({
|
|
**rec,
|
|
"scope_name": scope["name"],
|
|
"allowed_bibs": sorted(allowed_bibs),
|
|
"found_in_bibs": sorted(defining_bibs),
|
|
})
|
|
|
|
# Aggregate unresolved keys (key → list of files), preserving original
|
|
# report shape for backward compatibility.
|
|
unresolved_by_key: dict[str, list[str]] = defaultdict(list)
|
|
for r in unresolved_records:
|
|
if r["file"] not in unresolved_by_key[r["key"]]:
|
|
unresolved_by_key[r["key"]].append(r["file"])
|
|
unresolved = sorted(unresolved_by_key.keys())
|
|
|
|
# Aggregate scope violations: group by (scope_name, key) to avoid
|
|
# printing the same leak once per chapter that hit it.
|
|
sv_grouped: dict[tuple[str, str], dict] = {}
|
|
for r in scope_violation_records:
|
|
gk = (r["scope_name"], r["key"])
|
|
if gk not in sv_grouped:
|
|
sv_grouped[gk] = {
|
|
"scope": r["scope_name"],
|
|
"key": r["key"],
|
|
"allowed_bibs": r["allowed_bibs"],
|
|
"found_in_bibs": r["found_in_bibs"],
|
|
"cited_in": [],
|
|
}
|
|
if r["file"] not in sv_grouped[gk]["cited_in"]:
|
|
sv_grouped[gk]["cited_in"].append(r["file"])
|
|
scope_violations = sorted(
|
|
sv_grouped.values(),
|
|
key=lambda v: (v["scope"], v["key"]),
|
|
)
|
|
|
|
summary = {
|
|
"mode": "pooled" if args.no_scope_check else "per-scope",
|
|
"qmd_files_scanned": len(qmds),
|
|
"tex_files_scanned": tex_files_found,
|
|
"bib_files_scanned": len([1 for bp in BIB_FILES if (REPO_ROOT / bp).exists()]),
|
|
"files_outside_any_scope": sorted(files_outside_scope),
|
|
"unique_cited_keys": len(cited_keys),
|
|
"unique_bib_keys": len(bib_keys),
|
|
"bib_total_entries": bib_total,
|
|
"unresolved_count": len(unresolved),
|
|
"scope_violation_count": len(scope_violations),
|
|
"orphan_count": len(orphans),
|
|
"unresolved": [
|
|
{"key": k, "cited_in": unresolved_by_key[k][:5],
|
|
"extra_files_omitted": max(0, len(unresolved_by_key[k]) - 5)}
|
|
for k in unresolved
|
|
],
|
|
"scope_violations": [
|
|
{**v, "extra_files_omitted": max(0, len(v["cited_in"]) - 5),
|
|
"cited_in": v["cited_in"][:5]}
|
|
for v in scope_violations
|
|
],
|
|
"orphans": [
|
|
{"key": k, "defined_in": all_bib_keys[k]}
|
|
for k in orphans
|
|
],
|
|
}
|
|
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2))
|
|
return _exit_code(unresolved, scope_violations, orphans, args.strict)
|
|
|
|
# Human-readable
|
|
mode_label = "pooled (legacy)" if args.no_scope_check else "per-scope"
|
|
print(f"# Bib / QMD / TeX link integrity report ({mode_label})\n")
|
|
print(f" qmd files scanned: {summary['qmd_files_scanned']}")
|
|
print(f" tex files scanned: {summary['tex_files_scanned']}")
|
|
print(f" bib files scanned: {summary['bib_files_scanned']}")
|
|
print(f" unique cited keys: {summary['unique_cited_keys']}")
|
|
print(f" unique bib keys: {summary['unique_bib_keys']} ({summary['bib_total_entries']} total entries inc. duplicates)")
|
|
print(f" unresolved (broken): {summary['unresolved_count']}")
|
|
if not args.no_scope_check:
|
|
print(f" scope violations: {summary['scope_violation_count']}")
|
|
print(f" orphans (uncited): {summary['orphan_count']}")
|
|
|
|
if files_outside_scope and not args.no_scope_check:
|
|
print(f"\n warn: {len(files_outside_scope)} citation source(s) match no scope — citations from these are not scope-checked:")
|
|
for f in sorted(files_outside_scope)[:10]:
|
|
print(f" - {f}")
|
|
if len(files_outside_scope) > 10:
|
|
print(f" ... ({len(files_outside_scope) - 10} more)")
|
|
|
|
if not args.orphans_only:
|
|
print(f"\n## Unresolved citations ({len(unresolved)})")
|
|
if not unresolved:
|
|
print(" ✓ none — every cited key resolves to a bib entry\n")
|
|
else:
|
|
print(" These are real broken links — the key is in NO bib file:\n")
|
|
for k in unresolved[:50]:
|
|
src = unresolved_by_key[k][:3]
|
|
more = "" if len(unresolved_by_key[k]) <= 3 else f" (+{len(unresolved_by_key[k]) - 3} more)"
|
|
print(f" @{k}")
|
|
for s in src:
|
|
print(f" cited in: {s}")
|
|
if more: print(f" {more}")
|
|
if len(unresolved) > 50:
|
|
print(f" ... ({len(unresolved) - 50} more — see --json for full list)")
|
|
|
|
if not args.no_scope_check:
|
|
print(f"\n## Scope violations ({len(scope_violations)})")
|
|
if not scope_violations:
|
|
print(" ✓ none — every cite resolves within its volume's bib\n")
|
|
else:
|
|
print(" Citation source's scope does not include the bib that defines the key.")
|
|
print(" These render as broken in single-volume PDF builds:\n")
|
|
for v in scope_violations[:50]:
|
|
print(f" [{v['scope']}] @{v['key']}")
|
|
print(f" allowed: {', '.join(v['allowed_bibs'])}")
|
|
print(f" found in: {', '.join(v['found_in_bibs'])}")
|
|
for s in v["cited_in"][:3]:
|
|
print(f" cited in: {s}")
|
|
if len(v["cited_in"]) > 3:
|
|
print(f" (+{len(v['cited_in']) - 3} more files)")
|
|
if len(scope_violations) > 50:
|
|
print(f" ... ({len(scope_violations) - 50} more — see --json for full list)")
|
|
|
|
print(f"\n## Orphan bib entries ({len(orphans)})")
|
|
if not orphans:
|
|
print(" ✓ none — every bib entry is cited somewhere\n")
|
|
else:
|
|
print(" These bib entries are defined but cited nowhere — candidates for deletion:\n")
|
|
# Group by which bib file
|
|
by_file = defaultdict(list)
|
|
for k in orphans:
|
|
for bf in all_bib_keys[k]:
|
|
by_file[bf].append(k)
|
|
for bf, keys in sorted(by_file.items()):
|
|
print(f" {bf}: {len(keys)} orphans")
|
|
for k in keys[:10]:
|
|
print(f" - {k}")
|
|
if len(keys) > 10:
|
|
print(f" ... ({len(keys) - 10} more)")
|
|
print()
|
|
|
|
return _exit_code(unresolved, scope_violations, orphans, args.strict)
|
|
|
|
|
|
def _exit_code(unresolved: list, scope_violations: list, orphans: list, strict: bool) -> int:
|
|
if unresolved or scope_violations:
|
|
return 1
|
|
if strict and orphans:
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|