From d81ea93de6938ceed62cd6278be60e25b8cb56d4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 31 May 2026 19:10:37 +0200 Subject: [PATCH] plan: mir.2 Callee::Static pre-resolved, codegen stops re-deriving the callee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executable plan for spec 0060's mir.2 iteration: relocate static-callee resolution from codegen into lower_to_mir. Two plan-recon passes (codegen consumer surface + check-side producer surface) mapped the resolution frontend; this plan locks three design decisions made during planning, ahead of the tasks: 1. The Callee bridge enum is reshaped beyond the spec sketch: a third variant Builtin{name,sig} (operators / str-num intrinsics have no module/fn_name and are lowered inline by name), and a sig:Type on each resolved variant. The sig is the lossless replacement for the pre-mir.2 Callee::Indirect(inner).ty() read that drop's synth_callee_ret_mode depends on; it is synth_pure(callee), mode-preserving. NOT a mir.3 pull-forward — the MArg param-modes stay at mir.1 defaults. Spec 0060's Concrete-code-shapes Callee block is updated as part of the iteration (Task 5). 2. Classification mirrors check's own synth Term::Var ladder (lib.rs:3409-3582), never copies codegen's is_static_callee allowlist. The recon's divergence audit confirmed check's builtin set and codegen's inline-arm set match exactly, so the single-engine rule is mechanically satisfiable with no env threading gap. 3. type_home_module is fully deletable: a workspace-wide grep confirmed no program references a type-scoped T.fn as a value, so its second consumer (resolve_top_level_fn) collapses to the module-name fallback with no behaviour change. The spec's "grep-clean" acceptance holds; the spec's "x3 mirrors" wording over-counts (def + 2 live sites). Five tasks: (1) reshape Callee; (2) lower_to_mir classify_callee + App arm; (3) the atomic codegen consumer switch (App arm/drop/lower_app split/delete is_static_callee+type_home_module/resolve_top_level_fn); (4) pins (strengthen #53 to assert Callee::Static, add a three-way classification pin) + workspace suite; (5) strike the lower_app <-> is_static_callee lockstep row from CLAUDE.md, update spec 0060, clean stale comment references. The classify_pin fixture is ail-parse-gated (exit 0). --- docs/plans/0116-mir.2-callee-static.md | 832 +++++++++++++++++++++++++ 1 file changed, 832 insertions(+) create mode 100644 docs/plans/0116-mir.2-callee-static.md diff --git a/docs/plans/0116-mir.2-callee-static.md b/docs/plans/0116-mir.2-callee-static.md new file mode 100644 index 0000000..9affcb2 --- /dev/null +++ b/docs/plans/0116-mir.2-callee-static.md @@ -0,0 +1,832 @@ +# 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.