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:
@@ -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.
|
||||
Reference in New Issue
Block a user