First iteration of the mut-local milestone (foundation step on the
Stateful-islands roadmap path). Lands the schema + surface tier:
Term::Mut, Term::Assign, and the nested MutVar struct become
first-class AST nodes that round-trip cleanly through Form A.
Typecheck and codegen recognition are deferred to mut.2 and mut.3
per the spec's out-of-iteration boundary; reaching either dispatch
entry point with these variants produces CheckError::Internal /
CodegenError::Internal with a 'deferred to iter mut.{2,3}' message.
Concretely:
- crates/ailang-core/src/ast.rs: two new Term variants behind
#[serde(tag = 't')]; pub struct MutVar { name, ty, init } adjacent
to Arm. Two canonical-bytes pin tests for the explicit-empty-vars
serialisation and the assign round-trip.
- ~25 substantive Term-walker arms across ailang-core/desugar,
ailang-core/workspace, ailang-check (lib + lift + linearity + mono
+ pre_desugar_validation + reuse_shape + uniqueness),
ailang-codegen (escape + lambda + lib), ailang-prose, and
crates/ail/src/main.rs. Universal policy: substantive recurse-into-
children at every site; only the two dispatch entry points
(synth in ailang-check, lower_term in ailang-codegen) stub with
Internal-error. One test-side walker arm in
crates/ail/tests/codegen_import_map_fallback_pin.rs not
enumerated by the plan was added as well (defensive recursion).
- ailang-surface: parse_mut + parse_assign helpers; Term::Mut
body desugared from a flat statement sequence into a right-folded
Term::Seq chain inside the JSON-AST. Print arms in print.rs match
the parser convention. EBNF prologue + crates/ailang-core/specs/
form_a.md productions updated. Four new parser pin tests cover
the empty-mut, single-var, body-required, and vars-only-no-body
cases.
- Drift + coverage tests extended: design_schema_drift.rs adds two
exemplars + match arms; schema_coverage.rs adds two VariantTag
entries + EXPECTED_VARIANTS + visit_term arms; spec_drift.rs adds
two exemplars + match arms. DESIGN.md §'Term (expression)' gets
jsonc-blocked schemas for the two new variants.
- examples/mut.ail: six-fn round-trip fixture exercising empty mut,
single-var, two-var, nested-shadow, and the four supported scalar
return types (Int, Float, Bool, Unit). The round_trip auto-glob
and schema_coverage corpus walker both pick it up.
Plan deviation: the plan named lib.rs:2572 as the typecheck
dispatch stub site, but that line is actually verify_tail_positions
(substantive walker). The real dispatch is synth (3403-area, stub
at 3489); the orchestrator routed correctly.
Tests: 564 → 579 green; cargo build green; round-trip green for
the new fixture; all drift + coverage tests green.
Journal: docs/journals/2026-05-15-iter-mut.1.md.
Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.1.md.
12 KiB
iter mut.1 — schema + surface for local mutable state
Date: 2026-05-15
Started from: 60e4559e31
Status: DONE
Tasks completed: 6 of 6
Summary
Lands the foundational AST + surface forms for the mut-local
milestone (first milestone on the Stateful-islands roadmap path).
Two new Term variants (Mut and Assign) plus the nested
MutVar struct enter ailang-core::ast; canonical-JSON serde
round-trips them by virtue of the existing #[serde(tag = "t")]
machinery. Form A gains (mut …) / (var …) / (assign …)
productions in the parser and the corresponding write arms in the
printer; the parser right-folds the trailing body sequence into
Term::Seq so the canonical JSON-AST always sees a single
body: Term. Every Term exhaustive match in the workspace (~25
walker sites) gains substantive arms, except the two dispatch
entry points (synth in ailang-check, lower_term in
ailang-codegen) which stub with CheckError::Internal /
CodegenError::Internal per the spec's "out of iteration"
boundary — typecheck recognition lands in mut.2, codegen lowering
in mut.3. The three drift tests (design_schema_drift,
spec_drift, schema_coverage) all flip green after the
exemplar / variant-tag / visitor extensions, and examples/mut.ail
ships as the round-trip fixture covering empty / single-var /
two-var / nested-shadow / Bool / Unit cases. Full
cargo test --workspace 579/579 green (was 564 + 2 new AST pins +
4 new parser pins + 9 from the extended drift exemplars and the
mut.ail-corpus contributions netting to +15).
Per-task notes
-
iter mut.1.1 — AST extension: added
Term::Mut { vars: Vec<MutVar>, body: Box<Term> },Term::Assign { name: String, value: Box<Term> }, and theMutVar { name, ty, init }struct tocrates/ailang-core/src/ast.rs. Two new unit tests pin the canonical-bytes of the empty-vars mut serialisation and the Assign round-trip.MutVarcarries Arm's derives (Debug, Clone, Serialize, Deserialize) — the plan's literal pseudo-code asked forPartialEq + Eqbut its own justification ("derives match Arm's for cross-tree consistency") points at Arm, which does not derive those. Concern recorded; no functional impact (the Task 1 tests do not depend onPartialEq, andTermitself uses a customPartialEqimpl onTypeonly). -
iter mut.1.2 — substantive walker arms: 9 sites in
desugar.rs, 2 inworkspace.rs, 3 inailang-check/src/lib.rs(1 insubstitute_rigids_in_term, 1 inverify_tail_positions, 1 variant-name string emitter insidesynth'sTerm::ReuseAssub- match), 2 inlift.rs, 3 inlinearity.rs(one being a variant-name string emitter), 2 inmono.rs, 1 each inpre_desugar_validation.rs,reuse_shape.rs,uniqueness.rs, 3 inescape.rs, 1 inlambda.rs, 1 incodegen/src/lib.rs(thesynth_with_extrastype-synthesis helper), 3 inailang-prose/src/lib.rs, 2 inail/src/main.rs. Plus one unenumerated site incrates/ail/tests/codegen_import_map_fallback_pin.rs(an exhaustive walker inside a test) that the build required. Each arm follows the appropriate shape per existing convention at its site: rebuilder shapes forsubstitute_*/subst_*/desugar_term/lift_in_term/substitute_rigids_in_term/rewrite_term, visitor shapes forcollect_used_in_term/free_vars_in_term/find_non_callee_use/walk_term*/verify_tail_positions/term_has_letrec/ the linearity walkers /walkinreuse_shape.rs/walkinuniqueness.rs/walkinescape.rs/collect_captures/count_free_var/subst_var_with_term(rebuilder-shape in prose). Shadowing semantics across mut-var bindings replicates theTerm::Let/Term::Lam/Term::LetRec-shaped convention at each site: var-init terms see the outer scope plus already-declared vars; later var-inits and the body see all earlier var bindings. -
iter mut.1.3 — dispatch stubs: typecheck dispatch at
synthinailang-check/src/lib.rs(Plan line 2572 was wrong — that line isverify_tail_positions, which is substantive walker territory; the actual dispatch entry issynthat line 3403's formerTerm::ReuseAsarm, where the two new arms now sit) returnsCheckError::Internal("Term::Mut/Assign not yet supported in typecheck (deferred to iter mut.2)"). Codegen dispatch atlower_terminailang-codegen/src/lib.rs:1649returnsCodegenError::Internal("Term::Mut/Assign not yet supported in codegen (deferred to iter mut.3)"). Both errors use the single-field tuple-variant shape (Internal(String)). -
iter mut.1.4 — Form A parser + printer: dispatcher arms added for
"mut" => parse_mutand"assign" => parse_assigninparse_term's head-keyword match; the two helpers parse positionally (mut: pull leading(var ...)entries, then ≥ 1 body terms right-folded intoTerm::Seq; assign: positional ident-then-value pair). Plan pseudo-code used helper names (peek_is_open_paren_with_head,expect_head, etc.) that don't exist; substituted the actual API (expect_lparen,expect_keyword,expect_ident,parse_type,parse_term,expect_rparen,peek_head_ident,matches!(self.peek(), Tok::RParen)). Four RED-first parser tests added covering empty mut + 1-var-1-assign-1-final + missing-body rejection (both shapes). Print arms inprint.rswalk the right-spine ofTerm::Seqand emit each lhs as a top-level statement; the terminal expression closes the(mut …)form. EBNF prologue inparse.rsextended withmut-term,var-decl,assign-termproductions. The print arms had to land during the Task 1+2 build-unblock phase rather than waiting for Task 4 (they are exhaustive-match neighbours of the synth/lower_term dispatchers); this is a benign sequencing shift and the plan's "Task 2 build- green verification gate" still holds. -
iter mut.1.5 — drift + coverage extensions: two new exemplars added to
design_schema_drift.rsand two new match arms; two newVariantTagentries (TermMut,TermAssign),EXPECTED_VARIANTSextended, two newvisit_termarms inschema_coverage.rs; two new exemplars + two new match arms inspec_drift.rs.form_a.mdgains three new lines in the Parenthesised-forms block plus a prose paragraph naming the empty-vars and post-vars-body invariants and the diagnostic codes.DESIGN.md§"Term (expression)" gains the two jsonc schema blocks immediately after thereuse-asblock plus a closing prose paragraph naming the mut.1 dispatch-stub semantics and the deferred mut.2 / mut.3 iterations. Post-edit:design_schema_driftandspec_driftboth green;schema_coverageis RED on the missingexamples/mut.ailfixture, per plan Step 6 expectation. -
iter mut.1.6 — fixture + round-trip:
examples/mut.ailshipped with the six fns from plan Step 1 verbatim (empty / single-var with assign / two-var with combine / nested-shadow / Bool / Unit). The corpus-globbing round-trip test (parse_then_print_then_parse_is_idempotent_on_every_ail_fixture) and the parse-determinism test pick the fixture up automatically; both green.schema_coveragealso green now that both new variants are observed in the corpus. Fullcargo test --workspace579/579 green.
Concerns
-
MutVarderives diverge from the plan's literal pseudo-code (Debug, Clone, PartialEq, Eq, Serialize, Deserialize); the shipped form isDebug, Clone, Serialize, Deserializeto match Arm's pattern. The plan's own justification ("match Arm's derives for cross-tree consistency") points at Arm which does not derive PartialEq/Eq. Functional impact: none in mut.1 (the Task 1 tests do not depend on PartialEq, andTermuses a custom PartialEq impl scoped toTypeonly). A future iter that needs to compareMutVar(e.g. for in-place rewriting in mut.3 codegen) can add the derives at that time without breaking any mut.1 test. -
Plan recon for the typecheck dispatch entry was misindexed. The plan listed
crates/ailang-check/src/lib.rs:2572as the "check_term dispatch — STUBBED" site; that line is actually inverify_tail_positions, a substantive walker. The real typecheck dispatch entry issynth(defined at lib.rs:2613, with itsTerm::ReuseAsarm at line 3403 before the new mut arms). The stub was placed at the actual dispatch entry; line 2572 received a substantive arm. The variant-name string emitter sub-match referenced as line 3430 is insidesynth's formerTerm::ReuseAsarm and was extended as Task 2 Step 13 asks. Net: the spec intent (stub at typecheck dispatch) is preserved; only the line-number routing differs. -
One test-side walker site (
crates/ail/tests/codegen_import_map_fallback_pin.rs:62) carries an exhaustive match onTermand was not enumerated by the plan's Task 2 file list. The site is the body-walker of a regression-pin test from iter 24.tidy; it required two new arms to build green. Added with appropriate visitor-shape recursion. The plan-recon miss is recorded here as a documentary item; no behavioural impact.
Known debt
-
Prose-side
(mut ...)rendering inailang-prose/src/lib.rsis a minimal-correctness shape (mut { var <name> = <init>; ...; <body> }). The prose surface for mut blocks is not yet fully designed; a future prose-iter once the LLM-author signal arrives will refine it. This is a deliberate placeholder, not drift — recording for visibility. -
subst_varindesugar.rsdoes NOT rename theTerm::Assign.namefield. The desugar pass'ssubst_varrewrites let-rec captures (KnownType / LetBound / MatchArm), none of which can be mut-vars (mut-vars are first-order scalars, not captureable). Leaving the assign-name untouched is semantically correct; documented in the arm's doc-comment. Mentioned here for visibility.
Files touched
crates/ailang-core/src/ast.rs—Term::Mut,Term::Assign,MutVar, two new unit testscrates/ailang-core/src/desugar.rs— 9 walker armscrates/ailang-core/src/workspace.rs— 2 walker armscrates/ailang-check/src/lib.rs— 3 walker arms (substantive + variant-name) + 2 dispatch stubs insynthcrates/ailang-check/src/lift.rs— 2 walker armscrates/ailang-check/src/linearity.rs— 3 walker arms (2 substantive + 1 variant-name)crates/ailang-check/src/mono.rs— 2 walker arms (1 mut-ref rebuilder, 1 visitor)crates/ailang-check/src/pre_desugar_validation.rs— 1 walker armcrates/ailang-check/src/reuse_shape.rs— 1 walker armcrates/ailang-check/src/uniqueness.rs— 1 walker armcrates/ailang-codegen/src/escape.rs— 3 walker armscrates/ailang-codegen/src/lambda.rs— 1 walker armcrates/ailang-codegen/src/lib.rs— 1 walker arm (synth_with_extras) + 2 dispatch stubs inlower_termcrates/ailang-prose/src/lib.rs— 3 walker arms (1 substantive, 1count_free_var, 1subst_var_with_termrebuilder)crates/ail/src/main.rs— 2 walker arms (dep-walker +rewrite_term)crates/ail/tests/codegen_import_map_fallback_pin.rs— 1 walker arm (test-side, unenumerated by plan)crates/ailang-surface/src/parse.rs— dispatcher arms,parse_mut/parse_assignhelpers, EBNF prologue extension, 4 new parser testscrates/ailang-surface/src/print.rs— 2write_termarmscrates/ailang-core/tests/design_schema_drift.rs— 2 exemplars + 2 match armscrates/ailang-core/tests/schema_coverage.rs— 2 VariantTag + EXPECTED_VARIANTS + 2 visit_term armscrates/ailang-core/tests/spec_drift.rs— 2 exemplars + 2 match armscrates/ailang-core/specs/form_a.md— 3 new productions + prose paragraphdocs/DESIGN.md— 2 jsonc schema blocks + prose paragraphexamples/mut.ail— round-trip fixture (6 fns)
Stats
bench/orchestrator-stats/2026-05-15-iter-mut.1.json