feat(checker): cph-check orchestrator + cph-cli cph binary (WU-5)

Closes the core pipeline: the `cph` CLI checks an engineering file and compiles
a PDF.

cph-check — phases (a) load → (b) structural (known-kind set) → (c) schema →
(d) typst compile → (e) render-coverage. Compile is gated: skipped if (a)-(c)
produced any Error (broken input would only add noise); `check` compiles every
declared target (dedup identical diags), `build` compiles the one requested.
Render-coverage emits a non-blocking RenderIgnored *warning* when a (kind,
target) has no render rule — severity pinned via RENDER_IGNORED_SEVERITY const
citing spec Diagnostic.renderIgnoredSeverity + ADR-0005. No double-emit:
unknown/missing parts are skipped downstream. API: check(root, engine) ->
CheckReport, build(root, engine, target) -> (Option<pdf>, CheckReport).

cph-cli — clap: `cph check <path>` (exit 1 on any Error, warnings → 0) and
`cph build <path> --target <name> [-o out]` (default target student, default
out <path>/build/<target>.pdf). Diagnostics → stderr via Display, results →
stdout. --render-dir override.

End-to-end on examples/TH-141 (39 parts): `check` → 0 errors/0 warnings;
`build` → real PDF student 9pp/395KB, teacher 12pp/474KB. Broken-element check
(unclosed delimiter in a stmt.typ) → span-accurate
`error[E-TYPST-COMPILE]: unclosed delimiter --> lemmas/…/stmt.typ:7:8`, exit 1.
Workspace: fmt + clippy -D warnings + test all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:57:34 +08:00
parent 7e76482a31
commit b86ee24a26
6 changed files with 736 additions and 8 deletions
+4
View File
@@ -9,3 +9,7 @@ name = "cph"
path = "src/main.rs"
[dependencies]
cph-check = { path = "../cph-check" }
cph-diag = { workspace = true }
cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] }
+129 -6
View File
@@ -1,10 +1,133 @@
//! `cph` — the command-line entrypoint for the checker.
//!
//! Owned by **WU-5**. Will parse args, run the `cph-check` pipeline against an
//! engineering file, and print diagnostics. A stub for now so the workspace
//! builds and produces a runnable binary.
//! Owned by **WU-5**. Parses args, constructs the typst [`Engine`], runs the
//! `cph-check` pipeline against an engineering file, and prints diagnostics.
//!
//! Convention: **diagnostics go to stderr, results go to stdout.** `check`
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced.
// WU-5: wire up arg parsing + the check pipeline + diagnostic rendering.
fn main() {
println!("cph: not yet implemented");
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use cph_check::CheckReport;
use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files.
#[derive(Debug, Parser)]
#[command(name = "cph", version, about, long_about = None)]
struct Cli {
/// Override the `cph-render` package directory (the folder containing
/// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution
/// (`CPH_RENDER_DIR` env var, else the repo `render/`).
#[arg(long, global = true, value_name = "DIR")]
render_dir: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Run the full check pipeline and print diagnostics. Exits 1 on any error.
Check {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
},
/// Build a PDF for a render target. Exits 1 if the build fails.
Build {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
/// Render target to export.
#[arg(long, default_value = "student")]
target: String,
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
let engine = match &cli.render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
}
}
/// Print every diagnostic in `report` to stderr, followed by a summary line.
fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics {
eprintln!("{d}");
}
eprintln!(
"{} error{}, {} warning{}",
report.error_count(),
plural(report.error_count()),
report.warning_count(),
plural(report.warning_count()),
);
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
let report = cph_check::check(path, engine);
print_diagnostics(&report);
if report.has_errors() {
ExitCode::FAILURE
} else {
println!("check passed: {}", path.display());
ExitCode::SUCCESS
}
}
fn run_build(
path: &std::path::Path,
engine: &Engine,
target: &str,
out: Option<PathBuf>,
) -> ExitCode {
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build(path, engine, target);
print_diagnostics(&report);
match pdf {
Some(bytes) => {
if let Some(parent) = out_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"error: cannot create output directory '{}': {e}",
parent.display()
);
return ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display());
return ExitCode::FAILURE;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS
}
None => {
eprintln!("build failed: {} errors", report.error_count());
ExitCode::FAILURE
}
}
}