# mut.4-tidy — Implementation Plan > **Parent spec:** `docs/specs/0029-mut-local.md` (covered by > audit-mut-local findings; no new spec). > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Close the mut-local milestone audit by addressing the architect's drift findings and the bench check_ms regression. Specifically: (a) add a typecheck diagnostic that rejects lambda-captures-of-mut-var (`CheckError::MutVarCapturedByLambda`) and a negative fixture; (b) replace the codegen `lambda.rs` blame- typechecker comment + Internal-error path with an `unreachable!` once the typecheck gate is in place; (c) clean stale mut.1-stub history comments in DESIGN.md §"Term" and `crates/ailang-codegen/src/lib.rs`; (d) short-circuit the empty `mut_scope_stack` walk in the `Term::Var` arm of `synth` to eliminate the per-Var-resolution overhead for programs with no mut blocks. **Architecture:** Architect's two `[high]` drift items are downstream of one spec gap: `docs/specs/0029-mut-local.md` does not enumerate "lambda body referencing an enclosing mut-var" as a diagnostic. The fix has three sites — typecheck adds the diagnostic in `Term::Lam`'s synth arm, codegen replaces its blame-comment path with `unreachable!`, and the spec gets an "Out of scope" amendment noting that mut-vars cannot escape into closures in this milestone. The bench check_ms regression has one root cause — the `mut_scope_stack.iter().rev().find_map(...)` walk at every `Term::Var` resolution runs even when the stack is empty (every program with no mut blocks); short-circuiting on `is_empty()` makes the path zero-cost for the existing corpus. **Tech Stack:** `ailang-check`, `ailang-codegen`, `docs/`. Plus one new negative fixture and one driver-test extension. --- **Files this plan creates or modifies:** - Create: `examples/test_mut_var_captured_by_lambda.ail.json` — negative fixture: a lambda inside a mut block references a mut-var from the enclosing scope. - Modify: `crates/ailang-check/src/lib.rs:638-746` — add `CheckError::MutVarCapturedByLambda { name }` variant + `code()` arm `mut-var-captured-by-lambda` + `ctx()` arm + Display. - Modify: `crates/ailang-check/src/lib.rs` (`Term::Lam` arm of `synth`) — walk the lambda's body for free Vars that resolve to the enclosing `mut_scope_stack`; reject with `MutVarCapturedByLambda` if any match. - Modify: `crates/ailang-check/src/lib.rs` (`Term::Var` arm of `synth`) — short-circuit when `mut_scope_stack.is_empty()`. - Modify: `crates/ailang-codegen/src/lambda.rs:88-99` (capture-not- in-locals path) — replace the `CodegenError::Internal(... blames typechecker ...)` with `unreachable!("typecheck rejects lambda-capture-of-mut-var via MutVarCapturedByLambda since mut.4-tidy")`. - Modify: `crates/ailang-codegen/src/lambda.rs:466-467` — remove the doc-comment promise that mut.2 typecheck rejects lambda captures (it does as of mut.4); rewrite to reflect current reality. - Modify: `crates/ailang-check/src/lift.rs:379` — same comment cleanup. - Modify: `crates/ailang-codegen/src/lib.rs:1749-1758` — remove the stale mut.1-stub comment block above the real Term::Mut arm. - Modify: `docs/DESIGN.md:2369-2402` (§"Term (expression)" mut/assign jsonc block) — remove the trailing paragraph describing the mut.1-stub dispatch state; replace with a paragraph describing the current end-to-end mut.3 state. - Modify: `docs/specs/0029-mut-local.md` (§"Out of scope" or equivalent) — add a bullet naming "Lambda capture of a mut-var" as explicitly rejected, with the diagnostic name. - Modify: `crates/ailang-check/tests/mut_typecheck_pin.rs` — add driver test for the new fixture asserting the `mut-var-captured-by-lambda` diagnostic code. - Modify: `crates/ailang-core/tests/carve_out_inventory.rs` — EXPECTED extended 12 → 13 for the new negative fixture. --- ## Task 1 — `CheckError::MutVarCapturedByLambda` **Files:** - Modify: `crates/ailang-check/src/lib.rs:638-746` - [ ] **Step 1: Write the failing pin test** In `#[cfg(test)] mod tests`: ```rust #[test] fn mut_var_captured_by_lambda_diagnostic_has_kebab_code() { let e = CheckError::MutVarCapturedByLambda { name: "sum".into() }; assert_eq!(e.code(), "mut-var-captured-by-lambda"); let _ = e.ctx(); } ``` - [ ] **Step 2: Run to verify red** Run: `cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code` Expected: FAIL — variant does not exist. - [ ] **Step 3: Add the variant + code() + ctx() + Display** In the `CheckError` enum body, immediately after `UnsupportedMutVarType`, insert: ```rust /// Iter mut.4-tidy: `Term::Lam` reached typecheck inside a /// `Term::Mut` scope, and the lambda's body references a mut-var /// declared in that enclosing scope. Spec /// `docs/specs/0029-mut-local.md` §"Out of scope": mut-vars /// cannot escape — they are alloca-resident and lexically scoped. /// Capturing one into a closure would require either lifting it /// to the heap (deferred milestone) or rejecting the capture. /// This milestone takes the latter route. #[error("[mut-var-captured-by-lambda] mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] MutVarCapturedByLambda { name: String }, ``` Then the `code()` arm immediately after `UnsupportedMutVarType`'s: ```rust CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda", ``` And the `ctx()` arm: ```rust CheckError::MutVarCapturedByLambda { name } => json!({ "name": name, }), ``` - [ ] **Step 4: Verify** Run: `cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code` Expected: PASS. --- ## Task 2 — Reject lambda-captures-of-mut-var in `Term::Lam` synth arm **Files:** - Modify: `crates/ailang-check/src/lib.rs` (`Term::Lam` arm of `synth`) The arm is around `lib.rs:3374-3407` per the architect's report. The lambda's body is typechecked in a fresh `locals` scope with the lambda's params bound — but `mut_scope_stack` carries over from the caller. The fix is: BEFORE descending into the lambda body, scan the body for free Vars whose names resolve in `mut_scope_stack`. If any do, reject. - [ ] **Step 1: Write the failing pin (positive — lambda outside mut still works)** Add to `#[cfg(test)] mod tests`: ```rust #[test] fn lambda_outside_mut_still_typechecks_clean() { // (lam (params (typed x (con Int))) (ret (con Int)) (body (app + x 1))) let term = Term::Lam { params: vec!["x".into()], param_types: vec![Type::int()], ret_type: Type::int(), effects: vec![], body: Box::new(Term::App { func: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Var { name: "x".into() }, Term::Lit { lit: Literal::Int { value: 1 } }, ], // tail-call default field name }), }; let (_ty, _) = synth_in_builtins_env(&term); // No assertion on the returned type beyond that it doesn't panic // / fail — this is a regression test that lambda synth wasn't // broken by the new lambda-body free-var scan. } ``` This test should PASS even before Task 2 Step 2 — it's a regression guard. - [ ] **Step 2: Write the failing negative pin** ```rust #[test] fn lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda() { // (mut (var x (con Int) 0) // (let f (lam (params) (ret (con Int)) (body x)) ;; captures x // 0)) let term = Term::Mut { vars: vec![ailang_core::ast::MutVar { name: "x".into(), ty: Type::int(), init: Term::Lit { lit: Literal::Int { value: 0 } }, }], body: Box::new(Term::Let { name: "f".into(), value: Box::new(Term::Lam { params: vec![], param_types: vec![], ret_type: Type::int(), effects: vec![], body: Box::new(Term::Var { name: "x".into() }), }), body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), }), }; let err = synth_in_builtins_env_expect_err(&term); match err { CheckError::MutVarCapturedByLambda { name } => assert_eq!(name, "x"), other => panic!("expected MutVarCapturedByLambda, got {:?}", other), } } ``` (`synth_in_builtins_env_expect_err` is whatever helper the existing tests use to drive synth and unwrap an Err — match the convention.) - [ ] **Step 3: Run to verify red** Run: `cargo test --workspace -p ailang-check lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda` Expected: FAIL — currently the lambda body resolves `x` against the outer `mut_scope_stack` and types as Int with no diagnostic. - [ ] **Step 4: Add the scan to `Term::Lam`'s synth arm** In the `Term::Lam { params, param_types, body, .. }` arm of `synth`, BEFORE descending into the body (i.e. before the call to `synth(body, &lambda_locals, mut_scope_stack, ...)`), insert: ```rust // Iter mut.4-tidy: reject lambda-captures-of-mut-var. // Walk the lambda body for free Vars (names not bound by the // lambda's own params) that match any mut-var on the enclosing // stack. If any match, reject with MutVarCapturedByLambda. if !mut_scope_stack.is_empty() { let captured = collect_free_vars_in_term(body); let lambda_param_names: std::collections::BTreeSet<&str> = params.iter().map(|s| s.as_str()).collect(); for free_name in &captured { if lambda_param_names.contains(free_name.as_str()) { continue; } for frame in mut_scope_stack.iter() { if frame.contains_key(free_name) { return Err(CheckError::MutVarCapturedByLambda { name: free_name.clone(), }); } } } } ``` The helper `collect_free_vars_in_term(t: &Term) -> Vec` may already exist (the codebase has free-var walkers in `desugar.rs:1154`- area). If a directly-callable helper does not exist, write a small local one: ```rust fn collect_free_vars_in_term(t: &Term) -> Vec { fn walk(t: &Term, bound: &mut Vec, out: &mut Vec) { match t { Term::Var { name } => { if !bound.contains(name) && !out.contains(name) { out.push(name.clone()); } } Term::Let { name, value, body } => { walk(value, bound, out); bound.push(name.clone()); walk(body, bound, out); bound.pop(); } Term::LetRec { name, body, in_term, .. } => { bound.push(name.clone()); walk(body, bound, out); walk(in_term, bound, out); bound.pop(); } Term::Lam { params, body, .. } => { let n = bound.len(); for p in params { bound.push(p.clone()); } walk(body, bound, out); bound.truncate(n); } Term::Mut { vars, body } => { for v in vars { walk(&v.init, bound, out); } let n = bound.len(); for v in vars { bound.push(v.name.clone()); } walk(body, bound, out); bound.truncate(n); } Term::Assign { name, value } => { // Assign's name IS a use of the binding — counts as a free var if not bound. if !bound.contains(name) && !out.contains(name) { out.push(name.clone()); } walk(value, bound, out); } Term::App { func, args, .. } => { walk(func, bound, out); for a in args { walk(a, bound, out); } } Term::If { cond, then, else_ } => { walk(cond, bound, out); walk(then, bound, out); walk(else_, bound, out); } Term::Match { scrutinee, arms } => { walk(scrutinee, bound, out); for arm in arms { // pattern bindings... for simplicity, scan body // without removing pattern-bound names — over- // approximates free vars (worst-case: emits the // diagnostic when none was needed). The over- // approximation is conservative-safe for // capture-rejection; a false-positive can be // tightened in a future iter. walk(&arm.body, bound, out); } } Term::Seq { lhs, rhs } => { walk(lhs, bound, out); walk(rhs, bound, out); } Term::Clone { value } => walk(value, bound, out), Term::ReuseAs { source, body } => { walk(source, bound, out); walk(body, bound, out); } Term::Ctor { args, .. } => for a in args { walk(a, bound, out); }, Term::Do { args, .. } => for a in args { walk(a, bound, out); }, Term::Lit { .. } => {} } } let mut bound = Vec::new(); let mut out = Vec::new(); walk(t, &mut bound, &mut out); out } ``` (The exact match-arm shape must be exhaustive against the post-mut.1 Term enum. Pattern bindings inside Term::Match arms are over-approximated for the first cut; a tightening pass can land in a future tidy iter.) - [ ] **Step 5: Verify** Run: `cargo test --workspace -p ailang-check lambda_outside_mut_still_typechecks_clean lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda` Expected: both PASS. Run: `cargo test --workspace 2>&1 | grep -E 'FAILED|failures' | head -5` Expected: no failures introduced. (If any existing test broke, likely cause: the over-approximation in pattern bindings — check which test, decide whether to tighten the helper or to add a specific bound-name extraction for Term::Match arms.) --- ## Task 3 — Short-circuit empty `mut_scope_stack` in `Term::Var` arm **Files:** - Modify: `crates/ailang-check/src/lib.rs:2743-2747` - [ ] **Step 1: Locate the existing prepend** Find the iter mut.2 prepend in the `Term::Var` arm of `synth`, introduced at `lib.rs:2743-2747`: ```rust for frame in mut_scope_stack.iter().rev() { if let Some(ty) = frame.get(name) { return Ok((ty.clone(), /* ... */)); } } ``` - [ ] **Step 2: Wrap in an `is_empty` guard** ```rust if !mut_scope_stack.is_empty() { for frame in mut_scope_stack.iter().rev() { if let Some(ty) = frame.get(name) { return Ok((ty.clone(), /* ... */)); } } } ``` This is a single-instruction short-circuit. For the existing test corpus (zero mut blocks outside the four mut*.ail fixtures), this collapses the prepend cost to one `is_empty()` check per Var resolution — effectively zero overhead. - [ ] **Step 3: Verify the existing pin tests still pass** Run: `cargo test --workspace -p ailang-check 2>&1 | tail -5` Expected: PASS. - [ ] **Step 4: Re-run `compile_check.py` to confirm regression closes** Run: `bench/compile_check.py 2>&1 | tail -10` Expected: zero `REGRESSION` lines on check_ms. (If the short-circuit brings check_ms back within tolerance, the bench exit drops to 0 for this script. If not, additional optimization is needed — but the prepend cost is the only known introduced overhead in mut.2.) --- ## Task 4 — Codegen comment cleanup + unreachable blame fix **Files:** - Modify: `crates/ailang-codegen/src/lambda.rs:88-99` - Modify: `crates/ailang-codegen/src/lambda.rs:466-467` - Modify: `crates/ailang-codegen/src/lib.rs:1749-1758` - [ ] **Step 1: `lambda.rs:88-99` — replace Internal blame with unreachable!** Locate the existing path where a lambda capture is not found in `self.locals`. The current arm raises `CodegenError::Internal` with a message blaming the typechecker. Replace with: ```rust None => { // Iter mut.4-tidy: post-Task-2 the typechecker // rejects this via `CheckError::MutVarCapturedByLambda`. // Reaching this path means typecheck was skipped or // a bug let the AST through — both are bugs in // upstream layers, not in codegen. unreachable!( "lambda capture {name:?} not in locals — \ typecheck should have rejected via MutVarCapturedByLambda", ); } ``` - [ ] **Step 2: `lambda.rs:466-467` — comment cleanup** The existing comment promises mut.2 rejects lambda captures of mut-vars. Update to: ```rust // Mut-var captures by lambdas are rejected at typecheck by // CheckError::MutVarCapturedByLambda (mut.4-tidy). codegen does // not see them. ``` - [ ] **Step 3: `lib.rs:1749-1758` — remove stale mut.1-stub comment** The architect's report names this site as still describing the mut.1 stub state. Read the current comment and either delete it (if purely historical) or rewrite to reflect the mut.3 reality. The new comment should NOT mention "stub", "deferred to mut.3", or "Internal-error". A one-line comment summarising the arm's purpose ("Lower a mut-block: per-var alloca emit + body lower + scope exit") is sufficient. - [ ] **Step 4: Verify build green** Run: `cargo build --workspace 2>&1 | tail -5` Expected: green. Run: `cargo test --workspace 2>&1 | tail -5` Expected: green. --- ## Task 5 — `lift.rs` comment cleanup **Files:** - Modify: `crates/ailang-check/src/lift.rs:379` - [ ] **Step 1: Update the comment** The existing comment promises mut.2 rejects lambda captures of mut-vars. Update to match the mut.4-tidy reality, in the same form as Task 4 Step 2. - [ ] **Step 2: Verify build green** Run: `cargo build --workspace 2>&1 | tail -5` Expected: green. --- ## Task 6 — DESIGN.md amendment **Files:** - Modify: `docs/DESIGN.md:2369-2402` (§"Term (expression)" mut/assign block) - [ ] **Step 1: Update the trailing paragraph** The current jsonc block ends with a paragraph describing the mut.1-stub state. Replace with: ``` Both shapes ship in fully-lowered form as of iter mut.3 (2026-05-15): typecheck binds mut-vars in a lexical scope stack, codegen lowers them as entry-block allocas, and `Term::Assign` emits a `store` yielding the canonical Unit SSA. Mut-vars must be of one of the stack-resident scalar types {Int, Float, Bool, Unit}; capturing a mut-var into a lambda is rejected at typecheck via `CheckError::MutVarCapturedByLambda`. See `docs/specs/0029-mut-local.md`. ``` - [ ] **Step 2: Verify schema-drift tests still pass** Run: `cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5` Expected: PASS. --- ## Task 7 — Spec amendment **Files:** - Modify: `docs/specs/0029-mut-local.md` (§"Out of scope" or §"Acceptance criteria") - [ ] **Step 1: Add bullet to §"Out of scope"** In the §"Out of scope" subsection, append a bullet: ``` - **Lambda capture of a mut-var.** A lambda body whose free vars include a mut-var of an enclosing `Term::Mut` is rejected at typecheck with `CheckError::MutVarCapturedByLambda`. Mut-vars are alloca-resident and lexically scoped; lifting them to the heap for closure-capture would require ref-types and the `!Mut` effect, both of which are deferred to the follow-on Stateful-islands milestone (the layered effect-handler + ref-type combo). The rejection diagnostic was added in iter mut.4-tidy. ``` --- ## Task 8 — Negative fixture + driver test extension **Files:** - Create: `examples/test_mut_var_captured_by_lambda.ail.json` - Modify: `crates/ailang-check/tests/mut_typecheck_pin.rs` - Modify: `crates/ailang-core/tests/carve_out_inventory.rs` - [ ] **Step 1: Write the fixture** Create `examples/test_mut_var_captured_by_lambda.ail.json`: ```json { "name": "test_mut_var_captured_by_lambda", "imports": [], "defs": [ { "kind": "fn", "name": "main", "ty": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" } }, "params": [], "body": { "t": "mut", "vars": [ { "name": "x", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } } ], "body": { "t": "let", "name": "f", "value": { "t": "lam", "params": [], "paramTypes": [], "retType": { "k": "con", "name": "Int" }, "effects": [], "body": { "t": "var", "name": "x" } }, "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } } } } } ] } ``` Verify the exact root-key + field-name shape against an existing sibling fixture (e.g. `examples/test_mut_assign_out_of_scope.ail.json`). - [ ] **Step 2: Add driver test** In `crates/ailang-check/tests/mut_typecheck_pin.rs`: ```rust #[test] fn lambda_capturing_mut_var_emits_mut_var_captured_by_lambda() { let codes = check_fixture("test_mut_var_captured_by_lambda.ail.json"); assert_eq!(codes, vec!["mut-var-captured-by-lambda".to_string()]); } ``` - [ ] **Step 3: Update carve-out inventory** In `crates/ailang-core/tests/carve_out_inventory.rs`, bump the EXPECTED count from 12 → 13 (the new fixture joins the existing twelve `.ail.json` carve-outs). - [ ] **Step 4: Run all driver tests** Run: `cargo test --workspace -p ailang-check mut_typecheck_pin 2>&1 | tail -5` Expected: all six tests PASS (five from mut.2 + the new one). Run: `cargo test --workspace -p ailang-core carve_out_inventory 2>&1 | tail -3` Expected: PASS. Run: `cargo test --workspace 2>&1 | tail -5` Expected: full workspace green. --- ## Self-review checklist - [ ] **Drift coverage:** all four architect items (two `[high]` + two `[medium]`) plus the bench check_ms regression are addressed. Architect `[low]` items (prose render of MutVar) deliberately deferred per architect's recommendation. - [ ] **Placeholder scan:** grep for "TBD" / "TODO" / "implement later" / "similar to Task" / "appropriate error handling" — no hits in this plan. - [ ] **Type-name consistency:** `CheckError::MutVarCapturedByLambda`, `mut-var-captured-by-lambda` (kebab code), `collect_free_vars_in_term` (local helper), `mut_scope_stack`, `unreachable!` — each appears identically across tasks. - [ ] **Step granularity:** each step is 2-5 minutes. - [ ] **No commit steps:** verified. --- ## Acceptance gate for iter mut.4-tidy - `cargo build --workspace` green. - `cargo test --workspace` green, including the new negative-fixture pin and the carve_out_inventory bump. - `bench/compile_check.py` check_ms regression closed (within tolerance vs. baseline). - `bench/check.py` and `bench/cross_lang.py` unchanged from their pre-mut.4 state (tail-noise envelope holds; runtime stable). - All architect-flagged comments rewritten to reflect the post-mut.3 reality. - The follow-on Stateful-islands milestone is now unblocked per the architect's gate.