# mir.2 — `Callee::Static` pre-resolved, codegen stops re-deriving the callee — Implementation Plan > **Parent spec:** `docs/specs/0060-typed-mir.md` (Iteration decomposition table, mir.2 row) > > **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` > skill to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Move static-callee resolution out of codegen and into `lower_to_mir`: each `Term::App` callee is classified once (mirroring check's own `synth` ladder) into `Callee::Builtin` / `Callee::Static` / `Callee::Indirect`, so codegen reads the resolved callee off MIR and `is_static_callee` + `type_home_module` are deleted. **Architecture:** `lower_to_mir` gains a `classify_callee` helper that reproduces `synth`'s `Term::Var` resolution ladder (`crates/ailang-check/src/lib.rs:3409-3582`) and reports *which rung fired* as a `Callee` variant; the `Callee::Static`/`Builtin` carry the resolved callee's fn-type (`sig`) so codegen's drop path keeps reading `ret_mode`. Codegen's `MTerm::App` arm matches the variant — `Static` → `emit_call` (module pre-resolved), `Builtin` → inline opcode lowering, `Indirect` → the fn-pointer path — and the two re-derivers plus the `lower_app ↔ is_static_callee` lockstep pair are struck. **Tech Stack:** `ailang-mir` (the `Callee` bridge enum), `ailang-check::lower_to_mir` (producer), `ailang-codegen` (consumer: `lower_term`/`lower_app`/`drop`), the `lower_to_mir_ty` producer pins. --- ## Design decisions locked before this plan (orchestrator) These were settled during planning; they are NOT open in the tasks. 1. **`Callee` is a three-variant enum carrying the callee fn-type.** The landed shape (`crates/ailang-mir/src/lib.rs:136-139`) is `Static { module, fn_name }` + `Indirect(Box)`. mir.2 adds a third variant `Builtin { name, sig }` and a `sig: Type` field on `Static`. Rationale: (a) operators / str-num builtins (`+`, `not`, `str_concat`, …) are lowered inline by name and have no `module`/`fn_name` — they need their own variant, not a sentinel module; (b) `drop::synth_callee_ret_mode` reads the callee's `ret_mode`, today off `Callee::Indirect(inner).ty()` — once a resolved callee carries no sub-term, the `sig` is the lossless replacement (it is exactly `synth_pure(callee)`, the type the inner term would have carried). This is **not** a mir.3 pull-forward: the `MArg.mode`/`consume_count` param-side fields stay at their mir.1 defaults. 2. **Classification mirrors check's `synth`, never copies codegen's `is_static_callee` allowlist.** The recon's divergence audit confirmed check's builtin set (`crates/ailang-check/src/builtins.rs` `install`) and codegen's inline-arm set (`crates/ailang-codegen/src/lib.rs:2441-2620`) match exactly, so the single-engine rule is mechanically satisfiable: a callee that synth resolves through `env.globals` with no owning module is a `Builtin`; one with an owner is a `Static`. 3. **`type_home_module` is fully deletable in mir.2.** Its two live call sites are `lower_app:2647` (the call path — replaced by `Callee::Static`) and `resolve_top_level_fn:3007` (the *value*-position fn-reference path). A workspace-wide grep confirmed **no program references a type-scoped `T.fn` as a value** (every dotted `T.fn` in the corpus is a call head; the only value-position hits are doc strings). So `resolve_top_level_fn`'s `type_home_module` fallback collapses to the plain `prefix.to_string()` module-name fallback with no behaviour change, and the spec's "type_home_module grep-clean" acceptance holds as written. (The spec's "×3 mirrors" wording over-counts; the live surface is the definition + 2 call sites. Acceptance is grep-clean, not the literal count.) **Files this plan creates or modifies:** - Modify: `crates/ailang-mir/src/lib.rs:135-139` — add `Callee::Builtin`, add `sig` to `Callee::Static`. - Modify: `crates/ailang-check/src/lower_to_mir.rs:121-129` — `classify_callee` helper + rewritten `Term::App` arm. - Modify: `crates/ailang-codegen/src/lib.rs` — `MTerm::App` arm (`:1998-2035`), `lower_app`→`lower_builtin` split (`:2428-2711`), delete `is_static_callee` (`:2918-2976`), delete `type_home_module` (`:2897-2911`), `resolve_top_level_fn` fixup (`:3007`). - Modify: `crates/ailang-codegen/src/drop.rs:510-519` — `synth_callee_ret_mode` reads `sig` for `Static`/`Builtin`. - Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` — strengthen the #53 pin to assert `Callee::Static`; add a three-way classification pin. - Modify (docs/ledger): `CLAUDE.md:311` (strike lockstep row), `docs/specs/0060-typed-mir.md:184-202` + mir.2 acceptance, stale codegen comments + `crates/ail/tests/e2e.rs:2751`. --- ## Task 1: Reshape the `Callee` bridge enum **Files:** - Modify: `crates/ailang-mir/src/lib.rs:135-139` - [ ] **Step 1: Add the `Builtin` variant and the `sig` field** Replace the enum at `crates/ailang-mir/src/lib.rs:135-139`: ```rust #[derive(Debug, Clone)] pub enum Callee { Static { module: String, fn_name: String }, Indirect(Box), } ``` with: ```rust /// A resolved App callee. `Static`/`Builtin` are filled by mir.2's /// `lower_to_mir::classify_callee`, which mirrors check's `synth` /// `Term::Var` ladder; `Indirect` is a genuinely dynamic callee /// (fn-typed local/param, closure value, or a name shadowed by a /// local). `sig` is the callee's fn-type — the lossless replacement /// for the sub-term's `ty()` that codegen's drop path reads for the /// callee `ret_mode`. #[derive(Debug, Clone)] pub enum Callee { /// A statically-resolved user/prelude fn: codegen routes it to /// `emit_call(module, fn_name, …)` with `module` pre-resolved /// (replacing codegen's `type_home_module` ladder). Static { module: String, fn_name: String, sig: Type }, /// An operator / intrinsic builtin (`+`, `not`, `str_concat`, …): /// codegen lowers it inline by `name` (opcode selection), never /// via `emit_call`. Builtin { name: String, sig: Type }, /// A dynamic callee — lower the boxed term to a fn-pointer. Indirect(Box), } ``` - [ ] **Step 2: Build the leaf crate (partial gate — workspace stays red until Task 3)** Run: `cargo build -p ailang-mir` Expected: PASS (`Finished`). > NOTE: the full workspace will NOT build until Task 3 — adding the > `Builtin` variant makes the `match callee { Static, Indirect }` > arms in `ailang-codegen` (`lib.rs:2003`, `drop.rs:511`) > non-exhaustive. That is expected and closed in Task 3; do not try to > green the workspace here. --- ## Task 2: Producer — `lower_to_mir` classifies the callee **Files:** - Modify: `crates/ailang-check/src/lower_to_mir.rs:121-129` (the `Term::App` arm) + a new helper above `lower_term` - [ ] **Step 1: Add the `classify_callee` helper** Insert this helper immediately above `fn lower_term` (`crates/ailang-check/src/lower_to_mir.rs:110`). It reproduces synth's `Term::Var` ladder (`crates/ailang-check/src/lib.rs:3409-3582`) and reports the resolved identity: ```rust /// The resolved identity of an App callee, mirroring synth's /// `Term::Var` resolution ladder (`lib.rs:3409-3582`). `lower_term` /// turns this into the matching `Callee` variant. Resolution uses /// check's own env (the single engine) — never a copy of codegen's /// `is_static_callee` allowlist. enum CalleeClass { Builtin { name: String }, Static { module: String, fn_name: String }, Indirect, } fn classify_callee(ctx: &Ctx, callee: &Term) -> CalleeClass { // A non-`Var` callee (lambda, applied expression, …) is dynamic. let name = match callee { Term::Var { name } => name.clone(), _ => return CalleeClass::Indirect, }; // rung 1: shadowed by a local binder → dynamic (synth `lib.rs:3409`). if ctx.locals.contains_key(&name) { return CalleeClass::Indirect; } // rung 5: dotted `T.fn` / `Mod.fn` — the TypeDef-first ladder // (synth `lib.rs:3557-3582`). `T.fn` resolves to T's home module; // `Mod.fn` to the imported module. This replaces codegen's // `type_home_module`. if name.matches('.').count() == 1 { let (prefix, suffix) = name.split_once('.').expect("checked"); let type_home = ctx .env .module_types .iter() .find_map(|(m, types)| types.contains_key(prefix).then(|| m.clone())); let target = type_home.or_else(|| ctx.env.imports.get(prefix).cloned()); return match target { Some(module) => CalleeClass::Static { module, fn_name: suffix.to_string() }, // Unreachable for typechecked input (check would have // raised TypeScopedReceiverNotAType); defensive dynamic. None => CalleeClass::Indirect, }; } // rung 2: same-module global. It is a user fn IFF this module's // declared globals carry the name; otherwise it is a builtin — // both live in `env.globals` (synth `lib.rs:3416-3425`). if ctx.env.globals.contains_key(&name) { let is_user_fn = ctx .env .module_globals .get(&ctx.env.current_module) .is_some_and(|m| m.contains_key(&name)); return if is_user_fn { CalleeClass::Static { module: ctx.env.current_module.clone(), fn_name: name } } else { CalleeClass::Builtin { name } }; } // rung 3: implicit-import (prelude-fallback) fn (synth `lib.rs:3428-3435`). if let Some(module) = ctx.env.imports.values().find_map(|m| { ctx.env .module_globals .get(m) .filter(|g| g.contains_key(&name)) .map(|_| m.clone()) }) { return CalleeClass::Static { module, fn_name: name }; } // Typechecked input always resolves above; defensive dynamic. CalleeClass::Indirect } ``` > Implementer note: confirm `Ctx` exposes `env: &Env` and > `locals: IndexMap` (`lower_to_mir.rs:21-26`), and that > `Env` exposes the public fields `globals`, `module_globals`, > `module_types`, `imports`, `current_module` used above (they are the > same fields synth reads at `lib.rs:3409-3582`). `is_some_and` is > stable; if the crate's MSRV rejects it, use > `.map_or(false, |m| m.contains_key(&name))`. `bool::then` returns > `Option`; if clippy prefers `then_some`, the closure form is fine > since the value is a clone. - [ ] **Step 2: Rewrite the `Term::App` arm to emit the classified callee** Replace `crates/ailang-check/src/lower_to_mir.rs:121-129`: ```rust // callee is Indirect at mir.1 (mir.2 resolves Static). Term::App { callee, args, tail } => { let m_callee = Callee::Indirect(Box::new(lower_term(ctx, callee)?)); let m_args = args .iter() .map(|a| Ok(arg(lower_term(ctx, a)?))) .collect::>>()?; MTerm::App { callee: m_callee, args: m_args, tail: *tail, ty } } ``` with: ```rust // mir.2: classify the callee against check's own resolution // ladder and emit the resolved `Callee`. `sig` is the callee's // fn-type (mode-preserving via `synth_pure`), carried so // codegen's drop path reads the callee `ret_mode`. Term::App { callee, args, tail } => { let class = classify_callee(ctx, callee); let sig = ctx.synth_pure(callee)?; let m_callee = match class { CalleeClass::Builtin { name } => Callee::Builtin { name, sig }, CalleeClass::Static { module, fn_name } => { Callee::Static { module, fn_name, sig } } CalleeClass::Indirect => { Callee::Indirect(Box::new(lower_term(ctx, callee)?)) } }; let m_args = args .iter() .map(|a| Ok(arg(lower_term(ctx, a)?))) .collect::>>()?; MTerm::App { callee: m_callee, args: m_args, tail: *tail, ty } } ``` > Implementer note: `classify_callee(ctx, callee)` takes `&Ctx` > (immutable) and returns an owned `CalleeClass`, so its borrow ends > before the `ctx.synth_pure(callee)?` mutable call — no borrow > conflict. Ensure `Callee` and the (now three) variants are imported > at the top of `lower_to_mir.rs` (the `use ailang_mir::…` line that > already brings in `Callee`). - [ ] **Step 3: Build the producer crate (partial gate)** Run: `cargo build -p ailang-check` Expected: PASS (`Finished`). > The workspace still will not build (codegen consumers are stale) — > that is Task 3. `cargo build -p ailang-check` does not pull codegen, > so it is a clean partial gate for the producer. --- ## Task 3: Consumer — codegen reads the resolved callee; delete the re-derivers **Files:** - Modify: `crates/ailang-codegen/src/drop.rs:510-519` - Modify: `crates/ailang-codegen/src/lib.rs:1998-2035` (the `MTerm::App` arm) - Modify: `crates/ailang-codegen/src/lib.rs:2428-2711` (`lower_app` → `lower_builtin`, delete the module-resolution branches) - Modify: `crates/ailang-codegen/src/lib.rs:2918-2976` (delete `is_static_callee`) - Modify: `crates/ailang-codegen/src/lib.rs:2897-2911` (delete `type_home_module`) - Modify: `crates/ailang-codegen/src/lib.rs:3007` (`resolve_top_level_fn` fallback fixup) - [ ] **Step 1: `drop::synth_callee_ret_mode` reads `sig` for the resolved variants** Replace `crates/ailang-codegen/src/drop.rs:510-519`: ```rust fn synth_callee_ret_mode(&self, callee: &Callee) -> Option { let cty = match callee { Callee::Indirect(inner) => inner.ty(), Callee::Static { .. } => return None, }; match cty { Type::Fn { ret_mode, .. } => Some(ret_mode), _ => None, } } ``` with: ```rust fn synth_callee_ret_mode(&self, callee: &Callee) -> Option { // A resolved callee carries its fn-type as `sig`; an indirect // one carries it on the boxed sub-term. Both yield the callee // `ret_mode` identically — there is no behaviour change from // mir.1b, only a different place the same fn-type is read from. let cty = match callee { Callee::Indirect(inner) => inner.ty(), Callee::Static { sig, .. } | Callee::Builtin { sig, .. } => sig.clone(), }; match cty { Type::Fn { ret_mode, .. } => Some(ret_mode), _ => None, } } ``` - [ ] **Step 2: Rewrite the codegen `MTerm::App` arm to match the resolved `Callee`** Replace `crates/ailang-codegen/src/lib.rs:1998-2035` (the whole `MTerm::App { callee, args, tail, .. } => { … }` arm): ```rust MTerm::App { callee, args, tail, .. } => { // 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"), }; // Direct call when the callee is a `Var` referring to a // statically-known target (builtin, current-module fn, // qualified cross-module fn) AND not shadowed by a local. // Otherwise we fall through to the indirect-call path, // which lowers the callee to a fn-pointer and looks up // its sig in the sidetable. if let MTerm::Var { name, .. } = callee_mterm { let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name); if !shadowed && self.is_static_callee(name) { return self.lower_app(name, args, *tail); } } let (callee_ssa, callee_ty) = self.lower_term(callee_mterm)?; if callee_ty != "ptr" { return Err(CodegenError::Internal(format!( "indirect call: callee type must be ptr, got {callee_ty}" ))); } let sig = self .ssa_fn_sigs .get(&callee_ssa) .cloned() .ok_or_else(|| { CodegenError::Internal(format!( "indirect call: no FnSig recorded for `{callee_ssa}`" )) })?; self.emit_indirect_call(&callee_ssa, &sig, args, *tail) } ``` with: ```rust MTerm::App { callee, args, tail, .. } => match callee { // mir.2: the callee is pre-resolved in MIR. A builtin // is lowered inline by name (opcode selection); a // static user/prelude fn routes to `emit_call` with the // module already resolved; an indirect callee is // lowered to a fn-pointer and called via the sidetable // sig. There is no `is_static_callee` walk and no // `type_home_module` ladder. Callee::Builtin { name, .. } => self.lower_builtin(name, args, *tail), Callee::Static { module, fn_name, .. } => { let sig = self .module_user_fns .get(module) .and_then(|m| m.get(fn_name)) .cloned() .ok_or_else(|| { CodegenError::Internal(format!( "static call `{module}.{fn_name}`: no FnSig in workspace" )) })?; self.emit_call(module, fn_name, &sig, args, *tail) } Callee::Indirect(inner) => { let (callee_ssa, callee_ty) = self.lower_term(inner)?; if callee_ty != "ptr" { return Err(CodegenError::Internal(format!( "indirect call: callee type must be ptr, got {callee_ty}" ))); } let sig = self .ssa_fn_sigs .get(&callee_ssa) .cloned() .ok_or_else(|| { CodegenError::Internal(format!( "indirect call: no FnSig recorded for `{callee_ssa}`" )) })?; self.emit_indirect_call(&callee_ssa, &sig, args, *tail) } }, ``` - [ ] **Step 3: Split `lower_app` into `lower_builtin` (delete the module-resolution branches)** `lower_app` (`crates/ailang-codegen/src/lib.rs:2428`) currently holds two halves: the inline builtin/operator arms (`:2441-2620`) and the module-resolution + `emit_call` branches (`:2624-2711`). mir.2 keeps only the builtin half (now driven by `Callee::Builtin`); the module-resolution half is dead (its sole driver — the `is_static_callee` path at `:2016` — is gone, and `Callee::Static` routes straight to `emit_call` in Step 2). 1. Rename the function signature at `:2428` from: ```rust fn lower_app(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> { ``` to: ```rust /// Lower a `Callee::Builtin` call inline (opcode selection). Only /// the operator / str-num builtins reach here; user/prelude fns are /// `Callee::Static` and route to `emit_call`. (Pre-mir.2 this was /// `lower_app`, which also walked the cross-module / current-module /// / prelude resolution ladder — that ladder moved into /// `lower_to_mir::classify_callee`.) fn lower_builtin(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> { ``` 2. Delete the entire module-resolution tail of the function — from the `// Cross-module call:` comment at `:2622` through the final `Err(CodegenError::Internal(format!("unknown callee: \`{name}\`")))` and its closing `}` at `:2708-2710`. The builtin arms above it (`:2441` through the last builtin arm, ending just before `:2622`) are retained verbatim, followed by a new tail: ```rust Err(CodegenError::Internal(format!( "lower_builtin: `{name}` is not a builtin (should have been \ classified as Static or Indirect in lower_to_mir)" ))) } ``` > Implementer note: the retained builtin arms end at the last > `if name == … { … return Ok(…); }` block before the `// Cross-module > call:` comment (recon anchor `:2620`). Read the region `:2441-2622` > and cut exactly at the comment; do not delete any builtin arm. After > the cut, `lower_builtin` has no remaining reference to > `type_home_module`, `import_map`-based call resolution, `emit_call`, > or `module_user_fns` — those all lived in the deleted tail. - [ ] **Step 4: Delete `is_static_callee`** Delete the whole function `fn is_static_callee(&self, name: &str) -> bool` at `crates/ailang-codegen/src/lib.rs:2918-2976` (the definition through its closing brace). Its only call site was the `:2016` block removed in Step 2. - [ ] **Step 5: Delete `type_home_module` and fix `resolve_top_level_fn`** 1. Delete the whole function `fn type_home_module(&self, type_name: &str) -> Option` at `crates/ailang-codegen/src/lib.rs:2897-2911`. 2. In `resolve_top_level_fn` (`:2984`), replace the dotted-resolution `target` match at `:3004-3008`: ```rust let target: String = match self.import_map.get(prefix) { Some(m) => m.clone(), None if self.module_user_fns.contains_key(prefix) => prefix.to_string(), None => self.type_home_module(prefix).unwrap_or_else(|| prefix.to_string()), }; ``` with: ```rust // mir.2: `type_home_module` is gone. A type-scoped `T.fn` // reference in VALUE position (the only path that used the // type-home fallback here) is unreachable in the current // corpus — every dotted `T.fn` is a call head, resolved as // `Callee::Static` in lower_to_mir. The remaining value- // position dotted forms (`Mod.fn`) resolve through // `import_map` / the module-name fallback. If a type-scoped // fn is ever taken as a first-class value, its home-module // resolution moves into MIR at mir.5 (the last re-derivation // residue); until then the module-name fallback is correct. let target: String = match self.import_map.get(prefix) { Some(m) => m.clone(), None => prefix.to_string(), }; ``` > Implementer note: the `None if module_user_fns.contains_key(prefix)` > arm collapses into the final `None => prefix.to_string()` (both yield > `prefix.to_string()` for a module-name prefix), so the two-arm form > above is behaviour-equivalent for every reachable input. - [ ] **Step 6: Build the whole workspace (the consuming gate)** Run: `cargo build --workspace` Expected: PASS (`Finished`). Zero errors. If a `match callee` non-exhaustiveness error remains, an `if let Callee::Indirect(...)` site in `escape.rs` / `lambda.rs` is fine (those deliberately skip non-Indirect callees — see Task 4 Step 4); a *non-exhaustive `match`* error means a Step-2/Step-1 arm is missing a variant. --- ## Task 4: Pins — assert the resolution comes from MIR, and the guards stay green **Files:** - Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` (strengthen #53 pin + add classification pin) - [ ] **Step 1: Strengthen the #53 producer pin to assert `Callee::Static`** In `crates/ailang-check/tests/lower_to_mir_ty.rs`, locate `fn new_over_user_adt_carries_node_types` (`:51`). It already builds a `MirWorkspace` for the `new_counter_user_adt` fixture and inspects the `Counter.new` App node's `ty`. Add, after the existing `ty` assertions on that node, an assertion on the resolved callee. Append this block inside that test (adjust the node-navigation binding name to match the existing `MTerm::App { .. }` the test already destructures — read the test first): ```rust // mir.2: the `(new Counter 42)` desugars to `App{callee: // Var{"Counter.new"}}`; lower_to_mir must resolve it to a // `Callee::Static` whose module is `Counter`'s home (its own // module, spelled bare per the own-module-types-stay-bare rule), // NOT leave it `Indirect` for codegen's deleted ladder to resolve. match callee { ailang_mir::Callee::Static { module, fn_name, .. } => { assert_eq!(module, "new_counter_user_adt", "Counter.new home module"); assert_eq!(fn_name, "new", "Counter.new fn_name"); } other => panic!("expected Callee::Static for Counter.new, got {other:?}"), } ``` > Implementer note: the test currently destructures the App node to > reach its `ty`; reuse that same `MTerm::App { callee, .. }` binding > for the `callee` matched above. If the test reaches the node via a > helper that returns only the `ty`, extend the navigation to bind the > whole `MTerm::App` node first. `ailang_mir` is already a dev/test > dependency of `ailang-check` (the file builds `MirWorkspace`). - [ ] **Step 2: Add a three-way classification pin** Add a new test to `crates/ailang-check/tests/lower_to_mir_ty.rs` that pins all three `Callee` variants from one fixture. Use the existing fixture-elaboration helper the file already provides (read its name — `elaborate_fixture` / `body` per recon; reuse it). The fixture: a fn that (a) calls an arithmetic builtin `(app + i 1)` → `Builtin`, and (b) calls a bare same-module user fn → `Static`. (An `Indirect` case needs a fn-typed local; assert it via an existing higher-order example if the helper supports loading one, otherwise keep this pin to the Builtin + Static legs and note the Indirect leg is covered by the existing closure e2e tests.) ```rust #[test] fn callee_classification_builtin_and_static() { // `helper` is the same fn the sibling tests use to elaborate an // inline module source to a MirWorkspace and reach a named def's // body. Mirror their call shape exactly. let src = r#" (module classify_pin (fn bump (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (body (app + n 1))) (fn main (type (fn-type (params) (ret (con Int)))) (params) (body (app bump 41)))) "#; let ws = elaborate_fixture(src); let m = ws.modules.get("classify_pin").expect("module"); // `bump`'s body is `(app + n 1)` → the `+` callee is a Builtin. let bump = m.defs.iter().find(|d| d.name == "bump").expect("bump"); match &bump.body { MTerm::App { callee: Callee::Builtin { name, .. }, .. } => { assert_eq!(name, "+", "arithmetic op classified as Builtin"); } other => panic!("expected Builtin `+`, got {other:?}"), } // `main`'s body is `(app bump 41)` → `bump` is a same-module user // fn → Static{module: "classify_pin", fn_name: "bump"}. let main = m.defs.iter().find(|d| d.name == "main").expect("main"); match &main.body { MTerm::App { callee: Callee::Static { module, fn_name, .. }, .. } => { assert_eq!(module, "classify_pin", "own-module callee module"); assert_eq!(fn_name, "bump", "own-module callee fn_name"); } other => panic!("expected Static `bump`, got {other:?}"), } } ``` > Implementer note: match the exact name and return type of the > file's fixture helper (`elaborate_fixture` is the recon's name; if it > differs, use the real one) and its `MirWorkspace` accessor shape > (`ws.modules` is a `BTreeMap` per > `ailang-mir/src/lib.rs:16-19`; `MirDef.body` is the `MTerm` per > `:57`). Import `MTerm` and `Callee` from `ailang_mir` at the top of > the test file if not already imported. If `bump`'s single-expression > body is wrapped (e.g. the helper returns the def's `body` already, or > the body is the outer `MTerm` directly), navigate to the `App` node > accordingly — read a sibling test for the exact shape. - [ ] **Step 3: Run the producer pins** Run: `cargo test -p ailang-check --test lower_to_mir_ty` Expected: PASS — including `new_over_user_adt_carries_node_types` (now asserting `Callee::Static`) and `callee_classification_builtin_and_static`. - [ ] **Step 4: Verify the escape / lambda Indirect-only sites still hold** The escape/capture walkers match `if let Callee::Indirect(inner)` (`crates/ailang-codegen/src/escape.rs:141`, `:241`, `:460`; `crates/ailang-codegen/src/lambda.rs:401`). A `Static`/`Builtin` callee has no sub-term to walk and contributes no free var or capture (it names a global, not a local), so skipping it is correct. Confirm by reading each site that the skipped branch carried no free-var/escape/capture fact through the inner `Var` (it did not — a bare fn-name `Var` is a global reference). No code change; this is a read-and-confirm step. Run: `cargo test --workspace` Expected: PASS — the full suite green, including the acceptance witnesses: `user_adt_new_mono_pin` (#53), `raw_buf_new_type_arg_pin` (#51, still builds), the RC-stats drop pins (drop `ret_mode` still read), and `show_print_e2e` (the cross-module case mir.1b surfaced). `loop_recur_str_binder_no_leak_pin` stays `#[ignore]` (#49 lifts at mir.4). > Note: run the FULL workspace suite, not just `e2e` — mir.1b's case 6 > was caught only by `show_print_e2e`, a separate file. `cargo test > --workspace` is the real acceptance gate. --- ## Task 5: Strike the lockstep pair, update the spec, clean stale references **Files:** - Modify: `CLAUDE.md:311` - Modify: `docs/specs/0060-typed-mir.md:184-202` + the mir.2 acceptance line - Modify: stale codegen comments + `crates/ail/tests/e2e.rs:2751` - [ ] **Step 1: Strike the `lower_app ↔ is_static_callee` lockstep row** In `CLAUDE.md`, delete exactly the table row at `:311` (the `lower_app` arms ↔ `is_static_callee` pair). Leave the `Pattern::Lit::*` row (`:310`) and the `INTERCEPTS ↔ (intrinsic)` row (`:312`) intact. The struck row is the one beginning `` | `lower_app` arms in `crates/ailang-codegen/src/lib.rs::lower_app` ``. Run: `rg -n 'is_static_callee' CLAUDE.md` Expected: no matches (the symbol appears only in that row). - [ ] **Step 2: Update spec 0060's `Callee` shape and mir.2 acceptance** In `docs/specs/0060-typed-mir.md`, update the `Callee` line in the `## Concrete code shapes` block (`:199`) from: ```rust pub enum Callee { Static { module: String, fn_name: String }, Indirect(Box) } // (2) ``` to: ```rust pub enum Callee { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box) } // (2) — sig carries the callee fn-type for drop ret_mode; Builtin for inline-lowered operators/intrinsics ``` And in the mir.2 row of the Iteration-decomposition table (`:309`), leave the "Deletes / Adds / Acceptance" columns as written (they are still accurate: `Callee::Static` pre-resolved; deletes `type_home_module` + `is_static_callee`; strikes the lockstep pair; #53 green via MIR) — append one sentence to the spec's prose under that table noting the mir.2 refinements discovered in planning: ```markdown > mir.2 refinement (discovered in planning, `docs/plans/0116-mir.2-callee-static.md`): > `Callee` gains a `Builtin { name, sig }` variant (operators / str-num > intrinsics have no `module`/`fn_name`) and a `sig: Type` on each > resolved variant (codegen's drop path reads the callee `ret_mode` off > it — the lossless replacement for the pre-mir.2 `Indirect(inner).ty()` > read; this is NOT a mir.3 pull-forward, the `MArg` param-modes stay at > mir.1 defaults). `type_home_module` is fully deletable: its > value-position consumer (`resolve_top_level_fn`) is unreachable for > type-scoped names in the current corpus, so the acceptance > "type_home_module grep-clean" holds. ``` - [ ] **Step 3: Clean stale `is_static_callee` / `type_home_module` comment references** Update the comments that name the now-deleted symbols so the grep-clean gate is honest. Read each and rewrite the reference (do not delete load-bearing comment prose; just drop/replace the stale symbol name): - `crates/ailang-codegen/src/lib.rs` comments referencing `is_static_callee` (recon anchors `:65`, `:2002`, `:2644`, `:2645`, `:2696`, `:2983`) and `type_home_module` (`:2896`-area doc) — most live in regions deleted by Task 3; for any surviving comment (e.g. the `resolve_top_level_fn` doc at `:2982-2983` mentioning "`is_static_callee` filters them earlier"), reword to drop the dead symbol (e.g. "operators are not first-class values and never reach here as a `Callee::Static`/`Builtin`"). - `crates/ail/tests/e2e.rs:2751` — the prose doc comment naming "is_static_callee whitelist"; reword to reference the `Callee` classification in `lower_to_mir`. - [ ] **Step 4: Final grep-clean acceptance gate** Run: `rg -n 'is_static_callee|type_home_module' crates/ailang-codegen/ crates/ail/` Expected: no matches. (Both symbols — definitions, calls, and comment references — are gone from the codegen crate and the CLI test crate.) Run: `cargo test --workspace` Expected: PASS — re-confirm the full suite is green after the comment edits (doc-only, so this is a fast re-gate). --- ## Self-review (planner, inline) 1. **Spec coverage.** mir.2 row of spec 0060: `Callee::Static` pre-resolved (Tasks 1-2), deletes `type_home_module` + `is_static_callee` (Task 3 Steps 4-5), strikes the lockstep pair (Task 5 Step 1), #53 green via MIR (Task 4 Step 1). All covered. 2. **Placeholder scan.** No "TBD/TODO/similar to/implement later". Every code step carries exact before→after bytes. The two implementer-judgement points (the test's existing node-navigation binding; the `lower_builtin` cut boundary) are framed as read-the-region-then-apply, with the exact anchor lines — not open decisions. 3. **Type/name consistency.** `Callee::{Static,Builtin,Indirect}`, `lower_builtin`, `classify_callee`, `CalleeClass`, `synth_callee_ret_mode`, `module_user_fns`, `emit_call`, `lower_to_mir.rs`/`drop.rs`/`lib.rs` paths consistent across tasks. 4. **Step granularity.** Each step is one edit or one command. Task 3 is the largest (the atomic consumer switch — like mir.1b's switch, it cannot be split without a non-compiling intermediate), but its steps are individually small and ordered so each is a 2-5 min edit; the single workspace build gate sits at its end. 5. **No commit steps.** None present. 6. **Pin/replacement substring contiguity.** The grep gates (Task 5 Steps 1, 4) assert on `is_static_callee` / `type_home_module` / `is_static_callee in CLAUDE.md` — all single tokens that appear contiguously; no soft-wrap split. The `#53` pin asserts on `module`/`fn_name` string equality, not a substring of a wrapped body. 7. **Compile-gate vs deferred-caller ordering.** Tasks 1 and 2 carry *explicitly partial* gates (`-p ailang-mir`, `-p ailang-check`) with a written note that the workspace is red until Task 3; Task 3's gate is the first workspace-wide `cargo build`, and it threads every `Callee` consumer (App arm, drop, escape/lambda are Indirect-only and need no thread) in that same task. No caller is deferred past a "0 errors" workspace gate. 8. **Verification-command filter strings resolve.** `cargo test -p ailang-check --test lower_to_mir_ty` targets a real file (`crates/ailang-check/tests/lower_to_mir_ty.rs`, recon-confirmed); the named tests `new_over_user_adt_carries_node_types` and the new `callee_classification_builtin_and_static` are pinned by exact name; the workspace gates use the unfiltered suite. The `rg` gates assert on real present symbols expected to reach zero. 9. **Parse-the-bytes gate.** The two inlined `.ail` fixtures (the `classify_pin` module in Task 4 Step 2 and the spec-block edits) are surface-language bodies — see the parse-trace appended below. ### Parse-trace (self-review #9 / Step-7 gate) The plan inlines one new `.ail` program (the `classify_pin` fixture in Task 4 Step 2). The other `.ail` snippets quoted (the #51/#53/#49 fixtures in the spec) are existing committed files, already parsed. The `classify_pin` source is the bytes to gate: ``` (module classify_pin (fn bump (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (body (app + n 1))) (fn main (type (fn-type (params) (ret (con Int)))) (params) (body (app bump 41)))) ``` Run (the project's surface parser, per the profile's `spec_validation` parser for the `ail` fence — `ail parse`): `ail parse .ail` Expected: exit 0 (parses to a Form-B module with two `Def::Fn`s). The implementer runs this as the first action of Task 4 Step 2 before wiring the fixture into the test; a non-zero exit is a fixture defect to fix in the plan, not downstream. > Planner note: `bump` returns `(con Int)` with no effects and `main` > calls it purely — `(app + n 1)` and `(app bump 41)` are both > well-formed App forms. `+` is the arithmetic builtin (rung 2, > owner=None → `Builtin`); `bump` is a same-module `Def::Fn` (rung 2, > owner=Some → `Static`). The fixture exercises exactly the two legs > the pin asserts. --- ## Handoff Plan sits unstaged at `docs/plans/0116-mir.2-callee-static.md`. Hand to `implement` (standard mode, all five tasks). The orchestrator commits the plan when handing it forward and the iteration diff at iter close.