Files
AILang/docs/plans/0116-mir.2-callee-static.md
T
Brummel d81ea93de6 plan: mir.2 Callee::Static pre-resolved, codegen stops re-deriving the callee
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).
2026-05-31 19:10:37 +02:00

833 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<MTerm>)`. 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<MTerm>),
}
```
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<MTerm>),
}
```
- [ ] **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<String, Type>` (`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::<Result<Vec<_>>>()?;
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::<Result<Vec<_>>>()?;
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<ParamMode> {
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<ParamMode> {
// 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<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"),
};
// 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<String>` 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<String, MirModule>` 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<MTerm>) } // (2)
```
to:
```rust
pub enum Callee { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box<MTerm>) } // (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 <tmpfile>.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.