Second half of spec iteration mir.1, planned against the landed mir.1a
infrastructure (135f4ce). This is the project's largest single
mechanical change and it is atomic: codegen's entire lowering walk
flips from &Term to &MTerm with no compiling intermediate (dual-path is
spec-forbidden). plan-recon proved the blast radius with a compile stub
(flipping only lower_term's signature → 58 cascading errors across
lib.rs/drop.rs/match_lower.rs/escape.rs/lambda.rs, baseline restored
green). The plan gives a Term→MTerm correspondence rule for the
mechanical arm renames (the build gate is the totality checker), exact
before→after for the judgement sites (the 9 synth_arg_type reads → ty(),
the dual-source Emitter, escape pointer-identity, entry-point
signatures, CLI diagnostics), and a satisfiable gate per task.
Four recon open-questions resolved by orchestrator judgement (within
the approved spec's intent; the spec's MirModule sketch is illustrative
and exact shape is the planner's):
- OQ1: MirModule gains `ast: Module` — codegen reads structural data
(Type/Const/ctor/intrinsic markers/symbol tables) from .ast, typed fn
bodies from .defs. MirWorkspace stays the single artefact (it now
contains the post-mono AST); structural reads were never a
re-derivation, so this does not reintroduce one. Also resolves the
intrinsic-fn gap (lower_module skips intrinsic bodies; codegen
iterates ast.defs and looks up MirDef bodies by name).
- OQ2: emit_fn borrows mir_def.body:&MTerm once and shares it between
escape::analyze_fn_body and lower_term (pointer identity is only
stable within one borrowed tree); casts flip to *const MTerm.
- OQ3: MTerm::New's codegen arm stays unreachable! — New is desugared
away before codegen (RawBuf→payload, monomorphic user-ADT new→dotted
App), exactly as Term::New is today; #51 builds unchanged.
- OQ4: the CLI build/emit-ir paths drop their separate check_workspace
call and let elaborate_workspace's Err drive diagnostics (one check,
not two); `ail check` stays on check_workspace.
- emit_ir keeps &Module, calls elaborate_workspace internally,
converts Err(diags)→CodegenError; its builtin-only in-source tests
check clean without a prelude.
Task structure: Task 1 (additive) extends MirModule + populates ast +
adds codegen's ailang-mir dep, gate cargo test --workspace. Task 2 (the
atomic flip) converts the whole codegen crate including its tests, gate
cargo test -p ailang-codegen (deliberately excludes crates/ail so the
not-yet-threaded external callers don't fail this gate). Task 3 threads
the crates/ail callers (CLI build/emit-ir/staticlib + 6 e2e callers),
gate cargo test --workspace == 102 ok (the mir.1a baseline), #51/#53
build+run, synth_with_extras/synth_arg_type grep-clean.
refs #49
31 KiB
mir.1b — codegen consumes MIR (the atomic switch) — Implementation Plan
Parent spec:
docs/specs/0060-typed-mir.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill 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 →
MirModulecarries the AST module. The Emitter readsself.module.defs(AST) forDef::Typedrop-fns,Def::Const, intrinsic-newmarkers, 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 fromMirDef.body(the typedMTerm).MirModule(landed mir.1a) carries only fn bodies. Resolution: add one field —MirModule.ast: Module— so codegen reads structural data from.astand typed bodies from.defs.MirWorkspacestays 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_moduleskips intrinsic-bodied fns (noMirDef), so codegen iteratesmir_module.ast.defs(all fns, incl. intrinsic ones handled via the intercept registry) and looks up theMirDefbody by name for the non-intrinsic ones. - OQ2 — escape pointer-identity over
MTerm.escape::analyze_fn_bodyandlower_termkey 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&MTermtree —emit_fnborrowsmir_def.body: &MTermonce and passes the same reference toanalyze_fn_bodyandlower_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::Newstaysunreachable!in codegen. Exactly asTerm::New'slower_termarm isunreachable!today: everyNewnode is desugared away before codegen (RawBuf → payload ops; monomorphic user-ADTnew→ dottedApp, per mir.1a's commit). Soelaborate_workspace's desugar pass (unchanged) eliminatesNewbeforelower_to_mir, and codegen never hits a liveMTerm::New. #51 keeps building via its desugared payload path — untouched by mir.1b. (The element-type work that makesNew"real" is mir.5.) - OQ4 — CLI diagnostics: drop the separate
check_workspacecall.elaborate_workspacere-runscheck_workspaceinternally and returnsErr(Vec<Diagnostic>)on a type error. Thebuild/emit-irbuild paths currently print diagnostics from their owncheck_workspacecall thenprocess::exit(1). Resolution: those build paths drop their separate check call and letelaborate_workspace'sErrdrive the diagnostic print + exit(1) (one check, not two). Theail checksubcommand stays oncheck_workspace(diagnostics-only, no build). - emit_ir keeps
&Module, callselaborate_workspaceinternally, convertsErr(Vec<Diagnostic>)→CodegenError. Its in-source test callers build hand-written single-moduleModules using builtins (add,==), which resolve fromenv.globalswithout a prelude, socheck_workspacepasses; the one ill-typed test (eqon an ADT, assertingunwrap_err) now gets its error fromcheck(NoInstance) instead of lowering — same outcome (unwrap_errstill holds). Any in-sourceemit_irtest that references a prelude module fn (not a builtin) and so fails the internal check is migrated to aload_workspacefixture — bounded, implement-surfaced, local.
Files this plan creates or modifies:
- Modify:
crates/ailang-mir/src/lib.rs— addast: ModuletoMirModule. - Modify:
crates/ailang-check/src/lower_to_mir.rs— populateast. - Modify:
crates/ailang-codegen/Cargo.toml— addailang-mirdep. - 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— 6lower_workspace_with_alloccallers.
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<MArg> |
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<MTerm> |
Do { op, args, tail } |
Do { op, args, tail, ty } |
args: Vec<MArg> |
Ctor { type_name, ctor, args } |
Ctor { type_name, ctor, args, ty } |
args: Vec<MArg> |
Match { scrutinee, arms } |
Match { scrutinee, arms, ty } |
arms: Vec<MArm> (MArm.pat reuses ast::Pattern) |
Lam { … } |
Lam { …, ty } |
ret_ty: Type (not Box), body Box<MTerm> |
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<MLoopBinder> (.init is MTerm, not Box) |
Recur { args } |
Recur { args, ty } |
args: Vec<MArg> |
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<MTerm>)
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: ModuletoMirModule
In crates/ailang-mir/src/lib.rs, extend the import and the struct:
use ailang_core::ast::{Literal, Module, Pattern, Type};
#[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<MirDef>,
}
- Step 2: Populate
astinlower_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:
Ok(MirModule { name: module.name.clone(), ast: module.clone(), defs })
}
- Step 3: Add the
ailang-mirdependency to codegen
In crates/ailang-codegen/Cargo.toml, under [dependencies] (after
ailang-core.workspace = true), add:
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_innerpairing
In crates/ailang-codegen/src/lib.rs, change the five entry points
to take &MirWorkspace:
pub fn lower_workspace_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result<String> {
lower_workspace_inner(mir, alloc, Target::Executable)
}
pub fn lower_workspace_staticlib_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result<String> {
lower_workspace_inner(mir, alloc, Target::StaticLib)
}
pub fn lower_workspace(mir: &MirWorkspace) -> Result<String> {
lower_workspace_inner(mir, AllocStrategy::Rc, Target::Executable)
}
pub fn lower_workspace_staticlib(mir: &MirWorkspace) -> Result<String> {
lower_workspace_inner(mir, AllocStrategy::Rc, Target::StaticLib)
}
fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Target) -> Result<String> {
// …
}
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 everyws/m.defsread atmir/mir_module.ast.defs. The structure of the two passes is unchanged — only the source of each module's defs moves from the rawWorkspacetomir_module.ast, and a second source (mir_module.defs) is added for bodies.
- Step 2: Emitter holds AST module + MIR bodies;
emit_module/emit_fnpairing
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 MirDefs. 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
fis intrinsic-bodied (matches!(f.body, Term::Intrinsic)— reads the ASTFnDef, unchanged): handle via the intercept registry as today; no MIR body. - Else: look up the
MirDefwhosename == f.nameinself.mir_bodies, and callemit_fn(f, &mir_def.body)—f(ASTFnDef) 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
Typeintolocals' 4th slot stays — param types come fromf.tyviacrate::fn_param_types-equivalent (the AST signature), unchanged. escape::analyze_fn_body(body)(was&f.body) andself.lower_term(body)(was&f.body) both take the same borrowed&MTermbody(OQ2 — one borrow, shared, no clone).- The intrinsic early-return (
matches!(f.body, Term::Intrinsic), lib.rs:1394/1126) still reads the ASTf.body— it is a signature-only marker; unchanged.
Implementer note. The exact Emitter field plumbing (lifetime on
mir_bodies, howemit_modulethreads it) is yours to wire against the landed Emitter struct; the contract is: structural reads offself.module(=mir_module.ast), body off the matchedMirDef.body, one&MTermborrow 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<Term> are now &Box<MTerm> (same self.lower_term(child)
call); arg lists that were &[Term] are &[MArg] (self.lower_term(&arg.term)).
Two arms need explicit handling:
-
Litsplit. Replace the singleLitarm with two: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::Litarm'sLiteral::Strbranch becomes theStrarm's body; the rep field is ignored at mir.1b.) -
Letsynth read (the binder type). At lib.rs:1725, replace:let val_ail = self.synth_arg_type(value)?;with:
let val_ail = value.ty();(
valueis the&Box<MTerm>init;MTerm::ty()returns the carriedTypedirectly — no?.) The 4thlocalsslot (lib.rs:1727) is now sourced from this. The inherited-modeTerm::Varread (lib.rs:1742) becomesMTerm::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:
// callee is Callee::Indirect(Box<MTerm>) 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:
let arg_ty = self.synth_arg_type(&args[0])?;
both become:
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_appmay be reached both from theMTerm::Apparm oflower_term(callee + args already split) and as a helper. Confirm how theApparm callslower_app(it currently destructuresTerm::App { callee, args, tail }then dispatches on the callee name); thread theCalleeunwrap solower_appstill gets a&strname and&[MArg]args.emit_call(lib.rs:2625),emit_indirect_call(lib.rs:2724),lower_effect_op(lib.rs:2972) all takeargs: &[Term]→&[MArg]; theirargs.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/478re-matchMTerm; theLooparm at:487:match self.synth_arg_type(value) { Ok(t) => { /* is_ptr && !is_str on t */ } Err(_) => false, }becomes (no
Result):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)::514let cty = self.synth_arg_type(callee).ok()?;→let cty = callee.ty();(and thematch cty { Type::Fn { ret_mode, .. } => Some(ret_mode), _ => None }stays). -
drop_symbol_for_binder(:533,value: &Term→&MTerm); arms:535/546/560re-matchMTerm;:561if 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:851re-match;:862self.synth_arg_type(value).ok().and_then(|ty| …)→self.partial_drop_symbol_for_type(&value.ty())(wrap to match the surroundingif let (Some(sym), Some(mask))shape —value.ty()is infallible, so the.ok()disappears). -
:463term_ptridentity 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::<Result<_>>()?→.map(|a| a.term.ty()).collect::<Vec<_>>()(noResult);:98/99lowera.term. -
lower_reuse_as_rc(:179,source: &Term→&MTerm,body_args: &[Term]→&[MArg])::201Term::Var→MTerm::Var;:263same.map(|a| a.term.ty());:288lowera.term. -
lower_match(:440,scrutinee: &Term→&MTerm,arms: &[Arm]→&[MArm])::445let s_ail = self.synth_arg_type(scrutinee)?;→let s_ail = scrutinee.ty();;:459/460Term::Var→MTerm::Var;:476/477theVec<(CtorRef, &Arm, …)>→&MArm;:480arm.patand:481/484/488Pattern::*are unchanged (MArm.patreusesast::Pattern);arm.bodyis nowMTerm. -
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:
pub fn emit_ir(m: &Module) -> Result<String> {
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::<Vec<_>>().join("; ")
)))?;
lower_workspace(&mir)
}
Implementer note. Confirm
CodegenError::Internal(String)is the right variant (grep the enum); confirmDiagnosticexposes.message(else use itsDisplay).ailang_checkis a direct dep of codegen. The two in-source#[cfg(test)]lower_workspace_with_alloccallers (lib.rs:3709/3728) build awsvia test helpers — flip them toailang_check::elaborate_workspace(&ws)then pass&mir. The ill-typedemit_irtest (eqon ADT,unwrap_err) now errors fromcheckinsideelaborate—unwrap_errstill holds.
-
Step 10: Flip codegen's
tests/integration callers -
eq_primitives_pin.rs:30lower_workspace(&ws)— it already runscheck_workspace+monomorphise_workspace(:23-29); collapse that block tolet mir = ailang_check::elaborate_workspace(&ws).expect("elaborates"); lower_workspace(&mir). -
embed_record_layout_pin.rs:28lower_workspace_with_alloc(&ws, Rc)andembed_staticlib_lowering.rs:23/58/78(lower_workspace_staticlib_with_alloc×2,lower_workspace_with_alloc) — these do not yet importailang_check; adduse ailang_check::elaborate_workspace;, buildlet mir = elaborate_workspace(&ws).expect("elaborates");from theload_workspacews, and pass&mirto the lowering call. (ailang-checkis reachable from codegen's own integration tests — confirmed byeq_primitives_pin.rs:9's existinguse 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
buildpath →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:
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 checkhuman path (main.rs ~:611-620) — reuse the sameeprintln!shape (severity / code / message / span) so build-time and check-time diagnostics read identically. Dropbuild_to's own separatecheck_workspacecall (OQ4:elaborate_workspaceruns it internally). Thehas_exportgate and any read ofws.modules…f.exportstays on the pre-elaborate ASTws(it is structural, not in MIR at the CLI level) — confirm it readsws, notmir.
- Step 2: CLI
staticlibbuild 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-irpath →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):
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_alloccallers
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-1536for the first) and collapse its explicit desugar/lift/mono into the singleelaborate_workspacecall. Thewssource (aload_workspaceor 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 readstyoff 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 +
tylanded;synth_with_extras+synth_arg_typegone. - Next (mir.2):
Callee::Staticpre-resolved; deletetype_home_module×3 +is_static_callee; strike thelower_app ↔ is_static_calleelockstep pair from CLAUDE.md.