//! `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"; /// 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/Check/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, /// `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. /// /// **Legality decision (spec alignment).** `!has_errors()` is the /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`): /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI /// gate enforcing this alignment (repo constitution); it is kept greppable /// here so a reviewer can tie the orchestrator's gate to the Lean master. 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)` the target's `covers` declaration does not include. 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>, 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, }, ) } } } /// One shell step's execution outcome (for [`run_shell_target`]). #[derive(Debug, Clone, PartialEq)] pub struct ShellStepOutcome { /// The command line that was run (verbatim from the manifest `run` field). pub run: String, /// Process exit code (`None` if the process was killed by a signal). pub status: Option, /// Captured stdout. pub stdout: String, /// Captured stderr. pub stderr: String, } impl ShellStepOutcome { /// Whether the step exited successfully (status 0). pub fn ok(&self) -> bool { self.status == Some(0) } } /// The result of [`run_shell_target`]. #[derive(Debug, Clone, PartialEq)] pub struct ShellRunReport { /// The check phases' report (load → structural → schema). Shell steps run /// only when this has no errors. pub check: CheckReport, /// Each shell step's outcome, in declared order. Empty if the build was /// refused (check errors, unknown target, or the target has no shell steps). pub outcomes: Vec, /// `true` if the target was found, check passed, and every shell step exited 0. pub ok: bool, } /// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009 /// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI). /// /// This is the **escape-hatch executor**: unlike [`build`] (which compiles a /// typst template to a PDF), a shell target hands off to an external tool that /// writes files itself. cph only *runs the declared command* in the engineering /// root and reports what happened — it assembles nothing. /// /// Contract / safety: /// - Runs the check phases first (load → structural → schema). Any error refuses /// the run (`ok == false`, no command executed) — a shell build of a broken /// lesson is not attempted. /// - Each `Step::Shell { run }` is executed via the platform shell /// (`sh -c ` / `cmd /C `) with the **engineering root as the working /// directory**, so a manifest can use root-relative paths. /// - **Arbitrary command execution is opt-in by construction**: this function is /// only reached from `cph build --target `, never from `check`. Callers /// (the CLI) surface the command before running it. /// - `TypstCompile` steps inside the same target are skipped here (a shell target /// is not a typst build); a target with no shell steps yields `ok == false`. pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport { let mut diags = Vec::new(); let (lesson, load_diags) = cph_model::load(root); diags.extend(load_diags); let Some(lesson) = lesson else { return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: false, }, outcomes: Vec::new(), ok: false, }; }; let known = cph_schema::known_kinds(); run_structural_and_schema(&lesson, known, &mut diags); // Suppress the unused-parameter warning while keeping the signature uniform // with `build`/`check` (the engine is not needed to run shell steps, but // callers pass it so the API is consistent and future steps may use it). let _ = engine; let has_error = diags.iter().any(|d| d.severity == Severity::Error); let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{target}' not declared in manifest"), ) .with_hint(format!( "add a `[targets.{target}]` table, or run a declared target" )), ); return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; }; let shell_steps: Vec<&str> = tc .steps .iter() .filter_map(|s| match s { cph_model::Step::Shell { run } => Some(run.as_str()), cph_model::Step::TypstCompile { .. } => None, cph_model::Step::AssembleMarkdown { .. } => None, }) .collect(); if has_error || shell_steps.is_empty() { return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; } // Run each shell step in the engineering root. First non-zero exit stops the // sequence (a failed step's successors likely depend on it). let mut outcomes = Vec::new(); let mut all_ok = true; for run in shell_steps { let outcome = run_one_shell(&lesson.root, run); let ok = outcome.ok(); outcomes.push(outcome); if !ok { all_ok = false; break; } } ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes, ok: all_ok, } } /// Whether the named target is a **shell-step** target (its steps contain at /// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated /// asset bundle that [`run_shell_target`] executes rather than [`build`] /// compiling to PDF. /// /// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an /// unknown target returns `false`, so the caller falls through to the normal /// (typst) build path, which then reports the real error. pub fn target_is_shell(root: &Path, target: &str) -> bool { let (Some(lesson), _) = cph_model::load(root) else { return false; }; lesson .targets .iter() .find(|t| t.name == target) .is_some_and(|t| { t.steps .iter() .any(|s| matches!(s, cph_model::Step::Shell { .. })) && !t .steps .iter() .any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) }) } /// Run one shell command line in `cwd` via the platform shell, capturing output. fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome { use std::process::Command; let output = if cfg!(target_os = "windows") { Command::new("cmd") .arg("/C") .arg(run) .current_dir(cwd) .output() } else { Command::new("sh") .arg("-c") .arg(run) .current_dir(cwd) .output() }; match output { Ok(out) => ShellStepOutcome { run: run.to_string(), status: out.status.code(), stdout: String::from_utf8_lossy(&out.stdout).into_owned(), stderr: String::from_utf8_lossy(&out.stderr).into_owned(), }, Err(e) => ShellStepOutcome { run: run.to_string(), status: None, stdout: String::new(), stderr: format!("failed to spawn command: {e}"), }, } } /// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]). #[derive(Debug, Clone, PartialEq)] pub struct MarkdownAssembleOutcome { /// The field name that was assembled (verbatim from the step's `field`). pub field: String, /// How many elements' `.md` files were read and concatenated. pub parts_read: usize, /// The assembled markdown body (empty if nothing was read). pub body: String, /// Where the assembled file was written (relative to the engineering root); /// `None` if the write failed or was skipped. pub written: Option, /// `None` on success; an error message if reading a required file or writing /// the artifact failed. pub error: Option, } impl MarkdownAssembleOutcome { /// Whether the step succeeded (wrote the artifact with no error). pub fn ok(&self) -> bool { self.error.is_none() && self.written.is_some() } } /// The result of [`run_markdown_assemble_target`]. #[derive(Debug, Clone, PartialEq)] pub struct MarkdownAssembleReport { /// The check phases' report (load → structural → schema). Assembly runs only /// when this has no errors. pub check: CheckReport, /// Each assemble-markdown step's outcome, in declared order. Empty if the /// build was refused (check errors, unknown target, no assemble steps). pub outcomes: Vec, /// `true` if the target was found, check passed, and every assemble step wrote /// its artifact without error. pub ok: bool, } /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each /// element's `.md` markdown content file in `[[parts]]` order into the /// target's single-file artifact. This is the **third typed step**: unlike /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write /// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// /// Contract / safety (same three boundaries as the shell step, ADR-0013/0015): /// - Runs the check phases first (load → structural → schema). Any error refuses /// the run (`ok == false`, nothing written) — assembling a broken lesson is /// not attempted. /// - Walks `lesson.parts` **in declared order**, reading `/.md` /// when it exists (skipping absent ones — these fields are optional), joining /// them with a blank line, and writing to the artifact's single-file /// `filepath` (relative to the engineering root). Each `slides.md`/etc. /// authors its own heading; the assembler injects nothing (presentation in the /// content, ADR-0011). /// - **Opt-in by construction**: only reached from `cph build --target `, /// never from `check`. A non-typst target, so `check`'s compile phase skips it. /// - A write error is a **build-process error**, not a lesson diagnostic — it is /// reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy. pub fn run_markdown_assemble_target( root: &Path, engine: &Engine, target: &str, ) -> MarkdownAssembleReport { let mut diags = Vec::new(); let (lesson, load_diags) = cph_model::load(root); diags.extend(load_diags); let Some(lesson) = lesson else { return MarkdownAssembleReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: false, }, outcomes: Vec::new(), ok: false, }; }; let known = cph_schema::known_kinds(); run_structural_and_schema(&lesson, known, &mut diags); // Signature uniform with `build`/`run_shell_target`; the engine is not used // to assemble markdown (no typst compile), but callers pass it for API // consistency. let _ = engine; let has_error = diags.iter().any(|d| d.severity == Severity::Error); let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{target}' not declared in manifest"), ) .with_hint(format!( "add a `[targets.{target}]` table, or run a declared target" )), ); return MarkdownAssembleReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; }; let assemble_steps: Vec<&str> = tc .steps .iter() .filter_map(|s| match s { cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()), _ => None, }) .collect(); if has_error || assemble_steps.is_empty() { return MarkdownAssembleReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; } // Run each assemble-markdown step. First failure stops the sequence. let mut outcomes = Vec::new(); let mut all_ok = true; for field in assemble_steps { let outcome = assemble_one(&lesson, field); let ok = outcome.ok(); outcomes.push(outcome); if !ok { all_ok = false; break; } } MarkdownAssembleReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes, ok: all_ok, } } /// Assemble one markdown `field` across all parts in order, writing the /// single-file artifact. fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome { let mut chunks: Vec = Vec::new(); let mut parts_read = 0usize; // Inject the course-level title as the document's h1. The title is course // metadata (from `[info]` in the manifest), not per-element content, so the // assembler prepends it; each element's `.md` then contributes its // `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the // content" is about per-element presentation; the single course title is a // document-level fact the assembler owns.) if !lesson.info.title.trim().is_empty() { chunks.push(format!("# {}", lesson.info.title.trim())); } for part in &lesson.parts { if !part.descriptor.dir.is_dir() { continue; } let md = part.descriptor.dir.join(format!("{field}.md")); if md.is_file() { match std::fs::read_to_string(&md) { Ok(body) => { chunks.push(body); parts_read += 1; } Err(e) => { return MarkdownAssembleOutcome { field: field.to_string(), parts_read, body: String::new(), written: None, error: Some(format!("failed to read '{}': {e}", md.display())), }; } } } // Absent `.md` is fine — the field is optional (ADR-0015). } let body = chunks.join("\n\n"); // Resolve the target's single-file artifact path. (The assemble target is a // single-file artifact by construction; a file-tree target has no single // filepath to write to, which is a manifest error surfaced here.) let tc = lesson.targets.iter().find(|t| { t.steps .iter() .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field)) }); let Some(tc) = tc else { return MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: None, error: Some("no target carries this assemble-markdown step".into()), }; }; let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else { return MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: None, error: Some(format!( "target '{}' has a non-single-file artifact; assemble-markdown writes a single file", tc.name )), }; }; let out_path = lesson.root.join(filepath); if let Some(parent) = out_path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { return MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: None, error: Some(format!("failed to create '{}': {e}", parent.display())), }; } } match std::fs::write(&out_path, &body) { Ok(()) => { // Collect referenced image assets so the build dir is self-contained. // The assembled `body` is written under `build_root` (the artifact // filepath's parent); its `![](rel)` image references resolve against // that build root. So each referenced local image is copied from the // engineering root (`lesson.root/`) into the build root // (`build_root/`), preserving its relative path. A referenced // image that is missing at the engineering root is a build-process // error (a self-contained build with a dangling ref is broken) — it // does not enter the 6-class diagnostic taxonomy (ADR-0015). if let Some(build_root) = out_path.parent() { if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) { return MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: Some(filepath.display().to_string()), error: Some(e), }; } } MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: Some(filepath.display().to_string()), error: None, } } Err(e) => MarkdownAssembleOutcome { field: field.to_string(), parts_read, body, written: None, error: Some(format!("failed to write '{}': {e}", out_path.display())), }, } } /// Copy each local markdown image reference in `body` from the engineering root /// `eng_root` into the build root `build_root`, preserving the reference's /// relative path. Returns `Err(message)` on the first referenced image that is /// missing at `eng_root` (a build-process error, ADR-0015). /// /// References are `![alt](target)` markdown images; `target` is taken up to the /// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs /// (`http://`, `https://`, `data:`) are left untouched — they are not local /// assets to collect. Duplicate references copy once. fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> { let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); for target in image_refs(body) { if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") || target.starts_with("data:") { continue; } if !seen.insert(target.to_string()) { continue; } let src = eng_root.join(target); if !src.is_file() { return Err(format!( "slides references image '{}' which is missing from the engineering root", src.display() )); } let dst = build_root.join(target); if let Some(parent) = dst.parent() { if let Err(e) = std::fs::create_dir_all(parent) { return Err(format!("failed to create '{}': {e}", parent.display())); } } if let Err(e) = std::fs::copy(&src, &dst) { return Err(format!( "failed to copy '{}' → '{}': {e}", src.display(), dst.display() )); } } Ok(()) } /// Scan `body` for markdown image references `![alt](target)` and yield each /// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid /// pulling a regex dependency for a single, simple pattern. fn image_refs(body: &str) -> Vec<&str> { let bytes = body.as_bytes(); let mut out = Vec::new(); let mut i = 0; while i + 1 < bytes.len() { // Look for the `![` opener. if bytes[i] == b'!' && bytes[i + 1] == b'[' { // Find the matching `]`. if let Some(bracket_close) = body[i + 2..].find(']') { let after_bracket = i + 2 + bracket_close + 1; if after_bracket < bytes.len() && bytes[after_bracket] == b'(' { // Find the closing `)` of the link target. if let Some(paren_close) = body[after_bracket + 1..].find(')') { let target_start = after_bracket + 1; let target_end = target_start + paren_close; let target = &body[target_start..target_end]; // Trim a possible ` "title"` suffix: take up to first space. let target = target.split_whitespace().next().unwrap_or(""); out.push(target); i = target_end + 1; continue; } } } } i += 1; } out } /// Whether the named target is an **assemble-markdown** target (its steps /// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) — /// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds /// rather than [`build`] compiling to PDF. /// /// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an /// unknown target returns `false`, so the caller falls through to the normal /// (typst) build path, which then reports the real error. pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool { let (Some(lesson), _) = cph_model::load(root) else { return false; }; lesson .targets .iter() .find(|t| t.name == target) .is_some_and(|t| { t.steps .iter() .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. })) && !t .steps .iter() .any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) }) } /// 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, ) { // (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 **typst-compile** /// target, or `["student"]` if the lesson declares none. /// /// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle — /// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is /// **not** typst-compiled, so it is excluded here: compile-checking it would /// wrongly route a non-typst build through the typst engine. Such targets are /// executed only on an explicit `cph build --target ` (see /// [`run_shell_target`]); `check` validates the lesson structure, not the /// outcome of running an external tool. fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { return vec![DEFAULT_TARGET]; } lesson .targets .iter() .filter(|t| { t.steps .iter() .any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) }) .map(|t| t.name.as_str()) .collect() } /// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, /// emit a `RenderIgnored` **warning** when the target does not cover that kind. /// /// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's /// `RenderConfig.covers k t` in `Export/Render.lean`): a target's /// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not /// in that list draws the warning. An **absent** `covers` (the loader's `None`) /// is resolved here — the orchestrator owns the kind universe via `cph-schema` — /// to "covers all known kinds", so an undeclared target is fully covering rather /// than covering nothing. (An explicit `covers = []` stays empty: it covers /// nothing on purpose.) 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 { 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 { // Resolve the coverage set: an absent declaration defaults to the // full known-kind universe; an explicit list (possibly empty) is // taken verbatim. let covered = match &target.covers { None => true, Some(kinds) => kinds.iter().any(|k| k == &part.kind), }; if covered { continue; } let key = (part.kind.clone(), target.name.clone()); if seen.contains(&key) { continue; } seen.push(key); let mut d = Diagnostic::warning( DiagCode::RenderIgnored, format!( "kind '{}' is not covered by target '{}'; it will be omitted from that export", part.kind, target.name ), ) .with_hint(format!( "add '{}' to `covers` under target '{}', or remove the target", part.kind, target.name )); // 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) -> Vec { let mut out: Vec = Vec::with_capacity(diags.len()); for d in diags { if !out.contains(&d) { out.push(d); } } out }