feat(checker): cph-model manifest v2 — Artifact-with-fields + typed steps (WU-B', ADR-0011)

[targets.*] reworked: artifact is now an inline table with a type discriminator
({type="single-file",filepath} / {type="file-tree",root,outputs}) and steps is an
ordered array-of-tables ({type="typst-compile",template} / {type="shell",run}).
numbering removed entirely (presentation lives in the template per ADR-0011).

TargetConfig { name, artifact: Artifact, steps: Vec<Step> }; Artifact and Step
mirror Spec.Courseware.Artifact / Spec.Courseware.Step (doc-cited). Sensible
defaults so a bare [targets.student] still works: single-file build/<name>.pdf +
one typst-compile exports/<name>.typ. Malformed artifact/step type → non-fatal
SchemaViolation with fallback. 8 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:57:50 +08:00
parent de87cddaf1
commit d5bce1bede
4 changed files with 473 additions and 190 deletions
+363 -139
View File
@@ -36,9 +36,8 @@ pub struct Lesson {
pub parts: Vec<Part>,
/// Declared export targets, collected from the `[targets.<name>]` tables.
///
/// Per ADR-0009 an export target is a *build* producing a typed artifact,
/// with a file-resident, overridable presentation config. This carries the
/// file-resident slice of that (artifact choice + presentation overrides),
/// Per ADR-0009/0011 an export target is a *build* producing a typed
/// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those
/// in declared (TOML document) order. See [`TargetConfig`].
pub targets: Vec<TargetConfig>,
/// Engineering-file root (absolute), for resolving part paths.
@@ -56,69 +55,134 @@ impl Lesson {
}
}
/// One declared export target's *file-resident* build config (ADR-0009).
/// One declared export target's build config (ADR-0009/0011).
///
/// An export target is a build producing a typed [`ArtifactKind`]; its defaults
/// are seeded at project creation and **overridable in the engineering file**.
/// This struct models the slice of that build config that lives in the file:
/// the artifact choice and presentation overrides (currently heading
/// numbering). The per-kind render *map* itself stays in the render package
/// (`Spec.Courseware.TargetSpec.renders`), not here.
/// An export target is a **build** producing a typed [`Artifact`] via an
/// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that
/// build: the artifact carries fields (where the product lands / which files it
/// is), and the build is a step list, each step a typed operation. Presentation
/// (heading numbering, styling) lives in the `typstCompile` template file, not
/// in the manifest — so there is no `numbering` field here anymore.
///
/// ## Defaults for a minimal manifest
///
/// - A target with **no `artifact`** key defaults to
/// [`Artifact::SingleFile`] with `filepath = "build/<name>.pdf"`, so an
/// almost-empty `[targets.student]` still produces a usable build.
/// - A target with **no `steps`** defaults to a single
/// [`Step::TypstCompile`] with `template = "exports/<name>.typ"` (the
/// framework's stock per-target template).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetConfig {
/// Target name, e.g. `"student"` (the `[targets.<name>]` table key).
pub name: String,
/// The artifact shape this target produces. Defaults to
/// [`ArtifactKind::SingleFile`] when the `artifact` field is absent.
pub artifact: ArtifactKind,
/// Presentation override for this target. `None` when no presentation
/// config is given in the file (the render layer applies its defaults).
pub numbering: Option<NumberingConfig>,
/// The artifact this target produces. Defaults to a [`Artifact::SingleFile`]
/// at `build/<name>.pdf` when the `artifact` field is absent.
pub artifact: Artifact,
/// The ordered build steps. Defaults to a single [`Step::TypstCompile`]
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>,
}
/// The shape of the artifact an export target produces (ADR-0009).
/// The artifact an export target produces (ADR-0009/0011).
///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly:
///
/// ```text
/// inductive Artifact where
/// | singleFile
/// | fileTree
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ```
///
/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a
/// tree of files plus an index (a third-party-platform archive). The on-disk
/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and
/// `"file-tree"` → [`ArtifactKind::FileTree`].
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
/// is" — one file at some path vs. a tree of files under some root matching a
/// glob — is the non-obvious domain semantics that must be in the type, not
/// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案
/// PDF); `FileTree` is a tree of files (a third-party-platform archive). The
/// on-disk `artifact` inline table's `type` discriminator selects the variant:
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ArtifactKind {
/// One bundled document. Mirrors Lean `Artifact.singleFile`. Default.
SingleFile,
/// A tree of files plus an index. Mirrors Lean `Artifact.fileTree`.
FileTree,
}
impl ArtifactKind {
/// The default artifact shape when `artifact` is omitted: `SingleFile`.
const DEFAULT: ArtifactKind = ArtifactKind::SingleFile;
}
/// File-resident presentation overrides for a target (ADR-0009).
///
/// Each field is `Option` so **absence is distinguishable from a value**: when
/// a field is `None`, the render package applies its framework default; only an
/// explicitly-given value overrides it. This crate deliberately invents no
/// presentation defaults — that ownership stays in the render layer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NumberingConfig {
/// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g.
/// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent.
pub heading: Option<Vec<String>>,
pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
SingleFile {
/// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`.
filepath: PathBuf,
},
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
/// A **glob** describing which files this build produces, e.g.
/// `**/*.{html,js,json}` — kept as a string (the contract pins the
/// field, not a path/glob algebra).
outputs: String,
},
}
impl Artifact {
/// The default artifact when `artifact` is omitted: a single file at
/// `build/<name>.pdf`.
fn default_for(name: &str) -> Artifact {
Artifact::SingleFile {
filepath: PathBuf::from(format!("build/{name}.pdf")),
}
}
}
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Render.lean`), whose definition is exactly:
///
/// ```text
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
/// template file — typed, not a raw shell line, because the framework must wire
/// the manifest into it (`--input manifest=…`), which a bare `typst compile`
/// string cannot express. `Shell` is the escape hatch for steps that resist
/// declaration (ADR-0005's medium-only category (b): HTML interactive / npm
/// builds). The on-disk `[[steps]]` entry's `type` discriminator selects the
/// variant: `"typst-compile"` → [`Step::TypstCompile`], `"shell"` →
/// [`Step::Shell`].
///
/// As with [`Artifact`], this alignment is maintained by review, not CI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
/// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
Shell {
/// The command line to run.
run: String,
},
}
impl Step {
/// The default step when no `[[steps]]` are given: compile the stock
/// per-target template `exports/<name>.typ`.
fn default_for(name: &str) -> Step {
Step::TypstCompile {
template: PathBuf::from(format!("exports/{name}.typ")),
}
}
}
/// `[project]` table.
@@ -296,7 +360,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
};
// Parse each [targets.<name>] table into a structured build config
// (ADR-0009). Order is the TOML document order, as before. Malformed
// (ADR-0009/0011). Order is the TOML document order, as before. Malformed
// target config is non-fatal: it is reported and the target is kept with
// defaults so loading continues.
let targets: Vec<TargetConfig> = raw
@@ -485,13 +549,14 @@ fn load_descriptor(
}
}
/// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009),
/// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009/0011),
/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields.
///
/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the
/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading`
/// leaves the override absent — both reported, never dropping the target. An
/// empty `[targets.<name>]` body is valid (all defaults, no diagnostics).
/// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the
/// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the
/// default single `typst-compile` step ([`Step::default_for`]) — all reported,
/// never dropping the target. An empty `[targets.<name>]` body is valid (all
/// defaults, no diagnostics).
fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -> TargetConfig {
// A target body must be a table; anything else (`student = 1`) is malformed.
let mut table = match value {
@@ -505,127 +570,286 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
.with_hint("declare a target as a `[targets.<name>]` table"),
);
return TargetConfig {
artifact: Artifact::default_for(&name),
steps: vec![Step::default_for(&name)],
name,
artifact: ArtifactKind::DEFAULT,
numbering: None,
};
}
};
let artifact = match table.remove("artifact") {
None => ArtifactKind::DEFAULT,
Some(toml::Value::String(s)) => match s.as_str() {
"single-file" => ArtifactKind::SingleFile,
"file-tree" => ArtifactKind::FileTree,
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has an invalid artifact '{other}'; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
},
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-string `artifact`; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
None => Artifact::default_for(&name),
Some(value) => parse_artifact(&name, value, diags),
};
let numbering = parse_numbering(&name, &mut table, diags);
let steps = match table.remove("steps") {
None => vec![Step::default_for(&name)],
Some(value) => parse_steps(&name, value, diags),
};
TargetConfig {
name,
artifact,
numbering,
steps,
}
}
/// Parse the optional `[targets.<name>.numbering]` sub-table into a
/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present.
/// A malformed `heading` (not an array of strings) is reported as a
/// [`DiagCode::SchemaViolation`] and left absent (`None`).
fn parse_numbering(
name: &str,
table: &mut toml::Table,
diags: &mut Vec<Diagnostic>,
) -> Option<NumberingConfig> {
let numbering = table.remove("numbering")?;
let mut numbering_table = match numbering {
/// Parse the `artifact` inline table into an [`Artifact`]. The `type`
/// discriminator selects the variant and which fields are read. An unknown or
/// malformed `type`, a non-table value, or a missing required field is reported
/// as a [`DiagCode::SchemaViolation`]; in every error case a usable default
/// ([`Artifact::default_for`]) is returned so loading continues.
fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Artifact {
let mut table = match value {
toml::Value::Table(t) => t,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a non-table `numbering`"),
format!(
"target '{name}' has a non-table `artifact`; it must be an \
inline table like `{{ type = \"single-file\", filepath = \"\" }}`"
),
)
.with_hint(format!("declare numbering as `[targets.{name}.numbering]`")),
.with_hint("set `artifact` to an inline table with a `type` discriminator"),
);
return Some(NumberingConfig { heading: None });
return Artifact::default_for(name);
}
};
let heading = match numbering_table.remove("heading") {
None => None,
Some(toml::Value::Array(items)) => {
let mut patterns = Vec::with_capacity(items.len());
let mut malformed = false;
for item in items {
match item {
toml::Value::String(s) => patterns.push(s),
_ => {
malformed = true;
break;
}
}
}
if malformed {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
)
.with_hint(
"`numbering.heading` must be an array of per-level pattern strings",
),
);
None
} else {
Some(patterns)
}
}
let kind = match table.remove("type") {
Some(toml::Value::String(s)) => s,
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
format!("target '{name}' has a non-string `artifact.type`"),
)
.with_hint("`numbering.heading` must be an array of per-level pattern strings"),
.with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
);
None
return Artifact::default_for(name);
}
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has an `artifact` with no `type` discriminator"),
)
.with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"),
);
return Artifact::default_for(name);
}
};
Some(NumberingConfig { heading })
match kind.as_str() {
"single-file" => {
let filepath = match take_string_field(&mut table, "filepath") {
Some(s) => PathBuf::from(s),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' single-file `artifact` is missing a string \
`filepath`"
),
)
.with_hint("add `filepath = \"build/<name>.pdf\"` to the artifact"),
);
return Artifact::default_for(name);
}
};
Artifact::SingleFile { filepath }
}
"file-tree" => {
let root = take_string_field(&mut table, "root");
let outputs = take_string_field(&mut table, "outputs");
match (root, outputs) {
(Some(root), Some(outputs)) => Artifact::FileTree {
root: PathBuf::from(root),
outputs,
},
(root, outputs) => {
let mut missing = Vec::new();
if root.is_none() {
missing.push("root");
}
if outputs.is_none() {
missing.push("outputs");
}
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' file-tree `artifact` is missing required \
string field(s): {}",
missing.join(", ")
),
)
.with_hint(
"a file-tree artifact needs `root = \"<dir>\"` and \
`outputs = \"<glob>\"`",
),
);
Artifact::default_for(name)
}
}
}
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has an invalid `artifact.type` '{other}'; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
);
Artifact::default_for(name)
}
}
}
/// Parse the `steps` array-of-tables into an ordered `Vec<Step>`, preserving
/// declared order. A non-array `steps`, or any individual step with an unknown
/// or malformed `type` / missing required field, is reported as a
/// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while
/// well-formed ones are kept. If `steps` is present but yields no usable steps,
/// the default step ([`Step::default_for`]) is substituted so the build stays
/// runnable.
fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Vec<Step> {
let items = match value {
toml::Value::Array(items) => items,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-array `steps`; declare steps as \
`[[targets.{name}.steps]]` entries"
),
)
.with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
);
return vec![Step::default_for(name)];
}
};
let mut steps = Vec::with_capacity(items.len());
for item in items {
if let Some(step) = parse_step(name, item, diags) {
steps.push(step);
}
}
if steps.is_empty() {
// `steps` was present but produced nothing usable: keep the build
// runnable with the default step (the malformed entries were reported).
vec![Step::default_for(name)]
} else {
steps
}
}
/// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects
/// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`])
/// for a non-table entry, an unknown/malformed `type`, or a missing required
/// field.
fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Step> {
let mut table = match value {
toml::Value::Table(t) => t,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step that is not a table"),
)
.with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
);
return None;
}
};
let kind = match table.remove("type") {
Some(toml::Value::String(s)) => s,
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step with a non-string `type`"),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
);
return None;
}
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step with no `type` discriminator"),
)
.with_hint("add `type = \"typst-compile\"` (or `\"shell\"`) to the step"),
);
return None;
}
};
match kind.as_str() {
"typst-compile" => match take_string_field(&mut table, "template") {
Some(template) => Some(Step::TypstCompile {
template: PathBuf::from(template),
}),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' typst-compile step is missing a string `template`"
),
)
.with_hint("add `template = \"exports/<name>.typ\"` to the step"),
);
None
}
},
"shell" => match take_string_field(&mut table, "run") {
Some(run) => Some(Step::Shell { run }),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' shell step is missing a string `run`"),
)
.with_hint("add `run = \"<command>\"` to the step"),
);
None
}
},
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a step with an invalid `type` '{other}'; \
valid values are \"typst-compile\", \"shell\""
),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
);
None
}
}
}
/// Remove `key` from `table` and return it if it is a string; otherwise `None`
/// (a missing key and a non-string value are both treated as absent — the
/// caller reports the missing-field diagnostic with the right context).
fn take_string_field(table: &mut toml::Table, key: &str) -> Option<String> {
match table.remove(key) {
Some(toml::Value::String(s)) => Some(s),
_ => None,
}
}
/// True if `path` contains any `..` component (parent-dir traversal).