feat(binder): restructure CLI into check/fix/format hierarchy

Reorganize binder commands into a clean three-verb quality system:

  check   — grouped validation (refs, labels, headers, footnotes,
            figures, rendering) with --scope for granularity
  fix     — content management (headers, footnotes, glossary, images)
  format  — auto-formatters (blanks, python, lists, divs, tables)

Key changes:
- validate → check (with backward-compat alias)
- maintain → fix (with backward-compat alias)
- 17 flat checks grouped into 6 semantic categories
- --scope flag narrows to individual checks within a group
- New FormatCommand with native blanks/lists + script delegation
- Updated pre-commit hooks, VSCode extension, and help output
This commit is contained in:
Vijay Janapa Reddi
2026-02-12 23:37:56 -05:00
parent 8caeac9cc7
commit a0a7f7c658
8 changed files with 594 additions and 133 deletions

View File

@@ -121,7 +121,7 @@ repos:
- id: book-verify-section-ids
name: "Book: Verify all sections have IDs"
# NOTE: Currently only checking Vol1 - Vol2 is still in early development
entry: ./book/binder validate headers --vol1
entry: ./book/binder check headers --vol1
language: system
pass_filenames: false
files: ^book/quarto/contents/vol1/.*\.qmd$
@@ -158,14 +158,14 @@ repos:
- id: book-validate-footnotes
name: "Book: Validate footnote references"
entry: ./book/binder validate footnote-refs
entry: ./book/binder check footnotes --scope integrity
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
- id: book-check-forbidden-footnotes
name: "Book: Check for footnotes in tables/captions"
entry: ./book/binder validate footnote-placement
entry: ./book/binder check footnotes --scope placement
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
@@ -195,14 +195,14 @@ repos:
- id: book-check-figure-completeness
name: "Book: Check figures have captions and alt-text"
entry: ./book/binder validate figures
entry: ./book/binder check figures --scope captions
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
- id: book-check-figure-placement
name: "Book: Check figure/table placement (near first reference)"
entry: ./book/binder validate float-flow
entry: ./book/binder check figures --scope flow
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
@@ -231,7 +231,7 @@ repos:
- id: book-check-render-patterns
name: "Book: Check for rendering issues (LaTeX+Python)"
entry: ./book/binder validate rendering
entry: ./book/binder check rendering --scope patterns
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
@@ -239,7 +239,7 @@ repos:
- id: book-validate-dropcap
name: "Book: Validate drop cap compatibility"
entry: ./book/binder validate dropcaps
entry: ./book/binder check rendering --scope dropcaps
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
@@ -261,14 +261,14 @@ repos:
- id: book-check-index-placement
name: "Book: Check index placement (not inline with headings/callouts)"
entry: ./book/binder validate indexes
entry: ./book/binder check rendering --scope indexes
language: system
pass_filenames: false
files: ^book/quarto/contents/.*\.qmd$
- id: book-validate-part-keys
name: "Book: Validate part keys"
entry: ./book/binder validate parts
entry: ./book/binder check rendering --scope parts
language: system
pass_filenames: false
files: ^book/.*\.qmd$

View File

@@ -0,0 +1,394 @@
"""
Format commands for MLSysBook CLI.
Auto-formatters for QMD content: blank lines, Python code blocks,
list spacing, div spacing, and table formatting.
Usage:
binder format blanks — Collapse extra blank lines
binder format python — Format Python code blocks (Black, 70 chars)
binder format lists — Fix bullet list spacing
binder format divs — Fix div/callout spacing
binder format tables — Prettify grid tables
binder format all — Run all formatters
"""
import re
import sys
import subprocess
from pathlib import Path
from typing import List, Optional
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
# Paths to legacy scripts (used for complex formatters not yet natively ported)
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "tools" / "scripts"
_SCRIPT_PATHS = {
"python": _SCRIPTS_DIR / "content" / "format_python_in_qmd.py",
"tables": _SCRIPTS_DIR / "content" / "format_tables.py",
"divs": _SCRIPTS_DIR / "content" / "format_div_spacing.py",
}
class FormatCommand:
"""Auto-format QMD content."""
TARGETS = ["blanks", "python", "lists", "divs", "tables", "all"]
def __init__(self, config_manager, chapter_discovery):
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
def run(self, args: List[str]) -> bool:
"""Entry point — parse args and dispatch."""
if not args or args[0] in ("-h", "--help"):
self._print_help()
return True
target = args[0]
if target not in self.TARGETS:
console.print(f"[red]Unknown format target: {target}[/red]")
self._print_help()
return False
# Remaining args are file paths or flags
rest = args[1:]
files, check_only = self._parse_rest(rest)
if target == "all":
return self._run_all(files, check_only)
dispatch = {
"blanks": self._run_blanks,
"python": self._run_python,
"lists": self._run_lists,
"divs": self._run_divs,
"tables": self._run_tables,
}
return dispatch[target](files, check_only)
# ------------------------------------------------------------------
# Argument helpers
# ------------------------------------------------------------------
def _parse_rest(self, rest: List[str]) -> tuple:
"""Parse remaining args into (file_list, check_only)."""
check_only = False
files: List[str] = []
for arg in rest:
if arg in ("--check", "-c"):
check_only = True
elif arg.startswith("-"):
pass # ignore unknown flags gracefully
else:
files.append(arg)
return files, check_only
def _resolve_files(self, file_args: List[str]) -> List[Path]:
"""Resolve file arguments to a list of QMD paths."""
if file_args:
result = []
for f in file_args:
p = Path(f)
if not p.is_absolute():
p = (Path.cwd() / p).resolve()
if p.is_dir():
result.extend(sorted(p.rglob("*.qmd")))
elif p.suffix == ".qmd" and p.exists():
result.append(p)
return result
# Default: all content files
base = self.config_manager.book_dir / "contents"
return sorted(base.rglob("*.qmd"))
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def _print_help(self) -> None:
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Target", style="cyan", width=12)
table.add_column("Description", style="white", width=45)
table.add_row("blanks", "Collapse extra blank lines (native)")
table.add_row("python", "Format Python code blocks via Black (70 chars)")
table.add_row("lists", "Fix bullet list spacing (blank line before lists)")
table.add_row("divs", "Fix div/callout spacing (paragraph ↔ list gaps)")
table.add_row("tables", "Prettify grid tables (align columns, bold headers)")
table.add_row("all", "Run all formatters")
console.print(Panel(table, title="binder format <target> [files...] [--check]", border_style="cyan"))
console.print("[dim]Examples:[/dim]")
console.print(" [cyan]./binder format blanks[/cyan] [dim]# fix all files[/dim]")
console.print(" [cyan]./binder format tables --check[/cyan] [dim]# check only, no writes[/dim]")
console.print(" [cyan]./binder format python path/to/ch.qmd[/cyan] [dim]# single file[/dim]")
console.print()
# ------------------------------------------------------------------
# Run all
# ------------------------------------------------------------------
def _run_all(self, files: List[str], check_only: bool) -> bool:
results = []
for target in ("blanks", "lists", "divs", "python", "tables"):
dispatch = {
"blanks": self._run_blanks,
"python": self._run_python,
"lists": self._run_lists,
"divs": self._run_divs,
"tables": self._run_tables,
}
ok = dispatch[target](files, check_only)
results.append((target, ok))
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Formatter", style="cyan")
table.add_column("Status", style="white")
for name, ok in results:
status = "[green]PASS[/green]" if ok else "[red]MODIFIED[/red]"
table.add_row(name, status)
console.print(Panel(table, title="Binder Format Summary", border_style="cyan"))
return all(ok for _, ok in results)
# ------------------------------------------------------------------
# Blanks (native — ported from format_blank_lines.py)
# ------------------------------------------------------------------
def _run_blanks(self, file_args: List[str], check_only: bool) -> bool:
"""Collapse multiple consecutive blank lines into single blank lines."""
qmd_files = self._resolve_files(file_args)
modified = []
for path in qmd_files:
content = path.read_text(encoding="utf-8")
new_content = self._collapse_blank_lines(content)
if new_content != content:
if not check_only:
path.write_text(new_content, encoding="utf-8")
modified.append(path)
if modified:
label = "Would modify" if check_only else "Modified"
console.print(f"[yellow]blanks: {label} {len(modified)} file(s)[/yellow]")
for p in modified[:10]:
console.print(f" {self._rel(p)}")
if len(modified) > 10:
console.print(f" [dim]... {len(modified) - 10} more[/dim]")
return False # pre-commit convention: modified = exit 1
else:
console.print("[green]blanks: All files clean[/green]")
return True
@staticmethod
def _collapse_blank_lines(content: str) -> str:
"""Replace multiple consecutive blank lines with a single blank line.
Preserves content inside code blocks.
"""
lines = content.split("\n")
result = []
in_code_block = False
blank_count = 0
for line in lines:
if line.strip().startswith("```"):
in_code_block = not in_code_block
if blank_count > 0:
result.append("")
blank_count = 0
result.append(line)
continue
if in_code_block:
result.append(line)
continue
if line.strip() == "":
blank_count += 1
else:
if blank_count > 0:
result.append("")
blank_count = 0
result.append(line)
if blank_count > 0:
result.append("")
return "\n".join(result)
# ------------------------------------------------------------------
# Lists (native — ported from check_list_formatting.py)
# ------------------------------------------------------------------
def _run_lists(self, file_args: List[str], check_only: bool) -> bool:
"""Ensure blank line before bullet lists after colon-ending lines."""
qmd_files = self._resolve_files(file_args)
modified = []
for path in qmd_files:
try:
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
except Exception:
continue
new_lines, changed = self._fix_list_spacing(lines)
if changed:
if not check_only:
path.write_text("".join(new_lines), encoding="utf-8")
modified.append(path)
if modified:
label = "Would modify" if check_only else "Modified"
console.print(f"[yellow]lists: {label} {len(modified)} file(s)[/yellow]")
for p in modified[:10]:
console.print(f" {self._rel(p)}")
if len(modified) > 10:
console.print(f" [dim]... {len(modified) - 10} more[/dim]")
return False
else:
console.print("[green]lists: All files clean[/green]")
return True
@staticmethod
def _fix_list_spacing(lines: List[str]) -> tuple:
"""Insert blank line between colon-ending paragraph and bullet list.
Returns (new_lines, changed).
"""
new_lines = []
changed = False
in_code_block = False
for i, line in enumerate(lines):
stripped = line.rstrip()
if stripped.startswith("```"):
in_code_block = not in_code_block
new_lines.append(line)
if in_code_block or i + 1 >= len(lines):
continue
next_line = lines[i + 1].rstrip()
if (
stripped
and stripped.endswith(":")
and not stripped.startswith("```")
and not stripped.startswith(":::")
and not stripped.startswith("#")
and not stripped.startswith("|")
and next_line.startswith("- ")
):
new_lines.append("\n")
changed = True
return new_lines, changed
# ------------------------------------------------------------------
# Python (delegates to format_python_in_qmd.py)
# ------------------------------------------------------------------
def _run_python(self, file_args: List[str], check_only: bool) -> bool:
"""Format Python code blocks using Black."""
script = _SCRIPT_PATHS["python"]
if not script.exists():
console.print(f"[red]python: Script not found: {script}[/red]")
return False
qmd_files = self._resolve_files(file_args)
if not qmd_files:
console.print("[green]python: No files to process[/green]")
return True
cmd = [sys.executable, str(script)] + [str(f) for f in qmd_files]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
console.print("[green]python: All files clean[/green]")
return True
else:
if result.stdout:
for line in result.stdout.strip().splitlines()[:10]:
console.print(f" [yellow]{line}[/yellow]")
console.print(f"[yellow]python: {len(qmd_files)} file(s) processed[/yellow]")
return False
# ------------------------------------------------------------------
# Divs (delegates to format_div_spacing.py)
# ------------------------------------------------------------------
def _run_divs(self, file_args: List[str], check_only: bool) -> bool:
"""Fix div/callout spacing."""
script = _SCRIPT_PATHS["divs"]
if not script.exists():
console.print(f"[red]divs: Script not found: {script}[/red]")
return False
qmd_files = self._resolve_files(file_args)
if not qmd_files:
console.print("[green]divs: No files to process[/green]")
return True
modified_count = 0
for path in qmd_files:
mode = "--check" if check_only else "-f"
cmd = [sys.executable, str(script), mode, str(path)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
modified_count += 1
if modified_count:
label = "Would modify" if check_only else "Modified"
console.print(f"[yellow]divs: {label} {modified_count} file(s)[/yellow]")
return False
else:
console.print("[green]divs: All files clean[/green]")
return True
# ------------------------------------------------------------------
# Tables (delegates to format_tables.py)
# ------------------------------------------------------------------
def _run_tables(self, file_args: List[str], check_only: bool) -> bool:
"""Prettify grid tables."""
script = _SCRIPT_PATHS["tables"]
if not script.exists():
console.print(f"[red]tables: Script not found: {script}[/red]")
return False
mode = "--check" if check_only else "--fix"
if file_args:
cmd = [sys.executable, str(script), mode]
for f in file_args:
cmd.extend(["-f", f])
else:
content_dir = self.config_manager.book_dir / "contents"
cmd = [sys.executable, str(script), mode, "-d", str(content_dir)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
console.print("[green]tables: All files clean[/green]")
return True
else:
if result.stdout:
for line in result.stdout.strip().splitlines()[:10]:
console.print(f" [yellow]{line}[/yellow]")
console.print(f"[yellow]tables: Issues found (exit {result.returncode})[/yellow]")
return False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _rel(self, path: Path) -> str:
"""Return path relative to book dir for display."""
try:
return str(path.relative_to(self.config_manager.book_dir))
except ValueError:
return str(path)

View File

@@ -92,56 +92,83 @@ EXCLUDED_CITATION_PREFIXES = ("fig-", "tbl-", "sec-", "eq-", "lst-", "ch-")
class ValidateCommand:
"""Native `binder validate` command group."""
"""Native `binder check` command group (also available as `binder validate`).
Groups:
refs — inline-python, cross-refs, citations, inline patterns
labels — duplicate labels, orphaned/unreferenced labels
headers — section header IDs
footnotes — placement rules, reference integrity
figures — captions/alt-text, float flow, image files
rendering — render patterns, indexes, dropcaps, parts
all — run every check
"""
# Maps group name → list of (scope_name, runner_method_name) pairs.
# This is the single source of truth for the hierarchy.
GROUPS: Dict[str, List[tuple]] = {
"refs": [
("inline-python", "_run_inline_python"),
("cross-refs", "_run_refs"),
("citations", "_run_citations"),
("inline", "_run_inline_refs"),
],
"labels": [
("duplicates", "_run_duplicate_labels"),
("orphans", "_run_unreferenced_labels"),
],
"headers": [
("ids", "_run_headers"),
],
"footnotes": [
("placement", "_run_footnote_placement"),
("integrity", "_run_footnote_refs"),
],
"figures": [
("captions", "_run_figures"),
("flow", "_run_float_flow"),
("files", "_run_images"),
],
"rendering": [
("patterns", "_run_rendering"),
("indexes", "_run_indexes"),
("dropcaps", "_run_dropcaps"),
("parts", "_run_parts"),
],
}
def __init__(self, config_manager, chapter_discovery):
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
def run(self, args: List[str]) -> bool:
all_group_names = list(self.GROUPS.keys()) + ["all"]
parser = argparse.ArgumentParser(
prog="binder validate",
description="Run Binder-native validation checks",
prog="binder check",
description="Run quality checks on book content",
add_help=True,
)
parser.add_argument(
"subcommand",
nargs="?",
choices=[
"inline-python",
"refs",
"citations",
"duplicate-labels",
"unreferenced-labels",
"inline-refs",
"headers",
"footnote-placement",
"footnote-refs",
"figures",
"float-flow",
"indexes",
"rendering",
"dropcaps",
"parts",
"images",
"all",
],
help="Validation command to run",
choices=all_group_names,
help="Check group to run (refs, labels, headers, footnotes, figures, rendering, all)",
)
parser.add_argument("--path", default=None, help="File or directory path to validate")
parser.add_argument("--vol1", action="store_true", help="Scope validation to Volume I")
parser.add_argument("--vol2", action="store_true", help="Scope validation to Volume II")
parser.add_argument("--scope", default=None, help="Narrow to a specific check within a group")
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", help="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")
parser.add_argument("--check-patterns", action="store_true", help="inline-refs: include pattern hazard checks")
parser.add_argument("--figures", action="store_true", help="duplicate/unreferenced labels: figures")
parser.add_argument("--tables", action="store_true", help="duplicate/unreferenced labels: tables")
parser.add_argument("--sections", action="store_true", help="duplicate/unreferenced labels: sections")
parser.add_argument("--equations", action="store_true", help="duplicate/unreferenced labels: equations")
parser.add_argument("--listings", action="store_true", help="duplicate/unreferenced labels: listings")
parser.add_argument("--all-types", action="store_true", help="duplicate/unreferenced labels: all types")
parser.add_argument("--check-patterns", action="store_true", help="refs --scope inline: include pattern hazard checks")
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")
try:
ns = parser.parse_args(args)
@@ -150,7 +177,7 @@ class ValidateCommand:
return ("-h" in args) or ("--help" in args)
if not ns.subcommand:
parser.print_help()
self._print_check_help()
return False
root_path = self._resolve_path(ns.path, ns.vol1, ns.vol2)
@@ -159,58 +186,19 @@ class ValidateCommand:
return False
runs: List[ValidationRunResult] = []
if ns.subcommand == "all":
runs.append(self._run_inline_python(root_path))
runs.append(self._run_refs(root_path, citations_in_code=True, citations_in_raw=True))
runs.append(self._run_citations(root_path))
label_types = self._selected_label_types(ns)
runs.append(self._run_duplicate_labels(root_path, label_types))
runs.append(self._run_unreferenced_labels(root_path, label_types))
runs.append(self._run_inline_refs(root_path, check_patterns=ns.check_patterns))
runs.append(self._run_headers(root_path))
runs.append(self._run_footnote_placement(root_path))
runs.append(self._run_footnote_refs(root_path))
runs.append(self._run_figures(root_path))
runs.append(self._run_float_flow(root_path))
runs.append(self._run_indexes(root_path))
runs.append(self._run_rendering(root_path))
runs.append(self._run_dropcaps(root_path))
runs.append(self._run_parts(root_path))
runs.append(self._run_images(root_path))
elif ns.subcommand == "inline-python":
runs.append(self._run_inline_python(root_path))
elif ns.subcommand == "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)
runs.append(self._run_refs(root_path, citations_in_code=checks_code, citations_in_raw=checks_raw))
elif ns.subcommand == "citations":
runs.append(self._run_citations(root_path))
elif ns.subcommand == "duplicate-labels":
runs.append(self._run_duplicate_labels(root_path, self._selected_label_types(ns)))
elif ns.subcommand == "unreferenced-labels":
runs.append(self._run_unreferenced_labels(root_path, self._selected_label_types(ns)))
elif ns.subcommand == "inline-refs":
runs.append(self._run_inline_refs(root_path, check_patterns=ns.check_patterns))
elif ns.subcommand == "headers":
runs.append(self._run_headers(root_path))
elif ns.subcommand == "footnote-placement":
runs.append(self._run_footnote_placement(root_path))
elif ns.subcommand == "footnote-refs":
runs.append(self._run_footnote_refs(root_path))
elif ns.subcommand == "figures":
runs.append(self._run_figures(root_path))
elif ns.subcommand == "float-flow":
runs.append(self._run_float_flow(root_path))
elif ns.subcommand == "indexes":
runs.append(self._run_indexes(root_path))
elif ns.subcommand == "rendering":
runs.append(self._run_rendering(root_path))
elif ns.subcommand == "dropcaps":
runs.append(self._run_dropcaps(root_path))
elif ns.subcommand == "parts":
runs.append(self._run_parts(root_path))
elif ns.subcommand == "images":
runs.append(self._run_images(root_path))
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(s == scope for s, _ in self.GROUPS.get(group_name, [])):
valid = [s for s, _ 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 = {
@@ -228,6 +216,67 @@ class ValidateCommand:
return not any_failed
# ------------------------------------------------------------------
# Group dispatch
# ------------------------------------------------------------------
def _run_group(
self,
group: str,
scope: Optional[str],
root: Path,
ns: argparse.Namespace,
) -> List[ValidationRunResult]:
"""Run all checks in *group*, or just the one matching *scope*."""
results: List[ValidationRunResult] = []
for scope_name, method_name in self.GROUPS[group]:
if scope and scope != scope_name:
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))
elif method_name in ("_run_duplicate_labels", "_run_unreferenced_labels"):
results.append(method(root, self._selected_label_types(ns)))
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",
"labels": "Duplicate and orphaned labels",
"headers": "Section header IDs ({#sec-...})",
"footnotes": "Footnote placement and integrity",
"figures": "Captions, float flow, image files",
"rendering": "Render patterns, indexes, dropcaps, parts",
}
for group_name, checks in self.GROUPS.items():
scopes = ", ".join(s for s, _ in checks)
desc = descriptions.get(group_name, "")
table.add_row(group_name, scopes, desc)
table.add_row("all", "(everything)", "Run all checks")
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]# all reference checks[/dim]")
console.print(" [cyan]./binder check refs --scope citations[/cyan] [dim]# only citation check[/dim]")
console.print(" [cyan]./binder check figures --vol1[/cyan] [dim]# all figure checks, Vol I[/dim]")
console.print(" [cyan]./binder check all[/cyan] [dim]# everything[/dim]")
console.print()
# ------------------------------------------------------------------
def _resolve_path(self, path_arg: Optional[str], vol1: bool, vol2: bool) -> Path:
if path_arg:
path = Path(path_arg)
@@ -747,7 +796,7 @@ class ValidateCommand:
return ValidationRunResult(
name="headers",
description="Verify all section headers have {#sec-...} IDs",
description="Verify section headers have {#sec-...} IDs",
files_checked=len(files),
issues=issues,
elapsed_ms=int((time.time() - start) * 1000),
@@ -1645,7 +1694,7 @@ class ValidateCommand:
f'{run["elapsed_ms"]}ms',
"PASS" if run["passed"] else "FAIL",
)
console.print(Panel(table, title="Binder Validate Summary", border_style="cyan"))
console.print(Panel(table, title="Binder Check Summary", border_style="cyan"))
if total == 0:
console.print("[green]✅ All validation checks passed.[/green]")

View File

@@ -25,6 +25,7 @@ try:
from cli.commands.maintenance import MaintenanceCommand
from cli.commands.debug import DebugCommand
from cli.commands.validate import ValidateCommand
from cli.commands.formatting import FormatCommand
except ImportError:
# When run as local script
from core.config import ConfigManager
@@ -36,6 +37,7 @@ except ImportError:
from commands.maintenance import MaintenanceCommand
from commands.debug import DebugCommand
from commands.validate import ValidateCommand
from commands.formatting import FormatCommand
console = Console()
@@ -66,6 +68,7 @@ class MLSysBookCLI:
self.maintenance_command = MaintenanceCommand(self.config_manager, self.chapter_discovery)
self.debug_command = DebugCommand(self.config_manager, self.chapter_discovery, verbose=verbose)
self.validate_command = ValidateCommand(self.config_manager, self.chapter_discovery)
self.format_command = FormatCommand(self.config_manager, self.chapter_discovery)
def show_banner(self):
"""Display the CLI banner."""
@@ -117,31 +120,38 @@ class MLSysBookCLI:
full_table.add_row("build pdf --all", "Build full book (both volumes)", "./binder build pdf --all")
full_table.add_row("build epub --all", "Build full book (both volumes)", "./binder build epub --all")
# Quality Commands
quality_table = Table(show_header=True, header_style="bold yellow", box=None)
quality_table.add_column("Command", style="yellow", width=38)
quality_table.add_column("Description", style="white", width=30)
quality_table.add_column("Example", style="dim", width=28)
quality_table.add_row("check <group> [--scope ...]", "Run validation checks", "./binder check refs")
quality_table.add_row("check all", "Run all validation checks", "./binder check all --vol1")
quality_table.add_row("fix <topic> <action>", "Fix/manage content", "./binder fix headers add")
quality_table.add_row("format <target>", "Auto-format content", "./binder format tables")
# Management Commands
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
mgmt_table.add_column("Command", style="green", width=35)
mgmt_table.add_column("Command", style="green", width=38)
mgmt_table.add_column("Description", style="white", width=30)
mgmt_table.add_column("Example", style="dim", width=30)
mgmt_table.add_column("Example", style="dim", width=28)
mgmt_table.add_row("debug <fmt> --vol1|--vol2", "Find failing chapter + section", "./binder debug pdf --vol1")
mgmt_table.add_row("debug <fmt> --chapter <ch>", "Section-level debug (skip scan)", "./binder debug pdf --vol1 --chapter intro")
mgmt_table.add_row("maintain <topic> ...", "Run maintenance namespace commands", "./binder maintain repo-health")
mgmt_table.add_row("validate <subcommand>", "Run native validation checks", "./binder validate inline-refs")
mgmt_table.add_row("clean", "Clean build artifacts", "./binder clean")
mgmt_table.add_row("switch <format>", "Switch active config", "./binder switch pdf")
mgmt_table.add_row("list", "List available chapters", "./binder list")
mgmt_table.add_row("status", "Show current config status", "./binder status")
mgmt_table.add_row("doctor", "Run comprehensive health check", "./binder doctor")
mgmt_table.add_row("setup", "Setup development environment", "./binder setup")
mgmt_table.add_row("hello", "Show welcome message", "./binder hello")
mgmt_table.add_row("about", "Show project information", "./binder about")
mgmt_table.add_row("help", "Show this help", "./binder help")
# Display tables
console.print(Panel(fast_table, title="⚡ Fast Chapter Commands", border_style="green"))
console.print(Panel(vol_table, title="📖 Volume Commands", border_style="magenta"))
console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue"))
console.print(Panel(mgmt_table, title="🔧 Management", border_style="yellow"))
console.print(Panel(quality_table, title="✅ Quality & Formatting", border_style="yellow"))
console.print(Panel(mgmt_table, title="🔧 Management", border_style="cyan"))
# Pro Tips
examples = Text()
@@ -331,10 +341,18 @@ class MLSysBookCLI:
"""Handle about command."""
return self.maintenance_command.show_about()
def handle_maintain_command(self, args):
"""Handle maintenance namespace command."""
def handle_check_command(self, args):
"""Handle check (validation) command group."""
return self.validate_command.run(args)
def handle_fix_command(self, args):
"""Handle fix (maintenance) namespace command."""
return self.maintenance_command.run_namespace(args)
def handle_format_command(self, args):
"""Handle format command group."""
return self.format_command.run(args)
def handle_debug_command(self, args):
"""Handle debug command.
@@ -390,10 +408,6 @@ class MLSysBookCLI:
self.chapter_discovery.show_chapters(volume=volume)
return True
def handle_validate_command(self, args):
"""Handle validate command group."""
return self.validate_command.run(args)
def handle_status_command(self, args):
"""Handle status command."""
console.print("[bold blue]📊 MLSysBook CLI Status[/bold blue]")
@@ -428,11 +442,15 @@ class MLSysBookCLI:
"list": self.handle_list_command,
"status": self.handle_status_command,
"doctor": self.handle_doctor_command,
"maintain": self.handle_maintain_command,
"validate": self.handle_validate_command,
"check": self.handle_check_command,
"fix": self.handle_fix_command,
"format": self.handle_format_command,
"setup": self.handle_setup_command,
"hello": self.handle_hello_command,
"about": self.handle_about_command,
# Aliases (backward compat)
"validate": self.handle_check_command,
"maintain": self.handle_fix_command,
"help": lambda args: self.show_help() or True,
}

View File

@@ -28,17 +28,17 @@ export function registerPublishCommands(context: vscode.ExtensionContext): void
});
}),
vscode.commands.registerCommand('mlsysbook.buildGlossary', () => {
void runBookCommand('./book/binder maintain glossary build', root, {
void runBookCommand('./book/binder fix glossary build', root, {
label: 'Build global glossary',
});
}),
vscode.commands.registerCommand('mlsysbook.compressImages', () => {
void runBookCommand('./book/binder maintain images compress --all --smart-compression', root, {
void runBookCommand('./book/binder fix images compress --all --smart-compression', root, {
label: 'Compress images',
});
}),
vscode.commands.registerCommand('mlsysbook.repoHealth', () => {
void runBookCommand('./book/binder maintain repo-health', root, {
void runBookCommand('./book/binder fix repo-health', root, {
label: 'Repo health check',
});
}),

View File

@@ -61,17 +61,17 @@ export const PUBLISH_ACTIONS: ActionDef[] = [
export const MAINTENANCE_ACTIONS: ActionDef[] = [
{ id: 'clean', label: 'Clean Build Artifacts', command: './book/binder clean', icon: 'trash' },
{ id: 'doctor', label: 'Doctor (Health Check)', command: './book/binder doctor', icon: 'heart' },
{ id: 'glossary', label: 'Build Global Glossary', command: './book/binder maintain glossary build', icon: 'book' },
{ id: 'compress-images', label: 'Compress Images (Dry Run, All)', command: './book/binder maintain images compress --all --smart-compression', icon: 'file-media' },
{ id: 'repo-health', label: 'Repo Health Check', command: './book/binder maintain repo-health', icon: 'pulse' },
{ id: 'glossary', label: 'Build Global Glossary', command: './book/binder fix glossary build', icon: 'book' },
{ id: 'compress-images', label: 'Compress Images (Dry Run, All)', command: './book/binder fix images compress --all --smart-compression', icon: 'file-media' },
{ id: 'repo-health', label: 'Repo Health Check', command: './book/binder fix repo-health', icon: 'pulse' },
];
export const VALIDATE_ACTIONS: ActionDef[] = [
{ id: 'validate-all', label: 'Validate: All (Binder Native)', command: './book/binder validate all', icon: 'shield' },
{ id: 'validate-inline-python', label: 'Validate: Inline Python', command: './book/binder validate inline-python', icon: 'code' },
{ id: 'validate-refs', label: 'Validate: References in Raw/Code', command: './book/binder validate refs', icon: 'link' },
{ id: 'validate-citations', label: 'Validate: Citation Keys', command: './book/binder validate citations', icon: 'book' },
{ id: 'validate-duplicate-labels', label: 'Validate: Duplicate Labels', command: './book/binder validate duplicate-labels', icon: 'warning' },
{ id: 'validate-unreferenced-labels', label: 'Validate: Unreferenced Labels', command: './book/binder validate unreferenced-labels', icon: 'search' },
{ id: 'validate-inline-refs', label: 'Validate: Inline Refs', command: './book/binder validate inline-refs --check-patterns', icon: 'symbol-variable' },
export const CHECK_ACTIONS: ActionDef[] = [
{ id: 'check-all', label: 'Check: All', command: './book/binder check all', icon: 'shield' },
{ id: 'check-refs', label: 'Check: References', command: './book/binder check refs', icon: 'link' },
{ id: 'check-labels', label: 'Check: Labels', command: './book/binder check labels', icon: 'search' },
{ id: 'check-headers', label: 'Check: Headers', command: './book/binder check headers', icon: 'symbol-structure' },
{ id: 'check-footnotes', label: 'Check: Footnotes', command: './book/binder check footnotes', icon: 'note' },
{ id: 'check-figures', label: 'Check: Figures', command: './book/binder check figures', icon: 'file-media' },
{ id: 'check-rendering', label: 'Check: Rendering', command: './book/binder check rendering', icon: 'warning' },
];

View File

@@ -259,7 +259,7 @@ export function activate(context: vscode.ExtensionContext): void {
? `--path ${editor.document.uri.fsPath}`
: '--vol1';
runInVisibleTerminal(
`./book/binder maintain headers add ${target} --force`,
`./book/binder fix headers add ${target} --force`,
root,
'Add Section IDs',
);
@@ -267,7 +267,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand('mlsysbook.verifySectionIds', () => {
if (!root) { return; }
runInVisibleTerminal(
'./book/binder validate headers --vol1',
'./book/binder check headers --vol1',
root,
'Verify Section IDs',
);
@@ -275,7 +275,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand('mlsysbook.validateCrossReferences', () => {
if (!root) { return; }
runInVisibleTerminal(
'./book/binder validate unreferenced-labels --vol1 --all-types',
'./book/binder check labels --scope orphans --vol1 --all-types',
root,
'Validate Cross-References',
);

View File

@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, VALIDATE_ACTIONS } from '../constants';
import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, CHECK_ACTIONS } from '../constants';
import { ActionTreeItem } from '../models/treeItems';
type TreeNode = ActionTreeItem | SeparatorItem;
@@ -49,7 +49,7 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider<TreeNode>
'table',
);
const validateItems = VALIDATE_ACTIONS.map(action =>
const binderCheckItems = CHECK_ACTIONS.map(action =>
new ActionTreeItem(action.label, 'mlsysbook.validateRunAction', [action.command], action.icon)
);
@@ -61,8 +61,8 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider<TreeNode>
currentFileFixers,
currentFileTableFixer,
...fixerItems,
new SeparatorItem('--- Binder Validate (Fast, Focused) ---'),
...validateItems,
new SeparatorItem('--- Binder Check (Fast, Focused) ---'),
...binderCheckItems,
];
}
}