plan: mut.3 — codegen + e2e for Term::Mut / Term::Assign

Nine tasks: Emitter struct extension with mut_var_allocas +
pending_entry_allocas + entry_block_end_marker fields (Task 1);
start_block captures the entry-block byte marker (Task 2);
Term::Var resolution prepends mut-var lookup with load emission
(Task 3); Term::Mut codegen — alloca to side buffer, init at
current position, store to alloca, push binding (Task 4);
Term::Assign codegen — load alloca, lower value, store (Task 5);
final flush of pending_entry_allocas into self.body at the marker
(Task 6); examples/mut_counter.ail (Int) + examples/mut_sum_floats.ail
(Float) (Task 7); e2e tests in crates/ail/tests/e2e.rs (Task 8);
DESIGN.md bullet under 'What is supported' (Task 9).

Five Boss decisions encoded: entry-block hoist via String::insert_str
at the captured marker (chosen over post-pass or non-standard
emit-at-current); no LLVM helper wrapper — direct push_str matches
existing codebase convention; e2e tests append to the existing
crates/ail/tests/e2e.rs (reuse build_and_run helper); DESIGN.md
bullet lives in 'What is supported' subsection (recon misread —
the section exists at line 2680); Unit-codegen via the existing
Term::Lit { lit: Unit } canonical form.
This commit is contained in:
2026-05-15 01:44:13 +02:00
parent b24718a5ff
commit b057789b55
+716
View File
@@ -0,0 +1,716 @@
# mut.3 — codegen + e2e — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-15-mut-local.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Replace the iter mut.1 dispatch stubs at
`crates/ailang-codegen/src/lib.rs:1683-1691` with real LLVM lowering:
mut-vars become `alloca` slots hoisted to the fn's entry block,
`assign` lowers to `store`, mut-var reads lower to `load`. Extend the
codegen-side `Term::Var` resolution ladder to consult mut-var allocas
before `self.locals`. Ship two end-to-end fixtures
(`examples/mut_counter.ail` + `examples/mut_sum_floats.ail`) and
two e2e test cases asserting expected stdout. Append a bullet to
DESIGN.md §"What is supported".
**Architecture:** Mut-var allocas land in the fn's entry block via
a side buffer `pending_entry_allocas: String` accumulated during
body lowering and flushed once into `self.body` at a tracked byte
position immediately after `start_block("entry")` emits the `entry:`
label. This is a deliberate departure from the existing alloca
sites (`match_lower.rs:114`, `lambda.rs:240/266`) which emit allocas
at the current body position — those allocas are one-shot per fn
invocation and don't suffer from being non-entry-block. Mut-var
allocas in deeply-nested blocks (e.g. inside `if`/`match`/`seq`)
must remain accessible across the whole fn body, so mem2reg
eligibility (entry-block placement) matters. Mut-var lookup at
codegen uses a per-fn `mut_var_allocas: BTreeMap<String, (String,
Type)>` mapping name → (alloca SSA name, AIL type). Push entries
on `Term::Mut`-entry, remove on exit; lexical scoping mirrors the
mut.2 typecheck-side stack.
**Tech Stack:** `ailang-codegen` only. No `ailang-core`,
`ailang-surface`, `ailang-check`, `ailang-prose`, or `ail` source
changes (one new test fn in `crates/ail/tests/e2e.rs`). Two new
example fixtures + one DESIGN.md amendment.
---
**Files this plan creates or modifies:**
- Create: `examples/mut_counter.ail` — sum 1..10 via mut, prints 55.
- Create: `examples/mut_sum_floats.ail` — Float twin (sum 1.0..10.0),
prints the Float total.
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` — add
`mut_var_allocas: BTreeMap<String, (String, Type)>` and
`pending_entry_allocas: String` and `entry_block_end_marker: Option<usize>`
fields to `Emitter`.
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` — initialise the
three new fields in `Emitter::new`.
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` — reset the
three new fields per fn-body in `emit_fn`, and capture the
entry-block byte marker after `start_block("entry")`.
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` — extend
`start_block` (or its caller) to record `entry_block_end_marker`
on entry-block emission.
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var`
arm) — prepend a mut-var lookup that emits a `load` and returns
the SSA name.
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1691` — replace
the `Term::Mut`/`Term::Assign` `Internal` stubs with real lowering.
- Modify: `crates/ailang-codegen/src/lib.rs` (end of `emit_fn`) —
flush `pending_entry_allocas` into `self.body` at the marker.
- Modify: `crates/ail/tests/e2e.rs` — add `mut_counter_prints_55`
and `mut_sum_floats_prints_55_dot_0` test fns.
- Modify: `docs/DESIGN.md` — append a bullet to the "What **is**
supported" subsection around line 2680.
---
## Task 1 — `Emitter` field additions + per-fn reset
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` (struct fields)
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` (`new`)
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` (`emit_fn` reset)
- [ ] **Step 1: Add three fields to `Emitter`**
Locate the `Emitter` struct around `crates/ailang-codegen/src/lib.rs:716`.
After the existing field `current_param_modes: BTreeMap<String, ParamMode>`
(line 717), insert:
```rust
/// Iter mut.3: per-fn map of mut-var name → (alloca SSA name,
/// AIL element type). Populated when entering a `Term::Mut`
/// block; entries removed when leaving the block. Consulted by
/// the `Term::Var` arm of `lower_term` before `self.locals`,
/// matching the typecheck-side mut-scope-stack precedence.
mut_var_allocas: BTreeMap<String, (String, Type)>,
/// Iter mut.3: side buffer for `alloca` instructions emitted
/// during body lowering but hoisted to the fn's entry block.
/// Flushed once into `self.body` at `entry_block_end_marker`
/// after `lower_term` completes.
pending_entry_allocas: String,
/// Iter mut.3: byte position in `self.body` immediately after
/// the `entry:` label, captured during `start_block("entry")`
/// at fn-body emission. Used to splice `pending_entry_allocas`
/// into the entry block once body lowering completes.
entry_block_end_marker: Option<usize>,
```
- [ ] **Step 2: Initialise in `Emitter::new`**
Locate `Emitter::new` around `crates/ailang-codegen/src/lib.rs:820`.
Near the existing `closure_drops: BTreeMap::new(),` line (around 831),
add:
```rust
mut_var_allocas: BTreeMap::new(),
pending_entry_allocas: String::new(),
entry_block_end_marker: None,
```
- [ ] **Step 3: Reset in `emit_fn`**
Locate the per-fn reset block in `emit_fn` (around
`crates/ailang-codegen/src/lib.rs:1008-1034` — where
`self.locals.clear()`, `self.counter = 0`, `self.moved_slots.clear()`,
`self.current_param_modes.clear()` already live). Append:
```rust
self.mut_var_allocas.clear();
self.pending_entry_allocas.clear();
self.entry_block_end_marker = None;
```
- [ ] **Step 4: Verify build green**
Run: `cargo build --workspace 2>&1 | tail -10`
Expected: green (the three new fields are added but not yet
consumed; the only consumers are added in Tasks 2-3).
---
## Task 2 — `start_block` records the entry-block marker
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` (`start_block`)
`start_block` is the canonical helper that emits `<label>:\n` and
positions the body for subsequent instruction emission. The entry
block is the first one emitted per fn. Marker capture happens here.
- [ ] **Step 1: Locate `start_block`**
Around `crates/ailang-codegen/src/lib.rs:837`, find:
```rust
fn start_block(&mut self, label: &str) {
self.body.push_str(label);
self.body.push_str(":\n");
}
```
(Exact body may differ; read the existing form first.)
- [ ] **Step 2: Capture the entry-block marker**
Replace with (or extend) the body so that when `label == "entry"`,
the byte position immediately after the `entry:\n` is captured:
```rust
fn start_block(&mut self, label: &str) {
self.body.push_str(label);
self.body.push_str(":\n");
if label == "entry" {
self.entry_block_end_marker = Some(self.body.len());
}
}
```
The marker points to the byte index where the next character would
be written — i.e. the splice point for `pending_entry_allocas`.
Splicing at this position inserts the alloca block immediately after
the `entry:\n` label and before any subsequent body emission.
- [ ] **Step 3: Verify build green**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.
---
## Task 3 — `Term::Var` resolution prepends mut-var lookup
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var` arm of `lower_term`)
- [ ] **Step 1: Locate the `Term::Var` arm**
Around `crates/ailang-codegen/src/lib.rs:1292`, the existing arm
walks: `__unreachable__``self.locals.iter().rev().find(...)`
`resolve_top_level_fn``resolve_const` → emit `UnknownVar`. The
mut-var lookup slots immediately before `self.locals`.
- [ ] **Step 2: Prepend mut-var lookup**
Insert before the `self.locals.iter().rev().find(...)` call:
```rust
// Iter mut.3: mut-vars take precedence over let-bound
// / param resolutions. Innermost-first (BTreeMap iterates
// in sorted order, but per-fn entries are unique per
// name with innermost-wins shadowing already enforced
// at typecheck time, so a single lookup suffices here).
if let Some((alloca_name, ail_ty)) = self.mut_var_allocas.get(name).cloned() {
let llvm_ty = self.llvm_type(&ail_ty);
let load_ssa = self.fresh_ssa_name("mut_load");
self.body.push_str(&format!(
" {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
));
return Ok(load_ssa);
}
```
The exact helper names (`self.llvm_type`, `self.fresh_ssa_name`,
`self.body`, the return-tuple shape) must match the existing arms in
`lower_term` — the implementer reads adjacent arms (e.g. the
`self.locals.iter().rev().find()` branch immediately below) to copy
the canonical form. The example above is shape-correct but the
function-call sites (`llvm_type` / `fresh_ssa_name`) may be different
in this codebase; verify before writing.
Note: shadowing across nested mut blocks is handled by the
push/remove in Task 4, not by stack ordering here — when an inner
mut block shadows an outer's name, the inner's push overwrites the
`BTreeMap` entry; the outer's restored on inner-exit.
- [ ] **Step 3: Verify build green**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.
---
## Task 4 — `Term::Mut` codegen arm
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1687` — replace the `Term::Mut` stub.
- [ ] **Step 1: Replace the stub**
Around `crates/ailang-codegen/src/lib.rs:1683`, replace:
```rust
Term::Mut { .. } => {
Err(CodegenError::Internal(
"Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
))
}
```
with the real lowering:
```rust
Term::Mut { vars, body } => {
// Saved-binding map for proper shadowing on exit.
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
for v in vars {
let alloca_name = self.fresh_ssa_name(&format!("mut_{}", v.name));
let llvm_ty = self.llvm_type(&v.ty);
// Emit the alloca into the entry-block side
// buffer (hoisted, not at current body position).
self.pending_entry_allocas.push_str(&format!(
" {alloca_name} = alloca {llvm_ty}\n"
));
// Lower the init expression at the current body
// position. The mut-var binding is NOT yet
// visible during init (matches typecheck-side
// ordering at lib.rs:3576).
let init_ssa = self.lower_term(&v.init, /* whatever existing params */)?;
// Store the init result into the alloca at the
// current body position.
self.body.push_str(&format!(
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
));
// Now bind the name → alloca, saving any prior
// entry for restoration on block exit.
let prior = self.mut_var_allocas.insert(
v.name.clone(),
(alloca_name, v.ty.clone()),
);
saved.push((v.name.clone(), prior));
}
// Lower the body in the extended scope.
let body_ssa = self.lower_term(body, /* whatever existing params */)?;
// Restore bindings on exit.
for (name, prior) in saved.into_iter().rev() {
match prior {
Some(p) => { self.mut_var_allocas.insert(name, p); }
None => { self.mut_var_allocas.remove(&name); }
}
}
Ok(body_ssa)
}
```
The exact `lower_term` call signature must match the existing arms
in the same `match` — the implementer reads an adjacent arm (e.g.
`Term::Let` or `Term::Seq`) for the canonical form. The init/store
ordering inside the loop is the load-bearing semantic: the spec
demands "vars are checked in order with the in-progress scope" at
typecheck (`lib.rs:3576`), and codegen mirrors this by binding only
AFTER the init is lowered.
- [ ] **Step 2: Build + verify the mut.ail typecheck-clean fns now don't reach codegen stub for Mut**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.
Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -20`
Expected: this command may fail at runtime for some of the six fns
that use Bool/Unit (not yet covered by Task 5 in lower_term's
`Term::Assign` arm) — but the build should NOT produce the
"Term::Mut not yet supported" error. If it does, the Task 4 arm
isn't reached; debug before continuing.
---
## Task 5 — `Term::Assign` codegen arm
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1688-1691` — replace the `Term::Assign` stub.
- [ ] **Step 1: Replace the stub**
Around `crates/ailang-codegen/src/lib.rs:1688`, replace:
```rust
Term::Assign { .. } => {
Err(CodegenError::Internal(
"Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
))
}
```
with the real lowering:
```rust
Term::Assign { name, value } => {
// Look up the mut-var's alloca and AIL type.
let (alloca_name, ail_ty) = self.mut_var_allocas
.get(name)
.cloned()
.ok_or_else(|| CodegenError::Internal(
format!("Term::Assign {{ name: {name:?} }} reached codegen without a mut-var alloca — typecheck should have rejected this earlier")
))?;
let llvm_ty = self.llvm_type(&ail_ty);
let value_ssa = self.lower_term(value, /* existing params */)?;
self.body.push_str(&format!(
" store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
));
// Term::Assign's static type is Unit. Yield the
// canonical Unit SSA value per the codegen
// convention for Unit (verify against an adjacent
// arm — likely a literal "i1 0" or similar; the
// mut.ail mut_returns_unit fixture exercises this).
Ok(self.unit_ssa()) // or whichever helper returns the canonical Unit SSA
}
```
The `self.unit_ssa()` helper name is a placeholder; the implementer
identifies the canonical "Unit value" SSA emission convention from
an adjacent codegen arm (e.g. `Term::Lit { lit: Literal::Unit }`)
and uses whichever form matches.
- [ ] **Step 2: Verify build + mut.ail full pipeline**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.
Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -10`
Expected: build succeeds. (Whether the binary runs successfully is
exercised in Task 7 with the dedicated e2e fixtures.)
---
## Task 6 — Flush `pending_entry_allocas` at the end of `emit_fn`
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs` — the end of `emit_fn` (just before the fn body is written out / `}` is emitted).
- [ ] **Step 1: Locate the emit_fn end**
Around `crates/ailang-codegen/src/lib.rs:1008+`, find where `emit_fn`
finishes assembling `self.body` and is about to flush it to
`self.ir` (or wherever the final IR text accumulates). This is the
splice point.
- [ ] **Step 2: Splice `pending_entry_allocas` at the marker**
Immediately before the body is written out:
```rust
// Iter mut.3: splice any deferred entry-block allocas at
// the captured marker (immediately after the `entry:\n`
// label). If no mut-vars were emitted for this fn,
// `pending_entry_allocas` is empty and this is a no-op.
if !self.pending_entry_allocas.is_empty() {
if let Some(marker) = self.entry_block_end_marker {
let allocas = std::mem::take(&mut self.pending_entry_allocas);
self.body.insert_str(marker, &allocas);
} else {
return Err(CodegenError::Internal(
"pending_entry_allocas accumulated but no entry-block marker was captured".into(),
));
}
}
```
This splices the alloca instructions into `self.body` at the byte
position recorded by Task 2's `start_block` extension. The result is
an entry block whose first instructions are `alloca` for each
mut-var encountered anywhere in the fn body, followed by whatever
the original entry-block instructions are.
- [ ] **Step 3: Verify build green**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.
---
## Task 7 — `examples/mut_counter.ail` + `examples/mut_sum_floats.ail`
**Files:**
- Create: `examples/mut_counter.ail`
- Create: `examples/mut_sum_floats.ail`
- [ ] **Step 1: Write `examples/mut_counter.ail`**
Create `examples/mut_counter.ail`:
```
(module mut_counter
(fn main
(doc "Iter mut.3 — sum 1..10 via mut/var/assign. Expected stdout: 55.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var i (con Int) 1)
(var sum (con Int) 0)
(assign sum (loop_body i sum)))))))
(fn loop_body
(doc "Helper — recursive sum 1..10. Replaced by a real loop in a future milestone.")
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
(params i acc)
(body
(if (app > i 10)
acc
(app loop_body (app + i 1) (app + acc i))))))
```
Wait — this requires mut to call a helper, which the mut codegen
supports (init / body / assign-value can be any Term). But the
example's *idiomatic* form is harder to express without `while`
(deferred). The simplest accurate form:
```
(module mut_counter
(fn main
(doc "Iter mut.3 — sum 1..10 via mut + recursive helper. Expected stdout: 55.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var sum (con Int) 0)
(assign sum (app sum_helper 1 10))
sum))))
(fn sum_helper
(doc "Recursive helper — sum from lo through hi inclusive.")
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
(params lo hi)
(body
(if (app > lo hi)
0
(app + lo (tail-app sum_helper (app + lo 1) hi))))))
```
The mut block exercises one var (`sum`) with a real `assign` of a
non-trivial Term result. The recursive helper is the standing
tail-call iteration shape per Decision 8. Expected stdout: `55`.
- [ ] **Step 2: Write `examples/mut_sum_floats.ail`**
Create `examples/mut_sum_floats.ail`:
```
(module mut_sum_floats
(fn main
(doc "Iter mut.3 — Float twin of mut_counter. Expected stdout: 55.0 (or whatever the polymorphic-print form emits for Float 55.0).")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(mut
(var sum (con Float) 0.0)
(assign sum (app sum_helper 1.0 10.0))
sum))))
(fn sum_helper
(doc "Recursive Float helper — sum from lo through hi inclusive.")
(type (fn-type (params (con Float) (con Float)) (ret (con Float))))
(params lo hi)
(body
(if (app > lo hi)
0.0
(app + lo (tail-app sum_helper (app + lo 1.0) hi))))))
```
Expected stdout: whatever the polymorphic Float `print` emits for
the value `55.0`. The implementer verifies against an existing
Float-print fixture (e.g. `examples/floats.ail`'s known output) to
fix the expected string.
- [ ] **Step 3: Verify both fixtures round-trip + typecheck clean**
Run: `cargo run --quiet --bin ail -- check examples/mut_counter.ail 2>&1`
Expected: `ok (...)`.
Run: `cargo run --quiet --bin ail -- check examples/mut_sum_floats.ail 2>&1`
Expected: `ok (...)`.
Run: `cargo test --workspace -p ailang-surface round_trip 2>&1 | tail -5`
Expected: PASS — the auto-glob round-trip picks up both new fixtures.
---
## Task 8 — e2e tests in `crates/ail/tests/e2e.rs`
**Files:**
- Modify: `crates/ail/tests/e2e.rs`
- [ ] **Step 1: Read the canonical e2e test pattern**
Open `crates/ail/tests/e2e.rs` and locate the `build_and_run`
helper (around lines 13-38 per the recon). Read the convention for
a `#[test]` that invokes it and asserts on stdout.
- [ ] **Step 2: Add the two test fns**
At the end of `crates/ail/tests/e2e.rs`, append:
```rust
#[test]
fn mut_counter_prints_55() {
let stdout = build_and_run("mut_counter.ail");
assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
}
#[test]
fn mut_sum_floats_prints_55_dot() {
let stdout = build_and_run("mut_sum_floats.ail");
// The exact float-print form is whatever the polymorphic print
// emits for 55.0 — likely "55" if the formatter drops trailing
// zeros, "55.0" if it preserves them. Verify against
// examples/floats.ail's expected output before pinning.
let expected = "55"; // placeholder — implementer reads the floats.ail e2e for the canonical form
assert_eq!(stdout.trim(), expected, "mut_sum_floats must print {expected}, got {stdout:?}");
}
```
The "placeholder" comment marks a piece of work the implementer must
resolve at execution time by reading whichever existing Float-print
e2e test pins the expected stdout — the placeholder MUST NOT survive
into the committed file. Replace with the correct expected string
before the test passes.
- [ ] **Step 3: Run the new tests**
Run: `cargo test --workspace --test e2e mut_counter_prints_55 mut_sum_floats_prints_55_dot 2>&1 | tail -10`
Expected: both PASS.
Run: `cargo test --workspace 2>&1 | tail -5`
Expected: full workspace green.
---
## Task 9 — DESIGN.md amendment
**Files:**
- Modify: `docs/DESIGN.md` — append to "What **is** supported" subsection (around line 2680-2800).
- [ ] **Step 1: Locate the section**
Read `docs/DESIGN.md:2680-2800`. The section starts with:
```
What **is** supported (and used as the smoke test for the pipeline):
- Int, Bool, Unit, **Str**, **Float** as primitive types.
- `if`, `let`, function calls, recursion.
...
```
Find a suitable insertion point — likely after the "Anonymous lambdas
with capture" bullet (around line 2776) or after "Parameterised
ADTs" (around line 2788), depending on where the implementer judges
the new bullet thematically fits.
- [ ] **Step 2: Append the bullet**
Insert (with appropriate context):
```
- **Local mutable state.** `(mut (var <name> <type> <init>) ... <body>)`
declares lexically-scoped mutable bindings whose types are restricted
to the stack-resident scalars `Int`, `Float`, `Bool`, `Unit`. Inside
the block, `(assign <name> <value>)` updates a mut-var; references
to a mut-var name resolve to a `load` at codegen. Mut-vars are
alloca-resident (hoisted to the fn's entry block, exercised by
`examples/mut_counter.ail` and `examples/mut_sum_floats.ail`); they
do not escape the enclosing mut block and do not introduce a `!Mut`
effect onto the surrounding fn signature. Sealed-by-construction:
the spec restricts var element types to non-RC-managed primitives
and forbids first-class references, so Decision 10's "no shared
mutable refs" invariant is preserved (each mut-var is uniquely held
by its enclosing block). Future milestones on the Stateful-islands
path lift the type restriction (heap-RC-managed Str + ADTs), add
escaping references (`ref a` + `!Mut` effect), and introduce
composable transducers (`Stateful a b` + `pipe`). Spec:
`docs/specs/2026-05-15-mut-local.md`.
```
- [ ] **Step 3: Verify schema-drift tests still pass**
Run: `cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5`
Expected: PASS — the drift tests scope themselves to §"Data model"
(which is the §"Term (expression)" jsonc-blocked area, already
amended in mut.1), and this new bullet lives outside that scope.
---
## Self-review checklist
- [ ] **Spec coverage:** every section of spec §"Iteration mut.3 —
Codegen + e2e" has a matching task. Cross-check: codegen arms for
`Term::Mut` (Task 4) and `Term::Assign` (Task 5), entry-block-
alloca-hoist mechanism (Tasks 1+2+6), `Term::Var` resolution
prepending (Task 3), two example fixtures (Task 7), e2e tests
(Task 8), DESIGN.md amendment (Task 9). All covered.
- [ ] **Placeholder scan:** grep for "TBD" / "TODO" / "implement
later" / "similar to Task" / "appropriate error handling" /
"fill in later". Task 8 Step 2 contains a deliberate
"implementer reads the floats.ail e2e for the canonical form"
placeholder that must be resolved before the test file is
committed — the step explicitly names this. No other placeholders.
- [ ] **Type-name consistency:** `mut_var_allocas`,
`pending_entry_allocas`, `entry_block_end_marker`,
`CodegenError::Internal`, `LLVMBuildAlloca` (referenced in spec
but in this codebase emitted as text via `push_str`), `Term::Mut`,
`Term::Assign`, `MutVar`. Each appears identically across tasks.
- [ ] **Step granularity:** each step is 2-5 minutes (a single
field addition; a single arm replacement; a single splice point).
- [ ] **No commit steps:** verified — no `git commit` instructions.
- [ ] **Boss decisions named:** the five recon open questions are
resolved: (1) entry-block-hoist via side buffer +
`String::insert_str` splice at marker (Tasks 1+2+6); (2) no LLVM
helper wrapper — direct `push_str` (Task 4+5 code shape);
(3) e2e in `crates/ail/tests/e2e.rs` (Task 8); (4) DESIGN.md
bullet in "What **is** supported" subsection (Task 9 Step 1);
(5) Unit codegen via the existing `Term::Lit { lit: Unit }` arm's
canonical SSA emission (Task 5 Step 1 instruction).
---
## Acceptance gate for iteration mut.3
- `cargo build --workspace` green.
- `cargo test --workspace` green.
- `cargo run --bin ail -- check examples/mut.ail` clean.
- `cargo run --bin ail -- check examples/mut_counter.ail` clean.
- `cargo run --bin ail -- check examples/mut_sum_floats.ail` clean.
- `mut_counter.ail` builds and runs end-to-end via the e2e harness,
printing `55`.
- `mut_sum_floats.ail` builds and runs end-to-end, printing the
canonical Float-print form for `55.0` (resolved per Task 8 Step 2).
- DESIGN.md has the "Local mutable state" bullet under "What **is**
supported".
After this iteration closes, the **mut-local milestone is closed**
end-to-end:
- Schema + surface from mut.1.
- Typecheck from mut.2.
- Codegen + e2e from mut.3.
The follow-on Stateful-islands milestones (ref-types, !Mut effect,
MutArray, Stateful + pipe) build on top of this. Audit (architect
drift + bench regression) follows.