Files
AILang/docs/plans/0116-mir.2-callee-static.md
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

37 KiB
Raw Permalink Blame History

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 — Staticemit_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-129classify_callee helper + rewritten Term::App arm.
  • Modify: crates/ailang-codegen/src/lib.rsMTerm::App arm (:1998-2035), lower_applower_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-519synth_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:

#[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 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:

/// 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:

        // 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 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_applower_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:

    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::App arm to match the resolved Callee

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_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:
    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)> {
  1. 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 (: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-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:

            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 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):

    // 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.)

#[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:

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_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::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: 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.