forked from EduCraft/curriculum-project-hub
ebcb3a7589
教研工程文件根放 .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>
282 lines
9.8 KiB
Rust
282 lines
9.8 KiB
Rust
//! `cph-diag` — the shared diagnostic vocabulary for the checker.
|
|
//!
|
|
//! Every other crate in the workspace depends on these types to report
|
|
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
|
|
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
|
|
//! optional [`SourceSpan`] pointing back at the offending source, and a
|
|
//! [`Diagnostic`] tying them together with a human message and a fix hint.
|
|
//!
|
|
//! All public types derive `Serialize` (downstream prints diagnostics as JSON),
|
|
//! plus `Debug`, `Clone`, `PartialEq`, `Eq`.
|
|
|
|
use std::fmt;
|
|
use std::ops::Range;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Serialize;
|
|
|
|
/// Severity of a diagnostic.
|
|
///
|
|
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
|
|
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
|
|
/// exactly:
|
|
///
|
|
/// ```text
|
|
/// inductive Severity where
|
|
/// | warning
|
|
/// | error
|
|
/// ```
|
|
///
|
|
/// This two-valued shape is a **contract decision**, not an accident: the Lean
|
|
/// module pins `Severity` to exactly `warning | error` and states the finer
|
|
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
|
|
/// do **not** add an info/note level here. `error` blocks (the artifact is
|
|
/// invalid); `warning` does not block (the artifact still exports, but with
|
|
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
|
|
///
|
|
/// There is no CI gate enforcing this alignment (see the repo constitution);
|
|
/// it is maintained by review, which is why this correspondence is documented
|
|
/// here rather than only in the spec.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
pub enum Severity {
|
|
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
|
|
/// element. Mirrors Lean `Severity.warning`.
|
|
Warning,
|
|
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
|
|
Error,
|
|
}
|
|
|
|
/// A location in a source file within the engineering-file tree.
|
|
///
|
|
/// `file` is relative to the engineering-file root so spans are portable and
|
|
/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range`
|
|
/// is a 0-based byte offset range into the file.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct SourceSpan {
|
|
/// Path to the file, relative to the engineering-file root.
|
|
pub file: PathBuf,
|
|
/// Byte offset range into the file (0-based).
|
|
pub byte_range: Range<usize>,
|
|
/// 1-based line number.
|
|
pub line: u32,
|
|
/// 1-based column number.
|
|
pub col: u32,
|
|
}
|
|
|
|
/// Stable, machine-readable diagnostic codes.
|
|
///
|
|
/// This set is **closed**: codes are part of the checker's public contract, so
|
|
/// downstream tooling can match on the string form (see [`DiagCode::as_str`]).
|
|
/// Do not invent codes outside this enum without a deliberate decision.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
pub enum DiagCode {
|
|
/// A `[[parts]]` entry references a folder/path that does not exist.
|
|
PartPathMissing,
|
|
/// An element declares a `kind` that is not a known kind.
|
|
UnknownKind,
|
|
/// A required `content` `.typ` file (per the kind schema) is missing.
|
|
MissingContentFile,
|
|
/// Instance data does not conform to its kind's JSON Schema.
|
|
SchemaViolation,
|
|
/// 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,
|
|
/// 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 {
|
|
/// The stable string form of this code, e.g. `"E-PART-PATH"`.
|
|
///
|
|
/// The `E-` / `W-` prefix reflects the *typical* severity of the code but
|
|
/// is not authoritative — the actual severity lives on the [`Diagnostic`].
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
DiagCode::PartPathMissing => "E-PART-PATH",
|
|
DiagCode::UnknownKind => "E-UNKNOWN-KIND",
|
|
DiagCode::MissingContentFile => "E-MISSING-CONTENT",
|
|
DiagCode::SchemaViolation => "E-SCHEMA",
|
|
DiagCode::TypstCompile => "E-TYPST-COMPILE",
|
|
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
|
|
DiagCode::CphVersionMismatch => "E-CPH-VERSION",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for DiagCode {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
/// A single diagnostic emitted by the checker.
|
|
///
|
|
/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an
|
|
/// optional [`SourceSpan`] / hint with the builder methods
|
|
/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct Diagnostic {
|
|
/// How serious this diagnostic is.
|
|
pub severity: Severity,
|
|
/// The stable machine code.
|
|
pub code: DiagCode,
|
|
/// Human-readable message.
|
|
pub message: String,
|
|
/// Optional location this diagnostic points at.
|
|
pub span: Option<SourceSpan>,
|
|
/// Optional fix hint — the product's core value.
|
|
pub hint: Option<String>,
|
|
}
|
|
|
|
impl Diagnostic {
|
|
/// Create an `error`-severity diagnostic.
|
|
pub fn error(code: DiagCode, message: impl Into<String>) -> Self {
|
|
Diagnostic {
|
|
severity: Severity::Error,
|
|
code,
|
|
message: message.into(),
|
|
span: None,
|
|
hint: None,
|
|
}
|
|
}
|
|
|
|
/// Create a `warning`-severity diagnostic.
|
|
pub fn warning(code: DiagCode, message: impl Into<String>) -> Self {
|
|
Diagnostic {
|
|
severity: Severity::Warning,
|
|
code,
|
|
message: message.into(),
|
|
span: None,
|
|
hint: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a source span (builder-style).
|
|
pub fn with_span(mut self, span: SourceSpan) -> Self {
|
|
self.span = Some(span);
|
|
self
|
|
}
|
|
|
|
/// Attach a fix hint (builder-style).
|
|
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
|
self.hint = Some(hint.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Severity {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(match self {
|
|
Severity::Warning => "warning",
|
|
Severity::Error => "error",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Diagnostic {
|
|
/// Renders one line `error[E-PART-PATH]: <msg>`, plus an indented
|
|
/// `--> file:line:col` line when a span is present, and a `hint: ...` line
|
|
/// when a hint is present.
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?;
|
|
if let Some(span) = &self.span {
|
|
write!(
|
|
f,
|
|
"\n --> {}:{}:{}",
|
|
span.file.display(),
|
|
span.line,
|
|
span.col
|
|
)?;
|
|
}
|
|
if let Some(hint) = &self.hint {
|
|
write!(f, "\n hint: {hint}")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn code_as_str_is_stable() {
|
|
assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH");
|
|
assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED");
|
|
assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA");
|
|
// Display matches as_str.
|
|
assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND");
|
|
}
|
|
|
|
#[test]
|
|
fn error_constructor() {
|
|
let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'");
|
|
assert_eq!(d.severity, Severity::Error);
|
|
assert_eq!(d.code, DiagCode::UnknownKind);
|
|
assert_eq!(d.message, "no such kind 'frob'");
|
|
assert!(d.span.is_none());
|
|
assert!(d.hint.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn warning_constructor() {
|
|
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
|
|
assert_eq!(d.severity, Severity::Warning);
|
|
assert_eq!(d.code, DiagCode::RenderIgnored);
|
|
}
|
|
|
|
#[test]
|
|
fn builders_attach_span_and_hint() {
|
|
let span = SourceSpan {
|
|
file: PathBuf::from("segments/intro/textbook.typ"),
|
|
byte_range: 10..20,
|
|
line: 3,
|
|
col: 5,
|
|
};
|
|
let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket")
|
|
.with_span(span.clone())
|
|
.with_hint("close the '[' opened on line 2");
|
|
assert_eq!(d.span, Some(span));
|
|
assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn display_bare() {
|
|
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found");
|
|
assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found");
|
|
}
|
|
|
|
#[test]
|
|
fn display_with_span_and_hint() {
|
|
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found")
|
|
.with_span(SourceSpan {
|
|
file: PathBuf::from("manifest.toml"),
|
|
byte_range: 0..0,
|
|
line: 12,
|
|
col: 3,
|
|
})
|
|
.with_hint("create the folder 'segments/intro' or fix the path");
|
|
assert_eq!(
|
|
d.to_string(),
|
|
"error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn display_warning() {
|
|
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
|
|
assert_eq!(
|
|
d.to_string(),
|
|
"warning[W-RENDER-IGNORED]: ignored under 'teacher'"
|
|
);
|
|
}
|
|
}
|