Files
AILang/docs/journals/2026-05-15-iter-mut.2.md
T
Brummel b24718a5ff iter mut.2: typecheck for Term::Mut + Term::Assign
Replaces the iter mut.1 dispatch stubs in synth with real typecheck
logic. Codegen stubs stay (deferred to mut.3); examples/mut.ail now
typechecks clean but still cannot run end-to-end.

Concretely:

- crates/ailang-check/src/lib.rs: three new CheckError variants
  (MutAssignOutOfScope, AssignTypeMismatch, UnsupportedMutVarType)
  with #[error('[code]: msg')] Display attrs matching the cli-diag-
  human bracketed-code convention; code() + ctx() arms emit
  kebab-case codes and structured JSON payloads.

- synth signature: new parameter mut_scope_stack: &mut Vec<IndexMap<
  String, Type>> after locals, threaded through every recursive call
  site (synth's 18 internal calls + mono.rs:712/1337 re-synth +
  lift.rs:723 + builtins.rs test helpers + the mq.3 in-test helper
  at lib.rs:6663). Stack is per-walk and lexically scoped — push on
  Term::Mut entry, pop on exit. Each frame is an IndexMap so
  declaration order is preserved for the diagnostic 'available' list.

- Term::Var resolution: prepended with innermost-first mut-scope
  lookup. Mut-vars are monomorphic — no Forall instantiation, no
  free_fn_owner recording. Innermost-wins shadowing falls out of
  the iteration order.

- Term::Mut arm: gate var types to Int/Float/Bool/Unit only via
  a small is_supported_mut_var_type helper; each var's init is
  synth'd in the in-progress scope (the var itself is not yet
  bound, so it does not self-shadow during init); push completed
  frame, synth body, pop. Block's type is body's type.

- Term::Assign arm: walk mut_scope_stack innermost-first looking for
  name. On miss: MutAssignOutOfScope with available flattened
  across all frames (innermost-first, deduplicated, preserving
  order). On hit: synth value, unify against declared type — on
  mismatch emit AssignTypeMismatch with rendered types; on success
  produce Type::unit().

- Five .ail.json fixtures under examples/ exercising each
  diagnostic plus a positive nested-shadow sanity case, driven by
  the new pin test crates/ailang-check/tests/mut_typecheck_pin.rs
  (load_workspace + check_workspace + assert exact codes).

- carve_out_inventory.rs EXPECTED extended 7 → 12 to cover the new
  negative-fixture set (consistent with the form-a-default-authoring
  spec §C4(b) precedent for type-rejection fixtures staying as
  .ail.json-only).

Plan deviations from recon: three additional synth() call sites
beyond the four enumerated (lift.rs:723, builtins.rs x2,
lib.rs:6663) surfaced via the build-red structural signal; each
threaded with a fresh empty stack. carve_out_inventory.rs extension
was required by the existing pin but not named in the recon — a
documentary concern for the next planner pass.

Tests: 579 → 592 green; examples/mut.ail typechecks clean
('ok (26 symbols across 2 modules)') via cargo run --bin ail --
check examples/mut.ail; cargo build green; full workspace test
green.

Journal: docs/journals/2026-05-15-iter-mut.2.md.

Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.2.md.
2026-05-15 01:37:25 +02:00

13 KiB

iter mut.2 — typecheck recognition of Term::Mut and Term::Assign

Date: 2026-05-15 Started from: 3a5b1d3347 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<IndexMap<String, Type>> 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<IndexMap<String, Type>> 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