forked from bai/curriculum-project-hub
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:
+23
-17
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user