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.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
# 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<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
|
||||
@@ -71,3 +71,4 @@
|
||||
- 2026-05-14 — iter pd.3: prelude.ail.json deleted from working tree; cross-form-identity preflight + supporting bytes deleted from prelude_module_hash_pin (purpose discharged); migrate-bare-cross-module-refs defensive include + lockstep skip-branch deleted; carve_out_inventory.rs §C4(b) row dropped (8→7); form-a-default-authoring spec §C4(b) RETIRED status marker added; new prelude_decouple_carve_out_pin.rs asserts JSON file does not exist; milestone prelude-decouple closed → 2026-05-14-iter-pd.3.md
|
||||
- 2026-05-14 — audit-pd: milestone close (prelude-decouple) — architect drift fixed inline as audit-pd-tidy (DESIGN.md §"Roundtrip Invariant" carve-out count 8→7 + main.rs migrate-subcommand dead prelude fallback removed); bench mixed (check.py + compile_check.py established noise envelope, 15th consecutive observation, baseline pristine; cross_lang clean) → 2026-05-14-audit-pd.md
|
||||
- 2026-05-15 — iter mut.1: AST extension `Term::Mut` + `Term::Assign` + `MutVar` struct; Form A `(mut ...) / (var ...) / (assign ...)` productions parse + print + round-trip; ~25 substantive Term-walker arms across the codebase; dispatch stubs in `synth` (typecheck) + `lower_term` (codegen) return `CheckError::Internal` / `CodegenError::Internal` per the spec's out-of-iteration boundary; `examples/mut.ail` six-fn fixture (Int/Float/Bool/Unit + empty + nested-shadow); drift + coverage + spec-drift tests + DESIGN.md §"Term (expression)" + `crates/ailang-core/specs/form_a.md` amendments; tests 564 → 579 → 2026-05-15-iter-mut.1.md
|
||||
- 2026-05-15 — iter mut.2: typecheck for `Term::Mut` + `Term::Assign` replacing the iter mut.1 dispatch stubs; three new `CheckError` variants (`MutAssignOutOfScope`, `AssignTypeMismatch`, `UnsupportedMutVarType`) with bracketed-`[code]:` Display + `code()` + `ctx()` arms; `synth` signature threads `mut_scope_stack: &mut Vec<IndexMap<String, Type>>` after `locals`, propagated through all recursive call sites in `lib.rs` plus `mono.rs:712/1337` + `lift.rs:723` + `builtins.rs` test helpers + the mq.3 in-test helper at `lib.rs:6663`; `Term::Var` resolution prepended with innermost-first mut-scope lookup; `Term::Mut` arm gates var types to {Int,Float,Bool,Unit} and push/pops a fresh frame; `Term::Assign` arm walks the stack innermost-first, emits `AssignTypeMismatch` on type mismatch and `MutAssignOutOfScope` (with `available` flattened across all frames) on miss; five `.ail.json` fixtures under `examples/` (four negative + one positive nested-shadow) + driver `crates/ailang-check/tests/mut_typecheck_pin.rs`; `carve_out_inventory.rs` EXPECTED extended 7→12 for the new negative-fixture set; `examples/mut.ail` typechecks clean (`ok (26 symbols across 2 modules)`); tests 579 → 592 → 2026-05-15-iter-mut.2.md
|
||||
|
||||
Reference in New Issue
Block a user