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).
This commit is contained in:
2026-05-31 19:10:37 +02:00
parent 895ba846e8
commit d81ea93de6
+832
View File
@@ -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<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.