From d6694d064126880dd62a2969ebd1ca3eb6e18ddc Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 23 Jul 2026 18:19:10 +0200 Subject: [PATCH] feat(runner, cli): C29 load seam -- undescribed extension vocabulary refuses at load Iteration 2 of the self-description plan: field presence is compile- enforced since iteration 1, but an extension crate can still ship the empty-string alibi (or a name-restating doc) the compiler cannot see. load_crate now walks every charter-checked type id, resolves its builder, and runs aura_core::doc_gate over schema.doc; a fault refuses the load via ProjectError::UndescribedVocabularyEntry { type_id, fault } (exit 1 through the CLI's Display-driven surface, C14). The variant carries the DocGateFault beyond the spec's minimal sketch because the spec's error-handling section requires the refusal to name entry + field + rule -- with only the type id the prose could not distinguish the two faults (decision logged on #316). Two gate-failing fixture crates drive the e2e, modelled on badcharter-project: undescribed-project (und::Opaque, doc: "") for the Empty arm, restated-project (restated::Echo, doc: "Restated Echo") for the RestatesName arm -- the second contributed by the E2E phase to close the only-unit-pinned gap on that arm. A runner unit test pins both rendered Display arms directly. The described twins (demo-project, nested-nodes-project) stay green and are now load-bearing for the gate. Gates: cargo test -p aura-cli --test project_load (14 green, incl. both refusal e2es); cargo test --workspace green; clippy -D warnings clean. refs #316 --- .../fixtures/restated-project/.gitignore | 3 + .../tests/fixtures/restated-project/Aura.toml | 1 + .../fixtures/restated-project/Cargo.toml | 15 ++++ .../fixtures/restated-project/src/lib.rs | 83 +++++++++++++++++++ .../fixtures/undescribed-project/.gitignore | 3 + .../fixtures/undescribed-project/Aura.toml | 1 + .../fixtures/undescribed-project/Cargo.toml | 15 ++++ .../fixtures/undescribed-project/src/lib.rs | 78 +++++++++++++++++ crates/aura-cli/tests/project_load.rs | 80 ++++++++++++++++++ crates/aura-runner/src/project.rs | 60 +++++++++++++- 10 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 crates/aura-cli/tests/fixtures/restated-project/.gitignore create mode 100644 crates/aura-cli/tests/fixtures/restated-project/Aura.toml create mode 100644 crates/aura-cli/tests/fixtures/restated-project/Cargo.toml create mode 100644 crates/aura-cli/tests/fixtures/restated-project/src/lib.rs create mode 100644 crates/aura-cli/tests/fixtures/undescribed-project/.gitignore create mode 100644 crates/aura-cli/tests/fixtures/undescribed-project/Aura.toml create mode 100644 crates/aura-cli/tests/fixtures/undescribed-project/Cargo.toml create mode 100644 crates/aura-cli/tests/fixtures/undescribed-project/src/lib.rs diff --git a/crates/aura-cli/tests/fixtures/restated-project/.gitignore b/crates/aura-cli/tests/fixtures/restated-project/.gitignore new file mode 100644 index 0000000..af5cf3d --- /dev/null +++ b/crates/aura-cli/tests/fixtures/restated-project/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +/runs diff --git a/crates/aura-cli/tests/fixtures/restated-project/Aura.toml b/crates/aura-cli/tests/fixtures/restated-project/Aura.toml new file mode 100644 index 0000000..a56917e --- /dev/null +++ b/crates/aura-cli/tests/fixtures/restated-project/Aura.toml @@ -0,0 +1 @@ +# restated fixture — static project context only (C17); paths only (cycle 0102). diff --git a/crates/aura-cli/tests/fixtures/restated-project/Cargo.toml b/crates/aura-cli/tests/fixtures/restated-project/Cargo.toml new file mode 100644 index 0000000..ccd84c9 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/restated-project/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "restated-project" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "../../../../aura-core" } + +# Standalone workspace root: never a member of the engine workspace +# (docs/project-layout.md, the empty-[workspace] note). +[workspace] diff --git a/crates/aura-cli/tests/fixtures/restated-project/src/lib.rs b/crates/aura-cli/tests/fixtures/restated-project/src/lib.rs new file mode 100644 index 0000000..7761c03 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/restated-project/src/lib.rs @@ -0,0 +1,83 @@ +//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable +//! pass-through node under the `restated` namespace whose `NodeSchema` doc +//! merely restates its own type id (`DocGateFault::RestatesName`, the fault +//! arm `undescribed-project` does not exercise). The charter is satisfied +//! (prefixed id, list/resolver in sync) so the refusal under test is exactly +//! the doc gate's name-restatement branch. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// One-input f64 pass-through whose doc is a no-content restatement of its +/// own name. +pub struct Echo { + out: [Cell; 1], +} + +impl Echo { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "restated::Echo", + NodeSchema { + inputs: vec![PortSpec { + kind: ScalarKind::F64, + firing: Firing::Any, + name: "value".into(), + }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + // The one deliberate violation this fixture exists for: this + // norm-equals the type id "restated::Echo" once punctuation + // and case are stripped, so it carries no meaning beyond the + // name itself. + doc: "Restated Echo", + }, + |_| Box::new(Echo::new()), + ) + } +} + +impl Default for Echo { + fn default() -> Self { + Self::new() + } +} + +impl Node for Echo { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(w[0]); + Some(&self.out) + } + fn label(&self) -> String { + "restated::Echo".to_string() + } +} + +fn vocabulary(type_id: &str) -> Option { + match type_id { + "restated::Echo" => Some(Echo::builder()), + _ => None, + } +} + +fn type_ids() -> &'static [&'static str] { + &["restated::Echo"] +} + +aura_core::aura_project! { + namespace: "restated", + vocabulary: vocabulary, + type_ids: type_ids, +} diff --git a/crates/aura-cli/tests/fixtures/undescribed-project/.gitignore b/crates/aura-cli/tests/fixtures/undescribed-project/.gitignore new file mode 100644 index 0000000..af5cf3d --- /dev/null +++ b/crates/aura-cli/tests/fixtures/undescribed-project/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +/runs diff --git a/crates/aura-cli/tests/fixtures/undescribed-project/Aura.toml b/crates/aura-cli/tests/fixtures/undescribed-project/Aura.toml new file mode 100644 index 0000000..02ae9cd --- /dev/null +++ b/crates/aura-cli/tests/fixtures/undescribed-project/Aura.toml @@ -0,0 +1 @@ +# undescribed fixture — static project context only (C17); paths only (cycle 0102). diff --git a/crates/aura-cli/tests/fixtures/undescribed-project/Cargo.toml b/crates/aura-cli/tests/fixtures/undescribed-project/Cargo.toml new file mode 100644 index 0000000..72fc87a --- /dev/null +++ b/crates/aura-cli/tests/fixtures/undescribed-project/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "undescribed-project" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "../../../../aura-core" } + +# Standalone workspace root: never a member of the engine workspace +# (docs/project-layout.md, the empty-[workspace] note). +[workspace] diff --git a/crates/aura-cli/tests/fixtures/undescribed-project/src/lib.rs b/crates/aura-cli/tests/fixtures/undescribed-project/src/lib.rs new file mode 100644 index 0000000..507388c --- /dev/null +++ b/crates/aura-cli/tests/fixtures/undescribed-project/src/lib.rs @@ -0,0 +1,78 @@ +//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable +//! pass-through node under the `und` namespace whose `NodeSchema` carries the +//! empty-string alibi `doc: ""` — compile-legal (the field is present), but +//! the load gate must refuse it. The charter is satisfied (prefixed id, +//! list/resolver in sync) so the refusal under test is exactly the doc gate. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// One-input f64 pass-through with a deliberately empty doc. +pub struct Opaque { + out: [Cell; 1], +} + +impl Opaque { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "und::Opaque", + NodeSchema { + inputs: vec![PortSpec { + kind: ScalarKind::F64, + firing: Firing::Any, + name: "value".into(), + }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + // The one deliberate violation this fixture exists for. + doc: "", + }, + |_| Box::new(Opaque::new()), + ) + } +} + +impl Default for Opaque { + fn default() -> Self { + Self::new() + } +} + +impl Node for Opaque { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(w[0]); + Some(&self.out) + } + fn label(&self) -> String { + "und::Opaque".to_string() + } +} + +fn vocabulary(type_id: &str) -> Option { + match type_id { + "und::Opaque" => Some(Opaque::builder()), + _ => None, + } +} + +fn type_ids() -> &'static [&'static str] { + &["und::Opaque"] +} + +aura_core::aura_project! { + namespace: "und", + vocabulary: vocabulary, + type_ids: type_ids, +} diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index 46023b8..a057893 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -19,6 +19,14 @@ fn badcharter_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project") } +fn undescribed_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/undescribed-project") +} + +fn restated_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/restated-project") +} + fn aura(args: &[&str], cwd: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) @@ -164,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() { assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}"); } +/// C29 load seam end-to-end (#316): an extension node whose doc fails the +/// shape gate (the empty-string alibi the compiler cannot catch) refuses at +/// load with the entry and the rule named — through the real +/// `aura-cli::project::load` path (libloading + descriptor read), not merely +/// the pure `doc_gate` unit function. The described twin's load is pinned by +/// `project_run_resolves_demo_node_and_is_bit_identical`. +#[test] +fn undescribed_vocabulary_entry_refuses_end_to_end() { + let dir = undescribed_dir(); + let build = Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the undescribed fixture"); + assert!( + build.status.success(), + "undescribed fixture build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let run = aura(&["run", "x.json"], &dir); + assert_eq!( + run.status.code(), + Some(1), + "an undescribed vocabulary entry is a runtime refusal (exit 1), \ + not a usage error" + ); + let err = String::from_utf8_lossy(&run.stderr); + assert!( + err.contains("vocabulary entry `und::Opaque` has an empty doc"), + "stderr must name the entry and the rule: {err}" + ); + assert!(err.contains("C29"), "stderr must cite the contract: {err}"); +} + +/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely +/// restates its own type id (`DocGateFault::RestatesName`) refuses at load +/// exactly like the empty-doc case above, through the same real +/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was +/// previously pinned only against the pure `Display` impl in +/// `aura-runner::project`'s unit tests (never reachable at all through the +/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that +/// skipped the gate call for this arm specifically, would pass every +/// existing test and still ship a self-description hole for exactly this +/// case: an alibi doc that types-checks but says nothing. +#[test] +fn restated_name_vocabulary_entry_refuses_end_to_end() { + let dir = restated_dir(); + let build = Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the restated fixture"); + assert!( + build.status.success(), + "restated fixture build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let run = aura(&["run", "x.json"], &dir); + assert_eq!( + run.status.code(), + Some(1), + "a name-restating vocabulary entry is a runtime refusal (exit 1), \ + not a usage error" + ); + let err = String::from_utf8_lossy(&run.stderr); + assert!( + err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"), + "stderr must name the entry and the rule: {err}" + ); + assert!(err.contains("C29"), "stderr must cite the contract: {err}"); +} + /// Force a file's modification time to a fixed instant (explicit mtime, no /// sleeping — mtime granularity would make a "touch then compare" race). fn set_mtime(path: &Path, secs_since_epoch: u64) { diff --git a/crates/aura-runner/src/project.rs b/crates/aura-runner/src/project.rs index 5b952a8..3eee0e5 100644 --- a/crates/aura-runner/src/project.rs +++ b/crates/aura-runner/src/project.rs @@ -7,7 +7,7 @@ //! vocabulary charter. `Env` is the per-invocation context every verb reads: //! merged resolver, runs root, data path, provenance. -use aura_core::PrimitiveBuilder; +use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate}; use aura_core::project::{ AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL, CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice, @@ -63,6 +63,11 @@ pub enum ProjectError { /// handing off to `load_crate`, so this never reaches the cargo-metadata /// probe (whose raw os-error text would otherwise leak through). PointerDirMissing(PathBuf), + /// C29 load seam (#316): a resolved extension-vocabulary entry whose doc + /// fails the shape gate — the load refuses rather than admit an + /// undescribed node. Field presence is compile-enforced; shape is not, + /// so the gate carries the failed rule for the refusal prose. + UndescribedVocabularyEntry { type_id: String, fault: DocGateFault }, } impl fmt::Display for ProjectError { @@ -100,6 +105,19 @@ impl fmt::Display for ProjectError { "node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}" ), Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()), + Self::UndescribedVocabularyEntry { type_id, fault } => { + let rule = match fault { + DocGateFault::Empty => "has an empty doc", + DocGateFault::RestatesName => { + "has a doc that merely restates its name" + } + }; + write!( + f, + "vocabulary entry `{type_id}` {rule} — every vocabulary \ + entry ships a one-line meaning (C29)" + ) + } } } } @@ -579,6 +597,18 @@ fn load_crate(crate_root: &Path, release: bool) -> Result