mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-16 06:07:17 -05:00
10785 lines
480 KiB
Python
10785 lines
480 KiB
Python
"""
|
||
Native validation commands for MLSysBook Binder CLI.
|
||
|
||
Every `book-*` pre-commit hook dispatches through `./book/binder check <group>`
|
||
so there is one entry point, one error format, and one place to add new
|
||
checks. See the `ValidateCommand` class docstring below for the contract:
|
||
|
||
1. Add a `_run_<scope>` method that returns a ValidationRunResult.
|
||
2. Add a `Scope("<name>", "_run_<scope>", default=...)` to the relevant
|
||
group in GROUPS. Use `default=False` if the scope still fails on dev
|
||
or is intentionally opt-in (slow / heavy / manual stage).
|
||
3. Surface any new flags on the argparse block in `run()`.
|
||
|
||
Pre-commit picks the new scope up automatically once `default=True` —
|
||
no YAML edit needed. Ad-hoc audits use `--all-scopes` or `--scope <name>`.
|
||
|
||
Some checks still delegate to scripts under book/tools/scripts/ (tables,
|
||
spelling, epub, sources, grid-tables, images) and to standalone audit
|
||
scripts under book/tools/audit/index/. Prefer new logic in cli/checks/
|
||
with normal imports; see book/cli/README.md "Check implementation layout".
|
||
Legacy `_delegate_script` / importlib paths are being phased out.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||
|
||
from rich.console import Console
|
||
from rich.markup import escape as _rich_escape
|
||
from rich.panel import Panel
|
||
from rich.table import Table
|
||
|
||
from . import reference_check
|
||
|
||
console = Console()
|
||
|
||
|
||
@dataclass
|
||
class ValidationIssue:
|
||
file: str
|
||
line: int
|
||
code: str
|
||
message: str
|
||
severity: str = "error"
|
||
context: str = ""
|
||
suggestion: str = ""
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
payload = {
|
||
"file": self.file,
|
||
"line": self.line,
|
||
"code": self.code,
|
||
"message": self.message,
|
||
"severity": self.severity,
|
||
"context": self.context,
|
||
}
|
||
if self.suggestion:
|
||
payload["suggestion"] = self.suggestion
|
||
return payload
|
||
|
||
|
||
@dataclass
|
||
class ValidationRunResult:
|
||
name: str
|
||
description: str
|
||
files_checked: int
|
||
issues: List[ValidationIssue]
|
||
elapsed_ms: int
|
||
|
||
@property
|
||
def passed(self) -> bool:
|
||
return len(self.issues) == 0
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"name": self.name,
|
||
"description": self.description,
|
||
"files_checked": self.files_checked,
|
||
"passed": self.passed,
|
||
"issue_count": len(self.issues),
|
||
"elapsed_ms": self.elapsed_ms,
|
||
"issues": [issue.to_dict() for issue in self.issues],
|
||
}
|
||
|
||
|
||
INLINE_REF_PATTERN = re.compile(r"`\{python\}\s+(\w+(?:\.\w+)?)`")
|
||
INLINE_EXPR_PATTERN = re.compile(r"`\{python\}\s+([^`]+?)`")
|
||
CELL_START_PATTERN = re.compile(r"^```\{python\}|^```python")
|
||
CELL_END_PATTERN = re.compile(r"^```\s*$")
|
||
ASSIGN_PATTERN = re.compile(r"^([A-Za-z_]\w*)\s*=")
|
||
# Tuple unpacking: "a, b = ..." — captures all names on the left side
|
||
TUPLE_ASSIGN_PATTERN = re.compile(r"^((?:[A-Za-z_]\w*\s*,\s*)+[A-Za-z_]\w*)\s*=")
|
||
CLASS_DEF_PATTERN = re.compile(r"^class\s+(\w+)\s*[:(]")
|
||
GRID_TABLE_SEP_PATTERN = re.compile(r"^\+[-:=+]+\+$")
|
||
# NOTE: The old LATEX_INLINE_PATTERN regex could treat the closing `$` of one
|
||
# math span as the opening `$` of the next, producing false positives on lines
|
||
# such as `$P_0$: `{python} x_str`. $P_t$`. Use a scanner so only actual
|
||
# `$...$` and `\(...\)` spans are checked for inline Python.
|
||
|
||
|
||
def _is_escaped(text: str, idx: int) -> bool:
|
||
backslashes = 0
|
||
j = idx - 1
|
||
while j >= 0 and text[j] == "\\":
|
||
backslashes += 1
|
||
j -= 1
|
||
return backslashes % 2 == 1
|
||
|
||
|
||
def _inline_math_spans(text: str) -> List[str]:
|
||
"""Return inline math spans from one Markdown source line."""
|
||
spans: List[str] = []
|
||
i = 0
|
||
n = len(text)
|
||
while i < n:
|
||
if text.startswith(r"\(", i) and not _is_escaped(text, i):
|
||
end = text.find(r"\)", i + 2)
|
||
if end == -1:
|
||
i += 2
|
||
continue
|
||
spans.append(text[i:end + 2])
|
||
i = end + 2
|
||
continue
|
||
|
||
if text[i] == "$" and not _is_escaped(text, i):
|
||
if (i + 1 < n and text[i + 1] == "$") or (i > 0 and text[i - 1] == "$"):
|
||
i += 1
|
||
continue
|
||
j = i + 1
|
||
while j < n:
|
||
if text[j] == "$" and not _is_escaped(text, j):
|
||
if (j + 1 < n and text[j + 1] == "$") or text[j - 1] == "$":
|
||
j += 1
|
||
continue
|
||
spans.append(text[i:j + 1])
|
||
i = j + 1
|
||
break
|
||
j += 1
|
||
else:
|
||
i += 1
|
||
continue
|
||
|
||
i += 1
|
||
return spans
|
||
|
||
|
||
def _inline_python_math_spans(text: str) -> List[str]:
|
||
return [span for span in _inline_math_spans(text) if "{python}" in span]
|
||
|
||
|
||
LATEX_OPERATOR_AFTER_PATTERN = re.compile(
|
||
r"\s*\$\\(times|approx|ll|gg|mu|le|ge|neq|pm|cdot|div)"
|
||
)
|
||
CANONICAL_INLINE_SUFFIXES = ("_str", "_math", "_eq", "_frac")
|
||
|
||
|
||
def _inline_python_latex_operator_warnings(text: str) -> List[str]:
|
||
"""Refs adjacent to LaTeX operators without a canonical display suffix."""
|
||
refs: List[str] = []
|
||
for match in INLINE_EXPR_PATTERN.finditer(text):
|
||
after = text[match.end():]
|
||
if not LATEX_OPERATOR_AFTER_PATTERN.match(after):
|
||
continue
|
||
expr = match.group(1).strip()
|
||
name = expr.split("[", 1)[0].split("(", 1)[0].strip()
|
||
final = name.split(".")[-1]
|
||
if not final.endswith(CANONICAL_INLINE_SUFFIXES):
|
||
refs.append(match.group(0))
|
||
return refs
|
||
|
||
# LATEX_ADJACENT_PATTERN checks were retired from validate_inline_refs.py along
|
||
# with mlsysim.fmt's MarkdownStr migration. They had been guarding against
|
||
# Quarto's auto-escape silently corrupting commas and decimals inside $..$ math
|
||
# mode — a bug class that no longer exists now that fmt() returns a Markdown-
|
||
# rendering string that bypasses the escape. See mlsysim/mlsysim/fmt.py and
|
||
# the project math rules.
|
||
|
||
CITATION_REF_PATTERN = re.compile(r"@([A-Za-z0-9_:\-.]+)")
|
||
CITATION_BRACKET_PATTERN = re.compile(r"\[-?@[A-Za-z0-9_:\-.]+(?:;\s*-?@[A-Za-z0-9_:\-.]+)*\]")
|
||
|
||
LABEL_DEF_PATTERNS = {
|
||
"Figure": [
|
||
re.compile(r"\{#(fig-[\w-]+)"), # {#fig-xyz ...}
|
||
re.compile(r"#\|\s*label:\s*(fig-[\w-]+)"), # #| label: fig-xyz
|
||
re.compile(r"%%\|\s*label:\s*(fig-[\w-]+)"), # %%| label: fig-xyz (Jupyter)
|
||
],
|
||
"Table": [
|
||
re.compile(r"\{#(tbl-[\w-]+)"), # {#tbl-xyz}
|
||
re.compile(r"#\|\s*label:\s*(tbl-[\w-]+)"), # #| label: tbl-xyz
|
||
],
|
||
"Section": [
|
||
re.compile(r"\{#(sec-[\w-]+)"), # {#sec-xyz}
|
||
re.compile(r"^id:\s*(sec-[\w-]+)"), # YAML id: sec-xyz
|
||
],
|
||
"Equation": [re.compile(r"\{#(eq-[\w-]+)")], # {#eq-xyz}
|
||
"Listing": [
|
||
re.compile(r"\{#(lst-[\w-]+)"), # {#lst-xyz ...}
|
||
re.compile(r"#\|\s*label:\s*(lst-[\w-]+)"), # #| label: lst-xyz
|
||
],
|
||
# `alg-` is Quarto's native algorithm float; `algo-` is the
|
||
# mlsysbook-ext/pseudocode extension's prefix (native @alg- collides with
|
||
# Quarto's algorithm theorem env and FATALs the build, so the book uses
|
||
# @algo-). Accept both, including the pseudocode chunk-option label form.
|
||
"Algorithm": [
|
||
re.compile(r"\{#(algo?-[\w-]+)"), # {#alg-xyz} / {#algo-xyz}
|
||
re.compile(r"#\|\s*label:\s*(algo?-[\w-]+)"), # #| label: algo-xyz (pseudocode ext)
|
||
],
|
||
}
|
||
LABEL_REF_PATTERN = re.compile(r"@((?:[Ff]ig|[Tt]bl|[Ss]ec|[Ee]q|[Ll]st|[Aa]lgo|[Aa]lg)-[\w-]+)")
|
||
# Pseudocode algorithm label declared as a chunk option inside a ```pseudocode
|
||
# fence (`#| label: algo-xyz`). Harvested even inside code fences so @algo-
|
||
# references resolve (see _run_unreferenced_labels). Accepts alg-/algo-.
|
||
PSEUDOCODE_LABEL_PATTERN = re.compile(r"#\|\s*label:\s*(algo?-[\w-]+)")
|
||
|
||
EXCLUDED_CITATION_PREFIXES = (
|
||
"fig-", "tbl-", "sec-", "eq-", "lst-", "alg-", "algo-", "ch-", "nb-",
|
||
"Fig-", "Tbl-", "Sec-", "Eq-", "Lst-", "Alg-", "Algo-",
|
||
)
|
||
|
||
# Captionless float baseline: per-file counts of pre-existing violations
|
||
# grandfathered when the caption-required / label-required scopes landed.
|
||
# A commit that increases any per-file count fails the check; a commit that
|
||
# *decreases* a count is fine (debt going down). Regenerate with:
|
||
# ./book/binder check tables --scope caption-required --update-baseline
|
||
CAPTIONS_BASELINE_PATH = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "audit" / "baselines" / "captions_baseline.json"
|
||
)
|
||
|
||
# Existing bibliography citations inside scaffolding blocks. New citations in
|
||
# Purpose, learning objectives, or takeaways fail `refs.scaffold-citations`.
|
||
SCAFFOLD_CITATION_BASELINE_PATH = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "audit" / "baselines" / "scaffold_citation_baseline.json"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Scope:
|
||
"""One scope inside a binder check group.
|
||
|
||
A "scope" is a single check that lives inside a group (e.g.
|
||
`refs.citations`, `prose.contractions`). Scopes are the unit of dispatch:
|
||
`./binder check refs --scope citations` runs one. `./binder check refs`
|
||
runs every scope flagged `default=True`. Pre-commit consumes the
|
||
`default=True` set; ad-hoc audits opt into everything via
|
||
`./binder check refs --all-scopes`.
|
||
|
||
Why this matters: as the corpus matures, new scopes get added in a
|
||
"warning-only / not-yet-clean" state. Marking them `default=False`
|
||
lets them ship without blocking commits, while still being runnable
|
||
on demand. Flip to `default=True` once dev is clean for that scope.
|
||
"""
|
||
|
||
name: str
|
||
runner: str
|
||
default: bool = True
|
||
note: str = ""
|
||
|
||
|
||
class ValidateCommand:
|
||
"""Native `binder check` command group (also available as `binder validate`).
|
||
|
||
The hierarchy is:
|
||
binder check <group> # all default=True scopes
|
||
binder check <group> --scope <name> # one specific scope
|
||
binder check <group> --all-scopes # every scope (incl. default=False)
|
||
|
||
The `default` flag on each Scope encodes "is this scope curated for the
|
||
pre-commit/CI baseline today." That is the *only* concept the YAML and CI
|
||
need to know about. Adding a new check is therefore three things:
|
||
|
||
1. Add a `_run_<scope>` method below that returns a ValidationRunResult.
|
||
2. Add a `Scope("<name>", "_run_<scope>", default=...)` entry to the
|
||
appropriate group below. Use `default=False` when the scope still
|
||
fails on dev or is intentionally opt-in (slow, heavy, manual).
|
||
3. If the scope needs new flags, add them to the argparse block in
|
||
`run()` and dispatch them in `_run_group`.
|
||
|
||
Structured fix output (REQUIRED for every ValidationIssue). The check
|
||
output is consumed by automated fixers via `binder check <group> --json`,
|
||
so each issue must be machine-fixable on its own:
|
||
- `file` + `line` — exact location.
|
||
- `code` — a stable snake_case identifier (never reword it;
|
||
tools key on it).
|
||
- `suggestion` — the EXACT fix as `"<wrong> → <right>"`. Always set
|
||
it when the fix is deterministic. When it needs
|
||
human judgment (e.g. a prose range), still give the
|
||
best concrete form and say so in `message`; never
|
||
leave callers with only "fix this".
|
||
- `context` — the surrounding text so a fixer can locate the
|
||
span within the line.
|
||
The human-readable text and `--json` both render from the same
|
||
ValidationIssue, so populating `suggestion` serves both.
|
||
|
||
No new pre-commit hook needed. Pre-commit runs `./binder check <group>`
|
||
and picks up the new scope automatically once it is `default=True`.
|
||
|
||
Group catalogue:
|
||
cli — public Binder command contract (help/error shape)
|
||
refs — refs / citations / cross-refs hygiene
|
||
labels — duplicate labels, orphaned/unreferenced labels
|
||
headers — section header IDs and headline-case policy
|
||
bib — bibliography (.bib) hygiene + canonical forms
|
||
footnotes — definition-shape, placement, integrity, cross-chapter, capitalization
|
||
figures — captions/alt-text, float flow, image files
|
||
markup — low-level markup (patterns, div fences, dropcaps)
|
||
prose — prose style (contractions, dup words, ASCII, ...)
|
||
punctuation — em-dash, slash, vs., e.g./i.e., en-dash ranges
|
||
numbers — units / percent / binary units / currency notation
|
||
math — \\times spacing, attr-leaks, render-audit (manual)
|
||
structure — heading levels, H2 landings, parts, Purpose-unnumbered
|
||
code — python-echo, _str/_math LaTeX leak, LEGO dead code
|
||
tables — grid → pipe, table content
|
||
index — \\index{} placement, anti-patterns, tag-placement, xrefs
|
||
images — file formats, externals, SVG well-formedness
|
||
json — JSON syntax
|
||
units — physics-engine unit conversion tests
|
||
notation — iron-law symbol consistency (BW, R_peak, L_lat, D_vol)
|
||
spelling — prose + TikZ spell checks (requires aspell)
|
||
epub — source hygiene, smoke checks, epubcheck (built)
|
||
pdf — post-build PDF cross-ref / leak scan (built artifact)
|
||
registry — constants → registry migration gates
|
||
sources — source citation patterns
|
||
references — bibliography vs. academic DBs (hallucinator; slow)
|
||
content — content tree (shared/, frontmatter/ required)
|
||
all — every group above
|
||
"""
|
||
|
||
# Single source of truth for the group → scopes hierarchy.
|
||
# Order within each group: cheaper / earlier checks first, since later
|
||
# scopes typically build on earlier ones (e.g. labels.duplicates before
|
||
# labels.orphans). default=False scopes are listed alongside their kin
|
||
# so the file reads as a complete map of what *could* run.
|
||
GROUPS: Dict[str, List[Scope]] = {
|
||
"cli": [
|
||
Scope("contract", "_run_cli_contract",
|
||
note="public Binder command contract"),
|
||
Scope("binder-canonical", "_run_binder_canonical",
|
||
note="book-content pre-commit hooks must dispatch through ./book/binder",
|
||
default=True),
|
||
],
|
||
"refs": [
|
||
# python-syntax: passes on dev but never wired to pre-commit historically;
|
||
# leaving default=False to preserve current coverage exactly. Flip later
|
||
# if we want it in the curated set.
|
||
Scope("python-syntax", "_run_python_syntax", default=False),
|
||
# inline-python + self-ref currently fail on dev (corpus debt).
|
||
# Runnable on demand via --scope or --all-scopes.
|
||
Scope("inline-python", "_run_inline_python", default=False),
|
||
Scope("cross-refs", "_run_refs"),
|
||
Scope("citations", "_run_citations"),
|
||
Scope("scaffold-citations", "_run_scaffold_citations",
|
||
note="no bibliography citations in Purpose, learning objectives, or takeaways; current debt baseline-ratcheted"),
|
||
Scope("duplicate-year", "_run_duplicate_citation_year",
|
||
note='"[@foo1964] (1964)" — redundant year after citation'),
|
||
Scope("duplicate-key", "_run_duplicate_citation_key",
|
||
note='"[@k; @k]" or "[@k] [@k]" — same-key citeproc dup'),
|
||
Scope("manual-bracket", "_run_manual_bracket_citation",
|
||
note='*et al.*(…) — manual attribution next to [@k]'),
|
||
Scope("principles", "_run_principle_refs",
|
||
note="principles use #pri-* IDs and Principle \\ref{pri-*}"),
|
||
# inline scope is run with --check-patterns OFF in the curated set
|
||
# (matches former pre-commit hook). Pattern hazards still live in
|
||
# the corpus; opt in with --check-patterns when cleaning them up.
|
||
Scope("inline", "_run_inline_refs"),
|
||
Scope("self-ref", "_run_self_referential", default=False),
|
||
Scope("capitalized", "_run_mitpress_capitalized_refs",
|
||
note='"chapter 12" lowercase in prose (§10.3.2)'),
|
||
Scope("xref-case", "_run_xref_sentence_start_case",
|
||
note="@-prefix casing must match sentence position: @Fig- at a sentence start, @fig- mid-sentence (both directions; sec/fig/tbl/eq/lst/alg)"),
|
||
],
|
||
"labels": [
|
||
# duplicates and orphans are both curated, but each carries its
|
||
# own implicit filter when invoked from pre-commit:
|
||
# - duplicates: figures + tables + listings only (sections /
|
||
# equations have legitimate dup-key collisions in vol2 WIP)
|
||
# - orphans: vol1 only (vol2 has many forward labels for
|
||
# not-yet-authored chapters)
|
||
# The YAML hook embeds the appropriate flags. fig-labels currently
|
||
# fails (corpus debt) so it stays default=False until cleaned up.
|
||
Scope("duplicates", "_run_duplicate_labels"),
|
||
Scope("orphans", "_run_unreferenced_labels"),
|
||
Scope("fig-labels", "_run_fig_label_underscores", default=False),
|
||
],
|
||
"headers": [
|
||
# ids: vol1 only (vol2 chapters are early-development; many
|
||
# sections still missing IDs). YAML hook embeds --vol1.
|
||
Scope("ids", "_run_headers"),
|
||
Scope("case", "_run_mitpress_heading_case",
|
||
note="MIT Press headline-case (§10.3.1)"),
|
||
],
|
||
"bib": [
|
||
Scope("hygiene", "_run_bib_hygiene"),
|
||
Scope("style", "_run_bib_style",
|
||
note="baseline-ratcheted metadata style: title case, author initials, venues, publishers"),
|
||
Scope("integrity", "_run_bib_integrity",
|
||
note="volume-scoped citation resolution: no missing keys or cross-volume leaks"),
|
||
Scope("orphans", "_run_bib_orphans", default=False,
|
||
note="unused entries in volume BibTeX files; opt-in because current bibs retain legacy/canonical background"),
|
||
# key-content fails on dev (legacy keys grandfathered).
|
||
# Was never wired to pre-commit; default=False preserves status quo.
|
||
Scope("key-content", "_run_bib_key_content", default=False),
|
||
# key-year: ERRORS-ONLY subset of key-content (key year vs entry year,
|
||
# >1yr gap = silent citation corruption). Gated at pre-commit; the
|
||
# surname WARNINGS (intentional project-name keys) stay opt-in above.
|
||
Scope("key-year", "_run_bib_key_year",
|
||
note="key year must match entry year (errors-only; pre-commit gate)"),
|
||
# duplicate-keys: two entries sharing a citekey -> ambiguous citation.
|
||
Scope("duplicate-keys", "_run_bib_duplicate_keys",
|
||
note="every citekey must be unique across the bibliography"),
|
||
# links: every doi/url field must resolve (MIT Press "confirm all
|
||
# URLs"). Network-bound -> opt-in, not a pre-commit gate.
|
||
Scope("links", "_run_bib_links", default=False,
|
||
note="DOI/URL liveness: every locator resolves (network; opt-in)"),
|
||
],
|
||
"footnotes": [
|
||
Scope(
|
||
"definition-shape",
|
||
"_run_footnote_definition_shape",
|
||
note="bold head + colon per book-prose §5 (shapes S1–S5)",
|
||
),
|
||
Scope("placement", "_run_footnote_placement"),
|
||
Scope("integrity", "_run_footnote_refs"),
|
||
Scope("cross-chapter", "_run_footnote_cross_chapter", default=False),
|
||
Scope("capitalization", "_run_footnote_capitalization"),
|
||
],
|
||
"figures": [
|
||
Scope("captions", "_run_figures"),
|
||
Scope("caption-heads", "_run_mitpress_caption_head_style",
|
||
note="fig-cap/tbl-cap/lst-cap use **Bold Title**: Explanation"),
|
||
Scope("div-syntax", "_run_figure_div_syntax"),
|
||
Scope("label-required", "_run_figure_label_required",
|
||
note=" markdown images in body prose need {#fig-X}"),
|
||
# flow: deferred to copyeditor's PDF layout pass (2026-05-03).
|
||
# The "near first reference" heuristic conflicts with the
|
||
# repositioning the copyeditor does to balance pages in print.
|
||
# Re-enable when we own figure placement again.
|
||
Scope("flow", "_run_float_flow", default=False),
|
||
Scope("files", "_run_images"),
|
||
Scope("alt-text-style", "_run_mitpress_alt_text_style",
|
||
note="alt-text follows body-prose rules (§10.12)"),
|
||
],
|
||
# ------------------------------------------------------------------
|
||
# Semantic check groups: classify checks by WHAT is validated
|
||
# (markup patterns, prose style, punctuation, numbers, math, etc.),
|
||
# not by where-the-rule-came-from. A rule's provenance belongs in
|
||
# a comment, not a command name — hence no `mitpress-` prefix on
|
||
# scopes. See the project prose style guide for style provenance.
|
||
# ------------------------------------------------------------------
|
||
"markup": [
|
||
Scope("patterns", "_run_rendering",
|
||
note="low-level markup (backticks, dollar signs, asterisks)"),
|
||
Scope("list-spacing", "_run_markdown_list_spacing",
|
||
note="blank line required between bold lead-in paragraphs and lists"),
|
||
Scope("div-fences", "_run_div_fences",
|
||
note=":::/ :::: balance and form"),
|
||
Scope("callouts", "_run_callout_structure",
|
||
note="supported callout types, titles, and attributes"),
|
||
# default=False until vol2 narrative callouts are normalized to the
|
||
# same schema vol1 uses; flip to True once `--scope callout-schema`
|
||
# is clean on dev for both volumes.
|
||
Scope("callout-schema", "_run_callout_schema", default=False,
|
||
note="per-type bold-label structure (war-story = Context/Failure mode/Systems lesson)"),
|
||
Scope("dropcaps", "_run_dropcaps"),
|
||
],
|
||
"prose": [
|
||
Scope("contractions", "_run_mitpress_contractions",
|
||
note='no "can\'t", "it\'s" in body prose'),
|
||
Scope("spelling-dict", "_run_mitpress_spelling_dict",
|
||
note="canonical spellings (§10.7): trade-off, dataset, data center, Wi-Fi, …"),
|
||
Scope("duplicate-words", "_run_duplicate_words"),
|
||
Scope("unblended-prose", "_run_unblended_prose",
|
||
note="space after period"),
|
||
Scope("above-below", "_run_mitpress_above_below",
|
||
note='no "above"/"below" spatial refs'),
|
||
# ascii: corpus has many legitimate non-ASCII chars (em-dashes,
|
||
# smart quotes from imports). Default=False until corpus-wide
|
||
# ASCII pass lands.
|
||
Scope("ascii", "_run_ascii", default=False),
|
||
Scope("acknowledgements", "_run_mitpress_acknowledgements",
|
||
note="American spelling: Acknowledgments"),
|
||
Scope("compound-prefix", "_run_mitpress_compound_prefix",
|
||
note="pre-/non- close-up (§10.8)"),
|
||
Scope("concept-caps", "_run_mitpress_concept_term_capitalization",
|
||
note="iron law, memory wall lowercase (§10.3)"),
|
||
# abbreviation-first-use currently opt-in because some findings
|
||
# require editorial judgment.
|
||
Scope("abbreviation-first-use", "_run_mitpress_abbreviation_first_use", default=False),
|
||
Scope("latin-abbrevs", "_run_mitpress_latin_running_text",
|
||
note="viz./e.g./etc. in running text (§10.6)"),
|
||
],
|
||
"punctuation": [
|
||
Scope("emdash", "_run_mitpress_spaced_emdash",
|
||
note="word—word, no spaces"),
|
||
Scope("slash", "_run_mitpress_spaced_slash",
|
||
note="training/inference, no spaces"),
|
||
Scope("vs-period", "_run_mitpress_vs_period",
|
||
note="vs. not vs"),
|
||
Scope("eg-ie-comma", "_run_mitpress_eg_ie_comma",
|
||
note="comma after e.g./i.e."),
|
||
Scope("hyphen-range", "_run_mitpress_hyphen_range",
|
||
note="en-dash for number ranges"),
|
||
],
|
||
"numbers": [
|
||
Scope("unit-spacing", "_run_unit_spacing",
|
||
note="100 ms, not 100ms"),
|
||
Scope("binary-units", "_run_binary_units",
|
||
note="GB/TB not GiB/TiB"),
|
||
Scope("percent-spacing", "_run_percent_spacing",
|
||
note="no space before %"),
|
||
Scope("percent-word-before", "_run_percent_word_before",
|
||
note="digits before 'percent', not spelled-out words (5 percent not five percent)"),
|
||
Scope("percent-in-captions", "_run_mitpress_percent_in_captions",
|
||
note="spell out 'percent' in captions"),
|
||
Scope("percent-in-tables", "_run_mitpress_percent_in_tables",
|
||
note="use % symbol inside tables, not the word 'percent'"),
|
||
Scope("percent-in-prose", "_run_mitpress_percent_in_prose",
|
||
note="spell out 'percent' in body prose, not the % symbol"),
|
||
Scope("currency", "_run_currency_style",
|
||
note="use $ in content; USD defined once in notation"),
|
||
Scope("rendered-currency", "_run_rendered_currency_style",
|
||
note="post-render USD/$$/raw-LaTeX currency artifacts",
|
||
default=False),
|
||
],
|
||
"math": [
|
||
Scope("times-spacing", "_run_times_spacing",
|
||
note="space after $\\times$"),
|
||
Scope("times-product-spacing", "_run_times_product_spacing",
|
||
note="space before $\\times$ after inline code"),
|
||
Scope("attr-leaks", "_run_attr_latex_leaks",
|
||
note="LaTeX in title=/fig-cap/tbl-cap/fig-alt/tbl-alt"),
|
||
Scope("canonical", "_run_math_canonical",
|
||
note="fmt-family + _str/_math/_eq/_frac suffix discipline (LEGO)"),
|
||
Scope("prose-contract", "_run_fmt_prose_contract",
|
||
note="formatter-owned glyphs/units are not duplicated in prose"),
|
||
# Added 2026-05-26: blocklist for banned suffix= values in fmt()
|
||
# calls (wrong unit conventions like TFLOPS, Gbps, etc.).
|
||
Scope("suffix-consistency", "_run_suffix_consistency",
|
||
note='banned suffix= values (TFLOPS, Gbps, etc.)'),
|
||
Scope("multiplier-style", "_run_math_multiplier_style",
|
||
note="body-prose multiplier suffixes, Unicode ×, product spacing",
|
||
default=False),
|
||
# Typed-formatter migration gate: value-kind (percent/multiplier/
|
||
# percentage-points/count-scale) must be a typed formatter, not a
|
||
# free-text suffix= on fmt()/fmt_int(). default=False until the
|
||
# corpus migration completes; flip to gate on pre-commit.
|
||
Scope("suffix-semantics", "_run_fmt_semantic_suffix",
|
||
note="value-kind in suffix= must be a typed formatter",
|
||
default=False),
|
||
# render-audit builds every chapter (~10 min). Manual stage only;
|
||
# default=False ensures `binder check math` stays under 1s.
|
||
Scope("render-audit", "_run_math_render_audit", default=False),
|
||
],
|
||
"structure": [
|
||
Scope("heading-levels", "_run_heading_levels",
|
||
note="H1→H2→H3 hierarchy"),
|
||
Scope("h2-landings", "_run_h2_landings",
|
||
note="## sections need prose before ### subsections"),
|
||
Scope("parts", "_run_parts",
|
||
note="part keys valid"),
|
||
Scope("purpose-unnumbered", "_run_purpose_unnumbered",
|
||
note="Purpose sections unnumbered"),
|
||
],
|
||
"code": [
|
||
Scope("python-echo", "_run_python_echo",
|
||
note="echo: false on python blocks"),
|
||
Scope("str-latex-leak", "_run_str_latex_leak",
|
||
note="*_str exports must not contain raw LaTeX"),
|
||
Scope("lego-dead-code", "_run_lego_dead_code",
|
||
note="LEGO variables defined but never referenced"),
|
||
Scope("lego-prose-literals", "_run_lego_prose_literals",
|
||
note="hardcoded numbers in callout walkthroughs that share {python} refs"),
|
||
Scope("lego-prose-units", "_run_lego_prose_units",
|
||
note="unit/currency tokens after {python} *_str refs", default=False),
|
||
Scope("lego-load-pint", "_run_lego_load_pint",
|
||
note="physical *_value assignments must use ureg/registry", default=False),
|
||
Scope("lego-equations", "_run_lego_equations",
|
||
note="A/B=C prose lines must match computed values", default=False),
|
||
Scope("lego-units", "_run_lego_units",
|
||
note="LEGO unit discipline (warning-only + baseline)", default=True),
|
||
# Added 2026-05-26: \\${python} collision — escaped dollar before
|
||
# {python} silently fails to render; correct form is \\$\\`{python}.
|
||
Scope("python-dollar-collision", "_run_python_dollar_collision",
|
||
note="\\${python} collision — escaped dollar silently kills inline expr"),
|
||
# Added 2026-05-26: scan built HTML for literal {python} text that
|
||
# leaked through Quarto (expression failed to evaluate). Requires
|
||
# a prior html-audit build. Default=False (opt-in audit).
|
||
Scope("rendered-python-leak", "_run_rendered_python_leak",
|
||
note="literal {python} leaked into rendered HTML", default=False),
|
||
],
|
||
"tables": [
|
||
Scope("grid-tables", "_run_grid_tables",
|
||
note="prefer pipe tables"),
|
||
Scope("content", "_run_table_content",
|
||
note="bare pipes, fracs, HTML entities"),
|
||
Scope("caption-required", "_run_table_caption_required",
|
||
note="body-prose pipe tables need : caption {#tbl-X}"),
|
||
Scope("caption-position", "_run_table_caption_position",
|
||
note="pipe-table captions belong below the table"),
|
||
Scope("caption-orphan", "_run_table_caption_orphan",
|
||
note="caption between two `:::` closes breaks EPUB"),
|
||
Scope("caption-detached", "_run_table_caption_detached",
|
||
note="prose between pipe table and its `: caption {#tbl-}` breaks xref"),
|
||
],
|
||
"listings": [
|
||
# Listings in this book always use ::: {#lst-X lst-cap="..."} divs.
|
||
# caption-required enforces that any #lst- label carries an
|
||
# lst-cap (matching the figure/table caption-head convention).
|
||
Scope("caption-required", "_run_listing_caption_required",
|
||
note="any #lst- listing must carry lst-cap=..."),
|
||
],
|
||
"index": [
|
||
Scope("placement", "_run_indexes",
|
||
note='\\index{} not inline with headings/callouts'),
|
||
# Migrated 2026-05-06: was book/tools/audit/index/check_anti_patterns.py
|
||
Scope("anti-patterns", "_run_index_anti_patterns",
|
||
note="anti-patterns from the project index rules §9"),
|
||
# Migrated 2026-05-06: was book/tools/audit/index/check_tag_placement.py
|
||
Scope("tag-placement", "_run_index_tag_placement",
|
||
note='\\index{} not inside **bold**, *italic*, `code`, or headings'),
|
||
# Migrated 2026-05-06: was book/tools/audit/index/check_xref_resolves.py
|
||
Scope("xref-resolves", "_run_index_xref_resolves",
|
||
note="every |see / |seealso target resolves to a real main entry"),
|
||
# Added 2026-05-17: catches \index{} inside Python code fences,
|
||
# $..$ / $$..$$ math, and fig-cap/fig-alt/tbl-cap/lst-cap/title=
|
||
# attribute strings. Complements `placement` (which checks
|
||
# heading/div/footnote adjacency).
|
||
Scope("forbidden-contexts", "_run_index_placement_contexts",
|
||
note='\\index{} not inside code / math / attribute strings'),
|
||
],
|
||
|
||
"images": [
|
||
Scope("formats", "_run_image_formats"),
|
||
Scope("external", "_run_external_images"),
|
||
Scope("svg-xml", "_run_svg_wellformedness"),
|
||
],
|
||
"json": [
|
||
Scope("syntax", "_run_json_syntax"),
|
||
],
|
||
"units": [
|
||
# Physics-engine unit tests. Heavyweight for pre-commit; runs in
|
||
# under 5s today but is fundamentally a test suite, not a static
|
||
# check. TODO: consider moving to CI.
|
||
Scope("physics", "_run_unit_tests"),
|
||
],
|
||
"notation": [
|
||
Scope("consistency", "_run_notation_consistency",
|
||
note="iron-law symbols (BW, R_peak, L_lat, D_vol)"),
|
||
],
|
||
"spelling": [
|
||
# Both scopes require aspell. Heavy and not part of pre-commit
|
||
# today. Default=False until aspell is on every contributor box.
|
||
Scope("prose", "_run_spelling_prose", default=False),
|
||
Scope("tikz", "_run_spelling_tikz", default=False),
|
||
],
|
||
"epub": [
|
||
# Fast source-level invariants (<1s across SVGs + .bib).
|
||
# Suitable for pre-commit.
|
||
Scope("hygiene", "_run_epub_hygiene"),
|
||
# Reader-compatibility smoke checks against the built EPUB.
|
||
# Requires a prior epub build under _build/epub-vol*/.
|
||
# Default=False because it presupposes a build step.
|
||
Scope("smoke", "_run_epub_smoke", default=False),
|
||
# Full W3C epubcheck validation. Requires epubcheck + JRE,
|
||
# slow (~30s per volume). CI-only.
|
||
Scope("epubcheck", "_run_epubcheck", default=False),
|
||
],
|
||
"pdf": [
|
||
# Scans _build/pdf-vol*/ artifact via pdftotext. Requires
|
||
# --vol1 or --vol2 (or run both explicitly).
|
||
Scope("verify", "_run_pdf_verify"),
|
||
# Narrow rendered-PDF counter regression gate. Useful after
|
||
# chapter-only builds, where full cross-reference verification can
|
||
# be noisy because other chapters are intentionally absent.
|
||
Scope("numbering", "_run_pdf_numbering"),
|
||
# Narrow rendered-layout gate for table/prose crowding. Useful
|
||
# after chapter-only builds where `verify` can be noisy.
|
||
Scope("table-spacing", "_run_pdf_table_spacing"),
|
||
# Added 2026-05-26: scan PDF text for "UserWarning" strings that
|
||
# may indicate a Python warning leaked into the rendered output.
|
||
# Default=False — post-build audit, not a pre-commit gate.
|
||
Scope("pdf-warnings", "_run_pdf_warnings", default=False),
|
||
],
|
||
"registry": [
|
||
Scope("sources", "_run_registry_sources",
|
||
note="QMD LEGO cells: banned legacy constant/registry imports"),
|
||
Scope("tests", "_run_registry_tests",
|
||
note="mlsysim pytest registry gate tests"),
|
||
Scope("appendix", "_run_registry_appendix",
|
||
note="appendix LEGO cells exec against live registry"),
|
||
Scope("anchors", "_run_registry_anchors",
|
||
note="paper anchor validation"),
|
||
Scope("yaml-pending", "_run_registry_yaml_pending",
|
||
note="audit YAML should_change=true count must be zero"),
|
||
],
|
||
"sources": [
|
||
Scope("citations", "_run_sources"),
|
||
],
|
||
"references": [
|
||
# Hits external academic DBs (slow, network-dependent).
|
||
# Manual / CI-only by design.
|
||
Scope("hallucinator", "_run_check_references", default=False),
|
||
],
|
||
"content": [
|
||
# Release-only structure check for the canonical two-volume tree.
|
||
Scope("tree", "_run_content_tree", default=False),
|
||
],
|
||
}
|
||
|
||
def __init__(self, config_manager, chapter_discovery):
|
||
self.config_manager = config_manager
|
||
self.chapter_discovery = chapter_discovery
|
||
|
||
def run(self, args: List[str]) -> bool:
|
||
if args == ["help"]:
|
||
self._print_check_help()
|
||
return True
|
||
|
||
# Per-group help: `./binder check <group> help` prints a
|
||
# dedicated help panel for that group, including concrete
|
||
# error codes. This lives above argparse because `help` is
|
||
# not a valid scope in the GROUPS dict; intercepting it here
|
||
# keeps the argparse surface narrow while still giving each
|
||
# group room for bespoke guidance.
|
||
if len(args) == 2 and args[1] == "help":
|
||
group = args[0]
|
||
if group in self.GROUPS:
|
||
self._print_group_help(group)
|
||
return True
|
||
|
||
all_group_names = list(self.GROUPS.keys()) + ["all"]
|
||
parser = argparse.ArgumentParser(
|
||
prog="binder check",
|
||
description="Run quality checks on book content",
|
||
add_help=True,
|
||
)
|
||
parser.add_argument(
|
||
"subcommand",
|
||
nargs="?",
|
||
choices=all_group_names,
|
||
help="Check group to run; use `binder check` to see the live catalogue",
|
||
)
|
||
parser.add_argument(
|
||
"files",
|
||
nargs="*",
|
||
help="Optional file(s) or directories to check; used by pre-commit",
|
||
)
|
||
parser.add_argument("--scope", default=None, help="Narrow to a specific check within a group")
|
||
parser.add_argument(
|
||
"--all-scopes", action="store_true", default=False,
|
||
help="Include scopes marked default=False (heavy / not-yet-clean / opt-in). "
|
||
"Without this flag, `binder check <group>` runs only the curated set.",
|
||
)
|
||
parser.add_argument("--path", default=None, help="File or directory path to check")
|
||
parser.add_argument("--vol1", action="store_true", help="Scope to Volume I")
|
||
parser.add_argument("--vol2", action="store_true", help="Scope to Volume II")
|
||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON output")
|
||
parser.add_argument("--verbose", "-v", action="store_true", default=True, help="Show context for each issue (default)")
|
||
parser.add_argument("--quiet", "-q", action="store_true", dest="quiet", help="Suppress verbose output")
|
||
parser.add_argument("--citations-in-code", action="store_true", help="refs: check citations in code fences")
|
||
parser.add_argument("--citations-in-raw", action="store_true", help="refs: check citations in raw blocks")
|
||
# Default OFF: the inline scope ships in the curated set without
|
||
# pattern checks (matches pre-2026-05 hook behavior; the pattern
|
||
# corpus has known noise that needs cleanup before flipping default).
|
||
parser.add_argument("--check-patterns", action="store_true", default=False, help="refs --scope inline: include pattern hazard checks (opt-in; noisy on dev)")
|
||
parser.add_argument("--no-check-patterns", action="store_false", dest="check_patterns", help="refs --scope inline: skip pattern hazard checks (default)")
|
||
parser.add_argument("--check-scope", action="store_true", default=False, help="refs --scope inline: detect bare variable refs in class bodies that need ClassName.attr")
|
||
parser.add_argument("--no-check-scope", action="store_false", dest="check_scope", help="refs --scope inline: skip scope analysis")
|
||
parser.add_argument("--include-lightbox", action="store_true", default=False, help="math --scope attr-leaks: also surface fig-cap/tbl-cap math that leaks into HTML lightbox tooltips (warning, opt-in; ~70 pre-existing instances on dev)")
|
||
parser.add_argument("--figures", action="store_true", help="labels: filter to figures")
|
||
parser.add_argument("--tables", action="store_true", help="labels: filter to tables")
|
||
parser.add_argument("--sections", action="store_true", help="labels: filter to sections")
|
||
parser.add_argument("--equations", action="store_true", help="labels: filter to equations")
|
||
parser.add_argument("--listings", action="store_true", help="labels: filter to listings")
|
||
parser.add_argument("--all-types", action="store_true", help="labels: all label types")
|
||
parser.add_argument("-f", "--file", dest="refs_file", action="append", metavar="BIB", help="references: .bib file(s) to check")
|
||
parser.add_argument("-o", "--output", dest="refs_output", metavar="FILE", help="references: write report to FILE")
|
||
parser.add_argument("--limit", type=int, dest="refs_limit", metavar="N", help="references: check only first N refs (quick test)")
|
||
parser.add_argument("--skip-verified", dest="refs_skip_verified", action="store_true", help="references: skip refs already verified in cache")
|
||
parser.add_argument("--thorough", dest="refs_thorough", action="store_true", help="references: revalidate all refs (ignore cache)")
|
||
parser.add_argument("--refs-cache", dest="refs_cache", metavar="FILE", help="references: cache file (default: .references_verified.json in repo root)")
|
||
parser.add_argument("--only-from-report", dest="refs_only_from_report", metavar="FILE", help="references: validate only keys that had issues in this report file")
|
||
parser.add_argument("--only-keys", dest="refs_only_keys_file", metavar="FILE", help="references: validate only keys listed in FILE (one key per line)")
|
||
# epub --scope hygiene: auto-repair source files in-place.
|
||
parser.add_argument(
|
||
"--fix", action="store_true",
|
||
help="epub --scope hygiene: auto-repair SVG / BibTeX source invariants in place",
|
||
)
|
||
# epub --scope epubcheck: thresholds for FATAL and ERROR counts.
|
||
# Defaults: MAX_FATAL=0 (any FATAL fails the check — Kindle /
|
||
# ClearView reject), MAX_ERRORS=unlimited (grandfathered while the
|
||
# RSC-005/RSC-012 baselines stabilize). Tighten with --max-errors 0
|
||
# once the baseline is clean.
|
||
parser.add_argument(
|
||
"--max-fatal", type=int, default=0,
|
||
help="epub --scope epubcheck: max FATAL issues allowed before failure (default: 0)",
|
||
)
|
||
parser.add_argument(
|
||
"--max-errors", type=int, default=None,
|
||
help="epub --scope epubcheck: max ERROR issues allowed before failure (default: unlimited)",
|
||
)
|
||
# Ratchet: fail only when counts *increase* over a recorded baseline.
|
||
# Lets CI block regression without cliff-failing during incremental
|
||
# cleanup. Pairs with --update-baseline to re-record after a fix.
|
||
parser.add_argument(
|
||
"--baseline",
|
||
metavar="PATH",
|
||
default=None,
|
||
help="epub --scope epubcheck: JSON baseline of per-volume counts; fail only if current counts exceed baseline",
|
||
)
|
||
parser.add_argument(
|
||
"--update-baseline",
|
||
action="store_true",
|
||
help="epub --scope epubcheck: rewrite --baseline file with current counts (requires --baseline)",
|
||
)
|
||
parser.add_argument(
|
||
"--log", dest="pdf_log", type=str, default=None,
|
||
help="pdf --scope verify: optional Quarto render log to scan for crossref warnings",
|
||
)
|
||
|
||
try:
|
||
ns = parser.parse_args(args)
|
||
except SystemExit:
|
||
# argparse uses SystemExit(0) for --help and non-zero for parse errors.
|
||
return ("-h" in args) or ("--help" in args)
|
||
|
||
if not ns.subcommand:
|
||
self._print_check_help()
|
||
return True
|
||
|
||
root_path = self._resolve_path(ns.path, ns.vol1, ns.vol2)
|
||
if not root_path.exists():
|
||
self._emit(ns.json, {"status": "error", "message": f"Path not found: {root_path}"}, failed=True)
|
||
return False
|
||
|
||
runs: List[ValidationRunResult] = []
|
||
|
||
if ns.subcommand == "all":
|
||
for group_name in self.GROUPS:
|
||
runs.extend(self._run_group(group_name, None, root_path, ns))
|
||
else:
|
||
group_name = ns.subcommand
|
||
scope = ns.scope
|
||
if scope and not any(sc.name == scope for sc in self.GROUPS.get(group_name, [])):
|
||
valid = [sc.name for sc in self.GROUPS[group_name]]
|
||
console.print(f"[red]Unknown scope '{scope}' for group '{group_name}'.[/red]")
|
||
console.print(f"[yellow]Valid scopes: {', '.join(valid)}[/yellow]")
|
||
return False
|
||
runs.extend(self._run_group(group_name, scope, root_path, ns))
|
||
|
||
any_failed = any(not run.passed for run in runs)
|
||
summary = {
|
||
"status": "failed" if any_failed else "passed",
|
||
"command": ns.subcommand,
|
||
"path": str(root_path),
|
||
"runs": [run.to_dict() for run in runs],
|
||
"total_issues": sum(len(run.issues) for run in runs),
|
||
}
|
||
|
||
if ns.json:
|
||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
||
else:
|
||
verbose = not getattr(ns, "quiet", False)
|
||
self._print_human_summary(summary, verbose=verbose)
|
||
|
||
return not any_failed
|
||
|
||
# ------------------------------------------------------------------
|
||
# Group dispatch
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_group(
|
||
self,
|
||
group: str,
|
||
scope: Optional[str],
|
||
root: Path,
|
||
ns: argparse.Namespace,
|
||
) -> List[ValidationRunResult]:
|
||
"""Run checks in *group*.
|
||
|
||
Selection rules (in order):
|
||
1. If `scope` is set: run that one scope (regardless of its `default` flag).
|
||
2. Otherwise, run every scope in the group whose `default=True`.
|
||
3. If `--all-scopes` is set: also include `default=False` scopes.
|
||
|
||
This three-way distinction is what lets pre-commit run a curated
|
||
set without flag soup, while ad-hoc audits (`--all-scopes`) and
|
||
targeted runs (`--scope`) still reach every scope on demand.
|
||
"""
|
||
all_scopes = getattr(ns, "all_scopes", False)
|
||
results: List[ValidationRunResult] = []
|
||
for scope_obj in self.GROUPS[group]:
|
||
scope_name = scope_obj.name
|
||
method_name = scope_obj.runner
|
||
if scope:
|
||
if scope != scope_name:
|
||
continue
|
||
elif not all_scopes and not scope_obj.default:
|
||
# Default group invocation skips scopes flagged default=False.
|
||
# Run them via `--scope <name>` or `--all-scopes`.
|
||
continue
|
||
method = getattr(self, method_name)
|
||
# Some runners need extra kwargs
|
||
if method_name == "_run_refs":
|
||
checks_code = ns.citations_in_code or (not ns.citations_in_code and not ns.citations_in_raw)
|
||
checks_raw = ns.citations_in_raw or (not ns.citations_in_code and not ns.citations_in_raw)
|
||
results.append(method(root, citations_in_code=checks_code, citations_in_raw=checks_raw))
|
||
elif method_name == "_run_inline_refs":
|
||
results.append(method(root, check_patterns=ns.check_patterns,
|
||
check_scope=getattr(ns, 'check_scope', False)))
|
||
elif method_name == "_run_attr_latex_leaks":
|
||
results.append(method(root, include_lightbox=getattr(ns, 'include_lightbox', False)))
|
||
elif method_name == "_run_duplicate_labels":
|
||
# Curated default for duplicates: figures + tables + listings only.
|
||
# Section / equation dup-keys are legitimately ambiguous in vol2
|
||
# WIP (forward-referenced labels not yet authored) and would
|
||
# block every commit. Explicit type flags from the user override
|
||
# this default and switch to the user-selected subset.
|
||
explicit = (ns.figures or ns.tables or ns.sections or
|
||
ns.equations or ns.listings or ns.all_types)
|
||
if explicit:
|
||
label_types = self._selected_label_types(ns)
|
||
else:
|
||
label_types = {
|
||
k: v for k, v in LABEL_DEF_PATTERNS.items()
|
||
if k in ("Figure", "Table", "Listing")
|
||
}
|
||
results.append(method(root, label_types))
|
||
elif method_name == "_run_unreferenced_labels":
|
||
# Curated default for orphans: all label types. The vol filter
|
||
# comes from --vol1 / --vol2 at the path level (vol2 has many
|
||
# forward references not yet authored — pre-commit pins to
|
||
# --vol1 to avoid blocking commits on legitimate forwards).
|
||
results.append(method(root, self._selected_label_types(ns)))
|
||
elif method_name == "_run_check_references":
|
||
results.append(method(root, ns))
|
||
elif method_name == "_run_sources":
|
||
results.append(method(root, ns))
|
||
elif method_name == "_run_epubcheck":
|
||
# Thresholds come from --max-fatal / --max-errors; when not
|
||
# supplied on the CLI we keep max_errors=None (unlimited).
|
||
# The ratchet takes over when --baseline is supplied.
|
||
results.append(method(
|
||
root,
|
||
max_fatal=ns.max_fatal,
|
||
max_errors=ns.max_errors,
|
||
baseline_path=ns.baseline,
|
||
update_baseline=ns.update_baseline,
|
||
))
|
||
elif method_name == "_run_epub_hygiene":
|
||
results.append(method(root, fix=getattr(ns, 'fix', False)))
|
||
elif method_name == "_run_pdf_verify":
|
||
results.append(method(root, vol1=ns.vol1, vol2=ns.vol2, log_path=getattr(ns, 'pdf_log', None)))
|
||
elif method_name == "_run_pdf_numbering":
|
||
results.append(method(root, vol1=ns.vol1, vol2=ns.vol2))
|
||
elif method_name == "_run_pdf_table_spacing":
|
||
results.append(method(root, vol1=ns.vol1, vol2=ns.vol2))
|
||
else:
|
||
results.append(method(root))
|
||
return results
|
||
|
||
def _print_check_help(self) -> None:
|
||
"""Print a nicely formatted help for the check command."""
|
||
table = Table(show_header=True, header_style="bold cyan", box=None)
|
||
table.add_column("Group", style="cyan", width=14)
|
||
table.add_column("Scopes", style="yellow", width=38)
|
||
table.add_column("Description", style="white", width=32)
|
||
|
||
descriptions = {
|
||
"refs": "References, citations, inline Python, self-ref",
|
||
"cli": "Public Binder command contract (help, reset, removed aliases)",
|
||
"labels": "Duplicate labels, orphans, fig-label underscores",
|
||
"headers": "Section header IDs ({#sec-...}), H1-H5 case policy (MIT Press §10.3.1)",
|
||
"bib": "Bibliography hygiene — schema + canonical forms (§5)",
|
||
"footnotes": "Placement, integrity, cross-chapter duplicates, sentence-case first letter",
|
||
"figures": "Captions, float flow, image files",
|
||
"markup": "Low-level markup (patterns, div-fences, dropcaps)",
|
||
"prose": "Prose style (contractions, dup words, ASCII, above/below, Acknowledgments)",
|
||
"punctuation": "Em-dash, slash, vs. period, e.g./i.e. comma, en-dash ranges",
|
||
"numbers": "Units + percent spacing, binary units, percent-in-captions, currency",
|
||
"math": "\\times spacing, attr-leaks, fmt/suffix discipline (LEGO), multiplier prose, optional render audit",
|
||
"structure": "Heading levels, H2 landings, parts, Purpose-unnumbered",
|
||
"code": "Python code blocks (echo: false, _str/_math export hygiene)",
|
||
"tables": "Grid tables → pipe, table content hygiene, caption-required",
|
||
"listings": "Code listings carry lst-cap when labeled",
|
||
"index": "Index placement (\\index{} outside headings/callouts)",
|
||
"images": "Image file formats, external URLs",
|
||
"json": "JSON file syntax validation",
|
||
"units": "Physics engine unit conversion tests",
|
||
"spelling": "Prose and TikZ spell checking (requires aspell)",
|
||
"epub": "EPUB hygiene (source), epubcheck (built), structure (legacy)",
|
||
"pdf": "Built PDF cross-ref / LaTeX undefined-ref / traceback scan",
|
||
"registry": "Constants → registry migration gates (sources, tests, appendix)",
|
||
"sources": "Source citation analysis and validation",
|
||
"references": "Bibliography vs academic DBs (hallucinator)",
|
||
"content": "Content tree (shared/, frontmatter/ required)",
|
||
}
|
||
for group_name, checks in self.GROUPS.items():
|
||
# Show curated scopes plain; mark default=False ones with a dim asterisk
|
||
# so the user knows they exist but require --all-scopes / --scope opt-in.
|
||
parts = []
|
||
for sc in checks:
|
||
parts.append(sc.name if sc.default else f"{sc.name}*")
|
||
table.add_row(group_name, ", ".join(parts), descriptions.get(group_name, ""))
|
||
table.add_row("all", "(every group)", "Run every group's curated set; add --all-scopes for opt-in too")
|
||
|
||
console.print(Panel(table, title="binder check <group> [--scope <name>]", border_style="cyan"))
|
||
console.print("[dim]Examples:[/dim]")
|
||
console.print(" [cyan]./binder check refs[/cyan] [dim]# curated default scopes for refs[/dim]")
|
||
console.print(" [cyan]./binder check refs --scope citations[/cyan] [dim]# one specific scope[/dim]")
|
||
console.print(" [cyan]./binder check refs --all-scopes[/cyan] [dim]# every scope including opt-in (* in table)[/dim]")
|
||
console.print(" [cyan]./binder check figures --vol1[/cyan] [dim]# default figure scopes, Vol I only[/dim]")
|
||
console.print(" [cyan]./binder check all[/cyan] [dim]# every group, default scopes[/dim]")
|
||
console.print(" [cyan]./binder check <group> help[/cyan] [dim]# per-group error codes + guidance[/dim]")
|
||
console.print("[dim]An asterisk (*) marks a scope that is default=False — runnable via --scope or --all-scopes.[/dim]")
|
||
console.print()
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def _print_group_help(self, group: str) -> None:
|
||
"""Dispatch to per-group help. Falls back to a generic listing."""
|
||
if group == "epub":
|
||
self._print_epub_help()
|
||
return
|
||
# Generic fallback: list scopes, default flag, runner method, and the
|
||
# short note (if any). The Default column is the contract pre-commit /
|
||
# CI rely on.
|
||
scopes = self.GROUPS.get(group, [])
|
||
table = Table(show_header=True, header_style="bold cyan", box=None)
|
||
table.add_column("Scope", style="yellow")
|
||
table.add_column("Default", style="green", width=8)
|
||
table.add_column("Note", style="white")
|
||
table.add_column("Runner", style="dim")
|
||
for sc in scopes:
|
||
table.add_row(
|
||
sc.name,
|
||
"yes" if sc.default else "opt-in",
|
||
_rich_escape(sc.note) if sc.note else "",
|
||
sc.runner,
|
||
)
|
||
console.print(Panel(table, title=f"binder check {group}", border_style="cyan"))
|
||
console.print(f"[dim]Run [cyan]./binder check {group}[/cyan] for the curated set "
|
||
f"(Default = yes), [cyan]--scope <name>[/cyan] for one specific scope, "
|
||
f"or [cyan]--all-scopes[/cyan] to include opt-in scopes.[/dim]")
|
||
console.print()
|
||
|
||
def _print_epub_help(self) -> None:
|
||
"""Dedicated help for the `epub` group: the three scopes, the
|
||
error codes each can surface, and how to fix them at source.
|
||
|
||
This panel exists because the `epub` group is the most common
|
||
source of unexpected failures for new contributors — the error
|
||
codes (RSC-005, RSC-016, RSC-020, OPF-014, smoke-css-*) are
|
||
cryptic in isolation, but map cleanly to a few source-level
|
||
patterns. Printing the mapping here saves cross-referencing
|
||
the README when a CI failure is in hand.
|
||
"""
|
||
# --- Scopes panel -----------------------------------------------
|
||
scope_table = Table(show_header=True, header_style="bold cyan", box=None)
|
||
scope_table.add_column("Scope", style="yellow", width=12)
|
||
scope_table.add_column("When to run", style="white", width=30)
|
||
scope_table.add_column("Cost", style="dim", width=18)
|
||
scope_table.add_column("Needs", style="dim", width=12)
|
||
scope_table.add_row("hygiene", "Every commit (pre-commit)", "<1s, scans source", "—")
|
||
scope_table.add_row("smoke", "After build, for reader compat", "~200ms, scans built EPUB", "—")
|
||
scope_table.add_row("epubcheck", "CI, before release", "~7s per volume", "Java + epubcheck")
|
||
console.print(Panel(scope_table, title="./binder check epub — scopes", border_style="cyan"))
|
||
|
||
# --- Error code table ------------------------------------------
|
||
# Scope is derivable from the code prefix: svg-/bib-* = hygiene,
|
||
# smoke-* = smoke, RSC-*/OPF-* = epubcheck. Dropping the column
|
||
# leaves room for readable Trigger / Fix copy.
|
||
code_table = Table(show_header=True, header_style="bold cyan", box=None)
|
||
code_table.add_column("Code", style="red", width=32)
|
||
code_table.add_column("Trigger", style="white", width=30)
|
||
code_table.add_column("Fix at source", style="green", width=32)
|
||
|
||
rows = [
|
||
("svg-c0",
|
||
"C0 control char in SVG aria-label",
|
||
"Strip in Python plot title; or --fix"),
|
||
("svg-dupe-marker",
|
||
"Duplicate <marker id=…/> in one SVG",
|
||
"Delete duplicate; or --fix"),
|
||
("bib-url-escape-underscore",
|
||
r"\_ in bib url= or http doi=",
|
||
r"\_ → _ in the .bib; or --fix"),
|
||
("bib-url-escape-percent",
|
||
r"\% in bib URL field",
|
||
r"\% → % in the .bib; or --fix"),
|
||
("bib-url-raw-angle",
|
||
"raw < or > in bib URL field",
|
||
"%3C / %3E encode; or --fix"),
|
||
("smoke-css-custom-property-decl",
|
||
"--var-name: value; in packaged CSS",
|
||
"Inline the literal value"),
|
||
("smoke-css-custom-property-use",
|
||
"var(--x) in packaged CSS",
|
||
"Inline the literal value"),
|
||
("smoke-external-resource",
|
||
"src=/<link href= to http(s)://",
|
||
"Package the asset or drop"),
|
||
("RSC-016 (FATAL)",
|
||
"XML malformed (--, <br>, C0)",
|
||
"epub_postprocess sanitizes; fix at source if it recurs"),
|
||
("RSC-005",
|
||
"Malformed markup (alt on wrong elem)",
|
||
"Fix the emitter in the Quarto filter"),
|
||
("RSC-020",
|
||
"Invalid URL syntax in href",
|
||
"Same as bib-url-* above"),
|
||
("RSC-012",
|
||
"Broken fragment ID",
|
||
"resolve_cross_references.py handles this"),
|
||
("OPF-014",
|
||
"Missing mathml / other OPF property",
|
||
"epub_postprocess aligns nav mathml property"),
|
||
]
|
||
for code, trigger, fix in rows:
|
||
code_table.add_row(code, trigger, fix)
|
||
console.print(Panel(code_table, title="Error codes", border_style="red"))
|
||
|
||
# --- Command quick reference -----------------------------------
|
||
console.print("[bold]Common invocations:[/bold]")
|
||
console.print(" [cyan]./binder check epub[/cyan] [dim]# hygiene + smoke + epubcheck[/dim]")
|
||
console.print(" [cyan]./binder check epub --scope hygiene[/cyan] [dim]# source-only, fast[/dim]")
|
||
console.print(" [cyan]./binder check epub --scope hygiene --fix[/cyan] [dim]# auto-repair source issues[/dim]")
|
||
console.print(" [cyan]./binder check epub --scope epubcheck --max-fatal 0 --max-errors 0[/cyan]")
|
||
console.print(" [dim] # CI-style strict gating[/dim]")
|
||
console.print(" [cyan]./binder check epub --json[/cyan] [dim]# structured output[/dim]")
|
||
console.print(" [cyan]./binder build epub --vol1 --skip-hygiene[/cyan] [dim]# emergency build bypass[/dim]")
|
||
console.print()
|
||
console.print("[dim]Deep dive: book/cli/README.md → \"EPUB Checks — Two Layers, One CLI Surface\"[/dim]")
|
||
console.print()
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def _resolve_path(self, path_arg: Optional[str], vol1: bool, vol2: bool) -> Path:
|
||
if path_arg:
|
||
path = Path(path_arg)
|
||
if not path.is_absolute():
|
||
path = (Path.cwd() / path).resolve()
|
||
return path
|
||
base = self.config_manager.book_dir / "contents"
|
||
if vol1 and not vol2:
|
||
return base / "vol1"
|
||
if vol2 and not vol1:
|
||
return base / "vol2"
|
||
return base
|
||
|
||
def _selected_label_types(self, ns: argparse.Namespace) -> Dict[str, List[re.Pattern[str]]]:
|
||
explicit = ns.figures or ns.tables or ns.sections or ns.equations or ns.listings
|
||
if ns.all_types:
|
||
return LABEL_DEF_PATTERNS
|
||
if explicit:
|
||
selected: Dict[str, List[re.Pattern[str]]] = {}
|
||
if ns.figures:
|
||
selected["Figure"] = LABEL_DEF_PATTERNS["Figure"]
|
||
if ns.tables:
|
||
selected["Table"] = LABEL_DEF_PATTERNS["Table"]
|
||
if ns.sections:
|
||
selected["Section"] = LABEL_DEF_PATTERNS["Section"]
|
||
if ns.equations:
|
||
selected["Equation"] = LABEL_DEF_PATTERNS["Equation"]
|
||
if ns.listings:
|
||
selected["Listing"] = LABEL_DEF_PATTERNS["Listing"]
|
||
return selected
|
||
# default: all label types
|
||
return LABEL_DEF_PATTERNS
|
||
|
||
def _qmd_files(self, root: Path) -> List[Path]:
|
||
if root.is_file():
|
||
return [root] if root.suffix == ".qmd" else []
|
||
return sorted(
|
||
p for p in root.rglob("*.qmd") if "_shelved" not in p.name
|
||
)
|
||
|
||
def _bib_files(self, root: Path) -> List[Path]:
|
||
if root.is_file():
|
||
return [root] if root.suffix == ".bib" else []
|
||
return sorted(root.rglob("*.bib"))
|
||
|
||
def _read_text(self, path: Path) -> str:
|
||
try:
|
||
return path.read_text(encoding="utf-8")
|
||
except UnicodeDecodeError:
|
||
return path.read_text(encoding="utf-8", errors="ignore")
|
||
|
||
def _relative_file(self, path: Path) -> str:
|
||
try:
|
||
return str(path.relative_to(self.config_manager.book_dir))
|
||
except ValueError:
|
||
return str(path)
|
||
|
||
def _run_cli_contract(self, root: Path) -> ValidationRunResult:
|
||
"""Run read-only command-surface contract checks for Binder itself."""
|
||
start = time.time()
|
||
from cli.checks import cli_contract
|
||
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
violations = cli_contract.run_contract(repo_root)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=violation.file,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
)
|
||
for violation in violations
|
||
]
|
||
return ValidationRunResult(
|
||
name="cli-contract",
|
||
description="Public Binder CLI command contract",
|
||
files_checked=len(cli_contract.CASES),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_binder_canonical(self, root: Path) -> ValidationRunResult:
|
||
"""Assert book-content pre-commit hooks dispatch through ./book/binder."""
|
||
start = time.time()
|
||
from cli.checks import binder_canonical
|
||
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
violations = binder_canonical.run_canonical(repo_root)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=violation.file,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
)
|
||
for violation in violations
|
||
]
|
||
return ValidationRunResult(
|
||
name="binder-canonical",
|
||
description="Book-content hooks must route through Binder",
|
||
files_checked=1,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_python_syntax(self, root: Path) -> ValidationRunResult:
|
||
"""Compile every ```{python} code block to catch syntax errors."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
block_start_re = re.compile(r"^```\{python\}")
|
||
block_end_re = re.compile(r"^```\s*$")
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
lines = content.split("\n")
|
||
rel = str(file.relative_to(root)) if file.is_relative_to(root) else str(file)
|
||
|
||
in_block = False
|
||
block_lines: List[str] = []
|
||
block_start_line = 0
|
||
|
||
for i, line in enumerate(lines, start=1):
|
||
if block_start_re.match(line):
|
||
in_block = True
|
||
block_lines = []
|
||
block_start_line = i
|
||
continue
|
||
if in_block and block_end_re.match(line):
|
||
in_block = False
|
||
# Skip YAML-style #| directives before compiling
|
||
source_lines = [
|
||
ln for ln in block_lines
|
||
if not ln.strip().startswith("#|")
|
||
]
|
||
source = "\n".join(source_lines)
|
||
if not source.strip():
|
||
continue
|
||
try:
|
||
compile(source, f"{rel}:{block_start_line}", "exec")
|
||
except SyntaxError as exc:
|
||
err_line = block_start_line + (exc.lineno or 1)
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=err_line,
|
||
code="python_syntax",
|
||
message=f"Python syntax error: {exc.msg}",
|
||
severity="error",
|
||
context=(exc.text or "").strip()[:120],
|
||
))
|
||
continue
|
||
if in_block:
|
||
block_lines.append(line)
|
||
|
||
return ValidationRunResult(
|
||
name="python-syntax",
|
||
description="Validate Python code block syntax (compile check)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_inline_python(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
regex_checks = [
|
||
("missing_backtick", re.compile(r"(?<!`)(\{python\}\s+\w+`)"), "Missing opening backtick before {python}", "error"),
|
||
("dollar_as_backtick", re.compile(r"\$\{python\}\s+\w+`"), "Dollar sign used instead of backtick before {python}", "error"),
|
||
("display_math", re.compile(r"\$\$[^$]*`?\{python\}"), "Inline Python inside $$...$$ display math", "error"),
|
||
]
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code_block = False
|
||
in_grid = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code_block = not in_code_block
|
||
continue
|
||
if in_code_block:
|
||
continue
|
||
|
||
for code, pattern, message, severity in regex_checks:
|
||
for match in pattern.finditer(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity=severity,
|
||
context=match.group(0)[:160],
|
||
))
|
||
|
||
for ref in _inline_python_latex_operator_warnings(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="latex_adjacent_raw",
|
||
message="Non-canonical inline Python adjacent to LaTeX operator",
|
||
severity="warning",
|
||
context=ref[:160],
|
||
))
|
||
|
||
for math_span in _inline_python_math_spans(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="python_in_math",
|
||
message="Inline Python inside $...$ math can render incorrectly",
|
||
severity="error",
|
||
context=math_span[:160],
|
||
))
|
||
|
||
if GRID_TABLE_SEP_PATTERN.match(stripped):
|
||
in_grid = True
|
||
elif in_grid and not stripped.startswith("|") and stripped:
|
||
in_grid = False
|
||
|
||
if in_grid and "`{python}" in line:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="grid_table_python",
|
||
message="Inline Python in grid table; convert to pipe table",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
))
|
||
|
||
# Unwrapped {python} — missing backticks entirely
|
||
# Match {python} NOT preceded by ` and NOT at start of #| label line
|
||
if "{python}" in line and not stripped.startswith("#|"):
|
||
for um in re.finditer(r"(?<!`)\{python\}\s+\w+", line):
|
||
# Make sure it's not inside a backtick span
|
||
before = line[:um.start()]
|
||
if before.count("`") % 2 == 0: # even backticks = not inside span
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="unwrapped_python",
|
||
message="Inline Python missing backtick wrapping — will render as literal text",
|
||
severity="error",
|
||
context=um.group(0)[:120],
|
||
))
|
||
|
||
# Inline Python in headings — fragile for TOC/bookmarks/PDF
|
||
if stripped.startswith("#") and not stripped.startswith("#|") and "`{python}" in line:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="python_in_heading",
|
||
message="Inline Python in heading — fragile for TOC, bookmarks, and PDF",
|
||
severity="warning",
|
||
context=stripped[:120],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="inline-python",
|
||
description="Validate inline Python syntax and placement",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_refs(self, root: Path, citations_in_code: bool, citations_in_raw: bool) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
fenced_code_pattern = re.compile(r"```\{([^}]+)\}(.*?)```", re.DOTALL)
|
||
raw_block_pattern = re.compile(r"```\{=(html|latex|tex)\}(.*?)```", re.DOTALL | re.IGNORECASE)
|
||
problematic_classes = {"tikz", "latex", "tex"}
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
if citations_in_code:
|
||
for match in fenced_code_pattern.finditer(content):
|
||
attrs = match.group(1)
|
||
code_content = match.group(2)
|
||
class_match = re.search(r"\.([A-Za-z][A-Za-z0-9_-]*)", attrs)
|
||
cls = class_match.group(1).lower() if class_match else "unknown"
|
||
if cls not in problematic_classes:
|
||
continue
|
||
for cite_match in CITATION_BRACKET_PATTERN.finditer(code_content):
|
||
offset = match.start() + len(f"```{{{attrs}}}") + cite_match.start()
|
||
line_no = content[:offset].count("\n") + 1
|
||
line = content.splitlines()[line_no - 1] if line_no - 1 < len(content.splitlines()) else ""
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="citation_in_code",
|
||
message=f"Citation in .{cls} code block will not be processed",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
))
|
||
|
||
if citations_in_raw:
|
||
for match in raw_block_pattern.finditer(content):
|
||
raw_type = match.group(1).lower()
|
||
block = match.group(2)
|
||
for cite_match in CITATION_BRACKET_PATTERN.finditer(block):
|
||
offset = match.start() + cite_match.start()
|
||
line_no = content[:offset].count("\n") + 1
|
||
line = content.splitlines()[line_no - 1] if line_no - 1 < len(content.splitlines()) else ""
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="citation_in_raw",
|
||
message=f"Citation in raw {raw_type} block will not be processed",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="refs",
|
||
description="Validate citation/reference placement in raw/code blocks",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_CALLOUT_OPEN_RE = re.compile(r"^:{3,}\s*\{([^}]*)\}")
|
||
_ATTR_CLASS_RE = re.compile(r"\.([A-Za-z0-9_-]+)")
|
||
_ATTR_ID_RE = re.compile(r"#([A-Za-z0-9_-]+)")
|
||
|
||
def _run_principle_refs(self, root: Path) -> ValidationRunResult:
|
||
"""Validate semantic principle IDs and references.
|
||
|
||
This is a markup/semantic invariant, not a style-capitalization pass:
|
||
it does not decide whether a principle name should be lowercase in
|
||
prose. It only enforces the reference mechanics in book-prose §4.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
manual_principle_re = re.compile(r"\b[Pp]rinciple\s+\d+(?:\.\d+)?\b")
|
||
bad_principle_ref_re = re.compile(r"\b[Pp]rinciple\s+\\ref\{(?!pri-)([^}]+)\}")
|
||
cite_principle_re = re.compile(r"@pri-[A-Za-z0-9_-]+")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
opener = self._CALLOUT_OPEN_RE.match(stripped)
|
||
if opener:
|
||
attrs = opener.group(1)
|
||
classes = set(self._ATTR_CLASS_RE.findall(attrs))
|
||
if "callout-principle" in classes:
|
||
ids = self._ATTR_ID_RE.findall(attrs)
|
||
if not any(label.startswith("pri-") for label in ids):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="principle_missing_pri_id",
|
||
message="Principle callout must use a semantic #pri-* ID",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
if any(label.startswith("nte-") for label in ids):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="principle_uses_note_id",
|
||
message="Principle callout uses #nte-*; use #pri-* for principles",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
|
||
manual_match = manual_principle_re.search(line)
|
||
if manual_match:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="manual_principle_number",
|
||
message="Avoid manual principle numbering; use Principle \\ref{pri-*}",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
)
|
||
)
|
||
|
||
bad_ref_match = bad_principle_ref_re.search(line)
|
||
if bad_ref_match:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="principle_ref_non_pri",
|
||
message="Principle references must point to \\ref{pri-*}",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
)
|
||
)
|
||
|
||
cite_match = cite_principle_re.search(line)
|
||
if cite_match:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="principle_citation_syntax",
|
||
message="Use Principle \\ref{pri-*}, not @pri-* citation syntax",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="principles",
|
||
description="Principle callout IDs and reference mechanics",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _bibliography_for_qmd(self, file: Path) -> Optional[Path]:
|
||
"""Resolve the shared book references.bib for a book .qmd."""
|
||
try:
|
||
rel = file.relative_to(self.config_manager.book_dir)
|
||
except ValueError:
|
||
return None
|
||
parts = rel.parts
|
||
if not ({"vol1", "vol2", "frontmatter", "backmatter"} & set(parts)):
|
||
return None
|
||
bib_file = self.config_manager.book_dir / "contents" / "references.bib"
|
||
return bib_file if bib_file.exists() else None
|
||
|
||
def _run_citations(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
bib_key_pattern = re.compile(r"@\w+\{([^,\s]+)")
|
||
|
||
for file in files:
|
||
bib_file = self._bibliography_for_qmd(file)
|
||
if bib_file is None:
|
||
continue
|
||
|
||
content = self._read_text(file)
|
||
bib_content = self._read_text(bib_file)
|
||
bib_keys = set(bib_key_pattern.findall(bib_content))
|
||
# Strip YAML frontmatter (--- ... --- at file top) to avoid email false positives
|
||
qmd_content_no_code = re.sub(r"^---\n.*?\n---\n", "", content, flags=re.DOTALL)
|
||
# Strip HTML style/script blocks to avoid CSS @media false positives
|
||
qmd_content_no_code = re.sub(r"<style\b[^>]*>.*?</style>", "", qmd_content_no_code, flags=re.DOTALL)
|
||
qmd_content_no_code = re.sub(r"```.*?```", "", qmd_content_no_code, flags=re.DOTALL)
|
||
qmd_content_no_code = re.sub(r"`[^`]+`", "", qmd_content_no_code)
|
||
refs = set(CITATION_REF_PATTERN.findall(qmd_content_no_code))
|
||
refs = {r.rstrip(".,;:") for r in refs if not r.startswith(EXCLUDED_CITATION_PREFIXES)}
|
||
refs = {r for r in refs if not re.match(r"^\d+\.\d+", r)}
|
||
missing = sorted(refs - bib_keys)
|
||
for key in missing:
|
||
line_no = self._line_for_token(content, f"@{key}")
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="missing_citation",
|
||
message=f"Citation key @{key} missing in bibliography",
|
||
severity="error",
|
||
context=f"@{key}",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="citations",
|
||
description="Validate citation keys against bibliography files",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_SCAFFOLD_CITATION_KIND_RE = re.compile(
|
||
r"^:::+\s*\{[^}]*\.(callout-learning-objectives|callout-takeaways)\b"
|
||
)
|
||
_SCAFFOLD_DIV_OPEN_RE = re.compile(r"^:::+\s*\{")
|
||
_SCAFFOLD_DIV_CLOSE_RE = re.compile(r"^:::+\s*$")
|
||
_SCAFFOLD_PURPOSE_RE = re.compile(r"^##\s+Purpose\b")
|
||
_SCAFFOLD_H2_RE = re.compile(r"^##\s+")
|
||
|
||
def _load_scaffold_citation_baseline(self) -> Set[Tuple[str, str, str]]:
|
||
try:
|
||
data = json.loads(SCAFFOLD_CITATION_BASELINE_PATH.read_text(encoding="utf-8"))
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return set()
|
||
return {
|
||
(entry.get("file", ""), entry.get("kind", ""), entry.get("key", ""))
|
||
for entry in data.get("allowed", [])
|
||
}
|
||
|
||
@staticmethod
|
||
def _scaffold_citation_kind(raw_kind: str) -> str:
|
||
if raw_kind == "callout-learning-objectives":
|
||
return "learning-objectives"
|
||
if raw_kind == "callout-takeaways":
|
||
return "takeaways"
|
||
return raw_kind
|
||
|
||
@classmethod
|
||
def _is_bibliography_citation_key(cls, key: str) -> bool:
|
||
key = key.rstrip(".,;:)")
|
||
return (
|
||
bool(key)
|
||
and not key.startswith(EXCLUDED_CITATION_PREFIXES)
|
||
and not key.lower().startswith(EXCLUDED_CITATION_PREFIXES)
|
||
and not re.match(r"^\d+\.\d+", key)
|
||
)
|
||
|
||
def _run_scaffold_citations(self, root: Path) -> ValidationRunResult:
|
||
"""Block new bibliography citations in pedagogical scaffolding.
|
||
|
||
Purpose sections, learning objectives, and takeaways frame the chapter;
|
||
they should not carry provenance. Put source support in the body prose,
|
||
technical callout where the claim is taught, figure/table caption, or
|
||
appendix prose instead.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
baseline = self._load_scaffold_citation_baseline()
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
rel = self._relative_file(file)
|
||
lines = self._read_text(file).splitlines()
|
||
in_fence = False
|
||
in_purpose = False
|
||
callout_kind: Optional[str] = None
|
||
callout_depth = 0
|
||
|
||
for line_no, line in enumerate(lines, start=1):
|
||
if self._BIB_FENCE_RE.match(line):
|
||
in_fence = not in_fence
|
||
continue
|
||
if in_fence:
|
||
continue
|
||
|
||
if self._SCAFFOLD_PURPOSE_RE.match(line):
|
||
in_purpose = True
|
||
elif in_purpose and self._SCAFFOLD_H2_RE.match(line):
|
||
in_purpose = False
|
||
|
||
if callout_kind:
|
||
if self._SCAFFOLD_DIV_OPEN_RE.match(line):
|
||
callout_depth += 1
|
||
elif self._SCAFFOLD_DIV_CLOSE_RE.match(line):
|
||
callout_depth -= 1
|
||
if callout_depth <= 0:
|
||
callout_kind = None
|
||
continue
|
||
else:
|
||
match = self._SCAFFOLD_CITATION_KIND_RE.match(line)
|
||
if match:
|
||
callout_kind = self._scaffold_citation_kind(match.group(1))
|
||
callout_depth = 1
|
||
|
||
kind = "purpose" if in_purpose else callout_kind
|
||
if not kind:
|
||
continue
|
||
|
||
for key in CITATION_REF_PATTERN.findall(line):
|
||
key = key.rstrip(".,;:)")
|
||
if not self._is_bibliography_citation_key(key):
|
||
continue
|
||
if (rel, kind, key) in baseline:
|
||
continue
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=line_no,
|
||
code="citation_in_scaffold",
|
||
message=(
|
||
f"Bibliography citation @{key} appears in {kind}. "
|
||
"Purpose, learning objectives, and takeaways should not carry citations."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:220],
|
||
suggestion=(
|
||
f"Move @{key} to the body prose, a technical callout/caption, "
|
||
"or appendix passage where the sourced claim is actually taught; "
|
||
f"leave the {kind} text citation-free."
|
||
),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="scaffold-citations",
|
||
description="No bibliography citations in Purpose, learning objectives, or takeaways",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_duplicate_citation_year(self, root: Path) -> ValidationRunResult:
|
||
"""Flag `[@citekey] (YEAR)` patterns — the citation already renders the year.
|
||
|
||
Authors sometimes write `[@foo1964] (1964)` as belt-and-suspenders, but
|
||
Pandoc renders `[@foo1964]` as "(Foo 1964)", so the literal `(1964)` is
|
||
redundant and ships as "(Foo 1964) (1964)". Applies to `[@key]`,
|
||
`[-@key]`, and multi-citation brackets like `[@a; @b]`.
|
||
|
||
Resolution: move the citation to the end of the sentence and drop the
|
||
parenthetical year, OR — when the literal year genuinely differs from
|
||
the cited paper's year (e.g. system deployment year vs. publication
|
||
year) — reword to avoid two adjacent parenthetical years.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# `[-?@...]` followed by optional whitespace and a bare `(YEAR)`.
|
||
# Year range 1800–2099 keeps things loose without false-matching
|
||
# measurements like `(1964 ms)` or `(100 µs)`.
|
||
dup_year = re.compile(r"\[-?@[^\]]+\]\s*\((1[89]\d{2}|20\d{2})\)")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
# Strip inline code spans so `[@foo] (1964)` inside backticks
|
||
# — documentation of the anti-pattern itself — does not fire.
|
||
scrubbed = re.sub(r"`[^`]+`", "", line)
|
||
for m in dup_year.finditer(scrubbed):
|
||
context = scrubbed[max(0, m.start() - 10) : min(len(scrubbed), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="duplicate_citation_year",
|
||
message=(
|
||
"Redundant (YEAR) after citation — Pandoc already renders "
|
||
"the year inside the citation. Move citation to sentence end "
|
||
"or reword to avoid two parenthetical years."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="duplicate-citation-year",
|
||
description='Flag "[@citekey] (YEAR)" — citation already carries the year',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
|
||
def _run_duplicate_citation_key(self, root: Path) -> ValidationRunResult:
|
||
"""Flag same-key duplicates in cite groups: `[@k; @k]` or `[@k] [@k]`.
|
||
|
||
Both patterns render through citeproc as a visible duplicate
|
||
"(Author Year) (Author Year)" because the renderer treats each
|
||
appearance of the same key as a separate citation event.
|
||
|
||
Two shapes caught:
|
||
|
||
1. **Same key inside one bracket** — `[@key; @key]` or
|
||
`[@a; @b; @a]`. Common cause: copy-paste while authoring a
|
||
multi-cite. The fix is to drop the duplicate key:
|
||
`[@key]` or `[@a; @b]`.
|
||
|
||
2. **Same key in stacked brackets** — `[@key][@key]` or
|
||
`[@key] [@key]`. Common cause: two separate cite-additions
|
||
in the same paragraph that landed adjacent to each other.
|
||
The fix is to remove one of the brackets, OR — if both
|
||
cites genuinely anchor different facts — separate them with
|
||
prose so the reader sees each cite anchor a distinct claim.
|
||
|
||
Distinct from `duplicate-year` (which flags `[@k] (YEAR)`) and
|
||
from `manual-bracket` (which flags hand-typed author/year + cite).
|
||
Skips fenced code blocks and inline backticks so anti-pattern
|
||
documentation does not fire.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# "[@key; ...; @key]" — same key appears twice within one
|
||
# multi-cite bracket group, possibly with other keys between.
|
||
# Backreference (?P=k) requires the second occurrence to match
|
||
# the first capture exactly. Spans up to 8 keys per group to
|
||
# keep the regex bounded; longer multi-cites are rare.
|
||
same_bracket_dup = re.compile(
|
||
r"\[@(?P<k>[A-Za-z][\w:.-]*)"
|
||
r"(?:\s*;\s*@[\w:.-]+){0,8}"
|
||
r"\s*;\s*@(?P=k)\b"
|
||
)
|
||
# "[@key][@key]" or "[@key] [@key]" — same key in adjacent
|
||
# bracket groups. Allow whitespace, comma, semicolon, or period
|
||
# between the brackets (typical sentence punctuation that does
|
||
# not change the citeproc-duplicate outcome).
|
||
stacked_same_dup = re.compile(
|
||
r"\[@(?P<k>[A-Za-z][\w:.-]*)\][\s,;.]*\[@(?P=k)\b"
|
||
)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
# Strip inline code spans so anti-pattern documentation
|
||
# in backticks does not fire on the example string.
|
||
scrubbed = re.sub(r"`[^`]+`", "", line)
|
||
for m in same_bracket_dup.finditer(scrubbed):
|
||
context = scrubbed[max(0, m.start() - 10) : min(len(scrubbed), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="duplicate_citation_key_in_bracket",
|
||
message=(
|
||
f"Same key @{m.group('k')} appears twice inside one cite group. "
|
||
"Citeproc renders each occurrence as a separate (Author Year), "
|
||
"duplicating the rendered citation. Drop the redundant copy."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
)
|
||
for m in stacked_same_dup.finditer(scrubbed):
|
||
context = scrubbed[max(0, m.start() - 10) : min(len(scrubbed), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="duplicate_citation_key_stacked",
|
||
message=(
|
||
f"Same key @{m.group('k')} appears in two adjacent cite brackets. "
|
||
"Citeproc renders each as '(Author Year)', producing a visible "
|
||
"(Author Year) (Author Year) duplicate. Remove one bracket, or "
|
||
"separate the two facts with prose so each cite anchors a distinct claim."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="duplicate-citation-key",
|
||
description='Flag "[@k; @k]" or "[@k] [@k]" — same-key citeproc dup',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
|
||
def _run_manual_bracket_citation(self, root: Path) -> ValidationRunResult:
|
||
"""Flag hand-typed author/year attributions in prose.
|
||
|
||
Two failure modes are caught here, both rooted in the same anti-pattern:
|
||
an author + year written by hand instead of letting citeproc render the
|
||
name from a `@key` / `[@key]` citation.
|
||
|
||
1. **Manual + bracket** (citeproc duplicate). Writing
|
||
"Jeon et al. (ATC 2019) [@jeon2019analysis]" or
|
||
"Cohen & Welling (2016) [@c]" duplicates the in-text citation —
|
||
citeproc already prints "Jeon et al. (2019)" / "Cohen and Welling
|
||
(2016)" from the key. Prefer `[@key]` / `[@a; @b]` alone, or
|
||
narrative `@key`.
|
||
|
||
1b. **Narrative + duplicated year**. Writing
|
||
"@williams2009roofline introduced the model at UC Berkeley in
|
||
2009" repeats the year that the narrative citation already
|
||
renders. If the year is only there to identify the cited work,
|
||
move or drop it. If the year is a separate timeline fact, reword
|
||
so the prose does not read like duplicated attribution.
|
||
|
||
2. **Bare attribution** (no bib citation at all). Writing
|
||
"Tri Dao et al. at Stanford (2022)" or "Coffman et al. (1971)"
|
||
with no accompanying `[@key]` or narrative `@bibkey` on the line
|
||
bypasses the bibliography entirely — the cited work is not in
|
||
references.bib and the rendered text reads as plain prose
|
||
rather than a verifiable citation. This pattern shows up most
|
||
often inside footnote definitions where authors hand-type a
|
||
provenance line. Fix with narrative `@key` (renders as
|
||
"Dao et al. (2022)") or restructure to drop the attribution
|
||
when no bib entry exists.
|
||
|
||
Shapes we flag (prose, not code fences; inline backticks scrubbed first):
|
||
|
||
Manual + bracket (citeproc duplicate):
|
||
- `… et al. <opt-words> ( … YEAR … ) [@key]` — *et al.* + paren + bracket.
|
||
- `Surname & Surname ( … ) [@key]` — two-author + bracket.
|
||
|
||
Bare attribution (no [@key] or narrative @bibkey on the line):
|
||
- `… et al. <opt-words> ( … YEAR … )` — classic *et al.* + paren-year.
|
||
- `… et al.` with no paren-year on line — e.g. "Sambasivan et al. found
|
||
that…" — author + year claim with nothing for the reader to look up.
|
||
- `Surname and Surname ( … YEAR … )` — e.g. "Frankle and Carbin
|
||
(MIT, 2019)".
|
||
- `Surname, Surname, and Surname ( … YEAR … )` — e.g. "Hinton, Vinyals,
|
||
and Dean (Google, 2015)".
|
||
- `(Surname and Surname, YEAR)` — e.g. "(Ioffe and Szegedy,
|
||
2015)".
|
||
|
||
These six bare patterns share a fix: replace the manual attribution with
|
||
narrative `@key` (which renders as "Author et al. (YEAR)") or `[@key]`
|
||
as a fact anchor. If no bib entry exists, restructure to drop the date
|
||
and author rather than leaving an unverifiable claim.
|
||
|
||
We do **not** flag acronyms in parentheses that are not *author* lines, e.g.
|
||
"DARTS (Differentiable …) [@liu2019darts]": that has no *et al.* and no
|
||
*Word & Word* before the paren. The bare-attribution branches that involve
|
||
a paren require a 4-digit publication year (19xx/20xx) inside the
|
||
parenthetical, so "(5--10)% improvement" or "(in production)" do not
|
||
trigger it. Fenced code and inline backtick docs of anti-patterns are
|
||
skipped.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# "et al. <opt-words> ( … ) [@"
|
||
# The intermediate `[^.\n\[(]{0,80}` allows phrases like "at Stanford"
|
||
# or "in 2014" between `et al.` and the year-paren without crossing
|
||
# a sentence boundary, an opening bracket, or a nested paren.
|
||
et_al_bracket = re.compile(
|
||
r"et al\.[^.\n\[(]{0,80}\([^)\n]{0,500}\)\s*,?\s*\[@",
|
||
re.IGNORECASE,
|
||
)
|
||
# "Cohen & Welling (ReCOL 2016) [@" — two Titlecase tokens and & (surname style)
|
||
two_surname_amp_bracket = re.compile(
|
||
r"\b[A-Z][a-z][a-zA-Z'-]*\s+&\s+[A-Z][a-z][a-zA-Z'-]*\s*"
|
||
r"\([^)\n]{0,500}\)\s*,?\s*\[@"
|
||
)
|
||
# "Hennessy and Patterson [@Patterson2021]" — two surnames + and + bracket
|
||
# cite, with no paren-year between. Distinct from `two_surname_and_paren`
|
||
# (which requires a paren-year and is a *bare* attribution) and from
|
||
# `two_surname_amp_bracket` (which uses & and requires a paren). Citeproc
|
||
# renders [@key] as "(Author and Author YEAR)" — duplicates the prose.
|
||
two_surname_and_bracket = re.compile(
|
||
r"\b[A-Z][a-z][a-zA-Z'-]+\s+and\s+[A-Z][a-z][a-zA-Z'-]+\s*,?\s*\[@"
|
||
)
|
||
# "(Sweeney, 2002) [@sweeney2002k]" — single-author parenthetical year
|
||
# immediately before a bracket cite. Citeproc renders [@key] as
|
||
# "(Author YEAR)" so the (Surname, YEAR) prose duplicates it.
|
||
single_paren_author_year_bracket = re.compile(
|
||
r"\(\s*[A-Z][a-z][a-zA-Z'-]+,\s*(?:19|20)\d{2}\s*\)\s*,?\s*\[@"
|
||
)
|
||
# "**Optimal Brain Damage (LeCun, 1989)** [@cite]:" — footnote bold head
|
||
# whose parenthetical already names (Author, YEAR) or (YEAR), followed
|
||
# immediately by [@cite]. Per the book's footnote convention (Dartmouth
|
||
# Conference, AlexNet, HIPAA, EU AI Act, A11 Bionic, Optimal Brain
|
||
# Damage, etc.), the bold head IS the attribution — adding a bracket
|
||
# cite right after produces "(YEAR) (Author YEAR)" in the rendered output.
|
||
bold_head_year_bracket = re.compile(
|
||
r"\*\*[^*\n]*\([^)\n]*\b(?:19|20)\d{2}\b[^)\n]*\)\*\*\s*\[@"
|
||
)
|
||
# "[^fn-X]: **Term** [@cite]: ..." — footnote bold head followed
|
||
# immediately by a [@cite], even when the head has no parenthetical
|
||
# year. citeproc still inserts "(Author YEAR)" right where you put
|
||
# [@cite], producing rendered output like "**Federated Learning**
|
||
# (Li et al. 2020): A distributed training paradigm…" — the cite
|
||
# reads as part of the term name. Place the cite inside the body
|
||
# sentence at a specific factual claim instead.
|
||
footnote_head_then_bracket = re.compile(
|
||
r"^\[\^fn-[a-z0-9-]+\]:\s*\*\*[^*\n]+\*\*\s*\[@",
|
||
re.M,
|
||
)
|
||
# "text.[@cite]" — period BEFORE the cite. Convention is the
|
||
# opposite: the cite is part of the sentence, so it goes before the
|
||
# terminal punctuation: "text [@cite]." Pandoc renders the wrong
|
||
# order with the period jammed against the citation visually.
|
||
period_before_bracket = re.compile(r"\S\.\[@[A-Za-z]")
|
||
# "word[@cite]" — no space before the cite. A parenthetical citation
|
||
# always takes a leading space in body prose. Excludes footnote
|
||
# markers and reference labels.
|
||
no_space_before_bracket = re.compile(r"[a-zA-Z]\[@(?![Ss]ec-|[Ff]ig-|[Tt]bl-|[Ee]q-|[Ll]st-|exr-|exm-|thm-|cor-|cnj-|def-|prp-|rem-|prf-|alg-)[A-Za-z]")
|
||
# "[@a, @b]" — comma-separated multi-cite. Pandoc's citation syntax
|
||
# requires semicolons: "[@a; @b]".
|
||
comma_multicite = re.compile(r"\[@[A-Za-z][\w:.-]+,\s*@[A-Za-z]")
|
||
# "et al. <opt-words> ( … YEAR … )" — bare attribution.
|
||
# Requires a 4-digit publication year inside the paren so we don't
|
||
# flag unrelated parentheticals.
|
||
et_al_bare = re.compile(
|
||
r"et al\.[^.\n\[(]{0,100}"
|
||
r"\(\s*[^)\n]{0,60}\b(?:19|20)\d{2}\b[^)\n]{0,60}\)",
|
||
re.IGNORECASE,
|
||
)
|
||
# "… et al." anywhere on a line, with no paren-year nearby.
|
||
# Catches "Sambasivan et al. found that…" — the author makes an
|
||
# attribution claim but provides no citation for the reader to verify.
|
||
# The line-level no-cite suppression below ensures we only flag prose
|
||
# that genuinely lacks a [@key] or narrative @bibkey.
|
||
et_al_loose = re.compile(r"\bet\s+al\.", re.IGNORECASE)
|
||
# "Surname and Surname (… YEAR …)" — two-author parenthetical year.
|
||
# Catches "Frankle and Carbin (MIT, 2019)". Requires both surnames to
|
||
# start with a capital letter and be followed by lowercase, so common
|
||
# phrases like "this and that" / "Apple and Microsoft (2024 deal)"
|
||
# have a lower hit rate. We also require a 4-digit year inside the
|
||
# paren to avoid matching bare phrases like "Cohen and Welling".
|
||
two_surname_and_paren = re.compile(
|
||
r"\b[A-Z][a-z][a-zA-Z'-]+\s+and\s+[A-Z][a-z][a-zA-Z'-]+\s*"
|
||
r"\(\s*[^)\n]{0,80}\b(?:19|20)\d{2}\b[^)\n]{0,80}\)"
|
||
)
|
||
# "Surname, Surname, and Surname (… YEAR …)" — three-author.
|
||
# Catches "Hinton, Vinyals, and Dean (Google, 2015)". Optional Oxford
|
||
# comma (`,?`) before "and". Same paren-year requirement as above.
|
||
three_surname_paren = re.compile(
|
||
r"\b[A-Z][a-z][a-zA-Z'-]+,\s+[A-Z][a-z][a-zA-Z'-]+,?\s+and\s+"
|
||
r"[A-Z][a-z][a-zA-Z'-]+\s*"
|
||
r"\(\s*[^)\n]{0,80}\b(?:19|20)\d{2}\b[^)\n]{0,80}\)"
|
||
)
|
||
# "(Surname and Surname, YEAR)" — fully parenthesized two-author cite.
|
||
# Catches "(Ioffe and Szegedy, 2015)". Tighter than the two-surname
|
||
# form above because both surnames AND the year live inside one
|
||
# parenthesis with a comma separator — almost always an author cite.
|
||
paren_two_authors = re.compile(
|
||
r"\(\s*[A-Z][a-z][a-zA-Z'-]+\s+and\s+[A-Z][a-z][a-zA-Z'-]+,\s*"
|
||
r"(?:19|20)\d{2}\s*\)"
|
||
)
|
||
# Cite detectors used to suppress the bare branch when the line
|
||
# already has a real citation. Excludes Quarto cross-ref prefixes
|
||
# (@sec-, @fig-, etc.) so those are not mistaken for bibkeys.
|
||
bracket_cite = re.compile(r"\[@[A-Za-z]")
|
||
narrative_cite = re.compile(
|
||
r"@(?![Ss]ec-|[Ff]ig-|[Tt]bl-|[Ee]q-|[Ll]st-|exr-|exm-|thm-|cor-|cnj-|"
|
||
r"def-|prp-|rem-|prf-|alg-)[A-Za-z][\w:-]*"
|
||
)
|
||
|
||
def issue_et_al(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Hand-written *et al.* (…) before a bracket cite—use only [@key] "
|
||
"(or @key in narrative prose), not both. Citeproc already expands "
|
||
"the key; drop the parenthetical name/venue you typed before [."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_ampersand(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Hand-typed *Author & Author* (…) before a bracket cite—use only "
|
||
"[@key] (or @key in narrative), not both. Citeproc already prints "
|
||
"names from the key; drop the duplicate author line."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_bare_attribution(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="bare_manual_attribution",
|
||
message=(
|
||
"Hand-typed *Author et al. (YEAR)* with no bibliography citation "
|
||
"on this line. Use narrative @key (renders as 'Author et al. "
|
||
"(YEAR)') so the work appears in references.bib, or restructure "
|
||
"to drop the attribution when no bib entry exists."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_etal_no_cite(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="bare_etal_no_cite",
|
||
message=(
|
||
"Used *Author et al.* in prose with no citation on the line. "
|
||
"Either add narrative @key (renders as 'Author et al. (YEAR)') "
|
||
"or [@key] as a fact anchor, or rewrite to drop the named "
|
||
"attribution when no bib entry exists."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_two_authors_no_cite(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="bare_two_author_attribution",
|
||
message=(
|
||
"Hand-typed *Surname and Surname (YEAR)* with no bibliography "
|
||
"citation on this line. Use narrative @key (renders as "
|
||
"'Surname and Surname (YEAR)') or [@key] as a fact anchor, "
|
||
"not the manual paren-year form."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_three_authors_no_cite(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="bare_three_author_attribution",
|
||
message=(
|
||
"Hand-typed *Surname, Surname, and Surname (YEAR)* with no "
|
||
"bibliography citation on this line. Replace with narrative "
|
||
"@key (citeproc renders the names from the bib entry) or "
|
||
"[@key] as a fact anchor."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_paren_authors_no_cite(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="bare_paren_author_attribution",
|
||
message=(
|
||
"Hand-typed *(Surname and Surname, YEAR)* parenthetical "
|
||
"with no bibliography citation on this line. Replace with "
|
||
"[@key] — citeproc renders it the same way and links to "
|
||
"the bib entry."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_two_surname_and_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Hand-typed *Surname and Surname* before a bracket cite — "
|
||
"citeproc already prints both authors from [@key]. Use "
|
||
"narrative @key (renders as 'Surname and Surname (YEAR)') "
|
||
"or [@key] alone, not both."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_single_paren_year_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Hand-typed *(Surname, YEAR)* before a bracket cite — "
|
||
"citeproc renders [@key] as '(Author YEAR)' immediately "
|
||
"after, duplicating the prose. Use [@key] alone."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_bold_head_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Footnote bold head **Term (Author, YEAR)** is followed by "
|
||
"a bracket cite [@key]; citeproc renders '(Author YEAR)' "
|
||
"right after, duplicating the head's parenthetical. Per "
|
||
"book convention (Dartmouth Conference, AlexNet, HIPAA, "
|
||
"EU AI Act, etc.), the bold head IS the attribution — drop "
|
||
"the cite, or place it later in the body attached to a "
|
||
"specific factual claim."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_footnote_head_then_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="manual_bracket_citation",
|
||
message=(
|
||
"Footnote bold head **Term** followed immediately by "
|
||
"[@cite] — citeproc inserts '(Author YEAR)' right after "
|
||
"the term name, making the cite read as part of the term "
|
||
"rather than attribution for the body. Place the cite "
|
||
"inside the body sentence at a specific factual claim "
|
||
"(e.g., '...without centralizing the raw information "
|
||
"[@key].')."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_narrative_year_dup(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="narrative_citation_year",
|
||
message=(
|
||
"Narrative citation already renders the cited work's year. "
|
||
"Do not repeat the same year later in the clause; move it "
|
||
"or remove it unless it is a genuinely separate timeline fact."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_period_before_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="cite_placement",
|
||
message=(
|
||
"Period BEFORE the citation. Convention is "
|
||
"\"text [@cite].\" not \"text.[@cite]\" — the citation "
|
||
"is part of the sentence and goes before terminal "
|
||
"punctuation."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_no_space_before_bracket(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="cite_placement",
|
||
message=(
|
||
"Citation glued to the preceding word. Always insert a "
|
||
"space: \"word [@cite]\" not \"word[@cite]\"."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
def issue_comma_multicite(
|
||
f: Path, line_num: int, context: str
|
||
) -> ValidationIssue:
|
||
return ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=line_num,
|
||
code="cite_placement",
|
||
message=(
|
||
"Comma-separated multi-cite [@a, @b]. Pandoc's citation "
|
||
"syntax requires semicolons: [@a; @b]."
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
scrubbed = re.sub(r"`[^`]+`", "", line)
|
||
for m in et_al_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_et_al(file, idx, context))
|
||
for m in two_surname_amp_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_ampersand(file, idx, context))
|
||
for m in two_surname_and_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_two_surname_and_bracket(file, idx, context))
|
||
for m in single_paren_author_year_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_single_paren_year_bracket(file, idx, context))
|
||
for m in bold_head_year_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_bold_head_bracket(file, idx, context))
|
||
if footnote_head_then_bracket.match(scrubbed):
|
||
context = scrubbed[: min(len(scrubbed), 80)]
|
||
issues.append(issue_footnote_head_then_bracket(file, idx, context))
|
||
for m in period_before_bracket.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_period_before_bracket(file, idx, context))
|
||
for m in no_space_before_bracket.finditer(scrubbed):
|
||
# Skip footnote markers like text[^fn-...] which look similar but
|
||
# the regex requires "[@" so footnote markers (which use "[^") don't match.
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_no_space_before_bracket(file, idx, context))
|
||
for m in comma_multicite.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_comma_multicite(file, idx, context))
|
||
for m in narrative_cite.finditer(scrubbed):
|
||
key = m.group(0)[1:]
|
||
key_year_match = re.search(r"(?:18|19|20)\d{2}", key)
|
||
if not key_year_match:
|
||
continue
|
||
key_year = key_year_match.group(0)
|
||
tail = scrubbed[m.end() : m.end() + 120]
|
||
year_dup = re.search(
|
||
rf"\b(?:in|at|on|from|during|since)\s*\(?{key_year}\)?\b"
|
||
rf"|(?:\(\s*{key_year}\s*\))",
|
||
tail,
|
||
)
|
||
if year_dup:
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 80)
|
||
].strip()
|
||
issues.append(issue_narrative_year_dup(file, idx, context))
|
||
# Bare attribution: only flag when the line carries no real
|
||
# citation. A bracket cite anywhere or a narrative @bibkey
|
||
# (i.e., @ followed by a non-cross-ref word) suppresses this.
|
||
line_has_cite = bool(
|
||
bracket_cite.search(scrubbed)
|
||
or narrative_cite.search(scrubbed)
|
||
)
|
||
# Skip lines that look like code-comment annotations rather
|
||
# than body prose. Two signals: a leading `#` followed by
|
||
# ALL-CAPS content (e.g. "# VERIFIED DATA"), or any
|
||
# `arXiv:NNNN.NNNNN` token on the line. These appear inside
|
||
# `{python}` cells whose opening fence is mis-detected when
|
||
# the file has unbalanced fences elsewhere.
|
||
looks_like_code_comment = (
|
||
bool(re.match(r"^\s*#\s+[A-Z]{3,}", line))
|
||
or "arXiv:" in scrubbed
|
||
)
|
||
if not line_has_cite and not looks_like_code_comment:
|
||
for m in et_al_bare.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_bare_attribution(file, idx, context))
|
||
# If et_al_bare did not fire but et al. appears anywhere
|
||
# on the line, the author claim still lacks a citation.
|
||
if not et_al_bare.search(scrubbed):
|
||
for m in et_al_loose.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 30) : min(len(scrubbed), m.end() + 40)
|
||
].strip()
|
||
issues.append(issue_etal_no_cite(file, idx, context))
|
||
for m in two_surname_and_paren.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_two_authors_no_cite(file, idx, context))
|
||
for m in three_surname_paren.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 5) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_three_authors_no_cite(file, idx, context))
|
||
for m in paren_two_authors.finditer(scrubbed):
|
||
context = scrubbed[
|
||
max(0, m.start() - 10) : min(len(scrubbed), m.end() + 24)
|
||
].strip()
|
||
issues.append(issue_paren_authors_no_cite(file, idx, context))
|
||
|
||
return ValidationRunResult(
|
||
name="manual-bracket",
|
||
description=(
|
||
"Flag hand-typed Author et al. (YEAR) attributions: "
|
||
"manual+bracket duplicates and bare prose without [@key]"
|
||
),
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_duplicate_labels(self, root: Path, label_types: Dict[str, List[re.Pattern[str]]]) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
definitions: Dict[str, List[Tuple[Path, int, str]]] = {}
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for label_type, patterns in label_types.items():
|
||
for pattern in patterns:
|
||
for match in pattern.finditer(line):
|
||
label = match.group(1)
|
||
definitions.setdefault(label, []).append((file, idx, label_type))
|
||
|
||
for label, locations in definitions.items():
|
||
if len(locations) <= 1:
|
||
continue
|
||
for file, line_no, label_type in locations:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="duplicate_label",
|
||
message=f"Duplicate {label_type.lower()} label: {label}",
|
||
severity="error",
|
||
context=label,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="duplicate-labels",
|
||
description="Detect duplicate label definitions",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_unreferenced_labels(self, root: Path, label_types: Dict[str, List[re.Pattern[str]]]) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
defined: Dict[str, Tuple[Path, int, str]] = {}
|
||
references: Dict[str, List[Tuple[Path, int]]] = {}
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code_block = False
|
||
in_html_comment = False
|
||
for idx, line in enumerate(lines, 1):
|
||
# Skip three forms of non-rendered content:
|
||
# 1. Fenced code blocks (```...```). `@xref` mentions in
|
||
# Python cell box-comment headers (`# │ Context: @sec-foo`)
|
||
# are documentation that never renders. Counting them as
|
||
# references produces false-positive unresolved-reference
|
||
# errors for refs that target intentional documentation.
|
||
# 2. HTML comments (`<!-- ... -->`). Commented-out tikz
|
||
# examples often contain ``` fence-looking lines, which
|
||
# desynchronize the naive in_code_block toggle if not
|
||
# tracked separately. Track comment state explicitly.
|
||
# 3. Labels themselves are never defined inside fences or
|
||
# HTML comments, so skipping the whole region is safe.
|
||
stripped = line.lstrip()
|
||
|
||
# Update HTML-comment state first — comment markers may sit
|
||
# on the same line as content (rare) or span many lines.
|
||
if not in_code_block:
|
||
if "<!--" in line and "-->" not in line[line.index("<!--"):]:
|
||
in_html_comment = True
|
||
continue
|
||
if in_html_comment:
|
||
if "-->" in line:
|
||
in_html_comment = False
|
||
continue
|
||
|
||
# Pseudocode algorithm labels are declared as a chunk option
|
||
# (`#| label: algo-xyz`) INSIDE a ```pseudocode fence, so the
|
||
# code-block skip below would hide them and leave every
|
||
# @algo-/@Algo- reference unresolved. A chunk-option label is a
|
||
# genuine definition wherever it lives, so harvest it before the
|
||
# skip. (mlsysbook-ext/pseudocode; native @alg- collides with
|
||
# Quarto's algorithm theorem env, hence the algo- prefix.)
|
||
algo_def = PSEUDOCODE_LABEL_PATTERN.match(stripped)
|
||
if algo_def:
|
||
defined.setdefault(algo_def.group(1), (file, idx, "Algorithm"))
|
||
|
||
if stripped.startswith("```"):
|
||
in_code_block = not in_code_block
|
||
continue
|
||
if in_code_block:
|
||
continue
|
||
|
||
for label_type, patterns in label_types.items():
|
||
for pattern in patterns:
|
||
for match in pattern.finditer(line):
|
||
defined.setdefault(match.group(1), (file, idx, label_type))
|
||
|
||
for match in LABEL_REF_PATTERN.finditer(line):
|
||
label = match.group(1)
|
||
# Normalize capitalized Quarto prefix (@Fig- -> fig-)
|
||
label = label[0].lower() + label[1:]
|
||
references.setdefault(label, []).append((file, idx))
|
||
|
||
# unreferenced definitions (skip section defaults, consistent with legacy behavior)
|
||
for label, (file, line_no, label_type) in defined.items():
|
||
if label_type == "Section":
|
||
continue
|
||
if label not in references:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="unreferenced_label",
|
||
message=f"{label_type} label {label} is never referenced",
|
||
severity="warning",
|
||
context=label,
|
||
))
|
||
|
||
# unresolved references
|
||
defined_labels = set(defined.keys())
|
||
for label, locations in references.items():
|
||
if label in defined_labels:
|
||
continue
|
||
for file, line_no in locations:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="unresolved_reference",
|
||
message=f"Reference @{label} has no matching label definition",
|
||
severity="error",
|
||
context=f"@{label}",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="unreferenced-labels",
|
||
description="Detect unreferenced labels and unresolved references",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_inline_refs(self, root: Path, check_patterns: bool,
|
||
check_scope: bool = False) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
yaml_option_inline = re.compile(r"^#\|\s*(fig-cap|tbl-cap|lst-cap|fig-alt):\s*.*`\{python\}")
|
||
inline_fstring = re.compile(r"`\{python\}\s*f\"[^`]+`")
|
||
inline_func_call = re.compile(r"`\{python\}\s*\w+\([^`]+\)`")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
refs: List[Tuple[int, str]] = []
|
||
compute_vars: Set[str] = set()
|
||
compute_classes: Set[str] = set()
|
||
in_cell = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
if CELL_START_PATTERN.match(line.strip()):
|
||
in_cell = True
|
||
continue
|
||
if in_cell and CELL_END_PATTERN.match(line.strip()):
|
||
in_cell = False
|
||
continue
|
||
if in_cell:
|
||
cls_match = CLASS_DEF_PATTERN.match(line.strip())
|
||
if cls_match:
|
||
compute_classes.add(cls_match.group(1))
|
||
assign = ASSIGN_PATTERN.match(line.strip())
|
||
if assign:
|
||
compute_vars.add(assign.group(1))
|
||
tuple_assign = TUPLE_ASSIGN_PATTERN.match(line.strip())
|
||
if tuple_assign:
|
||
for name in re.split(r'\s*,\s*', tuple_assign.group(1)):
|
||
compute_vars.add(name.strip())
|
||
|
||
for match in INLINE_REF_PATTERN.finditer(line):
|
||
refs.append((idx, match.group(1)))
|
||
|
||
for line_no, ref in refs:
|
||
if "." in ref:
|
||
cls_name = ref.split(".", 1)[0]
|
||
resolved = cls_name in compute_classes or cls_name in compute_vars
|
||
else:
|
||
resolved = ref in compute_vars
|
||
if not resolved:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="undefined_inline_ref",
|
||
message=f"Inline reference `{ref}` is not defined in python cells",
|
||
severity="error",
|
||
context=f"`{{python}} {ref}`",
|
||
))
|
||
|
||
if check_patterns:
|
||
in_grid = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if GRID_TABLE_SEP_PATTERN.match(stripped):
|
||
in_grid = True
|
||
elif in_grid and stripped and not stripped.startswith("|"):
|
||
in_grid = False
|
||
if in_grid and "`{python}" in line:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="grid_table_inline_python",
|
||
message="Inline Python in grid tables is unsupported",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
))
|
||
if inline_fstring.search(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="inline_fstring",
|
||
message="Inline f-string should be precomputed in Python cell",
|
||
severity="warning",
|
||
context=stripped[:160],
|
||
))
|
||
if inline_func_call.search(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="inline_function_call",
|
||
message="Inline function call should be precomputed in Python cell",
|
||
severity="warning",
|
||
context=stripped[:160],
|
||
))
|
||
if yaml_option_inline.search(line):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="yaml_option_inline_python",
|
||
message="Inline Python in YAML fig/tbl/lst metadata will not render",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
))
|
||
if check_scope:
|
||
from book.tools.scripts.maintenance.validate_inline_refs import check_scope as _check_scope, BOOK_ROOT
|
||
try:
|
||
scope_warnings = _check_scope(file, verbose=False)
|
||
for filepath, lineno, check_type, msg in scope_warnings:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=lineno,
|
||
code=check_type.lower(),
|
||
message=msg,
|
||
severity="warning",
|
||
context="",
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
return ValidationRunResult(
|
||
name="inline-refs",
|
||
description="Validate inline Python refs and rendering hazard patterns",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Headers (ported from manage_section_ids.py --verify)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_headers(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
header_pat = re.compile(r"^(#{1,6})\s+(.+?)(?:\s*\{[^}]*\})?$")
|
||
div_start_pat = re.compile(r"^:::\s*\{\.")
|
||
div_end_pat = re.compile(r"^:::\s*$")
|
||
code_block_pat = re.compile(r"^```[^`]*$")
|
||
sec_id_pat = re.compile(r"\{#sec-[^}]+\}")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_div = False
|
||
in_html_comment = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
# HTML comments are tracked OUTSIDE code blocks. A code fence
|
||
# inside an HTML comment (e.g. a hidden TikZ example) is just
|
||
# text, not a real fence — toggling in_code on it would mis-
|
||
# classify the rest of the file. See `_maintain_section_ids`
|
||
# in maintenance.py for the matching writer-side fix.
|
||
if not in_code:
|
||
if "<!--" in stripped and "-->" not in stripped:
|
||
in_html_comment = True
|
||
continue
|
||
if in_html_comment:
|
||
if "-->" in stripped:
|
||
in_html_comment = False
|
||
continue
|
||
if code_block_pat.match(stripped):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if div_start_pat.match(stripped):
|
||
in_div = True
|
||
continue
|
||
if div_end_pat.match(stripped):
|
||
in_div = False
|
||
continue
|
||
if in_div:
|
||
continue
|
||
|
||
match = header_pat.match(line)
|
||
if not match:
|
||
continue
|
||
|
||
# Extract existing attributes
|
||
existing_attrs = ""
|
||
if "{" in line:
|
||
attrs_start = line.find("{")
|
||
attrs_end = line.rfind("}")
|
||
if attrs_end > attrs_start:
|
||
existing_attrs = line[attrs_start : attrs_end + 1]
|
||
|
||
if ".unnumbered" in existing_attrs:
|
||
continue
|
||
|
||
if not sec_id_pat.search(line):
|
||
title = match.group(2).strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="missing_section_id",
|
||
message=f"Header missing section ID: {title}",
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="headers",
|
||
description="Verify section headers have {#sec-...} IDs",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Footnote Placement (ported from check_forbidden_footnotes.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_footnote_placement(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
fn_pat = re.compile(r"\[\^fn-[\w-]+\]")
|
||
fn_def_pat = re.compile(r"^\[\^(fn-[\w-]+)\]:")
|
||
inline_fn_pat = re.compile(r"\^\[[^\]]+\]")
|
||
list_item_pat = re.compile(r"^(?P<indent>\s*)(?P<marker>(?:[-+*]|\d+[.)]))\s+")
|
||
table_sep_pat = re.compile(r"^\|[\s\-:+]+\|")
|
||
# Citation-then-footnote: visually anchors the footnote to the
|
||
# bibliographic reference instead of the concept term. \s*
|
||
# tolerates any whitespace between the two tokens (zero, single
|
||
# space, multiple spaces) — the misanchoring is the same
|
||
# regardless of how they are spaced.
|
||
# See book-prose.md §5 ("Footnote Marker Placement").
|
||
cite_then_fn_pat = re.compile(r"(\[@[^\]\s]+\])\s*(\[\^fn-[\w-]+\])")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
div_depth = 0
|
||
div_start_line = 0
|
||
|
||
def previous_list_item(line_index: int) -> Optional[Tuple[int, int]]:
|
||
"""Return (line, indent) for the list item immediately before a block."""
|
||
for prev_index in range(line_index - 1, -1, -1):
|
||
prev_line = lines[prev_index]
|
||
prev_stripped = prev_line.strip()
|
||
if not prev_stripped:
|
||
continue
|
||
if fn_def_pat.match(prev_stripped):
|
||
return None
|
||
if (
|
||
prev_stripped.startswith("#")
|
||
or prev_stripped.startswith(":::")
|
||
or prev_stripped.startswith("```")
|
||
or prev_stripped.startswith("|")
|
||
):
|
||
return None
|
||
marker = list_item_pat.match(prev_line)
|
||
if marker:
|
||
return prev_index + 1, len(marker.group("indent"))
|
||
if not prev_line.startswith((" ", "\t")):
|
||
return None
|
||
return None
|
||
|
||
def next_list_item_after_footnote(line_index: int) -> Optional[Tuple[int, int]]:
|
||
"""Return (line, indent) when a later list item follows before prose."""
|
||
for next_index in range(line_index + 1, len(lines)):
|
||
next_line = lines[next_index]
|
||
next_stripped = next_line.strip()
|
||
if not next_stripped:
|
||
continue
|
||
if fn_def_pat.match(next_stripped):
|
||
continue
|
||
marker = list_item_pat.match(next_line)
|
||
if marker:
|
||
return next_index + 1, len(marker.group("indent"))
|
||
if next_line.startswith((" ", "\t")):
|
||
continue
|
||
return None
|
||
return None
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
|
||
# Track div nesting
|
||
if re.match(r"^:{3,4}\s*\{", stripped) or re.match(r"^:{3,4}\s+\w", stripped):
|
||
div_depth += 1
|
||
if div_depth == 1:
|
||
div_start_line = idx
|
||
elif re.match(r"^:{3,4}\s*$", stripped):
|
||
if div_depth > 0:
|
||
div_depth -= 1
|
||
if div_depth == 0:
|
||
div_start_line = 0
|
||
|
||
fn_def = fn_def_pat.match(stripped)
|
||
if fn_def:
|
||
prev_list = previous_list_item(idx - 1)
|
||
next_list = next_list_item_after_footnote(idx - 1)
|
||
if prev_list and next_list:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_def_interrupts_list",
|
||
message=(
|
||
f"Footnote definition [^{fn_def.group(1)}]: appears between "
|
||
f"list items (previous item line {prev_list[0]}, next item "
|
||
f"line {next_list[0]}). Move the definition after the complete "
|
||
f"Markdown list so Pandoc preserves the list structure."
|
||
),
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
# Check inline footnotes (always forbidden)
|
||
for m in inline_fn_pat.finditer(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="inline_footnote",
|
||
message=f"Inline footnote syntax; use [^fn-name] reference format",
|
||
severity="error",
|
||
context=m.group(0)[:80],
|
||
)
|
||
)
|
||
|
||
# Citation immediately followed by footnote marker
|
||
# ([@cite][^fn-...]) — the marker should anchor to the
|
||
# concept term, not the bibliographic reference.
|
||
# rich-escape the bracketed tokens so [@key] doesn't get
|
||
# eaten by Rich's variable-interpolation markup.
|
||
for m in cite_then_fn_pat.finditer(line):
|
||
cite, fn = m.group(1), m.group(2)
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_after_citation",
|
||
message=(
|
||
f"Footnote {_rich_escape(fn)} placed immediately "
|
||
f"after citation {_rich_escape(cite)}; the marker "
|
||
f"should anchor to the concept term it "
|
||
f"elaborates, not the bibliographic reference. "
|
||
f"Move the marker leftward onto the term (see "
|
||
f"book-prose.md §5)."
|
||
),
|
||
severity="error",
|
||
context=_rich_escape(m.group(0)[:80]),
|
||
)
|
||
)
|
||
|
||
footnotes = fn_pat.findall(line)
|
||
if not footnotes:
|
||
continue
|
||
|
||
# Table cell check
|
||
if stripped.startswith("|") and stripped.count("|") >= 2 and not table_sep_pat.match(stripped):
|
||
for fn in footnotes:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_in_table",
|
||
message=f"Footnote {fn} in table cell",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
# YAML caption check
|
||
if re.match(r"^\s*(fig-cap|tbl-cap):", line):
|
||
cap_type = "figure" if "fig-cap" in line else "table"
|
||
for fn in footnotes:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=f"footnote_in_{cap_type}_caption",
|
||
message=f"Footnote {fn} in {cap_type} caption",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
# Markdown caption check
|
||
if re.match(r"^:\s*\*\*[^*]+\*\*:", line):
|
||
for fn in footnotes:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_in_markdown_caption",
|
||
message=f"Footnote {fn} in markdown caption",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
# Callout title check
|
||
if re.match(r"^:{3,4}\s*\{.*title=", stripped):
|
||
title_match = re.search(r'title="([^"]*)"', line)
|
||
if title_match and fn_pat.search(title_match.group(1)):
|
||
for fn in fn_pat.findall(title_match.group(1)):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_in_callout_title",
|
||
message=f"Footnote {fn} in callout title (breaks LaTeX)",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
# Div block check
|
||
if div_depth > 0 and div_start_line != idx:
|
||
for fn in footnotes:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_in_div",
|
||
message=f"Footnote {fn} inside div block (started line {div_start_line})",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="footnote-placement",
|
||
description="Check footnotes in forbidden locations and citation-adjacent placements",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Footnote Refs (ported from footnote_cleanup.py --validate)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_footnote_refs(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
ref_pat = re.compile(r"\[\^([^]]+)\]")
|
||
def_pat = re.compile(r"^\[\^([^]]+)\]:\s*(.+)$", re.MULTILINE)
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
lines = content.split("\n")
|
||
|
||
# Collect definitions
|
||
fn_defs: Dict[str, str] = {}
|
||
for m in def_pat.finditer(content):
|
||
fn_defs[m.group(1)] = m.group(2)
|
||
|
||
# Collect references (excluding definition lines themselves)
|
||
fn_refs: Dict[str, List[int]] = defaultdict(list)
|
||
for line_num, line in enumerate(lines, 1):
|
||
for m in ref_pat.finditer(line):
|
||
fn_id = m.group(1)
|
||
dm = def_pat.match(line)
|
||
if dm and dm.group(1) == fn_id:
|
||
continue # definition line, not a reference
|
||
fn_refs[fn_id].append(line_num)
|
||
|
||
# Undefined references
|
||
for fn_id in sorted(set(fn_refs.keys()) - set(fn_defs.keys())):
|
||
first_line = fn_refs[fn_id][0]
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=first_line,
|
||
code="undefined_footnote_ref",
|
||
message=f"Undefined footnote reference: [^{fn_id}]",
|
||
severity="error",
|
||
context=f"[^{fn_id}]",
|
||
)
|
||
)
|
||
|
||
# Unused definitions
|
||
for fn_id in sorted(set(fn_defs.keys()) - set(fn_refs.keys())):
|
||
def_line = self._line_for_token(content, f"[^{fn_id}]:")
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=def_line,
|
||
code="unused_footnote_def",
|
||
message=f"Unused footnote definition: [^{fn_id}]",
|
||
severity="warning",
|
||
context=f"[^{fn_id}]:",
|
||
)
|
||
)
|
||
|
||
# Duplicate definitions
|
||
def_counts: Dict[str, int] = defaultdict(int)
|
||
for line in lines:
|
||
dm = re.match(r"^\[\^([^]]+)\]:", line)
|
||
if dm:
|
||
def_counts[dm.group(1)] += 1
|
||
for fn_id, count in def_counts.items():
|
||
if count > 1:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=self._line_for_token(content, f"[^{fn_id}]:"),
|
||
code="duplicate_footnote_def",
|
||
message=f"Duplicate footnote definition ({count}x): [^{fn_id}]",
|
||
severity="error",
|
||
context=f"[^{fn_id}]:",
|
||
)
|
||
)
|
||
|
||
# Missing blank line before footnote definition
|
||
# Pandoc requires footnote definitions to start a new block.
|
||
# Without a preceding blank line, Pandoc treats the definition
|
||
# as continuation text and renders [^fn-name] as literal text.
|
||
fn_def_line_pat = re.compile(r"^\[\^[^\]]+\]:")
|
||
for idx, line in enumerate(lines):
|
||
if fn_def_line_pat.match(line) and idx > 0:
|
||
prev = lines[idx - 1]
|
||
if prev.strip(): # previous line is not blank
|
||
fn_match = re.match(r"^\[\^([^\]]+)\]:", line)
|
||
fn_id_str = fn_match.group(1) if fn_match else "?"
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx + 1,
|
||
code="footnote_missing_blank_line",
|
||
message=(
|
||
f"Footnote definition [^{fn_id_str}] has no blank line before it — "
|
||
f"Pandoc will not parse it as a footnote"
|
||
),
|
||
severity="error",
|
||
context=f"prev: {prev.strip()[:60]}",
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="footnote-refs",
|
||
description="Validate footnote references and definitions",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Figures (ported from check_figure_completeness.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
_CAPTION_HEAD_ATTR_RE = re.compile(
|
||
r"""\b(?P<key>fig-cap|tbl-cap|lst-cap)\s*=\s*"(?P<value>[^"]*)\""""
|
||
)
|
||
_MARKDOWN_CAPTION_HEAD_RE = re.compile(
|
||
r"""^\s*:\s+(?P<value>.+?)\s+\{#(?P<label>(?:fig|tbl|lst)-[\w-]+)"""
|
||
)
|
||
_CHUNK_CAPTION_HEAD_RE = re.compile(
|
||
r"""^\s*(?:#|%%)\|\s*(?P<key>fig-cap|tbl-cap|lst-cap)\s*:\s*(?P<value>.*?)\s*$"""
|
||
)
|
||
_CAPTION_HEAD_STYLE_RE = re.compile(
|
||
r"""^\*\*(?P<head>[^*\n]+)\*\*(?P<index>(?:\\index\{[^}\n]+\})*):\s+(?P<body>\S.*)$"""
|
||
)
|
||
|
||
def _run_mitpress_caption_head_style(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
def check_caption(
|
||
file: Path,
|
||
line_no: int,
|
||
key: str,
|
||
value: str,
|
||
context: str,
|
||
) -> None:
|
||
if not value.startswith("**"):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="caption_head_missing_bold",
|
||
message=f"{key} must start with a bold caption head: **Bold Title**: Explanation",
|
||
severity="error",
|
||
context=context[:160],
|
||
)
|
||
)
|
||
return
|
||
|
||
match = self._CAPTION_HEAD_STYLE_RE.match(value)
|
||
if not match:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="caption_head_separator",
|
||
message=f"{key} must use colon outside bold: **Bold Title**: Explanation",
|
||
severity="error",
|
||
context=context[:160],
|
||
)
|
||
)
|
||
return
|
||
|
||
head = match.group("head").strip()
|
||
body = match.group("body").lstrip()
|
||
if head.endswith((".", ":", ";", "!", "?")):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="caption_head_terminal_punctuation",
|
||
message=f"{key} caption head should not end with punctuation inside bold",
|
||
severity="error",
|
||
context=context[:160],
|
||
)
|
||
)
|
||
if body and re.match(r"[a-z]", body[0]):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="caption_body_lowercase_after_colon",
|
||
message=f"{key} caption text after the colon should begin as a sentence",
|
||
severity="error",
|
||
context=context[:160],
|
||
)
|
||
)
|
||
if "**" in body:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="caption_body_extra_bold",
|
||
message=f"{key} caption body should be plain prose; only the leading caption head is bold",
|
||
severity="error",
|
||
context=context[:160],
|
||
)
|
||
)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.rstrip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
chunk_match = self._CHUNK_CAPTION_HEAD_RE.match(line)
|
||
if chunk_match:
|
||
check_caption(
|
||
file,
|
||
idx,
|
||
chunk_match.group("key"),
|
||
chunk_match.group("value").strip().strip("'\""),
|
||
line.strip(),
|
||
)
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
for attr_match in self._CAPTION_HEAD_ATTR_RE.finditer(line):
|
||
check_caption(
|
||
file,
|
||
idx,
|
||
attr_match.group("key"),
|
||
attr_match.group("value").strip(),
|
||
line.strip(),
|
||
)
|
||
|
||
md_match = self._MARKDOWN_CAPTION_HEAD_RE.match(line)
|
||
if md_match:
|
||
label = md_match.group("label")
|
||
key = f"{label.split('-', 1)[0]}-caption"
|
||
check_caption(
|
||
file,
|
||
idx,
|
||
key,
|
||
md_match.group("value").strip(),
|
||
line.strip(),
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="caption-heads",
|
||
description="Check figure, table, and listing caption heads use **Bold Title**: Explanation",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_figures(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
fig_id_pat = re.compile(r"\{#(fig-[a-zA-Z0-9_-]+)[\s}]")
|
||
md_cap_pat = re.compile(r"!\[(.+?)\]\(")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
seen_ids: Set[str] = set()
|
||
|
||
# Pass 1: attribute-based figures
|
||
for idx, line in enumerate(lines, 1):
|
||
m = fig_id_pat.search(line)
|
||
if not m:
|
||
continue
|
||
fig_id = m.group(1)
|
||
has_cap = bool(re.search(r'fig-cap="[^"]+', line))
|
||
has_alt = bool(re.search(r'fig-alt="[^"]+', line))
|
||
|
||
if "![" in line:
|
||
md_m = md_cap_pat.search(line)
|
||
if md_m and md_m.group(1).strip():
|
||
has_cap = True
|
||
|
||
seen_ids.add(fig_id)
|
||
missing = []
|
||
if not has_cap:
|
||
missing.append("caption")
|
||
if not has_alt:
|
||
missing.append("alt-text")
|
||
if missing:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="incomplete_figure",
|
||
message=f"Figure {fig_id} missing: {', '.join(missing)}",
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
|
||
# Pass 2: code-cell figures
|
||
in_code = False
|
||
code_start = 0
|
||
cell_opts: Dict[str, str] = {}
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.rstrip()
|
||
if not in_code and re.match(r"^```\{(?:python|r|julia|ojs)", stripped):
|
||
in_code = True
|
||
code_start = idx
|
||
cell_opts = {}
|
||
continue
|
||
if in_code and stripped == "```":
|
||
label = cell_opts.get("label", "")
|
||
if label.startswith("fig-") and label not in seen_ids:
|
||
cap_val = cell_opts.get("fig-cap", "")
|
||
alt_val = cell_opts.get("fig-alt", "")
|
||
missing = []
|
||
if not cap_val:
|
||
missing.append("caption")
|
||
if not alt_val:
|
||
missing.append("alt-text")
|
||
if missing:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=code_start,
|
||
code="incomplete_figure",
|
||
message=f"Figure {label} missing: {', '.join(missing)}",
|
||
severity="error",
|
||
context=f"code-cell figure {label}",
|
||
)
|
||
)
|
||
seen_ids.add(label)
|
||
in_code = False
|
||
cell_opts = {}
|
||
continue
|
||
if in_code:
|
||
opt_m = re.match(r"^#\|\s*([\w-]+):\s*(.+)$", stripped)
|
||
if opt_m:
|
||
val = opt_m.group(2).strip().strip("\"'")
|
||
cell_opts[opt_m.group(1)] = val
|
||
|
||
return ValidationRunResult(
|
||
name="figures",
|
||
description="Check figures have captions and alt-text",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Captionless floats: pipe tables, markdown-image figures, and #lst-
|
||
# listings that lack the required caption/label/lst-cap. Baseline
|
||
# at book/tools/audit/baselines/captions_baseline.json grandfathers
|
||
# pre-existing violations; new ones block the commit.
|
||
# ------------------------------------------------------------------
|
||
|
||
_CAPTIONS_SKIP_PATH_PARTS = ("/frontmatter/", "/backmatter/")
|
||
_CAPTIONS_CALLOUT_OPEN_RE = re.compile(r"^:::+\s*\{[^}]*\.callout-")
|
||
_CAPTIONS_DIV_OPEN_RE = re.compile(r"^:::+\s*\{")
|
||
_CAPTIONS_DIV_CLOSE_RE = re.compile(r"^:::\s*$")
|
||
_PIPE_ROW_RE = re.compile(r"^\s*\|.+\|\s*$")
|
||
_PIPE_SEP_RE = re.compile(r"^\s*\|[\s:|-]+\|\s*$")
|
||
_TBL_CAP_ANY_RE = re.compile(r"^\s*:\s+\S")
|
||
_TBL_CAP_LABELED_RE = re.compile(r"^\s*:\s+.+\{#tbl-[\w-]+")
|
||
_MD_IMAGE_ANY_RE = re.compile(r"!\[[^\]]*\]\s*\([^)]+\)")
|
||
_MD_IMAGE_WITH_LABEL_RE = re.compile(r"!\[[^\]]*\]\s*\([^)]+\)\s*\{[^}]*#fig-")
|
||
_LST_DIV_OPEN_RE = re.compile(r"^:::+\s*\{[^}]*#(lst-[\w-]+)")
|
||
_LST_CAP_ATTR_RE = re.compile(r'lst-cap\s*=\s*"')
|
||
|
||
def _captions_skip_file(self, file: Path) -> bool:
|
||
rel = str(file).replace("\\", "/")
|
||
return any(p in rel for p in self._CAPTIONS_SKIP_PATH_PARTS)
|
||
|
||
def _load_captions_baseline(self) -> Dict[str, Dict[str, int]]:
|
||
"""Load per-file violation counts. Missing file → empty (no grandfather)."""
|
||
try:
|
||
data = json.loads(CAPTIONS_BASELINE_PATH.read_text(encoding="utf-8"))
|
||
except FileNotFoundError:
|
||
return {}
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
return data.get("counts", {})
|
||
|
||
def _apply_captions_baseline(
|
||
self,
|
||
issues: List[ValidationIssue],
|
||
code: str,
|
||
) -> List[ValidationIssue]:
|
||
"""Drop up to N issues per file where N is the baseline budget.
|
||
|
||
Same semantics as bib_lint_baseline.json: grandfathered violations
|
||
are filtered out entirely so the check passes; only NEW violations
|
||
(i.e., counts that *exceed* the per-file budget) surface as errors.
|
||
Shrink the baseline as the corpus debt gets cleaned up.
|
||
"""
|
||
baseline = self._load_captions_baseline().get(code, {})
|
||
grouped: Dict[str, List[ValidationIssue]] = {}
|
||
for issue in issues:
|
||
grouped.setdefault(issue.file, []).append(issue)
|
||
out: List[ValidationIssue] = []
|
||
for file, group in grouped.items():
|
||
budget = baseline.get(file, 0)
|
||
for i, issue in enumerate(group):
|
||
if i < budget:
|
||
continue
|
||
out.append(issue)
|
||
return out
|
||
|
||
def _captions_iter_body_lines(
|
||
self,
|
||
lines: List[str],
|
||
):
|
||
"""Yield (idx, line, in_callout) for lines outside code fences.
|
||
|
||
Tracks div nesting; flags whether the current line sits inside any
|
||
open ::: {.callout-*} block.
|
||
"""
|
||
in_code = False
|
||
div_stack: List[str] = []
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.rstrip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
yield idx, line, ("callout" in div_stack), in_code, "code-toggle"
|
||
continue
|
||
if in_code:
|
||
yield idx, line, ("callout" in div_stack), in_code, "code"
|
||
continue
|
||
if self._CAPTIONS_CALLOUT_OPEN_RE.match(stripped):
|
||
div_stack.append("callout")
|
||
yield idx, line, True, False, "div-open"
|
||
continue
|
||
if self._CAPTIONS_DIV_OPEN_RE.match(stripped):
|
||
div_stack.append("other")
|
||
yield idx, line, ("callout" in div_stack), False, "div-open"
|
||
continue
|
||
if self._CAPTIONS_DIV_CLOSE_RE.match(stripped):
|
||
if div_stack:
|
||
div_stack.pop()
|
||
yield idx, line, ("callout" in div_stack), False, "div-close"
|
||
continue
|
||
yield idx, line, ("callout" in div_stack), False, "prose"
|
||
|
||
def _run_table_caption_required(self, root: Path) -> ValidationRunResult:
|
||
"""Flag pipe tables (body prose AND inside callouts) lacking `: caption {#tbl-X}`.
|
||
|
||
Per the policy codified after the 2026-05 release pass: every pipe
|
||
table that carries quantitative content needs a caption + ID,
|
||
regardless of whether it sits in body prose, inside a callout, or
|
||
inside a `{tbl-colwidths=...}` div. The MIT Press / H&P / CS:APP
|
||
convention is universal captioning.
|
||
|
||
Skips files under frontmatter/. Pre-existing violations (including
|
||
the ~93 captionless tables surfaced when this rule was extended)
|
||
are grandfathered via the captions baseline; new ones error.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
if self._captions_skip_file(file):
|
||
continue
|
||
lines = self._read_text(file).splitlines()
|
||
state = list(self._captions_iter_body_lines(lines))
|
||
# Cache (idx-1 → in_callout, kind)
|
||
meta = {idx: (cal, kind) for idx, _, cal, _, kind in state}
|
||
i = 0
|
||
n = len(lines)
|
||
while i < n:
|
||
idx = i + 1
|
||
line = lines[i]
|
||
cal, kind = meta.get(idx, (False, "prose"))
|
||
if kind != "prose":
|
||
i += 1
|
||
continue
|
||
# NOTE: We used to skip tables inside ::: {.callout-*} divs
|
||
# ("the callout title acts as the contextual heading"). That
|
||
# exception was retired after the 2026-05 release pass — the
|
||
# callout title is not a citable reference, and tables that
|
||
# carry quantitative content need IDs in any context.
|
||
if (self._PIPE_ROW_RE.match(line)
|
||
and i + 1 < n
|
||
and self._PIPE_SEP_RE.match(lines[i + 1])):
|
||
table_start = idx
|
||
j = i + 2
|
||
while j < n and self._PIPE_ROW_RE.match(lines[j]):
|
||
j += 1
|
||
# `caption-required` is intentionally permissive so it
|
||
# can distinguish missing captions from misplaced ones.
|
||
# `caption-position` below enforces the book convention:
|
||
# pipe-table captions belong after the table body.
|
||
after: List[str] = []
|
||
k = j
|
||
while k < n and len(after) < 5:
|
||
if lines[k].strip():
|
||
after.append(lines[k])
|
||
k += 1
|
||
before: List[str] = []
|
||
k = i - 1
|
||
while k >= 0 and len(before) < 3:
|
||
s = lines[k].strip()
|
||
if s:
|
||
# Stop scanning upward once we hit something
|
||
# that clearly isn't a caption (a heading, a
|
||
# paragraph of prose, a fenced block).
|
||
if s.startswith(("#", "```", ":::", "|")):
|
||
if not s.startswith(":"):
|
||
break
|
||
before.append(lines[k])
|
||
if s.startswith(":") and not s.startswith("::"):
|
||
break
|
||
k -= 1
|
||
cap_text = "\n".join(after + before)
|
||
if not re.search(r"^\s*:\s+\S", cap_text, re.M):
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=table_start,
|
||
code="table_missing_caption",
|
||
message=(
|
||
"Pipe table in body prose has no caption "
|
||
"line. Add: `: **Title**: explanation. "
|
||
"{#tbl-X}` after the table, then "
|
||
"reference it with `@tbl-X` in prose."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
elif not re.search(
|
||
r"^\s*:\s+.+\{#tbl-[\w-]+", cap_text, re.M):
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=table_start,
|
||
code="table_caption_missing_label",
|
||
message=(
|
||
"Pipe table caption is missing a "
|
||
"{#tbl-X} label, so the table cannot be "
|
||
"cross-referenced. Add the label."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
i = j
|
||
else:
|
||
i += 1
|
||
|
||
issues = self._apply_captions_baseline(raw_issues, "table_caption_required")
|
||
return ValidationRunResult(
|
||
name="table-caption-required",
|
||
description=(
|
||
"Body-prose pipe tables must carry a caption + #tbl- label "
|
||
"(skipping callouts, frontmatter, backmatter)"
|
||
),
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _caption_is_after_pipe_table(
|
||
self,
|
||
lines: List[str],
|
||
caption_idx: int,
|
||
) -> bool:
|
||
"""Return True when a caption is already attached below a pipe table."""
|
||
j = caption_idx - 1
|
||
while j >= 0:
|
||
stripped = lines[j].strip()
|
||
if not stripped or stripped.startswith("\\index{"):
|
||
j -= 1
|
||
continue
|
||
# A caption may sit just after a closing div fence when a legacy
|
||
# colwidths wrapper is involved.
|
||
if stripped.startswith(":::"):
|
||
j -= 1
|
||
continue
|
||
return self._PIPE_ROW_LOOSE_RE.match(lines[j]) is not None
|
||
return False
|
||
|
||
def _pipe_table_start_after_caption(
|
||
self,
|
||
lines: List[str],
|
||
caption_idx: int,
|
||
) -> Optional[int]:
|
||
"""Return 0-indexed pipe-table header line after a top caption."""
|
||
j = caption_idx + 1
|
||
limit = min(len(lines), caption_idx + 8)
|
||
while j < limit:
|
||
stripped = lines[j].strip()
|
||
if not stripped:
|
||
j += 1
|
||
continue
|
||
# Allow a wrapper fence between a misplaced caption and table.
|
||
if stripped.startswith(":::"):
|
||
j += 1
|
||
continue
|
||
if (
|
||
self._PIPE_ROW_RE.match(lines[j])
|
||
and j + 1 < len(lines)
|
||
and self._PIPE_SEP_RE.match(lines[j + 1])
|
||
):
|
||
return j
|
||
return None
|
||
return None
|
||
|
||
def _run_table_caption_position(self, root: Path) -> ValidationRunResult:
|
||
"""Flag pipe-table captions placed before the table body.
|
||
|
||
MLSysBook convention is the Pandoc/Quarto pipe-table form:
|
||
|
||
| **Column** | **Column** |
|
||
|:-----------|:-----------|
|
||
| value | value |
|
||
|
||
: **Caption**: Explanation. {#tbl-id}
|
||
|
||
A caption placed above the pipe table still looks plausible in source,
|
||
but it is inconsistent with the corpus convention and makes later
|
||
table-moving/layout tooling ambiguous.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
# Frontmatter is allowed to contain non-book scaffolding; all book
|
||
# body/backmatter pipe tables use the same caption placement.
|
||
if "/frontmatter/" in str(file).replace("\\", "/"):
|
||
continue
|
||
lines = self._read_text(file).splitlines()
|
||
state = list(self._captions_iter_body_lines(lines))
|
||
meta = {idx: kind for idx, _, _, _, kind in state}
|
||
for i, line in enumerate(lines):
|
||
idx = i + 1
|
||
if meta.get(idx) != "prose":
|
||
continue
|
||
if not self._TBL_CAP_LABELED_RE.match(line):
|
||
continue
|
||
if self._caption_is_after_pipe_table(lines, i):
|
||
continue
|
||
table_start = self._pipe_table_start_after_caption(lines, i)
|
||
if table_start is None:
|
||
continue
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="table_caption_before_pipe_table",
|
||
message=(
|
||
"Pipe-table caption appears before the table "
|
||
f"(table header starts at line {table_start + 1}). "
|
||
"Move the caption below the final table row."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
suggestion=(
|
||
"Use the canonical order: pipe table first, then "
|
||
"`: **Title**: explanation. {#tbl-id}`."
|
||
),
|
||
))
|
||
return ValidationRunResult(
|
||
name="table-caption-position",
|
||
description="Pipe-table captions must appear below the table body",
|
||
files_checked=len(files),
|
||
issues=raw_issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_CAPTION_ORPHAN_RE = re.compile(r"^\s*:\s+.*\{#tbl-[A-Za-z0-9_-]+[\s}]")
|
||
_CAPTION_CLOSE_FENCE_RE = re.compile(r"^\s*:{3,}\s*$")
|
||
|
||
def _run_table_caption_orphan(self, root: Path) -> ValidationRunResult:
|
||
"""Flag `: caption {#tbl-...}` lines sandwiched between two `:::` close fences.
|
||
|
||
The pattern that broke vol1 EPUB on 2026-05-18 (commit 54ca22d7d7):
|
||
|
||
::: {.content-visible when-format="html"}
|
||
::: {tbl-colwidths="..."}
|
||
|
||
| row | row |
|
||
|
||
::: ← inner close
|
||
|
||
: **Caption**: ... {#tbl-foo} ← orphan caption
|
||
|
||
::: ← outer close
|
||
|
||
Pandoc's EPUB writer cannot bind a caption across the inner-close
|
||
fence; the label never registers, `@tbl-` xrefs go unresolved, and
|
||
epubcheck reports ERRORs. PDF is unaffected because the PDF block
|
||
uses raw LaTeX `tabular`.
|
||
|
||
Safe form: move `tbl-colwidths` onto the caption attribute and drop
|
||
the inner nested div.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
in_code = False
|
||
in_raw = False
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_raw = False
|
||
n = len(lines)
|
||
for i, line in enumerate(lines):
|
||
# Toggle code-fence state.
|
||
if re.match(r"^\s*(```+|~~~+)", line):
|
||
if re.match(r"^\s*```\s*\{\.?tikz|^\s*```\s*\{=latex\}", line):
|
||
in_raw = not in_raw
|
||
else:
|
||
in_code = not in_code
|
||
continue
|
||
if in_code or in_raw:
|
||
continue
|
||
if not self._CAPTION_ORPHAN_RE.match(line):
|
||
continue
|
||
# Walk backward to closest non-blank.
|
||
prev_idx = None
|
||
j = i - 1
|
||
while j >= 0:
|
||
if lines[j].strip():
|
||
prev_idx = j
|
||
break
|
||
j -= 1
|
||
# Walk forward to closest non-blank.
|
||
next_idx = None
|
||
k = i + 1
|
||
while k < n:
|
||
if lines[k].strip():
|
||
next_idx = k
|
||
break
|
||
k += 1
|
||
if prev_idx is None or next_idx is None:
|
||
continue
|
||
prev_is_close = self._CAPTION_CLOSE_FENCE_RE.match(lines[prev_idx]) is not None
|
||
next_is_close = self._CAPTION_CLOSE_FENCE_RE.match(lines[next_idx]) is not None
|
||
if prev_is_close and next_is_close:
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=i + 1,
|
||
code="table_caption_orphan",
|
||
message=(
|
||
"Caption is sandwiched between two `:::` close "
|
||
f"fences (prev close at line {prev_idx + 1}, "
|
||
f"next close at line {next_idx + 1}). Pandoc's "
|
||
"EPUB writer will orphan the caption and "
|
||
"@tbl- refs will go unresolved. Move "
|
||
"`tbl-colwidths` onto the caption attribute "
|
||
"and drop the inner nested div."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
return ValidationRunResult(
|
||
name="table-caption-orphan",
|
||
description=(
|
||
"Captions must not sit between two `:::` close fences "
|
||
"(EPUB build integrity)"
|
||
),
|
||
files_checked=len(files),
|
||
issues=raw_issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_TABLE_CAPTION_RE = re.compile(
|
||
r"^:\s+\*?\*?.*\{#tbl-[\w-]+",
|
||
)
|
||
_PIPE_ROW_LOOSE_RE = re.compile(r"^\s*\|.+\|")
|
||
|
||
def _run_table_caption_detached(self, root: Path) -> ValidationRunResult:
|
||
"""Flag pipe tables whose `: caption {#tbl-}` is separated by prose.
|
||
|
||
Quarto only binds a pipe-table caption to the table when the
|
||
`: Caption {#tbl-X}` line is the first non-blank line after the
|
||
last data row. Intervening prose breaks the binding and the
|
||
cross-reference renders as ``?@tbl-X`` in the PDF.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
n = len(lines)
|
||
in_code = False
|
||
i = 0
|
||
while i < n:
|
||
line = lines[i]
|
||
if re.match(r"^\s*```", line):
|
||
in_code = not in_code
|
||
i += 1
|
||
continue
|
||
if in_code:
|
||
i += 1
|
||
continue
|
||
if (self._PIPE_ROW_RE.match(line)
|
||
and i + 1 < n
|
||
and self._PIPE_SEP_RE.match(lines[i + 1])):
|
||
j = i + 2
|
||
while j < n and self._PIPE_ROW_LOOSE_RE.match(lines[j]):
|
||
j += 1
|
||
table_end = j - 1
|
||
k = j
|
||
while k < n and not lines[k].strip():
|
||
k += 1
|
||
if k < n and self._TABLE_CAPTION_RE.match(lines[k]):
|
||
i = k + 1
|
||
continue
|
||
cap_k = k
|
||
while cap_k < n and cap_k < j + 8:
|
||
if self._TABLE_CAPTION_RE.match(lines[cap_k]):
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=cap_k + 1,
|
||
code="table_caption_detached",
|
||
message=(
|
||
f"Caption with #tbl- label is separated "
|
||
f"from its pipe table (table ends line "
|
||
f"{table_end + 1}) by intervening prose. "
|
||
f"Move the caption immediately after the "
|
||
f"table or merge the prose into the caption."
|
||
),
|
||
severity="error",
|
||
context=lines[cap_k].strip()[:120],
|
||
))
|
||
break
|
||
cap_k += 1
|
||
i = max(j, cap_k + 1)
|
||
else:
|
||
i += 1
|
||
return ValidationRunResult(
|
||
name="table-caption-detached",
|
||
description=(
|
||
"Pipe-table captions must immediately follow the table "
|
||
"(no intervening prose)"
|
||
),
|
||
files_checked=len(files),
|
||
issues=raw_issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_FIG_DIV_OPEN_RE = re.compile(r"^:::+\s*\{[^}]*#fig-[\w-]+")
|
||
# Chapter-opener cover images are decorative and not cross-referenced
|
||
# by convention. They carry fig-alt for accessibility but no #fig-
|
||
# label. Skip them in the label-required check.
|
||
_COVER_IMAGE_RE = re.compile(r"!\[[^\]]*\]\s*\([^)]*/cover_[\w-]+\.(png|jpg|jpeg|svg|gif|webp)")
|
||
|
||
def _run_figure_label_required(self, root: Path) -> ValidationRunResult:
|
||
"""Flag `` markdown image figures with no `{#fig-X}` label.
|
||
|
||
A bare `` is fine when wrapped in a
|
||
`::: {#fig-X fig-cap=... fig-alt=...}` div (the conventional
|
||
pattern in this book), since the div carries the label. We flag
|
||
only images that are *neither* inline-labeled (`{#fig-X}` on the
|
||
same line) *nor* enclosed in a `#fig-` div. Skips callouts,
|
||
frontmatter, and backmatter the same way the other caption
|
||
scopes do.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
if self._captions_skip_file(file):
|
||
continue
|
||
lines = self._read_text(file).splitlines()
|
||
# Walk lines tracking div stack tagged by kind: "callout",
|
||
# "fig" (a #fig-X labeled div), or "other".
|
||
div_stack: List[str] = []
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.rstrip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if self._CAPTIONS_CALLOUT_OPEN_RE.match(stripped):
|
||
div_stack.append("callout")
|
||
continue
|
||
if self._FIG_DIV_OPEN_RE.match(stripped):
|
||
div_stack.append("fig")
|
||
continue
|
||
if self._CAPTIONS_DIV_OPEN_RE.match(stripped):
|
||
# Unnumbered margin illustrations live in `.column-margin`
|
||
# divs and are intentionally label-free marginalia
|
||
# (figure-margin.md §1/§8): no `{#fig-X}`, never referenced
|
||
# via `@fig-`. Tag them so their images are not flagged.
|
||
kind = "margin" if ".column-margin" in stripped else "other"
|
||
div_stack.append(kind)
|
||
continue
|
||
if self._CAPTIONS_DIV_CLOSE_RE.match(stripped):
|
||
if div_stack:
|
||
div_stack.pop()
|
||
continue
|
||
if "callout" in div_stack or "fig" in div_stack or "margin" in div_stack:
|
||
continue
|
||
if not self._MD_IMAGE_ANY_RE.search(line):
|
||
continue
|
||
if self._MD_IMAGE_WITH_LABEL_RE.search(line):
|
||
continue
|
||
if self._COVER_IMAGE_RE.search(line):
|
||
continue
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="figure_missing_label",
|
||
message=(
|
||
"Markdown image figure has no {#fig-X} label "
|
||
"and is not enclosed in a `::: {#fig-X ...}` "
|
||
"div. Wrap it so the figure can be "
|
||
"cross-referenced with `@fig-X` in prose."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
|
||
issues = self._apply_captions_baseline(raw_issues, "figure_label_required")
|
||
return ValidationRunResult(
|
||
name="figure-label-required",
|
||
description=(
|
||
"Markdown-image figures in body prose must carry a "
|
||
"{#fig-X} label so they can be cross-referenced"
|
||
),
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_listing_caption_required(self, root: Path) -> ValidationRunResult:
|
||
"""Flag `::: {#lst-X ...}` listing divs missing `lst-cap="..."`.
|
||
|
||
Listings are floating elements that must carry a caption so they
|
||
can be cross-referenced like figures and tables. A `#lst-` label
|
||
without an `lst-cap` is a malformed listing.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
raw_issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
if self._captions_skip_file(file):
|
||
continue
|
||
lines = self._read_text(file).splitlines()
|
||
for idx, line in enumerate(lines, 1):
|
||
m = self._LST_DIV_OPEN_RE.search(line)
|
||
if not m:
|
||
continue
|
||
# The opening div may carry attrs across continuation
|
||
# lines, but in practice the lst-cap=" lives on the same
|
||
# line. Look on the same line; widen to next 2 lines as a
|
||
# cheap fallback for unusual line-wrapping.
|
||
lookahead = line
|
||
if idx < len(lines):
|
||
lookahead += "\n" + lines[idx]
|
||
if idx + 1 < len(lines):
|
||
lookahead += "\n" + lines[idx + 1]
|
||
if self._LST_CAP_ATTR_RE.search(lookahead):
|
||
continue
|
||
raw_issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="listing_missing_caption",
|
||
message=(
|
||
f"Listing {m.group(1)} has no lst-cap=\"...\" "
|
||
f"attribute. Add a bold caption so the listing "
|
||
f"can be cross-referenced with `@{m.group(1)}` "
|
||
f"in prose."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
|
||
issues = self._apply_captions_baseline(raw_issues, "listing_caption_required")
|
||
return ValidationRunResult(
|
||
name="listing-caption-required",
|
||
description=(
|
||
"#lst- listing divs must carry lst-cap=\"...\" so the "
|
||
"listing can be captioned and cross-referenced"
|
||
),
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Figure div syntax (ported from check_figure_div_syntax.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
_MARKDOWN_IMAGE_FIG = re.compile(r"!\[.*\]\s*\([^)]+\)\s*\{#fig-")
|
||
_CHUNK_FIG_OPTION = re.compile(r"^#\|\s*(fig-cap|fig-alt)\s*[:=]")
|
||
|
||
def _run_figure_div_syntax(self, root: Path) -> ValidationRunResult:
|
||
"""Enforce div syntax for figures (no markdown-image figs or chunk options)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
for idx, line in enumerate(lines, 1):
|
||
if self._MARKDOWN_IMAGE_FIG.search(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="markdown_image_fig",
|
||
message="Use div syntax for figures, not {#fig-...}",
|
||
severity="error",
|
||
context=line.strip()[:80],
|
||
)
|
||
)
|
||
if self._CHUNK_FIG_OPTION.match(line.strip()):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="chunk_fig_option",
|
||
message="Use div syntax for fig-cap/fig-alt, not #| chunk options",
|
||
severity="error",
|
||
context=line.strip()[:80],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="div-syntax",
|
||
description="Enforce figure div syntax (no markdown-image or chunk fig-cap/fig-alt)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Float Flow (ported from figure_table_flow_audit.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_float_flow(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
div_def_pat = re.compile(r":::\s*\{[^}]*#((?:fig|tbl)-[\w-]+)")
|
||
img_def_pat = re.compile(r"!\[.*?\]\(.*?\)\s*\{[^}]*#((?:fig|tbl)-[\w-]+)")
|
||
tbl_cap_pat = re.compile(r"^:\s+.*\{[^}]*#((?:fig|tbl)-[\w-]+)")
|
||
ref_pat = re.compile(r"@((?:fig|tbl)-[\w-]+)", re.IGNORECASE)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
defs: Dict[str, int] = {}
|
||
refs: Dict[str, List[int]] = defaultdict(list)
|
||
in_code = False
|
||
in_float = False
|
||
float_label: Optional[str] = None
|
||
code_spans: List[Tuple[int, int]] = []
|
||
code_start = 0
|
||
cell_opts: Dict[str, str] = {}
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.rstrip()
|
||
|
||
# Code block tracking
|
||
if not in_code and re.match(r"^```\{", stripped):
|
||
in_code = True
|
||
code_start = idx
|
||
cell_opts = {}
|
||
continue
|
||
if in_code and stripped == "```":
|
||
code_spans.append((code_start, idx))
|
||
label = cell_opts.get("label", "")
|
||
if label.startswith(("fig-", "tbl-")) and label not in defs:
|
||
defs[label] = code_start
|
||
in_code = False
|
||
cell_opts = {}
|
||
continue
|
||
if in_code:
|
||
opt_m = re.match(r"^#\|\s*([\w-]+):\s*(.+)$", stripped)
|
||
if opt_m:
|
||
cell_opts[opt_m.group(1)] = opt_m.group(2).strip().strip("\"'")
|
||
continue
|
||
|
||
# Attribute-based definitions
|
||
for pat in [div_def_pat, img_def_pat, tbl_cap_pat]:
|
||
m = pat.search(line)
|
||
if m:
|
||
label = m.group(1)
|
||
if label not in defs:
|
||
defs[label] = idx
|
||
if pat == div_def_pat:
|
||
in_float = True
|
||
float_label = label
|
||
|
||
# Track float block end
|
||
if in_float:
|
||
ls = line.strip()
|
||
if ls.startswith(":::") and not ls.startswith("::: {"):
|
||
in_float = False
|
||
float_label = None
|
||
|
||
# References
|
||
if "fig-cap=" in line or "fig-alt=" in line:
|
||
continue
|
||
for m in ref_pat.finditer(line):
|
||
label = m.group(1)
|
||
label = label.split("-", 1)[0].lower() + "-" + label.split("-", 1)[1]
|
||
if in_float and label == float_label:
|
||
continue
|
||
refs[label].append(idx)
|
||
|
||
# Evaluate status
|
||
all_labels = set(defs.keys()) | set(refs.keys())
|
||
for label in sorted(all_labels):
|
||
def_line = defs.get(label)
|
||
ref_lines = refs.get(label, [])
|
||
first_ref = min(ref_lines) if ref_lines else None
|
||
|
||
if not def_line:
|
||
continue # XREF — informational, skip
|
||
if not first_ref:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=def_line,
|
||
code="orphan_float",
|
||
message=f"{'Figure' if label.startswith('fig-') else 'Table'} {label} defined but never referenced",
|
||
severity="warning",
|
||
context=label,
|
||
)
|
||
)
|
||
continue
|
||
|
||
# Compute prose gap
|
||
gap = def_line - first_ref
|
||
code_lines = 0
|
||
if gap > 0:
|
||
for cs, ce in code_spans:
|
||
os_ = max(first_ref, cs)
|
||
oe_ = min(def_line, ce)
|
||
if os_ <= oe_:
|
||
code_lines += oe_ - os_ + 1
|
||
prose_gap = gap - code_lines
|
||
|
||
if prose_gap > 30:
|
||
# Check closest reference
|
||
closest = min(ref_lines, key=lambda r: abs(def_line - r))
|
||
closest_gap = def_line - closest
|
||
if -10 <= closest_gap <= 30:
|
||
continue # OK
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=def_line,
|
||
code="late_float",
|
||
message=f"{label} defined at L{def_line}, first referenced at L{first_ref} (too far after mention)",
|
||
severity="warning",
|
||
context=label,
|
||
)
|
||
)
|
||
elif prose_gap < -10:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=def_line,
|
||
code="early_float",
|
||
message=f"{label} defined at L{def_line}, first referenced at L{first_ref} (appears before mention)",
|
||
severity="warning",
|
||
context=label,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="float-flow",
|
||
description="Audit figure/table placement relative to first reference",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Indexes (ported from check_index_placement.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_indexes(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
checks = [
|
||
("index_on_heading", re.compile(r"^#{1,6}\s+.*\\index\{"), "\\index{} on same line as heading"),
|
||
("index_before_div", re.compile(r"\\index\{[^}]*\}:::"), "\\index{} directly before ::: (div/callout)"),
|
||
("index_after_div", re.compile(r"^::+\s+\{[^}]*\}\s*\\index\{"), "\\index{} on same line as div/callout"),
|
||
("index_before_footnote", re.compile(r"^\\index\{[^}]*\}.*\[\^[^\]]+\]:"), "\\index{} before footnote definition"),
|
||
]
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
if line.strip().startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
for code, pattern, message in checks:
|
||
# Skip fig-cap lines for index_after_div
|
||
if code == "index_after_div" and "fig-cap=" in line:
|
||
continue
|
||
if pattern.search(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="indexes",
|
||
description="Check LaTeX \\index{} placement",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Rendering (ported from check_render_patterns.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_rendering(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
regex_checks = [
|
||
("missing_opening_backtick", re.compile(r"(?<!`)(\{python\}\s+\w+`)"), "Missing opening backtick on inline Python", "error"),
|
||
("dollar_before_python", re.compile(r"\$\{python\}\s+\w+`"), "Dollar sign instead of backtick before {python}", "error"),
|
||
("quad_asterisks", re.compile(r"\*{4,}"), "Quad asterisks — likely malformed bold/italic", "warning"),
|
||
("footnote_in_table", re.compile(r"^\|.*\[\^fn-[^\]]+\].*\|"), "Footnote in table cell — may break PDF", "warning"),
|
||
("double_dollar_python", re.compile(r"\$\$[^$]*`\{python\}"), "Inline Python in display math", "error"),
|
||
# Currency: unescaped $ before number can be parsed as math. Use \$ for currency (see book-prose.md).
|
||
# Match: $1,000 (comma), $4.00 (decimal), $50 million/billion/etc.
|
||
# Exclude: $1.5 \times (math), $0.5$ (inline math), $4.6 / (division).
|
||
("unescaped_currency", re.compile(
|
||
r"(?<!\\)\$[0-9]{1,3}(?:,[0-9]{3})+(?=\s(?!\s*\\times)|,[0-9]|\)|$)" # $1,000, exclude $25,000 \times
|
||
r"|(?<!\\)\$[0-9]+\.[0-9]+(?=\s(?!\s*\\times)(?!\s*/)(?!\s*-)(?!\s*\+)(?!\s*\\ll)|,[0-9]|\)|$|/)(?!\\$)" # $4.00, exclude math
|
||
r"|(?<!\\)\$[0-9]+(?=\s+(?:million|billion|thousand|M|B|K|per|each|/))" # $50 million
|
||
), "Unescaped dollar before number — use \\$ for currency", "warning"),
|
||
]
|
||
|
||
grid_sep_pat = re.compile(r"^\+[-:=+]+\+$")
|
||
math_span_pat = re.compile(r"(?<!\\)\$(?!\$)(?!`)(.+?)(?<!\\)\$")
|
||
|
||
# Lowercase 'x' used as multiplication in prose (should be $\times$).
|
||
# Matches: `...`x word, NUMx word — but NOT hex (0x61), code, fig-alt, or \index.
|
||
# The pattern requires a lowercase letter after x+space, which naturally
|
||
# excludes hardware counts like "8x A100" (uppercase after x).
|
||
lowercase_x_mult_pat = re.compile(
|
||
r"""`x\s+[a-z]""" # `...`x word (after inline python)
|
||
r"""|"""
|
||
r"""(?<![A-Z])\dx\s+[a-z]""" # Nx word — but not after uppercase (excludes MI300X, H100x, etc.)
|
||
)
|
||
# Hex literal pattern to exclude matches like 0x61, 0xff
|
||
hex_literal_pat = re.compile(r"0x[0-9a-fA-F]")
|
||
# Cross-reference ID pattern: @tbl-foo300x, @fig-bar2x, @sec-baz1x — these are labels not multiplication
|
||
xref_id_pat = re.compile(r"@(?:tbl|fig|sec|eq|lst)-[a-z0-9_-]+x\b", re.IGNORECASE)
|
||
# Quarto attribute blocks can contain IDs such as
|
||
# {#tbl-assumptions-mi300x tbl-colwidths="[...]"}; those are
|
||
# identifiers, not prose multiplication.
|
||
attr_block_pat = re.compile(r"\{[^{}]*(?:#[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+)[^{}]*\}")
|
||
# fig-alt lines to skip
|
||
fig_alt_pat = re.compile(r'fig-alt\s*=\s*"')
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_grid = False
|
||
in_code = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
|
||
# Code block tracking
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
# Grid table tracking
|
||
if grid_sep_pat.match(stripped):
|
||
in_grid = True
|
||
elif in_grid and not stripped.startswith("|") and not grid_sep_pat.match(stripped) and stripped:
|
||
in_grid = False
|
||
|
||
if in_grid and "`{python}" in line:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="grid_table_python",
|
||
message="Grid table with inline Python — convert to pipe table",
|
||
severity="error",
|
||
context=stripped[:120],
|
||
)
|
||
)
|
||
|
||
# Python inside $...$ or \(...\) math
|
||
for math_span in _inline_python_math_spans(line):
|
||
inner = math_span[2:-2] if math_span.startswith(r"\(") else math_span[1:-1]
|
||
inner_clean = re.sub(r"\^\{[^}]*`\{python\}[^`]*`[^}]*\}", "", inner)
|
||
if "{python}" in inner_clean:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="python_in_dollar_math",
|
||
message="Inline Python inside $...$ math block",
|
||
severity="error",
|
||
context=math_span[:120],
|
||
)
|
||
)
|
||
|
||
# Lowercase 'x' used as multiplication in prose
|
||
# Skip fig-alt lines and index entries
|
||
if not fig_alt_pat.search(line) and not stripped.startswith("\\index"):
|
||
# Strip cross-reference IDs before checking (e.g. @tbl-mi300x, @fig-foo2x)
|
||
line_no_attrs = attr_block_pat.sub("", line)
|
||
line_no_xrefs = xref_id_pat.sub("", line_no_attrs)
|
||
for rm in lowercase_x_mult_pat.finditer(line_no_xrefs):
|
||
# Exclude hex literals like 0x61, 0xff
|
||
ctx_start = max(0, rm.start() - 1)
|
||
if hex_literal_pat.match(line_no_xrefs[ctx_start : rm.end()]):
|
||
continue
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="lowercase_x_multiplication",
|
||
message="Lowercase 'x' used as multiplication — use $\\times$ instead",
|
||
severity="warning",
|
||
context=rm.group(0)[:120],
|
||
)
|
||
)
|
||
|
||
# Standard regex checks
|
||
# For currency check, strip inline math spans first to avoid
|
||
# false positives like $312 \times 10^{12} being flagged as currency.
|
||
line_no_math = math_span_pat.sub("MATHSPAN", line)
|
||
for code, pattern, message, severity in regex_checks:
|
||
check_line = line_no_math if code == "unescaped_currency" else line
|
||
for rm in pattern.finditer(check_line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity=severity,
|
||
context=rm.group(0)[:120],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="rendering",
|
||
description="Check for problematic rendering patterns",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_markdown_list_spacing(self, root: Path) -> ValidationRunResult:
|
||
"""Flag bold lead-in paragraphs that need a blank line before a list."""
|
||
start = time.time()
|
||
from cli.checks import markdown_list_spacing
|
||
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
for issue in markdown_list_spacing.check_file(file):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=issue.line_number,
|
||
code="bold_leadin_list_spacing",
|
||
message=(
|
||
"Bold lead-in paragraph is immediately followed by a list item; "
|
||
"Pandoc may render the list marker inline."
|
||
),
|
||
severity="error",
|
||
context=issue.context[:160],
|
||
suggestion=issue.suggestion,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="markdown-list-spacing",
|
||
description="Check bold lead-in paragraphs before Markdown lists",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_python_echo(self, root: Path) -> ValidationRunResult:
|
||
"""Ensure every ```{python} block has #| echo: false (code must not appear in output)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
block_start_re = re.compile(r"^```\{python\}")
|
||
block_end_re = re.compile(r"^```\s*$")
|
||
# Quarto chunk option: #| echo: false (with optional whitespace)
|
||
echo_false_re = re.compile(r"#\|\s*echo\s*:\s*false", re.IGNORECASE)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
if not block_start_re.match(line):
|
||
i += 1
|
||
continue
|
||
start_line = i + 1
|
||
found_echo_false = False
|
||
j = i + 1
|
||
# Scan option lines: #| key: value, or blank, until we hit code or closing ```
|
||
while j < len(lines):
|
||
next_line = lines[j]
|
||
if block_end_re.match(next_line):
|
||
break
|
||
stripped = next_line.strip()
|
||
if echo_false_re.search(stripped):
|
||
found_echo_false = True
|
||
break
|
||
# Option line or blank — keep scanning
|
||
if stripped.startswith("#|") or not stripped:
|
||
j += 1
|
||
continue
|
||
# Non-option line (actual code or comment) — options are done
|
||
break
|
||
if not found_echo_false:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=start_line,
|
||
code="python_missing_echo_false",
|
||
message="Python block must include #| echo: false — code must not appear in rendered output",
|
||
severity="error",
|
||
context="Add #| echo: false as first line after ```{python}",
|
||
)
|
||
)
|
||
# Advance past this block to the line after closing ```
|
||
k = j
|
||
while k < len(lines) and not block_end_re.match(lines[k]):
|
||
k += 1
|
||
i = k + 1
|
||
|
||
return ValidationRunResult(
|
||
name="python-echo",
|
||
description="Check Python blocks have echo: false",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Dropcaps (ported from validate_dropcap_compat.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_dropcaps(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
chapter_hdr = re.compile(r"^#\s+[^#].*\{#sec-")
|
||
numbered_h2 = re.compile(r"^##\s+[^#]")
|
||
unnumbered_h2 = re.compile(r"^##\s+.*\{.*\.unnumbered.*\}")
|
||
starts_xref = re.compile(r"^\s*@(sec|fig|tbl|lst|eq)-")
|
||
starts_link = re.compile(r"^\s*\[")
|
||
starts_inline = re.compile(r"^\s*`")
|
||
yaml_fence = re.compile(r"^---\s*$")
|
||
code_fence = re.compile(r"^```")
|
||
div_fence = re.compile(r"^:::")
|
||
blank = re.compile(r"^\s*$")
|
||
html_comment = re.compile(r"^\s*<!--")
|
||
raw_latex = re.compile(r"^\s*\\")
|
||
list_item = re.compile(r"^\s*[-*+]|\s*\d+\.")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_fm = False
|
||
in_code = False
|
||
in_div = 0
|
||
found_chapter = False
|
||
found_h2 = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
if idx == 1 and yaml_fence.match(line):
|
||
in_fm = True
|
||
continue
|
||
if in_fm:
|
||
if yaml_fence.match(line):
|
||
in_fm = False
|
||
continue
|
||
if code_fence.match(line):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if div_fence.match(line):
|
||
stripped = line.strip()
|
||
if stripped == ":::":
|
||
in_div = max(0, in_div - 1)
|
||
elif stripped.startswith(":::"):
|
||
in_div += 1
|
||
continue
|
||
if in_div > 0:
|
||
continue
|
||
|
||
if chapter_hdr.match(line):
|
||
found_chapter = True
|
||
found_h2 = False
|
||
continue
|
||
if not found_chapter:
|
||
continue
|
||
if numbered_h2.match(line) and not unnumbered_h2.match(line):
|
||
if not found_h2:
|
||
found_h2 = True
|
||
continue
|
||
if not found_h2:
|
||
continue
|
||
if blank.match(line) or html_comment.match(line) or raw_latex.match(line) or list_item.match(line):
|
||
continue
|
||
if line.strip().startswith("#"):
|
||
continue
|
||
|
||
# First paragraph line
|
||
if starts_xref.match(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="dropcap_crossref",
|
||
message="Drop cap paragraph starts with cross-reference",
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
elif starts_link.match(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="dropcap_link",
|
||
message="Drop cap paragraph starts with markdown link",
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
elif starts_inline.match(line):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="dropcap_inline",
|
||
message="Drop cap paragraph starts with inline code",
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
# Only check first paragraph per file
|
||
break
|
||
|
||
return ValidationRunResult(
|
||
name="dropcaps",
|
||
description="Validate drop cap compatibility",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Parts (ported from validate_part_keys.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_parts(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
part_key_pat = re.compile(r"\\part\{key:([^}]+)\}")
|
||
|
||
# Load summaries
|
||
summaries_keys: Set[str] = set()
|
||
possible_paths = [
|
||
self.config_manager.book_dir / "contents" / "parts" / "summaries.yml",
|
||
self.config_manager.book_dir / "contents" / "vol1" / "parts" / "summaries.yml",
|
||
self.config_manager.book_dir / "contents" / "vol2" / "parts" / "summaries.yml",
|
||
]
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
return ValidationRunResult(
|
||
name="parts",
|
||
description="Validate part keys (skipped — pyyaml not installed)",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
for yml_path in possible_paths:
|
||
if yml_path.exists():
|
||
try:
|
||
data = yaml.safe_load(yml_path.read_text(encoding="utf-8"))
|
||
for part in data.get("parts", []):
|
||
if "key" in part:
|
||
summaries_keys.add(part["key"].lower().replace("_", "").replace("-", ""))
|
||
except Exception:
|
||
pass
|
||
|
||
if not summaries_keys:
|
||
# No summaries found — skip gracefully
|
||
return ValidationRunResult(
|
||
name="parts",
|
||
description="Validate part keys (skipped — no summaries.yml found)",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
for m in part_key_pat.finditer(content):
|
||
key = m.group(1)
|
||
norm = key.lower().replace("_", "").replace("-", "")
|
||
if norm not in summaries_keys:
|
||
line_no = content[: m.start()].count("\n") + 1
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="invalid_part_key",
|
||
message=f"Part key '{key}' not found in summaries.yml",
|
||
severity="error",
|
||
context=m.group(0),
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="parts",
|
||
description="Validate \\part{{key:...}} against summaries.yml",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Heading levels (detect skipped heading levels)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_heading_levels(self, root: Path) -> ValidationRunResult:
|
||
"""Detect heading level skips outside of div contexts.
|
||
|
||
Headings inside Quarto divs (callouts, panels, columns, etc.) are
|
||
in a separate nesting context and are excluded from the hierarchy
|
||
check. Only headings at the top-level (div depth 0) are compared
|
||
against each other.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
heading_pat = re.compile(r"^(#{1,6})\s+")
|
||
code_fence = re.compile(r"^```")
|
||
yaml_fence = re.compile(r"^---\s*$")
|
||
# Div open: ::: or :::: (with optional class/id)
|
||
div_open_pat = re.compile(r"^(:{3,})\s*\{")
|
||
# Div close: bare ::: or :::: on its own line
|
||
div_close_pat = re.compile(r"^(:{3,})\s*$")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_yaml = False
|
||
prev_level = 0
|
||
div_depth = 0
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
|
||
# Track YAML front matter
|
||
if idx == 1 and yaml_fence.match(line):
|
||
in_yaml = True
|
||
continue
|
||
if in_yaml:
|
||
if yaml_fence.match(line):
|
||
in_yaml = False
|
||
continue
|
||
|
||
# Track code blocks
|
||
if code_fence.match(stripped):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
# Track div nesting depth
|
||
if div_open_pat.match(stripped):
|
||
div_depth += 1
|
||
continue
|
||
if div_close_pat.match(stripped) and div_depth > 0:
|
||
div_depth -= 1
|
||
continue
|
||
|
||
# Skip headings inside divs — they're in a nested context
|
||
if div_depth > 0:
|
||
continue
|
||
|
||
m = heading_pat.match(line)
|
||
if not m:
|
||
continue
|
||
|
||
level = len(m.group(1))
|
||
|
||
# Only flag if we skip a level going deeper
|
||
# (e.g., ## -> #### skips ###)
|
||
if prev_level > 0 and level > prev_level + 1:
|
||
skipped = ", ".join(
|
||
f"H{i}" for i in range(prev_level + 1, level)
|
||
)
|
||
heading_text = line.lstrip("#").strip()
|
||
# Truncate at { to remove attributes
|
||
if "{" in heading_text:
|
||
heading_text = heading_text[: heading_text.index("{")].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="heading_level_skip",
|
||
message=f"Heading jumps from H{prev_level} to H{level} (skips {skipped})",
|
||
severity="warning",
|
||
context=heading_text[:80],
|
||
)
|
||
)
|
||
|
||
prev_level = level
|
||
|
||
return ValidationRunResult(
|
||
name="heading-levels",
|
||
description="Detect skipped heading levels (e.g., ## to ####)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# H2 landing prose (detect ## followed immediately by ###)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_h2_landings(self, root: Path) -> ValidationRunResult:
|
||
"""Require H2 sections to orient readers before H3 subsections.
|
||
|
||
This catches the common source-shape problem:
|
||
|
||
## Section
|
||
### First subsection
|
||
|
||
Index markers and blank lines do not count as prose. A line that starts
|
||
with one or more ``\\index{...}`` markers and then continues with text
|
||
does count as prose, matching common chapter style.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
h2_pat = re.compile(r"^##\s+")
|
||
h3_pat = re.compile(r"^###\s+")
|
||
code_fence = re.compile(r"^```")
|
||
yaml_fence = re.compile(r"^---\s*$")
|
||
div_open_pat = re.compile(r"^(:{3,})\s*\{")
|
||
div_close_pat = re.compile(r"^(:{3,})\s*$")
|
||
index_prefix_pat = re.compile(r"^(?:\\index\{[^}]*\}\s*)+")
|
||
|
||
def visible_content(stripped: str) -> str:
|
||
"""Return reader-visible content after ignorable leading markers."""
|
||
if not stripped or stripped.startswith("<!--"):
|
||
return ""
|
||
without_index = index_prefix_pat.sub("", stripped).strip()
|
||
return without_index
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_yaml = False
|
||
div_depth = 0
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
|
||
if idx == 1 and yaml_fence.match(line):
|
||
in_yaml = True
|
||
continue
|
||
if in_yaml:
|
||
if yaml_fence.match(line):
|
||
in_yaml = False
|
||
continue
|
||
|
||
if code_fence.match(stripped):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
if div_open_pat.match(stripped):
|
||
div_depth += 1
|
||
continue
|
||
if div_close_pat.match(stripped) and div_depth > 0:
|
||
div_depth -= 1
|
||
continue
|
||
if div_depth > 0:
|
||
continue
|
||
|
||
if not h2_pat.match(line) or line.startswith("###"):
|
||
continue
|
||
|
||
first_line = None
|
||
first_text = ""
|
||
look = idx
|
||
while look < len(lines):
|
||
candidate = visible_content(lines[look].strip())
|
||
look += 1
|
||
if not candidate:
|
||
continue
|
||
first_line = look
|
||
first_text = candidate
|
||
break
|
||
|
||
if first_line is None or not h3_pat.match(first_text):
|
||
continue
|
||
|
||
heading_text = line.lstrip("#").strip()
|
||
if "{" in heading_text:
|
||
heading_text = heading_text[: heading_text.index("{")].strip()
|
||
subsection_text = first_text.lstrip("#").strip()
|
||
if "{" in subsection_text:
|
||
subsection_text = subsection_text[: subsection_text.index("{")].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="h2_missing_landing",
|
||
message=(
|
||
"H2 section drops directly into an H3 subsection "
|
||
"without intervening prose"
|
||
),
|
||
severity="error",
|
||
context=(
|
||
f"{heading_text[:60]} → {subsection_text[:60]}"
|
||
),
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="h2-landings",
|
||
description="Detect H2 sections that drop directly into H3 subsections",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Duplicate consecutive words (detect "the the", "is is", etc.)
|
||
# ------------------------------------------------------------------
|
||
|
||
_DUPE_WORD_PAT = re.compile(
|
||
r"\b(\w{2,})\s+\1\b",
|
||
re.IGNORECASE,
|
||
)
|
||
# Known false positives: intentional repetitions
|
||
_DUPE_WORD_ALLOW = frozenset({
|
||
"had", "that", "do", "bye", "bla", "cha", "go",
|
||
"log", # "log log n" is valid math
|
||
})
|
||
|
||
def _run_duplicate_words(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
code_fence = re.compile(r"^```")
|
||
yaml_fence = re.compile(r"^---\s*$")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_yaml = False
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
# Track YAML front matter
|
||
if idx == 1 and yaml_fence.match(line):
|
||
in_yaml = True
|
||
continue
|
||
if in_yaml:
|
||
if yaml_fence.match(line):
|
||
in_yaml = False
|
||
continue
|
||
|
||
# Skip code blocks
|
||
if code_fence.match(line.strip()):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
# Skip HTML comments, raw LaTeX, div fences, HTML tags
|
||
stripped = line.strip()
|
||
if stripped.startswith("<!--") or stripped.startswith("\\") or stripped.startswith(":::"):
|
||
continue
|
||
if stripped.startswith("<") and not stripped.startswith("<http"):
|
||
continue
|
||
# Skip lines that are mostly attributes/metadata
|
||
if stripped.startswith("#|") or stripped.startswith("%%|"):
|
||
continue
|
||
|
||
for m in self._DUPE_WORD_PAT.finditer(line):
|
||
word = m.group(1).lower()
|
||
if word in self._DUPE_WORD_ALLOW:
|
||
continue
|
||
# Skip if inside a LaTeX command or attribute
|
||
before = line[: m.start()]
|
||
if before.rstrip().endswith("\\") or "{" in line[m.start() : m.end() + 5]:
|
||
continue
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="duplicate_word",
|
||
message=f'Duplicate word: "{m.group(1)} {m.group(1)}"',
|
||
severity="warning",
|
||
context=line.strip()[:120],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="duplicate-words",
|
||
description="Detect duplicate consecutive words (typos)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Images (ported from validate_image_references.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_images(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
img_pat = re.compile(r"!\[[^\]]{0,1000}\]\(([^)]+)\)(?:\{[^}]*\})?")
|
||
valid_exts = {".png", ".jpg", ".jpeg", ".gif", ".svg"}
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
for m in img_pat.finditer(content):
|
||
img_path = m.group(1).strip()
|
||
if img_path.startswith(("http://", "https://")):
|
||
continue
|
||
ext = Path(img_path).suffix.lower()
|
||
if ext not in valid_exts:
|
||
continue
|
||
|
||
resolved = (file.parent / img_path).resolve()
|
||
line_no = content[: m.start()].count("\n") + 1
|
||
|
||
if not resolved.exists():
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="missing_image",
|
||
message=f"Image not found: {img_path}",
|
||
severity="error",
|
||
context=img_path,
|
||
)
|
||
)
|
||
else:
|
||
# Case check
|
||
try:
|
||
actual = self._realcase(str(resolved))
|
||
if str(resolved) != actual:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=line_no,
|
||
code="image_case_mismatch",
|
||
message=f"Image case mismatch: ref='{Path(str(resolved)).name}' disk='{Path(actual).name}'",
|
||
severity="error",
|
||
context=img_path,
|
||
)
|
||
)
|
||
except (FileNotFoundError, OSError):
|
||
pass
|
||
|
||
return ValidationRunResult(
|
||
name="images",
|
||
description="Validate image references exist on disk",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
@staticmethod
|
||
def _realcase(path: str) -> str:
|
||
"""Resolve actual case of a path on disk."""
|
||
dirname, basename = os.path.split(path)
|
||
if dirname == path:
|
||
return dirname
|
||
dirname = ValidateCommand._realcase(dirname)
|
||
norm_base = os.path.normcase(basename)
|
||
try:
|
||
for child in os.listdir(dirname):
|
||
if os.path.normcase(child) == norm_base:
|
||
return os.path.join(dirname, child)
|
||
except OSError:
|
||
pass
|
||
return path
|
||
|
||
# ------------------------------------------------------------------
|
||
# Self-referential sections (ported from check_self_referential_sections.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_self_referential(self, root: Path) -> ValidationRunResult:
|
||
"""Detect sections that reference themselves, their parent, or child."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
heading_pat = re.compile(r"^(#{1,6})\s+(.+?)(?:\s+\{#([^}]+)\})?$")
|
||
ref_pat = re.compile(r"@(sec-[a-zA-Z0-9-]+)")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
|
||
# Build heading hierarchy
|
||
headings: List[Dict] = []
|
||
parent_stack: Dict[int, Dict] = {}
|
||
|
||
for idx, line in enumerate(lines, 1):
|
||
m = heading_pat.match(line)
|
||
if not m:
|
||
continue
|
||
level = len(m.group(1))
|
||
title = m.group(2).strip()
|
||
sec_id = m.group(3)
|
||
parent_id = None
|
||
for plevel in range(level - 1, 0, -1):
|
||
if plevel in parent_stack:
|
||
parent_id = parent_stack[plevel].get("id")
|
||
break
|
||
hd = {"level": level, "title": title, "id": sec_id,
|
||
"line": idx, "parent_id": parent_id}
|
||
headings.append(hd)
|
||
parent_stack[level] = hd
|
||
parent_stack = {k: v for k, v in parent_stack.items() if k <= level}
|
||
|
||
# Build section map and children map
|
||
section_map: Dict[str, Dict] = {}
|
||
children_map: Dict[str, List[str]] = defaultdict(list)
|
||
for hd in headings:
|
||
if hd["id"]:
|
||
section_map[hd["id"]] = hd
|
||
if hd["parent_id"]:
|
||
children_map[hd["parent_id"]].append(hd["id"])
|
||
|
||
# Check references
|
||
for idx, line in enumerate(lines, 1):
|
||
for m in ref_pat.finditer(line):
|
||
ref_id = m.group(1)
|
||
# Find which section this line belongs to
|
||
current = None
|
||
for hd in headings:
|
||
if hd["line"] <= idx:
|
||
current = hd
|
||
else:
|
||
break
|
||
if not current or not current["id"]:
|
||
continue
|
||
|
||
cur_id = current["id"]
|
||
if ref_id == cur_id:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=idx,
|
||
code="self_reference",
|
||
message=f"Section '{current['title']}' references itself (@{ref_id})",
|
||
severity="warning",
|
||
context=line.strip()[:120],
|
||
))
|
||
elif current["parent_id"] == ref_id:
|
||
parent = section_map.get(ref_id)
|
||
ptitle = parent["title"] if parent else ref_id
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=idx,
|
||
code="parent_reference",
|
||
message=f"Section '{current['title']}' references its parent '{ptitle}' (@{ref_id})",
|
||
severity="warning",
|
||
context=line.strip()[:120],
|
||
))
|
||
elif ref_id in children_map.get(cur_id, []):
|
||
child = section_map.get(ref_id)
|
||
ctitle = child["title"] if child else ref_id
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=idx,
|
||
code="child_reference",
|
||
message=f"Section '{current['title']}' references its child '{ctitle}' (@{ref_id})",
|
||
severity="warning",
|
||
context=line.strip()[:120],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="self-referential",
|
||
description="Detect self-referential section references",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Figure label underscores (ported from check_fig_references.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_fig_label_underscores(self, root: Path) -> ValidationRunResult:
|
||
"""Find figure references containing underscores (invalid in Quarto)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
fig_ref_pat = re.compile(r"(?:\{#|@)fig-([^}\s]+)")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
if line.strip().startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in fig_ref_pat.finditer(line):
|
||
label_suffix = m.group(1)
|
||
if "_" in label_suffix:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=idx,
|
||
code="fig_label_underscore",
|
||
message=f"Figure label contains underscore: fig-{label_suffix} (use hyphens)",
|
||
severity="error",
|
||
context=line.strip()[:120],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="fig-labels",
|
||
description="Detect underscores in figure labels",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# ASCII check (ported from check_ascii.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_ascii(self, root: Path) -> ValidationRunResult:
|
||
"""Find non-ASCII Unicode characters in QMD files."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
non_ascii_pat = re.compile(r"[^\x00-\x7F]")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
# Skip LaTeX raw blocks and HTML comments
|
||
if stripped.startswith("\\") or stripped.startswith("<!--"):
|
||
continue
|
||
for m in non_ascii_pat.finditer(line):
|
||
char = m.group(0)
|
||
col = m.start()
|
||
context = line[max(0, col - 10):min(len(line), col + 10)]
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=idx,
|
||
code="non_ascii",
|
||
message=f"Non-ASCII character '{char}' (U+{ord(char):04X})",
|
||
severity="warning",
|
||
context=context.strip(),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="ascii",
|
||
description="Detect non-ASCII characters in QMD files",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Percent spacing (no space between number/str and %)
|
||
# ------------------------------------------------------------------
|
||
|
||
PERCENT_SPACING_PATTERN = re.compile(r"`[^`]*`\s+%")
|
||
|
||
# Spelled-out number words immediately before "percent" — digits required per
|
||
# numbers-and-math-in-prose.md §Percent ("percent" acts as a unit; always digits).
|
||
# Covers one–one hundred, including common compounds (twenty-five, etc.).
|
||
_PERCENT_WORD_NUMS = (
|
||
r"\b(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
|
||
r"fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|"
|
||
r"twenty-one|twenty-two|twenty-three|twenty-four|twenty-five|"
|
||
r"twenty-six|twenty-seven|twenty-eight|twenty-nine|"
|
||
r"thirty|forty|fifty|sixty|seventy|eighty|ninety|"
|
||
r"thirty-\w+|forty-\w+|fifty-\w+|sixty-\w+|seventy-\w+|eighty-\w+|ninety-\w+|"
|
||
r"one hundred)\s+percent\b"
|
||
)
|
||
PERCENT_WORD_BEFORE_PATTERN = re.compile(_PERCENT_WORD_NUMS, re.IGNORECASE)
|
||
|
||
def _run_percent_spacing(self, root: Path) -> ValidationRunResult:
|
||
"""Flag space between inline expression and % (e.g. `{python} x` % → use `{python} x`%)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in self.PERCENT_SPACING_PATTERN.finditer(line):
|
||
context = line[max(0, m.start() - 5) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="percent_spacing",
|
||
message="Remove space between value and % (use e.g. `{python} x`% not `{python} x` %)",
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="percent-spacing",
|
||
description="No space between inline value and % in QMD prose",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_percent_word_before(self, root: Path) -> ValidationRunResult:
|
||
"""Flag spelled-out number words before 'percent' (e.g. 'five percent' → '5 percent').
|
||
|
||
MIT Press rule: 'percent' acts as a unit; always use digits with units regardless
|
||
of value (numbers-and-math-in-prose.md §Percent). 'five percent' is wrong;
|
||
'5 percent' is correct.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in self.PERCENT_WORD_BEFORE_PATTERN.finditer(line):
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 20)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="percent_word_before",
|
||
message=f"Use digit before 'percent', not spelled-out word: '{m.group().strip()}' → replace with digit form",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="percent-word-before",
|
||
description="Digits required before 'percent' (percent acts as a unit)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_percent_in_tables(self, root: Path) -> ValidationRunResult:
|
||
"""Flag the spelled-out word 'percent' used as a unit inside pipe tables.
|
||
|
||
House style inverts between prose and tables. In prose and captions,
|
||
'percent' is spelled out (the % symbol is banned — see
|
||
`_run_mitpress_percent_in_captions`). In TABLES, cells are dense
|
||
tabular data and the conventional form is the % symbol: '86.4%', not
|
||
'86.4 percent'.
|
||
|
||
Detection lives in ``cli.checks.percent_tables`` — the same module the
|
||
``format percent-tables`` auto-fixer imports — so the check and the
|
||
fixer cannot drift. Header labels with no leading number and
|
||
'percentage points / pp' are never flagged; captions (prose) keep the
|
||
spelled-out word.
|
||
|
||
Auto-fixable: ``./book/binder format percent-tables``.
|
||
"""
|
||
from cli.checks.percent_tables import find_in_text
|
||
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
for hit in find_in_text(self._read_text(file)):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=hit.line,
|
||
code="percent_in_table",
|
||
message=(
|
||
"Use the % symbol, not the word 'percent', inside "
|
||
f"tables: '{hit.match}' → '{hit.replacement}'. Run "
|
||
"'./book/binder format percent-tables' to auto-fix."
|
||
),
|
||
severity="error",
|
||
context=hit.context,
|
||
suggestion=f"{hit.match} → {hit.replacement}",
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="percent-in-tables",
|
||
description="Tables use the % symbol, not the word 'percent' (prose keeps the word)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_percent_in_prose(self, root: Path) -> ValidationRunResult:
|
||
"""Flag the % symbol where body prose should spell out 'percent'.
|
||
|
||
Symmetric inverse of ``_run_mitpress_percent_in_tables``. Per MIT Press
|
||
``AU_QUERY_RESPONSES.md`` Category H (aligned with Chicago §9.18),
|
||
running prose spells out 'percent'; the % symbol is reserved for
|
||
tables, equations, code, and labels inside figures.
|
||
|
||
Detection lives in ``cli.checks.percent_prose`` and exempts every
|
||
legitimate non-prose context: code, math, attributes, link/image
|
||
targets, ``<style>`` CSS, HTML comments, caption / alt-text lines,
|
||
table rows, and quoted material (a `%` inside double quotes is a
|
||
verbatim quotation). Ranges need prose judgment ('30 to 50 percent'),
|
||
so there is no auto-fixer — fix flagged lines by hand.
|
||
"""
|
||
from cli.checks.percent_prose import find_prose_percent
|
||
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
for hit in find_prose_percent(self._read_text(file)):
|
||
if hit.is_range:
|
||
msg = (
|
||
f"Spell out 'percent' in body prose: '{hit.match}' is "
|
||
"part of a range — rewrite the whole range as "
|
||
"'X to Y percent' (e.g. '30 to 50 percent')."
|
||
)
|
||
suggestion = f"{hit.match} → (range) X to Y percent"
|
||
else:
|
||
msg = (
|
||
"Spell out 'percent' in body prose, not the % symbol: "
|
||
f"'{hit.match}' → '{hit.replacement}'. (% stays in "
|
||
"tables, equations, code, and figures.)"
|
||
)
|
||
suggestion = f"{hit.match} → {hit.replacement}"
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=hit.line,
|
||
code="percent_in_prose",
|
||
message=msg,
|
||
severity="error",
|
||
context=hit.context,
|
||
suggestion=suggestion,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="percent-in-prose",
|
||
description="Body prose spells out 'percent'; % only in tables/equations/code/figures",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Unit spacing (style: "100 ms", "4 GB" — never "100ms" or "4GB")
|
||
# ------------------------------------------------------------------
|
||
|
||
# Number (optional decimal) immediately followed by unit with no space (invalid per book-prose.md).
|
||
UNIT_SPACING_PATTERN = re.compile(
|
||
r"\d+(?:\.\d+)?"
|
||
r"(?:ms|GB|TB|MB|KB|Gbps|Mbps|Tbps|TFLOPS|GFLOPS|W)\b"
|
||
)
|
||
|
||
def _run_unit_spacing(self, root: Path) -> ValidationRunResult:
|
||
"""Flag number+unit with no space (e.g. 100ms → 100 ms, 4GB → 4 GB)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in self.UNIT_SPACING_PATTERN.finditer(line):
|
||
context = line[max(0, m.start() - 2) : min(len(line), m.end() + 5)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="unit_spacing",
|
||
message="Insert space between number and unit (e.g. 100 ms not 100ms, 4 GB not 4GB)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="unit-spacing",
|
||
description="Require space between number and unit (book-prose.md)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Binary units (style: "GB" and "TB", not "GiB" or "TiB" in prose)
|
||
# ------------------------------------------------------------------
|
||
|
||
BINARY_UNITS_PATTERN = re.compile(r"\b(GiB|TiB|MiB|KiB)\b")
|
||
|
||
def _run_binary_units(self, root: Path) -> ValidationRunResult:
|
||
"""Flag GiB/TiB in prose — use GB/TB per book-prose.md."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in self.BINARY_UNITS_PATTERN.finditer(line):
|
||
context = line[max(0, m.start() - 3) : min(len(line), m.end() + 3)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="binary_units",
|
||
message="Use GB/TB not GiB/TiB in prose (book-prose.md)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="binary-units",
|
||
description="No GiB/TiB in prose — use GB/TB",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_currency_style(self, root: Path) -> ValidationRunResult:
|
||
"""Flag literal USD outside the single notation definition."""
|
||
from cli.checks.currency_style import audit, iter_target_files
|
||
|
||
start = time.time()
|
||
target_files = iter_target_files([root])
|
||
violations = audit([root])
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for violation in violations:
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.book_dir))
|
||
except ValueError:
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="currency",
|
||
description="Use $ in content; define USD once in notation",
|
||
files_checked=len(target_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_rendered_currency_style(self, root: Path) -> ValidationRunResult:
|
||
"""Scan cached rendered HTML for visible currency/math artifacts."""
|
||
from cli.checks.currency_style import audit_rendered_html, iter_html_files
|
||
|
||
start = time.time()
|
||
repo_root = self.config_manager.root_dir
|
||
html_root = root
|
||
if not root.is_file() and not list(iter_html_files([root])):
|
||
html_root = repo_root / "book" / "quarto" / "_build" / "html-audit"
|
||
|
||
issues: List[ValidationIssue] = []
|
||
if not html_root.exists():
|
||
return ValidationRunResult(
|
||
name="rendered-currency",
|
||
description=(
|
||
"Skip rendered currency audit; no cached HTML audit "
|
||
f"directory at {html_root.relative_to(repo_root)}"
|
||
),
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
html_files = iter_html_files([html_root])
|
||
for violation in audit_rendered_html([html_root]):
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(repo_root))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="rendered-currency",
|
||
description="Scan rendered HTML for USD/$$/raw-LaTeX currency artifacts",
|
||
files_checked=len(html_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Contractions (forbidden in body prose per book-prose.md)
|
||
# ------------------------------------------------------------------
|
||
|
||
# Contraction → full form. Body prose forbids contractions as a deliberate
|
||
# register choice (.claude/rules/prose-craft.md), overriding the copy
|
||
# editor's permissive "OK unless excessive." The lone exception is quoted
|
||
# speech / direct dialogue, so we mask double-quoted spans before matching.
|
||
CONTRACTION_EXPANSIONS = {
|
||
"can't": "cannot", "don't": "do not", "it's": "it is", "we'll": "we will",
|
||
"won't": "will not", "hasn't": "has not", "haven't": "have not",
|
||
"isn't": "is not", "aren't": "are not", "wasn't": "was not",
|
||
"weren't": "were not", "doesn't": "does not", "didn't": "did not",
|
||
"wouldn't": "would not", "couldn't": "could not", "shouldn't": "should not",
|
||
"that's": "that is", "there's": "there is", "here's": "here is",
|
||
"what's": "what is", "you're": "you are", "we're": "we are",
|
||
"they're": "they are", "they've": "they have", "let's": "let us",
|
||
"who's": "who is",
|
||
}
|
||
CONTRACTIONS_PATTERN = re.compile(
|
||
r"\b(" + "|".join(re.escape(k) for k in CONTRACTION_EXPANSIONS) + r")\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
def _run_mitpress_contractions(self, root: Path) -> ValidationRunResult:
|
||
"""Flag contractions in prose — use full forms (cannot, do not, etc.)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if stripped.startswith("|") or stripped.startswith("<!--"):
|
||
continue
|
||
# §10.11 exception: quoted speech / dialogue. Blank inline code
|
||
# and double-quoted spans so contractions there are not flagged.
|
||
masked = re.sub(r"`[^`]*`", "", line)
|
||
masked = re.sub(r'"[^"]*"', "", masked)
|
||
masked = re.sub(r"“[^”]*”", "", masked)
|
||
for m in self.CONTRACTIONS_PATTERN.finditer(masked):
|
||
found = m.group()
|
||
full = self.CONTRACTION_EXPANSIONS[found.lower()]
|
||
if found[0].isupper():
|
||
full = full[0].upper() + full[1:]
|
||
context = masked[max(0, m.start() - 2): min(len(masked), m.end() + 2)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="contractions",
|
||
message=(
|
||
f"Contractions forbidden in body prose (§10.11): "
|
||
f"'{found}' → '{full}'."
|
||
),
|
||
severity="warning",
|
||
context=context,
|
||
suggestion=f"{found} → {full}",
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="contractions",
|
||
description="No contractions in body prose (book-prose.md)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_spelling_dict(self, root: Path) -> ValidationRunResult:
|
||
"""Enforce the MIT Press canonical spelling dictionary (§10.7).
|
||
|
||
Flags the unambiguous spelling/hyphenation errors and proper-noun
|
||
miscapitalizations (trade-off, dataset, data center, Wi-Fi, GPUDirect,
|
||
TinyML, …). Detection lives in ``cli.checks.mitpress_terms`` — shared
|
||
with the ``mitpress-terms`` format auto-fixer — and masks code, math,
|
||
cross-references, footnotes, and attributes, and skips glossary files
|
||
(their keys follow the §10.14 lowercase convention). Context-sensitive
|
||
adj/predicate pairs (compute-bound, open-source, real-time) are out of
|
||
scope. Auto-fix: ``./book/binder format mitpress-terms``.
|
||
"""
|
||
from cli.checks.mitpress_terms import find_in_text, should_skip_file
|
||
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
for file in files:
|
||
if should_skip_file(file):
|
||
continue
|
||
for hit in find_in_text(self._read_text(file)):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=hit.line,
|
||
code="mitpress_spelling",
|
||
message=(
|
||
f"Canonical spelling (§10.7): '{hit.match}' → "
|
||
f"'{hit.replacement}'. Auto-fix with "
|
||
"'./book/binder format mitpress-terms'."
|
||
),
|
||
severity="error",
|
||
context=hit.context,
|
||
suggestion=f"{hit.match} → {hit.replacement}",
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="spelling-dict",
|
||
description="MIT Press canonical spellings §10.7 (trade-off, dataset, Wi-Fi, …)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Unblended prose (paragraph split with leading space after period)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_unblended_prose(self, root: Path) -> ValidationRunResult:
|
||
"""Flag line starting with single space after previous line ended with period."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for i in range(1, len(lines)):
|
||
if lines[i - 1].strip().startswith("```"):
|
||
in_code = not in_code
|
||
if in_code:
|
||
continue
|
||
prev = lines[i - 1].strip()
|
||
curr = lines[i]
|
||
if not prev.endswith("."):
|
||
continue
|
||
if not (len(curr) > 1 and curr[0] == " " and curr[1].isupper()):
|
||
continue
|
||
context = (curr[:60] + "…") if len(curr) > 60 else curr
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=i + 1,
|
||
code="unblended_prose",
|
||
message="Paragraph likely split: line starts with space after period — merge into one paragraph",
|
||
severity="warning",
|
||
context=context.strip(),
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="unblended-prose",
|
||
description="Detect wrongly split paragraphs (leading space after period)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Times spacing (space after $\\times$ before word/unit per book-prose.md)
|
||
# ------------------------------------------------------------------
|
||
|
||
# $\times$ followed immediately by letter or ( with no separating space.
|
||
TIMES_SPACING_PATTERN = re.compile(r"\$\\times\s*\$(?=[a-zA-Z\(])")
|
||
|
||
def _run_times_spacing(self, root: Path) -> ValidationRunResult:
|
||
"""Flag $\\times$ immediately followed by word/paren with no space."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for m in self.TIMES_SPACING_PATTERN.finditer(line):
|
||
context = line[max(0, m.start() - 2) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="times_spacing",
|
||
message="Add space after $\\times$ before word or unit (e.g. $\\times$ speedup)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="times-spacing",
|
||
description="Space after $\\times$ before word/unit (book-prose.md)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Times product spacing (space around $\times$ in product expressions)
|
||
# ------------------------------------------------------------------
|
||
|
||
# Catch tight $\times$ in product expressions involving inline code or
|
||
# numbers. Multiplier patterns like `val`$\times$ speedup (space after,
|
||
# followed by a word) are intentional and NOT flagged.
|
||
#
|
||
# Patterns flagged (all need spaces around $\times$):
|
||
# `val`$\times$`val` — inline × inline
|
||
# `val`$\times$3 — inline × number
|
||
# 3$\times$`val` — number × inline
|
||
# `val`$\times$$math$ — inline × math span
|
||
TIMES_PRODUCT_PATTERNS = [
|
||
# inline × inline: `..`$\times$`..`
|
||
re.compile(r"`\$\\times\$`"),
|
||
# inline × number: `..`$\times$<digit>
|
||
re.compile(r"`\$\\times\$\d"),
|
||
# number × inline: <digit>$\times$`..`
|
||
re.compile(r"\d\$\\times\$`"),
|
||
# inline × math: `..`$\times$$..$ (tight dollar after times)
|
||
re.compile(r"`\$\\times\$\$"),
|
||
]
|
||
|
||
def _run_times_product_spacing(self, root: Path) -> ValidationRunResult:
|
||
"""Flag tight $\\times$ in product expressions (should be spaced)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
for pat in self.TIMES_PRODUCT_PATTERNS:
|
||
for m in pat.finditer(line):
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="times_product_spacing",
|
||
message="Add space around $\\times$ in product (e.g. ` $\\times$ ` not `$\\times$`)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="times-product-spacing",
|
||
description="Space around $\\times$ in product expressions",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Math: LaTeX leaks inside attribute strings (titles + captions + alt text)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# WHY THIS EXISTS
|
||
#
|
||
# Quarto runs Pandoc's math parser only on document body prose. Attribute
|
||
# values — callout `title="..."`, `fig-cap`, `tbl-cap`, `lst-cap`,
|
||
# `fig-alt`, `tbl-alt` — are extracted as plain strings and reused by
|
||
# downstream consumers (HTML lightbox `<img title="...">` tooltip, EPUB
|
||
# bookmarks, screen readers, PDF outline). Any LaTeX inside them either
|
||
# renders as literal `\command` / `^{N}` / `$..$` or, in the lightbox
|
||
# case, leaks raw LaTeX into the hover tooltip. See the audit retrospective
|
||
# in the project prose style guide ("Anti-pattern: LaTeX inside attribute
|
||
# strings"). Fix by switching to Unicode (×, ³, α, β, ρ, ≤, ≥, →, ∞, …).
|
||
#
|
||
# FAILURE MODES CAUGHT
|
||
# 1. Callout `title="..."` containing $...$, \command, or ^{...}/_{N}
|
||
# → ERROR (never renders correctly, in any output format)
|
||
# 2. fig-cap/tbl-cap/etc. with bare `^{N}` or `_{N}` outside `$...$`
|
||
# → ERROR (LaTeX-only syntax; renders literally without delimiters)
|
||
# 3. fig-cap/tbl-cap with inline `$\command$` math fragments
|
||
# → WARNING (caption renders fine, but lightbox tooltip leaks raw
|
||
# LaTeX). Reserve $...$ for genuinely complex math; prefer Unicode
|
||
# for short fragments like ρ, ≤, ×.
|
||
|
||
# Backslash-LaTeX commands commonly seen leaking. Kept conservative —
|
||
# adding new commands here is safe (more catches), but any addition that
|
||
# introduces false positives in legitimate prose should be reviewed.
|
||
_ATTR_LATEX_COMMANDS = (
|
||
r"times|frac|approx|alpha|beta|gamma|delta|epsilon|zeta|eta|theta|"
|
||
r"iota|kappa|lambda|mu|nu|xi|pi|rho|sigma|tau|upsilon|phi|chi|psi|"
|
||
r"omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Phi|Psi|Omega|"
|
||
r"sqrt|sum|int|cdot|leq|geq|neq|partial|nabla|infty|"
|
||
r"log|ln|exp|min|max|pm|mp|forall|exists|in|notin|to|gets|" # codespell:ignore notin
|
||
r"mapsto|rightarrow|Rightarrow|prod|equiv|sim|propto|"
|
||
r"land|lor|neg|text|mathbf|mathit|mathcal|mathbb"
|
||
)
|
||
_ATTR_LATEX_CMD_RE = re.compile(rf"\\(?:{_ATTR_LATEX_COMMANDS})\b")
|
||
# Candidate `$...$` span. We post-filter the captured inner content with
|
||
# _looks_like_math() to reject currency false positives like
|
||
# `$500 outweighs ... ($100)` where two unescaped `$` digit-prefixes
|
||
# bracket a sentence that is not math at all.
|
||
_ATTR_DOLLAR_MATH_RE = re.compile(r"(?<!\\)\$([^\s$][^$\n]*?)(?<!\\)\$")
|
||
_ATTR_CARET_BRACE_RE = re.compile(r"(?<![A-Za-z0-9_])\^\{[^}\n]{1,40}\}")
|
||
_ATTR_UNDER_BRACE_RE = re.compile(r"(?<![A-Za-z0-9_])_\{[^}\n]{1,40}\}")
|
||
|
||
@classmethod
|
||
def _looks_like_math(cls, content: str) -> bool:
|
||
"""True iff a `$...$` span's inner content is plausibly math.
|
||
|
||
Distinguishes real math from unescaped-currency false positives like
|
||
`$500 outweighs ... ($100)`. Heuristic:
|
||
|
||
- Anything with a backslash command, `^{...}` superscript, or
|
||
`_{...}` subscript is math.
|
||
- A span whose first character is a digit AND that contains no
|
||
LaTeX-y signals (backslash / ^ / _) is treated as currency
|
||
(false positive: `$500 outweighs ... ($100)` matches the
|
||
outer regex but is two adjacent dollar-prefixed amounts).
|
||
- Otherwise (single symbols like `$B$`, equations like `$T=0.5$`,
|
||
short mixes like `$y < 0$`) — math.
|
||
"""
|
||
if not content:
|
||
return False
|
||
if "\\" in content or "^{" in content or "_{" in content:
|
||
return True
|
||
if content[0].isdigit():
|
||
return False
|
||
return True
|
||
|
||
@classmethod
|
||
def _has_attr_math(cls, value: str) -> bool:
|
||
"""True iff *value* contains a `$...$` math span (after currency
|
||
false-positive filtering)."""
|
||
return any(cls._looks_like_math(m.group(1))
|
||
for m in cls._ATTR_DOLLAR_MATH_RE.finditer(value))
|
||
|
||
# Callout open fence with title= attribute (single line; multi-line attr
|
||
# values are rare in this codebase and intentionally out of scope here).
|
||
_CALLOUT_TITLE_RE = re.compile(
|
||
r"""^:::+\s*\{[^}]*\btitle\s*=\s*"([^"]*)"[^}]*\}\s*$"""
|
||
)
|
||
# YAML caption-like keys at top of a chunk options block or block YAML.
|
||
# Match e.g. `fig-cap: "..."`, `fig-cap: '...'`, `fig-cap: bare text`.
|
||
_YAML_CAP_KEYS = ("fig-cap", "tbl-cap", "lst-cap", "fig-alt", "tbl-alt")
|
||
_YAML_CAP_RE = re.compile(
|
||
r"""^(?P<indent>\s*)(?:\#\|\s*)?(?P<key>fig-cap|tbl-cap|lst-cap|fig-alt|tbl-alt)\s*:\s*(?P<value>.*?)\s*$"""
|
||
)
|
||
# Inline div attribute: `{#fig-x ... fig-cap="..." fig-alt="..."}` or
|
||
# similar single-line attribute blocks. Match any of the cap/alt keys.
|
||
_INLINE_CAP_RE = re.compile(
|
||
r"""\b(?P<key>fig-cap|tbl-cap|lst-cap|fig-alt|tbl-alt)\s*=\s*"(?P<value>[^"]*)\""""
|
||
)
|
||
|
||
@classmethod
|
||
def _strip_quotes(cls, raw: str) -> str:
|
||
s = raw.strip()
|
||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
|
||
return s[1:-1]
|
||
return s
|
||
|
||
@classmethod
|
||
def _strip_inline_math(cls, value: str) -> str:
|
||
"""Remove `$...$` math spans so we can scan the *non-math* remainder
|
||
for bare `^{}`/`_{}` that should have been wrapped in dollars.
|
||
|
||
Only strips spans that pass the math heuristic — currency false
|
||
positives (`$500 ... $100`) are left in place because they're
|
||
not math zones we want to ignore for the bare-brace scan.
|
||
"""
|
||
def _sub(m: re.Match) -> str:
|
||
return " " if cls._looks_like_math(m.group(1)) else m.group(0)
|
||
return cls._ATTR_DOLLAR_MATH_RE.sub(_sub, value)
|
||
|
||
def _scan_title_value(self, value: str) -> List[tuple]:
|
||
"""Return list of (code, severity, message_suffix) for issues in a
|
||
callout `title=` value. Math NEVER renders inside title; flag any."""
|
||
out: list[tuple] = []
|
||
if self._has_attr_math(value):
|
||
out.append(("attr_title_math", "error",
|
||
"callout title= contains $...$ — math is not parsed in attribute values; use Unicode (×, ³, α, β, ρ, …)"))
|
||
elif self._ATTR_LATEX_CMD_RE.search(value):
|
||
out.append(("attr_title_latex", "error",
|
||
"callout title= contains a LaTeX command — markdown is not parsed in attribute values; use Unicode"))
|
||
if self._ATTR_CARET_BRACE_RE.search(value) or self._ATTR_UNDER_BRACE_RE.search(value):
|
||
out.append(("attr_title_brace", "error",
|
||
"callout title= contains ^{...} or _{...} — use Unicode superscript/subscript (³, ₂, …)"))
|
||
return out
|
||
|
||
# Lightbox tooltip leak applies to fig-cap only — Quarto's lightbox is
|
||
# image-specific. Listing captions (lst-cap), table captions (tbl-cap),
|
||
# and alt text (fig-alt/tbl-alt) do not get a lightbox tooltip and so
|
||
# cannot leak through that channel. Math in those caps still renders
|
||
# correctly; warning about a non-existent leak there is just noise.
|
||
_LIGHTBOX_AFFECTED_KEYS = frozenset({"fig-cap"})
|
||
|
||
def _scan_caption_value(self, key: str, value: str, include_lightbox: bool = False) -> List[tuple]:
|
||
"""Return list of (code, severity, message_suffix) for issues in a
|
||
fig-cap/tbl-cap/lst-cap/fig-alt/tbl-alt value.
|
||
|
||
Math IS parsed inside the body of these captions, so `$...$` is
|
||
valid markup. Two failure modes:
|
||
|
||
- Bare `^{}/_{}` outside `$...$` is an error (LaTeX-only syntax;
|
||
won't render without delimiters). Applies to all caption-like
|
||
attribute keys.
|
||
- Inline `$...$` math in fig-cap renders fine in the caption
|
||
itself but Quarto's lightbox plugin extracts the caption string
|
||
for the `<img title>` hover tooltip with markdown stripped,
|
||
leaking raw LaTeX. Hover-only HTML cosmetic artifact; surfaces
|
||
only when *include_lightbox=True* (opt-in) and only for
|
||
fig-cap (other keys don't drive a lightbox).
|
||
"""
|
||
out: list[tuple] = []
|
||
non_math = self._strip_inline_math(value)
|
||
if self._ATTR_CARET_BRACE_RE.search(non_math) or self._ATTR_UNDER_BRACE_RE.search(non_math):
|
||
out.append((f"{key.replace('-', '_')}_bare_brace", "error",
|
||
f"{key} contains bare `^{{...}}` / `_{{...}}` outside `$...$` — wrap in `$...$` or use Unicode (³, ₂, …)"))
|
||
if (include_lightbox and key in self._LIGHTBOX_AFFECTED_KEYS
|
||
and self._has_attr_math(value)):
|
||
out.append((f"{key.replace('-', '_')}_lightbox_leak", "warning",
|
||
f"{key} contains `$...$` math — the caption renders fine, but Quarto's lightbox copies this string into the `<img title>` tooltip with markdown stripped, leaking raw LaTeX. Prefer Unicode for short fragments."))
|
||
return out
|
||
|
||
def _run_attr_latex_leaks(self, root: Path, include_lightbox: bool = False) -> ValidationRunResult:
|
||
"""LaTeX leakage inside Quarto attribute strings.
|
||
|
||
``include_lightbox`` (default False) opts into surfacing the
|
||
warning class for fig-cap/tbl-cap math fragments that leak into
|
||
the HTML lightbox tooltip. Off by default so pre-commit only
|
||
blocks on true rendering errors.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
text = self._read_text(file)
|
||
lines = text.splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
# Track fenced code blocks so we don't scan code samples
|
||
# that legitimately contain `title="..."` or `fig-cap=...`.
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
# 1. Callout title= attribute
|
||
m = self._CALLOUT_TITLE_RE.match(line)
|
||
if m:
|
||
value = m.group(1)
|
||
for code, severity, message in self._scan_title_value(value):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity=severity,
|
||
context=stripped[:160],
|
||
))
|
||
|
||
# 2. YAML-style caption / alt key (chunk options or block YAML)
|
||
ym = self._YAML_CAP_RE.match(line)
|
||
if ym and not stripped.startswith(":::") and not stripped.startswith("{"):
|
||
key = ym.group("key")
|
||
value = self._strip_quotes(ym.group("value"))
|
||
if value:
|
||
for code, severity, message in self._scan_caption_value(key, value, include_lightbox):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity=severity,
|
||
context=stripped[:160],
|
||
))
|
||
|
||
# 3. Inline div attribute: {... fig-cap="..." ...}
|
||
for im in self._INLINE_CAP_RE.finditer(line):
|
||
key = im.group("key")
|
||
value = im.group("value")
|
||
for code, severity, message in self._scan_caption_value(key, value, include_lightbox):
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity=severity,
|
||
context=stripped[:160],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="attr-leaks",
|
||
description="LaTeX inside attribute strings (callout title=, fig-cap, tbl-cap, fig-alt, tbl-alt)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Code: LaTeX inside *_str Python exports (the original bug)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# WHY THIS EXISTS
|
||
#
|
||
# The `_str` suffix is the project's convention for "this Python value is
|
||
# plain text destined for an inline `{python}` substitution". Inline
|
||
# `{python}` substitutions happen AFTER Pandoc's math parser runs, so any
|
||
# raw LaTeX (`$...$`, `\\command`, `^{...}`) embedded in a `_str` export
|
||
# survives into the rendered output as literal text. The fix is either:
|
||
# - Wrap the export in fmt_math() — and rename it `_math` so the
|
||
# suffix tells the next reader "this is a Markdown-wrapped LaTeX
|
||
# object, not a plain string."
|
||
# - Or compute and emit the value as plain text / Unicode.
|
||
# See the project prose style guide ("Anti-pattern: bare LaTeX inside a
|
||
# `_str` export").
|
||
|
||
# `_str` assignments inside Python code blocks. Capture the variable name
|
||
# so we can include it in the error message and skip `_math`/other names.
|
||
_STR_ASSIGN_RE = re.compile(
|
||
r"""^\s*(?P<lhs>[A-Za-z_][A-Za-z0-9_.]*_str)\s*=\s*(?P<rhs>[fFrR]{0,2}["'].*)$"""
|
||
)
|
||
# Common LaTeX commands likely to appear inside an f-string. Pre-compiled
|
||
# with the leading backslash already escaped for the Python source.
|
||
_STR_LATEX_CMD_RE = re.compile(rf"\\\\(?:{_ATTR_LATEX_COMMANDS})\b")
|
||
# `$...$` math span inside a Python string. Conservative on two axes:
|
||
# 1. Requires a non-space char immediately after the opening `$` to
|
||
# avoid matching legitimate uses of `$` for currency in plain prose.
|
||
# 2. Excludes `$` immediately followed by `{` — that is f-string
|
||
# substitution syntax for currency formatting (`f"${rate:.0f}"`),
|
||
# which writes a literal `$` followed by the substituted value.
|
||
# Math spans never have `${`-form openings.
|
||
_STR_DOLLAR_MATH_RE = re.compile(r"(?<!\\)\$(?!\{)[^\s$][^$\n]{0,80}?(?<!\\)\$")
|
||
# `^{...}` inside a Python string — LaTeX-only syntax, never legitimate
|
||
# in a plain `_str` value.
|
||
_STR_CARET_BRACE_RE = re.compile(r"\^\{[^}\n]{1,40}\}")
|
||
# Comment / docstring discriminator: skip lines that are obviously
|
||
# docstrings or comments (these don't affect rendering).
|
||
_PY_COMMENT_RE = re.compile(r"^\s*#")
|
||
|
||
def _run_str_latex_leak(self, root: Path) -> ValidationRunResult:
|
||
"""`*_str` exports must be plain text — no embedded LaTeX."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
block_start = re.compile(r"^```\{python\}")
|
||
block_end = re.compile(r"^```\s*$")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_python = False
|
||
for idx, line in enumerate(lines, 1):
|
||
if block_start.match(line):
|
||
in_python = True
|
||
continue
|
||
if block_end.match(line):
|
||
in_python = False
|
||
continue
|
||
if not in_python:
|
||
continue
|
||
if self._PY_COMMENT_RE.match(line):
|
||
continue
|
||
|
||
m = self._STR_ASSIGN_RE.match(line)
|
||
if not m:
|
||
continue
|
||
lhs = m.group("lhs")
|
||
rhs = m.group("rhs")
|
||
|
||
hits: list[tuple] = []
|
||
if self._STR_DOLLAR_MATH_RE.search(rhs):
|
||
hits.append(("str_export_dollar_math",
|
||
f"`{lhs}` contains `$...$` math — `_str` exports must be plain text. "
|
||
f"Rename to `{lhs[:-4]}_math` and wrap in `fmt_math(...)`."))
|
||
if self._STR_LATEX_CMD_RE.search(rhs):
|
||
hits.append(("str_export_latex_cmd",
|
||
f"`{lhs}` contains a LaTeX command (`\\\\times`, `\\\\frac`, `\\\\alpha`, …) — "
|
||
f"`_str` exports must be plain text. Rename to `{lhs[:-4]}_math` and wrap in `fmt_math(...)`."))
|
||
if self._STR_CARET_BRACE_RE.search(rhs):
|
||
hits.append(("str_export_caret_brace",
|
||
f"`{lhs}` contains `^{{...}}` / `_{{...}}` — `_str` exports must be plain text. "
|
||
f"Rename to `{lhs[:-4]}_math` and wrap in `fmt_math(...)`, or emit Unicode (³, ₂, …)."))
|
||
|
||
for code, message in hits:
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code=code,
|
||
message=message,
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="str-latex-leak",
|
||
description="*_str Python exports must not contain raw LaTeX (use fmt_math())",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Math: full HTML render audit (delegates to tools/audit/audit_math_rendering.py)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# WHY THIS EXISTS
|
||
#
|
||
# The static `attr-leaks` and `str-latex-leak` checks catch the source-
|
||
# level patterns we know about. The render audit is the empirical fallback:
|
||
# it actually builds each chapter's HTML and scans the *rendered* output
|
||
# for any LaTeX that escaped MathJax — including failure modes we haven't
|
||
# written a static check for yet. It is also the source of truth for the
|
||
# lightbox-tooltip leak class.
|
||
#
|
||
# SLOW. Wired through `binder check math --scope render-audit` so it can
|
||
# be run interactively or in CI, and registered as a `manual`-stage hook
|
||
# in `.pre-commit-config.yaml` (do not let it block routine commits).
|
||
|
||
def _run_math_render_audit(self, root: Path) -> ValidationRunResult:
|
||
"""Build each chapter's HTML and scan for unrendered LaTeX leakage.
|
||
|
||
Delegates to `tools/audit/audit_math_rendering.py`. This is slow
|
||
(~10 minutes for the full book) and is registered as a manual-stage
|
||
pre-commit hook; it is NOT run on every commit.
|
||
"""
|
||
start = time.time()
|
||
script = root / "tools" / "audit" / "audit_math_rendering.py"
|
||
issues: List[ValidationIssue] = []
|
||
if not script.exists():
|
||
issues.append(ValidationIssue(
|
||
file=str(script.relative_to(root)) if script.is_absolute() else str(script),
|
||
line=0,
|
||
code="render_audit_missing",
|
||
message="Render-audit script not found — expected tools/audit/audit_math_rendering.py",
|
||
severity="error",
|
||
context="",
|
||
))
|
||
return ValidationRunResult(
|
||
name="render-audit",
|
||
description="Full HTML build + leak scan (delegates to tools/audit/)",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
proc = subprocess.run(
|
||
[sys.executable, str(script)],
|
||
cwd=root,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
# The audit script writes audit-math-report.json; surface the summary
|
||
# line + nonzero exit as a single issue if there was leakage.
|
||
if proc.returncode != 0:
|
||
tail = (proc.stdout or "").strip().splitlines()
|
||
summary = next((line for line in reversed(tail)
|
||
if "leaky" in line.lower() or "fail" in line.lower()),
|
||
tail[-1] if tail else "render audit reported leaks")
|
||
issues.append(ValidationIssue(
|
||
file="audit-math-report.json",
|
||
line=0,
|
||
code="render_audit_leaks",
|
||
message=f"Render audit found leaks (exit {proc.returncode}): {summary}. "
|
||
f"See audit-math-report.md for details.",
|
||
severity="error",
|
||
context="",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="render-audit",
|
||
description="Full HTML build + leak scan (delegates to tools/audit/)",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_math_canonical(self, root: Path) -> ValidationRunResult:
|
||
"""math --scope canonical: fmt-family and suffix discipline for LEGO cells."""
|
||
from cli.checks.math_canonical import audit
|
||
|
||
start = time.time()
|
||
qmd_files = sorted(root.rglob("*.qmd")) if root.is_dir() else ([root] if root.suffix == ".qmd" else [])
|
||
violations = audit([root] if root.is_dir() else qmd_files or [root])
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for v in violations:
|
||
file_path = Path(v.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(root.resolve()))
|
||
except ValueError:
|
||
rel = v.file
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=v.line,
|
||
code=v.code,
|
||
message=v.message,
|
||
severity="error",
|
||
context=v.context,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="math-canonical",
|
||
description="fmt-family + _str/_math/_eq/_frac suffix discipline",
|
||
files_checked=len(qmd_files) if qmd_files else len({v.file for v in violations}),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_fmt_prose_contract(self, root: Path) -> ValidationRunResult:
|
||
"""math --scope prose-contract: formatter-owned glyphs match prose use."""
|
||
from cli.checks.fmt_prose_contract import check_file
|
||
|
||
start = time.time()
|
||
qmd_files = self._qmd_files(root)
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for qmd in qmd_files:
|
||
for violation in check_file(qmd):
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.book_dir))
|
||
except ValueError:
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.msg,
|
||
severity="error",
|
||
context=f"{{python}} {violation.ref}",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="fmt-prose-contract",
|
||
description="Formatter-owned glyph/unit prose contract",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_math_multiplier_style(self, root: Path) -> ValidationRunResult:
|
||
"""math --scope multiplier-style: multiplier and times typography."""
|
||
from cli.checks.math_multiplier_style import audit
|
||
|
||
start = time.time()
|
||
qmd_files = self._qmd_files(root)
|
||
violations = audit([root])
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for violation in violations:
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.book_dir))
|
||
except ValueError:
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="math-multiplier-style",
|
||
description="Multiplier suffixes, Unicode times, and product spacing",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_fmt_semantic_suffix(self, root: Path) -> ValidationRunResult:
|
||
"""math --scope suffix-semantics: value-kind must be a typed formatter."""
|
||
from cli.checks.fmt_semantic_suffix import audit
|
||
|
||
start = time.time()
|
||
qmd_files = self._qmd_files(root)
|
||
violations = audit([root])
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for violation in violations:
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.book_dir))
|
||
except ValueError:
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
suggestion=violation.suggestion,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="fmt-semantic-suffix",
|
||
description="Value-kind (percent/multiplier/pp/scale) must be a typed formatter",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Purpose sections must be unnumbered
|
||
# ------------------------------------------------------------------
|
||
|
||
_PURPOSE_HEADING = re.compile(r"^## Purpose\b")
|
||
|
||
def _run_purpose_unnumbered(self, root: Path) -> ValidationRunResult:
|
||
"""Ensure all '## Purpose' headings have {.unnumbered}."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
for idx, line in enumerate(lines, 1):
|
||
if self._PURPOSE_HEADING.match(line) and ".unnumbered" not in line:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="purpose_not_unnumbered",
|
||
message="Purpose section must include {.unnumbered}",
|
||
severity="error",
|
||
context=line.strip()[:80],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="purpose-unnumbered",
|
||
description="Ensure Purpose sections are unnumbered",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Div fence validation (malformed ::: / :::: fences)
|
||
# ------------------------------------------------------------------
|
||
|
||
# ::: or :::: followed by space then non-{ character — malformed fence
|
||
_DIV_FENCE_3 = re.compile(r"^:::[ ]+[^{ ]")
|
||
_DIV_FENCE_4 = re.compile(r"^::::[ ]+[^{ ]")
|
||
|
||
def _run_div_fences(self, root: Path) -> ValidationRunResult:
|
||
"""Flag ::: or :::: lines with trailing text instead of {.class} or closure."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if self._DIV_FENCE_4.match(stripped):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="malformed_div_fence_4",
|
||
message="Nested div fence (::::) has trailing text; must be bare :::: or :::: {.class}",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
elif self._DIV_FENCE_3.match(stripped):
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="malformed_div_fence_3",
|
||
message="Div fence (:::) has trailing text; must be bare ::: or ::: {.class}",
|
||
severity="error",
|
||
context=stripped[:80],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="div-fences",
|
||
description="Malformed div fences (::: / ::::) with trailing text",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
_ALLOWED_CALLOUT_CLASSES = {
|
||
"callout-chapter-connection",
|
||
"callout-checkpoint",
|
||
"callout-colab",
|
||
"callout-definition",
|
||
"callout-example",
|
||
"callout-important",
|
||
"callout-learning-objectives",
|
||
"callout-lighthouse",
|
||
"callout-note",
|
||
"callout-notebook",
|
||
"callout-perspective",
|
||
"callout-principle",
|
||
"callout-research-questions",
|
||
"callout-takeaways",
|
||
"callout-theorem",
|
||
"callout-tip",
|
||
"callout-war-story",
|
||
"callout-warning",
|
||
}
|
||
_CALLOUT_TITLE_OPTIONAL_CLASSES = {"callout-learning-objectives"}
|
||
|
||
def _run_callout_structure(self, root: Path) -> ValidationRunResult:
|
||
"""Validate generic callout mechanics from book-prose §4."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
|
||
opener = self._CALLOUT_OPEN_RE.match(stripped)
|
||
if not opener:
|
||
continue
|
||
|
||
attrs = opener.group(1)
|
||
classes = [
|
||
cls for cls in self._ATTR_CLASS_RE.findall(attrs)
|
||
if cls.startswith("callout-")
|
||
]
|
||
if not classes:
|
||
continue
|
||
|
||
if "icon=false" in attrs:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="callout_icon_false",
|
||
message="Remove icon=false; callout icons should follow the type default",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
|
||
has_title = re.search(r'\btitle\s*=', attrs) is not None
|
||
for cls in classes:
|
||
if cls not in self._ALLOWED_CALLOUT_CLASSES:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="unsupported_callout_type",
|
||
message=f"Unsupported callout type .{cls}; use a type from book-prose §4",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
if cls in self._CALLOUT_TITLE_OPTIONAL_CLASSES:
|
||
if has_title:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="learning_objectives_explicit_title",
|
||
message="Learning Objectives callouts use the auto-generated label; omit title=",
|
||
severity="warning",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
elif not has_title:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="callout_missing_title",
|
||
message=f".{cls} callout needs an explicit chapter-specific title",
|
||
severity="error",
|
||
context=stripped[:160],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="callouts",
|
||
description="Callout types, required titles, and unsupported attributes",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# Per-type bold-label schema: a callout of this type must match one of the
|
||
# allowed sequences of bold paragraph-lead labels (**Label**:) and
|
||
# ***Term*** heads. Types not listed are heterogeneous by design
|
||
# (.callout-example, .callout-perspective carry many valid shapes) and are
|
||
# not schema-enforced. "«term»" is the sentinel for a ***Term*** head.
|
||
#
|
||
# War-stories come in three compact archetypes: a short disaster where the
|
||
# damage folds into Failure mode (Context / Failure mode / Systems lesson),
|
||
# a failure with a distinct downstream impact (**Consequence**), and a
|
||
# constraint-response story where the engineering fix stays visible as
|
||
# **Resolution**.
|
||
_CALLOUT_LABEL_SCHEMAS = {
|
||
"callout-war-story": (
|
||
("Context", "Failure mode", "Systems lesson"),
|
||
("Context", "Failure mode", "Consequence", "Systems lesson"),
|
||
("Context", "Failure mode", "Resolution", "Systems lesson"),
|
||
),
|
||
"callout-definition": (("«term»",),),
|
||
}
|
||
|
||
def _run_callout_schema(self, root: Path) -> ValidationRunResult:
|
||
"""Enforce a uniform bold-label structure per callout type.
|
||
|
||
Some callout types carry a fixed narrative schema: every
|
||
``.callout-war-story`` is Context / Failure mode / Systems lesson, and
|
||
every ``.callout-definition`` opens with a single ``***Term***`` head.
|
||
This scope flags any instance of an enforced type whose bold
|
||
paragraph-lead labels deviate, keeping the structure consistent across
|
||
the corpus. Heterogeneous types (example, perspective) are skipped.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
label_re = re.compile(r"^\*\*([^*]+?)\*\*:")
|
||
term_re = re.compile(r"^\*\*\*([^*]+?)\*\*\*")
|
||
close_re = re.compile(r"^(:{3,})\s*$")
|
||
id_re = re.compile(r"#([\w-]+)")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
stack: List[list] = [] # [colons, callout_class, id, [labels], open_line]
|
||
for idx, raw in enumerate(lines, 1):
|
||
stripped = raw.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
opener = self._CALLOUT_OPEN_RE.match(stripped)
|
||
if opener:
|
||
attrs = opener.group(1)
|
||
cls = next(
|
||
(c for c in self._ATTR_CLASS_RE.findall(attrs)
|
||
if c.startswith("callout-")),
|
||
"",
|
||
)
|
||
cid = id_re.search(attrs)
|
||
colons = len(stripped) - len(stripped.lstrip(":"))
|
||
stack.append([colons, cls, cid.group(1) if cid else "(no-id)", [], idx])
|
||
continue
|
||
cm = close_re.match(stripped)
|
||
if cm and stack:
|
||
colons = len(cm.group(1))
|
||
for k in range(len(stack) - 1, -1, -1):
|
||
if stack[k][0] == colons:
|
||
_, cls, cid, labels, open_line = stack.pop(k)
|
||
allowed = self._CALLOUT_LABEL_SCHEMAS.get(cls)
|
||
if allowed is not None and tuple(labels) not in allowed:
|
||
expected = " or ".join(
|
||
"[" + " / ".join(s) + "]" for s in allowed
|
||
)
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=open_line,
|
||
code="callout_schema_mismatch",
|
||
message=(
|
||
f".{cls} {cid}: expected bold labels "
|
||
f"{expected}; found "
|
||
f"[{' / '.join(labels) if labels else '(none)'}]"
|
||
),
|
||
severity="error",
|
||
suggestion=(
|
||
f"Fold or rename labels so every .{cls} "
|
||
"shares the same structure"
|
||
),
|
||
)
|
||
)
|
||
break
|
||
continue
|
||
if stack:
|
||
lm = label_re.match(stripped)
|
||
if lm:
|
||
stack[-1][3].append(lm.group(1))
|
||
elif term_re.match(stripped):
|
||
stack[-1][3].append("«term»")
|
||
|
||
return ValidationRunResult(
|
||
name="callout-schema",
|
||
description="Per-type bold-label structure (war-story, definition)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ==================================================================
|
||
# MIT PRESS CHECKS (§10 of book-prose-merged.md)
|
||
# ==================================================================
|
||
|
||
def _run_mitpress_percent_in_captions(self, root: Path) -> ValidationRunResult:
|
||
"""Flag % in fig-cap/tbl-cap prose — should be 'percent' (§10.2)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
pct_in_cap = re.compile(r'(fig-cap|tbl-cap)="([^"]*)"')
|
||
pct_pat = re.compile(r"\d\s*%")
|
||
|
||
for file in files:
|
||
for idx, line in enumerate(self._read_text(file).splitlines(), 1):
|
||
for cap_m in pct_in_cap.finditer(line):
|
||
cap_text = cap_m.group(2)
|
||
for m in pct_pat.finditer(cap_text):
|
||
context = cap_text[max(0, m.start() - 10) : m.end() + 10].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_percent_in_caption",
|
||
message='Use "percent" not "%" in fig-cap/tbl-cap prose (§10.2)',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-percent-in-captions",
|
||
description="No % in figure/table captions — spell out 'percent' (MIT Press §10.2)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_spaced_emdash(self, root: Path) -> ValidationRunResult:
|
||
"""Flag spaced em dashes (word — word) in prose — should be closed (word—word)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
spaced_em = re.compile(r"\S — \S")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if stripped.startswith("#|") or stripped.startswith("# "):
|
||
continue
|
||
# Table placeholder dashes are fine
|
||
if "| —" in line or "— |" in line:
|
||
continue
|
||
if "fig-cap=" in line or "fig-alt=" in line or "title=" in line:
|
||
continue
|
||
for m in spaced_em.finditer(line):
|
||
context = line[max(0, m.start() - 5) : min(len(line), m.end() + 15)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_spaced_emdash",
|
||
message="Close up em dash: word—word not word — word (§1)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-spaced-emdash",
|
||
description="No spaced em dashes in prose — use word—word (MIT Press §1)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_spaced_slash(self, root: Path) -> ValidationRunResult:
|
||
"""Flag spaced slashes (word / word) in prose — should be closed (word/word)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
spaced_slash = re.compile(r"[a-zA-Z0-9]\s+/\s+[a-zA-Z0-9]")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_html_comment = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if in_html_comment:
|
||
if "-->" in stripped:
|
||
in_html_comment = False
|
||
continue
|
||
if stripped.startswith("<!--"):
|
||
if "-->" not in stripped:
|
||
in_html_comment = True
|
||
continue
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if stripped.startswith("#|") or stripped.startswith("# "):
|
||
continue
|
||
for m in spaced_slash.finditer(line):
|
||
context = line[max(0, m.start() - 5) : min(len(line), m.end() + 15)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_spaced_slash",
|
||
message="Close up slash: word/word not word / word (§1)",
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-spaced-slash",
|
||
description="No spaced slashes in prose — use word/word (MIT Press §1)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_vs_period(self, root: Path) -> ValidationRunResult:
|
||
"""Flag bare 'vs' without period — should be 'vs.' (§10.5)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# Match ' vs ' in prose (space-bounded) NOT followed by '.'
|
||
bare_vs = re.compile(r"(?<= )vs(?=[ ,;:)\]])")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if stripped.startswith("#|") or stripped.startswith("#"):
|
||
continue
|
||
# Skip lines with IDs, labels, URLs, fig-attributes, index, tables
|
||
if any(skip in line for skip in ["{#", "@sec-", "@fig-", "@tbl-",
|
||
"fig-cap=", "fig-alt=", "title=", "http", "-vs-",
|
||
"\\index{", "`{python}", "| "]):
|
||
continue
|
||
for m in bare_vs.finditer(line):
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_vs_period",
|
||
message='Use "vs." with period, not bare "vs" (§10.5)',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-vs-period",
|
||
description='Use "vs." not bare "vs" (MIT Press §10.5)',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_eg_ie_comma(self, root: Path) -> ValidationRunResult:
|
||
"""Flag missing comma after e.g. and i.e. (§10.10)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# e.g. or i.e. followed by a space and a word (no comma)
|
||
missing_comma = re.compile(r"\b(e\.g|i\.e)\.\s+(?!,)(\w)")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code or stripped.startswith("#|"):
|
||
continue
|
||
for m in missing_comma.finditer(line):
|
||
context = line[max(0, m.start() - 5) : min(len(line), m.end() + 15)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_eg_ie_comma",
|
||
message='Missing comma after e.g./i.e. — write "e.g.," or "i.e.," (§10.10)',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-eg-ie-comma",
|
||
description='Comma after e.g. and i.e. (MIT Press §10.10)',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_acknowledgements(self, root: Path) -> ValidationRunResult:
|
||
"""Flag British spelling 'Acknowledgements' — should be 'Acknowledgments' (American)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
for idx, line in enumerate(lines, 1):
|
||
if "Acknowledgements" in line:
|
||
context = line.strip()[:80]
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_acknowledgements",
|
||
message='Use American spelling "Acknowledgments" not "Acknowledgements"',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-acknowledgements",
|
||
description='American spelling: "Acknowledgments" not "Acknowledgements"',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_capitalized_refs(self, root: Path) -> ValidationRunResult:
|
||
"""Flag capitalized prose references: 'Chapter 12' → 'chapter 12' (§10.4)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# Match "Chapter N", "Section N", "Figure N", "Table N" in prose
|
||
# but NOT at sentence start, in headings, or in protected contexts
|
||
cap_ref = re.compile(r"(?<=[a-z,;:] )(Chapter|Section|Figure|Table) \d")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code or stripped.startswith("#"):
|
||
continue
|
||
if "fig-cap=" in line or "fig-alt=" in line or "title=" in line:
|
||
continue
|
||
for m in cap_ref.finditer(line):
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_capitalized_refs",
|
||
message=f'Lowercase "{m.group(1).lower()}" in prose refs (§10.4)',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-capitalized-refs",
|
||
description='Lowercase "chapter/section/figure/table" in prose references (MIT Press §10.4)',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_xref_sentence_start_case(self, root: Path) -> ValidationRunResult:
|
||
"""Flag crossref prefix casing that fights sentence position.
|
||
|
||
Crossref prefixes render lowercase, so the casing of the @-prefix is
|
||
purely a function of sentence position (.claude/rules/cross-references.md
|
||
-> Prefix Casing). This check enforces BOTH directions:
|
||
|
||
* Sentence start -- a lowercase @sec-/@fig-/@tbl-/@eq-/@lst-/@alg-
|
||
must be capitalized (@Sec-, @Fig-, ...) so the rendered prefix
|
||
("Section", "Figure") leads the sentence with a capital.
|
||
semicolon, open paren, a lowercase word, or a plain-prose colon
|
||
renders a stray capital ("...as Table 2 shows") and must be
|
||
lowercased (@sec-, @fig-, ...). A colon is mid-sentence ONLY in
|
||
running prose ("...as follows: section 3 derives ..."), per
|
||
.claude/rules/cross-references.md prefix-casing style. A colon after
|
||
a bold structural label ("**Setup**: @Fig- shows ...") instead
|
||
begins a complete sentence (emphasis.md bold lead-in rule) and is
|
||
classified as a sentence start.
|
||
* Algorithm theorem refs -- direct @Alg-/@alg- refs follow the same
|
||
casing rule as other cross-refs. Bracketed [Algorithm @alg-id]
|
||
refs are unnecessary because the shared Quarto language file
|
||
renders native @alg- refs as "Algorithm N"; bare Algorithm @alg-id
|
||
is invalid Quarto prose.
|
||
|
||
Both halves share one is_sentence_start / is_mid_sentence computation so
|
||
they stay consistent by construction. Sentence position is
|
||
intentionally classified conservatively: clause boundaries
|
||
that are genuinely ambiguous (em-dash, a heading line, a preceding
|
||
acronym) are left unflagged in the mid-sentence direction rather than
|
||
risk a false positive.
|
||
|
||
Reverse (mid-sentence) direction + @alg- added 2026-06-04 to mirror the
|
||
documented Prefix Casing policy; algorithm bracket/bare-prefix checks
|
||
were added the same day. Colon handling corrected 2026-06-04: a plain-
|
||
prose colon is mid-sentence (lowercase) per MIT Press, not a sentence
|
||
start; only a bold-label lead-in colon capitalizes. Previously every
|
||
colon forced a capital, which matched the engineering-textbook habit the
|
||
book's MIT Press copyedit rejected.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
xref_re = re.compile(r"@([Ff]ig|[Tt]bl|[Ss]ec|[Ee]q|[Ll]st|[Aa]lg)-[\w-]+")
|
||
alg_bracket_re = re.compile(r"\[(Algorithm|algorithm)\s+(@[Aa]lg-[\w-]+)\]")
|
||
bare_alg_re = re.compile(r"(?<!\[)\b(Algorithm|algorithm)\s+@[Aa]lg-[\w-]+")
|
||
footnote_def = re.compile(r"^\s*\[\^fn-")
|
||
|
||
def classify_position(line: str, lines: List[str], idx: int, col: int, ref_end: int) -> tuple[bool, bool]:
|
||
"""Return (is_sentence_start, is_mid_sentence) for a reference."""
|
||
before = line[:col].rstrip()
|
||
is_sentence_start = False
|
||
is_mid_sentence = False
|
||
|
||
if not before:
|
||
# Line-start ref: classify by the preceding non-blank line.
|
||
# A blank line, a sentence-final prev line, a heading, or a div
|
||
# close => sentence start. Only call mid-sentence when the prev
|
||
# line genuinely ends inside a clause (lowercase word, digit,
|
||
# comma, semicolon) -- a symmetric-conservative test that
|
||
# mirrors the same-line rule and avoids treating metadata (a
|
||
# standalone \index{} line) as running prose.
|
||
prev = ""
|
||
prev_blank = False
|
||
for pi in range(idx - 2, -1, -1):
|
||
ps = lines[pi].strip()
|
||
if not ps:
|
||
prev_blank = True
|
||
break
|
||
prev = ps
|
||
break
|
||
# Strip trailing \index{...} tags -- they are metadata
|
||
# appended to the paragraph, not sentence content.
|
||
prev = re.sub(r'(\s*\\index\{[^}]*\})+\s*$', '', prev)
|
||
if prev_blank or not prev:
|
||
is_sentence_start = True
|
||
elif (prev[-1] in ".?!"
|
||
or prev.startswith("#")
|
||
or prev.endswith(":::")):
|
||
is_sentence_start = True
|
||
elif prev[-1].islower() or prev[-1].isdigit() or prev[-1] in ",;":
|
||
is_mid_sentence = True
|
||
else:
|
||
last = before[-1]
|
||
if re.search(r'[.?!]\s*$', before):
|
||
is_sentence_start = True
|
||
elif last in ",;":
|
||
is_mid_sentence = True
|
||
elif last == "(":
|
||
# Parenthetical: "(see @sec-x)" lowercases, but
|
||
# "Foo. (@Sec-x ...)" opens a sentence inside parens.
|
||
head = before[:-1].rstrip()
|
||
if head and head[-1] in ".?!":
|
||
is_sentence_start = True
|
||
else:
|
||
is_mid_sentence = True
|
||
elif last.islower() or last.isdigit():
|
||
# Preceded by a lowercase word or a number -> the ref
|
||
# sits inside a clause. (Uppercase/acronym before is
|
||
# left unflagged on purpose -- it may end a clause.)
|
||
is_mid_sentence = True
|
||
elif re.search(r':\s*$', before):
|
||
# A colon splits two ways (MIT Press house style):
|
||
# * after a bold structural label ("**Setup**: @Fig- shows
|
||
# ...") the colon introduces a complete sentence, so the
|
||
# ref is a sentence start and takes a capital prefix
|
||
# (emphasis.md bold lead-in rule).
|
||
# * in plain running prose ("...as follows: @sec-3 derives
|
||
# ...") the colon introduces a single clause, so the ref
|
||
# is mid-sentence and takes a lowercase prefix.
|
||
# Drop any \index{} tag before testing the label shape.
|
||
head = re.sub(r'\\index\{[^}]*\}', '', before[:-1]).strip()
|
||
is_label_leadin = bool(
|
||
re.fullmatch(r'(?:[-*+]\s+|\d+[.)]\s+)?\*\*[^*]+\*\*', head)
|
||
)
|
||
after_ref = line[ref_end:].strip()
|
||
if after_ref and after_ref[0] not in ".,;:)]":
|
||
if is_label_leadin:
|
||
is_sentence_start = True
|
||
else:
|
||
is_mid_sentence = True
|
||
# otherwise (em-dash, other punctuation): neutral, no flag
|
||
|
||
return is_sentence_start, is_mid_sentence
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
if stripped.startswith("#|"):
|
||
continue
|
||
if stripped.startswith(":::"):
|
||
continue
|
||
if footnote_def.match(line):
|
||
continue
|
||
# Heading lines are governed by heading-case (Title Case for
|
||
# H1/H2, sentence case for H3+), not by sentence position; their
|
||
# crossref casing is out of scope for the mid-sentence rule.
|
||
on_heading = stripped.startswith("#")
|
||
|
||
for m in xref_re.finditer(line):
|
||
ref_type = m.group(1)
|
||
is_capitalized = ref_type[0].isupper()
|
||
type_lower = ref_type.lower()
|
||
is_sentence_start, is_mid_sentence = classify_position(
|
||
line, lines, idx, m.start(), m.end()
|
||
)
|
||
|
||
context = line.strip()[:100]
|
||
if not is_capitalized and is_sentence_start:
|
||
cap_form = f"@{type_lower[0].upper()}{type_lower[1:]}-"
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="xref_sentence_start_case",
|
||
message=(
|
||
f"Lowercase @{type_lower}- at sentence start "
|
||
f"-- use {cap_form} for uppercase prefix"
|
||
),
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
elif is_capitalized and is_mid_sentence and not on_heading:
|
||
low_form = f"@{type_lower}-"
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="xref_midsentence_case",
|
||
message=(
|
||
f"Capitalized @{ref_type}- mid-sentence "
|
||
f"-- use {low_form} for lowercase prefix"
|
||
),
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
for m in alg_bracket_re.finditer(line):
|
||
direct_ref = m.group(2)
|
||
is_sentence_start, _ = classify_position(
|
||
line, lines, idx, m.start(), m.end()
|
||
)
|
||
ref_suffix = re.sub(r"^@[Aa]lg-", "", direct_ref)
|
||
replacement = (
|
||
f"@Alg-{ref_suffix}"
|
||
if is_sentence_start
|
||
else f"@alg-{ref_suffix}"
|
||
)
|
||
context = line.strip()[:100]
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="algorithm_ref_bracketed_prefix",
|
||
message=(
|
||
"Use direct algorithm references such as "
|
||
f"{replacement}, not bracketed [Algorithm @alg-id]"
|
||
),
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
for m in bare_alg_re.finditer(line):
|
||
context = line.strip()[:100]
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="algorithm_ref_bare_prefix",
|
||
message=(
|
||
"Use direct algorithm references such as @Alg-id "
|
||
"or @alg-id, not bare Algorithm @alg-id"
|
||
),
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="xref-sentence-start-case",
|
||
description="Flag crossref prefix casing that fights sentence position (MIT Press convention)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_above_below(self, root: Path) -> ValidationRunResult:
|
||
"""Flag 'above'/'below' spatial references — use @sec- cross-refs or 'earlier'/'later'."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
spatial = re.compile(
|
||
r"\b(as shown above|as shown below|see above|see below|discussed above|"
|
||
r"discussed below|mentioned above|mentioned below|described above|"
|
||
r"described below|noted above|noted below|"
|
||
r"outlined above|outlined below|listed above|listed below)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code or stripped.startswith("#|") or stripped.startswith("#"):
|
||
continue
|
||
for m in spatial.finditer(line):
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_above_below",
|
||
message='Use @sec-/@fig- cross-refs or "earlier"/"later", not "above"/"below"',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-above-below",
|
||
description='No "above"/"below" spatial refs — use cross-refs or "earlier"/"later"',
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_hyphen_range(self, root: Path) -> ValidationRunResult:
|
||
"""Flag hyphen in number ranges — should use en dash (100–200 not 100-200)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
# Two+ digit numbers separated by a single hyphen (not en dash).
|
||
# Word boundary + negative lookbehind on `CVE-` so CVE IDs like
|
||
# CVE-2021-44228 aren't flagged (where 2021-44228 is part of the
|
||
# identifier, not a year range). The \b prevents finditer from
|
||
# advancing into the middle of the CVE number and re-matching.
|
||
hyphen_range = re.compile(r"(?<!CVE-)\b(\d{2,})-(\d{2,})")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code or stripped.startswith("#|"):
|
||
continue
|
||
# Skip math, URLs, labels, code refs
|
||
if "$" in line or "http" in line or "{#" in line or "@" in line:
|
||
continue
|
||
if stripped.startswith("|"):
|
||
continue
|
||
for m in hyphen_range.finditer(line):
|
||
a, b = int(m.group(1)), int(m.group(2))
|
||
if b > a and a >= 10: # likely a range
|
||
context = line[max(0, m.start() - 10) : min(len(line), m.end() + 10)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="mitpress_hyphen_range",
|
||
message=f'Use en dash for ranges: {m.group(1)}–{m.group(2)} not {m.group()} (§2)',
|
||
severity="warning",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="mitpress-hyphen-range",
|
||
description="En dash for number ranges (100–200 not 100-200, MIT Press §2)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Cross-chapter footnote duplicates (ported from audit_footnotes_cross_chapter.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_footnote_cross_chapter(self, root: Path) -> ValidationRunResult:
|
||
"""Find duplicate footnote IDs across chapters."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
fn_def_pat = re.compile(
|
||
r"^\[\^(fn-[^\]]+)\]:\s*(.+?)(?=\n\n|\n\[\^|\Z)",
|
||
re.DOTALL | re.MULTILINE,
|
||
)
|
||
|
||
# Collect all footnotes by ID
|
||
footnotes_by_id: Dict[str, List[Tuple[Path, str]]] = defaultdict(list)
|
||
|
||
for file in files:
|
||
content = self._read_text(file)
|
||
for m in fn_def_pat.finditer(content):
|
||
fn_id = m.group(1)
|
||
fn_content = " ".join(m.group(2).split())[:200]
|
||
footnotes_by_id[fn_id].append((file, fn_content))
|
||
|
||
# Report duplicates
|
||
for fn_id, occurrences in footnotes_by_id.items():
|
||
if len(occurrences) <= 1:
|
||
continue
|
||
for file, content in occurrences:
|
||
line_no = self._line_for_token(self._read_text(file), f"[^{fn_id}]:")
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(file), line=line_no,
|
||
code="cross_chapter_footnote",
|
||
message=f"Footnote [^{fn_id}] also defined in {len(occurrences) - 1} other file(s)",
|
||
severity="warning",
|
||
context=content[:80],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="cross-chapter-footnotes",
|
||
description="Detect duplicate footnote IDs across chapters",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Footnote Capitalization (delegates to check_footnote_caps.py)
|
||
# ------------------------------------------------------------------
|
||
# MIT Press body prose lowercases concept terms ("memory wall",
|
||
# "scaling laws") per §10.3, but the first letter of a footnote
|
||
# definition must still be a sentence-case capital. A global
|
||
# lowercasing sweep can accidentally strip that capital; this
|
||
# check catches regressions. Intentional lowercase prefixes
|
||
# (brand names like cuDNN/gRPC/vLLM, math variables like k-Center,
|
||
# SI units like pJ/MAC) are declared in
|
||
# book/tools/scripts/mit_press/footnote_caps_allowlist.txt.
|
||
# The standalone script (with a --fix flag) remains the single
|
||
# source of truth; this method imports and reuses its core
|
||
# logic so the check and the fixer cannot drift apart.
|
||
|
||
def _run_footnote_capitalization(self, root: Path) -> ValidationRunResult:
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
import importlib.util
|
||
import sys as _sys
|
||
script_path = (
|
||
Path(__file__).resolve().parents[2]
|
||
/ "tools" / "scripts" / "mit_press" / "check_footnote_caps.py"
|
||
)
|
||
mod_name = "mlsys_check_footnote_caps"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script_path)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
# Register before exec_module so @dataclass can resolve cls.__module__.
|
||
_sys.modules[mod_name] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
allowlist = mod.load_allowlist(mod.DEFAULT_ALLOWLIST)
|
||
for f in files:
|
||
for v in mod.scan_file(f, allowlist):
|
||
snippet = v.raw_line if len(v.raw_line) <= 160 else v.raw_line[:157] + "..."
|
||
if getattr(v, "kind", "") == "term_head_case":
|
||
code = "footnote_term_head_case"
|
||
message = (
|
||
f"Footnote term head has lowercase significant word(s): "
|
||
f"{v.detail}; use Title Case or preserve only canonical "
|
||
f"lowercase API/math/unit tokens"
|
||
)
|
||
else:
|
||
code = "footnote_lowercase_first_letter"
|
||
message = (
|
||
f"Footnote opens with lowercase {v.first_char!r}; "
|
||
f"capitalize, or add the id to footnote_caps_allowlist.txt "
|
||
f"if the lowercase is canonical (brand/math/SI)"
|
||
)
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(f),
|
||
line=v.line_no,
|
||
code=code,
|
||
message=message,
|
||
severity="error",
|
||
context=snippet,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="footnote_capitalization",
|
||
description="Footnote definitions must begin with a capital letter",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Footnote definition shape (book-prose.md §5 — S1–S5 canonical openers)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_footnote_definition_shape(self, root: Path) -> ValidationRunResult:
|
||
"""Footnote definition lines must match one canonical opener shape.
|
||
|
||
After ``[^fn-slug]:`` and optional ``[offset=…mm]``, the body must begin with:
|
||
|
||
- **S3** ``**Term** (gloss): `` — etymology / parenthetical gloss before colon
|
||
- **S2** ``**Term**\\index{…}\\index{…}: `` — index tags stacked before colon
|
||
- **S1/S5** ``**Term**: `` — standard definitional head (incl. biography)
|
||
|
||
Bare ``@sec-`` openers and plain prose (no bold head) are rejected.
|
||
Reference: the project prose style guide *Canonical definition-line shapes*.
|
||
"""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
def_line = re.compile(r"^\[\^(fn-[a-z0-9-]+)\]:\s*(.*)$")
|
||
offset_prefix = re.compile(r"^\[offset=[^\]]+\]\s*")
|
||
# S3: **Term** (…): or **Term**\index{}… (…): — gloss colon after closing )
|
||
pat_etymology = re.compile(
|
||
r"^\*\*.+?\*\*(?:\\index\{[^}]+\}\s*)*\s*\([^)]*\):\s*",
|
||
re.DOTALL,
|
||
)
|
||
# S2: **Term**\index{}…+: before colon
|
||
pat_index_first = re.compile(
|
||
r"^\*\*.+?\*\*\s*(?:\\index\{[^}]+\}\s*)+:\s*",
|
||
re.DOTALL,
|
||
)
|
||
# S1/S5: **Term**: — bold span then colon
|
||
pat_standard = re.compile(
|
||
r"^\*\*.+?\*\*:\s*",
|
||
re.DOTALL,
|
||
)
|
||
|
||
def opener_ok(rest: str) -> bool:
|
||
rest = rest.strip()
|
||
om = offset_prefix.match(rest)
|
||
if om:
|
||
rest = rest[om.end() :].lstrip()
|
||
if not rest:
|
||
return False
|
||
if rest.startswith("@"):
|
||
return False
|
||
if pat_etymology.match(rest):
|
||
return True
|
||
if pat_index_first.match(rest):
|
||
return True
|
||
if pat_standard.match(rest):
|
||
return True
|
||
return False
|
||
|
||
for file in files:
|
||
text = self._read_text(file)
|
||
in_fence = False
|
||
for idx, line in enumerate(text.splitlines(), 1):
|
||
if line.lstrip().startswith("```"):
|
||
in_fence = not in_fence
|
||
continue
|
||
if in_fence:
|
||
continue
|
||
m = def_line.match(line)
|
||
if not m:
|
||
continue
|
||
body = m.group(2)
|
||
if opener_ok(body):
|
||
continue
|
||
fn_id = m.group(1)
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="footnote_definition_shape",
|
||
message=(
|
||
f"Footnote [^{fn_id}] does not match canonical opener "
|
||
r"(**Term**:, **Term**\index{}:, or **Term** (gloss):). "
|
||
"See book-prose.md §5 Canonical definition-line shapes."
|
||
),
|
||
severity="error",
|
||
context=(line.strip()[:140]),
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="footnote-definition-shape",
|
||
description=(
|
||
"Footnote definitions use bold head + colon per book-prose §5 (S1–S5)"
|
||
),
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Table content validation (delegated to validate_tables.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_table_content(self, root: Path) -> ValidationRunResult:
|
||
"""Validate grid table content (bare pipes, fracs, HTML entities).
|
||
|
||
Native import of book/tools/scripts/content/validate_tables.py
|
||
via importlib — no subprocess. The script's per-file API
|
||
(`validate_file(path) -> List[TableIssue]`) is the entry point;
|
||
each TableIssue maps directly to a ValidationIssue. The script
|
||
itself remains callable as a standalone CLI for its --fix mode.
|
||
"""
|
||
import importlib.util
|
||
import sys as _sys
|
||
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "content" / "validate_tables.py"
|
||
)
|
||
mod_name = "mlsys_validate_tables"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
_sys.modules[mod_name] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
t0 = time.time()
|
||
qmd_files = sorted(root.rglob("*.qmd"))
|
||
issues: List[ValidationIssue] = []
|
||
for qmd in qmd_files:
|
||
try:
|
||
table_issues = mod.validate_file(qmd)
|
||
except Exception:
|
||
continue
|
||
for ti in table_issues:
|
||
# ti.file may already be relative (str) or absolute.
|
||
p = Path(ti.file)
|
||
if p.is_absolute():
|
||
try:
|
||
rel = str(p.relative_to(root))
|
||
except ValueError:
|
||
rel = str(p)
|
||
else:
|
||
rel = ti.file
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=ti.line, code=ti.code,
|
||
message=ti.message, severity=ti.severity,
|
||
context=(ti.context or "")[:160],
|
||
))
|
||
return ValidationRunResult(
|
||
name="table-content",
|
||
description="Grid table content invariants (bare pipes, fracs, HTML entities)",
|
||
files_checked=len(qmd_files), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Spelling checks (delegated to check_prose_spelling.py / check_tikz_spelling.py)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_spelling_prose(self, root: Path) -> ValidationRunResult:
|
||
"""Spell check prose text (requires aspell)."""
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "content" / "check_prose_spelling.py"
|
||
)
|
||
return self._delegate_script(script, [str(root)], "spelling-prose")
|
||
|
||
def _run_spelling_tikz(self, root: Path) -> ValidationRunResult:
|
||
"""Spell check TikZ diagram text (requires aspell)."""
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "content" / "check_tikz_spelling.py"
|
||
)
|
||
# check_tikz_spelling.py auto-scans from repo root, so pass no args
|
||
return self._delegate_script(script, [], "spelling-tikz")
|
||
|
||
# ------------------------------------------------------------------
|
||
# EPUB validation
|
||
#
|
||
# Three scopes share this section, each targeting a different layer of
|
||
# the EPUB build pipeline. See book/cli/commands/_epub_checks.py for
|
||
# the per-check logic and the rationale for the layering.
|
||
#
|
||
# hygiene — source-level invariants (pre-build, fast, pre-commit)
|
||
# epubcheck — W3C validator on built artifacts (post-build, CI)
|
||
# structure — legacy custom checks on built EPUB (no Java required)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_epub_hygiene(self, root: Path, *, fix: bool = False) -> ValidationRunResult:
|
||
"""Run the pre-commit-grade EPUB source hygiene checks.
|
||
|
||
Walks `book/quarto/contents/**/*.svg` and `book/quarto/**/*.bib`
|
||
looking for the four source-level patterns that produced FATAL
|
||
or ERROR-level epubcheck failures in April 2026:
|
||
|
||
1. C0 control chars inside SVG aria-label attributes
|
||
2. Duplicate `<marker id="X"/>` defs inside one SVG
|
||
3. BibTeX `\\_` or `\\%` escapes inside url= / http doi= fields
|
||
4. Raw `<` or `>` inside those same URL fields
|
||
|
||
Runs in <1s and is safe to invoke from pre-commit on every push.
|
||
|
||
When `fix=True` (from `--fix`), the detected issues are rewritten
|
||
in place before reporting. The returned issue list then reflects
|
||
what the check found BEFORE the fix — so a first `--fix` run
|
||
reports "N issues found, N auto-fixed" and a second run reports 0.
|
||
"""
|
||
from cli.commands._epub_checks import (
|
||
find_hygiene_issues,
|
||
fix_hygiene_issues,
|
||
)
|
||
|
||
t0 = time.time()
|
||
# Walk from repo root so the check sees both volumes.
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
|
||
fixes: Dict[str, int] = {}
|
||
if fix:
|
||
fixes = fix_hygiene_issues(repo_root)
|
||
|
||
epub_issues, files_checked = find_hygiene_issues(repo_root)
|
||
|
||
issues = [
|
||
ValidationIssue(
|
||
file=e.file,
|
||
line=e.line,
|
||
code=e.code,
|
||
message=e.message,
|
||
severity="error", # hygiene issues are always blocking
|
||
)
|
||
for e in epub_issues
|
||
]
|
||
|
||
description = "EPUB source hygiene (SVG + BibTeX invariants)"
|
||
if fix:
|
||
touched = sum(fixes.values())
|
||
description += (
|
||
f" — --fix applied: "
|
||
f"{fixes.get('svg_c0_chars_removed', 0)} C0 chars stripped, "
|
||
f"{fixes.get('svg_duplicate_markers', 0)} duplicate markers removed, "
|
||
f"{fixes.get('bib_url_rewrites', 0)} URL-field rewrites "
|
||
f"(touched {touched} total)"
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="epub-hygiene",
|
||
description=description,
|
||
files_checked=files_checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _check_baseline(
|
||
self,
|
||
*,
|
||
baseline_path: Optional[str],
|
||
update_baseline: bool,
|
||
per_volume_counts: Dict[str, Dict[str, int]],
|
||
) -> List[ValidationIssue]:
|
||
"""Ratchet: compare per-volume counts against a recorded baseline.
|
||
|
||
If *baseline_path* is None, do nothing (the flat --max-fatal /
|
||
--max-errors thresholds take over in the caller).
|
||
|
||
If *update_baseline* is True, rewrite the baseline file with the
|
||
current counts and return an empty issue list. This is the way
|
||
to lower the ceiling after a cleanup lands.
|
||
|
||
Otherwise: load the baseline file, compare to current counts,
|
||
and return a synthetic ValidationIssue per volume + severity
|
||
that regressed.
|
||
"""
|
||
if baseline_path is None:
|
||
if update_baseline:
|
||
# No path to update into — emit a clear error.
|
||
return [ValidationIssue(
|
||
file="(baseline)", line=0,
|
||
code="EPUBCHECK-BASELINE-NO-PATH",
|
||
message="--update-baseline supplied without --baseline PATH",
|
||
severity="error",
|
||
)]
|
||
return []
|
||
|
||
import datetime as _dt
|
||
import json as _json
|
||
|
||
baseline_file = Path(baseline_path)
|
||
|
||
# --- Update mode ------------------------------------------------
|
||
if update_baseline:
|
||
payload = {
|
||
"generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(
|
||
timespec="seconds"
|
||
),
|
||
"generated_by": "binder check epub --scope epubcheck --update-baseline",
|
||
"description": (
|
||
"Max allowed per-volume epubcheck counts. "
|
||
"`./binder check epub --scope epubcheck --baseline <this>` "
|
||
"fails only if any current count exceeds the recorded "
|
||
"value. Run `--update-baseline` again after a cleanup "
|
||
"to lower the ceiling."
|
||
),
|
||
"volumes": per_volume_counts,
|
||
}
|
||
baseline_file.parent.mkdir(parents=True, exist_ok=True)
|
||
baseline_file.write_text(_json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||
console.print(
|
||
f"[green]✓ Baseline updated:[/green] {baseline_file} "
|
||
f"[dim]({len(per_volume_counts)} volumes recorded)[/dim]"
|
||
)
|
||
return []
|
||
|
||
# --- Compare mode -----------------------------------------------
|
||
if not baseline_file.exists():
|
||
return [ValidationIssue(
|
||
file=str(baseline_file), line=0,
|
||
code="EPUBCHECK-BASELINE-MISSING",
|
||
message=(
|
||
f"Baseline file not found: {baseline_file}. Initialize "
|
||
"with `./binder check epub --scope epubcheck "
|
||
f"--baseline {baseline_file} --update-baseline`."
|
||
),
|
||
severity="error",
|
||
)]
|
||
|
||
try:
|
||
payload = _json.loads(baseline_file.read_text(encoding="utf-8"))
|
||
except _json.JSONDecodeError as e:
|
||
return [ValidationIssue(
|
||
file=str(baseline_file), line=0,
|
||
code="EPUBCHECK-BASELINE-INVALID",
|
||
message=f"Baseline file is not valid JSON: {e}",
|
||
severity="error",
|
||
)]
|
||
|
||
recorded = payload.get("volumes", {}) or {}
|
||
issues: List[ValidationIssue] = []
|
||
any_under = False
|
||
|
||
for vol, counts in per_volume_counts.items():
|
||
base = recorded.get(vol, {})
|
||
for severity in ("FATAL", "ERROR", "WARNING"):
|
||
current = counts.get(severity, 0)
|
||
allowed = int(base.get(severity, 0))
|
||
if current > allowed:
|
||
issues.append(ValidationIssue(
|
||
file="(epubcheck baseline)",
|
||
line=0,
|
||
code=f"EPUBCHECK-BASELINE-{severity}",
|
||
message=(
|
||
f"{vol}: {severity} count regressed — current "
|
||
f"{current} > baseline {allowed} (delta "
|
||
f"+{current - allowed}). Fix the underlying "
|
||
f"issues or run `--update-baseline` after "
|
||
f"verifying this is an accepted increase."
|
||
),
|
||
severity="error",
|
||
))
|
||
elif current < allowed:
|
||
any_under = True
|
||
|
||
if any_under and not issues:
|
||
console.print(
|
||
"[dim]ⓘ epubcheck counts are under baseline in one or more "
|
||
"volumes; run `--update-baseline` to lower the ceiling.[/dim]"
|
||
)
|
||
|
||
return issues
|
||
|
||
def _run_epub_smoke(self, root: Path) -> ValidationRunResult:
|
||
"""Reader-compatibility smoke checks against the built EPUB(s).
|
||
|
||
Epubcheck validates EPUB 3 spec conformance. This scope catches
|
||
patterns that pass epubcheck but break specific readers:
|
||
|
||
* CSS custom properties (`--var`, `var(--x)`) that older
|
||
ClearView / Tolino firmware cannot resolve.
|
||
* External resource references (`src="https://..."`,
|
||
`<link href="https://...">`) that EPUB readers do not fetch.
|
||
|
||
Runs against every EPUB discovered under `_build/epub-vol*/`.
|
||
Does not require Java — safe to run on any dev machine.
|
||
"""
|
||
from cli.commands._epub_checks import (
|
||
_discover_built_epubs,
|
||
run_smoke_checks_on,
|
||
)
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
epubs = _discover_built_epubs(repo_root)
|
||
|
||
if not epubs:
|
||
return ValidationRunResult(
|
||
name="epub-smoke",
|
||
description="EPUB reader-compatibility smoke (no built EPUBs found)",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
all_issues: List[ValidationIssue] = []
|
||
for epub in epubs:
|
||
for e in run_smoke_checks_on(epub, repo_root=repo_root):
|
||
all_issues.append(ValidationIssue(
|
||
file=e.file,
|
||
line=e.line,
|
||
code=e.code,
|
||
message=e.message,
|
||
# Smoke issues are advisory (warnings), not blockers,
|
||
# because they flag reader-subset behavior rather than
|
||
# spec violations. Promote to error by fixing at source.
|
||
severity=e.severity,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="epub-smoke",
|
||
description=(
|
||
f"EPUB reader-compatibility smoke "
|
||
f"({len(epubs)} EPUB(s) scanned)"
|
||
),
|
||
files_checked=len(epubs),
|
||
issues=all_issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_epubcheck(
|
||
self,
|
||
root: Path,
|
||
*,
|
||
max_fatal: int = 0,
|
||
max_errors: Optional[int] = None,
|
||
baseline_path: Optional[str] = None,
|
||
update_baseline: bool = False,
|
||
) -> ValidationRunResult:
|
||
"""Run the W3C `epubcheck` validator against the built EPUBs.
|
||
|
||
Discovers every `book/quarto/_build/epub-vol*/*.epub` (most recent
|
||
per volume), invokes `epubcheck --json -` on each, parses the
|
||
JSON message array, and converts each message to a
|
||
`ValidationIssue`. Also emits GitHub Actions `::error` annotations
|
||
when running under CI so the findings appear inline on PR diffs.
|
||
|
||
Threshold semantics
|
||
-------------------
|
||
Any FATAL above *max_fatal* marks the run as failed. Any ERROR
|
||
above *max_errors* (when supplied) marks the run as failed.
|
||
ERRORs below the threshold are still reported in the issue list
|
||
so reviewers see them; they just do not fail the build. The
|
||
default `max_errors=None` treats ERRORs as report-only, which
|
||
matches the current grandfathered CI policy.
|
||
"""
|
||
from cli.commands._epub_checks import (
|
||
_discover_built_epubs,
|
||
emit_github_annotations,
|
||
run_epubcheck_on,
|
||
)
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
epubs = _discover_built_epubs(repo_root)
|
||
|
||
if not epubs:
|
||
# Not a failure by itself; mirror the existing _run_epub
|
||
# behaviour when no EPUB has been built yet.
|
||
return ValidationRunResult(
|
||
name="epubcheck",
|
||
description="epubcheck (no built EPUBs found under _build/epub-vol*/)",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
all_issues: List[ValidationIssue] = []
|
||
per_volume_counts: Dict[str, Dict[str, int]] = {}
|
||
total_fatal = 0
|
||
total_errors = 0
|
||
|
||
for epub in epubs:
|
||
# Derive a stable volume key from the parent directory name.
|
||
# `_build/epub-vol1/foo.epub` → "vol1"; falls back to the
|
||
# EPUB's stem if the path layout is unexpected.
|
||
vol_key = epub.parent.name.replace("epub-", "") or epub.stem
|
||
|
||
epub_issues, counts = run_epubcheck_on(epub, repo_root=repo_root)
|
||
emit_github_annotations(epub_issues)
|
||
|
||
per_volume_counts[vol_key] = {
|
||
"FATAL": counts.get("FATAL", 0),
|
||
"ERROR": counts.get("ERROR", 0),
|
||
"WARNING": counts.get("WARNING", 0),
|
||
}
|
||
total_fatal += counts.get("FATAL", 0)
|
||
total_errors += counts.get("ERROR", 0)
|
||
|
||
for e in epub_issues:
|
||
all_issues.append(ValidationIssue(
|
||
file=e.file,
|
||
line=e.line,
|
||
code=e.code,
|
||
message=e.message,
|
||
# Map FATAL to "error" for ValidationIssue (which only
|
||
# has error/warning/info). The code field and message
|
||
# preserve the FATAL distinction for the summary line
|
||
# below so the user sees FATAL vs ERROR counts.
|
||
severity="error" if e.severity in ("fatal", "error") else e.severity,
|
||
))
|
||
|
||
# --- Ratchet / baseline handling --------------------------------
|
||
# The baseline is per-volume, per-severity. Semantics:
|
||
# current == baseline → pass silently
|
||
# current < baseline → pass; print a hint that the baseline
|
||
# can be tightened (don't auto-update
|
||
# without --update-baseline to avoid
|
||
# silently lowering the bar).
|
||
# current > baseline → fail; emit a synthetic issue with the
|
||
# per-volume delta so reviewers see what
|
||
# regressed.
|
||
baseline_issues = self._check_baseline(
|
||
baseline_path=baseline_path,
|
||
update_baseline=update_baseline,
|
||
per_volume_counts=per_volume_counts,
|
||
)
|
||
all_issues.extend(baseline_issues)
|
||
|
||
# Threshold enforcement (applied only when the ratchet is not in
|
||
# use — supplying --baseline tells the tool to use the ratchet
|
||
# instead of flat thresholds).
|
||
if baseline_path is None:
|
||
if total_fatal > max_fatal:
|
||
all_issues.append(ValidationIssue(
|
||
file="(epubcheck)",
|
||
line=0,
|
||
code="EPUBCHECK-FATAL-THRESHOLD",
|
||
message=(
|
||
f"FATAL count {total_fatal} exceeds threshold "
|
||
f"{max_fatal} — Kindle / ClearView will reject this EPUB."
|
||
),
|
||
severity="error",
|
||
))
|
||
if max_errors is not None and total_errors > max_errors:
|
||
all_issues.append(ValidationIssue(
|
||
file="(epubcheck)",
|
||
line=0,
|
||
code="EPUBCHECK-ERROR-THRESHOLD",
|
||
message=(
|
||
f"ERROR count {total_errors} exceeds threshold "
|
||
f"{max_errors}. Tighten or fix the underlying RSC-* issues."
|
||
),
|
||
severity="error",
|
||
))
|
||
|
||
# Build the description so the user sees the severity summary even
|
||
# when all issues are warnings/info.
|
||
desc = (
|
||
f"epubcheck ({len(epubs)} EPUB(s); "
|
||
f"{total_fatal} FATAL, {total_errors} ERROR)"
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="epubcheck",
|
||
description=desc,
|
||
files_checked=len(epubs),
|
||
issues=all_issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# PDF: built-artifact verification (pdftotext cross-ref scan)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_pdf_verify(
|
||
self,
|
||
root: Path,
|
||
*,
|
||
vol1: bool = False,
|
||
vol2: bool = False,
|
||
log_path: Optional[str] = None,
|
||
) -> ValidationRunResult:
|
||
from cli.commands._pdf_checks import format_checklist, verify_volume_pdf
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
quarto_dir = repo_root / "book" / "quarto"
|
||
log = Path(log_path) if log_path else None
|
||
|
||
volumes: List[str] = []
|
||
if vol1:
|
||
volumes.append("vol1")
|
||
if vol2:
|
||
volumes.append("vol2")
|
||
if not volumes:
|
||
volumes = ["vol1", "vol2"]
|
||
|
||
issues: List[ValidationIssue] = []
|
||
checked = 0
|
||
for vol in volumes:
|
||
result = verify_volume_pdf(quarto_dir, vol, log_path=log)
|
||
checked += 1
|
||
if not result.ok:
|
||
for issue in result.issues:
|
||
issues.append(ValidationIssue(
|
||
file=str(result.pdf_path.relative_to(repo_root)),
|
||
line=0,
|
||
code=issue.code,
|
||
message=issue.message,
|
||
severity="error",
|
||
context=f"count={issue.count}" if issue.count > 1 else "",
|
||
))
|
||
# Surface checklist summary as context for the first failure
|
||
if issues:
|
||
issues[-1].context = format_checklist(result)[:300]
|
||
|
||
return ValidationRunResult(
|
||
name="pdf-verify",
|
||
description=f"Built PDF verification ({', '.join(volumes)})",
|
||
files_checked=checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_pdf_numbering(
|
||
self,
|
||
root: Path,
|
||
*,
|
||
vol1: bool = False,
|
||
vol2: bool = False,
|
||
) -> ValidationRunResult:
|
||
from cli.commands._pdf_checks import default_pdf_path, scan_pdf_numbering
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
quarto_dir = repo_root / "book" / "quarto"
|
||
|
||
volumes: List[str] = []
|
||
if vol1:
|
||
volumes.append("vol1")
|
||
if vol2:
|
||
volumes.append("vol2")
|
||
if not volumes:
|
||
volumes = ["vol1", "vol2"]
|
||
|
||
issues: List[ValidationIssue] = []
|
||
checked = 0
|
||
for vol in volumes:
|
||
pdf_path = default_pdf_path(quarto_dir, vol)
|
||
checked += 1
|
||
for issue in scan_pdf_numbering(pdf_path):
|
||
issues.append(ValidationIssue(
|
||
file=str(pdf_path.relative_to(repo_root))
|
||
if pdf_path.is_absolute()
|
||
else str(pdf_path),
|
||
line=0,
|
||
code=issue.code,
|
||
message=issue.message,
|
||
severity="error",
|
||
context=f"count={issue.count}" if issue.count > 1 else "",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="pdf-numbering",
|
||
description=f"Built PDF object-numbering regression scan ({', '.join(volumes)})",
|
||
files_checked=checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_pdf_table_spacing(
|
||
self,
|
||
root: Path,
|
||
*,
|
||
vol1: bool = False,
|
||
vol2: bool = False,
|
||
) -> ValidationRunResult:
|
||
from cli.commands._pdf_checks import default_pdf_path, scan_table_prose_spacing
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
quarto_dir = repo_root / "book" / "quarto"
|
||
|
||
volumes: List[str] = []
|
||
if vol1:
|
||
volumes.append("vol1")
|
||
if vol2:
|
||
volumes.append("vol2")
|
||
if not volumes:
|
||
volumes = ["vol1", "vol2"]
|
||
|
||
issues: List[ValidationIssue] = []
|
||
checked = 0
|
||
for vol in volumes:
|
||
pdf_path = default_pdf_path(quarto_dir, vol)
|
||
checked += 1
|
||
for issue in scan_table_prose_spacing(pdf_path):
|
||
issues.append(ValidationIssue(
|
||
file=str(pdf_path.relative_to(repo_root))
|
||
if pdf_path.is_absolute()
|
||
else str(pdf_path),
|
||
line=0,
|
||
code=issue.code,
|
||
message=issue.message,
|
||
severity=issue.severity,
|
||
context=f"count={issue.count}" if issue.count > 1 else "",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="pdf-table-spacing",
|
||
description=f"Rendered PDF table/prose spacing scan ({', '.join(volumes)})",
|
||
files_checked=checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Registry: constants → registry migration gates
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_registry_sources(self, root: Path) -> ValidationRunResult:
|
||
from cli.commands._registry_checks import check_registry_sources, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
repo = repo_root_from_here()
|
||
qmd_files = self._qmd_files(root)
|
||
raw = check_registry_sources(repo, paths=qmd_files)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="registry-sources",
|
||
description=f"QMD registry source scan ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_registry_tests(self, root: Path) -> ValidationRunResult:
|
||
from cli.commands._registry_checks import run_registry_pytest, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
raw = run_registry_pytest(repo_root_from_here())
|
||
issues = [
|
||
ValidationIssue(file=i.file, line=i.line, code=i.code, message=i.message, severity=i.severity)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="registry-tests",
|
||
description="mlsysim registry pytest gates",
|
||
files_checked=5,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_registry_appendix(self, root: Path) -> ValidationRunResult:
|
||
from cli.commands._registry_checks import verify_appendix_lego, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
raw = verify_appendix_lego(repo_root_from_here())
|
||
issues = [
|
||
ValidationIssue(file=i.file, line=i.line, code=i.code, message=i.message, severity=i.severity)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="registry-appendix",
|
||
description="Appendix LEGO verify against live registry",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_registry_anchors(self, root: Path) -> ValidationRunResult:
|
||
from cli.commands._registry_checks import verify_paper_anchors, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
raw = verify_paper_anchors(repo_root_from_here())
|
||
issues = [
|
||
ValidationIssue(file=i.file, line=i.line, code=i.code, message=i.message, severity=i.severity)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="registry-anchors",
|
||
description="Paper anchor validation",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_registry_yaml_pending(self, root: Path) -> ValidationRunResult:
|
||
from cli.commands._registry_checks import check_yaml_pending, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
raw = check_yaml_pending(repo_root_from_here())
|
||
issues = [
|
||
ValidationIssue(file=i.file, line=i.line, code=i.code, message=i.message, severity=i.severity)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="registry-yaml-pending",
|
||
description="Audit YAML should_change=true count",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Content tree: require the canonical two-volume frontmatter shape.
|
||
# ------------------------------------------------------------------
|
||
|
||
# Required paths under contents/ so scripts can rely on the volume-local
|
||
# notation files and the shared top-level frontmatter tree.
|
||
CONTENT_TREE_REQUIRED: List[tuple] = [
|
||
("frontmatter", True), # (path relative to contents, is_dir)
|
||
("vol1/frontmatter", True),
|
||
("vol1/frontmatter/notation.qmd", False),
|
||
("vol2/frontmatter", True),
|
||
("vol2/frontmatter/notation.qmd", False),
|
||
]
|
||
|
||
def _run_content_tree(self, root: Path) -> ValidationRunResult:
|
||
"""Ensure contents/ has the expected release-time volume structure."""
|
||
t0 = time.time()
|
||
# Resolve to contents dir: root may be contents, or contents/vol1, or contents/vol2
|
||
if root.name in ("vol1", "vol2") and root.parent.name == "contents":
|
||
contents_dir = root.parent
|
||
else:
|
||
contents_dir = root
|
||
if not (contents_dir / "vol1").is_dir() or not (contents_dir / "vol2").is_dir():
|
||
# Not the book contents root; skip (e.g. user passed a chapter path)
|
||
return ValidationRunResult(
|
||
name="content-tree",
|
||
description="Content tree (shared/frontmatter required)",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
issues: List[ValidationIssue] = []
|
||
for rel, is_dir in self.CONTENT_TREE_REQUIRED:
|
||
path = contents_dir / rel
|
||
if is_dir:
|
||
if not path.is_dir():
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=str(path),
|
||
line=0,
|
||
code="content-tree",
|
||
message=f"Required directory missing: contents/{rel}",
|
||
severity="error",
|
||
)
|
||
)
|
||
else:
|
||
if not path.is_file():
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=str(path),
|
||
line=0,
|
||
code="content-tree",
|
||
message=f"Required file missing: contents/{rel}",
|
||
severity="error",
|
||
)
|
||
)
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
return ValidationRunResult(
|
||
name="content-tree",
|
||
description="Content tree (two-volume frontmatter required)",
|
||
files_checked=len(self.CONTENT_TREE_REQUIRED),
|
||
issues=issues,
|
||
elapsed_ms=elapsed,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Source citation validation (native audit.checks.source_citations)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_sources(self, root: Path, ns: Optional[argparse.Namespace] = None) -> ValidationRunResult:
|
||
"""Validate source citation patterns via `audit.checks.source_citations`."""
|
||
if ns is not None and getattr(ns, "files", None):
|
||
file_paths: list[Path] = []
|
||
for raw in ns.files:
|
||
p = Path(raw)
|
||
if not p.is_absolute():
|
||
p = self.config_manager.root_dir / p
|
||
if p.is_file():
|
||
file_paths.append(p)
|
||
if file_paths:
|
||
return self._run_audit_check_files(
|
||
file_paths,
|
||
"audit.checks.source_citations",
|
||
"sources",
|
||
"Source citation validation",
|
||
)
|
||
return self._run_audit_check(
|
||
root,
|
||
"audit.checks.source_citations",
|
||
"sources",
|
||
"Source citation validation",
|
||
)
|
||
|
||
def _run_check_references(self, root: Path, ns: Optional[argparse.Namespace] = None) -> ValidationRunResult:
|
||
"""Validate .bib references against academic DBs (native implementation)."""
|
||
repo_root = self.config_manager.book_dir.parent.parent
|
||
if getattr(ns, "refs_file", None):
|
||
bib_paths = [Path(f) if Path(f).is_absolute() else repo_root / f for f in ns.refs_file]
|
||
else:
|
||
bib_paths = [repo_root / p for p in reference_check.DEFAULT_BIB_REL_PATHS]
|
||
output_path = Path(ns.refs_output) if getattr(ns, "refs_output", None) else None
|
||
limit = getattr(ns, "refs_limit", None)
|
||
skip_verified = getattr(ns, "refs_skip_verified", False)
|
||
thorough = getattr(ns, "refs_thorough", False)
|
||
cache_path = getattr(ns, "refs_cache", None)
|
||
if cache_path is not None:
|
||
cache_path = Path(cache_path) if Path(cache_path).is_absolute() else repo_root / cache_path
|
||
else:
|
||
cache_path = repo_root / ".references_verified.json"
|
||
|
||
only_keys: Optional[List[str]] = None
|
||
only_from_report = getattr(ns, "refs_only_from_report", None)
|
||
only_keys_file = getattr(ns, "refs_only_keys_file", None)
|
||
if only_from_report:
|
||
report_path = Path(only_from_report) if Path(only_from_report).is_absolute() else repo_root / only_from_report
|
||
if report_path.exists():
|
||
only_keys = reference_check.parse_report_keys(report_path)
|
||
else:
|
||
console.print(f"[red]Report not found: {report_path}[/red]")
|
||
return ValidationRunResult(name="references", description="Bibliography vs academic DBs (hallucinator)", files_checked=0, issues=[ValidationIssue(file=str(report_path), line=0, code="references", message=f"Report not found: {report_path}", severity="error")], elapsed_ms=0)
|
||
elif only_keys_file:
|
||
keys_path = Path(only_keys_file) if Path(only_keys_file).is_absolute() else repo_root / only_keys_file
|
||
if keys_path.exists():
|
||
only_keys = [line.strip() for line in keys_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||
else:
|
||
console.print(f"[red]Keys file not found: {keys_path}[/red]")
|
||
return ValidationRunResult(name="references", description="Bibliography vs academic DBs (hallucinator)", files_checked=0, issues=[ValidationIssue(file=str(keys_path), line=0, code="references", message=f"Keys file not found: {keys_path}", severity="error")], elapsed_ms=0)
|
||
|
||
passed, elapsed_ms, issue_dicts, files_checked = reference_check.run(
|
||
bib_paths,
|
||
output_path=output_path,
|
||
limit=limit,
|
||
dedupe=True,
|
||
resilient=True,
|
||
console=console,
|
||
cache_path=cache_path,
|
||
skip_verified=skip_verified,
|
||
thorough=thorough,
|
||
only_keys=only_keys,
|
||
)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=d["file"],
|
||
line=d["line"],
|
||
code=d["code"],
|
||
message=d["message"],
|
||
severity=d.get("severity", "error"),
|
||
)
|
||
for d in issue_dicts
|
||
]
|
||
return ValidationRunResult(
|
||
name="references",
|
||
description="Bibliography vs academic DBs (hallucinator)",
|
||
files_checked=files_checked,
|
||
issues=issues,
|
||
elapsed_ms=elapsed_ms,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Shared helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _line_for_token(self, content: str, token: str) -> int:
|
||
index = content.find(token)
|
||
if index < 0:
|
||
return 1
|
||
return content[:index].count("\n") + 1
|
||
|
||
def _print_human_summary(self, summary: Dict[str, Any], verbose: bool = True) -> None:
|
||
runs = summary["runs"]
|
||
total = summary["total_issues"]
|
||
status = summary["status"]
|
||
|
||
# On success, stay silent — pre-commit shows "Passed" and direct
|
||
# callers see exit code 0.
|
||
if total == 0:
|
||
return
|
||
|
||
# Show the summary table when there are issues
|
||
table = Table(show_header=True, header_style="bold cyan", box=None)
|
||
table.add_column("Check", style="cyan")
|
||
table.add_column("Files", style="dim")
|
||
table.add_column("Issues", style="yellow")
|
||
table.add_column("Elapsed", style="dim")
|
||
table.add_column("Status", style="white")
|
||
for run in runs:
|
||
table.add_row(
|
||
run["name"],
|
||
str(run["files_checked"]),
|
||
str(run["issue_count"]),
|
||
f'{run["elapsed_ms"]}ms',
|
||
"[green]PASS[/green]" if run["passed"] else "[red]FAIL[/red]",
|
||
)
|
||
console.print(Panel(table, title="Binder Check Summary", border_style="cyan"))
|
||
|
||
# Count errors vs warnings across all runs
|
||
total_errors = 0
|
||
total_warnings = 0
|
||
for run in runs:
|
||
for issue in run["issues"]:
|
||
if issue["severity"] == "error":
|
||
total_errors += 1
|
||
else:
|
||
total_warnings += 1
|
||
|
||
for run in runs:
|
||
if run["issue_count"] == 0:
|
||
continue
|
||
run_errors = sum(1 for i in run["issues"] if i["severity"] == "error")
|
||
run_warnings = run["issue_count"] - run_errors
|
||
parts = []
|
||
if run_errors:
|
||
parts.append(f"{run_errors} error(s)")
|
||
if run_warnings:
|
||
parts.append(f"{run_warnings} warning(s)")
|
||
label = ", ".join(parts)
|
||
color = "bold red" if run_errors else "bold yellow"
|
||
console.print(f"[{color}]{run['name']}[/{color}] ({label})")
|
||
for issue in run["issues"][:30]:
|
||
line = issue["line"]
|
||
file = issue["file"]
|
||
msg = issue["message"]
|
||
code = issue.get("code", "")
|
||
sev = issue["severity"]
|
||
sev_label = "ERROR" if sev == "error" else "WARN"
|
||
sev_style = "red" if sev == "error" else "yellow"
|
||
safe_file = _rich_escape(str(file))
|
||
safe_msg = _rich_escape(str(msg))
|
||
code_label = f" {_rich_escape(f'[{code}]')}" if code else ""
|
||
console.print(f" [{sev_style}]{sev_label}[/{sev_style}] {safe_file}:{line}{code_label} {safe_msg}")
|
||
if verbose and issue.get("context"):
|
||
console.print(f" [dim]source: {_rich_escape(str(issue['context']))}[/dim]")
|
||
if verbose and issue.get("suggestion"):
|
||
console.print(f" [dim]fix: {_rich_escape(str(issue['suggestion']))}[/dim]")
|
||
if run["issue_count"] > 30:
|
||
console.print(f" [dim]... {run['issue_count'] - 30} more[/dim]")
|
||
console.print()
|
||
|
||
parts = []
|
||
if total_errors:
|
||
parts.append(f"{total_errors} error(s)")
|
||
if total_warnings:
|
||
parts.append(f"{total_warnings} warning(s)")
|
||
label = " and ".join(parts)
|
||
console.print(f"[red]Validation failed with {label}.[/red]")
|
||
|
||
def _emit(self, as_json: bool, payload: Dict[str, Any], failed: bool) -> None:
|
||
if as_json:
|
||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||
return
|
||
if failed:
|
||
console.print(f"[red]{payload.get('message', 'Operation failed')}[/red]")
|
||
else:
|
||
console.print(f"[green]{payload.get('message', 'Operation succeeded')}[/green]")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Subprocess helper: only for scripts that wrap external binaries.
|
||
#
|
||
# All pure-Python validators have been migrated to native imports
|
||
# (see _run_table_content, _run_grid_tables, _run_sources,
|
||
# _run_bib_hygiene, _run_image_formats, _run_external_images,
|
||
# _run_unit_tests, _run_notation_consistency). The remaining
|
||
# _delegate_script callers all wrap a non-Python tool:
|
||
# - _run_spelling_prose / _run_spelling_tikz → aspell CLI
|
||
# - _run_math_render_audit → Quarto's `quarto render` pipeline
|
||
# New checks should follow the native pattern unless they
|
||
# genuinely need to invoke an external binary.
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _delegate_script(script_path: Path, args: List[str], run_name: str) -> ValidationRunResult:
|
||
"""Run an external-binary-wrapping script and convert its exit code to a ValidationRunResult."""
|
||
import subprocess
|
||
t0 = time.time()
|
||
cmd = ["python3", str(script_path)] + args
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
if result.returncode == 0:
|
||
return ValidationRunResult(
|
||
name=run_name, description=run_name,
|
||
files_checked=0, issues=[], elapsed_ms=elapsed,
|
||
)
|
||
# Script failed — report its output as a single error
|
||
output = (result.stdout + result.stderr).strip()
|
||
return ValidationRunResult(
|
||
name=run_name, description=run_name,
|
||
files_checked=0, elapsed_ms=elapsed,
|
||
issues=[ValidationIssue(
|
||
file="(script output)", line=0, code=run_name,
|
||
message=output[:500] if output else f"Script exited with code {result.returncode}",
|
||
severity="error",
|
||
)],
|
||
)
|
||
except FileNotFoundError:
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
return ValidationRunResult(
|
||
name=run_name, description=run_name,
|
||
files_checked=0, elapsed_ms=elapsed,
|
||
issues=[ValidationIssue(
|
||
file=str(script_path), line=0, code=run_name,
|
||
message=f"Script not found: {script_path}", severity="error",
|
||
)],
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
return ValidationRunResult(
|
||
name=run_name, description=run_name,
|
||
files_checked=0, elapsed_ms=elapsed,
|
||
issues=[ValidationIssue(
|
||
file=str(script_path), line=0, code=run_name,
|
||
message="Script timed out after 120s", severity="error",
|
||
)],
|
||
)
|
||
|
||
def _run_grid_tables(self, root: Path) -> ValidationRunResult:
|
||
"""Check for grid tables (prefer pipe tables).
|
||
|
||
Native import of book/tools/scripts/utilities/convert_grid_to_pipe_tables.py
|
||
via importlib — no subprocess. The script's `check_grid_tables(content)`
|
||
per-content API is the entry point. The script itself remains
|
||
callable as a standalone CLI for its --fix mode.
|
||
"""
|
||
import importlib.util
|
||
import sys as _sys
|
||
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "utilities" / "convert_grid_to_pipe_tables.py"
|
||
)
|
||
mod_name = "mlsys_convert_grid_to_pipe_tables"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
_sys.modules[mod_name] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
t0 = time.time()
|
||
qmd_files = sorted(root.rglob("*.qmd"))
|
||
issues: List[ValidationIssue] = []
|
||
for qmd in qmd_files:
|
||
try:
|
||
content = qmd.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
grid_hits = mod.check_grid_tables(content)
|
||
except Exception:
|
||
continue
|
||
for line_num, desc in grid_hits:
|
||
try:
|
||
rel = str(qmd.relative_to(root))
|
||
except ValueError:
|
||
rel = str(qmd)
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=line_num, code="grid-table",
|
||
message=desc, severity="error", context="",
|
||
))
|
||
return ValidationRunResult(
|
||
name="grid-tables",
|
||
description="Grid tables (prefer pipe tables)",
|
||
files_checked=len(qmd_files), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_heading_case(self, root: Path) -> ValidationRunResult:
|
||
"""Enforce H1/H2 headline case + H3+ sentence case (MIT Press §10.3.1).
|
||
|
||
Native implementation: imports from cli.commands.headings directly.
|
||
Emits one ValidationIssue per violation with the offending current
|
||
form and the expected sentence-case form.
|
||
"""
|
||
from cli.commands.headings import find_violations
|
||
t0 = time.time()
|
||
qmd_files = [str(f) for f in sorted(root.rglob("*.qmd"))]
|
||
violations = find_violations(qmd_files)
|
||
issues: List[ValidationIssue] = []
|
||
for v in violations:
|
||
rel = v.path.replace(str(root) + "/", "")
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=v.line, code="heading-case",
|
||
message=f"expected: {v.expected.strip()}",
|
||
severity="error", context=v.current.strip(),
|
||
))
|
||
return ValidationRunResult(
|
||
name="heading-case",
|
||
description="H1/H2 headline + H3+ sentence case (§10.3.1)",
|
||
files_checked=len(qmd_files), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
@dataclass(frozen=True)
|
||
class _BibScope:
|
||
name: str
|
||
qmd_prefixes: Tuple[str, ...]
|
||
bib_paths: Tuple[Path, ...]
|
||
|
||
@dataclass(frozen=True)
|
||
class _BibEntryRef:
|
||
key: str
|
||
path: Path
|
||
line: int
|
||
entry_type: str
|
||
|
||
@dataclass(frozen=True)
|
||
class _CitationOccurrence:
|
||
key: str
|
||
path: Path
|
||
line: int
|
||
context: str
|
||
scope: "ValidateCommand._BibScope"
|
||
|
||
_BIB_CITE_RE = re.compile(r"(?<![=,(])\[?@([A-Za-z][\w:.-]*)\b")
|
||
_BIB_ENTRY_RE = re.compile(r"^@(?P<entry_type>\w+)\s*\{\s*(?P<key>[\w:.-]+)\s*,", re.M)
|
||
_BIB_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||
_BIB_SINGLE_LETTER_RE = re.compile(r"^[A-Z](\.[A-Z])*$")
|
||
_BIB_NON_CITE_PREFIXES = (
|
||
"sec-", "fig-", "tbl-", "eq-", "lst-", "exr-", "exm-", "thm-",
|
||
"cor-", "cnj-", "def-", "prp-", "rem-", "prf-", "alg-", "algo-",
|
||
"ch-", "nb-",
|
||
)
|
||
_BIB_FALSE_POSITIVE_KEYS = {
|
||
"media", "keyframes", "import", "supports", "page", "font-face",
|
||
"charset", "namespace", "document",
|
||
"grad", "staticmethod", "classmethod", "property", "abstractmethod",
|
||
"dataclass", "cached_property", "wraps",
|
||
"eecs",
|
||
}
|
||
|
||
def _book_bib_scopes(self) -> List[_BibScope]:
|
||
contents = self.config_manager.book_dir / "contents"
|
||
shared_bib = contents / "references.bib"
|
||
return [
|
||
self._BibScope("vol1", ("contents/vol1/",), (shared_bib,)),
|
||
self._BibScope("vol2", ("contents/vol2/",), (shared_bib,)),
|
||
self._BibScope(
|
||
"book-shared",
|
||
("contents/frontmatter/", "contents/backmatter/"),
|
||
(shared_bib,),
|
||
),
|
||
]
|
||
|
||
def _book_bib_scope_for_qmd(self, qmd_path: Path) -> Optional[_BibScope]:
|
||
try:
|
||
rel = qmd_path.relative_to(self.config_manager.book_dir).as_posix()
|
||
except ValueError:
|
||
return None
|
||
for scope in self._book_bib_scopes():
|
||
if any(rel.startswith(prefix) for prefix in scope.qmd_prefixes):
|
||
return scope
|
||
return None
|
||
|
||
def _book_bib_scope_for_bib(self, bib_path: Path) -> Optional[_BibScope]:
|
||
resolved = bib_path.resolve()
|
||
for scope in self._book_bib_scopes():
|
||
if any(path.resolve() == resolved for path in scope.bib_paths):
|
||
return scope
|
||
return None
|
||
|
||
def _book_qmd_files_for_bib_root(self, root: Path) -> List[Path]:
|
||
"""Return book QMD files relevant to a bibliography check."""
|
||
contents = self.config_manager.book_dir / "contents"
|
||
if root.is_file() and root.suffix == ".qmd":
|
||
return [root] if self._book_bib_scope_for_qmd(root) is not None else []
|
||
|
||
if root.is_file() and root.suffix == ".bib":
|
||
resolved = root.resolve()
|
||
matched_scopes = [
|
||
scope for scope in self._book_bib_scopes()
|
||
if any(path.resolve() == resolved for path in scope.bib_paths)
|
||
]
|
||
if not matched_scopes:
|
||
return []
|
||
prefixes = tuple(sorted({prefix for scope in matched_scopes for prefix in scope.qmd_prefixes}))
|
||
files: List[Path] = []
|
||
for prefix in prefixes:
|
||
rel_dir = prefix.removeprefix("contents/").rstrip("/")
|
||
qroot = contents / rel_dir
|
||
if qroot.exists():
|
||
files.extend(self._qmd_files(qroot))
|
||
return sorted({f for f in files if self._book_bib_scope_for_qmd(f) is not None})
|
||
|
||
if root.exists():
|
||
files = self._qmd_files(root)
|
||
return [f for f in files if self._book_bib_scope_for_qmd(f) is not None]
|
||
|
||
return []
|
||
|
||
def _book_bib_files_for_root(self, root: Path) -> List[Path]:
|
||
if root.is_file() and root.suffix == ".bib":
|
||
return [root]
|
||
|
||
selected: Set[Path] = set()
|
||
for qmd in self._book_qmd_files_for_bib_root(root):
|
||
scope = self._book_bib_scope_for_qmd(qmd)
|
||
if scope is not None:
|
||
selected.update(path for path in scope.bib_paths if path.exists())
|
||
if selected:
|
||
return sorted(selected)
|
||
|
||
bibs: Set[Path] = set()
|
||
for scope in self._book_bib_scopes():
|
||
for bib in scope.bib_paths:
|
||
if not bib.exists():
|
||
continue
|
||
try:
|
||
if bib == root or bib.is_relative_to(root):
|
||
bibs.add(bib)
|
||
except ValueError:
|
||
continue
|
||
return sorted(bibs)
|
||
|
||
@classmethod
|
||
def _bib_should_skip_cite_key(cls, key: str) -> bool:
|
||
key = key.rstrip(".,;:)")
|
||
return (
|
||
not key
|
||
or key.lower().startswith(cls._BIB_NON_CITE_PREFIXES)
|
||
or key in cls._BIB_FALSE_POSITIVE_KEYS
|
||
or bool(cls._BIB_SINGLE_LETTER_RE.match(key))
|
||
or bool(re.match(r"^\d+\.\d+", key))
|
||
)
|
||
|
||
@staticmethod
|
||
def _strip_bib_inline_protected(line: str) -> str:
|
||
line = re.sub(r"`[^`]*`", "", line)
|
||
line = re.sub(r"<!--.*?-->", "", line)
|
||
return line
|
||
|
||
def _citation_occurrences_for_qmd(self, path: Path) -> List[_CitationOccurrence]:
|
||
scope = self._book_bib_scope_for_qmd(path)
|
||
if scope is None:
|
||
return []
|
||
|
||
lines = self._read_text(path).splitlines()
|
||
occurrences: List[ValidateCommand._CitationOccurrence] = []
|
||
in_yaml = bool(lines and lines[0].strip() == "---")
|
||
in_fence = False
|
||
in_html_comment = False
|
||
in_raw_block: Optional[str] = None
|
||
|
||
for line_no, line in enumerate(lines, start=1):
|
||
stripped = line.strip()
|
||
|
||
if line_no > 1 and in_yaml and stripped == "---":
|
||
in_yaml = False
|
||
continue
|
||
if in_yaml:
|
||
continue
|
||
|
||
if in_html_comment:
|
||
if "-->" in line:
|
||
in_html_comment = False
|
||
continue
|
||
if "<!--" in line and "-->" not in line:
|
||
in_html_comment = True
|
||
continue
|
||
|
||
if in_raw_block:
|
||
if f"</{in_raw_block}>" in line.lower():
|
||
in_raw_block = None
|
||
continue
|
||
lower = line.lower()
|
||
if "<style" in lower and "</style>" not in lower:
|
||
in_raw_block = "style"
|
||
continue
|
||
if "<script" in lower and "</script>" not in lower:
|
||
in_raw_block = "script"
|
||
continue
|
||
|
||
if self._BIB_FENCE_RE.match(line):
|
||
in_fence = not in_fence
|
||
continue
|
||
if in_fence:
|
||
continue
|
||
|
||
scan_line = self._strip_bib_inline_protected(line)
|
||
for match in self._BIB_CITE_RE.finditer(scan_line):
|
||
key = match.group(1).rstrip(".,;:)")
|
||
if self._bib_should_skip_cite_key(key):
|
||
continue
|
||
occurrences.append(self._CitationOccurrence(
|
||
key=key,
|
||
path=path,
|
||
line=line_no,
|
||
context=line.strip()[:220],
|
||
scope=scope,
|
||
))
|
||
return occurrences
|
||
|
||
def _parse_bib_entries_for_integrity(self, bib_path: Path) -> Dict[str, _BibEntryRef]:
|
||
try:
|
||
text = self._read_text(bib_path)
|
||
except OSError:
|
||
return {}
|
||
entries: Dict[str, ValidateCommand._BibEntryRef] = {}
|
||
for match in self._BIB_ENTRY_RE.finditer(text):
|
||
key = match.group("key")
|
||
entries[key] = self._BibEntryRef(
|
||
key=key,
|
||
path=bib_path,
|
||
line=text.count("\n", 0, match.start()) + 1,
|
||
entry_type=match.group("entry_type").lower(),
|
||
)
|
||
return entries
|
||
|
||
def _book_bib_index(self) -> Dict[Path, Dict[str, _BibEntryRef]]:
|
||
index: Dict[Path, Dict[str, ValidateCommand._BibEntryRef]] = {}
|
||
for scope in self._book_bib_scopes():
|
||
for bib_path in scope.bib_paths:
|
||
if bib_path.exists() and bib_path not in index:
|
||
index[bib_path] = self._parse_bib_entries_for_integrity(bib_path)
|
||
return index
|
||
|
||
def _run_bib_integrity(self, root: Path) -> ValidationRunResult:
|
||
"""Validate citation keys against the shared book bibliography."""
|
||
t0 = time.time()
|
||
qmd_files = self._book_qmd_files_for_bib_root(root)
|
||
bib_index = self._book_bib_index()
|
||
all_bib_locations: Dict[str, List[ValidateCommand._BibEntryRef]] = defaultdict(list)
|
||
for entries in bib_index.values():
|
||
for entry in entries.values():
|
||
all_bib_locations[entry.key].append(entry)
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for qmd in qmd_files:
|
||
for occ in self._citation_occurrences_for_qmd(qmd):
|
||
allowed_entries: Dict[str, ValidateCommand._BibEntryRef] = {}
|
||
for bib_path in occ.scope.bib_paths:
|
||
allowed_entries.update(bib_index.get(bib_path, {}))
|
||
if occ.key in allowed_entries:
|
||
continue
|
||
|
||
rel = self._relative_file(occ.path)
|
||
allowed = ", ".join(self._relative_file(path) for path in occ.scope.bib_paths)
|
||
elsewhere = all_bib_locations.get(occ.key, [])
|
||
if elsewhere:
|
||
locations = ", ".join(
|
||
f"{self._relative_file(entry.path)}:{entry.line}" for entry in elsewhere
|
||
)
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=occ.line,
|
||
code="bib_scope_violation",
|
||
message=(
|
||
f"Citation @{occ.key} is not in the allowed bibliography "
|
||
f"for {occ.scope.name}; it exists in {locations}."
|
||
),
|
||
severity="error",
|
||
context=occ.context,
|
||
suggestion=(
|
||
f"Copy the reviewed @{occ.key} entry into {allowed}, "
|
||
f"or replace @{occ.key} with a source already present in that volume."
|
||
),
|
||
))
|
||
else:
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=occ.line,
|
||
code="bib_missing_citation",
|
||
message=(
|
||
f"Citation @{occ.key} is missing from the allowed "
|
||
f"bibliography for {occ.scope.name}: {allowed}."
|
||
),
|
||
severity="error",
|
||
context=occ.context,
|
||
suggestion=(
|
||
f"Add a verified @{occ.key} BibTeX entry to {allowed}, "
|
||
f"or replace/remove @{occ.key} if the local claim should use a different source."
|
||
),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="bib-integrity",
|
||
description="Shared book citation-to-BibTeX integrity",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_bib_orphans(self, root: Path) -> ValidationRunResult:
|
||
"""Report shared BibTeX entries that no book QMD cites."""
|
||
t0 = time.time()
|
||
selected_bibs = self._book_bib_files_for_root(root)
|
||
qmd_files = self._book_qmd_files_for_bib_root(root)
|
||
bib_index = self._book_bib_index()
|
||
|
||
cited_by_bib: Dict[Path, Set[str]] = {bib: set() for bib in selected_bibs}
|
||
for qmd in qmd_files:
|
||
for occ in self._citation_occurrences_for_qmd(qmd):
|
||
for bib_path in occ.scope.bib_paths:
|
||
if bib_path in cited_by_bib and occ.key in bib_index.get(bib_path, {}):
|
||
cited_by_bib[bib_path].add(occ.key)
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for bib_path in selected_bibs:
|
||
entries = bib_index.get(bib_path, {})
|
||
cited = cited_by_bib.get(bib_path, set())
|
||
for key in sorted(set(entries) - cited):
|
||
entry = entries[key]
|
||
issues.append(ValidationIssue(
|
||
file=self._relative_file(entry.path),
|
||
line=entry.line,
|
||
code="bib_orphan_entry",
|
||
message=(
|
||
f"@{entry.entry_type}{{{key}}} is not cited by any "
|
||
"book QMD file in the shared book scope."
|
||
),
|
||
severity="warning",
|
||
context=f"@{entry.entry_type}{{{key},",
|
||
suggestion=(
|
||
f"Remove @{key}, cite it where it supports a local claim, "
|
||
"or document it in a canonical-background allowlist before publication."
|
||
),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="bib-orphans",
|
||
description="Unused shared bibliography entries",
|
||
files_checked=len(selected_bibs),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _bib_baseline_key_path(self, path: Path) -> str:
|
||
"""Return the repo-relative path used by bib_lint baseline files."""
|
||
from cli.checks import bib_lint
|
||
|
||
return bib_lint.baseline_file_key(path, self.config_manager.root_dir)
|
||
|
||
@staticmethod
|
||
def _bib_style_suggestion(rule: str, key: str) -> str:
|
||
suggestions = {
|
||
"author-initials-only": (
|
||
f"Verify @{key}'s published byline and expand given names when "
|
||
"publicly attested; keep initials only when the source itself uses initials."
|
||
),
|
||
"article-missing-volume": (
|
||
f"Add the journal volume for @{key}, or verify the entry is genuinely "
|
||
"preprint-style and adjust the metadata accordingly."
|
||
),
|
||
"booktitle-suspect-acronym-only": (
|
||
f"Expand @{key}'s booktitle to the official full venue name plus "
|
||
"the acronym in parentheses when applicable."
|
||
),
|
||
"publisher-not-canonical": (
|
||
f"Verify @{key}'s publisher against the official venue/source. "
|
||
"Use the canonical publisher name, or update the allow-list only after review."
|
||
),
|
||
"title-appears-all-caps": (
|
||
f"Convert @{key}'s title to semantic sentence case and brace only "
|
||
"acronyms, mixed-case systems, and proper nouns."
|
||
),
|
||
"booktitle-appears-all-caps": (
|
||
f"Convert @{key}'s booktitle to the official mixed-case venue name "
|
||
"and include the acronym in parentheses when applicable."
|
||
),
|
||
"title-suspect-titlecase-function": (
|
||
f"Review @{key}'s title for semantic sentence case. Lowercase ordinary "
|
||
"mid-title function words and brace only terms whose capitalization must survive CSL."
|
||
),
|
||
"title-trailing-period": f"Remove the trailing period from @{key}'s title field.",
|
||
"pages-single-hyphen": f"Use a BibTeX page range with double hyphen for @{key}, e.g. 123--145.",
|
||
"pages-has-prefix": f"Remove p./pp. from @{key}'s pages field; keep only the bare range.",
|
||
"doi-with-prefix": f"Store @{key}'s DOI as a bare DOI without https://doi.org/.",
|
||
"journal-abbreviated": f"Spell out @{key}'s journal title using the official full name.",
|
||
"forbidden-field": f"Remove or convert the forbidden field on @{key} per .claude/rules/bib-check.md.",
|
||
}
|
||
return suggestions.get(
|
||
rule,
|
||
f"Fix @{key} per .claude/rules/bib-check.md, then rerun ./book/binder check bib --json.",
|
||
)
|
||
|
||
def _run_bib_hygiene(self, root: Path) -> ValidationRunResult:
|
||
"""Validate .bib files against §5 Bibliography Hygiene schema.
|
||
|
||
Native import of cli.checks.bib_lint — no subprocess. Mirrors the
|
||
original `bib_lint --check` semantics:
|
||
only NEW errors (not in book/tools/bib_lint_baseline.json) block;
|
||
baseline-grandfathered errors are silenced. The compatibility script
|
||
remains callable as a standalone CLI for its --fix / --baseline
|
||
modes.
|
||
"""
|
||
from cli.checks import bib_lint
|
||
|
||
t0 = time.time()
|
||
bib_files = self._bib_files(root)
|
||
if not bib_files:
|
||
return ValidationRunResult(
|
||
name="bib-hygiene", description="bib-hygiene",
|
||
files_checked=0, issues=[],
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# Baseline (file, entry_key, rule) tuples — these errors are grandfathered.
|
||
try:
|
||
baseline = bib_lint.load_baseline()
|
||
except Exception:
|
||
baseline = set()
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for bib_path in bib_files:
|
||
try:
|
||
text = bib_path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
entries, _ = bib_lint.parse_bib(text)
|
||
except ValueError as exc:
|
||
rel = self._relative_file(bib_path)
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=0, code="parse-error",
|
||
message=f"PARSE ERROR: {exc}", severity="error",
|
||
))
|
||
continue
|
||
|
||
fp_key = self._bib_baseline_key_path(bib_path)
|
||
rel = self._relative_file(bib_path)
|
||
|
||
for entry in entries:
|
||
for v in bib_lint.validate_entry(entry):
|
||
# Mirror bib_lint.py --check semantics exactly:
|
||
# only NEW errors block the gate; warnings and
|
||
# baseline-grandfathered errors are silent. Run the
|
||
# script directly with --report to see warnings.
|
||
if v.severity != "error":
|
||
continue
|
||
if (fp_key, v.entry_key, v.rule) in baseline:
|
||
continue
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=v.entry_line,
|
||
code=v.rule,
|
||
message=f"@{entry.entry_type}{{{v.entry_key}}} — {v.message}",
|
||
severity="error",
|
||
context="",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="bib-hygiene", description="bib-hygiene (§5)",
|
||
files_checked=len(bib_files), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_bib_style(self, root: Path) -> ValidationRunResult:
|
||
"""Fail on new warning/info BibTeX metadata debt.
|
||
|
||
This is intentionally separate from `bib hygiene`: hygiene is the hard
|
||
schema/error gate, while style is a ratchet over known metadata debt
|
||
such as suspicious title case, initials-only authors, missing journal
|
||
volumes, acronym-only venues, and non-canonical publisher names.
|
||
"""
|
||
from cli.checks import bib_lint
|
||
|
||
t0 = time.time()
|
||
bib_files = self._bib_files(root)
|
||
if not bib_files:
|
||
return ValidationRunResult(
|
||
name="bib-style",
|
||
description="bib-style",
|
||
files_checked=0,
|
||
issues=[],
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
try:
|
||
baseline = bib_lint.load_style_baseline()
|
||
except Exception:
|
||
baseline = set()
|
||
|
||
issues: List[ValidationIssue] = []
|
||
style_severities = getattr(bib_lint, "STYLE_BASELINE_SEVERITIES", {"warning", "info"})
|
||
for bib_path in bib_files:
|
||
try:
|
||
text = bib_path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
entries, _ = bib_lint.parse_bib(text)
|
||
except ValueError as exc:
|
||
rel = self._relative_file(bib_path)
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=0,
|
||
code="bib_style_parse_error",
|
||
message=f"PARSE ERROR: {exc}",
|
||
severity="error",
|
||
suggestion="Fix the BibTeX syntax before checking metadata style.",
|
||
))
|
||
continue
|
||
|
||
fp_key = self._bib_baseline_key_path(bib_path)
|
||
rel = self._relative_file(bib_path)
|
||
for entry in entries:
|
||
for v in bib_lint.validate_entry(entry):
|
||
if v.severity not in style_severities:
|
||
continue
|
||
if (fp_key, v.entry_key, v.rule) in baseline:
|
||
continue
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=v.entry_line,
|
||
code=f"bib_style_{v.rule}",
|
||
message=(
|
||
f"New bibliography style issue: @{entry.entry_type}{{{v.entry_key}}} "
|
||
f"— {v.message}. Current debt is grandfathered in "
|
||
"book/tools/bib_lint_style_baseline.json; new issues must be fixed "
|
||
"or explicitly accepted by updating that baseline after review."
|
||
),
|
||
severity="error",
|
||
context=f"@{entry.entry_type}{{{v.entry_key},",
|
||
suggestion=self._bib_style_suggestion(v.rule, v.entry_key),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="bib-style",
|
||
description="BibTeX warning/info metadata style ratchet (§5)",
|
||
files_checked=len(bib_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_bib_key_content(self, root: Path, errors_only: bool = False) -> ValidationRunResult:
|
||
"""Verify bib key surname/year tokens match the entry's actual
|
||
author and year fields. With errors_only=True, emit only the
|
||
year-mismatch ERRORs (skip surname-mismatch WARNINGs) so the
|
||
key-year scope can gate pre-commit without the intentional
|
||
project-name-key warnings blocking every commit.
|
||
|
||
The bib **key** is a contract: `surname[firstauthor]year[topicword]`.
|
||
When the key drifts from the entry body, citing the key in prose
|
||
renders text that contradicts the key. No existing check catches
|
||
this — `citations` confirms the key resolves to *some* entry,
|
||
`bib-hygiene` confirms required fields are present, but neither
|
||
compares the key prefix against the entry's content.
|
||
|
||
Real failure observed (May 2026): vol2 had
|
||
|
||
@article{shewhart1931economic,
|
||
author = {Carroll, Alison R. and Johnson, David P.},
|
||
year = {2020},
|
||
title = {Know It When You See It: ...},
|
||
...
|
||
}
|
||
|
||
Citing [@shewhart1931economic] rendered as "(Carroll and Johnson,
|
||
2020)" — silently misleading.
|
||
|
||
Heuristics to reduce false positives:
|
||
|
||
- Only check keys that follow the canonical surname-year convention
|
||
(`^[a-z]+\\d{4}`). Keys like `the_pile`, `deepbench_github`, or
|
||
`flexpoint_2017` (which use a separator) are intentional non-author
|
||
identifiers and skipped.
|
||
- Surname check passes if the key prefix is a prefix of *any* author's
|
||
surname, not just the first. Tolerates legitimate re-keys like
|
||
`harlap2018pipedream` where Harlap is the second author.
|
||
- Skip entries whose first author looks corporate / institutional
|
||
(Google, DeepMind, Intel, etc.).
|
||
- Surname comparison folds diacritics and strips non-alpha so
|
||
`hebert2018multicalibration` matches `H{\'e}bert-Johnson`.
|
||
"""
|
||
import unicodedata
|
||
|
||
def fold(s: str) -> str:
|
||
norm = unicodedata.normalize("NFKD", s.lower())
|
||
return "".join(c for c in norm if not unicodedata.combining(c))
|
||
|
||
# Single-token names that almost always indicate a corporate /
|
||
# institutional author rather than a person. When one of these
|
||
# appears as a "surname" in the first or second position of the
|
||
# author field, the entry is skipped — the bib key uses a topic
|
||
# rather than a personal surname.
|
||
CORPORATE_AUTHOR_TOKENS = {
|
||
"google", "deepmind", "microsoft", "openai", "anthropic", "meta",
|
||
"facebook", "amazon", "aws", "nvidia", "intel", "apple", "ibm",
|
||
"huggingface", "kaggle", "baidu", "alibaba", "tencent", "samsung",
|
||
"qualcomm", "arm", "tesla", "siemens", "epoch", "stability",
|
||
"mistral", "cohere", "deepseek", "moonshot", "perplexity",
|
||
"cerebras", "graphcore", "groq", "sambanova", "mlcommons",
|
||
"discord", "github", "gitlab", "wikipedia", "stackoverflow",
|
||
"openreview", "arxiv", "papers", "pytorch", "tensorflow", "jax",
|
||
"onnx", "kubernetes", "docker",
|
||
"corporation", "inc", "ltd", "llc", "research", "team", "labs",
|
||
"foundation", "consortium", "association", "council", "committee",
|
||
"group", "network", "alliance", "initiative", "project", "society",
|
||
"european", "national", "international", "world", "united",
|
||
"systems", "contributors", "developers", "authors", "community",
|
||
"staff", "editors", "board", "office", "department", "ministry",
|
||
"agency", "bureau", "commission", "service", "services",
|
||
}
|
||
|
||
# Standards/specification keys are `body + standard-number + year +
|
||
# topic` (e.g. iso140402006lca, isoiec420012023ai, ieee8021ar2018), so
|
||
# the surname-year convention regex grabs the *standard number* as the
|
||
# "year" (iso14040 -> "1404"). These are not personal surname-year keys;
|
||
# skip them entirely. (added 2026-06-15 after the references.bib
|
||
# validation pass false-flagged the ISO/IEC/IEEE standards entries.)
|
||
STANDARDS_KEY_PREFIXES = {
|
||
"iso", "iec", "isoiec", "ieee", "ansi", "etsi", "itu", "ituT",
|
||
"rfc", "nist", "jedec", "gsma", "omg", "din", "fips", "w3c", "ietf",
|
||
}
|
||
|
||
def author_list_surnames(author_field: str) -> List[str]:
|
||
if not author_field:
|
||
return []
|
||
# Strip common LaTeX accent commands so `H\'ebert` → `Hebert`.
|
||
a = re.sub(r"\\['\"`^~]\{?(\w)\}?", r"\1", author_field)
|
||
a = a.strip().strip("{}").strip()
|
||
authors = re.split(r"\s+and\s+", a)
|
||
surnames: List[str] = []
|
||
for raw in authors:
|
||
ent = raw.strip().strip("{}").strip()
|
||
if not ent:
|
||
continue
|
||
if "," in ent:
|
||
surname = ent.split(",", 1)[0].strip().strip("{}").strip()
|
||
else:
|
||
parts = ent.split()
|
||
surname = parts[-1] if parts else ""
|
||
if surname:
|
||
surnames.append(surname)
|
||
for s in surnames[:2]:
|
||
if fold(s) in CORPORATE_AUTHOR_TOKENS:
|
||
return []
|
||
return surnames
|
||
|
||
t0 = time.time()
|
||
bib_files = self._bib_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
entry_header_re = re.compile(r"^@(\w+)\s*\{\s*([\w:_-]+)\s*,", re.M)
|
||
# Surname-year canonical key: lowercase letters then 4-digit year.
|
||
convention_re = re.compile(r"^([a-z]+)(\d{4})")
|
||
# Tolerates one level of nested braces in field values.
|
||
field_re = re.compile(
|
||
r"\b(\w+)\s*=\s*"
|
||
r"(?:\{((?:[^{}]|\{[^{}]*\})*)\}|\"([^\"]*)\")",
|
||
re.S,
|
||
)
|
||
|
||
for bib_path in bib_files:
|
||
try:
|
||
text = bib_path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
rel = self._relative_file(bib_path)
|
||
except ValueError:
|
||
rel = str(bib_path)
|
||
|
||
for hm in entry_header_re.finditer(text):
|
||
entry_type = hm.group(1).lower()
|
||
key = hm.group(2)
|
||
line_no = text[:hm.start()].count("\n") + 1
|
||
|
||
# Walk balanced braces from the entry's opening { to its
|
||
# matching }. Skips escaped braces inside field values.
|
||
open_brace = text.find("{", hm.start())
|
||
if open_brace < 0:
|
||
continue
|
||
depth = 0
|
||
i = open_brace
|
||
n = len(text)
|
||
while i < n:
|
||
ch = text[i]
|
||
if ch == "\\" and i + 1 < n:
|
||
i += 2
|
||
continue
|
||
if ch == "{":
|
||
depth += 1
|
||
elif ch == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
i += 1
|
||
body = text[open_brace + 1 : i]
|
||
|
||
cm = convention_re.match(key.lower())
|
||
if not cm:
|
||
continue
|
||
key_surname = cm.group(1)
|
||
key_year = cm.group(2)
|
||
|
||
# Standards/spec keys are not surname-year keys (the regex
|
||
# mis-reads the standard number as a year). Skip them.
|
||
if key_surname in STANDARDS_KEY_PREFIXES:
|
||
continue
|
||
|
||
fields: Dict[str, str] = {}
|
||
for fm in field_re.finditer(body):
|
||
name = fm.group(1).lower()
|
||
value = fm.group(2) if fm.group(2) is not None else fm.group(3)
|
||
fields[name] = (value or "").strip()
|
||
|
||
author = fields.get("author", "")
|
||
year_field = fields.get("year", "")
|
||
|
||
surnames = author_list_surnames(author)
|
||
|
||
if surnames and not errors_only:
|
||
# Bidirectional prefix match: tolerates both
|
||
# `boroumandasplos2018` (key longer than surname,
|
||
# appended venue) AND `hebert2018multicalibration`
|
||
# (key shorter, only catches the prefix of a hyphenated
|
||
# surname). One direction must match for it to count.
|
||
matched = False
|
||
for s in surnames:
|
||
sa = re.sub(r"[^a-z]", "", fold(s))
|
||
if sa.startswith(key_surname) or key_surname.startswith(sa):
|
||
matched = True
|
||
break
|
||
if not matched:
|
||
first = surnames[0]
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=line_no,
|
||
code="bib_key_surname_mismatch",
|
||
message=(
|
||
f"@{entry_type}{{{key}}} — key surname "
|
||
f"'{key_surname}' matches no author in this "
|
||
f"entry (first author: '{first}'). Citing the "
|
||
f"key in prose renders content that contradicts "
|
||
f"the key. Either rename the key to match an "
|
||
f"actual author, or replace the entry body to "
|
||
f"match the work the key names."
|
||
),
|
||
severity="warning",
|
||
context=f"@{entry_type}{{{key},",
|
||
suggestion=(
|
||
f"Verify @{key}. If the entry body is correct, "
|
||
"rename the citekey to match an actual author and update all citations; "
|
||
"if the citekey is correct, replace the entry body with the work the key names."
|
||
),
|
||
))
|
||
|
||
if year_field and year_field != key_year:
|
||
# Tolerate a one-year gap: papers commonly key from
|
||
# the arXiv preprint year (e.g. 2021) but record the
|
||
# conference proceedings year (e.g. 2022) in the year
|
||
# field. Both attributions are correct; the gap is
|
||
# convention drift, not corruption. Larger gaps almost
|
||
# always indicate real key/content mismatch.
|
||
try:
|
||
gap = abs(int(year_field) - int(key_year))
|
||
except ValueError:
|
||
gap = 99 # unparsable year → flag
|
||
if gap > 1:
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=line_no,
|
||
code="bib_key_year_mismatch",
|
||
message=(
|
||
f"@{entry_type}{{{key}}} — key year {key_year} "
|
||
f"does not match year field {year_field} "
|
||
f"(gap of {gap} years). The key suggests a "
|
||
f"different work than the entry actually "
|
||
f"describes."
|
||
),
|
||
severity="error",
|
||
context=f"@{entry_type}{{{key},",
|
||
suggestion=(
|
||
f"Verify @{key}. If year={year_field} is correct, "
|
||
f"rename the citekey so it does not claim {key_year}; "
|
||
f"if the citekey year {key_year} is correct, update the year field."
|
||
),
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="bib-key-content",
|
||
description="Bib key prefix must match author surname + year (§5)",
|
||
files_checked=len(bib_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_bib_key_year(self, root: Path) -> ValidationRunResult:
|
||
"""Errors-only subset of key-content: key year vs entry year (>1yr gap).
|
||
Pre-commit gate for the silent-citation-corruption class; the
|
||
surname-mismatch warnings stay in the opt-in key-content scope."""
|
||
res = self._run_bib_key_content(root, errors_only=True)
|
||
return ValidationRunResult(
|
||
name="bib-key-year",
|
||
description="Bib key year must match entry year (errors-only gate, §5)",
|
||
files_checked=res.files_checked,
|
||
issues=res.issues,
|
||
elapsed_ms=res.elapsed_ms,
|
||
)
|
||
|
||
def _run_bib_duplicate_keys(self, root: Path) -> ValidationRunResult:
|
||
"""Every citekey must be unique. Two entries sharing a key make every
|
||
`[@key]` citation resolve to whichever entry the parser keeps — silent
|
||
ambiguity that no other check catches (bibtex-tidy only warns)."""
|
||
t0 = time.time()
|
||
bib_files = self._bib_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
header_re = re.compile(r"^@(\w+)\s*\{\s*([^,\s]+)\s*,", re.M)
|
||
for bib_path in bib_files:
|
||
try:
|
||
text = bib_path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
rel = self._relative_file(bib_path)
|
||
except ValueError:
|
||
rel = str(bib_path)
|
||
seen: Dict[str, int] = {}
|
||
for hm in header_re.finditer(text):
|
||
etype = hm.group(1).lower()
|
||
if etype in ("comment", "string", "preamble"):
|
||
continue
|
||
key = hm.group(2)
|
||
line_no = text[:hm.start()].count("\n") + 1
|
||
if key in seen:
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=line_no,
|
||
code="bib_duplicate_key",
|
||
message=(
|
||
f"Duplicate citekey @{key} (first defined at line "
|
||
f"{seen[key]}). Every [@{key}] citation resolves "
|
||
f"ambiguously to one of the two entries. Rename one "
|
||
f"key to be unique and update its citations."
|
||
),
|
||
severity="error",
|
||
context=f"@{etype}{{{key},",
|
||
suggestion=(
|
||
f"Give the second entry a distinct key (e.g. @{key}b "
|
||
f"or @{key}web) and repoint the prose cites that "
|
||
f"belong to it."
|
||
),
|
||
))
|
||
else:
|
||
seen[key] = line_no
|
||
return ValidationRunResult(
|
||
name="bib-duplicate-keys",
|
||
description="Every citekey must be unique across the bibliography",
|
||
files_checked=len(bib_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_bib_links(self, root: Path) -> ValidationRunResult:
|
||
"""DOI/URL liveness for the shared bibliography (MIT Press: confirm all
|
||
URLs). Network-bound and opt-in. 404/410/DNS = error (dead locator);
|
||
403/401 = warning (bot-block, ambiguous); 2xx/3xx = pass. DOIs are
|
||
checked via https://doi.org/<doi>."""
|
||
import ssl, urllib.request, urllib.error, urllib.parse
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
t0 = time.time()
|
||
ua = "MLSysBook-binder-bibcheck/1.0 (mailto:jvijay@gmail.com)"
|
||
ctx = ssl.create_default_context()
|
||
field_re = re.compile(r"\b(doi|url)\s*=\s*\{([^{}]*)\}", re.I)
|
||
header_re = re.compile(r"^@(\w+)\s*\{\s*([^,\s]+)\s*,", re.M)
|
||
|
||
def resolve(u):
|
||
for method in ("HEAD", "GET"):
|
||
try:
|
||
r = urllib.request.urlopen(urllib.request.Request(
|
||
u, method=method, headers={"User-Agent": ua}), timeout=25, context=ctx)
|
||
return r.status, None
|
||
except urllib.error.HTTPError as e:
|
||
# Many servers (and doi.org for some DataCite DOIs, e.g.
|
||
# NIST) answer HEAD with 404/403/405 but GET with 200.
|
||
# Retry GET before trusting a HEAD failure.
|
||
if e.code in (403, 401, 405, 400, 404, 410, 501) and method == "HEAD":
|
||
continue
|
||
return e.code, None
|
||
except Exception as e:
|
||
if method == "GET":
|
||
return None, type(e).__name__
|
||
return None, "error"
|
||
|
||
# collect (key, line, kind, target_url) for the shared book bib only
|
||
targets = []
|
||
shared = root / "book" / "quarto" / "contents" / "references.bib"
|
||
bib_files = [shared] if shared.exists() else self._bib_files(root)
|
||
for bib_path in bib_files:
|
||
try:
|
||
text = bib_path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
try:
|
||
rel = self._relative_file(bib_path)
|
||
except ValueError:
|
||
rel = str(bib_path)
|
||
# map each entry's char span to (key, line)
|
||
entries = [(m.group(2), text[:m.start()].count("\n") + 1, m.start())
|
||
for m in header_re.finditer(text)]
|
||
for fm in field_re.finditer(text):
|
||
kind = fm.group(1).lower()
|
||
val = fm.group(2).strip()
|
||
if not val:
|
||
continue
|
||
url = ("https://doi.org/" + re.sub(r"^https?://(dx\.)?doi\.org/", "", val, flags=re.I)
|
||
if kind == "doi" else val)
|
||
pos = fm.start()
|
||
key, line = "?", text[:pos].count("\n") + 1
|
||
for k, ln, st in entries:
|
||
if st <= pos:
|
||
key, line = k, ln
|
||
else:
|
||
break
|
||
targets.append((rel, key, line, kind, url))
|
||
|
||
issues: List[ValidationIssue] = []
|
||
def check(t):
|
||
rel, key, line, kind, url = t
|
||
st, err = resolve(url)
|
||
return (rel, key, line, kind, url, st, err)
|
||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||
for fut in as_completed({ex.submit(check, t): t for t in targets}):
|
||
rel, key, line, kind, url, st, err = fut.result()
|
||
# Only a definitive HTTP 404/410 is "dead" (error). A timeout,
|
||
# DNS failure, 5xx, or 403/401 means "could not confirm" — a
|
||
# warning to check by hand, not proof the locator is dead.
|
||
if st in (404, 410):
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=line, code="bib_dead_locator",
|
||
message=(f"@{key} {kind} is dead ({st}): {url[:90]}"),
|
||
severity="error", context=f"{kind} = {{{url[:70]}}}",
|
||
suggestion="Replace with a resolving DOI/URL or the canonical source page."))
|
||
elif st in (403, 401):
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=line, code="bib_locator_forbidden",
|
||
message=(f"@{key} {kind} returned {st} (bot-block; verify by hand): {url[:80]}"),
|
||
severity="warning", context=f"{kind} = {{{url[:70]}}}",
|
||
suggestion="Likely a real page that blocks automated requests; confirm in a browser."))
|
||
elif err is not None or st is None or st >= 500:
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=line, code="bib_locator_unconfirmed",
|
||
message=(f"@{key} {kind} could not be confirmed ({err or st}): {url[:80]}"),
|
||
severity="warning", context=f"{kind} = {{{url[:70]}}}",
|
||
suggestion="Timeout/DNS/5xx — slow or transient; confirm in a browser."))
|
||
issues.sort(key=lambda i: (i.file, i.line))
|
||
return ValidationRunResult(
|
||
name="bib-links",
|
||
description="DOI/URL liveness (MIT Press: confirm all URLs)",
|
||
files_checked=len(bib_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# Native audit-check adapters (book/tools/audit/checks/*).
|
||
#
|
||
# Each of these imports the matching check module from the audit
|
||
# package and invokes its `check(path, text, scope, counter)` entry
|
||
# point directly, per file. No subprocess. The audit package's
|
||
# sophisticated protected-context detection (code fences, math,
|
||
# attribute strings, index tags, citations, proper-noun phrases,
|
||
# D·A·M/C³ axes, etc.) lives in `audit.protected_contexts` and is
|
||
# reused verbatim — these wrappers add only the per-file iteration
|
||
# and Issue → ValidationIssue conversion.
|
||
#
|
||
# Scanner categories exposed as binder scopes (see GROUPS dict):
|
||
# prose.compound-prefix → audit.checks.compound_prefix
|
||
# prose.concept-caps → audit.checks.concept_term_capitalization
|
||
# prose.abbreviation-first-use → audit.checks.abbreviation_first_use
|
||
# prose.latin-abbrevs → audit.checks.latin_running_text
|
||
# figures.alt-text-style → audit.checks.alt_text_style
|
||
#
|
||
# These complement the existing native runners that reimplement a
|
||
# subset of the same rules in-tree (e.g. _run_mitpress_heading_case,
|
||
# _run_mitpress_acknowledgements). Over time those should migrate to
|
||
# use the audit package as the single source of truth.
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
|
||
def _run_audit_check(
|
||
self,
|
||
root: Path,
|
||
module_path: str,
|
||
name: str,
|
||
description: str,
|
||
) -> ValidationRunResult:
|
||
"""Import an audit.checks.* module and run its `check()` per QMD file.
|
||
|
||
The audit package lives at `book/tools/audit/`. It uses relative
|
||
imports against its own `audit.*` top-level package, so the parent
|
||
directory (`book/tools`) must be on sys.path for the import to
|
||
resolve. This method does the path-prepend once per call.
|
||
"""
|
||
import importlib
|
||
import sys as _sys
|
||
audit_root = Path(__file__).resolve().parent.parent.parent / "tools"
|
||
audit_root_str = str(audit_root)
|
||
if audit_root_str not in _sys.path:
|
||
_sys.path.insert(0, audit_root_str)
|
||
mod = importlib.import_module(module_path)
|
||
|
||
t0 = time.time()
|
||
qmd_files = sorted(root.rglob("*.qmd"))
|
||
issues: List[ValidationIssue] = []
|
||
counter = 0
|
||
for qmd in qmd_files:
|
||
try:
|
||
text = qmd.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
audit_issues, counter = mod.check(qmd, text, "both", counter)
|
||
for issue in audit_issues:
|
||
try:
|
||
rel = str(qmd.relative_to(root))
|
||
except ValueError:
|
||
rel = str(qmd)
|
||
message = issue.reason or issue.rule_text or issue.category
|
||
# needs_subagent flag → warning (human must review).
|
||
# Auto-fixable and protected issues → error (scripted fix).
|
||
severity = "warning" if getattr(issue, "needs_subagent", False) else "error"
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=issue.line,
|
||
code=issue.category,
|
||
message=message,
|
||
severity=severity,
|
||
context=(issue.before or "")[:160],
|
||
))
|
||
return ValidationRunResult(
|
||
name=name,
|
||
description=description,
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_audit_check_files(
|
||
self,
|
||
files: list[Path],
|
||
module_path: str,
|
||
name: str,
|
||
description: str,
|
||
) -> ValidationRunResult:
|
||
"""Run an audit check against an explicit file list."""
|
||
import importlib
|
||
import sys as _sys
|
||
|
||
audit_root = Path(__file__).resolve().parent.parent.parent / "tools"
|
||
audit_root_str = str(audit_root)
|
||
if audit_root_str not in _sys.path:
|
||
_sys.path.insert(0, audit_root_str)
|
||
mod = importlib.import_module(module_path)
|
||
|
||
t0 = time.time()
|
||
issues: List[ValidationIssue] = []
|
||
counter = 0
|
||
checked = 0
|
||
for qmd in files:
|
||
if qmd.suffix != ".qmd" or not qmd.exists():
|
||
continue
|
||
try:
|
||
text = qmd.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
checked += 1
|
||
audit_issues, counter = mod.check(qmd, text, "both", counter)
|
||
for issue in audit_issues:
|
||
message = issue.reason or issue.rule_text or issue.category
|
||
severity = "warning" if getattr(issue, "needs_subagent", False) else "error"
|
||
try:
|
||
rel = str(qmd.relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
rel = str(qmd)
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=issue.line,
|
||
code=issue.category,
|
||
message=message,
|
||
severity=severity,
|
||
context=(issue.before or "")[:160],
|
||
))
|
||
return ValidationRunResult(
|
||
name=name,
|
||
description=description,
|
||
files_checked=checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_compound_prefix(self, root: Path) -> ValidationRunResult:
|
||
"""Close up pre-/non- compound prefixes per §10.8 (strict 6-term list).
|
||
|
||
Flags: pre-training, pre-trained, pre-deployment, pre-learning,
|
||
pre-processing, non-zero. Does NOT flag multi-/semi-/anti-
|
||
compounds (domain-specific hyphens kept per §10.8).
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.compound_prefix",
|
||
"compound-prefix", "pre-/non- close-up (§10.8)",
|
||
)
|
||
|
||
def _run_mitpress_concept_term_capitalization(self, root: Path) -> ValidationRunResult:
|
||
"""Lowercase concept terms in body prose per §10.3.
|
||
|
||
Flags Title Case appearances of iron law, memory wall, compute
|
||
wall, data wall, bitter lesson, scaling laws, verification gap,
|
||
degradation equation, etc. Respects the exception contexts
|
||
(bold first-definition, §10.3 exception 2; §10.9 H1/H2 headline
|
||
case; principle callout titles; `\\index{}` keys).
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.concept_term_capitalization",
|
||
"concept-caps", "concept-term lowercase in prose (§10.3)",
|
||
)
|
||
|
||
def _run_mitpress_abbreviation_first_use(self, root: Path) -> ValidationRunResult:
|
||
"""Expand abbreviations on first use per §10.5.
|
||
|
||
Enforces the specialized-abbreviation list (XLA, MMLU, HELM,
|
||
HIS, KWS, OTA, CTM, V2X, RFM, etc.). Baseline CS/ML
|
||
abbreviations (CNN, GPU, JIT, NVMe, SLA, ...) are exempt per
|
||
§10.5's expanded Special Cases. Ordinary specialized acronyms
|
||
reset by chapter. Formal model names and recurring lighthouse
|
||
labels are governed by the lighthouse roster rules, not this
|
||
abbreviation check.
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.abbreviation_first_use",
|
||
"abbreviation-first-use", "expand abbreviations on first use (§10.5)",
|
||
)
|
||
|
||
def _run_notation_consistency(self, root: Path) -> ValidationRunResult:
|
||
"""Notation conventions vs the Notations chapter (BW, R_peak, L_lat, D_vol).
|
||
|
||
Routes through audit.scan.scan() so the persistent notation
|
||
accept-list at book/tools/audit/accepted_fps_notation.json is
|
||
applied (covers Little's Law L=concurrency, LogP model L=network
|
||
latency, subscripted L_{net}/L_{queue}, P=probability function,
|
||
etc.). The detection rules live in audit.checks.notation_consistency;
|
||
this method is the binder front door — pure Python imports, no
|
||
subprocess. Replaces the standalone book/tools/audit/run_notation_consistency.py.
|
||
"""
|
||
import importlib
|
||
import sys as _sys
|
||
|
||
audit_root = Path(__file__).resolve().parent.parent.parent / "tools"
|
||
audit_root_str = str(audit_root)
|
||
if audit_root_str not in _sys.path:
|
||
_sys.path.insert(0, audit_root_str)
|
||
scan_mod = importlib.import_module("audit.scan")
|
||
|
||
t0 = time.time()
|
||
# scope="both" is mandatory: the notation accept-list spans both
|
||
# volumes, and a single-volume binder scan would mark cross-volume
|
||
# entries as stale on every run.
|
||
ledger = scan_mod.scan(
|
||
scope="both",
|
||
categories=["notation-consistency"],
|
||
verbose=False,
|
||
use_accept_list=True,
|
||
)
|
||
|
||
issues: List[ValidationIssue] = []
|
||
seen_files: set[str] = set()
|
||
for issue in ledger.issues:
|
||
seen_files.add(str(issue.file))
|
||
# Only OPEN issues fail the gate; ACCEPTED and DEFERRED do not.
|
||
if issue.status != "open":
|
||
continue
|
||
try:
|
||
rel = str(Path(issue.file).resolve().relative_to(root))
|
||
except ValueError:
|
||
rel = str(issue.file)
|
||
message = issue.reason or issue.rule_text or issue.category
|
||
issues.append(ValidationIssue(
|
||
file=rel,
|
||
line=issue.line,
|
||
code=issue.category,
|
||
message=message,
|
||
severity="error",
|
||
context=(issue.before or "")[:160],
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="notation-consistency",
|
||
description="Notation conventions: BW, R_peak, L_lat, D_vol (Notations chapter)",
|
||
files_checked=len(seen_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_mitpress_latin_running_text(self, root: Path) -> ValidationRunResult:
|
||
"""Prefer English over Latin abbreviations in running prose (§10.6).
|
||
|
||
Flags e.g., i.e., etc., viz. in body prose. Permitted inside
|
||
parentheses, footnotes, and notes (space-constrained contexts).
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.latin_running_text",
|
||
"latin-abbrevs", "English over Latin in running text (§10.6)",
|
||
)
|
||
|
||
def _run_mitpress_alt_text_style(self, root: Path) -> ValidationRunResult:
|
||
"""Alt-text inside `fig-alt=\"...\"` follows body-prose rules (§10.12).
|
||
|
||
Flags: capitalized concept terms, `%` symbol, bare `vs`, LaTeX
|
||
commands, and other body-prose rule violations that leak into
|
||
alt-text attribute strings.
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.alt_text_style",
|
||
"alt-text-style", "alt-text style compliance (§10.12)",
|
||
)
|
||
|
||
def _run_image_formats(self, root: Path) -> ValidationRunResult:
|
||
"""Validate image file formats using Pillow.
|
||
|
||
Native import of book/tools/scripts/images/manage_images.py via
|
||
importlib — no subprocess. Calls `check_file()` per image; each
|
||
returned tuple becomes a ValidationIssue. The script remains
|
||
callable as a standalone CLI for its --fix mode.
|
||
"""
|
||
import importlib.util
|
||
import sys as _sys
|
||
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "images" / "manage_images.py"
|
||
)
|
||
mod_name = "mlsys_manage_images"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
_sys.modules[mod_name] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
t0 = time.time()
|
||
image_files: List[Path] = []
|
||
for ext in ("*.png", "*.jpg", "*.jpeg", "*.gif"):
|
||
image_files.extend(sorted(root.rglob(ext)))
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for img in image_files:
|
||
try:
|
||
# show_progress=False suppresses the script's own console
|
||
# output; binder owns the rendering channel.
|
||
bad = mod.check_file(str(img), strict=False, verbose=False,
|
||
fix=False, show_progress=False)
|
||
except Exception:
|
||
continue
|
||
for tup in bad:
|
||
# tuple shape: (filepath, msg, actual_format, expected_format)
|
||
fp, msg = tup[0], tup[1]
|
||
actual = tup[2] if len(tup) > 2 else None
|
||
expected = tup[3] if len(tup) > 3 else None
|
||
detail = msg
|
||
if actual and expected:
|
||
detail = f"{msg}: actual={actual}, expected={expected}"
|
||
p = Path(fp)
|
||
rel = (str(p.relative_to(root)) if p.is_absolute()
|
||
and p.is_relative_to(root) else fp)
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=0, code="image-format",
|
||
message=detail, severity="error", context="",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="image-formats",
|
||
description="Image file format validation (Pillow)",
|
||
files_checked=len(image_files), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_external_images(self, root: Path) -> ValidationRunResult:
|
||
"""Check for external image URLs in QMD files.
|
||
|
||
Native import of book/tools/scripts/images/manage_external_images.py
|
||
via importlib — no subprocess. Calls
|
||
`ImageDownloader.validate_external_images()` which returns the
|
||
list of (file, fig_id, url) tuples; each becomes a
|
||
ValidationIssue. The script remains callable as a standalone
|
||
CLI for its download / fix modes.
|
||
"""
|
||
import importlib.util
|
||
import sys as _sys
|
||
import io
|
||
import contextlib
|
||
|
||
script = (
|
||
Path(__file__).resolve().parent.parent.parent
|
||
/ "tools" / "scripts" / "images" / "manage_external_images.py"
|
||
)
|
||
mod_name = "mlsys_manage_external_images"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
_sys.modules[mod_name] = mod
|
||
spec.loader.exec_module(mod)
|
||
|
||
t0 = time.time()
|
||
downloader = mod.ImageDownloader(str(root))
|
||
# ImageDownloader prints status banners; suppress so binder owns
|
||
# the rendering channel.
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
try:
|
||
files_processed, external_images = downloader.validate_external_images(False)
|
||
except Exception as e:
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
return ValidationRunResult(
|
||
name="external-images", description="External image URL detection",
|
||
files_checked=0, elapsed_ms=elapsed,
|
||
issues=[ValidationIssue(
|
||
file=str(script.name), line=0, code="external-images-runtime",
|
||
message=f"validate_external_images failed: {type(e).__name__}: {e}",
|
||
severity="error",
|
||
)],
|
||
)
|
||
|
||
issues: List[ValidationIssue] = []
|
||
for tup in external_images:
|
||
# tuple shape: (file_path, fig_id, url)
|
||
file_path, fig_id, url = tup[0], tup[1], tup[2]
|
||
p = Path(file_path)
|
||
if p.is_absolute():
|
||
try:
|
||
rel = str(p.relative_to(root))
|
||
except ValueError:
|
||
rel = file_path
|
||
else:
|
||
rel = file_path
|
||
issues.append(ValidationIssue(
|
||
file=rel, line=0, code="external-image",
|
||
message=f"{fig_id} → {url}",
|
||
severity="error", context="",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="external-images",
|
||
description="External image URL detection",
|
||
files_checked=files_processed, issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_svg_wellformedness(self, root: Path) -> ValidationRunResult:
|
||
"""Validate SVG files are well-formed XML."""
|
||
start = time.time()
|
||
svg_files = [
|
||
f for f in sorted(root.rglob("*.svg"))
|
||
if "_files/mediabag/" not in str(f)
|
||
]
|
||
issues: List[ValidationIssue] = []
|
||
|
||
try:
|
||
from lxml import etree
|
||
except ImportError:
|
||
return ValidationRunResult(
|
||
name="svg-xml",
|
||
description="Validate SVG XML well-formedness",
|
||
files_checked=0,
|
||
issues=[ValidationIssue(
|
||
file="(system)", line=0, code="svg_xml",
|
||
message="lxml not installed — skipping SVG validation",
|
||
severity="warning",
|
||
)],
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
for svg_file in svg_files:
|
||
try:
|
||
etree.parse(str(svg_file))
|
||
except etree.XMLSyntaxError as e:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(svg_file),
|
||
line=getattr(e, "lineno", 0) or 0,
|
||
code="svg_xml_error",
|
||
message=f"Malformed SVG XML: {e}",
|
||
severity="error",
|
||
context=str(e)[:120],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="svg-xml",
|
||
description="Validate SVG XML well-formedness",
|
||
files_checked=len(svg_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
def _run_json_syntax(self, root: Path) -> ValidationRunResult:
|
||
"""Validate JSON file syntax."""
|
||
t0 = time.time()
|
||
json_files = sorted(root.rglob("*.json"))
|
||
if not json_files:
|
||
return ValidationRunResult(
|
||
name="json-syntax", description="Validate JSON file syntax",
|
||
files_checked=0, issues=[], elapsed_ms=0,
|
||
)
|
||
issues: List[ValidationIssue] = []
|
||
for fpath in json_files:
|
||
try:
|
||
with open(fpath, "r") as f:
|
||
json.load(f)
|
||
except json.JSONDecodeError as e:
|
||
issues.append(ValidationIssue(
|
||
file=str(fpath), line=e.lineno or 0, code="json-syntax",
|
||
message=f"Invalid JSON: {e.msg}", severity="error",
|
||
))
|
||
except Exception as e:
|
||
issues.append(ValidationIssue(
|
||
file=str(fpath), line=0, code="json-syntax",
|
||
message=f"Cannot read: {e}", severity="error",
|
||
))
|
||
elapsed = int((time.time() - t0) * 1000)
|
||
return ValidationRunResult(
|
||
name="json-syntax", description="Validate JSON file syntax",
|
||
files_checked=len(json_files), issues=issues, elapsed_ms=elapsed,
|
||
)
|
||
|
||
def _run_unit_tests(self, root: Path) -> ValidationRunResult:
|
||
"""Run physics engine unit conversion tests.
|
||
|
||
Native import of book/tests/test_units.py via importlib — no
|
||
subprocess. The script's test list is gated by `if __name__ ==
|
||
"__main__"`, so we discover `test_*` callables on the imported
|
||
module ourselves and run each, capturing the script's
|
||
module-level FAILURES list per test. The script remains
|
||
callable as a standalone CLI for direct ad-hoc runs.
|
||
"""
|
||
import importlib.util
|
||
import sys as _sys
|
||
import io
|
||
import contextlib
|
||
|
||
book_dir = Path(__file__).resolve().parent.parent.parent # book/
|
||
script = book_dir / "tests" / "test_units.py"
|
||
|
||
# t0 starts BEFORE the import: pint registry construction and
|
||
# mlsysim constants happen at module-load time, so that's where
|
||
# most of the wall-clock cost actually is.
|
||
t0 = time.time()
|
||
mod_name = "mlsys_test_units"
|
||
if mod_name in _sys.modules:
|
||
mod = _sys.modules[mod_name]
|
||
else:
|
||
spec = importlib.util.spec_from_file_location(mod_name, script)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
_sys.modules[mod_name] = mod
|
||
try:
|
||
# Suppress module-import banners; binder owns rendering.
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
spec.loader.exec_module(mod)
|
||
except Exception as e:
|
||
return ValidationRunResult(
|
||
name="unit-tests", description="Physics engine unit conversion tests",
|
||
files_checked=0,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
issues=[ValidationIssue(
|
||
file=str(script.name), line=0, code="unit-tests-import",
|
||
message=f"Could not import test_units.py: {type(e).__name__}: {e}",
|
||
severity="error",
|
||
)],
|
||
)
|
||
|
||
# Discover test functions on the module (mirrors pytest convention).
|
||
test_fns = sorted(
|
||
(name, getattr(mod, name)) for name in dir(mod)
|
||
if name.startswith("test_") and callable(getattr(mod, name))
|
||
)
|
||
issues: List[ValidationIssue] = []
|
||
for name, fn in test_fns:
|
||
# Reset the module-level FAILURES list so we can capture per-test failures.
|
||
mod.FAILURES = []
|
||
try:
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
result = fn()
|
||
except Exception as e:
|
||
issues.append(ValidationIssue(
|
||
file=str(script.name), line=0, code=name,
|
||
message=f"{name} raised {type(e).__name__}: {e}",
|
||
severity="error",
|
||
))
|
||
continue
|
||
if not result or mod.FAILURES:
|
||
# Each entry in FAILURES is a pre-formatted " ✗ ..." string.
|
||
detail = "; ".join(f.strip() for f in mod.FAILURES) or "test returned False"
|
||
issues.append(ValidationIssue(
|
||
file=str(script.name), line=0, code=name,
|
||
message=f"{name}: {detail}",
|
||
severity="error",
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="unit-tests",
|
||
description="Physics engine unit conversion tests",
|
||
files_checked=len(test_fns), issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Migrated runners (2026-05-06): scripts that previously lived as
|
||
# standalone pre-commit entries have moved into binder so every
|
||
# book-* check has one entry point. The scripts themselves remain
|
||
# callable from the CLI for ad-hoc use; these wrappers convert their
|
||
# exit code to a ValidationRunResult so they show up in the standard
|
||
# check summary table.
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_index_anti_patterns(self, root: Path) -> ValidationRunResult:
|
||
"""index --scope anti-patterns: \\index{} anti-patterns from §9."""
|
||
from cli.commands._index_checks import check_anti_patterns
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
raw = check_anti_patterns(repo_root / "book")
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="index-anti-patterns",
|
||
description="\\index{} anti-patterns (corpus-level)",
|
||
files_checked=len(list((repo_root / "book" / "quarto" / "contents").rglob("*.qmd"))),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_index_tag_placement(self, root: Path) -> ValidationRunResult:
|
||
"""index --scope tag-placement: \\index{} not inside bold/italic/code/heading."""
|
||
from cli.commands._index_checks import check_tag_placement
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
raw = check_tag_placement(repo_root / "book")
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="index-tag-placement",
|
||
description="\\index{} forbidden placement (bold/code/headings)",
|
||
files_checked=len(list((repo_root / "book" / "quarto" / "contents").rglob("*.qmd"))),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_index_xref_resolves(self, root: Path) -> ValidationRunResult:
|
||
"""index --scope xref-resolves: every |see / |seealso resolves."""
|
||
from cli.commands._index_checks import check_xref_resolves
|
||
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
raw = check_xref_resolves(repo_root / "book")
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="index-xref-resolves",
|
||
description="\\index{} see/seealso target resolution",
|
||
files_checked=len(list((repo_root / "book" / "quarto" / "contents").rglob("*.qmd"))),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_index_placement_contexts(self, root: Path) -> ValidationRunResult:
|
||
"""index --scope forbidden-contexts: \\index{} in code / math / attribute strings.
|
||
|
||
Catches anti-patterns from index.md §1 + book-prose.md §7:
|
||
|
||
- \\index{} inside ```{python} ... ``` fences (LaTeX never indexes
|
||
code; leaks as literal text in displayed code).
|
||
- \\index{} inside $..$ or $$..$$ math (corrupts makeindex key
|
||
parsing; anti-pattern #4 'Math in key').
|
||
- \\index{} inside fig-cap=, fig-alt=, tbl-cap=, tbl-alt=,
|
||
lst-cap=, title= attribute strings (Quarto extracts as plain
|
||
text for HTML tooltips / PDF bookmarks / EPUB metadata; leaks
|
||
as literal text).
|
||
|
||
Wraps the native audit check at book/tools/audit/checks/index_placement.py.
|
||
"""
|
||
return self._run_audit_check(
|
||
root, "audit.checks.index_placement",
|
||
"index-placement-contexts",
|
||
"\\index{} forbidden contexts (code / math / attribute strings)",
|
||
)
|
||
|
||
def _run_lego_dead_code(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-dead-code: LEGO variables defined but never used.
|
||
|
||
Native implementation backed by cli.checks.lego_dead_code.
|
||
"""
|
||
from cli.checks.lego_dead_code import audit_paths, qmd_files
|
||
|
||
t0 = time.time()
|
||
paths = [root]
|
||
files = qmd_files(paths)
|
||
violations = audit_paths(paths)
|
||
issues = []
|
||
for violation in violations:
|
||
file_path = Path(violation.file)
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(self.config_manager.root_dir))
|
||
except ValueError:
|
||
try:
|
||
rel = str(file_path.resolve().relative_to(root.resolve()))
|
||
except ValueError:
|
||
rel = violation.file
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=rel,
|
||
line=violation.line,
|
||
code=violation.code,
|
||
message=violation.message,
|
||
severity="error",
|
||
context=violation.context,
|
||
)
|
||
)
|
||
return ValidationRunResult(
|
||
name="lego-dead-code",
|
||
description="LEGO variables defined but never referenced",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_lego_prose_literals(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-prose-literals: walkthrough prose must not hardcode computed operands."""
|
||
from cli.commands._registry_checks import check_lego_prose_literals, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
repo = repo_root_from_here()
|
||
qmd_files = self._qmd_files(root)
|
||
raw = check_lego_prose_literals(repo, paths=qmd_files)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="lego-prose-literals",
|
||
description=f"LEGO walkthrough prose literal scan ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_lego_prose_units(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-prose-units: units after {python} *_str in prose."""
|
||
from cli.commands._registry_checks import check_lego_prose_units, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
repo = repo_root_from_here()
|
||
qmd_files = self._qmd_files(root)
|
||
raw = check_lego_prose_units(repo, paths=qmd_files)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="lego-prose-units",
|
||
description=f"LEGO prose unit-after-_str scan ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_lego_load_pint(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-load-pint: physical *_value must use ureg/registry."""
|
||
from cli.commands._registry_checks import check_lego_load_pint, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
repo = repo_root_from_here()
|
||
qmd_files = self._qmd_files(root)
|
||
raw = check_lego_load_pint(repo, paths=qmd_files)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="lego-load-pint",
|
||
description=f"LEGO pint LOAD lint ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_lego_equations(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-equations: numeric A/B=C coherence in prose."""
|
||
from cli.commands._registry_checks import check_lego_equations, repo_root_from_here
|
||
|
||
t0 = time.time()
|
||
repo = repo_root_from_here()
|
||
qmd_files = self._qmd_files(root)
|
||
raw = check_lego_equations(repo, paths=qmd_files)
|
||
issues = [
|
||
ValidationIssue(
|
||
file=i.file, line=i.line, code=i.code,
|
||
message=i.message, severity=i.severity,
|
||
)
|
||
for i in raw
|
||
]
|
||
return ValidationRunResult(
|
||
name="lego-equations",
|
||
description=f"LEGO equation coherence ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
def _run_lego_units(self, root: Path) -> ValidationRunResult:
|
||
"""code --scope lego-units: warning-only LEGO unit discipline linter."""
|
||
import json
|
||
from cli.checks.lego_units import lint_file
|
||
|
||
t0 = time.time()
|
||
repo = self.config_manager.root_dir
|
||
qmd_files = self._qmd_files(root)
|
||
baseline = repo / "book" / "tools" / "audit" / "lego_units_baseline.json"
|
||
allowed: set[tuple[str, str, str]] = set()
|
||
if baseline.exists():
|
||
for entry in json.loads(baseline.read_text(encoding="utf-8")):
|
||
allowed.add((entry["rule"], entry["file"], entry["message"]))
|
||
|
||
issues: list[ValidationIssue] = []
|
||
for qmd in qmd_files:
|
||
for issue in lint_file(qmd, repo):
|
||
key = (issue.rule, issue.file, issue.message)
|
||
if key in allowed:
|
||
continue
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=issue.file,
|
||
line=issue.line,
|
||
code=issue.rule,
|
||
message=issue.message,
|
||
severity=issue.severity,
|
||
)
|
||
)
|
||
return ValidationRunResult(
|
||
name="lego-units",
|
||
description=f"LEGO unit discipline lint ({len(qmd_files)} files)",
|
||
files_checked=len(qmd_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Check 1: \${python} collision detector (code group)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# An escaped dollar immediately before {python} — i.e. \${python} —
|
||
# silently fails to render as an inline expression. The correct form
|
||
# is \$\`{python} (backtick between \$ and {python}).
|
||
# Zero violations expected on the current corpus; this prevents
|
||
# regression.
|
||
|
||
# Pattern: backslash-dollar immediately before {python} WITHOUT a
|
||
# preceding backtick. The negative lookbehind ensures we skip the
|
||
# correct form \$`{python}.
|
||
_DOLLAR_PYTHON_COLLISION = re.compile(
|
||
r"(?<!`)" # not preceded by backtick (the correct form)
|
||
r"\\\$\{python\}" # literal \${python}
|
||
)
|
||
|
||
def _run_python_dollar_collision(self, root: Path) -> ValidationRunResult:
|
||
r"""Flag \${python} in QMD prose — silently fails to render."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
block_start = re.compile(r"^```")
|
||
raw_latex_start = re.compile(r"^\\\[|^\$\$|^\\begin\{")
|
||
raw_latex_end = re.compile(r"^\\\]|^\$\$|^\\end\{")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_code = False
|
||
in_raw_latex = False
|
||
for idx, line in enumerate(lines, 1):
|
||
stripped = line.strip()
|
||
# Toggle code fences
|
||
if stripped.startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
continue
|
||
# Skip raw LaTeX blocks (display math, \begin{} environments)
|
||
if raw_latex_start.match(stripped):
|
||
in_raw_latex = True
|
||
continue
|
||
if in_raw_latex:
|
||
if raw_latex_end.match(stripped):
|
||
in_raw_latex = False
|
||
continue
|
||
|
||
for m in self._DOLLAR_PYTHON_COLLISION.finditer(line):
|
||
context = line[max(0, m.start() - 10): min(len(line), m.end() + 20)].strip()
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="python_dollar_collision",
|
||
message=(
|
||
r"\${python} silently fails to render — "
|
||
r"use \$\`{python} (backtick between \$ and {python})"
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="python-dollar-collision",
|
||
description=r"\${python} collision detector (prevents silent render failure)",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Check 2: suffix-consistency blocklist (math group)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# Scans suffix="..." values in fmt() calls inside {python} cells for
|
||
# banned unit conventions. This is a blocklist, not an allowlist:
|
||
# everything is allowed except the explicitly banned patterns.
|
||
|
||
_SUFFIX_VALUE = re.compile(r"""suffix\s*=\s*(?:"([^"]*)"|'([^']*)')""")
|
||
|
||
# Banned patterns: wrong unit conventions for rate/throughput suffixes.
|
||
_BANNED_SUFFIXES: Set[str] = {
|
||
# Wrong pluralization of /s rate units
|
||
" TFLOPS/s", " GFLOPS/s",
|
||
# Wrong conventions — should use slash-s form
|
||
" TFLOPS", " TFLOPs",
|
||
" Gbps", " Mbps", " GBps", " TBps",
|
||
}
|
||
|
||
def _run_suffix_consistency(self, root: Path) -> ValidationRunResult:
|
||
"""Flag banned suffix= values in fmt() calls (wrong unit conventions)."""
|
||
start = time.time()
|
||
files = self._qmd_files(root)
|
||
issues: List[ValidationIssue] = []
|
||
|
||
block_start = re.compile(r"^```\{python\}")
|
||
block_end = re.compile(r"^```\s*$")
|
||
|
||
for file in files:
|
||
lines = self._read_text(file).splitlines()
|
||
in_python = False
|
||
for idx, line in enumerate(lines, 1):
|
||
if block_start.match(line):
|
||
in_python = True
|
||
continue
|
||
if block_end.match(line):
|
||
in_python = False
|
||
continue
|
||
if not in_python:
|
||
continue
|
||
|
||
for m in self._SUFFIX_VALUE.finditer(line):
|
||
val = m.group(1) if m.group(1) is not None else m.group(2)
|
||
if val in self._BANNED_SUFFIXES:
|
||
issues.append(
|
||
ValidationIssue(
|
||
file=self._relative_file(file),
|
||
line=idx,
|
||
code="suffix_banned_convention",
|
||
message=(
|
||
f'suffix="{val}" uses a banned unit convention. '
|
||
f"Rate units should use TFLOP/s, PFLOP/s, GB/s, etc. "
|
||
f"(slash-s form, no trailing S pluralization)."
|
||
),
|
||
severity="error",
|
||
context=line.strip()[:160],
|
||
)
|
||
)
|
||
|
||
return ValidationRunResult(
|
||
name="suffix-consistency",
|
||
description="Blocklist for banned suffix= unit conventions in fmt() calls",
|
||
files_checked=len(files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - start) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Check 3: PDF UserWarning check (pdf group, default=False)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# Post-build: scan extracted PDF text for "UserWarning" strings that
|
||
# may indicate a Python warning leaked into the rendered output.
|
||
|
||
def _run_pdf_warnings(
|
||
self,
|
||
root: Path,
|
||
*,
|
||
vol1: bool = False,
|
||
vol2: bool = False,
|
||
log_path: Optional[str] = None,
|
||
) -> ValidationRunResult:
|
||
"""Scan PDF text for UserWarning strings (post-build audit)."""
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
quarto_dir = repo_root / "book" / "quarto"
|
||
issues: List[ValidationIssue] = []
|
||
|
||
volumes: List[str] = []
|
||
if vol1:
|
||
volumes.append("vol1")
|
||
if vol2:
|
||
volumes.append("vol2")
|
||
if not volumes:
|
||
volumes = ["vol1", "vol2"]
|
||
|
||
import shutil
|
||
if shutil.which("pdftotext") is None:
|
||
issues.append(ValidationIssue(
|
||
file="", line=0, code="pdftotext_missing",
|
||
message="pdftotext not installed; install poppler (e.g. brew install poppler)",
|
||
severity="warning",
|
||
))
|
||
return ValidationRunResult(
|
||
name="pdf-warnings",
|
||
description="PDF UserWarning scan",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
from cli.commands._pdf_checks import PDF_BY_VOLUME
|
||
|
||
checked = 0
|
||
for vol in volumes:
|
||
pdf_name = PDF_BY_VOLUME.get(vol)
|
||
if not pdf_name:
|
||
continue
|
||
pdf_path = quarto_dir / "_build" / f"pdf-{vol}" / pdf_name
|
||
if not pdf_path.is_file():
|
||
continue
|
||
checked += 1
|
||
try:
|
||
proc = subprocess.run(
|
||
["pdftotext", "-layout", str(pdf_path), "-"],
|
||
capture_output=True, text=True,
|
||
)
|
||
body = proc.stdout or ""
|
||
except Exception:
|
||
continue
|
||
|
||
for line_num, line in enumerate(body.splitlines(), 1):
|
||
if "UserWarning" in line:
|
||
context = line.strip()[:200]
|
||
issues.append(ValidationIssue(
|
||
file=str(pdf_path.relative_to(repo_root)),
|
||
line=line_num,
|
||
code="pdf_userwarning",
|
||
message=f"UserWarning text found in {vol} PDF output",
|
||
severity="warning",
|
||
context=context,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="pdf-warnings",
|
||
description=f"PDF UserWarning scan ({', '.join(volumes)})",
|
||
files_checked=checked,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Check 6: rendered {python} leak check (code group, default=False)
|
||
# ------------------------------------------------------------------
|
||
#
|
||
# Post-render: scan all HTML files in _build/html-audit/ for literal
|
||
# {python} text that escaped Quarto inline-expression evaluation.
|
||
# Excludes {python} inside <code> tags (legitimate code listings).
|
||
|
||
_RENDERED_PYTHON_LEAK = re.compile(r"\{python\}")
|
||
# Match {python} inside <code>...</code> — these are legitimate.
|
||
_CODE_TAG_PYTHON = re.compile(r"<code[^>]*>.*?\{python\}.*?</code>", re.DOTALL)
|
||
|
||
def _run_rendered_python_leak(self, root: Path) -> ValidationRunResult:
|
||
"""Scan built HTML for literal {python} that leaked through Quarto."""
|
||
t0 = time.time()
|
||
repo_root = Path(__file__).resolve().parents[3]
|
||
html_audit_dir = repo_root / "book" / "quarto" / "_build" / "html-audit"
|
||
issues: List[ValidationIssue] = []
|
||
|
||
if not html_audit_dir.is_dir():
|
||
issues.append(ValidationIssue(
|
||
file="", line=0, code="html_audit_missing",
|
||
message=(
|
||
"html-audit build directory not found at "
|
||
f"{html_audit_dir.relative_to(repo_root)}. "
|
||
"Run a build first."
|
||
),
|
||
severity="warning",
|
||
))
|
||
return ValidationRunResult(
|
||
name="rendered-python-leak",
|
||
description="Literal {python} leak scan (no build found)",
|
||
files_checked=0,
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|
||
|
||
html_files = sorted(html_audit_dir.rglob("*.html"))
|
||
for html_file in html_files:
|
||
try:
|
||
content = html_file.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
continue
|
||
|
||
# Remove {python} occurrences inside <code> tags (legitimate)
|
||
cleaned = self._CODE_TAG_PYTHON.sub("", content)
|
||
|
||
for m in self._RENDERED_PYTHON_LEAK.finditer(cleaned):
|
||
# Find approximate line number
|
||
line_num = content[:m.start()].count("\n") + 1
|
||
start_ctx = max(0, m.start() - 40)
|
||
end_ctx = min(len(cleaned), m.end() + 40)
|
||
context = cleaned[start_ctx:end_ctx].replace("\n", " ").strip()[:120]
|
||
issues.append(ValidationIssue(
|
||
file=str(html_file.relative_to(repo_root)),
|
||
line=line_num,
|
||
code="rendered_python_leak",
|
||
message=(
|
||
"Literal {python} found in rendered HTML — "
|
||
"Quarto failed to evaluate an inline expression"
|
||
),
|
||
severity="error",
|
||
context=context,
|
||
))
|
||
|
||
return ValidationRunResult(
|
||
name="rendered-python-leak",
|
||
description=f"Literal {{python}} leak scan ({len(html_files)} HTML files)",
|
||
files_checked=len(html_files),
|
||
issues=issues,
|
||
elapsed_ms=int((time.time() - t0) * 1000),
|
||
)
|