From bd1699a4c0734c739eb3e0092b467cc68c28f45e Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:47:13 +0800 Subject: [PATCH] feat(checker): align cph-typst + cph-check to export-as-build model (WU-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the WU-B (structured targets) and WU-C (numbly, config) changes. cph-typst: - World resolves any @preview/* package from the in-repo vendored dir (/vendor/typst-packages/preview///), fully offline — no typst-kit download. Proven: build_pdf_with_real_render compiles the real render package (which imports @preview/numbly:0.1.0) through the embedded engine, PDF out. - Driver threads each target's presentation config: looks up the TargetConfig by name, emits `config: (numbering: (heading: (...)))` (escaped) when an override is present, else `config: (:)` (render default). - target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is single-file, deferred per ADR-0009); --target not declared → blocking diagnostic; no-targets lesson still defaults through. cph-check: - Migrated to Lesson.targets: Vec (target_names() / target.name at the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic). Workspace: fmt + clippy -D warnings + all tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/cph-check/src/lib.rs | 17 +++-- crates/cph-typst/src/driver.rs | 34 ++++++++++ crates/cph-typst/src/lib.rs | 53 ++++++++++++++- crates/cph-typst/src/world.rs | 29 +++++++- crates/cph-typst/tests/compile.rs | 66 +++++++++++++++++++ .../tests/fixtures/render-stub/lib.typ | 5 +- 6 files changed, 193 insertions(+), 11 deletions(-) diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 5926a22..90e6dc3 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -60,6 +60,13 @@ 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`): + /// 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 + /// here so a reviewer can tie the orchestrator's gate to the Lean master. pub fn has_errors(&self) -> bool { self.diagnostics .iter() @@ -247,7 +254,7 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { vec![DEFAULT_TARGET] } else { - lesson.targets.iter().map(String::as_str).collect() + lesson.target_names() } } @@ -271,11 +278,11 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec Vec(lesson: &'a Lesson, target: &str) -> Option<&'a cph_model::TargetConfig> { + lesson.targets.iter().find(|t| t.name == target) +} + /// In-memory directory the generated driver is mounted under (relative to the /// lesson root). Not written to disk. pub const DRIVER_DIR: &str = ".cph"; @@ -101,10 +106,39 @@ pub fn generate_driver(lesson: &Lesson, target: &str) -> String { // Trailing comma already present per element; ensure a 1-element array is // still an array (typst needs the trailing comma, which we always emit). s.push_str(" ),\n"); + s.push_str(&format!(" config: {},\n", config_dict(lesson, target))); s.push_str(")\n"); s } +/// The `config:` dict for `target` (ADR-0009 presentation overrides). +/// +/// Looks up the matching [`cph_model::TargetConfig`] by name and emits the +/// file-resident presentation slice the render package understands. For MVP +/// that is `numbering.heading` (an array of numbly pattern strings): +/// `config: (numbering: (heading: ("…", "…")))`. When the target declares no +/// `numbering.heading` override (or the target isn't declared at all), an empty +/// dict `(:)` is emitted so the render package applies its own correct default. +fn config_dict(lesson: &Lesson, target: &str) -> String { + let heading = target_config(lesson, target) + .and_then(|tc| tc.numbering.as_ref()) + .and_then(|n| n.heading.as_ref()); + + match heading { + Some(patterns) => { + let arr = patterns + .iter() + .map(|p| typst_str(p)) + .collect::>() + .join(", "); + // A 1-element typst array needs a trailing comma; always emitting + // one keeps any arity a valid array. + format!("(numbering: (heading: ({arr},)))") + } + None => "(:)".to_string(), + } +} + /// `(title: "…", author: "…")` — author key omitted when absent. fn info_dict(lesson: &Lesson) -> String { let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))]; diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 7197673..69bd800 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -24,8 +24,8 @@ mod world; use std::path::PathBuf; -use cph_diag::Diagnostic; -use cph_model::Lesson; +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::{ArtifactKind, Lesson}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; @@ -78,6 +78,9 @@ impl Engine { /// spans landing in the generated driver are re-pointed with a hint marking /// them as driver-internal (a driver-gen bug indicator) rather than dropped. pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec { + if let Some(blocking) = target_precheck(lesson, target) { + return blocking; + } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); @@ -93,6 +96,9 @@ impl Engine { /// bytes; on fatal errors returns the mapped diagnostics. Compile warnings /// are not surfaced here (use [`Engine::compile_check`] for those). pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result, Vec> { + if let Some(blocking) = target_precheck(lesson, target) { + return Err(blocking); + } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); let doc = match warned.output { @@ -120,6 +126,49 @@ impl Default for Engine { } } +/// Validate a `(lesson, target)` request before compiling. Returns +/// `Some(blocking_diagnostics)` when the request cannot be honored, or `None` +/// when it is fine to proceed: +/// +/// - **Unknown target** (the `--target` name isn't in `lesson.targets`): a +/// `SchemaViolation` error — a target must be declared in the manifest to be +/// built (ADR-0009: an export target is a declared build). +/// - **`FileTree` artifact**: deferred for MVP. ADR-0009 makes the artifact kind +/// decide assembly; only `SingleFile` (one bundled PDF) is implemented, so a +/// `FileTree` target returns a clear `SchemaViolation` rather than wrong +/// output. `SingleFile` proceeds to the normal one-PDF path. +/// +/// A target with **no** declared `[targets.*]` config at all is *not* an error +/// here: callers (e.g. `cph-check`) may compile-check a defaulted `"student"` +/// target that the lesson never declared. Only a name that *is* declared but is +/// `FileTree` is blocked; an undeclared name is the "unknown target" error. +fn target_precheck(lesson: &Lesson, target: &str) -> Option> { + match lesson.targets.iter().find(|t| t.name == target) { + None if lesson.targets.is_empty() => { + // Lesson declares no targets; the orchestrator compiles a defaulted + // target. Proceed with render-package defaults (SingleFile). + None + } + None => Some(vec![Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{target}' not declared in manifest"), + ) + .with_hint(format!( + "add a `[targets.{target}]` table to manifest.toml, or build a declared target" + ))]), + Some(tc) => match tc.artifact { + ArtifactKind::SingleFile => None, + ArtifactKind::FileTree => Some(vec![Diagnostic::error( + DiagCode::SchemaViolation, + "file-tree artifact not yet implemented (MVP supports single-file)", + ) + .with_hint(format!( + "set `[targets.{target}].artifact` to \"single-file\" for now" + ))]), + }, + } +} + /// Map a batch of typst diagnostics to `cph-diag` ones against `world`. fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec { diags diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs index 8a2e3a8..e82431d 100644 --- a/crates/cph-typst/src/world.rs +++ b/crates/cph-typst/src/world.rs @@ -12,8 +12,15 @@ //! - **`@local/cph-render`**: a `FileId` whose root is the render package spec //! is read from `render_dir.join(vpath.realize)`. This avoids installing the //! package into the typst local-package directory (MVP: no environment -//! coupling). Any *other* `@package` id yields a `NotFound` error (the MVP -//! render package has no external `@preview` dependencies). +//! coupling). +//! - **`@preview/:`**: resolved offline from the in-repo vendored +//! preview tree at `/vendor/typst-packages/preview///` +//! (mirroring typst's preview-cache layout). The render package imports +//! `@preview/numbly:0.1.0` (per-level heading numbering); reading it from the +//! vendored dir keeps compilation network-free with no `typst-kit` package +//! download. This is general — any `@preview/*` resolves from the vendored +//! preview dir; numbly 0.1.0 is the only one currently present. +//! - Any *other* `@package` namespace yields a `NotFound` error. //! //! Sources are cached in a `Mutex>` so repeated //! accesses during one compilation are cheap, as the `World` contract expects. @@ -94,7 +101,8 @@ impl LessonWorld { } /// Map a `FileId` to its on-disk path, honoring the project / render-package - /// roots. Returns a `FileError` for any other package root. + /// / vendored-`@preview` roots. Returns a `FileError` for any other package + /// root. fn resolve(&self, id: FileId) -> FileResult { let vpath = id.vpath(); match id.root() { @@ -102,6 +110,21 @@ impl LessonWorld { VirtualRoot::Package(spec) if *spec == self.render_spec => { vpath.realize(&self.render_dir).map_err(Into::into) } + // `@preview/:` → vendored preview tree under the + // render dir. Offline, no download: mirrors typst's preview-cache + // layout so e.g. `@preview/numbly:0.1.0` (imported by the render + // package) reads from + // `/vendor/typst-packages/preview/numbly/0.1.0/`. + VirtualRoot::Package(spec) if spec.namespace == "preview" => { + let pkg_root = self + .render_dir + .join("vendor") + .join("typst-packages") + .join("preview") + .join(spec.name.as_str()) + .join(spec.version.to_string()); + vpath.realize(&pkg_root).map_err(Into::into) + } VirtualRoot::Package(spec) => Err(FileError::Package( typst::diag::PackageError::NotFound(spec.clone()), )), diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 6f645c4..582dc10 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -78,6 +78,53 @@ fn optional_proof_included_when_present() { assert!(src.contains("_p1_proof = include")); } +/// The mini fixture's targets carry no `numbering.heading` override, so the +/// driver emits an empty `config: (:)` (render package applies its default). +#[test] +fn driver_emits_empty_config_when_no_override() { + let lesson = load_mini(); + let src = generate_driver(&lesson, "student"); + assert!( + src.contains("config: (:)"), + "expected empty config for a target with no numbering override:\n{src}" + ); +} + +/// CONFIG THREADING: a target declaring a `numbering.heading` override produces +/// a driver that passes `config: (numbering: (heading: (...)))` with the +/// patterns as escaped typst string literals, in order. +#[test] +fn driver_threads_numbering_heading_config() { + use cph_model::{ArtifactKind, Info, Lesson, NumberingConfig, Project, TargetConfig}; + use std::path::PathBuf; + + let lesson = Lesson { + project: Project { + id: "x".into(), + name: "x".into(), + }, + info: Info { + title: "t".into(), + author: None, + }, + parts: vec![], + targets: vec![TargetConfig { + name: "student".into(), + artifact: ArtifactKind::SingleFile, + numbering: Some(NumberingConfig { + heading: Some(vec!["{1:一}、".into(), "{1:1}.{2:1}".into()]), + }), + }], + root: PathBuf::from("/tmp/does-not-matter"), + }; + + let src = generate_driver(&lesson, "student"); + assert!( + src.contains(r#"config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}",)))"#), + "expected threaded numbering config in driver:\n{src}" + ); +} + /// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should /// produce zero Error-severity diagnostics. If this fails purely because the /// test host lacks fonts, the failure will be a typst font error — see the @@ -166,6 +213,13 @@ fn driver_internal_span_is_repointed() { /// targets. This proves the whole World → driver → render-package → PDF path, /// not just the stub. Requires system CJK fonts (present on dev + CI via /// fonts-noto-cjk). +/// +/// **This is also the offline-`@preview/numbly` proof (Task 1).** The real +/// render package's `src/style.typ` does `#import "@preview/numbly:0.1.0"`, so a +/// clean PDF here means the embedded `World` resolved numbly from the vendored +/// dir (`render/vendor/typst-packages/preview/numbly/0.1.0/`) with no network / +/// package download — had it failed, `build_pdf` would have returned a +/// package-not-found error and this test would panic. #[test] fn build_pdf_with_real_render() { let lesson = load_mini(); @@ -175,6 +229,14 @@ fn build_pdf_with_real_render() { "real render package missing at {}", render_dir.display() ); + // Sanity: the numbly the render package imports is actually vendored here. + assert!( + render_dir + .join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ") + .is_file(), + "vendored numbly missing under {}", + render_dir.display() + ); let engine = Engine::with_render_dir(render_dir); for target in ["student", "teacher"] { @@ -183,6 +245,10 @@ fn build_pdf_with_real_render() { .unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}")); assert!(pdf.starts_with(b"%PDF")); assert!(pdf.len() > 1024, "{target} PDF too small"); + eprintln!( + "build_pdf_with_real_render: {target} PDF = {} bytes (numbly resolved offline)", + pdf.len() + ); let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf")); std::fs::write(&out, &pdf).expect("write pdf"); } diff --git a/crates/cph-typst/tests/fixtures/render-stub/lib.typ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ index 1b9184b..9a56669 100644 --- a/crates/cph-typst/tests/fixtures/render-stub/lib.typ +++ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ @@ -4,7 +4,10 @@ // It exercises the same call shape as the real entry: it reads `info`, walks // `parts` IN ORDER, and renders each part's content fields by `kind`. Content // values are already-evaluated content (the driver produced them via include). -#let display(info: (:), target: "student", parts: ()) = { +// +// `config` mirrors the real entry's ADR-0009 presentation-config parameter; the +// stub accepts it (the driver always passes `config:`) but ignores it. +#let display(info: (:), target: "student", parts: (), config: (:)) = { set document(title: info.at("title", default: "")) [= #info.at("title", default: "")] [target: #target]