Files
AILang/docs/plans/0067-iter-mut.1.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

40 KiB

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-525Term 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:

#[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:

    /// 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<MutVar>,
        body: Box<Term>,
    },

    /// 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<Term>,
    },

Then, adjacent to the existing pub struct Arm { ... } (around line 515-525), insert:

/// 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": "<id>", "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):

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):

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:340collect_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:531subst_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<String>. 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:1298subst_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:1477find_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:1548any_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:2660any_lit_pattern

Visitor-shape returning bool. Same pattern as Step 7.

  • Step 9: workspace.rs:1245walk_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:242substitute_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:

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:731term_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. "<mut-block>"). 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:2572check_term dispatch.

  • Modify: crates/ailang-codegen/src/lib.rs:1649lower_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:

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:

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:

#[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:

"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:

fn parse_mut(&mut self, head_span: Span) -> Result<Term, ParseError> {
    // (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<MutVar> = Vec::new();
    let mut body_stmts: Vec<Term> = 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<Term, ParseError> {
    // (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:

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:

(
    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:

Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign",
  • Step 2: Extend schema_coverage.rs

In the VariantTag enum at lines 28-70, after TermReuseAs, add:

TermMut,
TermAssign,

In EXPECTED_VARIANTS at lines 76-111, append:

VariantTag::TermMut,
VariantTag::TermAssign,

In visit_term at lines 156-232, after the Term::ReuseAs arm (around line 226), append:

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:

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:

// 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": "<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. See
// `docs/specs/0029-mut-local.md`.
{ "t": "assign",
  "name": "<id>",
  "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.