Files
curriculum-project-hub/crates/cph-check/src/lib.rs
T
sjfhsjfh b86ee24a26 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>
2026-06-22 01:57:34 +08:00

317 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `cph-check` — check-pipeline orchestration.
//!
//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema`
//! (validate), and `cph-typst` (compile), runs the render-coverage rule
//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics.
//!
//! The orchestrator owns *sequencing and gating*, not the individual checks:
//! each phase is implemented by an upstream crate, and `cph-check` decides the
//! order, what gates what, and how the results are merged / deduped. See
//! [`check`] for the full pipeline and [`build`] for the export path.
use std::path::Path;
use cph_diag::{DiagCode, Diagnostic, Severity};
use cph_typst::Engine;
/// The default render target when a lesson declares no `[targets.*]` at all.
const DEFAULT_TARGET: &str = "student";
/// The render targets the MVP render package actually covers (student handout +
/// teacher plan). Used by the render-coverage rule (phase (e)).
const COVERED_TARGETS: &[&str] = &["student", "teacher"];
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
#[derive(Debug, Clone, PartialEq)]
pub struct CheckReport {
/// Every diagnostic collected across all phases, deduplicated.
pub diagnostics: Vec<Diagnostic>,
/// `false` if `cph_model::load` returned `None` (a hard manifest failure
/// that prevents any further phase from running).
pub lesson_loaded: bool,
}
impl CheckReport {
/// How many collected diagnostics are `Error`-severity.
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count()
}
/// How many collected diagnostics are `Warning`-severity.
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.count()
}
/// Whether any collected diagnostic is `Error`-severity.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
}
/// Run the full check pipeline against the engineering file at `root`.
///
/// Phases, in order, with gating:
/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard
/// failure (`None`) the report is returned immediately with
/// `lesson_loaded == false`; nothing downstream can run.
/// - **(b) structural** — for each part, check `part.kind` is in
/// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error.
/// (cph-model already checked path existence, `..` traversal, and
/// part/element kind *mismatch*; this complements that with the *known-set*
/// check and does not duplicate it.) Parts whose folder cph-model already
/// flagged as missing are skipped for the rest of the pipeline.
/// - **(c) schema** — for each part whose folder exists and whose kind is
/// known, `cph_schema::validate(&part.descriptor)`.
/// - **(d) typst compile** — only if phases (a)(c) produced **zero** errors
/// (otherwise the compile is noise on known-broken input). Compiles each
/// declared `lesson.targets` (defaulting to `"student"` if none), deduping
/// identical diagnostics across targets.
/// - **(e) render-coverage** — a `warning` (never an error) for each
/// `(kind, target)` lacking a render rule. Runs regardless of (d) gating.
///
/// The engine is taken as a parameter so the caller owns it (and tests can
/// inject `Engine::with_render_dir`).
pub fn check(root: &Path, engine: &Engine) -> CheckReport {
let mut diags = Vec::new();
// (a) load.
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
// Hard manifest failure: nothing else can run.
return CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
};
};
let known = cph_schema::known_kinds();
// (b) structural + (c) schema.
run_structural_and_schema(&lesson, known, &mut diags);
// (d) typst compile — gated on zero errors from (a)(c).
let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error);
if !pre_compile_has_error {
for target in compile_targets(&lesson) {
diags.extend(engine.compile_check(&lesson, target));
}
}
// (e) render-coverage — always runs; only a warning. Skips parts already
// broken in (b) (missing folder / unknown kind).
diags.extend(render_coverage(&lesson, known));
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
}
}
/// Build a PDF for `target`.
///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those
/// produce any `Error`, the build is refused: returns `(None, report)` with the
/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target`
/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`,
/// and on a typst error returns `(None, report)` with the compile diagnostics
/// folded into the report.
///
/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings
/// describe export *loss*, which is informational for a `check`, not a gate on
/// producing a single target's PDF.
pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
let mut diags = Vec::new();
// (a) load.
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
let known = cph_schema::known_kinds();
// (b) structural + (c) schema.
run_structural_and_schema(&lesson, known, &mut diags);
if diags.iter().any(|d| d.severity == Severity::Error) {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
);
}
// Phases passed: compile + export the requested target.
match engine.build_pdf(&lesson, target) {
Ok(bytes) => (
Some(bytes),
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
),
Err(compile_diags) => {
diags.extend(compile_diags);
(
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
)
}
}
}
/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
/// for parts whose folder is absent, so the orchestrator skips those parts in
/// the structural/schema/coverage phases to avoid piling diagnostics on the
/// same root cause.
fn part_exists(p: &cph_model::Part) -> bool {
p.descriptor.dir.is_dir()
}
/// Phases (b) + (c): the known-kind structural check and per-part schema
/// validation. Shared by [`check`] and [`build`] so their gating stays
/// identical.
fn run_structural_and_schema(
lesson: &cph_model::Lesson,
known: &[&str],
diags: &mut Vec<Diagnostic>,
) {
// (b) structural: known-kind check.
for part in &lesson.parts {
if !part_exists(part) {
continue;
}
if !known.contains(&part.kind.as_str()) {
diags.push(
Diagnostic::error(
DiagCode::UnknownKind,
format!(
"part '{}' has unknown kind '{}'",
part.path.display(),
part.kind
),
)
.with_hint(format!("valid kinds are: {}", known.join(", "))),
);
}
}
// (c) schema: validate each existing part with a known kind. Skipping
// unknown kinds avoids a second `UnknownKind` from `cph_schema::validate`
// (already reported in (b)).
for part in &lesson.parts {
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
continue;
}
diags.extend(cph_schema::validate(&part.descriptor));
}
}
/// The targets to compile-check for [`check`]: every declared target, or
/// `["student"]` if the lesson declares none.
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
if lesson.targets.is_empty() {
vec![DEFAULT_TARGET]
} else {
lesson.targets.iter().map(String::as_str).collect()
}
}
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
/// emit a `RenderIgnored` **warning** when the pair has no render rule.
///
/// The render matrix lives in the render package and is not introspectable from
/// Rust in the MVP, so coverage is defined operationally: a kind is covered when
/// it is a known stdlib kind (`known`) AND the target is one the render package
/// handles (`COVERED_TARGETS` = student/teacher). A declared target outside that
/// set, or a kind not in the known set, yields the warning. Parts already broken
/// in (b) (missing folder / unknown kind) are skipped — they are reported there.
fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic> {
let mut out = Vec::new();
// De-dupe (kind, target) pairs so we emit one warning per pair, not per part.
let mut seen: Vec<(String, String)> = Vec::new();
for part in &lesson.parts {
// Skip parts already flagged structurally.
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
continue;
}
for target in &lesson.targets {
let covered = COVERED_TARGETS.contains(&target.as_str());
if covered {
continue;
}
let key = (part.kind.clone(), target.clone());
if seen.contains(&key) {
continue;
}
seen.push(key);
let mut d = Diagnostic::warning(
DiagCode::RenderIgnored,
format!(
"kind '{}' has no render rule for target '{}'; it will be omitted from that export",
part.kind, target
),
)
.with_hint(format!(
"add a render rule for kind '{}' under target '{}', or remove the target",
part.kind, target
));
// Pin the severity via the named contract const. `Diagnostic::warning`
// already produces a warning, but assigning the const keeps the
// alignment to `renderIgnoredSeverity` load-bearing and greppable.
d.severity = RENDER_IGNORED_SEVERITY;
out.push(d);
}
}
out
}
/// Deduplicate diagnostics that are identical (same code+severity+message+span+
/// hint). The same typst error can surface once per compiled target, and the
/// same root cause can be reached by more than one phase; callers see each
/// distinct problem once.
fn dedup(diags: Vec<Diagnostic>) -> Vec<Diagnostic> {
let mut out: Vec<Diagnostic> = Vec::with_capacity(diags.len());
for d in diags {
if !out.contains(&d) {
out.push(d);
}
}
out
}