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.
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 —
CheckErrorvariants +code()+ctx(): Three new variants land before theInternalvariant 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 —
synthsignature threadsmut_scope_stack: New parametermut_scope_stack: &mut Vec<IndexMap<String, Type>>sits immediately afterlocals(canonical mid-position; matches the existing convention for per-fn-body lexical state). 18 recursive call sites inlib.rs::synthupdated via pattern-by-patternreplace_all(each first-arg form had a unique-enough wrapper). Entry-point callers incheck_fn:1934andcheck_const:2687declare 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) andbuiltins.rs(two test helperssynth_in_builtins_env+ a FloatPatternNotAllowed pin) — both required for build green; both get fresh empty stacks. The in-test mq.3 call site atlib.rs:6663also 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::Varprepends mut-scope resolution: A 5- line block at the top of the Var arm walksmut_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 consultslocals.get(name)first, so withlocals = { 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::Muttypecheck arm: Replaces the stub with real logic per plan Step 3. A file-scope helperis_supported_mut_var_type(&Type) -> boolgates each var's declared type toInt | 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 — keepsmut_scope_stacktruthful 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 — thebody_resultis 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) andmut_block_rejects_str_var_with_unsupported_type(negative, GREEN at Task 4 close). -
iter mut.2.5 —
Term::Assigntypecheck arm: Replaces the stub with real logic per plan Step 3. Resolves the target name viamut_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 manualcontainscheck (small N, no need for a Set), returnsMutAssignOutOfScope. Some case: synths the value, comparessubst.apply(&declared)vssubst.apply(&value)via!=(manual equality, NOTunify— unify would have emittedTypeMismatch, and the spec demands the distinctAssignTypeMismatchcode), returnsAssignTypeMismatchwith rendered type strings on mismatch orOk(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.jsonfixtures land underexamples/(carve-out style, per the Boss decision in the carrier overriding the spec's literalcrates/ailang-check/tests/-side placement). Each fixture is minimal — single-fnmain, no imports, body shape exactly the one the plan named. The driver testcrates/ailang-check/tests/mut_typecheck_pin.rsuses the canonicalload_workspace+check_workspace+diagnostics.iter().map(|d| d.code.clone()).collect()pattern frommethod_collision_pin.rsandtypeclass_22b2.rs— notodo!()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.ailtypechecks clean:cargo run --quiet --bin ail -- check examples/mut.ailreportsok (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). Fullcargo test --workspace: 592 / 0.
Concerns
-
Task 3 TDD-ordering gap: the
Term::Vararm 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 whenlocals = { x → Int }, contradicting bothassert_eq!(Float)andassert_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 emitCheckError::Internalso 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_envat 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.jsonfiles) 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 underexamples/. The boundary call: the fixtures match §C4(a)'s precedent (negative typecheck fixtures as.ail.jsonso the diagnostic-code assertion is the load-bearing form, not the surface). The positivetest_mut_nested_shadow_legal.ail.jsonis the one stretch — its load-bearing assertion is "zero diagnostics", which the form-A.ailform could equally carry. Landing it as.ail.jsonkeeps 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::MutorTerm::Assignreachinglower_termstill producesCodegenError::Internal. The full mut.ail E2E (build + run) lands in mut.3. The spec's Acceptance criterion #3 (mut_counter.ail+mut_sum_floats.ailbuild + 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_varsis 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;synthsignature gainsmut_scope_stackafterlocals; 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::Vararm prepends mut-scope resolution (5-line block);Term::Mutstub replaced with real logic (push/pop frame, per-var init, body synth);Term::Assignstub replaced with real logic (find/None-MutAssignOutOfScope/Some-AssignTypeMismatch-or-Unit); file-scope helperis_supported_mut_var_type; 7 new tests inmod 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 thesynth_standalonehelper.crates/ailang-check/src/mono.rs— 2 re-synth call sites (712, 1337) each get a freshmut_scope_stack.crates/ailang-check/src/lift.rs— letrec-capture re-synth at :723 gets a freshmut_scope_stack.crates/ailang-check/src/builtins.rs— 2 in-test synth calls (synth_in_builtins_env + FloatPatternNotAllowed pin) get a freshmut_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.jsonexamples/test_mut_assign_outside_mut.ail.jsonexamples/test_mut_assign_type_mismatch.ail.jsonexamples/test_mut_nested_shadow_legal.ail.jsonexamples/test_mut_var_unsupported_type.ail.json
Stats
bench/orchestrator-stats/2026-05-15-iter-mut.2.json