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).
37 KiB
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
implementskill 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.
-
Calleeis a three-variant enum carrying the callee fn-type. The landed shape (crates/ailang-mir/src/lib.rs:136-139) isStatic { module, fn_name }+Indirect(Box<MTerm>). mir.2 adds a third variantBuiltin { name, sig }and asig: Typefield onStatic. Rationale: (a) operators / str-num builtins (+,not,str_concat, …) are lowered inline by name and have nomodule/fn_name— they need their own variant, not a sentinel module; (b)drop::synth_callee_ret_modereads the callee'sret_mode, today offCallee::Indirect(inner).ty()— once a resolved callee carries no sub-term, thesigis the lossless replacement (it is exactlysynth_pure(callee), the type the inner term would have carried). This is not a mir.3 pull-forward: theMArg.mode/consume_countparam-side fields stay at their mir.1 defaults. -
Classification mirrors check's
synth, never copies codegen'sis_static_calleeallowlist. The recon's divergence audit confirmed check's builtin set (crates/ailang-check/src/builtins.rsinstall) 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 throughenv.globalswith no owning module is aBuiltin; one with an owner is aStatic. -
type_home_moduleis fully deletable in mir.2. Its two live call sites arelower_app:2647(the call path — replaced byCallee::Static) andresolve_top_level_fn:3007(the value-position fn-reference path). A workspace-wide grep confirmed no program references a type-scopedT.fnas a value (every dottedT.fnin the corpus is a call head; the only value-position hits are doc strings). Soresolve_top_level_fn'stype_home_modulefallback collapses to the plainprefix.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— addCallee::Builtin, addsigtoCallee::Static. - Modify:
crates/ailang-check/src/lower_to_mir.rs:121-129—classify_calleehelper + rewrittenTerm::Apparm. - Modify:
crates/ailang-codegen/src/lib.rs—MTerm::Apparm (:1998-2035),lower_app→lower_builtinsplit (:2428-2711), deleteis_static_callee(:2918-2976), deletetype_home_module(:2897-2911),resolve_top_level_fnfixup (:3007). - Modify:
crates/ailang-codegen/src/drop.rs:510-519—synth_callee_ret_modereadssigforStatic/Builtin. - Test:
crates/ailang-check/tests/lower_to_mir_ty.rs— strengthen the #53 pin to assertCallee::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
Builtinvariant and thesigfield
Replace the enum at crates/ailang-mir/src/lib.rs:135-139:
#[derive(Debug, Clone)]
pub enum Callee {
Static { module: String, fn_name: String },
Indirect(Box<MTerm>),
}
with:
/// 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
Builtinvariant makes thematch callee { Static, Indirect }arms inailang-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(theTerm::Apparm) + a new helper abovelower_term -
Step 1: Add the
classify_calleehelper
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:
/// 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
Ctxexposesenv: &Envandlocals: IndexMap<String, Type>(lower_to_mir.rs:21-26), and thatEnvexposes the public fieldsglobals,module_globals,module_types,imports,current_moduleused above (they are the same fields synth reads atlib.rs:3409-3582).is_some_andis stable; if the crate's MSRV rejects it, use.map_or(false, |m| m.contains_key(&name)).bool::thenreturnsOption; if clippy prefersthen_some, the closure form is fine since the value is a clone.
- Step 2: Rewrite the
Term::Apparm to emit the classified callee
Replace crates/ailang-check/src/lower_to_mir.rs:121-129:
// 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:
// 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 ownedCalleeClass, so its borrow ends before thectx.synth_pure(callee)?mutable call — no borrow conflict. EnsureCalleeand the (now three) variants are imported at the top oflower_to_mir.rs(theuse ailang_mir::…line that already brings inCallee).
- 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-checkdoes 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(theMTerm::Apparm) -
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(deleteis_static_callee) -
Modify:
crates/ailang-codegen/src/lib.rs:2897-2911(deletetype_home_module) -
Modify:
crates/ailang-codegen/src/lib.rs:3007(resolve_top_level_fnfallback fixup) -
Step 1:
drop::synth_callee_ret_modereadssigfor the resolved variants
Replace crates/ailang-codegen/src/drop.rs:510-519:
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:
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::Apparm to match the resolvedCallee
Replace crates/ailang-codegen/src/lib.rs:1998-2035 (the whole
MTerm::App { callee, args, tail, .. } => { … } arm):
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:
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_appintolower_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).
- Rename the function signature at
:2428from:
fn lower_app(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> {
to:
/// 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)> {
- Delete the entire module-resolution tail of the function — from the
// Cross-module call:comment at:2622through the finalErr(CodegenError::Internal(format!("unknown callee: \{name}`")))and its closing}at:2708-2710. The builtin arms above it (:2441through the last builtin arm, ending just before:2622`) are retained verbatim, followed by a new tail:
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-2622and cut exactly at the comment; do not delete any builtin arm. After the cut,lower_builtinhas no remaining reference totype_home_module,import_map-based call resolution,emit_call, ormodule_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_moduleand fixresolve_top_level_fn
-
Delete the whole function
fn type_home_module(&self, type_name: &str) -> Option<String>atcrates/ailang-codegen/src/lib.rs:2897-2911. -
In
resolve_top_level_fn(:2984), replace the dotted-resolutiontargetmatch at:3004-3008:
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:
// 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 finalNone => prefix.to_string()(both yieldprefix.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):
// 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 sameMTerm::App { callee, .. }binding for thecalleematched above. If the test reaches the node via a helper that returns only thety, extend the navigation to bind the wholeMTerm::Appnode first.ailang_miris already a dev/test dependency ofailang-check(the file buildsMirWorkspace).
- 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.)
#[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_fixtureis the recon's name; if it differs, use the real one) and itsMirWorkspaceaccessor shape (ws.modulesis aBTreeMap<String, MirModule>perailang-mir/src/lib.rs:16-19;MirDef.bodyis theMTermper:57). ImportMTermandCalleefromailang_mirat the top of the test file if not already imported. Ifbump's single-expression body is wrapped (e.g. the helper returns the def'sbodyalready, or the body is the outerMTermdirectly), navigate to theAppnode 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 byshow_print_e2e, a separate file.cargo test --workspaceis 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_calleelockstep 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
Calleeshape and mir.2 acceptance
In docs/specs/0060-typed-mir.md, update the Callee line in the
## Concrete code shapes block (:199) from:
pub enum Callee { Static { module: String, fn_name: String }, Indirect(Box<MTerm>) } // (2)
to:
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:
> 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_modulecomment 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.rscomments referencingis_static_callee(recon anchors:65,:2002,:2644,:2645,:2696,:2983) andtype_home_module(:2896-area doc) — most live in regions deleted by Task 3; for any surviving comment (e.g. theresolve_top_level_fndoc at:2982-2983mentioning "is_static_calleefilters them earlier"), reword to drop the dead symbol (e.g. "operators are not first-class values and never reach here as aCallee::Static/Builtin"). -
crates/ail/tests/e2e.rs:2751— the prose doc comment naming "is_static_callee whitelist"; reword to reference theCalleeclassification inlower_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)
- Spec coverage. mir.2 row of spec 0060:
Callee::Staticpre-resolved (Tasks 1-2), deletestype_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. - 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_builtincut boundary) are framed as read-the-region-then-apply, with the exact anchor lines — not open decisions. - 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.rspaths consistent across tasks. - 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.
- No commit steps. None present.
- 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#53pin asserts onmodule/fn_namestring equality, not a substring of a wrapped body. - 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-widecargo build, and it threads everyCalleeconsumer (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. - Verification-command filter strings resolve.
cargo test -p ailang-check --test lower_to_mir_tytargets a real file (crates/ailang-check/tests/lower_to_mir_ty.rs, recon-confirmed); the named testsnew_over_user_adt_carries_node_typesand the newcallee_classification_builtin_and_staticare pinned by exact name; the workspace gates use the unfiltered suite. Therggates assert on real present symbols expected to reach zero. - Parse-the-bytes gate. The two inlined
.ailfixtures (theclassify_pinmodule 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::Fns). 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:
bumpreturns(con Int)with no effects andmaincalls it purely —(app + n 1)and(app bump 41)are both well-formed App forms.+is the arithmetic builtin (rung 2, owner=None →Builtin);bumpis a same-moduleDef::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.