feat(model+spec): .cph-version 契约文件 + CphVersionMismatch 诊断(ADR-0016);bump 0.0.2

教研工程文件根放 .cph-version(内容=面向的 cph 版本)。cph 加载时比对自身版本
(CARGO_PKG_VERSION),不相容报 CphVersionMismatch error 并拒绝。当前判定为版本完全
相等(MVP);判定逻辑孤立在 versions_compatible 单谓词,后续可放宽为 semver 区间而
不动诊断分类/spec/CLI。

spec(7 类诊断,原 6 类):
- Diagnostic.lean: DiagKind 增 cphVersionMismatch(error 级),分类注释 6→7
- Pipeline.lean: load 阶段含 .cph-version 兼容性判定
- Courseware.lean: ADR 区间 →0016

实现:
- cph-diag: DiagCode::CphVersionMismatch("E-CPH-VERSION")
- cph-model: load() 解析 manifest 成功后 check_cph_version;versions_compatible
  谓词(完全相等);CPH_VERSION const。missing .cph-version 暂跳过(迁移期 OPEN);
  空文件报 error
- examples/TH-141、valid/mini fixture、KenKen 课各加 .cph-version=0.0.2

测试:cph-model 4 个版本门测试;全 workspace 53 passed;lake 25 jobs 绿。
负向验证:9.9.9 .cph-version → E-CPH-VERSION error + check 拒绝(exit 1)。

bump: workspace.package 0.0.1→0.0.2;cph --version 报 cph 0.0.2;README 同步
(含版本契约说明)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:03:54 +08:00
parent 57f7773987
commit ebcb3a7589
13 changed files with 294 additions and 24 deletions
+4
View File
@@ -88,6 +88,9 @@ pub enum DiagCode {
TypstCompile,
/// An element is ignored under a render target (ADR-0005: warning).
RenderIgnored,
/// The engineering file's `.cph-version` is not compatible with the running
/// CLI's version (ADR-0016). Decided at load time; `error` severity.
CphVersionMismatch,
}
impl DiagCode {
@@ -103,6 +106,7 @@ impl DiagCode {
DiagCode::SchemaViolation => "E-SCHEMA",
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
DiagCode::CphVersionMismatch => "E-CPH-VERSION",
}
}
}
+60
View File
@@ -391,6 +391,14 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
}
};
// `.cph-version` compatibility (ADR-0016). Decided at load time, before
// any structural/schema/compile work. An incompatible version is an
// error diagnostic (`CphVersionMismatch`); it does not halt loading, so
// any other defects surface alongside it — but an error diagnostic alone
// already makes the lesson illegal (ADR-0010), so `check`/`build` refuse.
// A missing `.cph-version` is skipped for now (ADR-0016 OPEN).
check_cph_version(root, &mut diags);
// [project] and [info] are required to build a Lesson at all.
let project = match raw.project {
Some(p) => Project {
@@ -506,6 +514,58 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
(Some(lesson), diags)
}
/// The cph version the running CLI was built with (ADR-0016). Pulled from the
/// crate's `CARGO_PKG_VERSION` at compile time.
pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Read the engineering file's `.cph-version` and, if present, push a
/// `CphVersionMismatch` error when it is not compatible with [`CPH_VERSION`]
/// (ADR-0016). A missing file is skipped for now (migration period; OPEN in
/// ADR-0016).
fn check_cph_version(root: &Path, diags: &mut Vec<Diagnostic>) {
let path = root.join(".cph-version");
let Ok(src) = std::fs::read_to_string(&path) else {
return; // missing `.cph-version` — skipped (ADR-0016 OPEN).
};
let file_version = src.trim();
if file_version.is_empty() {
diags.push(
Diagnostic::error(
DiagCode::CphVersionMismatch,
format!(".cph-version is empty; expected cph version {CPH_VERSION}"),
)
.with_hint(format!(
"write the cph version this file targets into {} (currently {CPH_VERSION})",
path.display()
)),
);
return;
}
if !versions_compatible(file_version, CPH_VERSION) {
diags.push(
Diagnostic::error(
DiagCode::CphVersionMismatch,
format!(
".cph-version declares {file_version}, but this cph is {CPH_VERSION} (incompatible)"
),
)
.with_hint(format!(
"align them: set .cph-version to {CPH_VERSION}, or install cph {file_version}"
)),
);
}
}
/// Whether an engineering file's declared cph version is compatible with the
/// running CLI's version (ADR-0016). **The rule is exact equality** — the
/// strictest choice, deliberately, while the format is young (0.0.x). This is
/// the single seam for future relaxation to a semver range: relaxing the
/// format does not change the diagnostic class, the spec, or the CLI — only
/// this predicate.
fn versions_compatible(file_version: &str, cli_version: &str) -> bool {
file_version == cli_version
}
/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting
/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back
/// to the part's declared kind with empty scalars so the lesson stays buildable.
+1
View File
@@ -0,0 +1 @@
0.0.2
+87
View File
@@ -272,3 +272,90 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() {
"a diagnostic should name the bad step type, got: {diags:?}"
);
}
// --- `.cph-version` compatibility gate (ADR-0016) ------------------------------
/// The cph version the running CLI was built with — what `.cph-version` must
/// match exactly (ADR-0016's MVP rule).
const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Write a one-part lesson into a temp dir, optionally with a `.cph-version`
/// file. Returns the temp dir so the caller runs `load`.
fn tmp_lesson_with_version(version: Option<&str>) -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!("cph-version-test-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
std::fs::write(
p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
)
.unwrap();
let seg = p.join("segments").join("a");
std::fs::create_dir_all(&seg).unwrap();
std::fs::write(seg.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(seg.join("textbook.typ"), "t.\n").unwrap();
if let Some(v) = version {
std::fs::write(p.join(".cph-version"), v).unwrap();
}
p
}
#[test]
fn cph_version_matching_is_not_a_diagnostic() {
let tmp = tmp_lesson_with_version(Some(CPH_VERSION));
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
assert!(
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
"a matching .cph-version must not warn, got {diags:?}"
);
}
#[test]
fn cph_version_mismatch_is_an_error_diagnostic() {
let tmp = tmp_lesson_with_version(Some("99.99.99"));
let (lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
// The lesson still loads (so other defects could surface), but the
// mismatch is an error diagnostic — which alone makes it illegal.
assert!(lesson.is_some(), "a mismatch should not halt loading");
let mm: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::CphVersionMismatch)
.collect();
assert_eq!(mm.len(), 1, "expected one CphVersionMismatch, got {diags:?}");
assert!(
mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION),
"diagnostic should name both versions, got: {}",
mm[0].message
);
}
#[test]
fn cph_version_missing_is_skipped() {
// No `.cph-version` file at all → skipped (ADR-0016 migration period; OPEN).
let tmp = tmp_lesson_with_version(None);
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
assert!(
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
"a missing .cph-version must be skipped, got {diags:?}"
);
}
#[test]
fn cph_version_empty_is_an_error() {
let tmp = tmp_lesson_with_version(Some(" \n"));
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
assert!(
diags
.iter()
.any(|d| d.code == DiagCode::CphVersionMismatch && d.message.contains("empty")),
"an empty .cph-version should be an error, got {diags:?}"
);
}
+1
View File
@@ -0,0 +1 @@
0.0.2