From 559128b723607bf92a11cd7820d2ea1ade93eb93 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 2 Jul 2026 21:36:10 +0200 Subject: [PATCH 01/13] =?UTF-8?q?test(codegen):=20red=20oracle=20=E2=80=94?= =?UTF-8?q?=20data=20implementation=20must=20become=20a=20WIT=20record=20(?= =?UTF-8?q?REQ-CODEGEN-WIT-RECORDS-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 of #319 item 1/3/6. Adds a red-before-green oracle: a `data implementation` with scalar subcomponents (EepromSnapshot.Impl = u32+u32+u8) referenced by a port must generate a WIT `record { addr: u32, value: u32, flags: u8 }`, not an opaque `type ... = list` blob (which needs cabi_realloc at the ABI boundary, defeating no_alloc). Currently RED — wit_gen emits list. Confirmed the classifier resolves as EepromSnapshot.Impl and the fixture instantiates cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-codegen/src/wit_gen.rs | 105 +++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index 8b0f7d6..78693cb 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -404,4 +404,109 @@ end TestPkg; assert_eq!(wit_ident("___"), "unnamed"); assert_eq!(wit_ident(""), "unnamed"); } + + /// Fixture for REQ-CODEGEN-WIT-RECORDS-001 (#319): a `data implementation` + /// with scalar subcomponents (the real-world `EepromSnapshot.Impl` = + /// u32 + u32 + u8 pattern), referenced by a process port. + fn build_record_test_instance() -> SystemInstance { + let aadl = r#" +package RecPkg +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Byte8 + properties + Data_Size => 1 Bytes; + end Byte8; + + data EepromSnapshot + end EepromSnapshot; + + data implementation EepromSnapshot.Impl + subcomponents + addr: data Word32; + value: data Word32; + flags: data Byte8; + end EepromSnapshot.Impl; + + process Store + features + snapshot_in: in data port EepromSnapshot.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end RecPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "rec.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + SystemInstance::instantiate( + &scope, + &Name::new("RecPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ) + } + + /// RED-before-green oracle for REQ-CODEGEN-WIT-RECORDS-001 (#319 items 1,3,6): + /// an AADL `data implementation` with scalar subcomponents must generate a + /// WIT `record` with one typed field per subcomponent — NOT an opaque + /// `list` blob (which would need `cabi_realloc` at the ABI boundary, + /// defeating no_alloc). Currently RED: wit_gen emits `type ... = list`. + #[test] + fn data_implementation_becomes_wit_record() { + let inst = build_record_test_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + let file = generate_wit(&inst, idx); + let body: String = file + .content + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + // Must NOT degrade the structured type to an opaque byte blob. + assert!( + !body.contains("= list"), + "data implementation must NOT degrade to list:\n{body}" + ); + // Must emit a typed record (name kebab-cased with PascalCase boundaries + // preserved: EepromSnapshot.Impl -> eeprom-snapshot-impl). + assert!( + body.contains("record eeprom-snapshot-impl {"), + "expected a WIT record for EepromSnapshot.Impl:\n{body}" + ); + // One typed field per scalar subcomponent (4B->u32, 4B->u32, 1B->u8). + for field in ["addr: u32", "value: u32", "flags: u8"] { + assert!( + body.contains(field), + "expected record field `{field}`:\n{body}" + ); + } + } } From adb6b7e30127326d938454e6565e8182af6567e8 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 3 Jul 2026 11:04:45 +0200 Subject: [PATCH 02/13] feat(codegen): decompose AADL data implementations into WIT records (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 of #319 items 1/3/6. Flips the red oracle from #316^ green: an AADL `data implementation` with scalar subcomponents now generates a typed WIT `record` instead of an opaque `list` blob (which would force cabi_realloc at the ABI boundary, defeating no_alloc). data implementation EepromSnapshot.Impl { addr: Word32; value: Word32; flags: Byte8; } => record eeprom-snapshot-impl { addr: u32, value: u32, flags: u8 } Mechanism: - spar-hir-def: new `GlobalScope::resolve_data_shape` + `data_size_bytes` (resolver.rs) resolve a `data` classifier to a typed `DataShape` — an impl with subcomponents becomes a `Record` (fields resolved recursively), a data type carrying `Data_Size` becomes a `Scalar`, else `Opaque`. Reuses the same classifier + Data_Size resolution the instance builder already trusts. - spar-codegen: `generate`/`generate_wit` now take `&GlobalScope`; wit_gen resolves each referenced type's shape and emits a `record` (scalar fields), a scalar alias, or the legacy `list` fallback. `wit_ident` now breaks camelCase/PascalCase word boundaries (`EepromSnapshot` -> `eeprom-snapshot`, #319 item 6) instead of collapsing them. Oracle (green, hardened): the generated record is asserted field-by-field AND fed to wit-parser to prove it is valid, bindable WIT. Existing behavior preserved (opaque -> list, scalar Data_Size -> uN); golden integration + kebab-case tests unchanged. Verified workspace-wide (cargo test --workspace --all-targets --no-run) — caught + fixed the codegen bench call site too. Scoped to P1: flat scalar records. Non-representable fields (non-power-of-2 size, nested records, opaque) keep the legacy list for now; the hard-error diagnostic (user-chosen) + nested records + Dispatch_Protocol lifecycle + wit-bindgen wiring are P2/P3. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-cli/src/main.rs | 2 +- .../benches/codegen_benchmarks.rs | 17 +- crates/spar-codegen/src/lib.rs | 12 +- crates/spar-codegen/src/wit_gen.rs | 163 +++++++++++++----- crates/spar-codegen/tests/golden_test.rs | 22 +-- crates/spar-hir-def/src/resolver.rs | 85 +++++++++ 6 files changed, 236 insertions(+), 65 deletions(-) diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index 3e32ee4..4bc1e79 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -2074,7 +2074,7 @@ fn cmd_codegen(args: &[String]) { dry_run, }; - let result = spar_codegen::generate(&inst, &config); + let result = spar_codegen::generate(&inst, &scope, &config); if dry_run { eprintln!( diff --git a/crates/spar-codegen/benches/codegen_benchmarks.rs b/crates/spar-codegen/benches/codegen_benchmarks.rs index 939d764..b1586ef 100644 --- a/crates/spar-codegen/benches/codegen_benchmarks.rs +++ b/crates/spar-codegen/benches/codegen_benchmarks.rs @@ -84,17 +84,18 @@ fn synth_aadl(n_threads: usize) -> String { } /// Build a `SystemInstance` from an AADL source string. -fn build_instance(aadl: &str) -> SystemInstance { +fn build_instance(aadl: &str) -> (GlobalScope, SystemInstance) { let db = HirDefDatabase::default(); let sf = SourceFile::new(&db, "bench.aadl".to_string(), aadl.to_string()); let tree = file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("BenchSystem"), &Name::new("BenchRoot"), &Name::new("impl"), - ) + ); + (scope, inst) } fn bench_codegen_emit(c: &mut Criterion) { @@ -105,7 +106,7 @@ fn bench_codegen_emit(c: &mut Criterion) { let n_threads = 64; let aadl = synth_aadl(n_threads); - let instance = build_instance(&aadl); + let (scope, instance) = build_instance(&aadl); let config = CodegenConfig { root_name: "bench_root".into(), output_dir: "output".into(), @@ -121,7 +122,7 @@ fn bench_codegen_emit(c: &mut Criterion) { // workspace, concatenated into in-memory strings. group.bench_function("generate_full_64", |b| { b.iter(|| { - let output = generate(black_box(&instance), black_box(&config)); + let output = generate(black_box(&instance), black_box(&scope), black_box(&config)); black_box(output); }); }); @@ -136,7 +137,11 @@ fn bench_codegen_emit(c: &mut Criterion) { }; group.bench_function("generate_rust_only_64", |b| { b.iter(|| { - let output = generate(black_box(&instance), black_box(&rust_only_config)); + let output = generate( + black_box(&instance), + black_box(&scope), + black_box(&rust_only_config), + ); black_box(output); }); }); diff --git a/crates/spar-codegen/src/lib.rs b/crates/spar-codegen/src/lib.rs index 8b9bb9b..c7de992 100644 --- a/crates/spar-codegen/src/lib.rs +++ b/crates/spar-codegen/src/lib.rs @@ -319,7 +319,15 @@ fn threads_for_processor( } /// Generate all code artifacts from an AADL instance model. -pub fn generate(inst: &SystemInstance, config: &CodegenConfig) -> CodegenOutput { +/// +/// `scope` is the global scope the instance was built from; codegen resolves +/// data-type classifiers through it (e.g. to decompose a `data implementation` +/// into a WIT record — REQ-CODEGEN-WIT-RECORDS-001). +pub fn generate( + inst: &SystemInstance, + scope: &spar_hir_def::resolver::GlobalScope, + config: &CodegenConfig, +) -> CodegenOutput { let mut files = Vec::new(); // Collect processes and threads @@ -341,7 +349,7 @@ pub fn generate(inst: &SystemInstance, config: &CodegenConfig) -> CodegenOutput // Generate WIT files if config.format == OutputFormat::Wit || config.format == OutputFormat::Both { for &(idx, _comp) in &processes { - files.push(wit_gen::generate_wit(inst, idx)); + files.push(wit_gen::generate_wit(inst, scope, idx)); } } diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index 78693cb..ab10b59 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -8,6 +8,8 @@ use std::collections::BTreeMap; use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::{ComponentCategory, Direction, FeatureKind}; +use spar_hir_def::name::ClassifierRef; +use spar_hir_def::resolver::{DataField, DataShape, GlobalScope}; use crate::GeneratedFile; @@ -15,13 +17,49 @@ use crate::GeneratedFile; /// A byte buffer is always valid and bindable; `bytes` is NOT a WIT primitive. const DEFAULT_WIT_TYPE: &str = "list"; +/// The WIT integer type for a power-of-two `Data_Size` in bytes, or `None` if +/// the size is not directly representable as a WIT scalar — which would force a +/// heap-allocating `list` at the ABI boundary (a no_alloc violation). +/// REQ-CODEGEN-WIT-RECORDS-001 (#319). +fn scalar_wit(bytes: u64) -> Option<&'static str> { + match bytes { + 1 => Some("u8"), + 2 => Some("u16"), + 4 => Some("u32"), + 8 => Some("u64"), + _ => None, + } +} + +/// Build the `(field, wit-type)` rows for a record whose fields are ALL directly +/// representable no_alloc scalars, in declaration order. Returns `None` if any +/// field is not (a non-power-of-two scalar, a nested record, or opaque): P1 +/// keeps such a type as the legacy `list`; P2 turns this into a hard error +/// and adds nested-record support. REQ-CODEGEN-WIT-RECORDS-001 (#319). +fn record_fields(fields: &[DataField]) -> Option> { + let mut rows = Vec::with_capacity(fields.len()); + for f in fields { + match &f.shape { + DataShape::Scalar { bytes } => { + rows.push((wit_ident(f.name.as_str()), scalar_wit(*bytes)?.to_string())); + } + _ => return None, + } + } + Some(rows) +} + /// Generate a WIT file for a process instance. /// /// Identifiers are emitted in WIT kebab-case (see [`wit_ident`]) — NOT the /// Rust `snake_case` used elsewhere in codegen — and every named data type a /// port references is emitted as a `type` alias so the interface resolves /// under `wasm-tools` / `wit-bindgen` (see issue #254). -pub fn generate_wit(inst: &SystemInstance, proc_idx: ComponentInstanceIdx) -> GeneratedFile { +pub fn generate_wit( + inst: &SystemInstance, + scope: &GlobalScope, + proc_idx: ComponentInstanceIdx, +) -> GeneratedFile { let comp = inst.component(proc_idx); let name = wit_ident(comp.name.as_str()); let pkg_name = wit_ident(comp.package.as_str()); @@ -40,47 +78,54 @@ pub fn generate_wit(inst: &SystemInstance, proc_idx: ComponentInstanceIdx) -> Ge .filter(|&&child_idx| inst.component(child_idx).category == ComponentCategory::Thread) .collect(); - // First pass: collect every named data type referenced by a port so we can - // emit `type` definitions. WIT rejects references to undefined types, so an - // interface that names `mattermessage` without defining it will not bind. - // The instance model carries each feature's resolved Data_Size (bytes), so - // scalar-sized data types map to precise WIT scalars instead of a byte - // buffer (REQ-CODEGEN-WIT-TYPES): 1 -> u8, 2 -> u16, 4 -> u32, 8 -> u64; - // anything else (or undeclared) stays list. If two features reference - // the same type name with conflicting sizes, fall back to list rather - // than guess. - let mut referenced_types: BTreeMap> = BTreeMap::new(); + // First pass: collect every named data type a port references, keyed by its + // WIT identifier, remembering the classifier so we can resolve its shape. + // WIT rejects references to undefined types, so every referenced type must + // be emitted (as a scalar alias, a record, or a byte-buffer fallback). + let mut referenced: BTreeMap = BTreeMap::new(); for &fi in &comp.features { let feat = &inst.features[fi]; - if let Some(c) = feat.classifier.as_ref() { - let ty = wit_ident(&c.to_string()); - match referenced_types.entry(ty) { - std::collections::btree_map::Entry::Vacant(e) => { - e.insert(feat.data_size_bytes); - } - std::collections::btree_map::Entry::Occupied(mut e) => { - if *e.get() != feat.data_size_bytes { - e.insert(None); // conflicting sizes: stay list - } - } - } + if let Some(cref) = feat.classifier.as_ref() { + referenced + .entry(wit_ident(&cref.to_string())) + .or_insert_with(|| cref.clone()); } } - // Generate an interface for the process's own ports + // Generate an interface for the process's own ports. wit.push_str(&format!("interface {name}-ports {{\n")); - for (ty, size) in &referenced_types { - let wit_ty = match size { - Some(1) => "u8", - Some(2) => "u16", - Some(4) => "u32", - Some(8) => "u64", - _ => DEFAULT_WIT_TYPE, - }; - wit.push_str(&format!(" type {ty} = {wit_ty};\n")); + // Resolve each referenced data type to its shape and emit it. A + // `data implementation` with scalar subcomponents becomes a WIT `record` + // (REQ-CODEGEN-WIT-RECORDS-001, #319) instead of an opaque `list` blob + // that would force `cabi_realloc` at the ABI boundary. Scalar-sized data + // types map to precise WIT integers (1 -> u8 ... 8 -> u64). + for (ty, cref) in &referenced { + match scope.resolve_data_shape(&comp.package, cref) { + DataShape::Record { fields } => match record_fields(&fields) { + Some(rows) => { + wit.push_str(&format!(" record {ty} {{\n")); + for (fname, fty) in rows { + wit.push_str(&format!(" {fname}: {fty},\n")); + } + wit.push_str(" }\n"); + } + // A field that can't be a no_alloc scalar (non-power-of-2 size, + // nested record, or opaque): keep the legacy byte-buffer alias + // for now. The hard-error diagnostic + nested records are P2/P3 + // of REQ-CODEGEN-WIT-RECORDS-001. + None => wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")), + }, + DataShape::Scalar { bytes } => { + let wit_ty = scalar_wit(bytes).unwrap_or(DEFAULT_WIT_TYPE); + wit.push_str(&format!(" type {ty} = {wit_ty};\n")); + } + DataShape::Opaque => { + wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")); + } + } } - if !referenced_types.is_empty() { + if !referenced.is_empty() { wit.push('\n'); } @@ -214,6 +259,18 @@ fn wit_ident(name: &str) -> String { let mut cur = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() { + // Break camelCase / PascalCase boundaries: an uppercase letter that + // follows a lowercase letter or digit starts a new word, so + // `EepromSnapshot` -> `eeprom-snapshot` (issue #319 item 6) instead + // of the collapsed `eepromsnapshot`. + if ch.is_ascii_uppercase() + && cur + .chars() + .last() + .is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) + { + words.push(std::mem::take(&mut cur)); + } cur.push(ch.to_ascii_lowercase()); } else if !cur.is_empty() { words.push(std::mem::take(&mut cur)); @@ -250,7 +307,7 @@ mod tests { use spar_hir_def::name::Name; use spar_hir_def::resolver::GlobalScope; - fn build_test_instance() -> SystemInstance { + fn build_test_instance() -> (GlobalScope, SystemInstance) { let aadl = r#" package TestPkg public @@ -302,17 +359,18 @@ end TestPkg; let sf = spar_base_db::SourceFile::new(&db, "test.aadl".to_string(), aadl.to_string()); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("TestPkg"), &Name::new("Top"), &Name::new("Impl"), - ) + ); + (scope, inst) } #[test] fn wit_gen_produces_output() { - let inst = build_test_instance(); + let (scope, inst) = build_test_instance(); // Find the process instance let proc_idx = inst .all_components() @@ -320,7 +378,7 @@ end TestPkg; .map(|(idx, _)| idx); if let Some(idx) = proc_idx { - let file = generate_wit(&inst, idx); + let file = generate_wit(&inst, &scope, idx); assert!(file.path.ends_with(".wit")); assert!(file.content.contains("package")); assert!(file.content.contains("world")); @@ -333,13 +391,13 @@ end TestPkg; /// files that only fail later in `wasm-tools` / `wit-bindgen`. #[test] fn generated_wit_is_valid_and_bindable() { - let inst = build_test_instance(); + let (scope, inst) = build_test_instance(); let idx = inst .all_components() .find(|(_, c)| c.category == ComponentCategory::Process) .map(|(idx, _)| idx) .expect("test model has a process"); - let file = generate_wit(&inst, idx); + let file = generate_wit(&inst, &scope, idx); // No stray underscores in the emitted WIT (kebab-case only). The header // comment echoes the AADL names, so check the body after it. @@ -408,7 +466,7 @@ end TestPkg; /// Fixture for REQ-CODEGEN-WIT-RECORDS-001 (#319): a `data implementation` /// with scalar subcomponents (the real-world `EepromSnapshot.Impl` = /// u32 + u32 + u8 pattern), referenced by a process port. - fn build_record_test_instance() -> SystemInstance { + fn build_record_test_instance() -> (GlobalScope, SystemInstance) { let aadl = r#" package RecPkg public @@ -461,12 +519,13 @@ end RecPkg; let sf = spar_base_db::SourceFile::new(&db, "rec.aadl".to_string(), aadl.to_string()); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("RecPkg"), &Name::new("Top"), &Name::new("Impl"), - ) + ); + (scope, inst) } /// RED-before-green oracle for REQ-CODEGEN-WIT-RECORDS-001 (#319 items 1,3,6): @@ -476,13 +535,13 @@ end RecPkg; /// defeating no_alloc). Currently RED: wit_gen emits `type ... = list`. #[test] fn data_implementation_becomes_wit_record() { - let inst = build_record_test_instance(); + let (scope, inst) = build_record_test_instance(); let idx = inst .all_components() .find(|(_, c)| c.category == ComponentCategory::Process) .map(|(idx, _)| idx) .expect("test model has a process"); - let file = generate_wit(&inst, idx); + let file = generate_wit(&inst, &scope, idx); let body: String = file .content .lines() @@ -508,5 +567,17 @@ end RecPkg; "expected record field `{field}`:\n{body}" ); } + + // The record must be VALID, bindable WIT — not just string-present. + // wit-parser rejects malformed records / undefined type references. + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("record.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "generated record WIT failed to parse/resolve: {e}\n---\n{}", + file.content + ) + }); } } diff --git a/crates/spar-codegen/tests/golden_test.rs b/crates/spar-codegen/tests/golden_test.rs index e05e4d1..8ebc7bc 100644 --- a/crates/spar-codegen/tests/golden_test.rs +++ b/crates/spar-codegen/tests/golden_test.rs @@ -11,16 +11,18 @@ use spar_hir_def::name::Name; use spar_hir_def::resolver::GlobalScope; /// Build a SystemInstance from inline AADL text. -fn build_instance(aadl: &str, pkg: &str, typ: &str, imp: &str) -> SystemInstance { +fn build_instance(aadl: &str, pkg: &str, typ: &str, imp: &str) -> (GlobalScope, SystemInstance) { let db = spar_hir_def::HirDefDatabase::default(); let sf = spar_base_db::SourceFile::new(&db, "test.aadl".to_string(), aadl.to_string()); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate(&scope, &Name::new(pkg), &Name::new(typ), &Name::new(imp)) + let inst = + SystemInstance::instantiate(&scope, &Name::new(pkg), &Name::new(typ), &Name::new(imp)); + (scope, inst) } /// Load the golden AADL model from test-data. -fn golden_instance() -> SystemInstance { +fn golden_instance() -> (GlobalScope, SystemInstance) { let aadl = include_str!("../../../test-data/codegen/building_control.aadl"); build_instance(aadl, "BuildingControl", "BuildingSystem", "impl") } @@ -29,7 +31,7 @@ fn golden_instance() -> SystemInstance { #[test] fn golden_instance_has_expected_hierarchy() { - let inst = golden_instance(); + let (_scope, inst) = golden_instance(); // Root is a system let root = inst.component(inst.root); @@ -60,7 +62,7 @@ fn golden_instance_has_expected_hierarchy() { // ── Full generation: all modules produce output ──────────────────── fn generate_all() -> CodegenOutput { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -69,7 +71,7 @@ fn generate_all() -> CodegenOutput { rivet: true, dry_run: true, }; - generate(&inst, &config) + generate(&inst, &scope, &config) } #[test] @@ -115,7 +117,7 @@ fn golden_model_generates_wit() { /// output as a predictably-shaped tree artifact. #[test] fn wit_format_emits_only_wit_files() { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -124,7 +126,7 @@ fn wit_format_emits_only_wit_files() { rivet: false, dry_run: true, }; - let output = generate(&inst, &config); + let output = generate(&inst, &scope, &config); assert!( !output.files.is_empty(), @@ -147,7 +149,7 @@ fn wit_format_emits_only_wit_files() { /// rust/both paths. #[test] fn rust_format_still_emits_workspace() { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -156,7 +158,7 @@ fn rust_format_still_emits_workspace() { rivet: false, dry_run: true, }; - let output = generate(&inst, &config); + let output = generate(&inst, &scope, &config); assert!( output.files.iter().any(|f| f.path == "Cargo.toml"), diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index 127e020..f5069de 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -28,6 +28,29 @@ pub enum ResolvedClassifier { Unresolved, } +/// The WIT-relevant shape of a `data` classifier, resolved from the item-tree +/// for code generation (REQ-CODEGEN-WIT-RECORDS-001, GitHub #319). Lets +/// spar-codegen decide between a scalar type alias, a WIT record, or a hard +/// error without re-implementing classifier / `Data_Size` resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataShape { + /// A scalar `data` type with a resolved `Data_Size`, in bytes. + Scalar { bytes: u64 }, + /// A `data implementation` decomposed into named fields (→ WIT record). + /// Fields are in declaration order; each shape is resolved recursively. + Record { fields: Vec }, + /// A `data` classifier with neither a `Data_Size` nor subcomponents, or one + /// that does not resolve — opaque (codegen keeps its legacy handling). + Opaque, +} + +/// One field of a [`DataShape::Record`] — a subcomponent of a data impl. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataField { + pub name: Name, + pub shape: DataShape, +} + /// Result of resolving a property reference. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedProperty { @@ -325,6 +348,68 @@ impl GlobalScope { Some(&tree.features[feat_idx]) } + /// Resolve a `data` classifier to its WIT-relevant [`DataShape`]: a + /// `data implementation` with subcomponents becomes a `Record` (fields + /// resolved recursively), a `data` type carrying `Data_Size` becomes a + /// `Scalar`, and anything else is `Opaque`. REQ-CODEGEN-WIT-RECORDS-001 + /// (GitHub #319) — codegen consumes this to emit typed records instead of + /// opaque `list` blobs. + pub fn resolve_data_shape(&self, from_package: &Name, classifier: &ClassifierRef) -> DataShape { + let resolved = self.resolve_classifier(from_package, classifier); + // An implementation with subcomponents = a record of fields. + if let ResolvedClassifier::ComponentImpl { loc, package } = &resolved + && let Some(ci) = self.get_component_impl(*loc) + && ci.category == crate::item_tree::ComponentCategory::Data + && !ci.subcomponents.is_empty() + && let Some(tree) = self.tree(loc.tree) + { + let mut fields = Vec::new(); + for &sub_idx in &ci.subcomponents { + let sub = &tree.subcomponents[sub_idx]; + let shape = match &sub.classifier { + Some(cref) => self.resolve_data_shape(package, cref), + None => DataShape::Opaque, + }; + fields.push(DataField { + name: sub.name.clone(), + shape, + }); + } + return DataShape::Record { fields }; + } + // A data type carrying Data_Size = a scalar. + if let Some(bytes) = self.data_size_bytes(from_package, classifier) { + return DataShape::Scalar { bytes }; + } + DataShape::Opaque + } + + /// Resolve a `data` classifier's declared `Data_Size`, in bytes. Mirrors the + /// instance builder's private resolver so codegen can size record fields + /// without instantiating them. REQ-CODEGEN-WIT-RECORDS-001. + pub fn data_size_bytes(&self, from_package: &Name, classifier: &ClassifierRef) -> Option { + let resolved = self.resolve_classifier(from_package, classifier); + let loc = match &resolved { + ResolvedClassifier::ComponentType { loc, .. } => *loc, + _ => return None, + }; + let ct = self.get_component_type(loc)?; + if ct.category != crate::item_tree::ComponentCategory::Data { + return None; + } + let tree = self.tree(loc.tree)?; + for &pa_idx in &ct.property_associations { + let pa = &tree.property_associations[pa_idx]; + let pn = pa.name.property_name.as_str(); + if pn.eq_ignore_ascii_case("Data_Size") || pn.eq_ignore_ascii_case("Source_Data_Size") { + // parse_size_value returns BITS ("8 Bytes" -> 64). + let bits = crate::property_value::parse_size_value(&pa.value)?; + return Some(bits / 8); + } + } + None + } + /// Resolve a classifier reference from within a package context. pub fn resolve_classifier( &self, From ac95c531199ca860dc6304a57447829ba8d1041b Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 3 Jul 2026 12:30:59 +0200 Subject: [PATCH 03/13] feat(codegen): hard-error on non-encodable record fields (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2a of #319. Implements the user-chosen no_alloc policy: a record field that cannot be encoded without heap allocation makes codegen REFUSE (return an error) rather than silently emit an allocating `list`. - New `CodegenError { errors: Vec }` (Display + Error); `generate` and `generate_wit` are now fallible. The CLI prints the diagnostic and exits 1 ("refuse to generate"). - `record_fields` is now three-way: `Err(msgs)` for genuinely non-encodable fields (a non-power-of-two Data_Size, or an opaque type with neither size nor fields) — every offending field reported, not just the first; `Ok(None)` for a nested-record field (representable but not yet emitted — kept as the legacy list fallback so a *supportable* type isn't wrongly rejected, P2b); `Ok(Some(rows))` for an all-scalar record. Oracle (new, green): a record with a 3-byte field and an opaque field → `generate_wit` returns Err naming both `odd` and `blob`, and does NOT flag the representable `good: u32`. Existing record + golden + kebab tests unchanged. Verified workspace-wide (cargo test --workspace --all-targets --no-run). Deliberately scoped: only record FIELDS hard-error. Top-level opaque port types keep the legacy list (unchanged behavior). Nested records (P2b), signedness/float mapping (P2c), and wit-bindgen wiring + Dispatch_Protocol lifecycle (P3) remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-cli/src/main.rs | 5 +- .../benches/codegen_benchmarks.rs | 6 +- crates/spar-codegen/src/lib.rs | 32 ++- crates/spar-codegen/src/wit_gen.rs | 184 +++++++++++++++--- crates/spar-codegen/tests/golden_test.rs | 8 +- 5 files changed, 202 insertions(+), 33 deletions(-) diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index 4bc1e79..c4e852e 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -2074,7 +2074,10 @@ fn cmd_codegen(args: &[String]) { dry_run, }; - let result = spar_codegen::generate(&inst, &scope, &config); + let result = spar_codegen::generate(&inst, &scope, &config).unwrap_or_else(|e| { + eprint!("{e}"); + process::exit(1); + }); if dry_run { eprintln!( diff --git a/crates/spar-codegen/benches/codegen_benchmarks.rs b/crates/spar-codegen/benches/codegen_benchmarks.rs index b1586ef..8a07341 100644 --- a/crates/spar-codegen/benches/codegen_benchmarks.rs +++ b/crates/spar-codegen/benches/codegen_benchmarks.rs @@ -122,7 +122,8 @@ fn bench_codegen_emit(c: &mut Criterion) { // workspace, concatenated into in-memory strings. group.bench_function("generate_full_64", |b| { b.iter(|| { - let output = generate(black_box(&instance), black_box(&scope), black_box(&config)); + let output = generate(black_box(&instance), black_box(&scope), black_box(&config)) + .expect("bench codegen"); black_box(output); }); }); @@ -141,7 +142,8 @@ fn bench_codegen_emit(c: &mut Criterion) { black_box(&instance), black_box(&scope), black_box(&rust_only_config), - ); + ) + .expect("bench codegen"); black_box(output); }); }); diff --git a/crates/spar-codegen/src/lib.rs b/crates/spar-codegen/src/lib.rs index c7de992..312d0c9 100644 --- a/crates/spar-codegen/src/lib.rs +++ b/crates/spar-codegen/src/lib.rs @@ -73,6 +73,32 @@ pub struct CodegenOutput { pub files: Vec, } +/// A hard code-generation failure: the model asks for something codegen refuses +/// to emit — chiefly a data type that cannot be encoded without heap allocation +/// (violating the no_alloc guarantee). Codegen fails rather than silently +/// emitting an allocating `list`. REQ-CODEGEN-WIT-RECORDS-001 (#319). +#[derive(Debug, Clone)] +pub struct CodegenError { + /// One message per offending field/type; all are reported, not just the first. + pub errors: Vec, +} + +impl std::fmt::Display for CodegenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "codegen refused to generate ({} error(s)):", + self.errors.len() + )?; + for e in &self.errors { + writeln!(f, " - {e}")?; + } + Ok(()) + } +} + +impl std::error::Error for CodegenError {} + /// Extract timing properties from a component instance's property map. /// /// Returns (period_ps, deadline_ps, wcet_ps) in picoseconds, or None for each @@ -327,7 +353,7 @@ pub fn generate( inst: &SystemInstance, scope: &spar_hir_def::resolver::GlobalScope, config: &CodegenConfig, -) -> CodegenOutput { +) -> Result { let mut files = Vec::new(); // Collect processes and threads @@ -349,7 +375,7 @@ pub fn generate( // Generate WIT files if config.format == OutputFormat::Wit || config.format == OutputFormat::Both { for &(idx, _comp) in &processes { - files.push(wit_gen::generate_wit(inst, scope, idx)); + files.push(wit_gen::generate_wit(inst, scope, idx)?); } } @@ -435,7 +461,7 @@ pub fn generate( )); } - CodegenOutput { files } + Ok(CodegenOutput { files }) } #[cfg(test)] diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index ab10b59..53ce26c 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -31,22 +31,49 @@ fn scalar_wit(bytes: u64) -> Option<&'static str> { } } -/// Build the `(field, wit-type)` rows for a record whose fields are ALL directly -/// representable no_alloc scalars, in declaration order. Returns `None` if any -/// field is not (a non-power-of-two scalar, a nested record, or opaque): P1 -/// keeps such a type as the legacy `list`; P2 turns this into a hard error -/// and adds nested-record support. REQ-CODEGEN-WIT-RECORDS-001 (#319). -fn record_fields(fields: &[DataField]) -> Option> { +/// Resolve a record's fields to WIT, in declaration order. Three outcomes +/// (REQ-CODEGEN-WIT-RECORDS-001, #319): +/// - `Err(msgs)` — one or more fields are genuinely NON-ENCODABLE without heap +/// allocation (a non-power-of-two `Data_Size`, or an opaque type with neither +/// size nor fields). Codegen must refuse (the user-chosen hard-error policy); +/// every offending field is reported. +/// - `Ok(None)` — no hard errors, but a field is a nested record, which is +/// representable but NOT YET emitted (P2 follow-up). Caller keeps the legacy +/// `list` for the whole type rather than fail on a supportable case. +/// - `Ok(Some(rows))` — every field is a directly representable no_alloc scalar. +fn record_fields( + record_name: &str, + fields: &[DataField], +) -> Result>, Vec> { let mut rows = Vec::with_capacity(fields.len()); + let mut errors = Vec::new(); + let mut has_nested = false; for f in fields { + let field = f.name.as_str(); match &f.shape { - DataShape::Scalar { bytes } => { - rows.push((wit_ident(f.name.as_str()), scalar_wit(*bytes)?.to_string())); - } - _ => return None, + DataShape::Scalar { bytes } => match scalar_wit(*bytes) { + Some(w) => rows.push((wit_ident(field), w.to_string())), + None => errors.push(format!( + "record `{record_name}` field `{field}`: Data_Size {bytes} bytes has no \ + no_alloc WIT scalar (only 1, 2, 4 or 8 bytes are representable)" + )), + }, + // Representable in WIT (nested records are no_alloc), just not emitted + // yet — fall back rather than reject a supportable type. + DataShape::Record { .. } => has_nested = true, + DataShape::Opaque => errors.push(format!( + "record `{record_name}` field `{field}`: type declares no Data_Size and has no \ + fields — it cannot be encoded without heap allocation" + )), } } - Some(rows) + if !errors.is_empty() { + Err(errors) + } else if has_nested { + Ok(None) + } else { + Ok(Some(rows)) + } } /// Generate a WIT file for a process instance. @@ -59,7 +86,7 @@ pub fn generate_wit( inst: &SystemInstance, scope: &GlobalScope, proc_idx: ComponentInstanceIdx, -) -> GeneratedFile { +) -> Result { let comp = inst.component(proc_idx); let name = wit_ident(comp.name.as_str()); let pkg_name = wit_ident(comp.package.as_str()); @@ -100,21 +127,23 @@ pub fn generate_wit( // (REQ-CODEGEN-WIT-RECORDS-001, #319) instead of an opaque `list` blob // that would force `cabi_realloc` at the ABI boundary. Scalar-sized data // types map to precise WIT integers (1 -> u8 ... 8 -> u64). + let mut errors: Vec = Vec::new(); for (ty, cref) in &referenced { match scope.resolve_data_shape(&comp.package, cref) { - DataShape::Record { fields } => match record_fields(&fields) { - Some(rows) => { + DataShape::Record { fields } => match record_fields(ty, &fields) { + Ok(Some(rows)) => { wit.push_str(&format!(" record {ty} {{\n")); for (fname, fty) in rows { wit.push_str(&format!(" {fname}: {fty},\n")); } wit.push_str(" }\n"); } - // A field that can't be a no_alloc scalar (non-power-of-2 size, - // nested record, or opaque): keep the legacy byte-buffer alias - // for now. The hard-error diagnostic + nested records are P2/P3 - // of REQ-CODEGEN-WIT-RECORDS-001. - None => wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")), + // A nested-record field: representable but not yet emitted — + // keep the legacy byte-buffer alias (P2 follow-up). + Ok(None) => wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")), + // A genuinely non-encodable field: refuse (no_alloc is a hard + // guarantee). Collect every offending field before failing. + Err(errs) => errors.extend(errs), }, DataShape::Scalar { bytes } => { let wit_ty = scalar_wit(bytes).unwrap_or(DEFAULT_WIT_TYPE); @@ -125,6 +154,9 @@ pub fn generate_wit( } } } + if !errors.is_empty() { + return Err(crate::CodegenError { errors }); + } if !referenced.is_empty() { wit.push('\n'); } @@ -196,10 +228,10 @@ pub fn generate_wit( wit.push_str("}\n"); - GeneratedFile { + Ok(GeneratedFile { path: format!("wit/{name}.wit"), content: wit, - } + }) } /// WIT reserved words that must be `%`-escaped when used as identifiers. @@ -378,7 +410,7 @@ end TestPkg; .map(|(idx, _)| idx); if let Some(idx) = proc_idx { - let file = generate_wit(&inst, &scope, idx); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); assert!(file.path.ends_with(".wit")); assert!(file.content.contains("package")); assert!(file.content.contains("world")); @@ -397,7 +429,7 @@ end TestPkg; .find(|(_, c)| c.category == ComponentCategory::Process) .map(|(idx, _)| idx) .expect("test model has a process"); - let file = generate_wit(&inst, &scope, idx); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); // No stray underscores in the emitted WIT (kebab-case only). The header // comment echoes the AADL names, so check the body after it. @@ -541,7 +573,7 @@ end RecPkg; .find(|(_, c)| c.category == ComponentCategory::Process) .map(|(idx, _)| idx) .expect("test model has a process"); - let file = generate_wit(&inst, &scope, idx); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); let body: String = file .content .lines() @@ -580,4 +612,108 @@ end RecPkg; ) }); } + + /// Fixture for the hard-error path: a record with fields that CANNOT be + /// encoded without heap allocation — a 3-byte scalar (no WIT integer) and an + /// opaque type (no Data_Size, no fields). + fn build_bad_record_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package BadPkg +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Odd3 + properties + Data_Size => 3 Bytes; + end Odd3; + + data Blob + end Blob; + + data BadRecord + end BadRecord; + + data implementation BadRecord.Impl + subcomponents + good: data Word32; + odd: data Odd3; + blob: data Blob; + end BadRecord.Impl; + + process BadStore + features + in_port: in data port BadRecord.Impl; + end BadStore; + + process implementation BadStore.Impl + subcomponents + th: thread BadThread.Impl; + end BadStore.Impl; + + thread BadThread + end BadThread; + + thread implementation BadThread.Impl + end BadThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process BadStore.Impl; + end Top.Impl; +end BadPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "bad.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("BadPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for the user-chosen hard-error policy (REQ-CODEGEN-WIT-RECORDS-001, + /// #319): a record field that cannot be a no_alloc scalar makes codegen + /// REFUSE (return an error) rather than silently emit an allocating + /// `list`. Both offending fields must be reported; the good field must + /// not appear as an error. + #[test] + fn non_encodable_record_field_is_a_hard_error() { + let (scope, inst) = build_bad_record_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + + let err = generate_wit(&inst, &scope, idx) + .expect_err("codegen must refuse a record with non-encodable fields"); + + // The 3-byte field and the opaque field are both reported… + assert!( + err.errors.iter().any(|e| e.contains("`odd`")), + "expected a hard error naming the 3-byte field `odd`: {:?}", + err.errors + ); + assert!( + err.errors.iter().any(|e| e.contains("`blob`")), + "expected a hard error naming the opaque field `blob`: {:?}", + err.errors + ); + // …and the representable `good` field is NOT flagged. + assert!( + !err.errors.iter().any(|e| e.contains("`good`")), + "the representable u32 field `good` must not be an error: {:?}", + err.errors + ); + } } diff --git a/crates/spar-codegen/tests/golden_test.rs b/crates/spar-codegen/tests/golden_test.rs index 8ebc7bc..1206454 100644 --- a/crates/spar-codegen/tests/golden_test.rs +++ b/crates/spar-codegen/tests/golden_test.rs @@ -71,7 +71,7 @@ fn generate_all() -> CodegenOutput { rivet: true, dry_run: true, }; - generate(&inst, &scope, &config) + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model") } #[test] @@ -126,7 +126,8 @@ fn wit_format_emits_only_wit_files() { rivet: false, dry_run: true, }; - let output = generate(&inst, &scope, &config); + let output = + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model"); assert!( !output.files.is_empty(), @@ -158,7 +159,8 @@ fn rust_format_still_emits_workspace() { rivet: false, dry_run: true, }; - let output = generate(&inst, &scope, &config); + let output = + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model"); assert!( output.files.iter().any(|f| f.path == "Cargo.toml"), From 08da1f5b6d4ad9e0208202bd9525fbc961430734 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 7 Jul 2026 21:36:52 +0200 Subject: [PATCH 04/13] feat(codegen): map AADL signedness/float to WIT s*/u*/f* scalars (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2c of #319. Scalar record fields (and top-level scalar port types) now honor the Data Modeling Annex representation instead of always emitting unsigned: - `Data_Representation => Float` -> f32 (4B) / f64 (8B) - `Number_Representation => Signed` -> s8 / s16 / s32 / s64 - unspecified -> unsigned u8 / u16 / u32 / u64 (unchanged default) `DataShape::Scalar` gains a `ScalarKind` (Unsigned/Signed/Float); the resolver's `scalar_of` reads `Data_Representation`/`Number_Representation` off the data type's property associations (last `::` segment, so `Data_Model::Float` matches), defaulting to integer/unsigned. `scalar_wit(bytes, kind)` maps accordingly; a sub-32-bit float (no WIT f8/f16) is non-representable and hard-errors as a record field, per P2a. Oracle (green): a record { temp: FloatWord[4B,Float], offset: SignedWord[4B,Signed], count: Ushort[2B] } generates `record { temp: f32, offset: s32, count: u16 }` and binds under wit-parser. Existing unsigned mappings (clock64->u64, flag8->u8) unchanged. Verified workspace-wide. Note: field named `offset` not `delta` — `delta` is a spar reserved keyword (DELTA_KW) and, used as a subcomponent identifier, silently drops all following declarations with no diagnostic (a separate spar parser-robustness gap). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-codegen/src/wit_gen.rs | 153 +++++++++++++++++++++++++--- crates/spar-hir-def/src/resolver.rs | 57 +++++++++-- 2 files changed, 187 insertions(+), 23 deletions(-) diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index 53ce26c..490c4ac 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::{ComponentCategory, Direction, FeatureKind}; use spar_hir_def::name::ClassifierRef; -use spar_hir_def::resolver::{DataField, DataShape, GlobalScope}; +use spar_hir_def::resolver::{DataField, DataShape, GlobalScope, ScalarKind}; use crate::GeneratedFile; @@ -17,17 +17,32 @@ use crate::GeneratedFile; /// A byte buffer is always valid and bindable; `bytes` is NOT a WIT primitive. const DEFAULT_WIT_TYPE: &str = "list"; -/// The WIT integer type for a power-of-two `Data_Size` in bytes, or `None` if -/// the size is not directly representable as a WIT scalar — which would force a -/// heap-allocating `list` at the ABI boundary (a no_alloc violation). +/// The WIT scalar for a power-of-two `Data_Size` in bytes and its numeric +/// representation, or `None` if not directly representable — which would force a +/// heap-allocating `list` at the ABI boundary (a no_alloc violation). WIT +/// has no f8/f16, so sub-32-bit floats are unrepresentable. /// REQ-CODEGEN-WIT-RECORDS-001 (#319). -fn scalar_wit(bytes: u64) -> Option<&'static str> { - match bytes { - 1 => Some("u8"), - 2 => Some("u16"), - 4 => Some("u32"), - 8 => Some("u64"), - _ => None, +fn scalar_wit(bytes: u64, kind: ScalarKind) -> Option<&'static str> { + match kind { + ScalarKind::Float => match bytes { + 4 => Some("f32"), + 8 => Some("f64"), + _ => None, + }, + ScalarKind::Signed => match bytes { + 1 => Some("s8"), + 2 => Some("s16"), + 4 => Some("s32"), + 8 => Some("s64"), + _ => None, + }, + ScalarKind::Unsigned => match bytes { + 1 => Some("u8"), + 2 => Some("u16"), + 4 => Some("u32"), + 8 => Some("u64"), + _ => None, + }, } } @@ -51,11 +66,11 @@ fn record_fields( for f in fields { let field = f.name.as_str(); match &f.shape { - DataShape::Scalar { bytes } => match scalar_wit(*bytes) { + DataShape::Scalar { bytes, kind } => match scalar_wit(*bytes, *kind) { Some(w) => rows.push((wit_ident(field), w.to_string())), None => errors.push(format!( - "record `{record_name}` field `{field}`: Data_Size {bytes} bytes has no \ - no_alloc WIT scalar (only 1, 2, 4 or 8 bytes are representable)" + "record `{record_name}` field `{field}`: {bytes}-byte {kind:?} has no \ + no_alloc WIT scalar (int 1/2/4/8, float 4/8 only)" )), }, // Representable in WIT (nested records are no_alloc), just not emitted @@ -145,8 +160,8 @@ pub fn generate_wit( // guarantee). Collect every offending field before failing. Err(errs) => errors.extend(errs), }, - DataShape::Scalar { bytes } => { - let wit_ty = scalar_wit(bytes).unwrap_or(DEFAULT_WIT_TYPE); + DataShape::Scalar { bytes, kind } => { + let wit_ty = scalar_wit(bytes, kind).unwrap_or(DEFAULT_WIT_TYPE); wit.push_str(&format!(" type {ty} = {wit_ty};\n")); } DataShape::Opaque => { @@ -716,4 +731,110 @@ end BadPkg; err.errors ); } + + /// Fixture with signed / float / unsigned scalar fields (Data Modeling Annex + /// `Number_Representation` / `Data_Representation` properties). + fn build_signed_float_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package NumPkg +public + data FloatWord + properties + Data_Size => 4 Bytes; + Data_Representation => Float; + end FloatWord; + + data SignedWord + properties + Data_Size => 4 Bytes; + Number_Representation => Signed; + end SignedWord; + + data Ushort + properties + Data_Size => 2 Bytes; + end Ushort; + + data Reading + end Reading; + + data implementation Reading.Impl + subcomponents + temp: data FloatWord; + offset: data SignedWord; + count: data Ushort; + end Reading.Impl; + + process Store + features + snapshot_in: in data port Reading.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end NumPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "num.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("NumPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for signed/float mapping (REQ-CODEGEN-WIT-RECORDS-001, #319 P2c): + /// `Data_Representation => Float` maps to `f32`/`f64`, `Number_Representation + /// => Signed` to `s8..s64`, and unspecified stays unsigned `u*`. + #[test] + fn record_fields_honor_signedness_and_float() { + let (scope, inst) = build_signed_float_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body: String = file + .content + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + for field in ["temp: f32", "offset: s32", "count: u16"] { + assert!( + body.contains(field), + "expected record field `{field}` (signed/float mapping):\n{body}" + ); + } + + // Still valid, bindable WIT. + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("num.wit", &file.content) + .unwrap_or_else(|e| { + panic!("generated WIT failed to parse: {e}\n---\n{}", file.content) + }); + } } diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index f5069de..0bf5660 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -34,8 +34,9 @@ pub enum ResolvedClassifier { /// error without re-implementing classifier / `Data_Size` resolution. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DataShape { - /// A scalar `data` type with a resolved `Data_Size`, in bytes. - Scalar { bytes: u64 }, + /// A scalar `data` type with a resolved `Data_Size`, in bytes, and its + /// numeric representation (signed / unsigned / float). + Scalar { bytes: u64, kind: ScalarKind }, /// A `data implementation` decomposed into named fields (→ WIT record). /// Fields are in declaration order; each shape is resolved recursively. Record { fields: Vec }, @@ -44,6 +45,16 @@ pub enum DataShape { Opaque, } +/// Numeric representation of a scalar `data` type, from the Data Modeling Annex +/// `Data_Representation` / `Number_Representation` properties. Defaults to +/// `Unsigned` when unspecified (conservative). REQ-CODEGEN-WIT-RECORDS-001. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScalarKind { + Unsigned, + Signed, + Float, +} + /// One field of a [`DataShape::Record`] — a subcomponent of a data impl. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataField { @@ -378,8 +389,8 @@ impl GlobalScope { return DataShape::Record { fields }; } // A data type carrying Data_Size = a scalar. - if let Some(bytes) = self.data_size_bytes(from_package, classifier) { - return DataShape::Scalar { bytes }; + if let Some((bytes, kind)) = self.scalar_of(from_package, classifier) { + return DataShape::Scalar { bytes, kind }; } DataShape::Opaque } @@ -388,6 +399,20 @@ impl GlobalScope { /// instance builder's private resolver so codegen can size record fields /// without instantiating them. REQ-CODEGEN-WIT-RECORDS-001. pub fn data_size_bytes(&self, from_package: &Name, classifier: &ClassifierRef) -> Option { + self.scalar_of(from_package, classifier) + .map(|(bytes, _)| bytes) + } + + /// Resolve a scalar `data` type to its (`Data_Size` bytes, representation). + /// Reads the Data Modeling Annex `Data_Representation` (Integer vs Float) and + /// `Number_Representation` (Signed vs Unsigned); both default to + /// integer/unsigned when unspecified. Returns `None` when the classifier is + /// not a `data` type or declares no `Data_Size`. REQ-CODEGEN-WIT-RECORDS-001. + fn scalar_of( + &self, + from_package: &Name, + classifier: &ClassifierRef, + ) -> Option<(u64, ScalarKind)> { let resolved = self.resolve_classifier(from_package, classifier); let loc = match &resolved { ResolvedClassifier::ComponentType { loc, .. } => *loc, @@ -398,16 +423,34 @@ impl GlobalScope { return None; } let tree = self.tree(loc.tree)?; + // Last path segment of an enum literal, so a qualified value like + // `Data_Model::Float` still matches `Float`. + fn leaf(v: &str) -> &str { + v.rsplit("::").next().unwrap_or(v).trim() + } + let mut bytes = None; + let mut is_float = false; + let mut is_signed = false; for &pa_idx in &ct.property_associations { let pa = &tree.property_associations[pa_idx]; let pn = pa.name.property_name.as_str(); if pn.eq_ignore_ascii_case("Data_Size") || pn.eq_ignore_ascii_case("Source_Data_Size") { // parse_size_value returns BITS ("8 Bytes" -> 64). - let bits = crate::property_value::parse_size_value(&pa.value)?; - return Some(bits / 8); + bytes = crate::property_value::parse_size_value(&pa.value).map(|bits| bits / 8); + } else if pn.eq_ignore_ascii_case("Data_Representation") { + is_float = leaf(&pa.value).eq_ignore_ascii_case("Float"); + } else if pn.eq_ignore_ascii_case("Number_Representation") { + is_signed = leaf(&pa.value).eq_ignore_ascii_case("Signed"); } } - None + let kind = if is_float { + ScalarKind::Float + } else if is_signed { + ScalarKind::Signed + } else { + ScalarKind::Unsigned + }; + Some((bytes?, kind)) } /// Resolve a classifier reference from within a package context. From aef1ed4e1c8c43c076cc4a243fb31b1d91255df3 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 07:19:07 +0200 Subject: [PATCH 05/13] feat(codegen): emit nested WIT records for record-typed fields (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2b of #319 — completes P2. A record field whose type is itself a `data implementation` now becomes a nested WIT record, emitted dependency-first and referenced by name, instead of falling back to `list`. data implementation Packet.Impl { head: data Header.Impl; body: data Word32; } => record header-impl { hi: u16, lo: u8 } record packet-impl { head: header-impl, body: u32 } - `DataShape::Record` carries its source `type_name` so nested records can be named consistently (resolver sets it from the classifier). - `flatten_record` recursively collects every distinct record definition into a dependency-first list (post-order; `seen` dedups shared nested types) and references nested records by their WIT type name. Non-encodable fields still hard-error (P2a) recursively through nesting. Oracle (green): nested `header-impl` is emitted BEFORE the outer `packet-impl` that references it (`head: header-impl`), no `list`, binds under wit-parser. Existing flat-record / hard-error / signedness tests unchanged. Verified workspace-wide. P2 complete (hard-error + signedness/float + nested records). Remaining: P3 — wit-bindgen Guest/export! wiring + Dispatch_Protocol lifecycle + the compile/zero-cabi_realloc acceptance oracle. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-codegen/src/wit_gen.rs | 251 +++++++++++++++++++++++----- crates/spar-hir-def/src/resolver.rs | 14 +- 2 files changed, 216 insertions(+), 49 deletions(-) diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index 490c4ac..d6797c5 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -46,49 +46,55 @@ fn scalar_wit(bytes: u64, kind: ScalarKind) -> Option<&'static str> { } } -/// Resolve a record's fields to WIT, in declaration order. Three outcomes -/// (REQ-CODEGEN-WIT-RECORDS-001, #319): -/// - `Err(msgs)` — one or more fields are genuinely NON-ENCODABLE without heap -/// allocation (a non-power-of-two `Data_Size`, or an opaque type with neither -/// size nor fields). Codegen must refuse (the user-chosen hard-error policy); -/// every offending field is reported. -/// - `Ok(None)` — no hard errors, but a field is a nested record, which is -/// representable but NOT YET emitted (P2 follow-up). Caller keeps the legacy -/// `list` for the whole type rather than fail on a supportable case. -/// - `Ok(Some(rows))` — every field is a directly representable no_alloc scalar. -fn record_fields( - record_name: &str, +/// One emitted WIT record definition: `(wit-type-name, [(field, wit-type)])`. +type RecordDef = (String, Vec<(String, String)>); + +/// Recursively flatten a `Record` shape into WIT record definitions, appending +/// each distinct record to `out` in NESTED-FIRST order (a record is pushed after +/// the records it depends on) and returning the top record's WIT type name. +/// A nested-record field becomes a reference to that record's type; a genuinely +/// non-encodable field (non-power-of-two `Data_Size`, sub-32-bit float, or an +/// opaque type) is collected into `errors` — the caller refuses to generate +/// (the user-chosen no_alloc hard-error policy). REQ-CODEGEN-WIT-RECORDS-001 (#319). +fn flatten_record( + type_name: &str, fields: &[DataField], -) -> Result>, Vec> { + out: &mut Vec, + seen: &mut std::collections::BTreeSet, + errors: &mut Vec, +) -> String { + let wit_name = wit_ident(type_name); + if !seen.insert(wit_name.clone()) { + return wit_name; // already flattened (shared nested type) + } let mut rows = Vec::with_capacity(fields.len()); - let mut errors = Vec::new(); - let mut has_nested = false; for f in fields { let field = f.name.as_str(); match &f.shape { DataShape::Scalar { bytes, kind } => match scalar_wit(*bytes, *kind) { Some(w) => rows.push((wit_ident(field), w.to_string())), None => errors.push(format!( - "record `{record_name}` field `{field}`: {bytes}-byte {kind:?} has no \ + "record `{type_name}` field `{field}`: {bytes}-byte {kind:?} has no \ no_alloc WIT scalar (int 1/2/4/8, float 4/8 only)" )), }, - // Representable in WIT (nested records are no_alloc), just not emitted - // yet — fall back rather than reject a supportable type. - DataShape::Record { .. } => has_nested = true, + DataShape::Record { + type_name: nested_tn, + fields: nested_fields, + } => { + let nested = flatten_record(nested_tn, nested_fields, out, seen, errors); + rows.push((wit_ident(field), nested)); + } DataShape::Opaque => errors.push(format!( - "record `{record_name}` field `{field}`: type declares no Data_Size and has no \ + "record `{type_name}` field `{field}`: type declares no Data_Size and has no \ fields — it cannot be encoded without heap allocation" )), } } - if !errors.is_empty() { - Err(errors) - } else if has_nested { - Ok(None) - } else { - Ok(Some(rows)) - } + // Pushed AFTER its nested records (post-order), so definitions are emitted + // dependency-first. + out.push((wit_name.clone(), rows)); + wit_name } /// Generate a WIT file for a process instance. @@ -142,36 +148,45 @@ pub fn generate_wit( // (REQ-CODEGEN-WIT-RECORDS-001, #319) instead of an opaque `list` blob // that would force `cabi_realloc` at the ABI boundary. Scalar-sized data // types map to precise WIT integers (1 -> u8 ... 8 -> u64). + let mut record_defs: Vec = Vec::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut aliases: Vec<(String, String)> = Vec::new(); let mut errors: Vec = Vec::new(); for (ty, cref) in &referenced { match scope.resolve_data_shape(&comp.package, cref) { - DataShape::Record { fields } => match record_fields(ty, &fields) { - Ok(Some(rows)) => { - wit.push_str(&format!(" record {ty} {{\n")); - for (fname, fty) in rows { - wit.push_str(&format!(" {fname}: {fty},\n")); - } - wit.push_str(" }\n"); - } - // A nested-record field: representable but not yet emitted — - // keep the legacy byte-buffer alias (P2 follow-up). - Ok(None) => wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")), - // A genuinely non-encodable field: refuse (no_alloc is a hard - // guarantee). Collect every offending field before failing. - Err(errs) => errors.extend(errs), - }, + // A record (and its nested records, transitively) — refuse on any + // non-encodable field. + DataShape::Record { type_name, fields } => { + flatten_record( + &type_name, + &fields, + &mut record_defs, + &mut seen, + &mut errors, + ); + } DataShape::Scalar { bytes, kind } => { let wit_ty = scalar_wit(bytes, kind).unwrap_or(DEFAULT_WIT_TYPE); - wit.push_str(&format!(" type {ty} = {wit_ty};\n")); - } - DataShape::Opaque => { - wit.push_str(&format!(" type {ty} = {DEFAULT_WIT_TYPE};\n")); + aliases.push((ty.clone(), wit_ty.to_string())); } + DataShape::Opaque => aliases.push((ty.clone(), DEFAULT_WIT_TYPE.to_string())), } } if !errors.is_empty() { return Err(crate::CodegenError { errors }); } + // Emit record definitions dependency-first (nested before outer), then the + // scalar / byte-buffer aliases. + for (rec_name, rows) in &record_defs { + wit.push_str(&format!(" record {rec_name} {{\n")); + for (fname, fty) in rows { + wit.push_str(&format!(" {fname}: {fty},\n")); + } + wit.push_str(" }\n"); + } + for (alias, wit_ty) in &aliases { + wit.push_str(&format!(" type {alias} = {wit_ty};\n")); + } if !referenced.is_empty() { wit.push('\n'); } @@ -837,4 +852,148 @@ end NumPkg; panic!("generated WIT failed to parse: {e}\n---\n{}", file.content) }); } + + /// Fixture with a NESTED record: `Packet.Impl` has a field whose type is + /// itself a record (`Header.Impl`). + fn build_nested_record_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package NestPkg +public + data Word16 + properties + Data_Size => 2 Bytes; + end Word16; + + data Byte + properties + Data_Size => 1 Bytes; + end Byte; + + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Header + end Header; + + data implementation Header.Impl + subcomponents + hi: data Word16; + lo: data Byte; + end Header.Impl; + + data Packet + end Packet; + + data implementation Packet.Impl + subcomponents + head: data Header.Impl; + body: data Word32; + end Packet.Impl; + + process Store + features + snapshot_in: in data port Packet.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end NestPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "nest.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("NestPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for nested records (REQ-CODEGEN-WIT-RECORDS-001, #319 P2b): a record + /// field whose type is another `data implementation` becomes a nested WIT + /// record, emitted dependency-first and referenced by name. + #[test] + fn nested_record_emits_inner_record_and_references_it() { + let (scope, inst) = build_nested_record_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body: String = file + .content + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + // Inner record with its scalar fields… + assert!( + body.contains("record header-impl {"), + "expected nested record `header-impl`:\n{body}" + ); + for field in ["hi: u16", "lo: u8"] { + assert!( + body.contains(field), + "expected inner field `{field}`:\n{body}" + ); + } + // …and the outer record referencing it by type name (not list). + assert!( + body.contains("record packet-impl {"), + "expected outer record `packet-impl`:\n{body}" + ); + assert!( + body.contains("head: header-impl"), + "outer record must reference the nested record by name:\n{body}" + ); + assert!( + body.contains("body: u32"), + "expected outer scalar field `body: u32`:\n{body}" + ); + assert!( + !body.contains("= list"), + "nested record must NOT fall back to list:\n{body}" + ); + + // The nested record must be declared BEFORE the outer that references it, + // and the whole thing must bind under wit-parser. + let inner_at = body.find("record header-impl").unwrap(); + let outer_at = body.find("record packet-impl").unwrap(); + assert!( + inner_at < outer_at, + "nested record must be emitted dependency-first:\n{body}" + ); + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("nest.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "nested-record WIT failed to parse: {e}\n---\n{}", + file.content + ) + }); + } } diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index 0bf5660..1126f6a 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -38,8 +38,13 @@ pub enum DataShape { /// numeric representation (signed / unsigned / float). Scalar { bytes: u64, kind: ScalarKind }, /// A `data implementation` decomposed into named fields (→ WIT record). - /// Fields are in declaration order; each shape is resolved recursively. - Record { fields: Vec }, + /// `type_name` is the source classifier string (e.g. `Packet.Impl`) so + /// codegen can name a nested record consistently; `fields` are in + /// declaration order, each shape resolved recursively. + Record { + type_name: String, + fields: Vec, + }, /// A `data` classifier with neither a `Data_Size` nor subcomponents, or one /// that does not resolve — opaque (codegen keeps its legacy handling). Opaque, @@ -386,7 +391,10 @@ impl GlobalScope { shape, }); } - return DataShape::Record { fields }; + return DataShape::Record { + type_name: classifier.to_string(), + fields, + }; } // A data type carrying Data_Size = a scalar. if let Some((bytes, kind)) = self.scalar_of(from_package, classifier) { From 6c6e005b32f0467d2ef820d55b44a6ff4c3bc864 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 08:19:30 +0200 Subject: [PATCH 06/13] feat(codegen): wire WIT world to Rust Guest + Dispatch_Protocol lifecycle (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #319 items 4 & 7 (WIT<->Rust disconnect; opaque world export). The generated interface wiring is compiler-enforced, not string-hoped. - lib.rs: shared Lifecycle / lifecycle_for(dispatch) / dispatch_protocol(inst,idx) helpers — the single source the WIT world exports, the Rust trait/Guest, and the test harness all derive from (Periodic -> compute only; else initialize/compute/finalize). - wit_gen: each thread exports its Dispatch_Protocol lifecycle as {instance}-{method} funcs, replacing the opaque single {thread}: func(). wit_ident is now pub(crate) so rust_gen derives exactly-matching names. - rust_gen::generate_process_bindings: emit crates/{proc}/src/lib.rs with wit_bindgen::generate! + impl Guest (todo!() stub bodies) + export!, and make the per-thread {Struct}Component trait lifecycle-derived. workspace_gen no longer emits the placeholder crate lib.rs (bindings own it). - test_gen: harness is now lifecycle-aware — a Periodic thread's harness calls and defines tests for `compute` only, never the initialize/finalize the trait no longer has. Keeps generated test output coherent with the trimmed trait. - Oracle tests/lifecycle_bindings.rs: fast string-alignment tests + a compiler-enforced #[ignore] gate (emitted_crate_cargo_checks) on a RECORD-bearing, MULTI-PROCESS, mixed-dispatch model — it emits the full workspace and runs `cargo check`. wit-bindgen derives the Guest trait from the world, so a name-mangling/keyword/collision bug (or a wit-package/path collision across the two member crates) is a compile error (verified GREEN; a deliberately renamed Guest method fails E0046/E0407). - Export names use the SUBCOMPONENT INSTANCE name (unique per composite); both generators read child.name so world and Guest agree by construction. - artifacts: add REQ-CODEGEN-WIT-RECORDS-001 (referenced by 5 prior commits but never created) + successor REQ-CODEGEN-WIT-RECORDS-002 (whole-crate compile + wasm cabi_realloc symbol check) + TEST-CODEGEN-WIT-RECORDS + TEST-CODEGEN-WIT-BINDINGS. Remaining (REQ-CODEGEN-WIT-RECORDS-002): define Rust *Impl record structs + module-wire the per-thread files so the WHOLE crate compiles (Guest delegates to per-thread components instead of todo!()), and the wasm zero-cabi_realloc symbol check (structurally wasm-toolchain-gated). Co-Authored-By: Claude Opus 4.8 (1M context) --- artifacts/requirements.yaml | 68 ++++ artifacts/verification.yaml | 54 ++++ crates/spar-codegen/src/lib.rs | 66 ++++ crates/spar-codegen/src/rust_gen.rs | 143 +++++++-- crates/spar-codegen/src/test_gen.rs | 131 ++++++-- crates/spar-codegen/src/wit_gen.rs | 18 +- crates/spar-codegen/src/workspace_gen.rs | 37 +-- crates/spar-codegen/tests/golden_test.rs | 12 +- .../spar-codegen/tests/lifecycle_bindings.rs | 300 ++++++++++++++++++ 9 files changed, 746 insertions(+), 83 deletions(-) create mode 100644 crates/spar-codegen/tests/lifecycle_bindings.rs diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index e5dfebd..186c34c 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2649,6 +2649,74 @@ artifacts: - type: traces-to target: REQ-CODEGEN-WIT + - id: REQ-CODEGEN-WIT-RECORDS-001 + type: requirement + title: "Structured WIT records + wit-bindgen-wired Rust from AADL" + description: > + Codegen shall emit no_alloc-representable structured interfaces from real + AADL components, closing the #319 gaps where a `data implementation` with + subcomponents degraded to an opaque `type x = list` (forcing + cabi_realloc) and the Rust skeleton had no wit-bindgen wiring. Delivered + and oracle-verified in this requirement: + (1) a `data implementation` with scalar subcomponents becomes a WIT + `record { field: type }`, recursively for nested data implementations, + emitted dependency-first (#319 items 1); + (2) scalar fields map to precise WIT scalars honoring signedness/float + (Data_Representation => Float -> f32/f64; Number_Representation => Signed + -> s8..s64; else u8..u64), and a field that cannot be a no_alloc scalar + (non-power-of-two size, sub-32-bit float, opaque type) is a HARD ERROR + naming the field — codegen refuses rather than emit an allocating fallback + (user-chosen policy); + (3) `wit_ident` breaks PascalCase word boundaries (EepromSnapshot -> + eeprom-snapshot, #319 item 6); + (4) each thread's WIT world exports the lifecycle its `Dispatch_Protocol` + implies — Periodic -> a single `{instance}-compute`; other protocols -> + `{instance}-initialize/compute/finalize` — replacing the old opaque single + `{thread}: func()` (#319 item 7); and + (5) a per-process binding crate root (`crates/{proc}/src/lib.rs`) wires the + world to Rust: `wit_bindgen::generate!` + `impl Guest` (methods matching the + world exports) + `export!`, and the per-thread `{Struct}Component` trait is + lifecycle-derived from the same single source (#319 item 4). All five share + `crate::Lifecycle`/`lifecycle_for`/`dispatch_protocol` so the WIT world and + the Rust Guest cannot drift — and because wit-bindgen derives the `Guest` + trait from the world, a `cargo check` of the emitted crate is a + compiler-enforced alignment oracle, not a string match. + OUT OF SCOPE (successor REQ-CODEGEN-WIT-RECORDS-002): defining the Rust + `*Impl` record structs + typed ports and module-wiring the per-thread files + so the WHOLE crate (not just the binding lib.rs) compiles, and the + wasm-component zero-`cabi_realloc` symbol check. Reported as GitHub #319. + status: implemented + tags: [codegen, wit, rust, wit-bindgen, interop] + fields: + release: v0.24.0 + links: + - type: traces-to + target: REQ-CODEGEN-WIT-TYPES + + - id: REQ-CODEGEN-WIT-RECORDS-002 + type: requirement + title: "Whole generated component crate compiles + is cabi_realloc-free" + description: > + Successor to REQ-CODEGEN-WIT-RECORDS-001: make the ENTIRE generated + component crate compile and meet the no_alloc guarantee, not just the WIT + binding lib.rs. Requires (a) emitting Rust struct definitions for the + `*Impl` record types the per-thread port structs reference (today + `feature_to_rust_type` names PascalCase structs that are never defined — + #319 item 3), (b) module-wiring the per-thread `src/{proc}/{thread}.rs` + files into the crate and having the process `Guest` impl delegate to the + per-thread `{Struct}Component`s instead of `todo!()` stubs, and (c) a + wasm-component build (`wasm32-wasip2`) with an `nm`/symbol check asserting + zero `cabi_realloc` — the A1 acceptance gate that is structurally + wasm-toolchain-gated. Until then, the -001 oracle proves only that the + binding lib.rs + WIT world compile and align. + status: proposed + tags: [codegen, wit, rust, wit-bindgen, no-alloc, wasm] + fields: + release: v0.24.0 + links: + - type: traces-to + target: REQ-CODEGEN-WIT-RECORDS-001 + - id: REQ-PROOF-SCHED-001 type: requirement title: "Lean scheduling proofs (EDF, RMBound, RTA) wired into CI" diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 9aae758..6a3852f 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -3900,6 +3900,60 @@ artifacts: - type: satisfies target: REQ-CODEGEN-WIT-TYPES + - id: TEST-CODEGEN-WIT-RECORDS + type: feature + title: AADL data implementations become no_alloc WIT records + description: > + wit_gen.rs unit oracles for REQ-CODEGEN-WIT-RECORDS-001 records path: + data_implementation_becomes_wit_record (EepromSnapshot.Impl = u32+u32+u8 + -> `record eeprom-snapshot-impl { addr: u32, value: u32, flags: u8 }`, NOT + list), non_encodable_record_field_is_a_hard_error (3-byte + opaque + fields both reported, representable field not flagged), + record_fields_honor_signedness_and_float (temp: f32, offset: s32, + count: u16), and nested_record_emits_inner_record_and_references_it + (Header.Impl emitted dependency-first, referenced by name from Packet.Impl). + Each also feeds the output through wit-parser's Resolve::push_str, so the + records are valid bindable WIT, not just string-present. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --lib -- wit_gen + status: passing + tags: [codegen, wit, rust, interop, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-001 + + - id: TEST-CODEGEN-WIT-BINDINGS + type: feature + title: WIT world <-> Rust Guest lifecycle wiring (compiler-enforced) + description: > + tests/lifecycle_bindings.rs for REQ-CODEGEN-WIT-RECORDS-001 items 4 & 7. + world_exports_dispatch_derived_lifecycle asserts the WIT world exports the + Dispatch_Protocol-derived lifecycle per subcomponent instance (Periodic + `pw` -> compute only; Sporadic `sw` -> initialize/compute/finalize), + confirming Dispatch_Protocol resolves per-instance and the old opaque + single-func export is gone. bindings_wire_generate_guest_and_export asserts + the emitted crate root has `wit_bindgen::generate!` + `impl Guest for + Component` (methods matching the world) + `export!(Component)`. + The COMPILER-ENFORCED gate emitted_crate_cargo_checks (#[ignore], run with + `-- --ignored`) writes the full generated workspace to a tempdir and runs + `cargo check`: wit-bindgen derives the Guest trait from the world, so a + name-mangling/keyword/collision mismatch in the emitter is a compile error + (verified GREEN: `Checking ctrl v0.1.0`; a deliberately renamed Guest + method fails with E0046/E0407). This closes the string-match gap that a + pure assertion oracle would leave. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --test lifecycle_bindings + - run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + status: passing + tags: [codegen, wit, rust, wit-bindgen, interop, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-001 + - id: TEST-NC-TFA type: feature title: FP-TFA / TFA++ total-flow bounds, golden + soundness diff --git a/crates/spar-codegen/src/lib.rs b/crates/spar-codegen/src/lib.rs index 312d0c9..d53b2a8 100644 --- a/crates/spar-codegen/src/lib.rs +++ b/crates/spar-codegen/src/lib.rs @@ -99,6 +99,65 @@ impl std::fmt::Display for CodegenError { impl std::error::Error for CodegenError {} +/// The lifecycle entry points a generated component exposes, derived from a +/// thread's AADL `Dispatch_Protocol`. +/// +/// Per the REQ-CODEGEN-WIT-RECORDS-001 design decision (#319): a **Periodic** +/// thread runs a single `compute` every period, so it exposes only that entry +/// point; every other protocol (Sporadic / Aperiodic / Timed / Hybrid / +/// Background) gets the full `initialize` / `compute` / `finalize` lifecycle. +/// +/// This is the single source of truth shared by `wit_gen` (which emits these as +/// `world` exports) and `rust_gen` (which emits them as the component trait and +/// the `wit-bindgen` `Guest` impl) so the two artifacts cannot drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// Periodic dispatch: a single `compute` entry point. + ComputeOnly, + /// Non-periodic dispatch: `initialize`, `compute`, `finalize`. + Full, +} + +impl Lifecycle { + /// The lifecycle entry-point method names, in call order. These are the + /// exact identifiers emitted as WIT world exports (`{thread}-{method}`) and + /// as Rust trait / `Guest` methods (`{method}`), so both sides stay aligned. + pub fn methods(self) -> &'static [&'static str] { + match self { + Lifecycle::ComputeOnly => &["compute"], + Lifecycle::Full => &["initialize", "compute", "finalize"], + } + } +} + +/// Map an AADL `Dispatch_Protocol` value to the lifecycle a generated component +/// exposes. Comparison is case-insensitive; an unset or unrecognized protocol +/// is treated as non-periodic (the conservative full lifecycle). See +/// [`Lifecycle`]. +pub fn lifecycle_for(dispatch: &str) -> Lifecycle { + if dispatch.trim().eq_ignore_ascii_case("Periodic") { + Lifecycle::ComputeOnly + } else { + Lifecycle::Full + } +} + +/// Read a thread instance's `Dispatch_Protocol`, trying the property namespaces +/// AADL models use in practice (canonical `Thread_Properties`, plus the +/// `Timing_Properties` / `Deployment_Properties` / unqualified forms real models +/// and our benches emit). Defaults to `"Periodic"` when unset — the AS5506 +/// default dispatch protocol. +pub fn dispatch_protocol(inst: &SystemInstance, idx: ComponentInstanceIdx) -> String { + let props = inst.properties_for(idx); + props + .get("Thread_Properties", "Dispatch_Protocol") + .or_else(|| props.get("Timing_Properties", "Dispatch_Protocol")) + .or_else(|| props.get("Deployment_Properties", "Dispatch_Protocol")) + .or_else(|| props.get("", "Dispatch_Protocol")) + .unwrap_or("Periodic") + .to_string() +} + /// Extract timing properties from a component instance's property map. /// /// Returns (period_ps, deadline_ps, wcet_ps) in picoseconds, or None for each @@ -384,6 +443,13 @@ pub fn generate( for &(idx, _comp) in &threads { files.push(rust_gen::generate_rust_component(inst, idx)); } + // Per-process WIT-binding crate root (`crates/{proc}/src/lib.rs`): the + // `wit_bindgen::generate!` + `impl Guest` wiring that connects the WIT + // world to Rust (REQ-CODEGEN-WIT-RECORDS-001, #319 items 4 & 7). This + // replaces the placeholder lib.rs that workspace_gen used to emit. + for &(idx, _comp) in &processes { + files.push(rust_gen::generate_process_bindings(inst, idx)); + } } // Generate config files. These are deployment TOML for the Rust diff --git a/crates/spar-codegen/src/rust_gen.rs b/crates/spar-codegen/src/rust_gen.rs index 42c2735..31bd5fc 100644 --- a/crates/spar-codegen/src/rust_gen.rs +++ b/crates/spar-codegen/src/rust_gen.rs @@ -80,23 +80,26 @@ pub fn generate_rust_component( code.push_str("}\n\n"); - // Component trait + // Component trait. The lifecycle methods are derived from the thread's + // `Dispatch_Protocol` (REQ-CODEGEN-WIT-RECORDS-001, #319 item 7): a Periodic + // thread exposes only `compute`; every other protocol gets the full + // `initialize` / `compute` / `finalize`. This matches the WIT world exports + // and the generated `Guest` impl — all three derive from `crate::Lifecycle`. + let methods = crate::lifecycle_for(dispatch).methods(); + code.push_str(&format!( "/// Dispatch trait for the {name} thread ({dispatch}).\n" )); code.push_str(&format!("pub trait {struct_name}Component {{\n")); - code.push_str(" /// Called once at initialization.\n"); - code.push_str(&format!( - " fn initialize(&mut self, ports: &mut {struct_name}Ports);\n\n" - )); - code.push_str(&format!(" /// Called on each dispatch ({dispatch}).\n")); - code.push_str(&format!( - " fn compute(&mut self, ports: &mut {struct_name}Ports);\n\n" - )); - code.push_str(" /// Called on finalization.\n"); - code.push_str(&format!( - " fn finalize(&mut self, ports: &mut {struct_name}Ports);\n" - )); + for (i, method) in methods.iter().enumerate() { + code.push_str(&format!(" /// {}\n", lifecycle_doc(method, dispatch))); + code.push_str(&format!( + " fn {method}(&mut self, ports: &mut {struct_name}Ports);\n" + )); + if i + 1 < methods.len() { + code.push('\n'); + } + } code.push_str("}\n\n"); // Skeleton implementation @@ -105,21 +108,16 @@ pub fn generate_rust_component( code.push_str(&format!( "impl {struct_name}Component for {struct_name}Default {{\n" )); - code.push_str(&format!( - " fn initialize(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: initialization logic\n"); - code.push_str(" }\n\n"); - code.push_str(&format!( - " fn compute(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: periodic compute logic\n"); - code.push_str(" }\n\n"); - code.push_str(&format!( - " fn finalize(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: finalization logic\n"); - code.push_str(" }\n"); + for (i, method) in methods.iter().enumerate() { + code.push_str(&format!( + " fn {method}(&mut self, _ports: &mut {struct_name}Ports) {{\n" + )); + code.push_str(&format!(" // TODO: {method} logic\n")); + code.push_str(" }\n"); + if i + 1 < methods.len() { + code.push('\n'); + } + } code.push_str("}\n"); // Determine process parent name for path @@ -134,6 +132,95 @@ pub fn generate_rust_component( } } +/// Doc-comment text for a lifecycle trait method. +fn lifecycle_doc(method: &str, dispatch: &str) -> String { + match method { + "initialize" => "Called once at initialization.".to_string(), + "compute" => format!("Called on each dispatch ({dispatch})."), + "finalize" => "Called on finalization.".to_string(), + other => format!("Lifecycle entry point: {other}."), + } +} + +/// Generate the per-process WIT-binding crate root (`crates/{proc}/src/lib.rs`) +/// that wires the AADL process's WIT `world` to Rust via `wit-bindgen` +/// (REQ-CODEGEN-WIT-RECORDS-001, #319 items 4 & 7). +/// +/// Emits the `wit_bindgen::generate!` for the process world, a `Component` +/// struct, and an `impl Guest` whose methods are the lifecycle entry points every +/// child thread's `Dispatch_Protocol` implies — the SAME set [`crate::wit_gen`] +/// exports into the world. Because the `Guest` trait `generate!` produces is +/// derived from that world, the Rust compiler (when the crate is built) *enforces* +/// world⟷Guest alignment: a missing or misnamed method is a compile error, not a +/// silent drift (the item-7 failure mode). +/// +/// Method bodies are `todo!()` stubs. This unit proves the *interface* wiring +/// compiles and matches the model; the behavior is supplied by the per-thread +/// `{Struct}Component` implementations ([`generate_rust_component`]). +pub fn generate_process_bindings( + inst: &SystemInstance, + proc_idx: ComponentInstanceIdx, +) -> GeneratedFile { + let comp = inst.component(proc_idx); + // Crate directory matches workspace_gen's `crates/{sanitize_ident(name)}`. + let crate_dir = sanitize_ident(comp.name.as_str()); + // World name + wit file name match wit_gen exactly (kebab-case). + let world_name = crate::wit_gen::wit_ident(comp.name.as_str()); + + let child_threads: Vec<_> = comp + .children + .iter() + .filter(|&&child_idx| { + inst.component(child_idx).category == spar_hir_def::item_tree::ComponentCategory::Thread + }) + .collect(); + + let mut code = String::new(); + code.push_str(&format!( + "//! Generated WIT bindings for AADL process: {}::{}\n", + comp.package, comp.name + )); + code.push_str("//! DO NOT EDIT — regenerate with `spar codegen`.\n\n"); + + // `generate!` loads the single world file emitted by wit_gen. The path is + // relative to this crate root (`crates/{proc}/`) reaching the top-level + // `wit/` directory. An unqualified world name resolves because the file + // declares exactly one package. + code.push_str("wit_bindgen::generate!({\n"); + code.push_str(&format!(" world: \"{world_name}-world\",\n")); + code.push_str(&format!(" path: \"../../wit/{world_name}.wit\",\n")); + code.push_str("});\n\n"); + + code.push_str("/// Component entry point, exported to the WIT world.\n"); + code.push_str("struct Component;\n\n"); + code.push_str("impl Guest for Component {\n"); + for &&child_idx in &child_threads { + let child = inst.component(child_idx); + let thread_kebab = crate::wit_gen::wit_ident(child.name.as_str()); + let dispatch = crate::dispatch_protocol(inst, child_idx); + for method in crate::lifecycle_for(&dispatch).methods() { + // wit-bindgen mangles the kebab WIT export `{thread}-{method}` to the + // snake Rust method `{thread}_{method}`. Deriving both names from the + // same `wit_ident` keeps them consistent; the compiler is the final + // arbiter (see doc comment). + let rust_method = format!("{thread_kebab}-{method}").replace('-', "_"); + code.push_str(&format!(" fn {rust_method}() {{\n")); + code.push_str(&format!( + " todo!(\"{}::{method}\")\n", + child.name.as_str() + )); + code.push_str(" }\n"); + } + } + code.push_str("}\n\n"); + code.push_str("export!(Component);\n"); + + GeneratedFile { + path: format!("crates/{crate_dir}/src/lib.rs"), + content: code, + } +} + /// Convert a feature kind + optional classifier to a Rust type. fn feature_to_rust_type( kind: FeatureKind, diff --git a/crates/spar-codegen/src/test_gen.rs b/crates/spar-codegen/src/test_gen.rs index 82d0ac9..24de9c1 100644 --- a/crates/spar-codegen/src/test_gen.rs +++ b/crates/spar-codegen/src/test_gen.rs @@ -29,37 +29,54 @@ pub fn generate_test_harness( code.push_str(&format!("use super::{name}::*;\n\n")); - // Test: component initializes without panic - code.push_str("#[test]\n"); - code.push_str(&format!("fn {name}_initializes() {{\n")); - code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); - code.push_str(&format!( - " let mut ports = {struct_name}Ports::default();\n" - )); - code.push_str(" comp.initialize(&mut ports);\n"); - code.push_str("}\n\n"); + // The harness may only call lifecycle methods the thread's `Dispatch_Protocol` + // actually exposes on the trait — a Periodic thread has `compute` only, so + // emitting `initialize`/`finalize` calls would reference methods that + // rust_gen no longer generates (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). + let dispatch = crate::dispatch_protocol(inst, thread_idx); + let methods = crate::lifecycle_for(&dispatch).methods(); + let has_initialize = methods.contains(&"initialize"); + let has_finalize = methods.contains(&"finalize"); - // Test: compute dispatch executes + // Test: component initializes without panic (only if it has an initialize). + if has_initialize { + code.push_str("#[test]\n"); + code.push_str(&format!("fn {name}_initializes() {{\n")); + code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); + code.push_str(&format!( + " let mut ports = {struct_name}Ports::default();\n" + )); + code.push_str(" comp.initialize(&mut ports);\n"); + code.push_str("}\n\n"); + } + + // Test: compute dispatch executes (all threads have `compute`). code.push_str("#[test]\n"); code.push_str(&format!("fn {name}_compute_dispatches() {{\n")); code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); code.push_str(&format!( " let mut ports = {struct_name}Ports::default();\n" )); - code.push_str(" comp.initialize(&mut ports);\n"); + if has_initialize { + code.push_str(" comp.initialize(&mut ports);\n"); + } code.push_str(" comp.compute(&mut ports);\n"); code.push_str("}\n\n"); - // Test: finalize executes - code.push_str("#[test]\n"); - code.push_str(&format!("fn {name}_finalizes() {{\n")); - code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); - code.push_str(&format!( - " let mut ports = {struct_name}Ports::default();\n" - )); - code.push_str(" comp.initialize(&mut ports);\n"); - code.push_str(" comp.finalize(&mut ports);\n"); - code.push_str("}\n\n"); + // Test: finalize executes (only if it has a finalize). + if has_finalize { + code.push_str("#[test]\n"); + code.push_str(&format!("fn {name}_finalizes() {{\n")); + code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); + code.push_str(&format!( + " let mut ports = {struct_name}Ports::default();\n" + )); + if has_initialize { + code.push_str(" comp.initialize(&mut ports);\n"); + } + code.push_str(" comp.finalize(&mut ports);\n"); + code.push_str("}\n\n"); + } // Test: timing constants are consistent if period.is_some() || deadline.is_some() || wcet.is_some() { @@ -120,6 +137,8 @@ mod tests { package TestPkg public thread CtrlThread + properties + Thread_Properties::Dispatch_Protocol => Sporadic; end CtrlThread; thread implementation CtrlThread.Impl @@ -164,11 +183,81 @@ end TestPkg; .map(|(idx, _)| idx); if let Some(idx) = thread_idx { + // CtrlThread is Sporadic → full initialize/compute/finalize lifecycle. let file = generate_test_harness(&inst, idx); assert!(file.path.contains("_test.rs")); assert!(file.content.contains("#[test]")); assert!(file.content.contains("_initializes")); assert!(file.content.contains("_compute_dispatches")); + assert!(file.content.contains("_finalizes")); } } + + /// A Periodic thread exposes only `compute`, so its harness must NOT call + /// (or define tests for) initialize/finalize — those methods no longer exist + /// on the trait (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). Guards against the + /// test_gen/rust_gen desync the lifecycle trimming could introduce. + #[test] + fn periodic_harness_omits_initialize_and_finalize() { + let aadl = r#" +package PerPkg +public + thread PWorker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end PWorker; + + thread implementation PWorker.Impl + end PWorker.Impl; + + process Proc + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread PWorker.Impl; + end Proc.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + p: process Proc.Impl; + end Top.Impl; +end PerPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "per.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("PerPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Thread) + .map(|(idx, _)| idx) + .expect("model has a thread"); + let file = generate_test_harness(&inst, idx); + + assert!( + file.content.contains("_compute_dispatches"), + "periodic harness must still test compute:\n{}", + file.content + ); + assert!( + !file.content.contains("comp.initialize"), + "periodic harness must not call initialize:\n{}", + file.content + ); + assert!( + !file.content.contains("comp.finalize"), + "periodic harness must not call finalize:\n{}", + file.content + ); + } } diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index d6797c5..c20d2d2 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -246,14 +246,23 @@ pub fn generate_wit( wit.push_str("}\n\n"); - // Generate world + // Generate world. Each child thread exports the lifecycle entry points its + // `Dispatch_Protocol` implies (REQ-CODEGEN-WIT-RECORDS-001, #319 item 7): a + // Periodic thread exports a single `{thread}-compute`; every other protocol + // exports `{thread}-initialize` / `{thread}-compute` / `{thread}-finalize`. + // This replaces the old opaque single `{thread}: func()` export so the world + // and the generated Rust `Guest` impl expose the *same* lifecycle — the + // shared `crate::Lifecycle` helper is the single source both derive from. wit.push_str(&format!("world {name}-world {{\n")); wit.push_str(&format!(" import {name}-ports;\n")); for &&child_idx in &child_threads { let child = inst.component(child_idx); let child_name = wit_ident(child.name.as_str()); - wit.push_str(&format!(" export {child_name}: func();\n")); + let dispatch = crate::dispatch_protocol(inst, child_idx); + for method in crate::lifecycle_for(&dispatch).methods() { + wit.push_str(&format!(" export {child_name}-{method}: func();\n")); + } } wit.push_str("}\n"); @@ -314,7 +323,10 @@ const WIT_KEYWORDS: &[&str] = &[ /// [`crate::sanitize_ident`]. Underscores and other separators become hyphens, /// words that would start with a digit are letter-prefixed, and collisions with /// WIT keywords are `%`-escaped. See issue #254. -fn wit_ident(name: &str) -> String { +/// +/// `pub(crate)` so `rust_gen` can derive world-export / `Guest`-method names that +/// exactly match the WIT this module emits (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). +pub(crate) fn wit_ident(name: &str) -> String { // Split into lowercase alphanumeric words on any run of separators // (`_`, `.`, `-`, whitespace, etc.). let mut words: Vec = Vec::new(); diff --git a/crates/spar-codegen/src/workspace_gen.rs b/crates/spar-codegen/src/workspace_gen.rs index 801dd82..04cb422 100644 --- a/crates/spar-codegen/src/workspace_gen.rs +++ b/crates/spar-codegen/src/workspace_gen.rs @@ -41,11 +41,12 @@ pub fn generate_workspace(root_name: &str, process_names: &[String]) -> Vec GeneratedFile { } } -fn generate_crate_lib_rs(name: &str) -> GeneratedFile { - let safe = sanitize_path_component(name); - let mut code = String::new(); - - code.push_str(&format!( - "//! Generated component crate for AADL process: {safe}\n" - )); - code.push_str("//! DO NOT EDIT -- regenerate with `spar codegen`.\n\n"); - code.push_str("// Component modules will be generated here by spar codegen.\n"); - code.push_str("// Each thread becomes a submodule with port types and dispatch trait.\n"); - - GeneratedFile { - path: format!("crates/{safe}/src/lib.rs"), - content: code, - } -} - #[cfg(test)] mod tests { use super::*; @@ -340,16 +324,15 @@ mod tests { } #[test] - fn crate_lib_rs_generated() { + fn crate_lib_rs_not_emitted_by_workspace_gen() { + // The crate root is now the wit-bindgen binding crate emitted from + // generate() (rust_gen::generate_process_bindings), NOT workspace_gen. let files = generate_workspace("test_system", &["controller".to_string()]); - - let lib_rs = files - .iter() - .find(|f| f.path == "crates/controller/src/lib.rs") - .unwrap(); assert!( - lib_rs.content.contains("Generated component crate"), - "Must have generated header" + !files + .iter() + .any(|f| f.path == "crates/controller/src/lib.rs"), + "workspace_gen must not emit the crate lib.rs (bindings own it)" ); } diff --git a/crates/spar-codegen/tests/golden_test.rs b/crates/spar-codegen/tests/golden_test.rs index 1206454..1e7e37e 100644 --- a/crates/spar-codegen/tests/golden_test.rs +++ b/crates/spar-codegen/tests/golden_test.rs @@ -250,14 +250,18 @@ fn golden_model_generates_tests() { test.content.contains("#[test]"), "Test harness should contain #[test] attributes" ); - assert!( - test.content.contains("_initializes"), - "Test harness should have initialization test" - ); + // The golden BuildingControl threads are Periodic, so the harness exposes the + // compute lifecycle only — no initialize/finalize tests (they'd call trait + // methods rust_gen no longer generates for a Periodic thread). + // REQ-CODEGEN-WIT-RECORDS-001 (#319 item 7). assert!( test.content.contains("_compute_dispatches"), "Test harness should have dispatch test" ); + assert!( + !test.content.contains("_initializes"), + "Periodic thread harness must not have an initialization test" + ); } // ── Proof generation ─────────────────────────────────────────────── diff --git a/crates/spar-codegen/tests/lifecycle_bindings.rs b/crates/spar-codegen/tests/lifecycle_bindings.rs new file mode 100644 index 0000000..0766a00 --- /dev/null +++ b/crates/spar-codegen/tests/lifecycle_bindings.rs @@ -0,0 +1,300 @@ +//! Integration oracle for REQ-CODEGEN-WIT-RECORDS-001 (#319 items 4 & 7): +//! `Dispatch_Protocol`-derived lifecycle + WIT world ⟷ Rust `Guest` alignment. +//! +//! Two levels of oracle: +//! 1. **String alignment** (fast, always runs): the WIT world exports the exact +//! lifecycle a thread's `Dispatch_Protocol` implies, and the generated +//! bindings `impl Guest` names the matching methods. +//! 2. **Compiler-enforced** (`#[ignore]`, opt-in): emit the whole crate and run +//! `cargo check`. wit-bindgen's `generate!` derives the `Guest` trait from +//! the world, so a mangling / keyword / collision bug in the emitter is a +//! compile error — the string test cannot catch a mismatch it replicates. +//! Ignored because it shells out to `cargo` (network for the first build, +//! disk for the target dir). Run with: +//! `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` + +use spar_codegen::rust_gen::generate_process_bindings; +use spar_codegen::wit_gen::generate_wit; +use spar_codegen::{CodegenConfig, OutputFormat, generate}; +use spar_hir_def::instance::SystemInstance; +use spar_hir_def::item_tree::ComponentCategory; +use spar_hir_def::name::Name; +use spar_hir_def::resolver::GlobalScope; + +/// A process `Ctrl` with a Periodic thread and a Sporadic thread, so the two +/// lifecycle shapes are exercised in one model. +const LIFECYCLE_AADL: &str = r#" +package LifePkg +public + thread PeriodicWorker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end PeriodicWorker; + + thread implementation PeriodicWorker.Impl + end PeriodicWorker.Impl; + + thread SporadicWorker + properties + Thread_Properties::Dispatch_Protocol => Sporadic; + end SporadicWorker; + + thread implementation SporadicWorker.Impl + end SporadicWorker.Impl; + + process Ctrl + end Ctrl; + + process implementation Ctrl.Impl + subcomponents + pw: thread PeriodicWorker.Impl; + sw: thread SporadicWorker.Impl; + end Ctrl.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + ctrl: process Ctrl.Impl; + end Top.Impl; +end LifePkg; +"#; + +fn build(aadl: &str, pkg: &str, typ: &str, imp: &str) -> (GlobalScope, SystemInstance) { + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "life.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = + SystemInstance::instantiate(&scope, &Name::new(pkg), &Name::new(typ), &Name::new(imp)); + (scope, inst) +} + +fn process_idx(inst: &SystemInstance) -> spar_hir_def::instance::ComponentInstanceIdx { + inst.all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("model has a process") +} + +/// Item 7: the WIT world exports the lifecycle each thread's `Dispatch_Protocol` +/// implies — Periodic → `compute` only; Sporadic → initialize/compute/finalize — +/// NOT the old opaque single `{thread}: func()`. +#[test] +fn world_exports_dispatch_derived_lifecycle() { + let (scope, inst) = build(LIFECYCLE_AADL, "LifePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body = &wit.content; + + // Exports are keyed by the *subcomponent instance* name (`pw`, `sw`) — unique + // within the process — not the thread type name. + // + // Periodic instance `pw`: compute only. + assert!( + body.contains("export pw-compute: func();"), + "periodic instance must export compute:\n{body}" + ); + assert!( + !body.contains("export pw-initialize"), + "periodic instance must NOT export initialize:\n{body}" + ); + assert!( + !body.contains("export pw-finalize"), + "periodic instance must NOT export finalize:\n{body}" + ); + + // Sporadic instance `sw`: full lifecycle. If this fails with only `-compute`, + // the Dispatch_Protocol did not resolve and defaulted to Periodic — a real + // bug, not a fixture typo. + for m in ["initialize", "compute", "finalize"] { + assert!( + body.contains(&format!("export sw-{m}: func();")), + "sporadic instance must export {m} (Dispatch_Protocol must resolve):\n{body}" + ); + } + + // The old opaque single-func export must be gone. + assert!( + !body.contains("export pw: func();"), + "opaque per-thread export must be replaced by lifecycle exports:\n{body}" + ); +} + +/// Items 4 & 7: the generated bindings crate root wires the world to Rust — +/// `generate!` + `impl Guest` (matching the world's lifecycle exports) + `export!`. +#[test] +fn bindings_wire_generate_guest_and_export() { + let (_scope, inst) = build(LIFECYCLE_AADL, "LifePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let file = generate_process_bindings(&inst, idx); + let src = &file.content; + + assert_eq!( + file.path, "crates/ctrl/src/lib.rs", + "bindings land at the process crate root" + ); + assert!( + src.contains("wit_bindgen::generate!({"), + "must invoke wit_bindgen::generate!:\n{src}" + ); + assert!( + src.contains("world: \"ctrl-world\","), + "generate! must target the process world:\n{src}" + ); + assert!( + src.contains("impl Guest for Component {"), + "must impl the world's Guest trait:\n{src}" + ); + assert!( + src.contains("export!(Component);"), + "must export the component:\n{src}" + ); + + // Guest methods match the world exports (instance name; kebab `-` → snake `_`). + assert!( + src.contains("fn pw_compute()"), + "periodic instance → single compute method:\n{src}" + ); + for m in ["initialize", "compute", "finalize"] { + assert!( + src.contains(&format!("fn sw_{m}()")), + "sporadic instance → {m} method:\n{src}" + ); + } + assert!( + !src.contains("fn pw_initialize()"), + "periodic instance must not get initialize:\n{src}" + ); +} + +/// A RECORD-bearing, MULTI-PROCESS, mixed-dispatch model: `ProcA` has a +/// record-typed port (`Snapshot.Impl` = u32 + u8) and a Periodic thread; `ProcB` +/// has a scalar port and a Sporadic thread. This is what the compile oracle must +/// exercise — the requirement's subject is record interfaces, and two processes +/// surface wit-package / `../../wit/*.wit` path collisions a single-process +/// fixture never would. +const RECORDS_MP_AADL: &str = r#" +package MpkgRec +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Byte8 + properties + Data_Size => 1 Bytes; + end Byte8; + + data Snapshot + end Snapshot; + + data implementation Snapshot.Impl + subcomponents + addr: data Word32; + flags: data Byte8; + end Snapshot.Impl; + + thread WorkerA + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end WorkerA; + + thread implementation WorkerA.Impl + end WorkerA.Impl; + + thread WorkerB + properties + Thread_Properties::Dispatch_Protocol => Sporadic; + end WorkerB; + + thread implementation WorkerB.Impl + end WorkerB.Impl; + + process ProcA + features + snap_in: in data port Snapshot.Impl; + end ProcA; + + process implementation ProcA.Impl + subcomponents + wa: thread WorkerA.Impl; + end ProcA.Impl; + + process ProcB + features + count_in: in data port Word32; + end ProcB; + + process implementation ProcB.Impl + subcomponents + wb: thread WorkerB.Impl; + end ProcB.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + pa: process ProcA.Impl; + pb: process ProcB.Impl; + end Top.Impl; +end MpkgRec; +"#; + +/// COMPILER-ENFORCED oracle (opt-in): emit the full crate and `cargo check` it. +/// This is the real item-4/7 gate — it runs wit-bindgen's `generate!`, so the +/// `impl Guest` is type-checked against the world. A name-mangling or keyword +/// bug in the emitter fails here even when the string test above is green. +/// +/// Uses the RECORD-bearing, MULTI-PROCESS fixture so the check covers a bindings +/// crate whose world imports a WIT `record` interface, and two member crates with +/// distinct wit packages — not just the portless single-process alignment case. +/// +/// `#[ignore]` because it shells out to cargo. Run: +/// `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` +#[test] +#[ignore = "shells out to cargo check (network/disk); run with --ignored"] +fn emitted_crate_cargo_checks() { + use std::process::Command; + + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + let config = CodegenConfig { + root_name: "life".into(), + output_dir: "out".into(), + format: OutputFormat::Both, + verify: None, + rivet: false, + dry_run: true, + }; + let out = generate(&inst, &scope, &config).expect("codegen should succeed"); + + // Materialize the emitted workspace into a temp dir. + let root = std::env::temp_dir().join("spar-p3-bindings-oracle"); + let _ = std::fs::remove_dir_all(&root); + for f in &out.files { + let path = root.join(&f.path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &f.content).unwrap(); + } + + // A dedicated target dir on the internal disk avoids the global 512GB + // redirect (which may be full) and file-lock contention. + let target_dir = std::env::temp_dir().join("spar-p3-bindings-target"); + let status = Command::new("cargo") + .arg("check") + .current_dir(&root) + .env("CARGO_INCREMENTAL", "0") + .env("CARGO_TARGET_DIR", &target_dir) + .status() + .expect("failed to spawn cargo check"); + + assert!( + status.success(), + "generated crate failed to `cargo check` (world⟷Guest wiring is broken); \ + inspect {}", + root.display() + ); +} From 2bce0767460deb37c4eb0c0167492f1547640ed4 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 19:03:52 +0200 Subject: [PATCH 07/13] feat(codegen): whole generated component crate compiles + no-alloc WIT oracle (REQ-CODEGEN-WIT-RECORDS-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #319 item 3. The generated per-process crate now compiles as a whole — not just the binding lib.rs — and the no_alloc guarantee has a sound oracle. - rust_gen: resolve each classifier-typed port to a real Rust type via the SAME `DataShape` wit_gen uses — `rust_scalar`/`data_shape_to_rust_type` (scalar -> u8..u64/i8..i64/f32/f64; record -> a `crate::types` struct; opaque -> Vec). New `generate_types_module` emits `crates/{proc}/src/types.rs` with the record structs (nested-first, deduped, derived from the same shape as the WIT records so they can't drift). Per-thread files are RELOCATED under the crate (`crates/{proc}/src/{thread}.rs`) and `mod`-wired from the binding lib.rs, whose `Guest` now DELEGATES each export to the per-thread `{Struct}Component` (construct-on-call) instead of `todo!()`. A crate-level `#![allow(unsafe_op_in_unsafe_fn)]` keeps the edition-2024 wit-bindgen glue warning-clean. - build_gen: the contract build.rs now reads the relocated `crates/{proc}/src/{thread}.rs` sources. - NO-ALLOC ORACLE is at the WIT level, not the wasm symbol level. A probe showed `cabi_realloc` is UNCONDITIONAL wit-bindgen-rt glue — exported and called in both a scalar-record and a `list` component — so a symbol check cannot discriminate. `list`/`string` are the only unbounded canonical-ABI types, so the oracle asserts the WIT of an all-scalar/record model has none, with an opaque-port model as the negative control (it deliberately keeps the P2a `list` fallback and MUST contain it). - Oracles (tests/lifecycle_bindings.rs): no_alloc WIT check + negative control (fast); emitted_crate_cargo_checks now compiles the WHOLE crate; new emitted_crate_builds_to_wasm32_wasip2 (#[ignore], stronger evidence) — all GREEN. - golden_test: updated for the new per-thread file location. SCOPE: the no-alloc guarantee holds only for models whose ports are all scalar/record; an opaque top-level port is out of it by design. Deferred (data plane): populating ports from the imported WIT functions + persistent component state (Guest methods are free fns). Co-Authored-By: Claude Opus 4.8 (1M context) --- artifacts/requirements.yaml | 45 +++- artifacts/verification.yaml | 30 +++ crates/spar-codegen/src/build_gen.rs | 12 +- crates/spar-codegen/src/lib.rs | 14 +- crates/spar-codegen/src/rust_gen.rs | 246 +++++++++++++++++- crates/spar-codegen/tests/golden_test.rs | 29 ++- .../spar-codegen/tests/lifecycle_bindings.rs | 132 ++++++++++ 7 files changed, 469 insertions(+), 39 deletions(-) diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 186c34c..865acc0 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2695,22 +2695,39 @@ artifacts: - id: REQ-CODEGEN-WIT-RECORDS-002 type: requirement - title: "Whole generated component crate compiles + is cabi_realloc-free" + title: "Whole generated component crate compiles + no unbounded ABI types" description: > Successor to REQ-CODEGEN-WIT-RECORDS-001: make the ENTIRE generated - component crate compile and meet the no_alloc guarantee, not just the WIT - binding lib.rs. Requires (a) emitting Rust struct definitions for the - `*Impl` record types the per-thread port structs reference (today - `feature_to_rust_type` names PascalCase structs that are never defined — - #319 item 3), (b) module-wiring the per-thread `src/{proc}/{thread}.rs` - files into the crate and having the process `Guest` impl delegate to the - per-thread `{Struct}Component`s instead of `todo!()` stubs, and (c) a - wasm-component build (`wasm32-wasip2`) with an `nm`/symbol check asserting - zero `cabi_realloc` — the A1 acceptance gate that is structurally - wasm-toolchain-gated. Until then, the -001 oracle proves only that the - binding lib.rs + WIT world compile and align. - status: proposed - tags: [codegen, wit, rust, wit-bindgen, no-alloc, wasm] + component crate compile, not just the WIT binding lib.rs, and give the + no_alloc guarantee a sound oracle. Three parts: + (a) resolve each classifier-typed port to a real Rust type via the SAME + `DataShape` `wit_gen` uses (scalar -> u8..u64/i8..i64/f32/f64; record -> + a generated `#[derive(Default)]` struct in a shared per-crate `types` + module, nested-first and de-duplicated; opaque -> `Vec`), so the Rust + struct and the WIT record cannot drift — today `feature_to_rust_type` names + PascalCase structs that are never defined (#319 item 3); + (b) relocate the per-thread files UNDER the crate (`crates/{proc}/src/`) and + module-wire them from the binding lib.rs, with the process `Guest` impl + delegating each export to the per-thread `{Struct}Component` (construct-on- + call) instead of `todo!()`; + (c) NO-ALLOC ORACLE at the WIT level, not the wasm symbol level. A probe + (2026-07-08) showed `cabi_realloc` is UNCONDITIONAL wit-bindgen-rt glue — + present and called in both a scalar-record component and a `list` one — + so an `nm`/symbol "zero cabi_realloc" check cannot discriminate and is + unsound. The sound, by-construction signal is that `list`/`string` are the + only unbounded canonical-ABI types (the ones forcing dynamic `cabi_realloc`); + everything bounded uses a static return area. So the oracle asserts the + generated WIT for an all-scalar/record model contains no `list<`/`string`, + with an opaque-port model as the negative control (it deliberately keeps the + P2a `list` fallback and MUST contain it — proving the check can fire). + SCOPE: the no-alloc guarantee holds only for models whose ports are all + scalar/record; an opaque top-level port is out of the guarantee by design. + Deferred to a further requirement (data plane): populating ports from the + imported WIT functions and persistent component state (Guest methods are + free fns). Optional stronger evidence, not a completion gate: the crate also + builds to `wasm32-wasip2` (toolchain present locally). + status: implemented + tags: [codegen, wit, rust, wit-bindgen, no-alloc] fields: release: v0.24.0 links: diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 6a3852f..9c0bd2c 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -3954,6 +3954,36 @@ artifacts: - type: satisfies target: REQ-CODEGEN-WIT-RECORDS-001 + - id: TEST-CODEGEN-WIT-WHOLECRATE + type: feature + title: Whole generated crate compiles + no unbounded ABI types + description: > + tests/lifecycle_bindings.rs for REQ-CODEGEN-WIT-RECORDS-002. The NO-ALLOC + oracle is at the WIT level (a probe proved the wasm `cabi_realloc` symbol is + unconditional wit-bindgen-rt glue and cannot discriminate): + no_alloc_all_scalar_record_model_has_no_unbounded_wit_types asserts a + record + multi-process model emits no `list<`/`string`, and the negative + control no_alloc_opaque_port_model_does_contain_list_fallback asserts an + opaque top-level port DOES emit `list` (so the check can fire; opaque + ports are out of the no-alloc guarantee by design). The compiler-enforced + gate emitted_crate_cargo_checks (#[ignore]) now `cargo check`s the WHOLE + crate — the relocated per-thread modules (crates/{proc}/src/{thread}.rs), + the crate-local `types` record structs (derived from the same DataShape as + the WIT records), and the process `Guest` delegating to each per-thread + component — verified GREEN. emitted_crate_builds_to_wasm32_wasip2 (#[ignore], + stronger evidence, not a completion gate) builds the record + multi-process + crate to wasm32-wasip2 warning-clean. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --test lifecycle_bindings + - run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + status: passing + tags: [codegen, wit, rust, wit-bindgen, no-alloc, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-002 + - id: TEST-NC-TFA type: feature title: FP-TFA / TFA++ total-flow bounds, golden + soundness diff --git a/crates/spar-codegen/src/build_gen.rs b/crates/spar-codegen/src/build_gen.rs index 1dde7bb..02c72ec 100644 --- a/crates/spar-codegen/src/build_gen.rs +++ b/crates/spar-codegen/src/build_gen.rs @@ -73,7 +73,7 @@ pub fn generate_build_verifier() -> GeneratedFile { // constants. Divergence => panic! => compilation failure. let script = r#"//! AADL build-time contract verification — generated by spar codegen. //! DO NOT EDIT. Fails the build when a generated timing constant in -//! src//.rs diverges from aadl-contract.toml. +//! crates//src/.rs diverges from aadl-contract.toml. use std::collections::BTreeMap; use std::fs; @@ -81,7 +81,7 @@ use std::path::Path; fn main() { println!("cargo:rerun-if-changed=aadl-contract.toml"); - println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=crates"); let manifest = match fs::read_to_string("aadl-contract.toml") { Ok(m) => m, @@ -110,7 +110,13 @@ fn main() { } for (thread, expected) in &contract { - let src = Path::new("src").join(format!("{thread}.rs")); + // Manifest keys are "/"; the source lives under that + // process crate at crates//src/.rs. + let (proc_dir, th_name) = thread.split_once('/').unwrap_or(("", thread.as_str())); + let src = Path::new("crates") + .join(proc_dir) + .join("src") + .join(format!("{th_name}.rs")); let code = match fs::read_to_string(&src) { Ok(c) => c, Err(_) => panic!( diff --git a/crates/spar-codegen/src/lib.rs b/crates/spar-codegen/src/lib.rs index d53b2a8..f939e93 100644 --- a/crates/spar-codegen/src/lib.rs +++ b/crates/spar-codegen/src/lib.rs @@ -441,13 +441,17 @@ pub fn generate( // Generate Rust files if config.format == OutputFormat::Rust || config.format == OutputFormat::Both { for &(idx, _comp) in &threads { - files.push(rust_gen::generate_rust_component(inst, idx)); + files.push(rust_gen::generate_rust_component(inst, scope, idx)); } - // Per-process WIT-binding crate root (`crates/{proc}/src/lib.rs`): the - // `wit_bindgen::generate!` + `impl Guest` wiring that connects the WIT - // world to Rust (REQ-CODEGEN-WIT-RECORDS-001, #319 items 4 & 7). This - // replaces the placeholder lib.rs that workspace_gen used to emit. + // Per process: the crate-local record `types` module (REQ-CODEGEN-WIT- + // RECORDS-002, #319 item 3) and the WIT-binding crate root + // (`crates/{proc}/src/lib.rs`) — the `wit_bindgen::generate!` + `impl + // Guest` wiring that connects the WIT world to Rust and mod-wires the + // per-thread component modules (REQ-CODEGEN-WIT-RECORDS-001/002, #319 + // items 3, 4 & 7). This replaces the placeholder lib.rs workspace_gen + // used to emit. for &(idx, _comp) in &processes { + files.push(rust_gen::generate_types_module(inst, scope, idx)); files.push(rust_gen::generate_process_bindings(inst, idx)); } } diff --git a/crates/spar-codegen/src/rust_gen.rs b/crates/spar-codegen/src/rust_gen.rs index 31bd5fc..6036757 100644 --- a/crates/spar-codegen/src/rust_gen.rs +++ b/crates/spar-codegen/src/rust_gen.rs @@ -4,18 +4,74 @@ //! the appropriate port struct, dispatch loop, and WASI bindings. use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; -use spar_hir_def::item_tree::{Direction, FeatureKind}; +use spar_hir_def::item_tree::{ComponentCategory, Direction, FeatureKind}; +use spar_hir_def::name::ClassifierRef; +use spar_hir_def::resolver::{DataField, DataShape, GlobalScope, ScalarKind}; use crate::{GeneratedFile, extract_timing, format_time_ps, sanitize_ident, to_pascal_case}; +/// The Rust scalar for a power-of-two `Data_Size` (bytes) + numeric +/// representation, mirroring `wit_gen::scalar_wit` so the Rust port type and the +/// WIT scalar stay in lock-step (REQ-CODEGEN-WIT-RECORDS-002, #319 item 3). +/// `None` for a size WIT/Rust cannot represent no_alloc (odd size, sub-32-bit +/// float) — the caller falls back to `Vec`. +fn rust_scalar(bytes: u64, kind: ScalarKind) -> Option<&'static str> { + match kind { + ScalarKind::Float => match bytes { + 4 => Some("f32"), + 8 => Some("f64"), + _ => None, + }, + ScalarKind::Signed => match bytes { + 1 => Some("i8"), + 2 => Some("i16"), + 4 => Some("i32"), + 8 => Some("i64"), + _ => None, + }, + ScalarKind::Unsigned => match bytes { + 1 => Some("u8"), + 2 => Some("u16"), + 4 => Some("u32"), + 8 => Some("u64"), + _ => None, + }, + } +} + +/// The Rust type a resolved [`DataShape`] maps to for a port field: a scalar maps +/// to a Rust primitive, a record to a struct in the crate-local `types` module +/// (generated by [`generate_types_module`]), and anything unrepresentable to +/// `Vec`. Record structs are named `to_pascal_case(type_name)` — the same +/// key `flatten_record_rust` emits — so references resolve. +fn data_shape_to_rust_type(shape: &DataShape) -> String { + match shape { + DataShape::Scalar { bytes, kind } => { + rust_scalar(*bytes, *kind).map_or_else(|| "Vec".to_string(), |s| s.to_string()) + } + DataShape::Record { type_name, .. } => { + format!("crate::types::{}", to_pascal_case(type_name)) + } + DataShape::Opaque => "Vec".to_string(), + } +} + /// Generate a Rust component skeleton for a thread instance. +/// +/// `scope` resolves each port's data classifier to a concrete Rust type (scalar, +/// a `crate::types` record struct, or `Vec`) so the port struct actually +/// compiles — REQ-CODEGEN-WIT-RECORDS-002 (#319 item 3). The file is emitted +/// UNDER the process crate (`crates/{proc}/src/{thread}.rs`) so the binding +/// lib.rs can `mod`-wire it. pub fn generate_rust_component( inst: &SystemInstance, + scope: &GlobalScope, thread_idx: ComponentInstanceIdx, ) -> GeneratedFile { let comp = inst.component(thread_idx); let name = sanitize_ident(comp.name.as_str()); let struct_name = to_pascal_case(comp.name.as_str()); + let pkg = &comp.package; let (period, deadline, wcet) = extract_timing(inst, thread_idx); @@ -62,7 +118,7 @@ pub fn generate_rust_component( for &fi in &comp.features { let feat = &inst.features[fi]; let feat_name = sanitize_ident(feat.name.as_str()); - let rust_type = feature_to_rust_type(feat.kind, &feat.classifier); + let rust_type = feature_to_rust_type(feat.kind, &feat.classifier, scope, pkg); let dir_comment = match feat.direction { Some(Direction::In) => "in", @@ -120,14 +176,14 @@ pub fn generate_rust_component( } code.push_str("}\n"); - // Determine process parent name for path + // Determine process parent name; the thread module lives under that crate. let parent_name = comp .parent .map(|p| sanitize_ident(inst.component(p).name.as_str())) .unwrap_or_else(|| "unknown".to_string()); GeneratedFile { - path: format!("src/{parent_name}/{name}.rs"), + path: format!("crates/{parent_name}/src/{name}.rs"), content: code, } } @@ -182,6 +238,20 @@ pub fn generate_process_bindings( )); code.push_str("//! DO NOT EDIT — regenerate with `spar codegen`.\n\n"); + // The `wit_bindgen::generate!` + `export!` glue calls its own unsafe `*_cabi` + // shims; on the edition-2024 generated crate that trips the + // `unsafe_op_in_unsafe_fn` future-compat lint inside macro-expanded code the + // user cannot edit. Allow it crate-wide so the generated crate is warning-clean. + code.push_str("#![allow(unsafe_op_in_unsafe_fn)]\n\n"); + + // The crate-local record types and the per-thread component modules, wired in. + code.push_str("mod types;\n"); + for &&child_idx in &child_threads { + let child_mod = sanitize_ident(inst.component(child_idx).name.as_str()); + code.push_str(&format!("mod {child_mod};\n")); + } + code.push('\n'); + // `generate!` loads the single world file emitted by wit_gen. The path is // relative to this crate root (`crates/{proc}/`) reaching the top-level // `wit/` directory. An unqualified world name resolves because the file @@ -197,18 +267,28 @@ pub fn generate_process_bindings( for &&child_idx in &child_threads { let child = inst.component(child_idx); let thread_kebab = crate::wit_gen::wit_ident(child.name.as_str()); + let child_mod = sanitize_ident(child.name.as_str()); + let child_struct = to_pascal_case(child.name.as_str()); let dispatch = crate::dispatch_protocol(inst, child_idx); for method in crate::lifecycle_for(&dispatch).methods() { // wit-bindgen mangles the kebab WIT export `{thread}-{method}` to the // snake Rust method `{thread}_{method}`. Deriving both names from the // same `wit_ident` keeps them consistent; the compiler is the final - // arbiter (see doc comment). + // arbiter (see doc comment). The body delegates to the per-thread + // component (construct-on-call); populating ports from the imported + // WIT functions + persistent state is the deferred data plane. let rust_method = format!("{thread_kebab}-{method}").replace('-', "_"); code.push_str(&format!(" fn {rust_method}() {{\n")); code.push_str(&format!( - " todo!(\"{}::{method}\")\n", - child.name.as_str() + " use crate::{child_mod}::{child_struct}Component;\n" + )); + code.push_str(&format!( + " let mut component = crate::{child_mod}::{child_struct}Default;\n" )); + code.push_str(&format!( + " let mut ports = crate::{child_mod}::{child_struct}Ports::default();\n" + )); + code.push_str(&format!(" component.{method}(&mut ports);\n")); code.push_str(" }\n"); } } @@ -221,14 +301,19 @@ pub fn generate_process_bindings( } } -/// Convert a feature kind + optional classifier to a Rust type. +/// Convert a feature kind + optional classifier to a Rust type. The classifier +/// is resolved through `scope` to its [`DataShape`] so the port type is a real, +/// defined type — a scalar, a `crate::types` record struct, or `Vec` — rather +/// than the undefined PascalCase name the old mapping produced (#319 item 3). fn feature_to_rust_type( kind: FeatureKind, - classifier: &Option, + classifier: &Option, + scope: &GlobalScope, + package: &spar_hir_def::name::Name, ) -> String { let base_type = classifier .as_ref() - .map(|c| to_pascal_case(&c.to_string())) + .map(|c| data_shape_to_rust_type(&scope.resolve_data_shape(package, c))) .unwrap_or_else(|| "Vec".to_string()); match kind { @@ -239,6 +324,94 @@ fn feature_to_rust_type( } } +/// Generate the crate-local `types` module (`crates/{proc}/src/types.rs`) holding +/// the Rust record structs the per-thread port structs reference. Each struct is +/// derived from the SAME [`DataShape`] `wit_gen` emits as a WIT record, so the +/// Rust struct and the WIT record cannot drift (REQ-CODEGEN-WIT-RECORDS-002, +/// #319 item 3). Structs are emitted nested-first and de-duplicated; the module +/// is (validly) empty when the process has no record-typed ports. +pub fn generate_types_module( + inst: &SystemInstance, + scope: &GlobalScope, + proc_idx: ComponentInstanceIdx, +) -> GeneratedFile { + let comp = inst.component(proc_idx); + let crate_dir = sanitize_ident(comp.name.as_str()); + + // Collect record shapes from every child thread's classifier-typed ports. + let mut structs: Vec<(String, Vec<(String, String)>)> = Vec::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for &child_idx in &comp.children { + let child = inst.component(child_idx); + if child.category != ComponentCategory::Thread { + continue; + } + for &fi in &child.features { + let feat = &inst.features[fi]; + if let Some(cref) = feat.classifier.as_ref() + && let DataShape::Record { type_name, fields } = + scope.resolve_data_shape(&child.package, cref) + { + flatten_record_rust(&type_name, &fields, &mut structs, &mut seen); + } + } + } + + let mut code = String::new(); + code.push_str(&format!( + "//! Generated record types for AADL process: {}::{}\n", + comp.package, comp.name + )); + code.push_str("//! DO NOT EDIT — regenerate with `spar codegen`.\n\n"); + if structs.is_empty() { + code.push_str("// (no record-typed ports in this process)\n"); + } + for (sname, fields) in &structs { + code.push_str("#[derive(Clone, Debug, Default, PartialEq)]\n"); + code.push_str(&format!("pub struct {sname} {{\n")); + for (fname, fty) in fields { + code.push_str(&format!(" pub {fname}: {fty},\n")); + } + code.push_str("}\n\n"); + } + + GeneratedFile { + path: format!("crates/{crate_dir}/src/types.rs"), + content: code, + } +} + +/// Recursively collect Rust struct definitions for a record shape, nested-first +/// and de-duplicated (the Rust analog of `wit_gen::flatten_record`). A nested +/// record field becomes `crate::types::{Name}` via [`data_shape_to_rust_type`]. +fn flatten_record_rust( + type_name: &str, + fields: &[DataField], + out: &mut Vec<(String, Vec<(String, String)>)>, + seen: &mut std::collections::BTreeSet, +) { + let sname = to_pascal_case(type_name); + if !seen.insert(sname.clone()) { + return; // already collected (shared nested type) + } + let mut rows = Vec::with_capacity(fields.len()); + for f in fields { + // Recurse into nested records first so they are defined before use. + if let DataShape::Record { + type_name: nt, + fields: nf, + } = &f.shape + { + flatten_record_rust(nt, nf, out, seen); + } + rows.push(( + sanitize_ident(f.name.as_str()), + data_shape_to_rust_type(&f.shape), + )); + } + out.push((sname, rows)); +} + #[cfg(test)] mod tests { use super::*; @@ -252,14 +425,61 @@ mod tests { #[test] fn feature_rust_type_mapping() { - assert_eq!(feature_to_rust_type(FeatureKind::EventPort, &None), "bool"); + // No classifier → scope is not consulted; an empty scope is fine. + let scope = GlobalScope::from_trees(vec![]); + let pkg = spar_hir_def::name::Name::new("Pkg"); assert_eq!( - feature_to_rust_type(FeatureKind::DataPort, &None), + feature_to_rust_type(FeatureKind::EventPort, &None, &scope, &pkg), + "bool" + ); + assert_eq!( + feature_to_rust_type(FeatureKind::DataPort, &None, &scope, &pkg), "Vec" ); assert_eq!( - feature_to_rust_type(FeatureKind::EventDataPort, &None), + feature_to_rust_type(FeatureKind::EventDataPort, &None, &scope, &pkg), "Option>" ); } + + #[test] + fn data_shape_maps_to_rust_types() { + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 4, + kind: ScalarKind::Unsigned + }), + "u32" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 4, + kind: ScalarKind::Signed + }), + "i32" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 8, + kind: ScalarKind::Float + }), + "f64" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Record { + type_name: "Packet.Impl".to_string(), + fields: vec![] + }), + "crate::types::PacketImpl" + ); + assert_eq!(data_shape_to_rust_type(&DataShape::Opaque), "Vec"); + // Non-power-of-two scalar has no no_alloc Rust primitive → Vec. + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 3, + kind: ScalarKind::Unsigned + }), + "Vec" + ); + } } diff --git a/crates/spar-codegen/tests/golden_test.rs b/crates/spar-codegen/tests/golden_test.rs index 1e7e37e..fc8aaa4 100644 --- a/crates/spar-codegen/tests/golden_test.rs +++ b/crates/spar-codegen/tests/golden_test.rs @@ -184,7 +184,13 @@ fn golden_model_generates_rust_component() { let rust_files: Vec<_> = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .collect(); assert!( !rust_files.is_empty(), @@ -449,7 +455,10 @@ fn golden_model_generates_build_contract() { for line in manifest.content.lines() { if let Some(rest) = line.trim().strip_prefix("[thread.\"") { let thread = rest.trim_end_matches("\"]"); - let src = format!("src/{thread}.rs"); + // Manifest key is "/"; the source now lives under + // that process crate (crates//src/.rs). + let (proc_dir, th_name) = thread.split_once('/').unwrap_or(("", thread)); + let src = format!("crates/{proc_dir}/src/{th_name}.rs"); assert!( output.files.iter().any(|f| f.path == src), "manifest names {thread} but {src} was not generated" @@ -473,7 +482,13 @@ fn golden_model_all_modules_produce_output() { let rust_count = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .count(); let config_count = output .files @@ -570,7 +585,13 @@ fn golden_model_timing_properties_in_rust() { let rust_files: Vec<_> = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .collect(); let has_period = rust_files.iter().any(|f| f.content.contains("PERIOD_PS")); diff --git a/crates/spar-codegen/tests/lifecycle_bindings.rs b/crates/spar-codegen/tests/lifecycle_bindings.rs index 0766a00..02f8ae3 100644 --- a/crates/spar-codegen/tests/lifecycle_bindings.rs +++ b/crates/spar-codegen/tests/lifecycle_bindings.rs @@ -244,6 +244,87 @@ public end MpkgRec; "#; +/// A model with an OPAQUE top-level port (a `data` type with no Data_Size and no +/// subcomponents). This is the negative control for the no_alloc oracle: wit_gen +/// deliberately keeps the `list` fallback here (P2a), so the generated WIT +/// MUST contain `list` — proving the no-alloc check can actually fire. +const OPAQUE_PORT_AADL: &str = r#" +package OpaquePkg +public + data Blob + end Blob; + + thread Worker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end Worker; + + thread implementation Worker.Impl + end Worker.Impl; + + process Proc + features + blob_in: in data port Blob; + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread Worker.Impl; + end Proc.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + p: process Proc.Impl; + end Top.Impl; +end OpaquePkg; +"#; + +/// NO-ALLOC ORACLE (REQ-CODEGEN-WIT-RECORDS-002 item 3c). The sound signal is at +/// the WIT level: `list`/`string` are the only unbounded canonical-ABI types (the +/// ones forcing dynamic `cabi_realloc`); everything scalar/record is bounded. A +/// probe (2026-07-08) showed the wasm `cabi_realloc` SYMBOL is unconditional +/// wit-bindgen-rt glue — present in both alloc and no-alloc components — so a +/// symbol check cannot discriminate. This checks the WIT instead. +#[test] +fn no_alloc_all_scalar_record_model_has_no_unbounded_wit_types() { + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + // Check every process's generated WIT. + let mut checked = 0; + for (idx, comp) in inst.all_components() { + if comp.category != ComponentCategory::Process { + continue; + } + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + assert!( + !wit.content.contains("list<") && !wit.content.contains("string"), + "all-scalar/record model must emit no unbounded (list/string) WIT \ + types:\n{}", + wit.content + ); + checked += 1; + } + assert_eq!(checked, 2, "fixture has two processes"); +} + +/// Negative control: the no_alloc oracle can fire. An opaque top-level port keeps +/// the `list` fallback, so its WIT MUST contain `list` — confirming the +/// assertion above is not vacuous, and marking the scope boundary (opaque ports +/// are out of the no-alloc guarantee). +#[test] +fn no_alloc_opaque_port_model_does_contain_list_fallback() { + let (scope, inst) = build(OPAQUE_PORT_AADL, "OpaquePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + assert!( + wit.content.contains("list"), + "opaque port must fall back to list (negative control):\n{}", + wit.content + ); +} + /// COMPILER-ENFORCED oracle (opt-in): emit the full crate and `cargo check` it. /// This is the real item-4/7 gate — it runs wit-bindgen's `generate!`, so the /// `impl Guest` is type-checked against the world. A name-mangling or keyword @@ -298,3 +379,54 @@ fn emitted_crate_cargo_checks() { root.display() ); } + +/// STRONGER EVIDENCE (opt-in, not a completion gate): the emitted crate builds all +/// the way to the real deployment target `wasm32-wasip2` — proving the records + +/// bindings + delegation survive componentization, not just a host type-check. +/// Per REQ-CODEGEN-WIT-RECORDS-002 this is nice-to-have; the required no-alloc +/// oracle is the WIT-level check above (a wasm `cabi_realloc` symbol is +/// unconditional wit-bindgen-rt glue and cannot discriminate). +/// +/// `#[ignore]` — needs the wasm32-wasip2 target + shells out to cargo. Run: +/// `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` +#[test] +#[ignore = "needs wasm32-wasip2 target + shells out to cargo; run with --ignored"] +fn emitted_crate_builds_to_wasm32_wasip2() { + use std::process::Command; + + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + let config = CodegenConfig { + root_name: "life".into(), + output_dir: "out".into(), + format: OutputFormat::Both, + verify: None, + rivet: false, + dry_run: true, + }; + let out = generate(&inst, &scope, &config).expect("codegen should succeed"); + + let root = std::env::temp_dir().join("spar-p3-wasm-oracle"); + let _ = std::fs::remove_dir_all(&root); + for f in &out.files { + let path = root.join(&f.path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &f.content).unwrap(); + } + + let target_dir = std::env::temp_dir().join("spar-p3-wasm-target"); + let status = Command::new("cargo") + .arg("build") + .arg("--target") + .arg("wasm32-wasip2") + .current_dir(&root) + .env("CARGO_INCREMENTAL", "0") + .env("CARGO_TARGET_DIR", &target_dir) + .status() + .expect("failed to spawn cargo build"); + + assert!( + status.success(), + "generated crate failed to build to wasm32-wasip2; inspect {}", + root.display() + ); +} From 1ad4eb1eadd8e5bcc065446e671ee65a8f89f631 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 19:10:52 +0200 Subject: [PATCH 08/13] ci(codegen): run the #[ignore] compile oracles in CI (REQ-CODEGEN-WIT-RECORDS-001/002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo nextest`/`cargo test` do not run #[ignore] tests, so the compiler-enforced codegen oracles — emitted_crate_cargo_checks (world⟷Guest alignment) and emitted_crate_builds_to_wasm32_wasip2 (whole-crate compile + no-alloc ABI) — were never gating in CI: a contributor could break generated-crate compilation or world/Guest alignment and CI would stay green. Add a dedicated `codegen-oracle` job that installs the wasm32-wasip2 target and runs `-- --ignored`, making the merge gate for these features non-empty. Note the CI coverage in the two TEST artifacts so `status: passing` no longer over-implies default-`cargo test` coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 19 +++++++++++++++++++ artifacts/verification.yaml | 9 +++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6a2a58..d15e404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,25 @@ jobs: path: target/nextest/ci/junit.xml if-no-files-found: ignore + # ── Codegen compile oracle (the #[ignore] gates) ──────────────────── + # `cargo nextest`/`cargo test` above do NOT run #[ignore] tests, so the + # compiler-enforced codegen oracles (emitted_crate_cargo_checks, + # emitted_crate_builds_to_wasm32_wasip2) would otherwise never gate in CI. + # They shell out to cargo to build a generated crate — including to + # wasm32-wasip2 — so this job installs that target and runs the ignored set. + # REQ-CODEGEN-WIT-RECORDS-001/002 (#319). + codegen-oracle: + name: Codegen compile oracle + runs-on: [self-hosted, linux, x64, rust-cpu] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + targets: wasm32-wasip2 + - uses: Swatinem/rust-cache@v2 + - name: Run generated-crate compile oracles (ignored tests) + run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + # ── Bench compile smoke (fast regression gate) ────────────────────── bench-smoke: name: Bench compile smoke diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 9c0bd2c..9cfe3e7 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -3942,7 +3942,9 @@ artifacts: name-mangling/keyword/collision mismatch in the emitter is a compile error (verified GREEN: `Checking ctrl v0.1.0`; a deliberately renamed Guest method fails with E0046/E0407). This closes the string-match gap that a - pure assertion oracle would leave. + pure assertion oracle would leave. The `#[ignore]` gate runs in CI via the + `codegen-oracle` job (ci.yml) — `cargo nextest`/`cargo test` do NOT run + ignored tests, so without that job this oracle would not gate. fields: method: automated-test steps: @@ -3972,7 +3974,10 @@ artifacts: the WIT records), and the process `Guest` delegating to each per-thread component — verified GREEN. emitted_crate_builds_to_wasm32_wasip2 (#[ignore], stronger evidence, not a completion gate) builds the record + multi-process - crate to wasm32-wasip2 warning-clean. + crate to wasm32-wasip2 warning-clean. Both `#[ignore]` oracles run in CI via + the `codegen-oracle` job (ci.yml), which installs wasm32-wasip2 and runs + `-- --ignored` — plain `cargo nextest`/`cargo test` skip ignored tests, so + that job is what makes these compile oracles actually gate. fields: method: automated-test steps: From e2b160f5f8bd21d1f347e77e930b8c9dc88350b5 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 19:11:52 +0200 Subject: [PATCH 09/13] docs(codegen): track that --verify test/build emits inert artifacts (REQ-CODEGEN-VERIFY-WORKSPACE-001) Surfaced during REQ-CODEGEN-WIT-RECORDS-002: `spar codegen --verify test/build/all` emits harnesses + a timing-contract build.rs that are structurally inert in the generated virtual workspace (test harnesses use `super::` outside any crate member; the root build.rs never runs because a `[workspace]`-only manifest has no build script). Both predate REQ-002. Filed as a proposed requirement so `--verify all` is not read as producing a runnable verification harness until the harnesses/contract are relocated into the per-process crates. Co-Authored-By: Claude Opus 4.8 (1M context) --- artifacts/requirements.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 865acc0..a73d929 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2734,6 +2734,35 @@ artifacts: - type: traces-to target: REQ-CODEGEN-WIT-RECORDS-001 + - id: REQ-CODEGEN-VERIFY-WORKSPACE-001 + type: requirement + title: "spar codegen --verify test/build emits inert artifacts" + description: > + Bug / honesty gap surfaced during REQ-CODEGEN-WIT-RECORDS-002: the + `--verify test` and `--verify build` (hence `--verify all`) outputs are + structurally inert in the generated multi-crate workspace and therefore + self-certify nothing. (1) test_gen emits `tests//_test.rs` + with `use super::::*;` at the WORKSPACE ROOT — outside every crate + member, and `super::` is meaningless for a top-level file — so the harnesses + are never compiled or run. (2) build_gen emits `build.rs` + `aadl-contract.toml` + at the workspace ROOT, but workspace_gen makes a VIRTUAL workspace (a + `[workspace]`-only root with no `[package]`), and cargo never runs a build + script for a virtual manifest — so the timing-contract verifier never fires. + Both predate REQ-002 (they assume a flat single-crate layout that + workspace_gen does not produce). Fix: relocate the test harnesses under each + process crate (`crates//tests/` or inline `#[cfg(test)]` modules using + the real crate paths, not `super::`), and move the contract build.rs + + manifest into each process crate so the build script actually runs. Until + then, `spar codegen --verify test/build/all` must not be read as producing a + runnable test/verification harness. Related to #319. + status: proposed + tags: [codegen, verify, workspace, bug] + fields: + release: v0.24.0 + links: + - type: traces-to + target: REQ-CODEGEN-WIT-RECORDS-002 + - id: REQ-PROOF-SCHED-001 type: requirement title: "Lean scheduling proofs (EDF, RMBound, RTA) wired into CI" From cf553809a324fc080ea8e32d210d5ae08d8cf53b Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 19:25:25 +0200 Subject: [PATCH 10/13] fix(codegen): normalize doc-list indent in lifecycle_bindings (clippy -D warnings) Clippy `doc list item overindented` on the module doc comment's ordered list. Align the list markers and continuations so `cargo clippy --workspace --all-targets -- -D warnings` passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spar-codegen/tests/lifecycle_bindings.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/spar-codegen/tests/lifecycle_bindings.rs b/crates/spar-codegen/tests/lifecycle_bindings.rs index 02f8ae3..ed75283 100644 --- a/crates/spar-codegen/tests/lifecycle_bindings.rs +++ b/crates/spar-codegen/tests/lifecycle_bindings.rs @@ -2,16 +2,16 @@ //! `Dispatch_Protocol`-derived lifecycle + WIT world ⟷ Rust `Guest` alignment. //! //! Two levels of oracle: -//! 1. **String alignment** (fast, always runs): the WIT world exports the exact -//! lifecycle a thread's `Dispatch_Protocol` implies, and the generated -//! bindings `impl Guest` names the matching methods. -//! 2. **Compiler-enforced** (`#[ignore]`, opt-in): emit the whole crate and run -//! `cargo check`. wit-bindgen's `generate!` derives the `Guest` trait from -//! the world, so a mangling / keyword / collision bug in the emitter is a -//! compile error — the string test cannot catch a mismatch it replicates. -//! Ignored because it shells out to `cargo` (network for the first build, -//! disk for the target dir). Run with: -//! `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` +//! 1. **String alignment** (fast, always runs): the WIT world exports the exact +//! lifecycle a thread's `Dispatch_Protocol` implies, and the generated +//! bindings `impl Guest` names the matching methods. +//! 2. **Compiler-enforced** (`#[ignore]`, opt-in): emit the whole crate and run +//! `cargo check`. wit-bindgen's `generate!` derives the `Guest` trait from +//! the world, so a mangling / keyword / collision bug in the emitter is a +//! compile error — the string test cannot catch a mismatch it replicates. +//! Ignored because it shells out to `cargo` (network for the first build, +//! disk for the target dir). Run with: +//! `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` use spar_codegen::rust_gen::generate_process_bindings; use spar_codegen::wit_gen::generate_wit; From 5274718550ca619de437bd423d5a8aa0574d68b2 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 20:35:48 +0200 Subject: [PATCH 11/13] build(deps): bump crossbeam-epoch 0.9.18 -> 0.9.20 (RUSTSEC-2026-0204) RUSTSEC-2026-0204 (published 2026-07-06): invalid pointer dereference in the `fmt::Pointer` impl for crossbeam-epoch's `Atomic`/`Shared`. Transitive via salsa/rayon; lockfile-only bump to the patched 0.9.20. `cargo audit` clean. Pre-existing on main (not introduced by this branch); fixed here so #320's Security Audit gate is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 381985c..a5fadab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,9 +333,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From 859604badd13d177f4932e45775b4df2ac4e7e73 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 20:35:48 +0200 Subject: [PATCH 12/13] fix(fuzz): update fuzz_codegen_roundtrip for generate() signature (REQ-CODEGEN-WIT-RECORDS-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spar_codegen::generate` took `scope` and became fallible (`-> Result`) in P1/P2a, but the fuzz target still called the 2-arg `generate(&inst, &config)` and treated the result as non-Result. The `fuzz/` crate has its own `[workspace]` (cargo-fuzz convention), so `cargo test --workspace` never compiled it — the break only surfaced when CI's fuzz-smoke job built the target. Thread `scope` and handle the `Result` with `if let Ok(..)` (an `Err` is a legitimate no_alloc refusal, not a crash, so it must not be unwrapped). Verified: `cargo check --manifest-path fuzz/Cargo.toml` compiles clean (the macOS ASan-link step is environment-only; CI builds x86_64-unknown-linux-gnu). Co-Authored-By: Claude Opus 4.8 (1M context) --- fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs | 24 ++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs index 814385a..c651342 100644 --- a/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs +++ b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs @@ -30,7 +30,7 @@ struct Knobs { dry_run: bool, } -fn build_seed_instance() -> SystemInstance { +fn build_seed_instance() -> (GlobalScope, SystemInstance) { let db = spar_hir_def::HirDefDatabase::default(); let sf = spar_base_db::SourceFile::new( &db, @@ -39,16 +39,17 @@ fn build_seed_instance() -> SystemInstance { ); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("BuildingControl"), &Name::new("BuildingSystem"), &Name::new("impl"), - ) + ); + (scope, inst) } fuzz_target!(|knobs: Knobs| { - let inst = build_seed_instance(); + let (scope, inst) = build_seed_instance(); let format = match knobs.format_pick % 3 { 0 => OutputFormat::Rust, @@ -74,11 +75,14 @@ fuzz_target!(|knobs: Knobs| { }; // Contract: no panic on any config combination, even though inputs are - // identical for the instance model. - let out = generate(&inst, &config); - // Touch every file path + content length so a latent panic in formatting - // code would fire here rather than being dead-code-eliminated. - for f in &out.files { - std::hint::black_box((f.path.len(), f.content.len())); + // identical for the instance model. `generate` is fallible (it refuses, + // rather than panics, on non-encodable record fields — REQ-CODEGEN-WIT- + // RECORDS-001); a returned `Err` is a legitimate outcome, not a crash. + if let Ok(out) = generate(&inst, &scope, &config) { + // Touch every file path + content length so a latent panic in + // formatting code would fire here rather than being DCE'd. + for f in &out.files { + std::hint::black_box((f.path.len(), f.content.len())); + } } }); From dabc8d1901d0645afe91ba8629c9357aac8c511a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 8 Jul 2026 20:43:28 +0200 Subject: [PATCH 13/13] build(deps): update cargo-vet exemption for crossbeam-epoch 0.9.20 Follows the RUSTSEC-2026-0204 bump: cargo-vet requires the new version to be audited or exempted. Move the existing crossbeam-epoch exemption from 0.9.18 to 0.9.20 (safe-to-deploy). `cargo vet --locked` succeeds (4 fully audited, 207 exempted). Co-Authored-By: Claude Opus 4.8 (1M context) --- supply-chain/config.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 1915f60..d5b6bbb 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -157,7 +157,7 @@ version = "0.8.6" criteria = "safe-to-deploy" [[exemptions.crossbeam-epoch]] -version = "0.9.18" +version = "0.9.20" criteria = "safe-to-deploy" [[exemptions.crossbeam-queue]] @@ -831,4 +831,3 @@ criteria = "safe-to-run" [[exemptions.zmij]] version = "1.0.21" criteria = "safe-to-deploy" -