Files
Vijay Janapa Reddi 27d4f8114b binder: fix repo-root, build, doctor and info bugs; clearer errors; dedup
Bug fixes
- Build lock is keyed on books/ so builds started from different
  directories block each other (fixes two tests broken by 89b828efa3).
- ConfigManager resolves the repo root when binder runs from books/ or
  binder/, so bib, newsletter, doctor and render paths are right.
- `build <word> --all` treats intro/scaling/agentic/physical as chapter
  names (it built every volume), and Ctrl-C stops a --all loop.
- doctor checked chapter names that no longer exist and failed on the
  generated _quarto.yml; info defaulted to a deleted books/contents.
- `headings apply --path <typo>` no longer rewrites the whole book;
  `bib clean` stops on unreadable chapters instead of dropping entries.
- Discovery falls back to a line scan on invalid YAML instead of crashing.
- Volume III/IV were labeled Volume II in layout and release output.

Output
- Unexpected errors print the command, exception type, and raising
  file:line (new cli/core/errors.py), with the traceback on stderr.
- Chapter-not-found suggests the closest names and the list command.
- Silent except blocks in bib lookups, info, headings, layout,
  maintenance, preview and debug now say what went wrong.

Cleanup
- Shared qmd_files_under/display_path, file-rewrite loop, doctor result
  helper, preview server, clean helpers, volume display names.
- Removed legacy import fallbacks, dead config attributes, legacy backup
  restore logic, and unused imports.
2026-10-19 19:28:49 +02:00

337 lines
15 KiB
Python

"""Isolate build failures chapter by chapter, then section by section.
``binder debug <fmt> --volN`` runs two phases:
1. **Chapter scan.** Every chapter in the volume's PDF order is built on its
own. With ``--parallel N`` the builds run N at a time.
2. **Section bisection.** Each failing chapter (or the one named with
``--chapter``) is truncated to a growing number of ``##`` sections and
rebuilt, binary-searching for the first section that breaks the build.
Every build runs in a disposable git worktree (see ``cli.core.parallel``), so
the truncated chapters in phase 2 never touch the files in your checkout.
Chapter builds pass ``--skip-validate``: the question here is whether a
chapter renders, and references to chapters outside an isolated build would
otherwise always fail validation.
Logs and artifacts land in ``books/_build/debug/<vol>/<fmt>/<run-id>/``.
Usage:
./binder/binder debug pdf --vol1 # scan, then bisect failures
./binder/binder debug pdf --vol1 --parallel 4 # scan four chapters at a time
./binder/binder debug html --vol2 --chapter training # bisect one chapter
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import List, Optional, Tuple
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from ..core.discovery import AmbiguousChapterError, format_volume_display_name
from ..core.parallel import BuildJob, BuildSession, JobResult, new_run_id, run_jobs
from ..core.process import interrupts_as_keyboard_interrupt
from ..core.workspace import take_snapshot
console = Console()
#: Location of ``section_splitter.py``, which parses a chapter into sections.
CONTENT_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "tools" / "scripts" / "content"
#: Flags for every debug build: rendering is the question, not validation.
CHAPTER_BUILD_ARGS = ("--skip-validate",)
# Quarto warnings worth surfacing even when a build succeeds, as
# (pattern, human-readable label) pairs.
_QUARTO_WARN_PATTERNS: List[Tuple[re.Pattern, str]] = [
(
re.compile(r"Duplicate note reference '([^']+)'", re.IGNORECASE),
"Duplicate footnote reference",
),
(
re.compile(r"The following string was found in the document: :::", re.IGNORECASE),
"Unclosed/stray fenced div (:::)",
),
]
def _extract_quarto_warnings(output: str) -> List[str]:
"""Return the known Quarto warnings found in *output*, deduplicated, in order of appearance."""
found: List[str] = []
for pattern, label in _QUARTO_WARN_PATTERNS:
for match in pattern.finditer(output):
detail = match.group(1) if match.lastindex else ""
message = f"{label}: {detail}" if detail else label
if message not in found:
found.append(message)
return found
def _failure_excerpt(result: JobResult, lines: int = 20) -> str:
"""Return the failure note plus the last *lines* lines of a job's log."""
text = result.log_text().strip()
tail = "\n".join(text.splitlines()[-lines:]) if text else ""
return f"{result.note}\n{tail}".strip()
def _print_result(result: JobResult, verbose: bool = False) -> None:
"""Print PASS, WARN, or FAIL for a finished build, with warnings and, if verbose, the log tail."""
warnings = _extract_quarto_warnings(result.log_text())
if not result.ok:
console.print(f"[red]FAIL[/red] ({result.seconds:.1f}s)")
elif warnings:
console.print(f"[yellow]WARN[/yellow] ({result.seconds:.1f}s)")
else:
console.print(f"[green]PASS[/green] ({result.seconds:.1f}s)")
for warning in warnings:
console.print(f" [yellow]⚠ {escape(warning)}[/yellow]")
if verbose and not result.ok:
for line in _failure_excerpt(result).splitlines()[-3:]:
console.print(f" [dim]{escape(line)}[/dim]")
class DebugCommand:
"""Two-phase build debugger for the book volumes."""
def __init__(self, config_manager, chapter_discovery, verbose: bool = False):
"""Store the shared configuration and discovery objects.
Args:
config_manager: ``ConfigManager`` for the checkout being debugged.
chapter_discovery: ``ChapterDiscovery`` used to list and resolve chapters.
verbose: Print the tail of each failed build's log.
"""
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
self.verbose = verbose
self.book_dir = config_manager.book_dir
self.repo_root = config_manager.book_dir.parent
def debug_build(
self,
format_type: str,
volume: str,
chapter: Optional[str] = None,
workers: int = 1,
keep_workspaces: bool = False,
) -> bool:
"""Run the debugger for one volume and format.
Args:
format_type: ``pdf``, ``html``, or ``epub``.
volume: Volume to debug, such as ``vol1``.
chapter: Skip the chapter scan and bisect this chapter.
workers: Chapter builds to run at once during the scan.
keep_workspaces: Leave the build worktrees on disk for inspection.
Returns:
True once debugging has finished, whatever it found; False when it
could not run.
"""
console.print(Panel(
f"[bold red]Build Debugger[/bold red]\n"
f"[dim]{format_volume_display_name(volume)} / {format_type.upper()}[/dim]",
border_style="red",
))
run_dir = self.book_dir / "_build" / "debug" / volume / format_type / new_run_id()
run_dir.mkdir(parents=True, exist_ok=True)
console.print(f"[dim]Debug logs: {run_dir}[/dim]")
if chapter:
console.print(f"\n[bold]Section-level debug for [cyan]{escape(chapter)}[/cyan][/bold]\n")
return self._bisect_chapter(chapter, volume, format_type, run_dir, keep_workspaces)
console.print("\n[bold]Phase 1:[/bold] Building each chapter on its own...\n")
failures = self._scan_chapters(volume, format_type, run_dir, workers, keep_workspaces)
if failures is None:
return False
if not failures:
console.print(Panel(
"[bold green]All chapters build successfully.[/bold green]\n"
"[dim]No failures to debug.[/dim]",
border_style="green",
))
return True
console.print(f"\n[bold red]Found {len(failures)} failing chapter(s):[/bold red]")
for result in failures:
console.print(f" [red]x[/red] {escape(result.job.chapter or result.job.name)} "
f"[dim](log: {escape(str(result.log_path))})[/dim]")
for index, result in enumerate(failures, 1):
console.print(f"\n[bold]Phase 2 ({index}/{len(failures)}):[/bold] "
f"Section-level debug for [cyan]{escape(result.job.chapter)}[/cyan]\n")
self._bisect_chapter(result.job.chapter, volume, format_type, run_dir, keep_workspaces)
return True
def _scan_chapters(
self, volume: str, format_type: str, run_dir: Path, workers: int, keep_workspaces: bool
) -> Optional[List[JobResult]]:
"""Build every chapter of *volume* on its own and return the failed results.
Returns ``None`` when the volume has no chapters to build.
"""
chapters = self.chapter_discovery.get_chapters_from_config(volume)
if not chapters:
config = self.book_dir / "config" / f"_quarto-pdf-{volume}.yml"
console.print(f"[red]No chapters found for {escape(volume)}: its chapter list comes from "
f"{escape(str(config))}.[/red]")
return None
jobs = [BuildJob(format_type, volume, stem, CHAPTER_BUILD_ARGS, label=f"{index:02d}_{stem}")
for index, stem in enumerate(chapters, 1)]
console.print(f"[dim]{len(jobs)} chapters, {workers} build(s) at a time[/dim]\n")
def report(result: JobResult) -> None:
"""Print a finished chapter's name followed by its build result."""
console.print(f" {escape(result.job.chapter):<32s}", end=" ")
_print_result(result, self.verbose)
results = run_jobs(self.repo_root, jobs, run_dir / "phase1", workers=workers,
keep_workspaces=keep_workspaces, on_finish=report)
failures = [result for result in results if not result.ok]
console.print(f"\n[dim]Results: {len(results) - len(failures)} passed, {len(failures)} failed[/dim]")
return failures
def _bisect_chapter(
self, chapter_name: str, volume: str, format_type: str, run_dir: Path, keep_workspaces: bool
) -> bool:
"""Binary-search one chapter for the first section that breaks its build.
Returns:
True once the search has finished; False when the chapter cannot
be found or parsed.
"""
if str(CONTENT_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(CONTENT_SCRIPTS_DIR))
try:
from section_splitter import split_chapter
except ImportError:
console.print("[red]Cannot import section_splitter.py from content scripts.[/red]")
console.print(f"[dim]Expected at: {CONTENT_SCRIPTS_DIR / 'section_splitter.py'}[/dim]")
return False
spec = chapter_name if "/" in chapter_name else f"{volume}/{chapter_name}"
try:
qmd_path = self.chapter_discovery.find_chapter_file(spec, allow_fuzzy=True)
except AmbiguousChapterError as error:
console.print(f"[red]Ambiguous chapter {escape(chapter_name)}:[/red] "
f"{escape(', '.join(error.locations))}")
return False
if qmd_path is None:
console.print(f"[red]Chapter not found in {escape(volume)}: {escape(chapter_name)}[/red]")
console.print(f"[yellow]See every chapter with: ./binder/binder list --{escape(volume)}[/yellow]")
return False
console.print(f"[dim]Parsing {qmd_path.name} into sections...[/dim]")
chapter = split_chapter(str(qmd_path))
if not chapter.sections:
console.print("[yellow]No ## sections found in chapter.[/yellow]")
return False
section_table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
section_table.add_column("#", style="dim", width=4)
section_table.add_column("Section", width=50)
section_table.add_column("Lines", style="dim", width=12)
for section in chapter.sections:
section_table.add_row(str(section.index), escape(section.title),
f"L{section.start_line}-{section.end_line}")
console.print(section_table)
console.print()
steps_dir = run_dir / "phase2" / qmd_path.stem
relative = qmd_path.resolve().relative_to(self.repo_root.resolve())
# Signals become KeyboardInterrupt so the session removes its workspace.
with interrupts_as_keyboard_interrupt(), BuildSession(
take_snapshot(self.repo_root), steps_dir, name=f"bisect-{qmd_path.stem}",
run_id=new_run_id(), keep=keep_workspaces) as session:
failing = self._binary_search_sections(session, chapter, qmd_path.stem, volume,
format_type, relative)
console.print()
if failing is None:
console.print(Panel(
f"[bold green]All {len(chapter.sections)} sections in {escape(qmd_path.stem)} "
"build successfully.[/bold green]",
border_style="green",
))
elif failing == -1:
console.print(Panel(
"[bold red]Preamble itself fails to build.[/bold red]\n"
"[dim]The issue is in the YAML frontmatter or content before the first ## section.[/dim]",
border_style="red",
))
else:
section = chapter.sections[failing]
text = Text()
text.append("Build breaks at section ", style="bold red")
text.append(f"{failing}", style="bold cyan")
text.append(": ", style="bold red")
text.append(f'"{section.title}"', style="bold white")
text.append(f"\n\nFile: {qmd_path.name}", style="dim")
text.append(f"\nLines: {section.start_line}-{section.end_line}", style="dim")
if section.section_id:
text.append(f"\nID: #{section.section_id}", style="dim")
text.append(f"\nLogs: {steps_dir}", style="dim")
console.print(Panel(text, title="Result", border_style="red"))
return True
def _binary_search_sections(
self, session: BuildSession, chapter, stem: str, volume: str, format_type: str, relative: Path
) -> Optional[int]:
"""Find the first section whose inclusion breaks the build.
Each step writes a truncated chapter into the session's workspace and
builds it there.
Returns:
The failing section index, -1 if the preamble alone fails, or
``None`` if the whole chapter builds.
"""
def builds(label: str, up_to_section: int) -> bool:
"""Build the chapter truncated after *up_to_section* and return whether it succeeded."""
content = self._assemble_content(chapter, up_to_section)
job = BuildJob(format_type, volume, stem, CHAPTER_BUILD_ARGS, label=label)
result = session.run(
job, prepare=lambda root: (root / relative).write_text(content, encoding="utf-8"))
_print_result(result, self.verbose)
return result.ok
count = len(chapter.sections)
console.print(" [dim]\\[pre][/dim] Preamble only", end=" ")
if not builds("preamble", -1):
return -1
console.print(f" [dim]\\[full][/dim] All {count} sections", end=" ")
if builds("full", count - 1):
return None
low, high, build_count = 0, count - 1, 2
while low < high:
middle = (low + high) // 2
build_count += 1
title = escape(chapter.sections[middle].title[:40])
console.print(f' [dim]\\[bisect {build_count}][/dim] Up to #{middle}: "{title}"', end=" ")
if builds(f"step_{build_count:02d}_upto_{middle}", middle):
low = middle + 1
else:
high = middle
console.print(f"\n[dim]Isolated in {build_count} builds (binary search).[/dim]")
return low
@staticmethod
def _assemble_content(chapter, up_to_section: int) -> str:
"""Return the chapter's frontmatter and preamble plus sections 0 through *up_to_section*.
Args:
chapter: ``ChapterStructure`` from ``section_splitter.split_chapter``.
up_to_section: Highest section index to include; -1 keeps only the preamble.
"""
parts = [part for part in (chapter.frontmatter, chapter.pre_content) if part]
parts.extend(section.content for section in chapter.sections if section.index <= up_to_section)
return "\n".join(parts)