Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/typed-wasm-codegen/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
<!-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
# typed-wasm-codegen

The first in-tree `.twasm → .wasm` **producer** (codegen **v0**).
Expand Down Expand Up @@ -46,10 +47,10 @@ cargo test -p typed-wasm-codegen
| Aspect | v0 status |
|---|---|
| Host language / location | Rust crate, sibling of `typed-wasm-verify`, emits via `wasm-encoder` |
| Front-end (`.twasm`) → IR | **deferred** — v0 builds the IR for `example01` directly (seam tracked by #127) |
| Front-end (`.twasm`) → IR | **in-process Rust parser** (`src/parser.rs`) — all six `examples/*.twasm` parse → emit → verify (`tests/corpus.rs`), incl. ownership qualifiers → `typedwasm.ownership` (ADR-0006) |
| `typedwasm.regions` + `typedwasm.access-sites` | **emitted**, verifier-accepted |
| WAT (text) emission | **emitted** via `--emit wat\|both` (#125) |
| `typedwasm.ownership` (L7/L10) | **emitted** for Linear params (multi-module callee, #128); example 01 has no linear resources |
| `typedwasm.ownership` (L7/L10) | **emitted** for any `own`/`&mut`/`&` source discipline (parser-recorded, incl. Linear returns) and for the multi-module callee (#128) |
| Multi-module (linear boundary) | **emitted** — callee export + caller import round-trip through `verify_cross_module` (#128) |
| Function-body lowering | representative type-correct bodies, not full `region.scan`/indexing semantics |

Expand Down
20 changes: 10 additions & 10 deletions crates/typed-wasm-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
//! IR for [`example01`] directly rather than parsing `.twasm`. Wiring the
//! checker's AST to this IR (a serialized JSON IR) is tracked by issue
//! #127 (D1: all 10 levels × all 6 examples).
//! * L7/L10 ownership/linearity carriers (`typedwasm.ownership`) are not
//! emitted for the read/write example 01 (its region borrows are not
//! linear resources); they land with `examples/03-ownership-linearity`
//! under #127.
//! * L7/L10 ownership/linearity carriers (`typedwasm.ownership`) are
//! emitted whenever the source declares discipline (`own` / `&mut` /
//! `&` qualifiers) — the parser records them into [`Module::ownership`]
//! and `emit` writes the carrier (exercised by `tests/example03.rs`).
//! * Full function-body semantics (indexing, `region.scan`, null checks):
//! v0 emits type-correct representative bodies, not the full lowering.

Expand Down Expand Up @@ -318,9 +318,9 @@ pub struct Module {
pub imports: Vec<Import>,
pub funcs: Vec<Func>,
/// Per-local-function ownership annotations: `(local_func_index,
/// param_kinds)`. Emitted as the `typedwasm.ownership` carrier;
/// empty = no L7/L10 carrier.
pub ownership: Vec<(usize, Vec<Ownership>)>,
/// param_kinds, ret_kind)`. Emitted as the `typedwasm.ownership`
/// carrier; empty = no L7/L10 carrier.
pub ownership: Vec<(usize, Vec<Ownership>, Ownership)>,
}

// ----------------------------------------------------------------------
Expand Down Expand Up @@ -464,10 +464,10 @@ pub fn emit(module: &Module) -> Vec<u8> {
let ownership_entries: Vec<OwnershipEntry> = module
.ownership
.iter()
.map(|(local_i, kinds)| OwnershipEntry {
.map(|(local_i, kinds, ret)| OwnershipEntry {
func_idx: import_count + *local_i as u32,
param_kinds: kinds.iter().map(|o| o.to_kind()).collect(),
ret_kind: OwnershipKind::Unrestricted,
ret_kind: ret.to_kind(),
})
.collect();

Expand Down Expand Up @@ -985,7 +985,7 @@ pub fn multimodule_callee() -> Module {
accesses: vec![],
export: true,
}],
ownership: vec![(0, vec![Ownership::Linear])],
ownership: vec![(0, vec![Ownership::Linear], Ownership::Unrestricted)],
}
}

Expand Down
49 changes: 41 additions & 8 deletions crates/typed-wasm-codegen/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct Parser<'a> {
memory: Option<Memory>,
imports: Vec<crate::Import>,
funcs: Vec<crate::Func>,
ownership: Vec<(usize, Vec<crate::Ownership>)>,
ownership: Vec<(usize, Vec<crate::Ownership>, crate::Ownership)>,
}

impl<'a> Parser<'a> {
Expand Down Expand Up @@ -590,6 +590,10 @@ impl<'a> Parser<'a> {
// Per-param (name, region-index) for body lowering: a region-typed
// param records the region it points at; scalar params record None.
let mut param_meta: Vec<(String, Option<usize>)> = Vec::new();
// L7/L10 ownership kinds per param (`own`/`&mut`/`&` qualifiers),
// recorded into `Module::ownership` so the emitted module carries
// the `typedwasm.ownership` section the verifier checks.
let mut param_kinds: Vec<crate::Ownership> = Vec::new();
loop {
self.skip_whitespace();
if self.peek_char(')') {
Expand All @@ -614,7 +618,7 @@ impl<'a> Parser<'a> {
};

// Parse the type, which may include ownership qualifiers
let (param_ty, _) = self.parse_param_type()?;
let (param_ty, _, kind) = self.parse_param_type()?;

// Map field type to Wty; remember the region a region-typed param
// points at (for `region.get $p` lowering).
Expand All @@ -625,6 +629,7 @@ impl<'a> Parser<'a> {

params.push(wty);
param_meta.push((pname, region));
param_kinds.push(kind);

if self.peek_char(',') {
self.expect(",")?;
Expand All @@ -635,12 +640,14 @@ impl<'a> Parser<'a> {

// Parse optional -> return type
let mut results = Vec::new();
let mut ret_kind = crate::Ownership::Unrestricted;
if self.peek_word("->") {
self.expect("->")?;
self.skip_whitespace();

// For now, assume single return type - parse it
let (ret_ty, _) = self.parse_param_type()?;
let (ret_ty, _, kind) = self.parse_param_type()?;
ret_kind = kind;
let wty = match ret_ty {
FieldTy::Scalar(s) => wty_from_scalar(&s),
FieldTy::Ptr { .. } => Wty::I32,
Expand Down Expand Up @@ -706,6 +713,18 @@ impl<'a> Parser<'a> {
}
};

// Record the function's ownership signature when the source asked
// for L7/L10 discipline anywhere in it. All-Unrestricted functions
// stay out of the carrier (empty = no constraint, matching the
// "empty = no L7/L10 carrier" Module contract).
let has_discipline = ret_kind != crate::Ownership::Unrestricted
|| param_kinds
.iter()
.any(|k| *k != crate::Ownership::Unrestricted);
if has_discipline {
self.ownership.push((self.funcs.len(), param_kinds, ret_kind));
}

self.funcs.push(crate::Func {
name,
params,
Expand Down Expand Up @@ -1047,8 +1066,12 @@ impl<'a> Parser<'a> {
}
}

/// Parse a parameter type which may include ownership qualifiers like 'own', '&', '&mut'
fn parse_param_type(&mut self) -> Result<(FieldTy, u32), String> {
/// Parse a parameter type which may include ownership qualifiers like 'own', '&', '&mut'.
/// The third component is the L7/L10 ownership kind the qualifier denotes:
/// `own` → Linear, `&mut` → ExclBorrow, `&` → SharedBorrow. A bare
/// `region<T>` (no qualifier) and every scalar stay Unrestricted — the
/// carrier only asserts discipline the source explicitly asked for.
fn parse_param_type(&mut self) -> Result<(FieldTy, u32, crate::Ownership), String> {
self.skip_whitespace();

// Check for ownership qualifier
Expand Down Expand Up @@ -1097,14 +1120,24 @@ impl<'a> Parser<'a> {
PtrKind::Owning // default
};

let ownership = if is_own {
crate::Ownership::Linear
} else if is_excl_borrow {
crate::Ownership::ExclBorrow
} else if is_shared_borrow {
crate::Ownership::SharedBorrow
} else {
crate::Ownership::Unrestricted
};
Ok((FieldTy::Ptr {
kind,
target: idx,
nullable: false,
}, 1))
}, 1, ownership))
} else {
// Parse as normal field type
self.parse_field_type()
let (ty, card) = self.parse_field_type()?;
Ok((ty, card, crate::Ownership::Unrestricted))
}
}

Expand Down
8 changes: 4 additions & 4 deletions crates/typed-wasm-codegen/tests/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
// Enforced ECHIDNA-style over a deterministically-generated corpus, plus
// negative controls that MUST be rejected so the property has teeth.
//
// The corpus is generated at the IR level — the producer has no in-process
// `.twasm` parser yet (#127). A `verify(codegen(parse(src)))` corpus over
// real `.twasm` sources follows once the front-end → IR seam lands.
// Two corpus axes: IR-level generated modules, plus `verify(emit(parse(src)))`
// over real `.twasm` sources — both the six canonical examples and randomly
// generated source text (the in-process parser is `src/parser.rs`, ADR-0006).

use typed_wasm_codegen::{
emit, example01, paint_type_tile, paint_type_layer, parser, Field, Func, Memory, Module,
Expand Down Expand Up @@ -618,7 +618,7 @@ fn one_func_module(kind: Ownership, body: Vec<Op>) -> Module {
accesses: vec![],
export: true,
}],
ownership: vec![(0, vec![kind])],
ownership: vec![(0, vec![kind], Ownership::Unrestricted)],
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/typed-wasm-codegen/tests/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fn clean_examples_self_verify() {
self_verify(&example01()).is_ok(),
"example 01 should self-verify clean"
);
// example03 not yet implemented - tracked by #127
// example03 source→emit→verify coverage lives in tests/example03.rs
}

#[test]
Expand All @@ -38,7 +38,7 @@ fn double_free_gives_named_actionable_message() {
accesses: vec![],
export: true,
}],
ownership: vec![(0, vec![Ownership::Linear])],
ownership: vec![(0, vec![Ownership::Linear], Ownership::Unrestricted)],
};
let diagnostics = self_verify(&module).expect_err("double-free must be rejected");
let joined = diagnostics.join("\n");
Expand Down
148 changes: 142 additions & 6 deletions crates/typed-wasm-codegen/tests/example03.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,148 @@
// Codegen coverage for examples/03-ownership-linearity.twasm — L7–L10
// (Phase 1 deliverable 1 / #127).
//
// Note: example03 is not yet implemented in the IR (needs full front-end → IR
// lowering from #127). These tests are deferred until example03() exists.
// The full source→parse→emit→verify pipeline over the L7–L10 flagship
// example: the parser must translate the source's `own` / `&mut` / `&`
// qualifiers into `Module::ownership` entries, the emitted wasm must carry
// the `typedwasm.ownership` section with exactly those kinds on the wire,
// and the verifier must both accept the honest module and reject a
// double-consume mutant (so the pass has teeth, not vacuous green).

use typed_wasm_codegen::{emit, parser, Op, Ownership};
use typed_wasm_verify::{
parse_ownership_section_payload, verify_from_module, OwnershipKind, VerifyError,
OWNERSHIP_SECTION_NAME,
};
use wasmparser::{Parser as WasmParser, Payload};

const SRC: &str = include_str!("../../../examples/03-ownership-linearity.twasm");

/// Extract the `typedwasm.ownership` custom-section payload from wasm bytes.
fn ownership_payload(wasm: &[u8]) -> Option<Vec<u8>> {
for payload in WasmParser::new(0).parse_all(wasm) {
if let Ok(Payload::CustomSection(reader)) = payload {
if reader.name() == OWNERSHIP_SECTION_NAME {
return Some(reader.data().to_vec());
}
}
}
None
}

/// The parser records the source's ownership qualifiers into the IR:
/// every function that asks for L7–L10 discipline gets an entry, and
/// all-Unrestricted functions stay out of the carrier.
#[test]
fn example03_parser_records_ownership_signatures() {
let module = parser::parse_module(SRC).expect("03-ownership-linearity.twasm must parse");

let by_name = |name: &str| {
let idx = module
.funcs
.iter()
.position(|f| f.name == name)
.unwrap_or_else(|| panic!("function {name} must be parsed"));
module.ownership.iter().find(|(i, _, _)| *i == idx)
};

// spawn_particle: six scalar params, returns `own region<Particle>`.
let (_, params, ret) = by_name("spawn_particle").expect("spawn_particle carries discipline");
assert!(params.iter().all(|k| *k == Ownership::Unrestricted));
assert_eq!(*ret, Ownership::Linear, "own return is Linear");

// despawn_particle(particle: own region<Particle>) — Linear param.
let (_, params, ret) = by_name("despawn_particle").expect("despawn_particle has discipline");
assert_eq!(params[0], Ownership::Linear);
assert_eq!(*ret, Ownership::Unrestricted);

// update_particle(p: &mut region<Particle>, dt: f32) — ExclBorrow.
let (_, params, _) = by_name("update_particle").expect("update_particle has discipline");
assert_eq!(params[0], Ownership::ExclBorrow);
assert_eq!(params[1], Ownership::Unrestricted);

// read_particle_pos(p: &region<Particle>) — SharedBorrow.
let (_, params, _) = by_name("read_particle_pos").expect("read_particle_pos has discipline");
assert_eq!(params[0], Ownership::SharedBorrow);

// find_nearest_alive / safe_batch_update: borrow + scalars.
let (_, params, _) = by_name("find_nearest_alive").expect("find_nearest_alive has discipline");
assert_eq!(params[0], Ownership::SharedBorrow);
let (_, params, _) = by_name("safe_batch_update").expect("safe_batch_update has discipline");
assert_eq!(params[0], Ownership::ExclBorrow);

// particle_lifecycle() takes no params and returns nothing — no entry.
assert!(
by_name("particle_lifecycle").is_none(),
"all-Unrestricted signature must stay out of the carrier"
);
}

/// The emitted wasm carries the ownership kinds on the wire, byte-decodable
/// by the verifier's own section parser, and the module verifies.
#[test]
fn example03_placeholder() {
// TODO: Implement example03() function when #127 (front-end → IR) is complete
// For now, this is a placeholder test that always passes.
// Tracked by: hyperpolymath/typed-wasm#127
fn example03_emits_ownership_carrier_and_verifies() {
let module = parser::parse_module(SRC).expect("03-ownership-linearity.twasm must parse");
let wasm = emit(&module);

let payload =
ownership_payload(&wasm).expect("emitted example03 must carry typedwasm.ownership");
let entries = parse_ownership_section_payload(&payload);
assert_eq!(
entries.len(),
module.ownership.len(),
"one wire entry per disciplined function"
);

// Wire spot-checks (example03 has no imports, so global idx == local idx).
let despawn_idx = module
.funcs
.iter()
.position(|f| f.name == "despawn_particle")
.unwrap() as u32;
let despawn = entries
.iter()
.find(|e| e.func_idx == despawn_idx)
.expect("despawn_particle on the wire");
assert_eq!(despawn.param_kinds[0], OwnershipKind::Linear);

let spawn_idx = module
.funcs
.iter()
.position(|f| f.name == "spawn_particle")
.unwrap() as u32;
let spawn = entries
.iter()
.find(|e| e.func_idx == spawn_idx)
.expect("spawn_particle on the wire");
assert_eq!(spawn.ret_kind, OwnershipKind::Linear, "own return on the wire");

verify_from_module(&wasm).expect("honest example03 must pass L7/L10");
}

/// Teeth: consuming despawn_particle's Linear param twice (a double-free at
/// the wasm level) must be rejected by the verifier — proving the carrier
/// emitted from parsed source is load-bearing, not decorative.
#[test]
fn example03_double_free_mutant_is_rejected() {
let mut module = parser::parse_module(SRC).expect("03-ownership-linearity.twasm must parse");
let despawn_idx = module
.funcs
.iter()
.position(|f| f.name == "despawn_particle")
.expect("despawn_particle must be parsed");
module.funcs[despawn_idx].body =
vec![Op::LocalGet(0), Op::Drop, Op::LocalGet(0), Op::Drop];
module.funcs[despawn_idx].accesses.clear();

let wasm = emit(&module);
match verify_from_module(&wasm) {
Err(VerifyError::Ownership(errs)) => {
assert!(
!errs.is_empty(),
"double-free must surface at least one ownership error"
);
}
Err(other) => panic!("expected an ownership rejection, got: {other:?}"),
Ok(()) => panic!("double-free mutant must NOT verify"),
}
}
2 changes: 1 addition & 1 deletion crates/typed-wasm-codegen/tests/optimization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ fn ownership_func_idx_is_load_bearing() {
memory: None,
imports: vec![],
funcs: funcs(),
ownership: vec![(owned, vec![Ownership::Linear])],
ownership: vec![(owned, vec![Ownership::Linear], Ownership::Unrestricted)],
};

// Carrier marks func 0 Linear → its double-use is the violation.
Expand Down
Loading
Loading