From 7fa5abd04db7b73838c542f3325bf86ae597a657 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:21:33 +0100 Subject: [PATCH] feat(frontend): parser records own/&mut/& into the typedwasm.ownership carrier + ADR-0006 (Rust front-end canonical) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parsed path emitted NO ownership carrier: Parser.ownership was initialised empty and never pushed, so L7/L10 verified vacuously on every parsed .twasm source (the 6/6 corpus was weaker than it looked). - parse_param_type now returns the ownership kind a qualifier denotes (own -> Linear, &mut -> ExclBorrow, & -> SharedBorrow; bare region and scalars stay Unrestricted) and parse_function records a Module::ownership entry for any function whose signature asks for discipline. - Module::ownership tuples gain a ret_kind slot (the wire format always had one; emit() hardcoded Unrestricted) so 'own' returns like spawn_particle's land on the wire. - tests/example03.rs: placeholder replaced with 3 real tests — parser records the expected kinds for every example-03 function, the emitted wasm carries the decodable carrier and verifies, and a double-free mutant of despawn_particle is REJECTED (the carrier has teeth). - ADR-0006: the Rust front-end is the canonical .twasm parser (owner direction 2026-07-07 — independence as a compile target); supersedes ADR-0004 section-2's 'no Rust parser' pin; AffineScript front-end demoted to reference; JSON-IR seam repurposed as the external-producer seam. - Stale '#127 / no in-process parser' comments + README matrix refreshed. Workspace: 142 tests, 0 failures. Co-Authored-By: Claude Fable 5 --- crates/typed-wasm-codegen/README.md | 5 +- crates/typed-wasm-codegen/src/lib.rs | 20 +-- crates/typed-wasm-codegen/src/parser.rs | 49 +++++- crates/typed-wasm-codegen/tests/corpus.rs | 8 +- crates/typed-wasm-codegen/tests/errors.rs | 4 +- crates/typed-wasm-codegen/tests/example03.rs | 148 +++++++++++++++++- .../typed-wasm-codegen/tests/optimization.rs | 2 +- .../decisions/0004-codegen-host-language.adoc | 14 +- .../0006-rust-frontend-canonical.adoc | 99 ++++++++++++ 9 files changed, 311 insertions(+), 38 deletions(-) create mode 100644 docs/decisions/0006-rust-frontend-canonical.adoc diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md index 9585227..29f67f0 100644 --- a/crates/typed-wasm-codegen/README.md +++ b/crates/typed-wasm-codegen/README.md @@ -1,4 +1,5 @@ + # typed-wasm-codegen The first in-tree `.twasm → .wasm` **producer** (codegen **v0**). @@ -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 | diff --git a/crates/typed-wasm-codegen/src/lib.rs b/crates/typed-wasm-codegen/src/lib.rs index 7b5b4f4..96cd8d3 100644 --- a/crates/typed-wasm-codegen/src/lib.rs +++ b/crates/typed-wasm-codegen/src/lib.rs @@ -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. @@ -318,9 +318,9 @@ pub struct Module { pub imports: Vec, pub funcs: Vec, /// 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)>, + /// param_kinds, ret_kind)`. Emitted as the `typedwasm.ownership` + /// carrier; empty = no L7/L10 carrier. + pub ownership: Vec<(usize, Vec, Ownership)>, } // ---------------------------------------------------------------------- @@ -464,10 +464,10 @@ pub fn emit(module: &Module) -> Vec { let ownership_entries: Vec = 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(); @@ -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)], } } diff --git a/crates/typed-wasm-codegen/src/parser.rs b/crates/typed-wasm-codegen/src/parser.rs index 3b676b2..c34e1fc 100644 --- a/crates/typed-wasm-codegen/src/parser.rs +++ b/crates/typed-wasm-codegen/src/parser.rs @@ -30,7 +30,7 @@ struct Parser<'a> { memory: Option, imports: Vec, funcs: Vec, - ownership: Vec<(usize, Vec)>, + ownership: Vec<(usize, Vec, crate::Ownership)>, } impl<'a> Parser<'a> { @@ -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)> = 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 = Vec::new(); loop { self.skip_whitespace(); if self.peek_char(')') { @@ -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). @@ -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(",")?; @@ -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, @@ -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, @@ -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` (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 @@ -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)) } } diff --git a/crates/typed-wasm-codegen/tests/corpus.rs b/crates/typed-wasm-codegen/tests/corpus.rs index b4358a7..2706924 100644 --- a/crates/typed-wasm-codegen/tests/corpus.rs +++ b/crates/typed-wasm-codegen/tests/corpus.rs @@ -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, @@ -618,7 +618,7 @@ fn one_func_module(kind: Ownership, body: Vec) -> Module { accesses: vec![], export: true, }], - ownership: vec![(0, vec![kind])], + ownership: vec![(0, vec![kind], Ownership::Unrestricted)], } } diff --git a/crates/typed-wasm-codegen/tests/errors.rs b/crates/typed-wasm-codegen/tests/errors.rs index 4d7e362..55f9b81 100644 --- a/crates/typed-wasm-codegen/tests/errors.rs +++ b/crates/typed-wasm-codegen/tests/errors.rs @@ -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] @@ -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"); diff --git a/crates/typed-wasm-codegen/tests/example03.rs b/crates/typed-wasm-codegen/tests/example03.rs index a573cec..8e7f226 100644 --- a/crates/typed-wasm-codegen/tests/example03.rs +++ b/crates/typed-wasm-codegen/tests/example03.rs @@ -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> { + 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`. + 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) — 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, 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: ®ion) — 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"), + } } diff --git a/crates/typed-wasm-codegen/tests/optimization.rs b/crates/typed-wasm-codegen/tests/optimization.rs index 1f8d388..8a79575 100644 --- a/crates/typed-wasm-codegen/tests/optimization.rs +++ b/crates/typed-wasm-codegen/tests/optimization.rs @@ -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. diff --git a/docs/decisions/0004-codegen-host-language.adoc b/docs/decisions/0004-codegen-host-language.adoc index 4a83c23..c532c8e 100644 --- a/docs/decisions/0004-codegen-host-language.adoc +++ b/docs/decisions/0004-codegen-host-language.adoc @@ -1,6 +1,6 @@ = Architecture Decision Record: 0004-codegen-host-language - + # 4. Codegen host language, integration point & IR threading @@ -8,13 +8,17 @@ Date: 2026-05-30 ## Status -Proposed +Accepted (§1, §3) / Superseded in §2 by +link:0006-rust-frontend-canonical.adoc[ADR-0006] This ADR is the decision gate for Phase 1 codegen (https://github.com/hyperpolymath/typed-wasm/issues/123[issue #123]). -It is **Proposed** pending owner ratification at PR review: committing -to Rust for the in-tree producer is a load-bearing choice and the -front-end → IR seam is deferred, so the decision is not yet Accepted. +§1 (Rust producer crate) and §3 (carrier set) are borne out by the +implemented `typed-wasm-codegen` crate. §2's "no Rust `.twasm` parser" +pin is superseded: per owner direction (2026-07-07, independence as a +compile target), the in-tree Rust parser is canonical — see +link:0006-rust-frontend-canonical.adoc[ADR-0006]; §2's JSON-IR seam is +repurposed there as the external-producer seam. ## Context diff --git a/docs/decisions/0006-rust-frontend-canonical.adoc b/docs/decisions/0006-rust-frontend-canonical.adoc new file mode 100644 index 0000000..4981b23 --- /dev/null +++ b/docs/decisions/0006-rust-frontend-canonical.adoc @@ -0,0 +1,99 @@ += Architecture Decision Record: 0006-rust-frontend-canonical + + + +# 6. The in-tree Rust front-end is the canonical `.twasm` parser + +Date: 2026-07-07 + +## Status + +Accepted + +Owner direction (2026-07-07 session): typed-wasm is to be realised as an +**independent compile target** — usable by AffineScript and Ephapax but +not dependent on either. This ADR records that direction where it bites +first: the front-end. + +Supersedes link:0004-codegen-host-language.adoc[ADR-0004] §2's pin that +"typed-wasm will **not** grow a second `.twasm` parser in Rust", and +retires the "stopgap until the AffineScript front-end" framing from the +issue-#127 thread. ADR-0004 §1 (Rust producer crate, `wasm-encoder`) and +§3 (carrier emission set) stand unchanged. + +## Context + +ADR-0004 §2 deferred the front-end → IR seam to a serialised JSON IR +emitted by the AffineScript checker (`src/parser/*.affine`, compiled to +`.mjs` by the `affinescript` toolchain per `affinescript.json`), and +explicitly ruled out a Rust parser as a duplicate-parser code smell. + +Reality moved past the pin. PR #165 landed a hand-written Rust parser +(`crates/typed-wasm-codegen/src/parser.rs`) as a paint-type stopgap; it +has since grown to parse **all six** canonical `examples/*.twasm` sources +end-to-end (`tests/corpus.rs` round-trips parse → emit → verify 6/6, +plus generated source text and never-panic totality sweeps), and now +records source ownership qualifiers into the `typedwasm.ownership` +carrier (`tests/example03.rs`). + +Meanwhile the independence goal makes the AffineScript-hosted front-end +a structural liability, not a destination: the standalone compile +target's own surface language must not require one of its producers' +toolchains to parse. The same reasoning already renamed the carrier +section producer-neutral (`affinescript.ownership` → `typedwasm.ownership`, +2026-05-26). + +## Decision + +1. **The Rust parser is the canonical `.twasm` front-end.** The + `source → parse_module → IR → emit → verify` pipeline in + `crates/typed-wasm-codegen` is the reference path for `.twasm`; + `tw build` is the reference CLI. Gaps against `spec/grammar.ebnf` + (full statement lowering, `if`/`else`, `region.scan` loops, L11/L12 + draft forms) are tracked as issues against this parser, not deferred + to an external front-end. +2. **The AffineScript front-end (`src/parser/*.affine`) is demoted to a + reference implementation** — kept for cross-checking and for the + AffineScript ecosystem's own use, no longer on typed-wasm's critical + path. `affinescript.json` stays, but nothing in build, test, or CI + may require the `affinescript` toolchain. +3. **The JSON-IR seam of ADR-0004 §2 is repurposed** as the *external + producer* seam: any language front-end (AffineScript, Ephapax, or a + third party) may hand typed-wasm a serialised IR instead of `.twasm` + source. It is an interoperability surface, not the front-end. + +## Consequences + +### Positive + +- typed-wasm builds, tests, and verifies with the Rust toolchain alone — + the independence property the compile-target vision requires. +- One language for parser + codegen + verifier keeps the closed-loop + round-trip corpus (parse → emit → verify, in-process) as the soundness + net around the whole pipeline. +- The "duplicate parser" smell of ADR-0004 inverts: the duplicate is now + the *AffineScript* parser, and divergence is caught by keeping it as a + cross-check reference rather than a dependency. + +### Negative + +- Two grammars to keep in sync until the `.affine` front-end is archived + or fully delegated to the AffineScript repo. +- The Rust parser must now carry the full weight of + `spec/grammar.ebnf` — its v0 shortcuts (stub bodies for non-lowerable + statements, simplified control flow) become tracked debt rather than + acceptable stopgap behaviour. + +### Neutral + +- Producer repos are unaffected: AffineScript and Ephapax keep emitting + carriers per ADR-0002/0003; the wire contract does not change. + +## Cross-references + +* link:0004-codegen-host-language.adoc[ADR-0004] — superseded in §2 only +* `crates/typed-wasm-codegen/src/parser.rs` — the canonical front-end +* `tests/corpus.rs`, `tests/example03.rs` — the 6/6 round-trip + + ownership-carrier evidence +* https://github.com/hyperpolymath/typed-wasm/issues/127[issue #127] — + the D1 thread whose "stopgap" framing this ADR retires