# iter mut.2 — typecheck recognition of `Term::Mut` and `Term::Assign` **Date:** 2026-05-15 **Started from:** 3a5b1d33472e5c35c4917702e30fa5a12b749e31 **Status:** DONE **Tasks completed:** 7 of 7 ## Summary Replaces the mut.1 dispatch stubs in `synth` (`ailang-check`) for `Term::Mut` and `Term::Assign` with real typecheck logic. Three new `CheckError` variants ship (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with kebab-coded `code()` arms and structured `ctx()` payloads, following the cli-diag-human bracketed-`[code]` convention. A new `mut_scope_stack: &mut Vec>` parameter threads through every `synth` call site (24 sites across lib.rs + mono.rs:712 / mono.rs:1337 + lift.rs:723 + builtins.rs in two test helpers); each entry-point caller (`check_fn`, `check_const`, mono re-synth, lift letrec-capture synth) opens a fresh empty stack at top-of-body. `Term::Var` resolution prepends the mut-scope check ahead of the existing locals/globals chain (innermost-first wins on shadowing). `Term::Mut` pushes a frame after gating each `MutVar.ty` against `is_supported_mut_var_type` (Int / Float / Bool / Unit), synths inits in the in-progress scope, unifies init type with declared, and synths the body in the extended scope. `Term::Assign` resolves the target name through the stack, returns `MutAssignOutOfScope` (with a flat-deduplicated `available` list) on miss, compares value type against the declared via `subst.apply` + equality (manual to preserve the `AssignTypeMismatch` diagnostic identity), and yields `Type::unit()` on success. Five `.ail.json` fixtures + a 5-test driver (`mut_typecheck_pin.rs`) cover each diagnostic plus the positive nested-shadow case; the carve-out inventory at `carve_out_inventory.rs` is extended 7 → 12 to accept them. `examples/mut.ail` (the mut.1 six-fn round-trip fixture) now typechecks clean via `ail check`. Codegen stubs unchanged — `examples/mut.ail` still cannot run end-to-end; that lands in mut.3. Full `cargo test --workspace`: 592 passed / 0 failed (was 579 at start of mut.2; +13 = 5 driver pins + 7 lib.rs `mod tests` pins + 1 inventory test count unchanged — the inventory entry count went from `seven` to `twelve` but the test itself was already counted). ## Per-task notes - iter mut.2.1 — `CheckError` variants + `code()` + `ctx()`: Three new variants land before the `Internal` variant with thiserror `#[error("[code] message")]` attributes per the cli-diag-human convention. `code()` arms return the canonical kebab strings (`mut-assign-out-of-scope`, `assign-type-mismatch`, `mut-var-unsupported-type`). `ctx()` arms emit the structured payloads named in the plan (`{name, available}`, `{name, expected, actual}`, `{name, type}`). One in-crate pin (`mut_typecheck_diagnostics_have_kebab_codes`) gates the code() + ctx() shape; RED → GREEN observed. - iter mut.2.2 — `synth` signature threads `mut_scope_stack`: New parameter `mut_scope_stack: &mut Vec>` sits immediately after `locals` (canonical mid-position; matches the existing convention for per-fn-body lexical state). 18 recursive call sites in `lib.rs::synth` updated via pattern-by-pattern `replace_all` (each first-arg form had a unique-enough wrapper). Entry-point callers in `check_fn:1934` and `check_const:2687` declare a fresh empty stack. mono.rs's two re-synth sites at 712 + 1337 also declare a fresh empty stack (they begin from a top-of-body position). Plan-recon missed two more call sites: `lift.rs:723` (letrec-capture re-synth) and `builtins.rs` (two test helpers `synth_in_builtins_env` + a FloatPatternNotAllowed pin) — both required for build green; both get fresh empty stacks. The in-test mq.3 call site at `lib.rs:6663` also got the new parameter. Build was red structurally (E0061 at 23+ sites) until every call site was updated, then green. - iter mut.2.3 — `Term::Var` prepends mut-scope resolution: A 5- line block at the top of the Var arm walks `mut_scope_stack.iter().rev()` innermost-first, returning the matched type cloned. Mut-vars are monomorphic per spec §"var element types" — no Forall instantiation, no FreeFnCall. Two pins (`mut_var_resolution_shadows_outer_local`, `mut_var_resolution_picks_innermost_frame`) directly populate the stack to isolate this change from the Mut-arm work in Task 4 (the plan's literal Let { Mut { ... } } shape would have hit the still-stubbed Mut arm at this stage; the property protected — mut-scope wins over locals + innermost-wins on shadowing — is identical). TDD discipline gap: the Var-arm edit landed before the pin tests were observed RED on disk; the reasoning is analytical (the pre-edit code consults `locals.get(name)` first, so with `locals = { x → Int }` the Var arm returned Int, failing the assert-Float; same logic for the two-frame test). Recorded as Concern. - iter mut.2.4 — `Term::Mut` typecheck arm: Replaces the stub with real logic per plan Step 3. A file-scope helper `is_supported_mut_var_type(&Type) -> bool` gates each var's declared type to `Int | Float | Bool | Unit` (no args). Per-var loop synths the init in the outer scope plus the in-progress frame (the in-flight frame is cloned and pushed during the init synth, then popped — keeps `mut_scope_stack` truthful at every nested call), unifies init type with declared, inserts the (name → declared ty) entry into the frame. After all vars: push the completed frame, synth body in extended scope, pop frame (regardless of body outcome — the `body_result` is captured before pop). Two pins: `mut_block_typechecks_with_int_var_and_returns_int` (positive, initially RED because Assign was still stubbed — flipped GREEN by Task 5) and `mut_block_rejects_str_var_with_unsupported_type` (negative, GREEN at Task 4 close). - iter mut.2.5 — `Term::Assign` typecheck arm: Replaces the stub with real logic per plan Step 3. Resolves the target name via `mut_scope_stack.iter().rev().find_map(|f| f.get(name).cloned())`. None case: flattens every name in every frame innermost-first, dedup-preserving-order via a manual `contains` check (small N, no need for a Set), returns `MutAssignOutOfScope`. Some case: synths the value, compares `subst.apply(&declared)` vs `subst.apply(&value)` via `!=` (manual equality, NOT `unify` — unify would have emitted `TypeMismatch`, and the spec demands the distinct `AssignTypeMismatch` code), returns `AssignTypeMismatch` with rendered type strings on mismatch or `Ok(Type::unit())` on match. Three pins: `assign_to_in_scope_mut_var_synths_unit` (positive), `assign_to_out_of_scope_name_fires_diagnostic` (None case), `assign_with_wrong_value_type_fires_diagnostic` (Some-mismatch case). RED → GREEN observed for all three. - iter mut.2.6 — Five fixtures + driver test: Five `.ail.json` fixtures land under `examples/` (carve-out style, per the Boss decision in the carrier overriding the spec's literal `crates/ailang-check/tests/`-side placement). Each fixture is minimal — single-fn `main`, no imports, body shape exactly the one the plan named. The driver test `crates/ailang-check/tests/mut_typecheck_pin.rs` uses the canonical `load_workspace` + `check_workspace` + `diagnostics.iter().map(|d| d.code.clone()).collect()` pattern from `method_collision_pin.rs` and `typeclass_22b2.rs` — no `todo!()` survives. Carve-out inventory extended from seven to twelve to accept the five new fixtures (alphabetised; the comment block names mut.2 as the lineage). All 5 driver tests GREEN; carve-out inventory GREEN. - iter mut.2.7 — Verify `examples/mut.ail` typechecks clean: `cargo run --quiet --bin ail -- check examples/mut.ail` reports `ok (26 symbols across 2 modules)` — zero diagnostics across all six fns (`mut_empty`, `mut_single_var`, `mut_two_vars`, `mut_nested_shadow`, `mut_returns_bool`, `mut_returns_unit`). Full `cargo test --workspace`: 592 / 0. ## Concerns - Task 3 TDD-ordering gap: the `Term::Var` arm prepend landed before the two pin tests were observed RED on disk. The reasoning that the pre-edit code would have failed both pins is analytical (locals-first resolution returns Int when `locals = { x → Int }`, contradicting both `assert_eq!(Float)` and `assert_eq!(Bool)`). The discipline gap is recorded per the implementer's Iron Law ("the test you wrote in RED MUST pass; no other test may regress" — and the spirit-not-ritual clause that tests-after prove nothing about whether they would have caught the regression pre-edit). Subsequent tasks (4 + 5) restored the RED-first sequencing; on those, the Mut / Assign stubs already emit `CheckError::Internal` so the pins were observed RED with the canonical pattern. No correctness impact; the pins protect the property correctly post-edit. - Plan-recon missed three synth call sites beyond the four the plan enumerated (lib.rs 1885 + 2634; mono.rs 712 + 1337). The build-red structural signal flushed them out via E0061: `lib.rs:6663` (in-test mq.3 helper), `lift.rs:723` (letrec- capture re-synth), `builtins.rs` (`synth_in_builtins_env` at 351 + a FloatPatternNotAllowed test pin at 594). Each gets a fresh empty stack with the same "begins from top-of-body" rationale as mono.rs. No behavioural impact; documentary concern for the planner's next plan-recon dispatch. - The carve-out inventory extension was strictly required (the inventory test enforces a closed list of `examples/*.ail.json` files) but was not explicitly named in the Boss carrier or the plan. Recording here so the planner's recon for future iters knows to enumerate the side-effect when fixtures land under `examples/`. The boundary call: the fixtures match §C4(a)'s precedent (negative typecheck fixtures as `.ail.json` so the diagnostic-code assertion is the load-bearing form, not the surface). The positive `test_mut_nested_shadow_legal.ail.json` is the one stretch — its load-bearing assertion is "zero diagnostics", which the form-A `.ail` form could equally carry. Landing it as `.ail.json` keeps all five mut.2 fixtures on one driver and one extension on the carve-out list; the alternative (splitting positive to `.ail`, negatives to `.ail.json`) would have produced two drivers or a hybrid loader. Conservative call documented for visibility. ## Known debt - Codegen stubs unchanged in mut.2 per the spec's iteration boundary. A `Term::Mut` or `Term::Assign` reaching `lower_term` still produces `CodegenError::Internal`. The full mut.ail E2E (build + run) lands in mut.3. The spec's Acceptance criterion #3 (`mut_counter.ail` + `mut_sum_floats.ail` build + run + print expected stdout) is mut.3-scoped. - The Mut-arm's in-progress-frame clone-on-each-init is O(n²) over the var count. n is realistically ≤ 2 (the mut.ail fixture's `mut_two_vars` is the largest in the corpus). Not worth a more elaborate structure (e.g. pushing a single in- progress frame and mutating it across inits); the clone-pop shape keeps the stack truthful at every nested call and makes the per-var init scope obvious. Recording for visibility. ## Files touched Modified: - `crates/ailang-check/src/lib.rs` — 3 new CheckError variants + 3 code() arms + 3 ctx() arms; `synth` signature gains `mut_scope_stack` after `locals`; 18 recursive synth call sites updated; 2 entry-point callers (`check_fn`, `check_const`) declare fresh empty stacks; in-test synth call (mq.3 helper) gets new param; `Term::Var` arm prepends mut-scope resolution (5-line block); `Term::Mut` stub replaced with real logic (push/pop frame, per-var init, body synth); `Term::Assign` stub replaced with real logic (find/None-MutAssignOutOfScope/Some-AssignTypeMismatch-or-Unit); file-scope helper `is_supported_mut_var_type`; 7 new tests in `mod tests` (`mut_typecheck_diagnostics_have_kebab_codes`, `mut_var_resolution_shadows_outer_local`, `mut_var_resolution_picks_innermost_frame`, `mut_block_typechecks_with_int_var_and_returns_int`, `mut_block_rejects_str_var_with_unsupported_type`, `assign_to_in_scope_mut_var_synths_unit`, `assign_to_out_of_scope_name_fires_diagnostic`, `assign_with_wrong_value_type_fires_diagnostic`) plus the `synth_standalone` helper. - `crates/ailang-check/src/mono.rs` — 2 re-synth call sites (712, 1337) each get a fresh `mut_scope_stack`. - `crates/ailang-check/src/lift.rs` — letrec-capture re-synth at :723 gets a fresh `mut_scope_stack`. - `crates/ailang-check/src/builtins.rs` — 2 in-test synth calls (synth_in_builtins_env + FloatPatternNotAllowed pin) get a fresh `mut_scope_stack`. - `crates/ailang-core/tests/carve_out_inventory.rs` — EXPECTED list 7 → 12, comment-block updated naming mut.2 as the lineage of the 5 new entries. New: - `crates/ailang-check/tests/mut_typecheck_pin.rs` — 5-test driver via canonical load_workspace + check_workspace + collect-codes pattern. - `examples/test_mut_assign_out_of_scope.ail.json` - `examples/test_mut_assign_outside_mut.ail.json` - `examples/test_mut_assign_type_mismatch.ail.json` - `examples/test_mut_nested_shadow_legal.ail.json` - `examples/test_mut_var_unsupported_type.ail.json` ## Stats bench/orchestrator-stats/2026-05-15-iter-mut.2.json