diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md index 2da32a4..7f8cb41 100644 --- a/crates/typed-wasm-codegen/README.md +++ b/crates/typed-wasm-codegen/README.md @@ -58,4 +58,5 @@ cargo test -p typed-wasm-codegen - **#127** — codegen coverage across all 10 levels × all 6 examples (and the front-end → IR JSON seam). - **#130** — promote the round-trip tests into the ECHIDNA property corpus. +- **Per-module split** — **done**: `module Name { … }` blocks parse into separate modules (`parse_modules`; importers seeded with the producer's actual schema), `tw build --split` emits one wasm per module, `tw link` certifies the graph (`tests/multimodule_split.rs`, `tests/fixtures/multimodule/game.twasm`). - **L13 positive-form / region-imports** — **done** (issue #140): `import region … from "…" { … }` parses into `Module::region_imports`, emits the `typedwasm.region-imports` carrier (proposal 0003 `[accepted]` / ADR-0007), and `verify_link_graph` certifies cross-module schema agreement (`tests/example02.rs`). diff --git a/crates/typed-wasm-codegen/src/bin/tw.rs b/crates/typed-wasm-codegen/src/bin/tw.rs index 87df13f..04338c7 100644 --- a/crates/typed-wasm-codegen/src/bin/tw.rs +++ b/crates/typed-wasm-codegen/src/bin/tw.rs @@ -4,10 +4,13 @@ //! `tw` — the typed-wasm build CLI (codegen v0). //! //! Usage: -//! tw build [-o ] +//! tw build [-o ] [--emit wasm|wat|both] [--split] +//! tw link … //! -//! v0 supports only the example-01 schema; general `.twasm` front-end → -//! IR lowering is tracked in ADR-0004 and issue #127. +//! `--split` emits one wasm per top-level `module Name { … }` block +//! (`..wasm`); `link` runs the L13 cross-module link-graph +//! pass (ADR-0007) over already-built modules, naming each by its file +//! stem, and reports certificates / violations. use std::path::Path; use std::process::ExitCode; @@ -16,6 +19,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); match args.get(1).map(String::as_str) { Some("build") => build(&args[2..]), + Some("link") => link(&args[2..]), Some("--version") | Some("-V") => { println!("tw {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS @@ -33,7 +37,8 @@ fn main() -> ExitCode { } fn usage() { - eprintln!("usage: tw build [-o ] [--emit wasm|wat|both]"); + eprintln!("usage: tw build [-o ] [--emit wasm|wat|both] [--split]"); + eprintln!(" tw link ..."); } /// What `tw build` emits. @@ -65,6 +70,7 @@ fn build(rest: &[String]) -> ExitCode { let mut input: Option = None; let mut output: Option = None; let mut emit = Emit::Wasm; + let mut split = false; let mut i = 0; while i < rest.len() { let arg = rest[i].as_str(); @@ -96,6 +102,7 @@ fn build(rest: &[String]) -> ExitCode { return ExitCode::FAILURE; } }, + "--split" => split = true, s if !s.starts_with('-') => input = Some(s.to_string()), other => { eprintln!("tw build: unknown option '{other}'"); @@ -119,8 +126,11 @@ fn build(rest: &[String]) -> ExitCode { } }; - // Try to parse the .twasm file using the Rust parser (issue #127). - // This parser handles the paint-type schemas and example-01. + if split { + return build_split(&input, &src, output.as_deref(), emit); + } + + // Parse via the canonical Rust front-end (ADR-0006). let bytes = match typed_wasm_codegen::parser::parse_module(&src) { Ok(module) => { let bytes = typed_wasm_codegen::emit(&module); @@ -171,6 +181,114 @@ fn build(rest: &[String]) -> ExitCode { ExitCode::SUCCESS } +/// `tw build --split`: one output per `module Name { … }` block. +fn build_split(input: &str, src: &str, output: Option<&str>, emit: Emit) -> ExitCode { + let modules = match typed_wasm_codegen::parser::parse_modules(src) { + Ok(m) => m, + Err(e) => { + eprintln!("tw build: parse error in '{input}': {e}"); + return ExitCode::FAILURE; + } + }; + let base = output.unwrap_or(input); + let stem = Path::new(base) + .with_extension("") + .to_string_lossy() + .into_owned(); + let mut wrote = Vec::new(); + for (name, module) in &modules { + let bytes = typed_wasm_codegen::emit(module); + if let Err(diagnostics) = typed_wasm_codegen::self_verify(module) { + for msg in diagnostics { + eprintln!("tw build: self-verify warning [{name}]: {msg}"); + } + } + if emit.wants_wasm() { + let path = format!("{stem}.{name}.wasm"); + if let Err(e) = std::fs::write(&path, &bytes) { + eprintln!("tw build: cannot write '{path}': {e}"); + return ExitCode::FAILURE; + } + wrote.push(format!("{path} ({} bytes)", bytes.len())); + } + if emit.wants_wat() { + let path = format!("{stem}.{name}.wat"); + let text = typed_wasm_codegen::wat(&bytes); + if let Err(e) = std::fs::write(&path, text.as_bytes()) { + eprintln!("tw build: cannot write '{path}': {e}"); + return ExitCode::FAILURE; + } + wrote.push(path); + } + } + eprintln!( + "tw build: split {} module(s): {}", + modules.len(), + wrote.join(" + ") + ); + eprintln!("tw build: check cross-module schema agreement with `tw link `."); + ExitCode::SUCCESS +} + +/// `tw link`: the L13 positive-form link-graph pass (ADR-0007) over +/// built modules. Each module's wasm-level name is its file stem's last +/// dot-segment (`game.physics.wasm` → `physics`). +fn link(files: &[String]) -> ExitCode { + if files.is_empty() { + eprintln!("tw link: no modules given"); + usage(); + return ExitCode::FAILURE; + } + let mut named: Vec<(String, Vec)> = Vec::new(); + for f in files { + let bytes = match std::fs::read(f) { + Ok(b) => b, + Err(e) => { + eprintln!("tw link: cannot read '{f}': {e}"); + return ExitCode::FAILURE; + } + }; + let stem = Path::new(f) + .with_extension("") + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| f.clone()); + let name = stem.rsplit('.').next().unwrap_or(&stem).to_string(); + named.push((name, bytes)); + } + let graph: Vec<(&str, &[u8])> = named + .iter() + .map(|(n, b)| (n.as_str(), b.as_slice())) + .collect(); + match typed_wasm_verify::verify_link_graph(&graph) { + Ok(report) => { + for c in &report.certificates { + println!( + "CERTIFIED {} imports {}.{}", + c.consumer, c.producer, c.region_name + ); + } + for e in &report.errors { + eprintln!("VIOLATION {e}"); + } + if report.errors.is_empty() { + eprintln!( + "tw link: {} module(s), {} certificate(s), no violations", + named.len(), + report.certificates.len() + ); + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + Err(e) => { + eprintln!("tw link: wasm parse error: {e}"); + ExitCode::FAILURE + } + } +} + /// `base` with its extension replaced by `ext` /// (e.g. `with_extension("foo.twasm", "wasm")` -> `"foo.wasm"`). fn with_extension(base: &str, ext: &str) -> String { diff --git a/crates/typed-wasm-codegen/src/parser.rs b/crates/typed-wasm-codegen/src/parser.rs index 8450c39..e14037a 100644 --- a/crates/typed-wasm-codegen/src/parser.rs +++ b/crates/typed-wasm-codegen/src/parser.rs @@ -20,10 +20,127 @@ use crate::{Field, FieldTy, Memory, Module, PtrKind, Region, Scalar, Wty}; use std::collections::HashMap; -/// Parse a .twasm source file into a Module IR. +/// Parse a .twasm source file into a single merged Module IR. +/// `module Name [isolated] { … }` blocks are flattened into one module +/// (the historical single-module view the round-trip corpus uses); +/// [`parse_modules`] gives the per-module split. pub fn parse_module(src: &str) -> Result { - let parser = Parser::new(src); - parser.parse_module() + let fragments = slice_modules(src); + if fragments.len() == 1 { + return Parser::new(src).parse_module(); + } + let merged: String = fragments + .into_iter() + .map(|(_, frag)| frag) + .collect::>() + .join("\n"); + Parser::new(&merged).parse_module() +} + +/// Parse a multi-module `.twasm` source into one Module per top-level +/// `module Name [isolated] { … }` block (declarations outside any block +/// form the `"main"` module, omitted when empty). Two passes: modules +/// parse independently, then any module importing a region whose +/// producer is in the same file is RE-parsed with the producer's actual +/// region schemas seeded in — so cross-module accesses lower against +/// the producer's real layout, not the expected subset's packing. +pub fn parse_modules(src: &str) -> Result, String> { + let fragments = slice_modules(src); + let mut parsed: Vec<(String, Module, Vec, String)> = Vec::new(); + for (name, frag) in fragments { + let (module, instances) = Parser::new(&frag).parse_with_instances()?; + if name == "main" + && module.regions.is_empty() + && module.funcs.is_empty() + && module.region_imports.is_empty() + { + continue; // no default-module content + } + parsed.push((name, module, instances, frag)); + } + + // Pass 2: seed importers with in-file producers' actual schemas. + let producer_snapshot: Vec<(String, Module, Vec)> = parsed + .iter() + .map(|(n, m, i, _)| (n.clone(), m.clone(), i.clone())) + .collect(); + for (_, module, _, frag) in parsed.iter_mut() { + if module.region_imports.is_empty() { + continue; + } + let mut seeds: Vec<(Region, u64)> = Vec::new(); + for imp in &module.region_imports { + if let Some((_, producer, instances)) = producer_snapshot + .iter() + .find(|(n, _, _)| *n == imp.producer_module) + { + if let Some(idx) = + producer.regions.iter().position(|r| r.name == imp.region_name) + { + seeds.push(( + producer.regions[idx].clone(), + instances.get(idx).copied().unwrap_or(1), + )); + } + } + } + if !seeds.is_empty() { + let mut p = Parser::new(frag); + for (region, instances) in seeds { + p.region_map.insert(region.name.clone(), p.regions.len()); + p.regions.push(region); + p.region_instances.push(instances); + } + *module = p.parse_module()?; + } + } + + Ok(parsed.into_iter().map(|(n, m, _, _)| (n, m)).collect()) +} + +/// Split source at top-level `module Name [isolated] { … }` blocks into +/// `(name, fragment)` pairs; text outside any block accumulates into a +/// `"main"` fragment (kept even when blank so `parse_module` can join). +fn slice_modules(src: &str) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut default_frag = String::new(); + let mut scanner = Parser::new(src); + let mut plain_start = 0usize; + loop { + scanner.skip_whitespace(); + if scanner.pos >= src.len() { + break; + } + if scanner.peek_word("module") { + let block_start = scanner.pos; + scanner.pos += "module".len(); + let name = scanner.parse_ident(); + scanner.skip_whitespace(); + let _ = scanner.try_keyword("isolated"); + scanner.skip_whitespace(); + if !name.is_empty() && scanner.try_char('{') { + default_frag.push_str(&src[plain_start..block_start]); + let body_start = scanner.pos; + scanner.skip_to_brace_close(); + // skip_to_brace_close consumed through the closing `}`. + let body_end = scanner.pos.saturating_sub(1).max(body_start); + out.push((name, src[body_start..body_end].to_string())); + plain_start = scanner.pos; + continue; + } + // `module X;` or malformed: leave for the plain fragment. + scanner.pos = block_start + "module".len(); + } + // Advance one declaration: consume through it so we never split + // inside a nested brace (fn bodies, regions, …). + scanner.skip_declaration(); + } + default_frag.push_str(&src[plain_start..]); + if out.is_empty() { + return vec![("main".into(), default_frag)]; + } + out.insert(0, ("main".into(), default_frag)); + out } /// A simple hand-written parser for .twasm syntax. @@ -63,7 +180,15 @@ impl<'a> Parser<'a> { } } - fn parse_module(mut self) -> Result { + fn parse_module(self) -> Result { + self.parse_with_instances().map(|(m, _)| m) + } + + /// As [`Self::parse_module`], additionally returning the per-region + /// declared instance counts (needed to seed importers in + /// [`parse_modules`] so their scans iterate the producer's real + /// instance array). + fn parse_with_instances(mut self) -> Result<(Module, Vec), String> { while self.pos < self.src.len() { self.skip_whitespace(); if self.pos >= self.src.len() { @@ -88,14 +213,17 @@ impl<'a> Parser<'a> { } } - Ok(Module { - regions: self.regions, - memory: self.memory, - imports: self.imports, - funcs: self.funcs, - ownership: self.ownership, - region_imports: self.region_imports, - }) + Ok(( + Module { + regions: self.regions, + memory: self.memory, + imports: self.imports, + funcs: self.funcs, + ownership: self.ownership, + region_imports: self.region_imports, + }, + self.region_instances, + )) } fn peek_word(&mut self, word: &str) -> bool { diff --git a/crates/typed-wasm-codegen/tests/fixtures/multimodule/game.twasm b/crates/typed-wasm-codegen/tests/fixtures/multimodule/game.twasm new file mode 100644 index 0000000..84ab0eb --- /dev/null +++ b/crates/typed-wasm-codegen/tests/fixtures/multimodule/game.twasm @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// examples/02-multi-module.twasm restructured with the grammar's real +// `module Name { … }` blocks (v1.2 L13 form) so the per-module split +// (`parse_modules`, `tw build --split`, `tw link`) has a canonical +// fixture. Bodies are within the statement lowerer's coverage so every +// module emits real typed accesses. + +module physics { + region Entity[64] { + pos_x: f32; + pos_y: f32; + vel_x: f32; + vel_y: f32; + flags: u32; + } + + fn physics_step(entities: &mut region, dt: f32) { + region.scan $entities where (flags & 1) == 1 -> |e| { + region.get $e .vel_x -> vx; + region.get $e .pos_x -> px; + region.set $e .pos_x, px + vx * dt; + } + } +} + +module ai { + import region Entity from "physics" { + pos_x: f32; + pos_y: f32; + flags: u32; + } + + fn nearest_x(entities: ®ion, count: i32) -> f32 { + let mut best: f32 = 999999.0; + let mut i: i32 = 0; + while i < count { + region.get $entities[i] .pos_x -> x; + if x < best { + best = x; + } + i = i + 1; + } + return best; + } +} + +module render { + import region Entity from "physics" { + pos_x: f32; + flags: u32; + } + + fn count_visible(entities: ®ion) -> i32 { + let mut n: i32 = 0; + region.scan $entities where (flags & 3) == 3 -> |e| { + n = n + 1; + } + return n; + } +} diff --git a/crates/typed-wasm-codegen/tests/multimodule_split.rs b/crates/typed-wasm-codegen/tests/multimodule_split.rs new file mode 100644 index 0000000..feaa82b --- /dev/null +++ b/crates/typed-wasm-codegen/tests/multimodule_split.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Per-module split (#140 remainder): `module Name { … }` blocks parse +// into separate Modules (`parse_modules`), importers are seeded with +// the producer's ACTUAL schema (real offsets + instance counts), each +// module emits its own wasm, and the link graph certifies agreement +// over genuinely separate binaries — the examples/02 killer feature as +// three real modules instead of one merged parse. + +use typed_wasm_codegen::{emit, parser}; +use typed_wasm_verify::{verify_link_graph, RegionImportsError, WasmTy}; + +const GAME: &str = include_str!("fixtures/multimodule/game.twasm"); + +#[test] +fn module_blocks_split_and_seed_importers() { + let modules = parser::parse_modules(GAME).expect("game.twasm parses"); + let names: Vec<&str> = modules.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["physics", "ai", "render"]); + + let physics = &modules[0].1; + assert_eq!(physics.regions[0].name, "Entity"); + assert_eq!(physics.region_imports.len(), 0); + + // The importer's Entity is the producer's ACTUAL schema (5 fields, + // real byte size), not its 3-field expected subset — so accesses + // lower against real offsets. + let ai = &modules[1].1; + assert_eq!(ai.region_imports.len(), 1); + assert_eq!(ai.region_imports[0].expected_fields.len(), 3); + assert_eq!(ai.regions[0].name, "Entity"); + assert_eq!( + ai.regions[0].fields.len(), + physics.regions[0].fields.len(), + "seeded with the producer's actual schema" + ); + assert_eq!(ai.regions[0].byte_size, physics.regions[0].byte_size); + + // Every module's functions lower for real. + for (name, module) in &modules { + for f in &module.funcs { + assert!( + !f.accesses.is_empty(), + "{name}.{} must lower for real", + f.name + ); + } + } +} + +#[test] +fn split_modules_link_with_certificates() { + let modules = parser::parse_modules(GAME).expect("game.twasm parses"); + let built: Vec<(String, Vec)> = modules + .iter() + .map(|(n, m)| (n.clone(), emit(m))) + .collect(); + for (name, bytes) in &built { + wasmparser::Validator::new() + .validate_all(bytes) + .unwrap_or_else(|e| panic!("{name} must validate: {e}")); + } + let graph: Vec<(&str, &[u8])> = built + .iter() + .map(|(n, b)| (n.as_str(), b.as_slice())) + .collect(); + let report = verify_link_graph(&graph).expect("link graph runs"); + assert_eq!(report.errors, vec![], "clean graph"); + assert_eq!(report.certificates.len(), 2, "ai + render both certified"); +} + +#[test] +fn split_mutant_expectation_is_rejected_at_link() { + let mut modules = parser::parse_modules(GAME).expect("game.twasm parses"); + let ai = &mut modules[1].1; + ai.region_imports[0] + .expected_fields + .iter_mut() + .find(|f| f.name == "flags") + .unwrap() + .wasm_ty = WasmTy::F64; // actual is u32 + let built: Vec<(String, Vec)> = modules + .iter() + .map(|(n, m)| (n.clone(), emit(m))) + .collect(); + let graph: Vec<(&str, &[u8])> = built + .iter() + .map(|(n, b)| (n.as_str(), b.as_slice())) + .collect(); + let report = verify_link_graph(&graph).expect("link graph runs"); + assert!(report + .errors + .iter() + .any(|e| matches!(e, RegionImportsError::SchemaImportMismatch { .. }))); +} + +/// The merged single-module view (the corpus contract) still works on +/// module-block sources: blocks flatten, imports union-merge. +#[test] +fn merged_view_still_parses_module_blocks() { + let merged = parser::parse_module(GAME).expect("merged parse"); + assert_eq!(merged.regions.len(), 1); + assert_eq!(merged.funcs.len(), 3); + assert_eq!(merged.region_imports.len(), 1, "imports union-merged"); +} diff --git a/crates/typed-wasm-verify/tests/fixtures/producer_pair/README.md b/crates/typed-wasm-verify/tests/fixtures/producer_pair/README.md new file mode 100644 index 0000000..7249542 --- /dev/null +++ b/crates/typed-wasm-verify/tests/fixtures/producer_pair/README.md @@ -0,0 +1,27 @@ + + +# producer_pair fixtures — producer-emitted cross-module ownership boundary + +The producer-emitted callee/caller differential pair issue #140 asked +for: `typed-wasm-codegen`'s `multimodule_callee()` / `multimodule_caller(n)` +IR emitted to real bytes and committed, exercised by +`tests/producer_pair.rs` against `extract_exports` + `verify_cross_module`. + +| File | Content | Expected verdict | +|---|---|---| +| `callee.wasm` | exports `consume(x: Linear)` with an ownership carrier | interface extracts: `[Linear] → Unrestricted` | +| `caller_ok.wasm` | imports `consume`, calls it exactly once | **accepted** | +| `caller_double.wasm` | imports `consume`, calls it twice | **rejected** (`LinearImportCalledMultiple`) | + +Captured 2026-07-07. Regenerate after producer changes with the +one-shot generator (deterministic — an unchanged producer regenerates +byte-identical files): + +```bash +cargo run -p typed-wasm-codegen --example gen_producer_pair # (recreate from git history if pruned) +``` + +The in-crate parity oracle remains `typed-wasm-codegen/tests/multimodule.rs`; +this pair pins the emitted **bytes** so byte-level drift in either the +producer or the verifier shows up as a fixture diff, mirroring +`c5_real/` (AffineScript) and `zig_producer/` (Zig). diff --git a/crates/typed-wasm-verify/tests/fixtures/producer_pair/callee.wasm b/crates/typed-wasm-verify/tests/fixtures/producer_pair/callee.wasm new file mode 100644 index 0000000..3b24a98 Binary files /dev/null and b/crates/typed-wasm-verify/tests/fixtures/producer_pair/callee.wasm differ diff --git a/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_double.wasm b/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_double.wasm new file mode 100644 index 0000000..fc2d592 Binary files /dev/null and b/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_double.wasm differ diff --git a/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_ok.wasm b/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_ok.wasm new file mode 100644 index 0000000..75e0fae Binary files /dev/null and b/crates/typed-wasm-verify/tests/fixtures/producer_pair/caller_ok.wasm differ diff --git a/crates/typed-wasm-verify/tests/producer_pair.rs b/crates/typed-wasm-verify/tests/producer_pair.rs new file mode 100644 index 0000000..2a5490c --- /dev/null +++ b/crates/typed-wasm-verify/tests/producer_pair.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Producer-emitted cross-module differential pair (#140): the in-tree +// producer's callee/caller bytes are committed and pinned here against +// the L10 linear import boundary — `extract_exports` on the callee, +// `verify_cross_module` on the callers. Provenance: +// tests/fixtures/producer_pair/README.md. + +use typed_wasm_verify::{ + extract_exports, verify_cross_module, CrossError, OwnershipKind, VerifyError, +}; + +const CALLEE: &[u8] = include_bytes!("fixtures/producer_pair/callee.wasm"); +const CALLER_OK: &[u8] = include_bytes!("fixtures/producer_pair/caller_ok.wasm"); +const CALLER_DOUBLE: &[u8] = include_bytes!("fixtures/producer_pair/caller_double.wasm"); + +#[test] +fn producer_pair_fixtures_are_valid_wasm() { + for (name, bytes) in [ + ("callee", CALLEE), + ("caller_ok", CALLER_OK), + ("caller_double", CALLER_DOUBLE), + ] { + wasmparser::Validator::new() + .validate_all(bytes) + .unwrap_or_else(|e| panic!("{name} must be valid wasm: {e}")); + } +} + +#[test] +fn callee_interface_extracts_linear_consume() { + let exports = extract_exports(CALLEE).expect("callee interface"); + let consume = exports + .iter() + .find(|f| f.name == "consume") + .expect("callee exports consume"); + assert_eq!(consume.param_kinds, vec![OwnershipKind::Linear]); +} + +#[test] +fn caller_calling_once_is_accepted() { + let exports = extract_exports(CALLEE).expect("callee interface"); + verify_cross_module(&exports, CALLER_OK) + .expect("single call of a Linear import is clean"); +} + +#[test] +fn caller_calling_twice_is_rejected() { + let exports = extract_exports(CALLEE).expect("callee interface"); + match verify_cross_module(&exports, CALLER_DOUBLE) { + Err(VerifyError::Cross(errs)) => assert!( + errs.iter() + .any(|e| matches!(e, CrossError::LinearImportCalledMultiple { .. })), + "expected LinearImportCalledMultiple, got: {errs:?}" + ), + other => panic!("double call must be rejected with a Cross error: {other:?}"), + } +}