diff --git a/docs/plans/0115-mir.1b-codegen-consumes-mir.md b/docs/plans/0115-mir.1b-codegen-consumes-mir.md new file mode 100644 index 0000000..899f526 --- /dev/null +++ b/docs/plans/0115-mir.1b-codegen-consumes-mir.md @@ -0,0 +1,655 @@ +# mir.1b — codegen consumes MIR (the atomic switch) — Implementation Plan + +> **Parent spec:** `docs/specs/0060-typed-mir.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` +> skill to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Flip the entire codegen lowering walk from `&Term` to +`&MTerm`, delete `synth_with_extras` + `synth_arg_type` (the type +re-derivation mirror), switch their 9 call sites to read `MTerm::ty()`, +move the public `lower_workspace*` entry points to take `&MirWorkspace`, +and switch the CLI / test build paths to `elaborate_workspace` — so +codegen re-derives **no types**. The three other re-derivers +(`type_home_module`, `is_static_callee`, the 2nd `infer_module_with_cross`) +STAY for mir.1b; they leave in mir.2/mir.3. + +**Architecture:** This is the second half of spec iteration mir.1, +planned against the landed mir.1a infrastructure (commit 135f4ce). It +is the project's **largest single mechanical change** and it is +**atomic**: there is no compiling intermediate between "codegen walks +`&Term`" and "codegen walks `&MTerm`" (dual-path is spec-forbidden). +The recon proved this — flipping only `lower_term`'s signature yields +58 compiler errors cascading through `lib.rs` / `drop.rs` / +`match_lower.rs` / `escape.rs` / `lambda.rs`. The compiler is therefore +the totality checker for the mechanical arm-by-arm rename; this plan +gives a **correspondence rule** for the mechanical flips, **exact +before→after** for the judgement sites (the 9 synth sites, the +dual-source Emitter, the escape pointer-identity, the entry-point +signatures, the CLI diagnostics), and a build gate per task. + +**Tech Stack:** `ailang-mir` (one field added), `ailang-check` +(`lower_module` populates it), `ailang-codegen` (the bulk flip), +`crates/ail` (CLI build paths + e2e callers). + +--- + +## Design decisions baked into this plan (orchestrator, resolving recon's open questions) + +The recon (read-only) surfaced four design ambiguities. All four are +resolved here by orchestrator judgement, within the approved spec's +intent (the spec's MirModule sketch is illustrative; the spec mandates +removing the *re-derivers*, and exact MIR shape is the planner's to +fix). None is a user fork. + +- **OQ1 — Emitter dual-source → `MirModule` carries the AST module.** + The Emitter reads `self.module.defs` (AST) for `Def::Type` drop-fns, + `Def::Const`, intrinsic-`new` markers, and the pass-1 symbol tables + (`module_user_fns` / `module_def_ail_types` / `module_ctor_index` / + `module_consts`), but each fn **body** must now come from + `MirDef.body` (the typed `MTerm`). `MirModule` (landed mir.1a) carries + only fn bodies. **Resolution:** add one field — `MirModule.ast: + Module` — so codegen reads structural data from `.ast` and typed + bodies from `.defs`. `MirWorkspace` stays the **single** artefact + codegen consumes (it now *contains* the post-mono AST). This is + spec-consistent: structural module reads were never a re-derivation; + the spec only requires deleting the type/callee/mode/rep + re-derivers. Carrying the AST also resolves the intrinsic-fn gap: + `lower_module` skips intrinsic-bodied fns (no `MirDef`), so codegen + iterates `mir_module.ast.defs` (all fns, incl. intrinsic ones handled + via the intercept registry) and looks up the `MirDef` body by name + for the non-intrinsic ones. +- **OQ2 — escape pointer-identity over `MTerm`.** `escape::analyze_fn_body` + and `lower_term` key nodes by raw-pointer identity (`(t as *const + Term) as usize`). After the flip they key `*const MTerm`, and the + invariant is that **both passes walk the *same* borrowed `&MTerm` + tree** — `emit_fn` borrows `mir_def.body: &MTerm` **once** and passes + the same reference to `analyze_fn_body` and `lower_term`. A clone + between the passes would silently break identity. The pointer-cast + sites flip `*const Term` → `*const MTerm`; mechanical once the + same-tree borrow is stated. +- **OQ3 — `MTerm::New` stays `unreachable!` in codegen.** Exactly as + `Term::New`'s `lower_term` arm is `unreachable!` today: every `New` + node is desugared away before codegen (RawBuf → payload ops; + monomorphic user-ADT `new` → dotted `App`, per mir.1a's commit). So + `elaborate_workspace`'s desugar pass (unchanged) eliminates `New` + before `lower_to_mir`, and codegen never hits a live `MTerm::New`. + #51 keeps building via its desugared payload path — untouched by + mir.1b. (The element-type work that makes `New` "real" is mir.5.) +- **OQ4 — CLI diagnostics: drop the separate `check_workspace` call.** + `elaborate_workspace` re-runs `check_workspace` internally and returns + `Err(Vec)` on a type error. The `build` / `emit-ir` + build paths currently print diagnostics from their *own* + `check_workspace` call then `process::exit(1)`. **Resolution:** those + build paths drop their separate check call and let + `elaborate_workspace`'s `Err` drive the diagnostic print + exit(1) + (one check, not two). The `ail check` subcommand stays on + `check_workspace` (diagnostics-only, no build). +- **emit_ir keeps `&Module`, calls `elaborate_workspace` internally, + converts `Err(Vec)` → `CodegenError`.** Its in-source + test callers build hand-written single-module `Module`s using + *builtins* (`add`, `==`), which resolve from `env.globals` without a + prelude, so `check_workspace` passes; the one ill-typed test + (`eq` on an ADT, asserting `unwrap_err`) now gets its error from + `check` (`NoInstance`) instead of lowering — same outcome + (`unwrap_err` still holds). Any in-source `emit_ir` test that + references a *prelude module* fn (not a builtin) and so fails the + internal check is migrated to a `load_workspace` fixture — bounded, + implement-surfaced, local. + +**Files this plan creates or modifies:** + +- Modify: `crates/ailang-mir/src/lib.rs` — add `ast: Module` to `MirModule`. +- Modify: `crates/ailang-check/src/lower_to_mir.rs` — populate `ast`. +- Modify: `crates/ailang-codegen/Cargo.toml` — add `ailang-mir` dep. +- Modify: `crates/ailang-codegen/src/lib.rs` — the bulk flip + deletes + + entry-point signatures + emit_ir. +- Modify: `crates/ailang-codegen/src/drop.rs` — `&Term`→`&MTerm`, 4 synth sites. +- Modify: `crates/ailang-codegen/src/match_lower.rs` — `&Term`/`&[Term]`/`&[Arm]`→MIR, 3 synth sites. +- Modify: `crates/ailang-codegen/src/escape.rs` — analysis walkers `&Term`→`&MTerm`. +- Modify: `crates/ailang-codegen/src/lambda.rs` — `&Term`→`&MTerm`. +- Modify: `crates/ailang-codegen/tests/{embed_record_layout_pin,embed_staticlib_lowering,eq_primitives_pin}.rs` — callers build MIR. +- Modify: `crates/ail/src/main.rs` — build / emit-ir / staticlib paths → `elaborate_workspace`. +- Modify: `crates/ail/tests/e2e.rs` — 6 `lower_workspace_with_alloc` callers. + +--- + +## The Term → MTerm correspondence rule (used throughout Task 2) + +Every codegen `match` on a `Term` re-matches the `MTerm` counterpart. +The mapping is structural and 1:1 except for the four noted shifts. +**The build gate is the totality checker** — the compiler flags every +unconverted arm. Apply this rule at every match site the recon +enumerated; the judgement sites (synth reads, callee unwrap, escape +pointer, dual-source) get exact code in the steps below. + +| `Term` arm | `MTerm` arm | Shift | +|---|---|---| +| `Lit { lit }` | `Lit { lit, ty }` for non-Str; **`Literal::Str` → new `Str { lit, rep }` arm** | Str split (rep unused at mir.1b) | +| `Var { name }` | `Var { name, ty }` | — | +| `App { callee, args, tail }` | `App { callee, args, tail, ty }` | `callee: Callee` (unwrap `Indirect`), `args: Vec` | +| `Let { name, value, body }` | `Let { name, mode, init, body, ty }` | `value`→`init`; `mode` ignored (mir.3) | +| `If { cond, then, else_ }` | `If { cond, then, else_, ty }` | children `Box` | +| `Do { op, args, tail }` | `Do { op, args, tail, ty }` | `args: Vec` | +| `Ctor { type_name, ctor, args }` | `Ctor { type_name, ctor, args, ty }` | `args: Vec` | +| `Match { scrutinee, arms }` | `Match { scrutinee, arms, ty }` | `arms: Vec` (`MArm.pat` reuses `ast::Pattern`) | +| `Lam { … }` | `Lam { …, ty }` | `ret_ty: Type` (not `Box`), body `Box` | +| `Seq { lhs, rhs }` | `Seq { lhs, rhs, ty }` | — | +| `Clone { value }` | `Clone { value, ty }` | — | +| `ReuseAs { source, body }` | `ReuseAs { source, body, ty }` | inner `MTerm::Ctor` destructure | +| `Loop { binders, body }` | `Loop { binders, body, ty }` | `binders: Vec` (`.init` is `MTerm`, not `Box`) | +| `Recur { args }` | `Recur { args, ty }` | `args: Vec` | +| `New { … }` | `New { … }` | arm stays `unreachable!` (OQ3) | +| `Intrinsic` | `Intrinsic { ty }` | unit → struct variant | + +**Arg access:** wherever an arm reads `&args[i]` (a `&Term`), it now +reads `&args[i].term` (the `MTerm` inside the `MArg`). Wherever it read +`self.synth_arg_type(&args[i])`, it reads `args[i].term.ty()`. +**Callee access:** `App`'s `callee` is `Callee::Indirect(Box)` +at mir.1b; unwrap it to reach the inner `MTerm` (see Task 2 Step 4). +**Imports:** each flipped file adds `use ailang_mir::{MTerm, MArg, +MArm, MLoopBinder, Callee, …}` and keeps `use ailang_core::ast::{…, +Pattern, Type, Literal}` (patterns/literals/types are unchanged). + +--- + +## Task 1: `MirModule.ast` + codegen's `ailang-mir` dep (additive, green) + +This task is additive — it extends a landed type and wires a dep; +nothing consumes the new field yet, so the whole suite stays green. + +**Files:** +- Modify: `crates/ailang-mir/src/lib.rs` +- Modify: `crates/ailang-check/src/lower_to_mir.rs` +- Modify: `crates/ailang-codegen/Cargo.toml` + +- [ ] **Step 1: Add `ast: Module` to `MirModule`** + +In `crates/ailang-mir/src/lib.rs`, extend the import and the struct: + +```rust +use ailang_core::ast::{Literal, Module, Pattern, Type}; +``` +```rust +#[derive(Debug, Clone)] +pub struct MirModule { + pub name: String, + /// The post-mono AST module. codegen reads structural data + /// (Def::Type / Def::Const / ctor layouts / intrinsic-fn markers / + /// imports) from here; the typed fn bodies come from `defs`. This + /// keeps `MirWorkspace` the single artefact codegen consumes — + /// structural reads were never a re-derivation, so carrying the AST + /// alongside the typed bodies does not reintroduce one. + pub ast: Module, + pub defs: Vec, +} +``` + +- [ ] **Step 2: Populate `ast` in `lower_module`** + +In `crates/ailang-check/src/lower_to_mir.rs`, the `lower_module` return +already builds `MirModule { name, defs }` — add the `ast` field with +the post-mono module clone: + +```rust + Ok(MirModule { name: module.name.clone(), ast: module.clone(), defs }) +} +``` + +- [ ] **Step 3: Add the `ailang-mir` dependency to codegen** + +In `crates/ailang-codegen/Cargo.toml`, under `[dependencies]` (after +`ailang-core.workspace = true`), add: + +```toml +ailang-mir.workspace = true +``` + +(No cycle: codegen → check → mir → core; `ailang-mir` is a leaf. This +edge lets codegen name `MTerm` / `MArg` / `Callee` / `MirWorkspace` +directly rather than only transitively.) + +- [ ] **Step 4: Build gate — additive, nothing breaks** + +Run: `cargo test --workspace` +Expected: PASS — whole suite green (the `ast` field is additive; +`lower_module` populates it; the mir.1a `lower_to_mir_ty` pins still +pass; codegen does not yet read `ast`). This confirms the foundation +before the atomic flip. + +--- + +## Task 2: the atomic codegen flip (lib + all codegen tests) + +The whole of codegen's lowering walk flips `&Term` → `&MTerm` in one +task — there is no compiling intermediate. Apply the correspondence +rule (above) at every `Term::*` match the recon enumerated; the steps +below give exact code for the judgement sites. The task's build gate +(`cargo test -p ailang-codegen --no-run`) is the totality checker: +it does not go green until every arm, signature, and caller inside the +codegen crate (lib + in-source tests + `tests/`) is converted. + +**Files:** +- Modify: `crates/ailang-codegen/src/lib.rs` +- Modify: `crates/ailang-codegen/src/drop.rs` +- Modify: `crates/ailang-codegen/src/match_lower.rs` +- Modify: `crates/ailang-codegen/src/escape.rs` +- Modify: `crates/ailang-codegen/src/lambda.rs` +- Modify: `crates/ailang-codegen/tests/{embed_record_layout_pin,embed_staticlib_lowering,eq_primitives_pin}.rs` + +- [ ] **Step 1: Flip the entry-point signatures + `lower_workspace_inner` pairing** + +In `crates/ailang-codegen/src/lib.rs`, change the five entry points +to take `&MirWorkspace`: + +```rust +pub fn lower_workspace_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result { + lower_workspace_inner(mir, alloc, Target::Executable) +} +pub fn lower_workspace_staticlib_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result { + lower_workspace_inner(mir, alloc, Target::StaticLib) +} +pub fn lower_workspace(mir: &MirWorkspace) -> Result { + lower_workspace_inner(mir, AllocStrategy::Rc, Target::Executable) +} +pub fn lower_workspace_staticlib(mir: &MirWorkspace) -> Result { + lower_workspace_inner(mir, AllocStrategy::Rc, Target::StaticLib) +} +fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Target) -> Result { + // … +} +``` + +In `lower_workspace_inner`: the desugar block (lib.rs:295-313) is +**deleted** — `elaborate_workspace` already desugared + lifted + mono'd +before producing the MIR. The pass-1 symbol tables (lib.rs:333-396) +now iterate `mir.modules` and read each `mir_module.ast.defs` (AST +structure) instead of `ws.modules`'s `m.defs`. The pass-2 per-module +`Emitter` construction (lib.rs:400-455) hands the Emitter **both** +`&mir_module.ast` (for structural reads) and `&mir_module.defs` (for +bodies) — see Step 2. The entry-`main` check (lib.rs:460-472) reads +`mir.modules[&mir.entry].ast` for the `main` signature. + +> **Implementer note.** Read `lower_workspace_inner`'s current body +> (lib.rs:294-472) and re-point every `ws`/`m.defs` read at +> `mir`/`mir_module.ast.defs`. The structure of the two passes is +> unchanged — only the source of each module's defs moves from the raw +> `Workspace` to `mir_module.ast`, and a second source (`mir_module.defs`) +> is added for bodies. + +- [ ] **Step 2: Emitter holds AST module + MIR bodies; `emit_module` / `emit_fn` pairing** + +The `Emitter` (lib.rs:730) currently holds `module: &Module`. Keep +that (it now points at `mir_module.ast`) and add a body source — a map +or slice of the module's `MirDef`s. Minimal shape: store +`mir_bodies: &'a [MirDef]` (from `mir_module.defs`) on the Emitter, and +have `emit_module` look up each fn's body by name. + +`emit_module` (lib.rs:1054) iterates `self.module.defs` (AST) as today +for `Def::Type` / `Def::Const` / intrinsic markers. For each +`Def::Fn(f)`: +- If `f` is intrinsic-bodied (`matches!(f.body, Term::Intrinsic)` — + reads the AST `FnDef`, unchanged): handle via the intercept registry + as today; no MIR body. +- Else: look up the `MirDef` whose `name == f.name` in `self.mir_bodies`, + and call `emit_fn(f, &mir_def.body)` — `f` (AST `FnDef`) supplies + sig / params / export / the intrinsic marker; `&mir_def.body` + (`&MTerm`) supplies the typed body. + +`emit_fn` (lib.rs:1265) signature gains the body: `fn emit_fn(&mut +self, f: &FnDef, body: &MTerm)`. Inside: +- The param-bind loop (lib.rs:1344) that pushed the AST param `Type` + into `locals`' 4th slot stays — param types come from `f.ty` via + `crate::fn_param_types`-equivalent (the AST signature), unchanged. +- `escape::analyze_fn_body(body)` (was `&f.body`) and + `self.lower_term(body)` (was `&f.body`) both take the **same** + borrowed `&MTerm` `body` (OQ2 — one borrow, shared, no clone). +- The intrinsic early-return (`matches!(f.body, Term::Intrinsic)`, + lib.rs:1394/1126) still reads the AST `f.body` — it is a + signature-only marker; unchanged. + +> **Implementer note.** The exact Emitter field plumbing (lifetime on +> `mir_bodies`, how `emit_module` threads it) is yours to wire against +> the landed Emitter struct; the contract is: structural reads off +> `self.module` (= `mir_module.ast`), body off the matched +> `MirDef.body`, one `&MTerm` borrow shared between escape + lowering. + +- [ ] **Step 3: Flip `lower_term` (lib.rs:1604) per the correspondence rule** + +`fn lower_term(&mut self, t: &MTerm) -> Result<(String, String)>`. +Re-match every arm per the correspondence table. Children that were +`&Box` are now `&Box` (same `self.lower_term(child)` +call); arg lists that were `&[Term]` are `&[MArg]` (`self.lower_term(&arg.term)`). +Two arms need explicit handling: + +- **`Lit` split.** Replace the single `Lit` arm with two: + ```rust + MTerm::Lit { lit, .. } => { /* the existing non-Str literal lowering, unchanged */ } + MTerm::Str { lit, .. } => { /* the existing Literal::Str lowering body, lifted out of the old Lit arm */ } + ``` + (The old `Term::Lit` arm's `Literal::Str` branch becomes the `Str` + arm's body; the rep field is ignored at mir.1b.) +- **`Let` synth read (the binder type).** At lib.rs:1725, replace: + ```rust + let val_ail = self.synth_arg_type(value)?; + ``` + with: + ```rust + let val_ail = value.ty(); + ``` + (`value` is the `&Box` init; `MTerm::ty()` returns the carried + `Type` directly — no `?`.) The 4th `locals` slot (lib.rs:1727) is now + sourced from this. The inherited-mode `Term::Var` read (lib.rs:1742) + becomes `MTerm::Var`. + +- [ ] **Step 4: Flip `lower_app` (lib.rs:2340) — callee unwrap + the 2 arith synth reads** + +`lower_app`'s `args: &[Term]` → `&[MArg]`; every `self.lower_term(&args[i])` +→ `self.lower_term(&args[i].term)`. The callee/name resolution +(`is_static_callee`, `type_home_module`) takes a `&str` name and is +**unchanged** (stays for mir.2). The `App` arm's static-dispatch test +(lib.rs:1918-1927) that currently reads `if let Term::Var { name } = +callee.as_ref()` now unwraps the `Callee`: + +```rust +// callee is Callee::Indirect(Box) at mir.1b; the static name, +// if any, is the inner MTerm::Var. (mir.2 replaces this with +// Callee::Static and deletes is_static_callee.) +let callee_mterm: &MTerm = match callee { + Callee::Indirect(inner) => inner.as_ref(), + Callee::Static { .. } => unreachable!("callee is Indirect until mir.2"), +}; +if let MTerm::Var { name, .. } = callee_mterm { + // … existing static-dispatch path on `name`, unchanged … +} +``` + +The two arithmetic/`neg` arg-type reads — lib.rs:2359 and lib.rs:2394: +```rust +let arg_ty = self.synth_arg_type(&args[0])?; +``` +both become: +```rust +let arg_ty = args[0].term.ty(); +``` +(then the existing `builtin_binop_typed(name, &arg_ty)` / Int-vs-Float +match on `arg_ty` is unchanged). + +> **Implementer note.** `lower_app` may be reached both from the +> `MTerm::App` arm of `lower_term` (callee + args already split) and as +> a helper. Confirm how the `App` arm calls `lower_app` (it currently +> destructures `Term::App { callee, args, tail }` then dispatches on the +> callee name); thread the `Callee` unwrap so `lower_app` still gets a +> `&str` name and `&[MArg]` args. `emit_call` (lib.rs:2625), +> `emit_indirect_call` (lib.rs:2724), `lower_effect_op` (lib.rs:2972) +> all take `args: &[Term]` → `&[MArg]`; their `args.iter()` / +> `.zip(...)` read `.term`. + +- [ ] **Step 5: Delete `synth_with_extras` + `synth_arg_type`** + +Delete `synth_arg_type` (lib.rs:3238-3240) and `synth_with_extras` +(lib.rs:3242-3519) entirely (~280 lines). Their internal calls to +`type_home_module` / `collect_owner_local_types` vanish with them +(those helpers stay — used elsewhere). The `module_def_ail_types` +reads *inside* `synth_with_extras` (lib.rs:3292/3301/3321) vanish; the +field itself stays (read at lib.rs:2687 for mir.3 mode work) and so do +its other readers. + +Verify the field `module_def_ail_types` (lib.rs:759) has no remaining +reader that was only `synth_with_extras` — the `:2687` read (in +`emit_call`'s borrow-drop) stays. (If, after the delete, the field has +*zero* readers, leave it — mir.3 needs it; a `#[allow(dead_code)]` is +acceptable rather than removing and re-adding it. Confirm the `:2687` +reader survives.) + +- [ ] **Step 6: Flip `drop.rs` (4 synth sites + signatures)** + +Apply the correspondence rule to `drop.rs`'s walkers and rewrite the 4 +synth reads: +- `is_rc_heap_allocated` (`:457`, `value: &Term` → `&MTerm`); arms + `:462/466/478` re-match `MTerm`; the `Loop` arm at `:487`: + ```rust + match self.synth_arg_type(value) { + Ok(t) => { /* is_ptr && !is_str on t */ } + Err(_) => false, + } + ``` + becomes (no `Result`): + ```rust + let t = value.ty(); + let is_ptr = matches!(crate::synth::llvm_type(&t).as_deref(), Ok("ptr")); + let is_str = matches!(&t, Type::Con { name, .. } if name == "Str"); + is_ptr && !is_str + ``` +- `synth_callee_ret_mode` (`:513`, `callee: &Term` → `&MTerm`): `:514` + `let cty = self.synth_arg_type(callee).ok()?;` → `let cty = + callee.ty();` (and the `match cty { Type::Fn { ret_mode, .. } => + Some(ret_mode), _ => None }` stays). +- `drop_symbol_for_binder` (`:533`, `value: &Term` → `&MTerm`); arms + `:535/546/560` re-match `MTerm`; `:561` `if let Ok(Type::Con { name, + .. }) = self.synth_arg_type(value)` → `if let Type::Con { name, .. } + = value.ty()`. +- `emit_inlined_partial_drop` (`:844`, `value: &Term` → `&MTerm`); arm + `:851` re-match; `:862` + `self.synth_arg_type(value).ok().and_then(|ty| …)` → + `self.partial_drop_symbol_for_type(&value.ty())` (wrap to match the + surrounding `if let (Some(sym), Some(mask))` shape — `value.ty()` + is infallible, so the `.ok()` disappears). +- `:463` `term_ptr` identity cast `*const Term` → `*const MTerm`. + +- [ ] **Step 7: Flip `match_lower.rs` (3 synth sites + signatures)** + +- `lower_ctor` (`:42`, `args: &[Term]` → `&[MArg]`): `:82` + `.map(|a| self.synth_arg_type(a)).collect::>()?` → + `.map(|a| a.term.ty()).collect::>()` (no `Result`); `:98/99` + lower `a.term`. +- `lower_reuse_as_rc` (`:179`, `source: &Term` → `&MTerm`, `body_args: + &[Term]` → `&[MArg]`): `:201` `Term::Var` → `MTerm::Var`; `:263` + same `.map(|a| a.term.ty())`; `:288` lower `a.term`. +- `lower_match` (`:440`, `scrutinee: &Term` → `&MTerm`, `arms: &[Arm]` + → `&[MArm]`): `:445` `let s_ail = self.synth_arg_type(scrutinee)?;` + → `let s_ail = scrutinee.ty();`; `:459/460` `Term::Var` → `MTerm::Var`; + `:476/477` the `Vec<(CtorRef, &Arm, …)>` → `&MArm`; `:480` `arm.pat` + and `:481/484/488` `Pattern::*` are **unchanged** (`MArm.pat` reuses + `ast::Pattern`); `arm.body` is now `MTerm`. + +- [ ] **Step 8: Flip `escape.rs` + `lambda.rs` (pure walkers)** + +`escape.rs`: `analyze_fn_body` (`:97`), `walk` (`:110`), `escapes` +(`:227`), `collect_free_vars` (`:446`) flip `&Term` → `&MTerm`; all +`Term::*` arms re-match `MTerm::*` (the `Str` literal becomes its own +arm); `pattern_bound_names` (`:430`, `&Pattern`) is **unchanged**. +Import (`:83`): add `use ailang_mir::MTerm;` (keep `Pattern`/`NewArg` +from `ast`). The `#[cfg(test)]` `ctor(...) -> Term` helper (`:574`): +if the escape unit tests build `Term` directly, either flip the helper +to build `MTerm` or keep the tests AST-only by testing a thin `MTerm` +wrapper — implementer judgement; the gate is that `escape`'s tests +compile and pass. + +`lambda.rs`: `lower_lambda` (`:39`, `lam_body: &Term` → `&MTerm`; +`:222` calls `lower_term`), and the thunk-body walker / `collect_captures` +(`:373`, `t: &Term` → `&MTerm`); `pattern_bound_names` (`:509`, +`&Pattern`) unchanged. + +- [ ] **Step 9: Flip `emit_ir` (lib.rs:213) — build MIR internally** + +`emit_ir` keeps `&Module`. Replace its body's terminal +`lower_workspace(&ws)` (lib.rs:230) with an `elaborate_workspace` call, +converting the diagnostics error to a `CodegenError`: + +```rust +pub fn emit_ir(m: &Module) -> Result { + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), + }; + let mir = ailang_check::elaborate_workspace(&ws) + .map_err(|diags| CodegenError::Internal(format!( + "elaborate failed: {}", + diags.iter().map(|d| d.message.clone()).collect::>().join("; ") + )))?; + lower_workspace(&mir) +} +``` + +> **Implementer note.** Confirm `CodegenError::Internal(String)` is the +> right variant (grep the enum); confirm `Diagnostic` exposes `.message` +> (else use its `Display`). `ailang_check` is a direct dep of codegen. +> The two in-source `#[cfg(test)]` `lower_workspace_with_alloc` callers +> (lib.rs:3709/3728) build a `ws` via test helpers — flip them to +> `ailang_check::elaborate_workspace(&ws)` then pass `&mir`. The +> ill-typed `emit_ir` test (`eq` on ADT, `unwrap_err`) now errors from +> `check` inside `elaborate` — `unwrap_err` still holds. + +- [ ] **Step 10: Flip codegen's `tests/` integration callers** + +- `eq_primitives_pin.rs:30` `lower_workspace(&ws)` — it already runs + `check_workspace` + `monomorphise_workspace` (`:23-29`); collapse + that block to `let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates"); lower_workspace(&mir)`. +- `embed_record_layout_pin.rs:28` `lower_workspace_with_alloc(&ws, Rc)` + and `embed_staticlib_lowering.rs:23/58/78` + (`lower_workspace_staticlib_with_alloc` ×2, `lower_workspace_with_alloc`) + — these do not yet import `ailang_check`; add + `use ailang_check::elaborate_workspace;`, build `let mir = + elaborate_workspace(&ws).expect("elaborates");` from the + `load_workspace` `ws`, and pass `&mir` to the lowering call. (`ailang-check` + is reachable from codegen's own integration tests — confirmed by + `eq_primitives_pin.rs:9`'s existing `use ailang_check::…`.) + +- [ ] **Step 11: Build gate — codegen crate fully converted** + +Run: `cargo test -p ailang-codegen 2>&1 | tail -20` +Expected: PASS — codegen lib + in-source tests + `tests/` integration +all compile and pass. The `synth_with_extras` / `synth_arg_type` +deletion is grep-clean within codegen: + +Run: `git grep -nE 'synth_with_extras|synth_arg_type' crates/ailang-codegen/src/` +Expected: no matches (only possibly a doc-comment mention to reword — +if any remain in prose, reword them; the recon found module/fn +doc-comments naming `synth_arg_type` at match_lower.rs:18, +subst.rs:75, synth.rs:60/204, drop.rs:856 — reword these to describe +the `MTerm::ty()` read). + +--- + +## Task 3: external callers (CLI build paths + e2e) + +The entry-point signature change to `&MirWorkspace` breaks the callers +in `crates/ail`. Thread them all here; the gate is the full workspace. + +**Files:** +- Modify: `crates/ail/src/main.rs` +- Modify: `crates/ail/tests/e2e.rs` + +- [ ] **Step 1: CLI `build` path → `elaborate_workspace` (+ OQ4 diagnostics)** + +In `crates/ail/src/main.rs`, the `build_to` fn (`:2286+`): replace the +block that runs `check_workspace` (`:2293`) + per-module desugar/lift +(`:2320-2330`) + `monomorphise_workspace` (`:2346`) + +`lower_workspace_with_alloc(&ws, alloc)` (`:2348`) with: + +```rust +let mir = match ailang_check::elaborate_workspace(&ws) { + Ok(mir) => mir, + Err(diags) => { + for d in &diags { + eprintln!("{}: [{}] {}", /* severity */ d.severity_str(), d.code, d.message); + } + std::process::exit(1); + } +}; +let ir = ailang_codegen::lower_workspace_with_alloc(&mir, alloc)?; +``` + +> **Implementer note.** Match the existing diagnostic-print format used +> by the `ail check` human path (main.rs ~:611-620) — reuse the same +> `eprintln!` shape (severity / code / message / span) so build-time +> and check-time diagnostics read identically. Drop `build_to`'s own +> separate `check_workspace` call (OQ4: `elaborate_workspace` runs it +> internally). The `has_export` gate and any read of `ws.modules…f.export` +> stays on the **pre-elaborate AST `ws`** (it is structural, not in MIR +> at the CLI level) — confirm it reads `ws`, not `mir`. + +- [ ] **Step 2: CLI `staticlib` build path → `elaborate_workspace`** + +`build_staticlib` (`:2456+`): same shape — replace its `check_workspace` +(`:2464`) + lift (`:2482-2488`) + `monomorphise_workspace` (`:2495`) + +`lower_workspace_staticlib_with_alloc(&ws, alloc)` (`:2512`) with an +`elaborate_workspace` call + `lower_workspace_staticlib_with_alloc(&mir, +alloc)`. The `has_export` check (`:2499-2505`) stays on the AST `ws`. + +- [ ] **Step 3: CLI `emit-ir` path → `elaborate_workspace`** + +`Cmd::EmitIr` arm (`:647+`): it currently prints diagnostics from its +own `check_workspace` (`:651`) then lifts/monos (`:682-695`) then calls +`lower_workspace` / `lower_workspace_staticlib` (`:705/707`). Replace +with `elaborate_workspace`, driving diagnostics from its `Err` (OQ4): + +```rust +let mir = match ailang_check::elaborate_workspace(&ws) { + Ok(mir) => mir, + Err(diags) => { /* print diags as above */ std::process::exit(1); } +}; +let ir = if emit == "staticlib" { + ailang_codegen::lower_workspace_staticlib(&mir)? +} else { + ailang_codegen::lower_workspace(&mir)? +}; +``` + +The `has_export` gate (`:697-704`) stays on the AST `ws`. + +- [ ] **Step 4: e2e.rs — 6 `lower_workspace_with_alloc` callers** + +In `crates/ail/tests/e2e.rs`, each of the 6 callers (`:1537/1708/1782/1871/1994/2084`) +is preceded by its own load + lift + mono block. For each: replace the +lift/mono block with `let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");` +and pass `&mir` to `lower_workspace_with_alloc(&mir, AllocStrategy::Rc)`. + +> **Implementer note.** Read each caller's preceding block (e.g. +> `:1517-1536` for the first) and collapse its explicit +> desugar/lift/mono into the single `elaborate_workspace` call. The +> `ws` source (a `load_workspace` or hand-built workspace) stays; only +> the lift→mono→lower bridge changes. + +- [ ] **Step 5: Full workspace gate** + +Run: `cargo test --workspace 2>&1 | grep -E 'test result:|error\[|FAILED' | grep -v 'ok\.' | sort | uniq -c` +Expected: no `error[` / no `FAILED` lines. + +Run: `cargo test --workspace 2>&1 | grep -c 'test result: ok'` +Expected: the same count as before mir.1b (102) — every existing test +still green, the #49 pin still the single `ignored` (lifts at mir.4). + +- [ ] **Step 6: Acceptance — #51/#53 still build + run; synth grep-clean** + +Run (the two regression witnesses build and run unchanged): +`cargo run -q -p ail -- run examples/new_counter_user_adt.ail` +Expected: prints `ok` (exit 0). +Run: `cargo run -q -p ail -- run examples/new_rawbuf_size_only.ail` +Expected: builds and runs (prints the size; exit 0). + +Run (no type re-derivation left in codegen): +`git grep -nE 'synth_with_extras|synth_arg_type' crates/ailang-codegen/src/` +Expected: no matches. + +--- + +## Notes for the orchestrator (commit + handoff) + +- **Commit shape:** one cohesive commit — the atomic switch is one + logical change. Subject e.g. `feat(codegen): mir.1b — codegen + consumes MIR; delete the synth_with_extras type-derivation mirror`. + Body: the boundary now reads `ty` off MIR (one engine, not two); the + three other re-derivers stay (mir.2/mir.3); MirModule carries the AST + for structural reads (single artefact preserved); `refs #49` (closure + at mir.4). +- **Spec mir.1 row is now complete** across mir.1a + mir.1b: structural + MIR + `ty` landed; `synth_with_extras` + `synth_arg_type` gone. +- **Next (mir.2):** `Callee::Static` pre-resolved; delete + `type_home_module` ×3 + `is_static_callee`; strike the `lower_app ↔ + is_static_callee` lockstep pair from CLAUDE.md.