feat(checker): scaffold repo-root cargo workspace + cph-diag + cph-model (WU-1)

Stand up the Rust implementation of the rule-based checker (the thing that
"stands in Lean's position" at product runtime). Single repo-wide cargo
workspace at the repo root so future components (e.g. an exporter) can reuse
shared crates.

- workspace: 6 member crates under crates/; cph-schema/cph-typst/cph-check/
  cph-cli are stubs for later WUs that compile clean today.
- cph-diag: shared diagnostic vocabulary. Severity { Warning, Error } mirrors
  Spec.Courseware.Diagnostic.Severity exactly (two-valued, no info/note — a
  contract decision documented in-code since there's no CI gate for alignment).
  Closed DiagCode set with stable string forms; Diagnostic with optional span +
  fix hint; Display renders `error[CODE]: msg / --> file:line:col / hint: …`.
- cph-model: the ADR-0008 loader. Reads manifest.toml ([project]/[info]/ordered
  [[parts]]/[targets.*]) + each part's element.toml into an ordered Lesson
  (mirrors Lean `Lesson = List (Element P)`). Structure-only: cross-checks
  part.kind == element.toml kind, rejects `..` traversal; does NOT do schema
  validation (WU-3), content-file existence (WU-3), or typst (WU-4).
  `load(root) -> (Option<Lesson>, Vec<Diagnostic>)`: Some even on collectible
  part errors, None only on hard manifest failure.
- 13 tests (7 cph-diag, 6 cph-model incl. static fixtures doubling as format
  docs). build + test + clippy -D warnings + fmt --check all green.

Judgment call flagged for review: cph-diag has no ManifestMalformed code, so
manifest-structure errors map to SchemaViolation (TODO noted in-code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:27:32 +08:00
parent 8599f472c0
commit c810ea6137
30 changed files with 1212 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "cph-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
+275
View File
@@ -0,0 +1,275 @@
//! `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/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,
/// 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.
TypstCompile,
/// An element is ignored under a render target (ADR-0005: warning).
RenderIgnored,
}
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::DanglingReference => "E-DANGLING-REF",
DiagCode::SchemaViolation => "E-SCHEMA",
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
}
}
}
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'"
);
}
}