Files
hongjr03 3f9b60f692 chore: remove Lean spec; ADRs are the single source of truth
The spec/ Lean semantic master had no conformance gate, no codegen, and
no CI tie to implementations — alignment was carried entirely by human
review, the same mechanism that carries the ADRs. In practice the ADRs
plus greppable code comments were already the load-bearing artifacts,
so spec/ was the most expensive kind of stale documentation.

- delete spec/ and the spec-check CI workflow
- README: constitution rewritten around ADRs as decision truth
- AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR,
  never rewrite ADR history; supersede instead)
- code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers
  (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma)
- leave ADR bodies and .scratch audit snapshots untouched (history);
  fix live references in open readiness tickets
2026-07-20 09:07:26 +00:00

933 lines
35 KiB
Rust
Raw Permalink 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";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by 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
/// 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.
///
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// kept greppable here so a reviewer can tie the orchestrator's gate to
/// the ADR.
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<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,
},
)
}
}
}
/// 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<i32>,
/// 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<ShellStepOutcome>,
/// `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 <run>` / `cmd /C <run>`) 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 <name>`, 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' `<field>.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<String>,
/// `None` on success; an error message if reading a required file or writing
/// the artifact failed.
pub error: Option<String>,
}
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<MarkdownAssembleOutcome>,
/// `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 `<field>.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 `<part.path>/<field>.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 <name>`,
/// 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<String> = 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 `<field>.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 `<field>.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/<rel>`) into the build root
// (`build_root/<rel>`), 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<String> = 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<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 **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 <name>` (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<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 {
// 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<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
}