iter mut.1: AST extension + Form A surface for local mutable state
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.
This commit is contained in:
@@ -2365,12 +2365,42 @@ are real surface forms.
|
||||
// (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen
|
||||
// lowers as in-place rewrite under `--alloc=rc`.
|
||||
{ "t": "reuse-as", "source": Term, "body": Term }
|
||||
|
||||
// Iter mut.1: local mutable-state block. `vars` declares zero or
|
||||
// more lexically-scoped mutable bindings (initialised in order);
|
||||
// `body` is a single Term in scope of all vars. The block's static
|
||||
// type is `body`'s static type. The `vars` array stays present in
|
||||
// canonical JSON even when empty (no `skip_serializing_if` —
|
||||
// hash-stable-on-omission is NOT applied here; the field is part of
|
||||
// the shape). See `docs/specs/2026-05-15-mut-local.md`.
|
||||
{ "t": "mut",
|
||||
"vars": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
|
||||
"body": Term }
|
||||
|
||||
// Iter mut.1: in-block update of a mut-var. Legal only as a
|
||||
// sub-term of a `Term::Mut` whose `vars` includes a var with the
|
||||
// same `name`. Static type Unit. The lexical-scope rule is enforced
|
||||
// at typecheck (iter mut.2, `mut-assign-out-of-scope`); codegen
|
||||
// relies on it (iter mut.3).
|
||||
{ "t": "assign",
|
||||
"name": "<id>",
|
||||
"value": Term }
|
||||
```
|
||||
|
||||
In the MVP, `do` is only a direct call to a built-in effect op (no
|
||||
handler). A `lam` term constructs an anonymous function value; free
|
||||
variables of its body are captured from the enclosing scope.
|
||||
|
||||
Iter mut.1 lands `Term::Mut` and `Term::Assign` as first-class AST
|
||||
nodes that round-trip cleanly between Form A and canonical JSON.
|
||||
Typecheck recognition (iter mut.2) and codegen lowering (iter mut.3)
|
||||
are sequenced as separate iterations; in iter mut.1 the typecheck
|
||||
dispatch (`synth` in `ailang-check`) and codegen dispatch
|
||||
(`lower_term` in `ailang-codegen`) stub these variants with
|
||||
`CheckError::Internal` / `CodegenError::Internal` respectively, per
|
||||
spec `docs/specs/2026-05-15-mut-local.md` §"Iteration mut.1"
|
||||
out-of-iteration boundary.
|
||||
|
||||
**`Literal`**:
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# iter mut.1 — schema + surface for local mutable state
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Started from:** 60e4559e31d7f09de77e2679aab2d524de257a61
|
||||
**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 the `MutVar { name, ty, init }` struct to `crates/ailang-core/src/ast.rs`.
|
||||
Two new unit tests pin the canonical-bytes of the empty-vars mut
|
||||
serialisation and the Assign round-trip. `MutVar` carries Arm's
|
||||
derives (`Debug, Clone, Serialize, Deserialize`) — the plan's
|
||||
literal pseudo-code asked for `PartialEq + Eq` but 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 on
|
||||
`PartialEq`, and `Term` itself uses a custom `PartialEq` impl on
|
||||
`Type` only).
|
||||
|
||||
- iter mut.1.2 — substantive walker arms: 9 sites in `desugar.rs`,
|
||||
2 in `workspace.rs`, 3 in `ailang-check/src/lib.rs` (1 in
|
||||
`substitute_rigids_in_term`, 1 in `verify_tail_positions`, 1
|
||||
variant-name string emitter inside `synth`'s `Term::ReuseAs` sub-
|
||||
match), 2 in `lift.rs`, 3 in `linearity.rs` (one being a
|
||||
variant-name string emitter), 2 in `mono.rs`, 1 each in
|
||||
`pre_desugar_validation.rs`, `reuse_shape.rs`, `uniqueness.rs`,
|
||||
3 in `escape.rs`, 1 in `lambda.rs`, 1 in `codegen/src/lib.rs`
|
||||
(the `synth_with_extras` type-synthesis helper), 3 in
|
||||
`ailang-prose/src/lib.rs`, 2 in `ail/src/main.rs`. Plus one
|
||||
unenumerated site in `crates/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 for `substitute_*` / `subst_*` /
|
||||
`desugar_term` / `lift_in_term` / `substitute_rigids_in_term` /
|
||||
`rewrite_term`, visitor shapes for `collect_used_in_term` /
|
||||
`free_vars_in_term` / `find_non_callee_use` / `walk_term*` /
|
||||
`verify_tail_positions` / `term_has_letrec` / the linearity
|
||||
walkers / `walk` in `reuse_shape.rs` / `walk` in `uniqueness.rs`
|
||||
/ `walk` in `escape.rs` / `collect_captures` / `count_free_var` /
|
||||
`subst_var_with_term` (rebuilder-shape in prose). Shadowing
|
||||
semantics across mut-var bindings replicates the
|
||||
`Term::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 `synth` in
|
||||
`ailang-check/src/lib.rs` (Plan line 2572 was wrong — that line
|
||||
is `verify_tail_positions`, which is substantive walker
|
||||
territory; the actual dispatch entry is `synth` at line 3403's
|
||||
former `Term::ReuseAs` arm, where the two new arms now sit)
|
||||
returns `CheckError::Internal("Term::Mut/Assign not yet
|
||||
supported in typecheck (deferred to iter mut.2)")`. Codegen
|
||||
dispatch at `lower_term` in `ailang-codegen/src/lib.rs:1649`
|
||||
returns `CodegenError::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_mut` and `"assign" => parse_assign` in
|
||||
`parse_term`'s head-keyword match; the two helpers parse
|
||||
positionally (mut: pull leading `(var ...)` entries, then ≥ 1
|
||||
body terms right-folded into `Term::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 in `print.rs` walk the right-spine of
|
||||
`Term::Seq` and emit each lhs as a top-level statement; the
|
||||
terminal expression closes the `(mut …)` form. EBNF prologue in
|
||||
`parse.rs` extended with `mut-term`, `var-decl`, `assign-term`
|
||||
productions. 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.rs` and two new match arms; two
|
||||
new `VariantTag` entries (`TermMut`, `TermAssign`),
|
||||
`EXPECTED_VARIANTS` extended, two new `visit_term` arms in
|
||||
`schema_coverage.rs`; two new exemplars + two new match arms in
|
||||
`spec_drift.rs`. `form_a.md` gains 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 the `reuse-as` block plus a
|
||||
closing prose paragraph naming the mut.1 dispatch-stub semantics
|
||||
and the deferred mut.2 / mut.3 iterations. Post-edit:
|
||||
`design_schema_drift` and `spec_drift` both green;
|
||||
`schema_coverage` is RED on the missing `examples/mut.ail`
|
||||
fixture, per plan Step 6 expectation.
|
||||
|
||||
- iter mut.1.6 — fixture + round-trip: `examples/mut.ail` shipped
|
||||
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_coverage` also green now that both new
|
||||
variants are observed in the corpus. Full `cargo test
|
||||
--workspace` 579/579 green.
|
||||
|
||||
## Concerns
|
||||
|
||||
- `MutVar` derives diverge from the plan's literal pseudo-code
|
||||
(`Debug, Clone, PartialEq, Eq, Serialize, Deserialize`); the
|
||||
shipped form is `Debug, Clone, Serialize, Deserialize` to 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, and `Term` uses
|
||||
a custom PartialEq impl scoped to `Type` only). A future iter
|
||||
that needs to compare `MutVar` (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:2572` as the
|
||||
"check_term dispatch — STUBBED" site; that line is actually in
|
||||
`verify_tail_positions`, a substantive walker. The real
|
||||
typecheck dispatch entry is `synth` (defined at lib.rs:2613,
|
||||
with its `Term::ReuseAs` arm 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 inside `synth`'s
|
||||
former `Term::ReuseAs` arm 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 on `Term` and 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 in `ailang-prose/src/lib.rs` is
|
||||
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_var` in `desugar.rs` does NOT rename the `Term::Assign.name`
|
||||
field. The desugar pass's `subst_var` rewrites 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 tests
|
||||
- `crates/ailang-core/src/desugar.rs` — 9 walker arms
|
||||
- `crates/ailang-core/src/workspace.rs` — 2 walker arms
|
||||
- `crates/ailang-check/src/lib.rs` — 3 walker arms (substantive + variant-name) + 2 dispatch stubs in `synth`
|
||||
- `crates/ailang-check/src/lift.rs` — 2 walker arms
|
||||
- `crates/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 arm
|
||||
- `crates/ailang-check/src/reuse_shape.rs` — 1 walker arm
|
||||
- `crates/ailang-check/src/uniqueness.rs` — 1 walker arm
|
||||
- `crates/ailang-codegen/src/escape.rs` — 3 walker arms
|
||||
- `crates/ailang-codegen/src/lambda.rs` — 1 walker arm
|
||||
- `crates/ailang-codegen/src/lib.rs` — 1 walker arm (`synth_with_extras`) + 2 dispatch stubs in `lower_term`
|
||||
- `crates/ailang-prose/src/lib.rs` — 3 walker arms (1 substantive, 1 `count_free_var`, 1 `subst_var_with_term` rebuilder)
|
||||
- `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_assign` helpers, EBNF prologue extension, 4 new parser tests
|
||||
- `crates/ailang-surface/src/print.rs` — 2 `write_term` arms
|
||||
- `crates/ailang-core/tests/design_schema_drift.rs` — 2 exemplars + 2 match arms
|
||||
- `crates/ailang-core/tests/schema_coverage.rs` — 2 VariantTag + EXPECTED_VARIANTS + 2 visit_term arms
|
||||
- `crates/ailang-core/tests/spec_drift.rs` — 2 exemplars + 2 match arms
|
||||
- `crates/ailang-core/specs/form_a.md` — 3 new productions + prose paragraph
|
||||
- `docs/DESIGN.md` — 2 jsonc schema blocks + prose paragraph
|
||||
- `examples/mut.ail` — round-trip fixture (6 fns)
|
||||
|
||||
## Stats
|
||||
|
||||
bench/orchestrator-stats/2026-05-15-iter-mut.1.json
|
||||
@@ -70,3 +70,4 @@
|
||||
- 2026-05-14 — iter pd.2: surface owns prelude embed (PRELUDE_AIL + parse_prelude in ailang-surface); pd.1 shim load_workspace_with retired along with PRELUDE_JSON / load_prelude / load_one; cross-form-identity preflight PASSED at module_hash 3abe0d3fa3c11c99; production literal-"prelude" in core/src/ now zero → 2026-05-14-iter-pd.2.md
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user