chore(spec+checker): cleanup pass — trim docs, reorg Courseware, covers-as-data, fold DanglingReference (ADR-0012)

Spec母本噪音清理 + 一处 spec↔impl 对齐 + 一处契约自洽修正。

Part A — spec doc 瘦身:每条 doc 收到"语义点 + 标签 + ADR ref + 承载性 why",
把跨文件复读的方法论(element-kind 开放性对比、likec4 画不出、分歧点测试)上移到
module header。OPEN 框架保持清晰(RunState/Capability 完整性仍明示须 surface)。

Part B — Courseware/ 由 10 文件平铺重组为 Model/ Export/ Check/ Open/ 四子命名空间
(namespace Spec.Courseware 不变,零引用改动)+ 四个子 aggregator。lake build 绿(24 jobs)。

Part C — 渲染覆盖落为数据(ADR-0011 对齐):去掉 cph-check 里硬编码的
COVERED_TARGETS,改读 TargetConfig.covers。cph-model 新增 covers: Option<Vec<String>>
(None=未声明,由 cph-check 默认为全部 known kinds;显式 [] 表示不覆盖任何 kind)。
新增 3 个覆盖行为测试。

ADR-0012 — DanglingReference 退役(诊断 7→6 类):其两种情形(未解析 @ref、越界/缺失
相对 import)都是 typst 编译期失败,归 typstCompile。同步移除 Oracle.refsResolve
(被 compiles 蕴含),Legal 少一个合取项。impl 删去从未被发射的 DiagCode::DanglingReference,
闭合 named-but-unemitted 的 spec↔impl 缝。ADR-0010 加修订指针。

OPEN 点(RunState/Capability 完整性、QuestionBank、Course)按既定纪律保持 OPEN,本次
只改善其框架措辞,不决策。

验证:spec lake build 绿;cargo test 全绿(含新增覆盖测试);clippy 静默;
TH-141 check 0 errors/0 warnings 无回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:59:32 +08:00
parent cadf8ad0d3
commit c684d25d50
38 changed files with 813 additions and 714 deletions
+23 -17
View File
@@ -17,15 +17,11 @@ 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
/// (`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,
@@ -62,7 +58,7 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Diagnostic.lean`):
/// 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
@@ -93,7 +89,8 @@ impl CheckReport {
/// 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.
/// `(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`).
@@ -259,14 +256,17 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
}
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
/// emit a `RenderIgnored` **warning** when the pair has no render rule.
/// emit a `RenderIgnored` **warning** when the target does not cover that kind.
///
/// 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.
/// 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.
@@ -278,7 +278,13 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic
continue;
}
for target in &lesson.targets {
let covered = COVERED_TARGETS.contains(&target.name.as_str());
// 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;
}
@@ -290,12 +296,12 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic
let mut d = Diagnostic::warning(
DiagCode::RenderIgnored,
format!(
"kind '{}' has no render rule for target '{}'; it will be omitted from that export",
"kind '{}' is not covered by target '{}'; it will be omitted from that export",
part.kind, target.name
),
)
.with_hint(format!(
"add a render rule for kind '{}' under target '{}', or remove the target",
"add '{}' to `covers` under target '{}', or remove the target",
part.kind, target.name
));
// Pin the severity via the named contract const. `Diagnostic::warning`
+126
View File
@@ -136,6 +136,132 @@ path = "segments/does-not-exist"
cleanup(&tmp);
}
/// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]`
/// body spliced in. Returns nothing; the caller runs `check`.
fn write_segment_lesson_with_target(tmp: &PathBuf, target_body: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "cov"
name = "cov"
[info]
title = "cov"
[[parts]]
kind = "segment"
path = "segments/intro"
{target_body}
"#
),
)
.unwrap();
let part_dir = tmp.join("segments").join("intro");
std::fs::create_dir_all(&part_dir).unwrap();
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(part_dir.join("textbook.typ"), "Hello.\n").unwrap();
}
#[test]
fn covers_omitting_used_kind_warns_render_ignored() {
// A target that declares `covers = ["example"]` does NOT cover the lesson's
// `segment` part → exactly one RenderIgnored warning (non-blocking).
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
covers = ["example"]
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
let render_ignored: Vec<_> = report
.diagnostics
.iter()
.filter(|d| d.code == DiagCode::RenderIgnored)
.collect();
assert_eq!(
render_ignored.len(),
1,
"expected exactly one RenderIgnored warning, got: {:#?}",
report.diagnostics
);
// It is a non-blocking warning, so legality is unaffected by it.
assert_eq!(
report.warning_count(),
1,
"the coverage gap is a warning: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
#[test]
fn covers_including_used_kind_has_no_render_ignored() {
// A target that covers `segment` (the only used kind) → no RenderIgnored.
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
covers = ["segment"]
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
assert!(
!report
.diagnostics
.iter()
.any(|d| d.code == DiagCode::RenderIgnored),
"a covering target should yield no RenderIgnored, got: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
#[test]
fn absent_covers_defaults_to_full_coverage() {
// No `covers` key at all → the orchestrator defaults to all known kinds, so
// even a non-"student"/"teacher" target name covers `segment`. (This is the
// behavior change: coverage now follows the declaration/default, not a
// hardcoded target-name allowlist.)
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
assert!(
!report
.diagnostics
.iter()
.any(|d| d.code == DiagCode::RenderIgnored),
"an absent `covers` defaults to full coverage, got: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
fn tempdir() -> PathBuf {
+8 -6
View File
@@ -18,7 +18,7 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Diagnostic.lean`), whose definition is
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
///
/// ```text
@@ -76,12 +76,15 @@ pub enum DiagCode {
UnknownKind,
/// A required `content` `.typ` file (per the kind schema) is missing.
MissingContentFile,
/// A reference (e.g. a relative import or cross-element link) does not
/// resolve.
DanglingReference,
/// Instance data does not conform to its kind's JSON Schema.
SchemaViolation,
/// A typst source failed to compile.
/// The assembled typst source failed to compile.
///
/// Includes **reference-resolution** failures — an unresolved cross-reference
/// (`@ref`) or a relative `import`/`include` that escapes the import boundary
/// or names a missing file. typst detects all of these at compile time, so
/// they surface here rather than as a separate diagnostic (ADR-0012 folded
/// the former `DanglingReference` class into this one).
TypstCompile,
/// An element is ignored under a render target (ADR-0005: warning).
RenderIgnored,
@@ -97,7 +100,6 @@ impl DiagCode {
DiagCode::PartPathMissing => "E-PART-PATH",
DiagCode::UnknownKind => "E-UNKNOWN-KIND",
DiagCode::MissingContentFile => "E-MISSING-CONTENT",
DiagCode::DanglingReference => "E-DANGLING-REF",
DiagCode::SchemaViolation => "E-SCHEMA",
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
+88 -3
View File
@@ -4,7 +4,7 @@
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Lesson.lean`), where the order of `parts` carries
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//!
//! Scope boundaries (deliberately staying in lane):
@@ -72,6 +72,9 @@ impl Lesson {
/// - A target with **no `steps`** defaults to a single
/// [`Step::TypstCompile`] with `template = "exports/<name>.typ"` (the
/// framework's stock per-target template).
/// - A target with **no `covers`** key leaves [`TargetConfig::covers`] `None`,
/// meaning "coverage not declared". The consumer (`cph-check`) resolves that
/// to the full known-kind universe — see the field doc.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetConfig {
/// Target name, e.g. `"student"` (the `[targets.<name>]` table key).
@@ -82,12 +85,25 @@ pub struct TargetConfig {
/// 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 **render-coverage declaration**: which element kinds this target
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps.
///
/// `Some(kinds)` is the explicit manifest list (`covers = ["segment", …]`).
/// `None` means the `covers` key was **absent**; the loader does not own the
/// kind universe (it must not depend on `cph-schema`), so it records the
/// absence and lets `cph-check` default it to all known kinds. An empty
/// `Some(vec![])` is distinct: a target that explicitly covers nothing.
pub covers: Option<Vec<String>>,
}
/// 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:
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
///
/// ```text
/// inductive Artifact where
@@ -141,7 +157,7 @@ impl Artifact {
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Render.lean`), whose definition is exactly:
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
///
/// ```text
/// inductive Step where
@@ -572,6 +588,7 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
return TargetConfig {
artifact: Artifact::default_for(&name),
steps: vec![Step::default_for(&name)],
covers: None,
name,
};
}
@@ -587,10 +604,78 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
Some(value) => parse_steps(&name, value, diags),
};
let covers = match table.remove("covers") {
None => None,
Some(value) => parse_covers(&name, value, diags),
};
TargetConfig {
name,
artifact,
steps,
covers,
}
}
/// Parse a target's optional `covers` array into the render-coverage declaration
/// (ADR-0011). Each entry is a kind name (a string). A non-array `covers`, or any
/// non-string entry, is reported as a [`DiagCode::SchemaViolation`]; in that case
/// the declaration falls back to `None` ("not declared"), so `cph-check` defaults
/// it to the full known-kind universe rather than silently covering nothing.
///
/// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that
/// explicitly renders no kind (every used kind then draws a `renderIgnored`
/// warning) — distinct from an absent key.
fn parse_covers(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Vec<String>> {
let items = match value {
toml::Value::Array(items) => items,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-array `covers`; declare it as a list of \
kind names, e.g. `covers = [\"segment\", \"example\"]`"
),
)
.with_hint("set `covers` to an array of kind-name strings, or omit it"),
);
return None;
}
};
let mut kinds = Vec::with_capacity(items.len());
for item in items {
match item {
toml::Value::String(s) => kinds.push(s),
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-string entry in `covers`: {}",
json_like_type(&other)
),
)
.with_hint("each `covers` entry is a kind name (a string)"),
);
return None;
}
}
}
Some(kinds)
}
/// A short type label for a TOML value, for the `covers` non-string diagnostic.
fn json_like_type(v: &toml::Value) -> &'static str {
match v {
toml::Value::String(_) => "string",
toml::Value::Integer(_) => "integer",
toml::Value::Float(_) => "float",
toml::Value::Boolean(_) => "boolean",
toml::Value::Datetime(_) => "datetime",
toml::Value::Array(_) => "array",
toml::Value::Table(_) => "table",
}
}
+2
View File
@@ -211,6 +211,7 @@ fn file_tree_artifact_is_deferred() {
steps: vec![Step::TypstCompile {
template: PathBuf::from("exports/web.typ"),
}],
covers: None,
}],
root: fixture_root(),
};
@@ -248,6 +249,7 @@ fn shell_only_target_is_deferred() {
steps: vec![Step::Shell {
run: "echo hi".into(),
}],
covers: None,
}],
root: fixture_root(),
};