# mut.1 — schema + surface — Implementation Plan > **Parent spec:** `docs/specs/0029-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/0029-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/0029-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/0029-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.