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.
40 KiB
mut.1 — schema + surface — Implementation Plan
Parent spec:
docs/specs/0029-mut-local.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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—Termenum extension + newMutVarstruct. - Modify:
crates/ailang-core/src/desugar.rs— nineTerm::ReuseAs- adjacent walker arms gain substantiveTerm::Mut+Term::Assignarms 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 inMutVar.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 helpersparse_mut/parse_assign, plus the EBNF prologue atparse.rs:7-86. - Modify:
crates/ailang-surface/src/print.rs:401-548— two new arms inwrite_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_termdispatch — STUBBED to returnCheckError::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_termdispatch — STUBBED to returnCodegenError::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 newVariantTagentries +EXPECTED_VARIANTSextension + two newvisit_termarms. - 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 forTerm::MutandTerm::Assignafter thereuse-asblock (line 2364); add the prose paragraph naming the iteration that introduces them. - Test:
crates/ailang-core/src/ast.rsunit tests (new) — canonical- bytes pin for empty-varsTerm::Mutserialisation. - Test:
crates/ailang-surface/src/parse.rsunit 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
MutVarstruct toast.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 thewalk_term_embedded_typescall at line 1245 — that one must visitvars[i].ty). - Modify:
crates/ailang-check/src/lib.rslines 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.rslines 373, 731. - Modify:
crates/ailang-check/src/linearity.rslines 555, 778, 900. - Modify:
crates/ailang-check/src/mono.rslines 1220, 1576. - Modify:
crates/ailang-check/src/pre_desugar_validation.rsline 119. - Modify:
crates/ailang-check/src/reuse_shape.rsline 248. - Modify:
crates/ailang-check/src/uniqueness.rsline 329. - Modify:
crates/ailang-codegen/src/escape.rslines 187, 358, 469. - Modify:
crates/ailang-codegen/src/lambda.rsline 428. - Modify:
crates/ailang-codegen/src/lib.rsline 2909 (the second site in this file; line 1649 is the codegen dispatch stub in Task 3). - Modify:
crates/ailang-prose/src/lib.rslines 885, 1079, 1187. - Modify:
crates/ail/src/main.rslines 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<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: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:1519anddesugar.rs:1548—any_nested_ctorandany_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:
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. "<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:2572—check_termdispatch. -
Modify:
crates/ailang-codegen/src/lib.rs:1649—lower_termdispatch. -
Step 1:
check_termdispatch stub atlib.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_termdispatch stub atcodegen/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_mutandparse_assignhelpers
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(incrates/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 commitinstructions. 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_termandlower_termdispatch sites which stub with Internal" (Tasks 2-3); (2) empty-varscanonical 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 --workspacegreen.cargo test --workspacegreen, including:ast::tests::term_mut_empty_vars_serialises_with_explicit_vars_fieldast::tests::term_assign_round_trips_through_jsonparses_empty_mut_with_int_bodyparses_mut_with_one_var_and_one_assign_and_finalrejects_mut_with_no_bodyrejects_mut_with_only_vars_no_final_expressiondesign_md_anchors_every_term_variantdata_model_section_is_boundedevery_ast_variant_is_observed_in_the_fixture_corpusparse_then_print_then_parse_is_idempotent_on_every_ail_fixture(includingexamples/mut.ail)parse_is_deterministic_over_every_ail_fixture(includingexamples/mut.ail)spec_drift
examples/mut.ailparses, round-trips, and is observed by the fixture-corpus walker.Term::MutandTerm::Assignreaching typecheck produceCheckError::Internalwith the expected message text.Term::MutandTerm::Assignreaching codegen produceCodegenError::Internalwith 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.