From 60e4559e31d7f09de77e2679aab2d524de257a61 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 15 May 2026 00:43:19 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20mut.1=20=E2=80=94=20AST=20extension=20+?= =?UTF-8?q?=20Form=20A=20surface=20for=20mut/var/assign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks: AST + serde (Task 1), substantive walker arms across the ~25 exhaustive Term match sites (Task 2), the two dispatch stubs in check_term and lower_term that produce CheckError::Internal / CodegenError::Internal per the spec's out-of-iteration boundary (Task 3), Form A parser + printer (Task 4), schema-drift + coverage + spec-drift extensions + form_a.md + DESIGN.md amendments (Task 5), examples/mut.ail fixture with round-trip + corpus-coverage gate (Task 6). Three Boss decisions made explicit in the plan: walker-arm policy (substantive everywhere except the two dispatch entry points), empty-vars canonical-bytes pin, parse rejection of (mut) without body. Plan-recon flagged all three; the plan resolves them rather than deferring. --- docs/plans/2026-05-15-iter-mut.1.md | 1130 +++++++++++++++++++++++++++ 1 file changed, 1130 insertions(+) create mode 100644 docs/plans/2026-05-15-iter-mut.1.md diff --git a/docs/plans/2026-05-15-iter-mut.1.md b/docs/plans/2026-05-15-iter-mut.1.md new file mode 100644 index 0000000..c5794c4 --- /dev/null +++ b/docs/plans/2026-05-15-iter-mut.1.md @@ -0,0 +1,1130 @@ +# mut.1 — schema + surface — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-15-mut-local.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Land the AST extension for `Term::Mut`, `Term::Assign`, and +the nested `MutVar` struct; wire canonical-JSON serde; add Form A +`(mut …)` / `(var …)` / `(assign …)` productions in the surface parser +and printer; extend the four drift / coverage tests; amend +DESIGN.md §"Term (expression)" and `crates/ailang-core/specs/form_a.md` +with the new shapes; and add `examples/mut.ail` as the round-trip +fixture. After this iteration, `Term::Mut` and `Term::Assign` exist as +first-class AST nodes that round-trip cleanly between Form A text and +canonical JSON, but typecheck still rejects them with an internal-error +stub (typecheck recognition lands in mut.2). + +**Architecture:** Two new `Term` variants behind the existing +`#[serde(tag = "t")]` machinery — no per-variant serde wiring required. +A new `pub struct MutVar { name, ty, init }` adjacent to `Arm` in +`ast.rs`. Form A surface gains three productions parsed positionally +(`(mut …)` peels leading `(var …)` entries then right-folds the +remaining body sequence into `Term::Seq`; `(assign N V)` is a fixed +positional pair). Every `Term` exhaustive `match` in the workspace +(~25 sites carrying a `Term::ReuseAs` arm today) gains a new arm — +substantive (structural recurse) for every site except the two +dispatch entry points (`check_term` in `ailang-check/src/lib.rs` and +`lower_term` in `ailang-codegen/src/lib.rs`), which stub with +`CheckError::Internal` / `CodegenError::Internal` per the spec's +"out of iteration" boundary. + +**Tech Stack:** `ailang-core` (AST + serde + drift tests), +`ailang-surface` (parse + print), `ailang-check` (walker arms + +typecheck dispatch stub), `ailang-codegen` (walker arms + codegen +dispatch stub), `ailang-prose` (walker arms), `ail` (CLI walker arms). + +--- + +**Files this plan creates or modifies:** + +- Create: `examples/mut.ail` — round-trip fixture (empty mut, single- + var, two-var, nested, scalar return-type variants). +- Modify: `crates/ailang-core/src/ast.rs:386-525` — `Term` enum + extension + new `MutVar` struct. +- Modify: `crates/ailang-core/src/desugar.rs` — nine `Term::ReuseAs`- + adjacent walker arms gain substantive `Term::Mut` + `Term::Assign` + arms at lines 340, 531, 1154, 1298, 1419, 1477, 1519, 1548, 2660. +- Modify: `crates/ailang-core/src/workspace.rs:1245, 1367` — two + walker arms (one of them visits embedded types in `MutVar.ty`). +- Modify: `crates/ailang-core/specs/form_a.md:253-300` — add `(mut …)`, + `(var …)`, `(assign …)` productions. +- Modify: `crates/ailang-surface/src/parse.rs:1183-1254, 1525-1566` — + dispatcher + two new production helpers `parse_mut` / `parse_assign`, + plus the EBNF prologue at `parse.rs:7-86`. +- Modify: `crates/ailang-surface/src/print.rs:401-548` — two new arms + in `write_term` (exhaustive match). +- Modify: `crates/ailang-check/src/lib.rs:242, 2572, 3403, 3430` — + four exhaustive-match sites; line 242 (`substitute_rigids_in_term`), + line 2572 (`check_term` dispatch — STUBBED to return + `CheckError::Internal`), line 3403 (deep walker), line 3430 + (variant-name string emitter). +- Modify: `crates/ailang-check/src/lift.rs:373, 731` — two walker arms. +- Modify: `crates/ailang-check/src/linearity.rs:555, 778, 900` — + three walker arms. +- Modify: `crates/ailang-check/src/mono.rs:1220, 1576` — two walker + arms. +- Modify: `crates/ailang-check/src/pre_desugar_validation.rs:119` — + one walker arm. +- Modify: `crates/ailang-check/src/reuse_shape.rs:248` — one walker + arm. +- Modify: `crates/ailang-check/src/uniqueness.rs:329` — one walker + arm. +- Modify: `crates/ailang-codegen/src/escape.rs:187, 358, 469` — + three walker arms. +- Modify: `crates/ailang-codegen/src/lambda.rs:428` — one walker arm. +- Modify: `crates/ailang-codegen/src/lib.rs:1649, 2909` — line 1649 + (`lower_term` dispatch — STUBBED to return `CodegenError::Internal`), + line 2909 (drop emitter — substantive). +- Modify: `crates/ailang-prose/src/lib.rs:885, 1079, 1187` — three + walker arms. +- Modify: `crates/ail/src/main.rs:1510, 2703` — two walker arms. +- Modify: `crates/ailang-core/tests/design_schema_drift.rs:43-166` — + two new exemplars + two new match arms. +- Modify: `crates/ailang-core/tests/schema_coverage.rs:28-232` — + two new `VariantTag` entries + `EXPECTED_VARIANTS` extension + two + new `visit_term` arms. +- Modify: `crates/ailang-core/tests/spec_drift.rs:90-145` — two new + exemplars + two new match arms. +- Modify: `docs/DESIGN.md:2318-2368` — append jsonc schemas for + `Term::Mut` and `Term::Assign` after the `reuse-as` block (line 2364); + add the prose paragraph naming the iteration that introduces them. +- Test: `crates/ailang-core/src/ast.rs` unit tests (new) — canonical- + bytes pin for empty-`vars` `Term::Mut` serialisation. +- Test: `crates/ailang-surface/src/parse.rs` unit tests (new) — + positive parses for `(mut)` + `(assign)` shapes; negative parse for + `(mut)` with no body. + +--- + +## Task 1 — AST extension: `Term::Mut`, `Term::Assign`, `MutVar` + +**Files:** +- Modify: `crates/ailang-core/src/ast.rs:386-525` + +**Files: write a failing test first.** Pin canonical-bytes for the +empty-`vars` case (the spec's deliberate non-omission). + +- [ ] **Step 1: Add the canonical-bytes pin to `ast.rs`** + +In `#[cfg(test)] mod tests` near the end of `crates/ailang-core/src/ast.rs`, +add: + +```rust +#[test] +fn term_mut_empty_vars_serialises_with_explicit_vars_field() { + // Spec 2026-05-15-mut-local §"Canonical-form invariants": + // the `vars` array stays present rather than being omitted. + let t = Term::Mut { + vars: Vec::new(), + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }; + let bytes = serde_json::to_string(&t).expect("serialise"); + assert_eq!( + bytes, + r#"{"t":"mut","vars":[],"body":{"t":"lit","lit":{"kind":"int","value":0}}}"#, + ); +} + +#[test] +fn term_assign_round_trips_through_json() { + let t = Term::Assign { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }), + }; + let bytes = serde_json::to_string(&t).expect("serialise"); + assert_eq!( + bytes, + r#"{"t":"assign","name":"x","value":{"t":"lit","lit":{"kind":"int","value":42}}}"#, + ); + let back: Term = serde_json::from_str(&bytes).expect("deserialise"); + match back { + Term::Assign { name, value } => { + assert_eq!(name, "x"); + match *value { + Term::Lit { lit: Literal::Int { value: 42 } } => {} + _ => panic!("inner literal mismatch"), + } + } + _ => panic!("variant mismatch"), + } +} +``` + +- [ ] **Step 2: Run to verify red** + +Run: `cargo test --workspace -p ailang-core term_mut_empty_vars_serialises_with_explicit_vars_field` +Expected: compile FAIL with `error[E0599]: no variant or associated item named Mut found for enum Term`. + +- [ ] **Step 3: Add the two variants and the `MutVar` struct to `ast.rs`** + +Locate the `Term` enum at `crates/ailang-core/src/ast.rs:386-513`. +After the existing `Term::ReuseAs` variant (currently ending around +line 512), and before the closing `}` of the enum, insert: + +```rust + /// Iter mut.1: local mutable-state block. `vars` declares zero or + /// more lexically-scoped mutable bindings (initialised in order); + /// `body` is a single Term evaluated in scope of all vars. The + /// block's static type is `body`'s static type. `vars` stays + /// present in canonical JSON even when empty. + Mut { + vars: Vec, + body: Box, + }, + + /// Iter mut.1: in-block update of a mut-var. Only legal as a + /// sub-term of a `Term::Mut` whose `vars` includes a var with the + /// same `name`. Static type is Unit. + Assign { + name: String, + value: Box, + }, +``` + +Then, adjacent to the existing `pub struct Arm { ... }` (around line +515-525), insert: + +```rust +/// Iter mut.1: a mutable binding inside `Term::Mut`. Lives as a +/// nested field of `Term::Mut`, not as a standalone Term variant — +/// mut-vars are not first-class values. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutVar { + pub name: String, + #[serde(rename = "type")] + pub ty: Type, + pub init: Term, +} +``` + +The `#[serde(rename = "type")]` matches the spec's JSON-AST schema +(`{ "name": "", "type": Type, "init": Term }`). The struct's +derives match `Arm`'s for cross-tree consistency. + +- [ ] **Step 4: Run to verify green for the canonical-bytes test, while red for downstream walker sites** + +Run: `cargo test --workspace -p ailang-core term_mut_empty_vars_serialises_with_explicit_vars_field` +Expected: PASS (this test does not touch downstream walkers). + +Run: `cargo build --workspace 2>&1 | head -40` +Expected: many `error[E0004]: non-exhaustive patterns: &Term::Mut { .. } not covered` errors across `ailang-core::desugar`, `ailang-check`, `ailang-codegen`, `ailang-surface`, `ailang-prose`, `ail`. These are the walker sites Task 2 and Task 3 fix. + +--- + +## Task 2 — Substantive walker arms in pre-typecheck + helper crates + +Adds `Term::Mut` and `Term::Assign` arms to every `Term` exhaustive +match outside the two dispatch entry points. Each arm structurally +recurses; no behaviour changes for existing Term variants. + +The shape per arm depends on whether the walker rebuilds a `Term` +tree (`substitute_*` family — returns a new Term) or only visits +(linearity / coverage walkers — returns `()`). + +**Rebuilder shape** (for sites that map `Term → Term`): + +```rust +Term::Mut { vars, body } => Term::Mut { + vars: vars.iter().map(|v| MutVar { + name: v.name.clone(), + ty: walk_ty(&v.ty, /* whatever context */), + init: walk(&v.init, /* whatever context */), + }).collect(), + body: Box::new(walk(body, /* whatever context */)), +}, +Term::Assign { name, value } => Term::Assign { + name: name.clone(), + value: Box::new(walk(value, /* whatever context */)), +}, +``` + +**Visitor shape** (for sites that walk without rebuilding): + +```rust +Term::Mut { vars, body } => { + for v in vars { + visit_ty(&v.ty); + visit(&v.init); + } + visit(body); +} +Term::Assign { value, .. } => { + visit(value); +} +``` + +Some sites visit types separately; others don't. The implementer +matches the existing arm conventions at each site (e.g. if the +existing `Term::ReuseAs` arm at the site only visits `source` and +`body` without touching types, the new `Term::Mut` arm visits +`vars[i].init` and `body` without `visit_ty` — same convention). + +**Files:** +- Modify: `crates/ailang-core/src/desugar.rs` — nine walker arms. +- Modify: `crates/ailang-core/src/workspace.rs` — two walker arms + (one of them visits embedded types; see the `walk_term_embedded_types` + call at line 1245 — that one must visit `vars[i].ty`). +- Modify: `crates/ailang-check/src/lib.rs` lines 242, 3403, 3430 — + three of the four sites in this file (line 2572 is the dispatch + stub handled in Task 3). +- Modify: `crates/ailang-check/src/lift.rs` lines 373, 731. +- Modify: `crates/ailang-check/src/linearity.rs` lines 555, 778, 900. +- Modify: `crates/ailang-check/src/mono.rs` lines 1220, 1576. +- Modify: `crates/ailang-check/src/pre_desugar_validation.rs` line 119. +- Modify: `crates/ailang-check/src/reuse_shape.rs` line 248. +- Modify: `crates/ailang-check/src/uniqueness.rs` line 329. +- Modify: `crates/ailang-codegen/src/escape.rs` lines 187, 358, 469. +- Modify: `crates/ailang-codegen/src/lambda.rs` line 428. +- Modify: `crates/ailang-codegen/src/lib.rs` line 2909 (the second + site in this file; line 1649 is the codegen dispatch stub in + Task 3). +- Modify: `crates/ailang-prose/src/lib.rs` lines 885, 1079, 1187. +- Modify: `crates/ail/src/main.rs` lines 1510, 2703. + +**Site-by-site disposition:** + +- [ ] **Step 1: `desugar.rs:340` — `collect_used_in_term`** + +Visitor-shape arm. Recurse into `vars[i].init` and `body` for +`Term::Mut`; recurse into `value` for `Term::Assign`. No types visited +(this walker tracks Var usages only). + +- [ ] **Step 2: `desugar.rs:531` — `subst_var`** + +Rebuilder-shape arm. The walker substitutes `name → replacement` +inside a Term. For `Term::Mut`: substitute inside each var's `init` +and the `body`; var *declarations* are not Var references and do not +get rewritten. For `Term::Assign`: substitute inside `value`. The +`name` field of `Term::Assign` is a Var-declaration reference (not +a Var-use); the spec treats var declarations as fixed by lexical +position, so this walker does NOT rename them. + +- [ ] **Step 3: `desugar.rs:1154` — free-vars walker** + +Visitor-shape returning a `BTreeSet`. For `Term::Mut`: union +the free vars of each `vars[i].init` plus the body's free vars, +then **subtract the var names** (they are bound). For `Term::Assign`: +the value's free vars plus the assigned `name` itself (the var being +assigned is a *use* of the binding, so it counts as a free var +unless bound by an enclosing `Term::Mut`). + +- [ ] **Step 4: `desugar.rs:1298` — `subst_call_with_extras`** + +Rebuilder-shape. Same as Step 2 but inside a more involved context +(extra args). Match the `Term::ReuseAs` arm's structure at this site +and apply to `Term::Mut`/`Term::Assign`. + +- [ ] **Step 5: `desugar.rs:1419` — analogous rewriter** + +Rebuilder-shape. Per the recon, this is the analogous companion of +Step 4. Same pattern. + +- [ ] **Step 6: `desugar.rs:1477` — `find_non_callee_use`** + +Visitor-shape. Detects whether a name appears in non-callee position. +For `Term::Mut`: walk children. For `Term::Assign`: walk `value`; the +assigned `name` itself is a non-callee use (mut-vars cannot be in +callee position because their type is restricted to scalars in +mut.2 — but this site cannot rely on mut.2; for now, treat `name` +as a use to keep the conservative-safe semantics). + +- [ ] **Step 7: `desugar.rs:1519` and `desugar.rs:1548` — `any_nested_ctor` and `any_let_rec`** + +Visitor-shape returning `bool`. For `Term::Mut`: any var init or the +body. For `Term::Assign`: the value. + +- [ ] **Step 8: `desugar.rs:2660` — `any_lit_pattern`** + +Visitor-shape returning `bool`. Same pattern as Step 7. + +- [ ] **Step 9: `workspace.rs:1245` — `walk_term_embedded_types`** + +Visitor-shape that visits every `Type` embedded in a Term tree. +For `Term::Mut`: visit each `vars[i].ty` plus recurse into init and +body. For `Term::Assign`: recurse into `value` (no embedded types). + +- [ ] **Step 10: `workspace.rs:1367` — generic Term walker** + +Visitor-shape per the existing arms at this site. + +- [ ] **Step 11: `ailang-check/src/lib.rs:242` — `substitute_rigids_in_term`** + +Rebuilder-shape. For `Term::Mut`: rebuild with each `vars[i].ty` +substituted via `substitute_rigids_in_type`, each `init` via +`substitute_rigids_in_term`, and `body` rebuilt. For `Term::Assign`: +rebuild with `value` recursed. + +- [ ] **Step 12: `ailang-check/src/lib.rs:3403` — deep walker** + +Match the existing arm convention at this site. Inspect +`substitute_rigids_in_term` (Step 11) for the canonical rebuilder +shape if rebuilding; if visiting-only, use the visitor shape. + +- [ ] **Step 13: `ailang-check/src/lib.rs:3430` — variant-name string emitter** + +The existing `Term::ReuseAs { .. } => "reuse-as"` arm at line 3430 +indicates a `Term → &'static str` walker. Add: + +```rust +Term::Mut { .. } => "mut", +Term::Assign { .. } => "assign", +``` + +- [ ] **Step 14: `ailang-check/src/lift.rs:373` — letrec lift walker (rebuilder)** + +Rebuilder-shape. Same pattern as Step 11. + +- [ ] **Step 15: `ailang-check/src/lift.rs:731` — `term_has_letrec`** + +Visitor-shape returning `bool`. For `Term::Mut`: any `vars[i].init` +or body. For `Term::Assign`: the value. + +- [ ] **Step 16: `ailang-check/src/linearity.rs:555` — linearity walker** + +Visitor-shape per the existing arms at this site. Mut-vars are not +linear values (they're alloca slots, not RC-managed) but the walker +should still recurse into `init` / `body` / `value` to track any +linearity-relevant operations inside them. + +- [ ] **Step 17: `ailang-check/src/linearity.rs:778` — analogous** + +Same pattern as Step 16. + +- [ ] **Step 18: `ailang-check/src/linearity.rs:900` — analogous** + +Same pattern as Step 16. + +- [ ] **Step 19: `ailang-check/src/mono.rs:1220` — monomorphiser walker** + +Rebuilder-shape. Substitute types in `vars[i].ty` using the existing +mono substitution; recurse into init and body. For Term::Assign: +recurse into value. + +- [ ] **Step 20: `ailang-check/src/mono.rs:1576` — second mono walker** + +Same as Step 19. + +- [ ] **Step 21: `ailang-check/src/pre_desugar_validation.rs:119`** + +Visitor-shape. Whatever invariant the existing arms validate, apply +to the new variants by recursing into children. Mut-vars do not +participate in pre-desugar validation (they don't exist yet in +nested-ctor / letrec patterns); a simple recurse-children arm +suffices. + +- [ ] **Step 22: `ailang-check/src/reuse_shape.rs:248`** + +Visitor-shape. The existing `Term::ReuseAs` arm at this site is the +load-bearing case for reuse-shape inference; `Term::Mut`/`Term::Assign` +do not participate (no allocating-ctor inside mut-vars in mut.1 +because var types are scalar). Arm: recurse into children for +defensive depth, but emit no reuse-shape information. + +- [ ] **Step 23: `ailang-check/src/uniqueness.rs:329`** + +Visitor-shape. Mut-vars are not RC-managed values in mut.1; the +uniqueness analyser treats them as not-RC-tracked. Arm: recurse into +init/body/value children. + +- [ ] **Step 24: `ailang-codegen/src/escape.rs:187` — escape walker A** + +Visitor-shape. Mut-vars are alloca-resident by spec (§"Codegen") and +do not introduce escape edges. Arm: recurse into init/body/value +children. The walker should NOT mark any allocation site inside the +mut-var tree as escaping just because it lives inside a mut block — +the recursion preserves whatever escape decision the inner Terms +make on their own. + +- [ ] **Step 25: `ailang-codegen/src/escape.rs:358` — escape walker B** + +Same as Step 24. + +- [ ] **Step 26: `ailang-codegen/src/escape.rs:469` — escape walker C** + +Same as Step 24. + +- [ ] **Step 27: `ailang-codegen/src/lambda.rs:428`** + +Visitor-shape. Mut-vars cannot be captured by lambdas in mut.1 +(mut.2 enforces this via the typecheck pass; for now the codegen +arm just recurses children defensively). + +- [ ] **Step 28: `ailang-codegen/src/lib.rs:2909` — drop emitter** + +Visitor-shape per the existing arms at this site (the drop emitter +walks Term values to discover drop points for RC types). Mut-vars +are scalar in mut.1, so no drops are emitted for them; the arm +recurses into children. + +- [ ] **Step 29: `ailang-prose/src/lib.rs:885` — prose walker A** + +Rebuilder or visitor shape per the existing arms. Prose is a pretty- +printer; the new arms should print a minimal but valid prose form +or, if the prose surface is not yet ready for mut/assign, emit a +placeholder text (e.g. `""`). Prose-projection coverage +is not a gate for mut.1; minimal-correctness is enough. + +- [ ] **Step 30: `ailang-prose/src/lib.rs:1079` — prose walker B** + +Same as Step 29. + +- [ ] **Step 31: `ailang-prose/src/lib.rs:1187` — prose walker C** + +Same as Step 29. + +- [ ] **Step 32: `ail/src/main.rs:1510` — CLI describe / prose-merge A** + +Whatever the existing `Term::ReuseAs` arm does at this site, apply +analogously. Likely a visitor for `ail describe` or similar; recurse +children. + +- [ ] **Step 33: `ail/src/main.rs:2703` — CLI describe / prose-merge B** + +Same as Step 32. + +- [ ] **Step 34: Verify build green after Tasks 2's body** + +Note: this verification is the gate for Task 2's completion **after** +Task 3 lands. The compiler still complains about the two dispatch +stubs (Task 3) — proceed to Task 3 before re-running. + +--- + +## Task 3 — Dispatch-entry stubs in `ailang-check` and `ailang-codegen` + +Two sites stub `Term::Mut` and `Term::Assign` with an internal-error +return per the spec §"Iteration mut.1" out-of-iteration boundary. +After this task plus Task 2, the workspace builds green. + +**Files:** +- Modify: `crates/ailang-check/src/lib.rs:2572` — `check_term` dispatch. +- Modify: `crates/ailang-codegen/src/lib.rs:1649` — `lower_term` dispatch. + +- [ ] **Step 1: `check_term` dispatch stub at `lib.rs:2572`** + +The `Term::ReuseAs` arm at this site is the canonical typechecker +treatment. Add immediately after it: + +```rust +Term::Mut { .. } => { + return Err(CheckError::Internal { + message: "Term::Mut not yet supported in typecheck (deferred to iter mut.2)".into(), + }); +} +Term::Assign { .. } => { + return Err(CheckError::Internal { + message: "Term::Assign not yet supported in typecheck (deferred to iter mut.2)".into(), + }); +} +``` + +(The exact `CheckError::Internal` field name may be `msg` or +`message` — verify against the existing variant declaration around +line 620 and match it.) + +- [ ] **Step 2: `lower_term` dispatch stub at `codegen/src/lib.rs:1649`** + +The `Term::ReuseAs` arm at this site is the canonical codegen +treatment. Add immediately after it: + +```rust +Term::Mut { .. } => { + return Err(CodegenError::Internal( + "Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(), + )); +} +Term::Assign { .. } => { + return Err(CodegenError::Internal( + "Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(), + )); +} +``` + +(Verify the exact `CodegenError::Internal` shape — single-field +tuple variant `Internal(String)` vs. struct variant — and match.) + +- [ ] **Step 3: Verify build green** + +Run: `cargo build --workspace 2>&1 | tail -20` +Expected: no errors. (Warnings about unused fields in the new arms +are acceptable and will retire when typecheck / codegen land in +mut.2 / mut.3.) + +- [ ] **Step 4: Verify the AST tests still pass** + +Run: `cargo test --workspace -p ailang-core ast::tests` +Expected: PASS — including the two canonical-bytes tests from +Task 1. + +--- + +## Task 4 — Form A surface: parser + printer + +**Files:** +- Modify: `crates/ailang-surface/src/parse.rs:7-86, 1183-1254, 1525-1566` +- Modify: `crates/ailang-surface/src/print.rs:401-548` + +- [ ] **Step 1: Write the failing parser test for the empty-mut form** + +Add to the `#[cfg(test)] mod tests` block at the end of +`crates/ailang-surface/src/parse.rs`: + +```rust +#[test] +fn parses_empty_mut_with_int_body() { + let src = "(mut 0)"; + let t: Term = parse_term(src).expect("parse"); + match t { + Term::Mut { vars, body } => { + assert!(vars.is_empty(), "vars should be empty"); + match *body { + Term::Lit { lit: Literal::Int { value: 0 } } => {} + other => panic!("expected Lit Int 0, got {:?}", other), + } + } + other => panic!("expected Term::Mut, got {:?}", other), + } +} + +#[test] +fn parses_mut_with_one_var_and_one_assign_and_final() { + let src = "(mut (var x (con Int) 0) (assign x (app + x 1)) x)"; + let t: Term = parse_term(src).expect("parse"); + match t { + Term::Mut { vars, body } => { + assert_eq!(vars.len(), 1); + assert_eq!(vars[0].name, "x"); + // Body should be a Seq of (assign x ...) and x. + match *body { + Term::Seq { lhs, rhs } => { + match *lhs { + Term::Assign { name, .. } => assert_eq!(name, "x"), + other => panic!("expected Assign, got {:?}", other), + } + match *rhs { + Term::Var { name } => assert_eq!(name, "x"), + other => panic!("expected Var x, got {:?}", other), + } + } + other => panic!("expected Seq, got {:?}", other), + } + } + other => panic!("expected Term::Mut, got {:?}", other), + } +} + +#[test] +fn rejects_mut_with_no_body() { + let src = "(mut)"; + let err = parse_term(src).expect_err("must reject empty mut"); + let msg = format!("{}", err); + assert!(msg.contains("mut"), "error mentions mut: {msg}"); + assert!(msg.contains("body") || msg.contains("expression"), + "error mentions missing body: {msg}"); +} + +#[test] +fn rejects_mut_with_only_vars_no_final_expression() { + let src = "(mut (var x (con Int) 0))"; + let err = parse_term(src).expect_err("must reject vars-only mut"); + let msg = format!("{}", err); + assert!(msg.contains("body") || msg.contains("expression"), + "error mentions missing body: {msg}"); +} +``` + +The helper `parse_term` for tests should mirror the convention used +by the existing `Term::Clone` / `Term::ReuseAs` test set in the same +file (see lines 1769-1908). If `parse_term` does not exist with that +exact name, wrap the existing test-entry function (`parse_module` → +extract single term) the same way prior tests do. + +- [ ] **Step 2: Run to verify red** + +Run: `cargo test --workspace -p ailang-surface parses_empty_mut_with_int_body` +Expected: FAIL — parser does not recognise `mut` head. + +- [ ] **Step 3: Add `parse_mut` and `parse_assign` helpers** + +Locate the term-head dispatcher around `crates/ailang-surface/src/parse.rs:1194-1221`. +After the `"reuse-as"` arm, add: + +```rust +"mut" => self.parse_mut(span)?, +"assign" => self.parse_assign(span)?, +``` + +Adjacent to the existing `parse_reuse_as` helper around line 1531, +add the two new helpers: + +```rust +fn parse_mut(&mut self, head_span: Span) -> Result { + // (mut (var NAME TYPE INIT)* BODY_STMT* FINAL_EXPR) + // + // Reads zero or more (var ...) entries off the front, then 1+ + // trailing terms; the trailing terms are right-folded into + // Term::Seq with the final term as the seed. + + let mut vars: Vec = Vec::new(); + let mut body_stmts: Vec = Vec::new(); + + while self.peek_is_open_paren_with_head("var") { + let var_span = self.expect_open_paren()?; + self.expect_head("var")?; + let name = self.parse_ident()?; + let ty = self.parse_type()?; + let init = self.parse_term()?; + self.expect_close_paren(var_span)?; + vars.push(MutVar { name, ty, init }); + } + + while !self.peek_is_close_paren() { + body_stmts.push(self.parse_term()?); + } + + if body_stmts.is_empty() { + return Err(ParseError::at( + head_span, + "(mut ...) requires at least one body expression after vars", + )); + } + + // Right-fold: [s1, s2, ..., sN, final] becomes Seq(s1, Seq(s2, ... Seq(sN, final))) + let mut body = body_stmts.pop().expect("non-empty after the check above"); + while let Some(s) = body_stmts.pop() { + body = Term::Seq { lhs: Box::new(s), rhs: Box::new(body) }; + } + + Ok(Term::Mut { + vars, + body: Box::new(body), + }) +} + +fn parse_assign(&mut self, _head_span: Span) -> Result { + // (assign NAME VALUE) + let name = self.parse_ident()?; + let value = self.parse_term()?; + Ok(Term::Assign { + name, + value: Box::new(value), + }) +} +``` + +The exact names of the helper methods on `self` (`peek_is_open_paren_with_head`, +`expect_open_paren`, `expect_head`, `parse_ident`, `parse_type`, +`parse_term`, `peek_is_close_paren`, `expect_close_paren`, +`ParseError::at`) must match the existing parser's API. Inspect +`parse_reuse_as` (around line 1531) and `parse_seq` (around line 1386) +to read off the canonical names; substitute as needed. + +- [ ] **Step 4: Run to verify parser tests pass** + +Run: `cargo test --workspace -p ailang-surface parses_empty_mut_with_int_body parses_mut_with_one_var_and_one_assign_and_final rejects_mut_with_no_body rejects_mut_with_only_vars_no_final_expression` +Expected: ALL PASS. + +- [ ] **Step 5: Add print arms to `print.rs`** + +Locate `write_term` around `crates/ailang-surface/src/print.rs:401`. +The exhaustive match has a `Term::ReuseAs` arm around line 540-548 +matching the source structure. After it, add: + +```rust +Term::Mut { vars, body } => { + self.write_str("(mut")?; + for v in vars { + self.write_str(" (var ")?; + self.write_str(&v.name)?; + self.write_str(" ")?; + self.write_type(&v.ty)?; + self.write_str(" ")?; + self.write_term(&v.init)?; + self.write_str(")")?; + } + // Body is a Term — may be Seq-shaped or a single expression. + // Walk the right-spine of Seq and emit each lhs as a top-level + // statement, then the final rhs as the trailing expression. + let mut cursor: &Term = body; + loop { + match cursor { + Term::Seq { lhs, rhs } => { + self.write_str(" ")?; + self.write_term(lhs)?; + cursor = rhs; + } + other => { + self.write_str(" ")?; + self.write_term(other)?; + break; + } + } + } + self.write_str(")")?; +} +Term::Assign { name, value } => { + self.write_str("(assign ")?; + self.write_str(name)?; + self.write_str(" ")?; + self.write_term(value)?; + self.write_str(")")?; +} +``` + +- [ ] **Step 6: Update the EBNF prologue at `parse.rs:7-86`** + +The parser file has a Form A EBNF block as a doc-comment header. +Locate the `term` alternation around line 39-68 and add three new +alternations: + +``` + | "(" "mut" (var-decl)* term+ ")" + | "(" "assign" ident term ")" + + var-decl ::= "(" "var" ident type term ")" +``` + +Place these adjacent to the existing `Term::ReuseAs` line in the +alternation. + +--- + +## Task 5 — Drift + coverage test extensions + +The schema-drift, schema-coverage, and spec-drift tests are designed +to RED when a `Term` variant is added without anchoring it in the +relevant artefacts. After Tasks 1-4, the build is green but these +three tests are red. Task 5 makes them green. + +**Files:** +- Modify: `crates/ailang-core/tests/design_schema_drift.rs:43-166` +- Modify: `crates/ailang-core/tests/schema_coverage.rs:28-232` +- Modify: `crates/ailang-core/tests/spec_drift.rs:90-145` +- Modify: `crates/ailang-core/specs/form_a.md:253-300` +- Modify: `docs/DESIGN.md:2318-2368` + +- [ ] **Step 1: Extend `design_schema_drift.rs`** + +In the `exemplars: Vec<(&str, Term)>` constructor at lines 44-140, +after the entry for `"(reuse-as"`, append: + +```rust +( + r#""t": "mut""#, + Term::Mut { + vars: Vec::new(), + body: Box::new(Term::Lit { lit: Literal::Unit }), + }, +), +( + r#""t": "assign""#, + Term::Assign { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Unit }), + }, +), +``` + +In the exhaustive match at lines 145-159 (the `Term::X => "x"` +variant-name mapping), after the `Term::ReuseAs { .. } => "reuse-as"` +arm, append: + +```rust +Term::Mut { .. } => "mut", +Term::Assign { .. } => "assign", +``` + +- [ ] **Step 2: Extend `schema_coverage.rs`** + +In the `VariantTag` enum at lines 28-70, after `TermReuseAs`, add: + +```rust +TermMut, +TermAssign, +``` + +In `EXPECTED_VARIANTS` at lines 76-111, append: + +```rust +VariantTag::TermMut, +VariantTag::TermAssign, +``` + +In `visit_term` at lines 156-232, after the `Term::ReuseAs` arm +(around line 226), append: + +```rust +Term::Mut { vars, body } => { + observed.insert(VariantTag::TermMut); + for v in vars { + visit_type(&v.ty, observed); + visit_term(&v.init, observed); + } + visit_term(body, observed); +} +Term::Assign { value, .. } => { + observed.insert(VariantTag::TermAssign); + visit_term(value, observed); +} +``` + +- [ ] **Step 3: Extend `spec_drift.rs`** + +In the exemplar list around `tests/spec_drift.rs:90-117`, after the +`"(reuse-as"` entry, append `"(mut"` and `"(assign"` entries with +canonical exemplars matching the Step 1 shape. + +In the exhaustive match at lines 124-138, after `Term::ReuseAs { .. } => "reuse-as"`, +append: + +```rust +Term::Mut { .. } => "mut", +Term::Assign { .. } => "assign", +``` + +- [ ] **Step 4: Extend `form_a.md` (in `crates/ailang-core/specs/`)** + +Around lines 281-283 in `crates/ailang-core/specs/form_a.md`, after +the entries for `seq` / `clone` / `reuse-as`, add three lines: + +``` +(mut (var NAME TYPE INIT)* BODY) +(var NAME TYPE INIT) ;; legal only inside (mut ...) +(assign NAME VALUE) ;; legal only inside (mut ...) +``` + +After the existing per-term notes around lines 298-300, append a +short paragraph: + +``` +The body of `(mut ...)` is a flat sequence of zero or more Unit- +typed statements followed by exactly one final expression of any +type. The parser desugars this sequence into a right-folded +`Term::Seq` chain inside `Term::Mut.body`; the canonical JSON-AST +always sees a single `body: Term`. A `(mut ...)` form with neither +vars nor body is rejected at parse; `(var ...)` and `(assign ...)` +outside a `(mut ...)` are rejected at parse for `var` and at +typecheck for `assign` (mut.2). See spec +`docs/specs/2026-05-15-mut-local.md`. +``` + +- [ ] **Step 5: Extend DESIGN.md §"Term (expression)"** + +Locate the existing jsonc schema block in +`docs/DESIGN.md:2318-2368`. After the `reuse-as` block (around +line 2364), append: + +```jsonc +// 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. Block static type +// is body's type. `vars` stays present even when empty +// (hash-stable when omitted is NOT applied here — the field is +// always serialised). +{ "t": "mut", + "vars": [ { "name": "", "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. See +// `docs/specs/2026-05-15-mut-local.md`. +{ "t": "assign", + "name": "", + "value": Term } +``` + +The order matters: the `assign` block comes immediately after the +`mut` block to mirror the order in the enum declaration and the +exemplar list in `design_schema_drift.rs`. + +- [ ] **Step 6: Run the three drift tests** + +Run: `cargo test --workspace -p ailang-core schema_drift` +Expected: PASS — the `design_md_anchors_every_term_variant` and the +`data_model_section_is_bounded` tests both green. + +Run: `cargo test --workspace -p ailang-core schema_coverage` +Expected: still RED on `every_ast_variant_is_observed_in_the_fixture_corpus` +because `examples/mut.ail` does not yet exist. Continue to Task 6 +to ship it. + +Run: `cargo test --workspace -p ailang-core spec_drift` +Expected: PASS. + +--- + +## Task 6 — `examples/mut.ail` fixture + round-trip test + +The round-trip test in `crates/ailang-surface/tests/round_trip.rs` +auto-globs every `examples/*.ail` fixture; once the fixture exists, +it gets picked up automatically. Same for `schema_coverage.rs`'s +fixture-corpus scan. + +**Files:** +- Create: `examples/mut.ail` + +- [ ] **Step 1: Write the fixture** + +Create `examples/mut.ail` with the following content: + +``` +(module mut + + (fn mut_empty + (doc "Iter mut.1 — empty mut block; body is a single Int literal.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body (mut 0))) + + (fn mut_single_var + (doc "Iter mut.1 — one var, one assign, final expression reads the var.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body + (mut + (var x (con Int) 0) + (assign x (app + x 1)) + x))) + + (fn mut_two_vars + (doc "Iter mut.1 — two vars, two assigns, final expression combines them.") + (type (fn-type (params) (ret (con Float)))) + (params) + (body + (mut + (var sum (con Float) 0.0) + (var count (con Int) 0) + (assign sum (app + sum 1.0)) + (assign count (app + count 1)) + (app + sum (app int_to_float count))))) + + (fn mut_nested_shadow + (doc "Iter mut.1 — outer var shadowed by inner mut block's var of the same name.") + (type (fn-type (params) (ret (con Int)))) + (params) + (body + (mut + (var x (con Int) 10) + (assign x (app + x 1)) + (mut + (var x (con Int) 100) + (assign x (app + x 1)) + x)))) + + (fn mut_returns_bool + (doc "Iter mut.1 — exercise Bool as a supported scalar var type.") + (type (fn-type (params) (ret (con Bool)))) + (params) + (body + (mut + (var flag (con Bool) false) + (assign flag true) + flag))) + + (fn mut_returns_unit + (doc "Iter mut.1 — exercise Unit as the supported scalar zero case.") + (type (fn-type (params) (ret (con Unit)))) + (params) + (body + (mut + (var u (con Unit) (lit-unit)) + (assign u (lit-unit)) + u)))) +``` + +These six fns cover: empty body, single-var, two-var, nested shadow, +Bool, Unit. The four supported scalar types (Int, Float, Bool, Unit) +are each represented. The fixture does NOT cover Str (deferred to a +future milestone per spec §"Out of scope"). + +- [ ] **Step 2: Run round-trip** + +Run: `cargo test --workspace -p ailang-surface round_trip` +Expected: PASS — both `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture` +and `parse_is_deterministic_over_every_ail_fixture` green, including +the newly-added `mut.ail` fixture. + +- [ ] **Step 3: Run schema-coverage** + +Run: `cargo test --workspace -p ailang-core schema_coverage` +Expected: PASS — `every_ast_variant_is_observed_in_the_fixture_corpus` +green now that `examples/mut.ail` declares the two new variants. + +- [ ] **Step 4: Full workspace test run** + +Run: `cargo test --workspace 2>&1 | tail -20` +Expected: every test green. If any test fails, inspect the failure +before continuing — the iteration's gate is full-workspace-green. + +--- + +## Self-review checklist + +- [ ] **Spec coverage:** every section of `docs/specs/2026-05-15-mut-local.md` + §"Iteration mut.1 — Schema + surface" has a corresponding task here. + Cross-check: AST extension (Task 1), Form A surface (Task 4), + canonical-form invariants (Task 1 empty-vars pin + Task 5 drift + tests), DESIGN.md amendment (Task 5 Step 5), round-trip fixture + (Task 6). All covered. + +- [ ] **Placeholder scan:** grep this file for "TBD" / "TODO" / + "implement later" / "similar to Task" / "appropriate error + handling". Expected: no hits. + +- [ ] **Type-name consistency:** `Term::Mut`, `Term::Assign`, `MutVar`, + `CheckError::Internal`, `CodegenError::Internal`, `vars`, `body`, + `name`, `value`, `init`, `ty`. Each appears with the same spelling + in every task that references it. + +- [ ] **Step granularity:** every step is 2-5 minutes. Tasks 1-3 + Step shapes are all "add an exhaustive-match arm of one of two + canonical shapes (rebuilder or visitor) at a named line"; each is + bite-sized. Task 4 steps follow TDD shape with code shown. Task + 5 steps are mechanical drift-test extensions. Task 6 steps are + fixture + test invocations. + +- [ ] **No commit steps:** verified — the plan contains no + `git commit` instructions. The implementer leaves work in the + working tree; the Boss commits at iter end. + +- [ ] **Boss decisions named:** the three recon open questions are + resolved in this plan: (1) walker-arm policy is "substantive + everywhere except `check_term` and `lower_term` dispatch sites + which stub with Internal" (Tasks 2-3); (2) empty-`vars` canonical + bytes pinned by the test in Task 1 Step 1; (3) `(mut)` with no + body rejected at parse via the test in Task 4 Step 1. + +--- + +## Acceptance gate for iteration mut.1 + +- `cargo build --workspace` green. +- `cargo test --workspace` green, including: + - `ast::tests::term_mut_empty_vars_serialises_with_explicit_vars_field` + - `ast::tests::term_assign_round_trips_through_json` + - `parses_empty_mut_with_int_body` + - `parses_mut_with_one_var_and_one_assign_and_final` + - `rejects_mut_with_no_body` + - `rejects_mut_with_only_vars_no_final_expression` + - `design_md_anchors_every_term_variant` + - `data_model_section_is_bounded` + - `every_ast_variant_is_observed_in_the_fixture_corpus` + - `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture` + (including `examples/mut.ail`) + - `parse_is_deterministic_over_every_ail_fixture` + (including `examples/mut.ail`) + - `spec_drift` +- `examples/mut.ail` parses, round-trips, and is observed by the + fixture-corpus walker. +- `Term::Mut` and `Term::Assign` reaching typecheck produce + `CheckError::Internal` with the expected message text. +- `Term::Mut` and `Term::Assign` reaching codegen produce + `CodegenError::Internal` with the expected message text. + +The iteration does NOT include typecheck recognition (deferred to +mut.2) or codegen lowering (deferred to mut.3); a `mut.ail`-based +e2e test that *runs* a mut block is therefore out of scope and is +not part of the gate.