mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
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:
@@ -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$
|
||||
|
||||
394
book/cli/commands/formatting.py
Normal file
394
book/cli/commands/formatting.py
Normal 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)
|
||||
@@ -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]")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user