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.
23 KiB
mut.4-tidy — Implementation Plan
Parent spec:
docs/specs/0029-mut-local.md(covered by audit-mut-local findings; no new spec).For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Close the mut-local milestone audit by addressing the
architect's drift findings and the bench check_ms regression.
Specifically: (a) add a typecheck diagnostic that rejects
lambda-captures-of-mut-var (CheckError::MutVarCapturedByLambda)
and a negative fixture; (b) replace the codegen lambda.rs blame-
typechecker comment + Internal-error path with an unreachable!
once the typecheck gate is in place; (c) clean stale mut.1-stub
history comments in DESIGN.md §"Term" and
crates/ailang-codegen/src/lib.rs; (d) short-circuit the empty
mut_scope_stack walk in the Term::Var arm of synth to
eliminate the per-Var-resolution overhead for programs with no mut
blocks.
Architecture: Architect's two [high] drift items are downstream
of one spec gap: docs/specs/0029-mut-local.md does not
enumerate "lambda body referencing an enclosing mut-var" as a
diagnostic. The fix has three sites — typecheck adds the diagnostic
in Term::Lam's synth arm, codegen replaces its blame-comment path
with unreachable!, and the spec gets an "Out of scope" amendment
noting that mut-vars cannot escape into closures in this milestone.
The bench check_ms regression has one root cause — the
mut_scope_stack.iter().rev().find_map(...) walk at every
Term::Var resolution runs even when the stack is empty (every
program with no mut blocks); short-circuiting on is_empty() makes
the path zero-cost for the existing corpus.
Tech Stack: ailang-check, ailang-codegen, docs/. Plus one
new negative fixture and one driver-test extension.
Files this plan creates or modifies:
- Create:
examples/test_mut_var_captured_by_lambda.ail.json— negative fixture: a lambda inside a mut block references a mut-var from the enclosing scope. - Modify:
crates/ailang-check/src/lib.rs:638-746— addCheckError::MutVarCapturedByLambda { name }variant +code()armmut-var-captured-by-lambda+ctx()arm + Display. - Modify:
crates/ailang-check/src/lib.rs(Term::Lamarm ofsynth) — walk the lambda's body for free Vars that resolve to the enclosingmut_scope_stack; reject withMutVarCapturedByLambdaif any match. - Modify:
crates/ailang-check/src/lib.rs(Term::Vararm ofsynth) — short-circuit whenmut_scope_stack.is_empty(). - Modify:
crates/ailang-codegen/src/lambda.rs:88-99(capture-not- in-locals path) — replace theCodegenError::Internal(... blames typechecker ...)withunreachable!("typecheck rejects lambda-capture-of-mut-var via MutVarCapturedByLambda since mut.4-tidy"). - Modify:
crates/ailang-codegen/src/lambda.rs:466-467— remove the doc-comment promise that mut.2 typecheck rejects lambda captures (it does as of mut.4); rewrite to reflect current reality. - Modify:
crates/ailang-check/src/lift.rs:379— same comment cleanup. - Modify:
crates/ailang-codegen/src/lib.rs:1749-1758— remove the stale mut.1-stub comment block above the real Term::Mut arm. - Modify:
docs/DESIGN.md:2369-2402(§"Term (expression)" mut/assign jsonc block) — remove the trailing paragraph describing the mut.1-stub dispatch state; replace with a paragraph describing the current end-to-end mut.3 state. - Modify:
docs/specs/0029-mut-local.md(§"Out of scope" or equivalent) — add a bullet naming "Lambda capture of a mut-var" as explicitly rejected, with the diagnostic name. - Modify:
crates/ailang-check/tests/mut_typecheck_pin.rs— add driver test for the new fixture asserting themut-var-captured-by-lambdadiagnostic code. - Modify:
crates/ailang-core/tests/carve_out_inventory.rs— EXPECTED extended 12 → 13 for the new negative fixture.
Task 1 — CheckError::MutVarCapturedByLambda
Files:
-
Modify:
crates/ailang-check/src/lib.rs:638-746 -
Step 1: Write the failing pin test
In #[cfg(test)] mod tests:
#[test]
fn mut_var_captured_by_lambda_diagnostic_has_kebab_code() {
let e = CheckError::MutVarCapturedByLambda { name: "sum".into() };
assert_eq!(e.code(), "mut-var-captured-by-lambda");
let _ = e.ctx();
}
- Step 2: Run to verify red
Run: cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code
Expected: FAIL — variant does not exist.
- Step 3: Add the variant + code() + ctx() + Display
In the CheckError enum body, immediately after
UnsupportedMutVarType, insert:
/// Iter mut.4-tidy: `Term::Lam` reached typecheck inside a
/// `Term::Mut` scope, and the lambda's body references a mut-var
/// declared in that enclosing scope. Spec
/// `docs/specs/0029-mut-local.md` §"Out of scope": mut-vars
/// cannot escape — they are alloca-resident and lexically scoped.
/// Capturing one into a closure would require either lifting it
/// to the heap (deferred milestone) or rejecting the capture.
/// This milestone takes the latter route.
#[error("[mut-var-captured-by-lambda] mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
MutVarCapturedByLambda { name: String },
Then the code() arm immediately after UnsupportedMutVarType's:
CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
And the ctx() arm:
CheckError::MutVarCapturedByLambda { name } => json!({
"name": name,
}),
- Step 4: Verify
Run: cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code
Expected: PASS.
Task 2 — Reject lambda-captures-of-mut-var in Term::Lam synth arm
Files:
- Modify:
crates/ailang-check/src/lib.rs(Term::Lamarm ofsynth)
The arm is around lib.rs:3374-3407 per the architect's report.
The lambda's body is typechecked in a fresh locals scope with the
lambda's params bound — but mut_scope_stack carries over from the
caller. The fix is: BEFORE descending into the lambda body, scan
the body for free Vars whose names resolve in mut_scope_stack.
If any do, reject.
- Step 1: Write the failing pin (positive — lambda outside mut still works)
Add to #[cfg(test)] mod tests:
#[test]
fn lambda_outside_mut_still_typechecks_clean() {
// (lam (params (typed x (con Int))) (ret (con Int)) (body (app + x 1)))
let term = Term::Lam {
params: vec!["x".into()],
param_types: vec![Type::int()],
ret_type: Type::int(),
effects: vec![],
body: Box::new(Term::App {
func: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "x".into() },
Term::Lit { lit: Literal::Int { value: 1 } },
],
// tail-call default field name
}),
};
let (_ty, _) = synth_in_builtins_env(&term);
// No assertion on the returned type beyond that it doesn't panic
// / fail — this is a regression test that lambda synth wasn't
// broken by the new lambda-body free-var scan.
}
This test should PASS even before Task 2 Step 2 — it's a regression guard.
- Step 2: Write the failing negative pin
#[test]
fn lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda() {
// (mut (var x (con Int) 0)
// (let f (lam (params) (ret (con Int)) (body x)) ;; captures x
// 0))
let term = Term::Mut {
vars: vec![ailang_core::ast::MutVar {
name: "x".into(),
ty: Type::int(),
init: Term::Lit { lit: Literal::Int { value: 0 } },
}],
body: Box::new(Term::Let {
name: "f".into(),
value: Box::new(Term::Lam {
params: vec![],
param_types: vec![],
ret_type: Type::int(),
effects: vec![],
body: Box::new(Term::Var { name: "x".into() }),
}),
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
}),
};
let err = synth_in_builtins_env_expect_err(&term);
match err {
CheckError::MutVarCapturedByLambda { name } => assert_eq!(name, "x"),
other => panic!("expected MutVarCapturedByLambda, got {:?}", other),
}
}
(synth_in_builtins_env_expect_err is whatever helper the existing
tests use to drive synth and unwrap an Err — match the convention.)
- Step 3: Run to verify red
Run: cargo test --workspace -p ailang-check lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda
Expected: FAIL — currently the lambda body resolves x against the
outer mut_scope_stack and types as Int with no diagnostic.
- Step 4: Add the scan to
Term::Lam's synth arm
In the Term::Lam { params, param_types, body, .. } arm of synth,
BEFORE descending into the body (i.e. before the call to
synth(body, &lambda_locals, mut_scope_stack, ...)), insert:
// Iter mut.4-tidy: reject lambda-captures-of-mut-var.
// Walk the lambda body for free Vars (names not bound by the
// lambda's own params) that match any mut-var on the enclosing
// stack. If any match, reject with MutVarCapturedByLambda.
if !mut_scope_stack.is_empty() {
let captured = collect_free_vars_in_term(body);
let lambda_param_names: std::collections::BTreeSet<&str> =
params.iter().map(|s| s.as_str()).collect();
for free_name in &captured {
if lambda_param_names.contains(free_name.as_str()) {
continue;
}
for frame in mut_scope_stack.iter() {
if frame.contains_key(free_name) {
return Err(CheckError::MutVarCapturedByLambda {
name: free_name.clone(),
});
}
}
}
}
The helper collect_free_vars_in_term(t: &Term) -> Vec<String> may
already exist (the codebase has free-var walkers in desugar.rs:1154-
area). If a directly-callable helper does not exist, write a small
local one:
fn collect_free_vars_in_term(t: &Term) -> Vec<String> {
fn walk(t: &Term, bound: &mut Vec<String>, out: &mut Vec<String>) {
match t {
Term::Var { name } => {
if !bound.contains(name) && !out.contains(name) {
out.push(name.clone());
}
}
Term::Let { name, value, body } => {
walk(value, bound, out);
bound.push(name.clone());
walk(body, bound, out);
bound.pop();
}
Term::LetRec { name, body, in_term, .. } => {
bound.push(name.clone());
walk(body, bound, out);
walk(in_term, bound, out);
bound.pop();
}
Term::Lam { params, body, .. } => {
let n = bound.len();
for p in params { bound.push(p.clone()); }
walk(body, bound, out);
bound.truncate(n);
}
Term::Mut { vars, body } => {
for v in vars { walk(&v.init, bound, out); }
let n = bound.len();
for v in vars { bound.push(v.name.clone()); }
walk(body, bound, out);
bound.truncate(n);
}
Term::Assign { name, value } => {
// Assign's name IS a use of the binding — counts as a free var if not bound.
if !bound.contains(name) && !out.contains(name) {
out.push(name.clone());
}
walk(value, bound, out);
}
Term::App { func, args, .. } => {
walk(func, bound, out);
for a in args { walk(a, bound, out); }
}
Term::If { cond, then, else_ } => {
walk(cond, bound, out);
walk(then, bound, out);
walk(else_, bound, out);
}
Term::Match { scrutinee, arms } => {
walk(scrutinee, bound, out);
for arm in arms {
// pattern bindings... for simplicity, scan body
// without removing pattern-bound names — over-
// approximates free vars (worst-case: emits the
// diagnostic when none was needed). The over-
// approximation is conservative-safe for
// capture-rejection; a false-positive can be
// tightened in a future iter.
walk(&arm.body, bound, out);
}
}
Term::Seq { lhs, rhs } => {
walk(lhs, bound, out);
walk(rhs, bound, out);
}
Term::Clone { value } => walk(value, bound, out),
Term::ReuseAs { source, body } => {
walk(source, bound, out);
walk(body, bound, out);
}
Term::Ctor { args, .. } => for a in args { walk(a, bound, out); },
Term::Do { args, .. } => for a in args { walk(a, bound, out); },
Term::Lit { .. } => {}
}
}
let mut bound = Vec::new();
let mut out = Vec::new();
walk(t, &mut bound, &mut out);
out
}
(The exact match-arm shape must be exhaustive against the post-mut.1 Term enum. Pattern bindings inside Term::Match arms are over-approximated for the first cut; a tightening pass can land in a future tidy iter.)
- Step 5: Verify
Run: cargo test --workspace -p ailang-check lambda_outside_mut_still_typechecks_clean lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda
Expected: both PASS.
Run: cargo test --workspace 2>&1 | grep -E 'FAILED|failures' | head -5
Expected: no failures introduced. (If any existing test broke,
likely cause: the over-approximation in pattern bindings — check
which test, decide whether to tighten the helper or to add a
specific bound-name extraction for Term::Match arms.)
Task 3 — Short-circuit empty mut_scope_stack in Term::Var arm
Files:
-
Modify:
crates/ailang-check/src/lib.rs:2743-2747 -
Step 1: Locate the existing prepend
Find the iter mut.2 prepend in the Term::Var arm of synth,
introduced at lib.rs:2743-2747:
for frame in mut_scope_stack.iter().rev() {
if let Some(ty) = frame.get(name) {
return Ok((ty.clone(), /* ... */));
}
}
- Step 2: Wrap in an
is_emptyguard
if !mut_scope_stack.is_empty() {
for frame in mut_scope_stack.iter().rev() {
if let Some(ty) = frame.get(name) {
return Ok((ty.clone(), /* ... */));
}
}
}
This is a single-instruction short-circuit. For the existing test
corpus (zero mut blocks outside the four mut*.ail fixtures), this
collapses the prepend cost to one is_empty() check per Var
resolution — effectively zero overhead.
- Step 3: Verify the existing pin tests still pass
Run: cargo test --workspace -p ailang-check 2>&1 | tail -5
Expected: PASS.
- Step 4: Re-run
compile_check.pyto confirm regression closes
Run: bench/compile_check.py 2>&1 | tail -10
Expected: zero REGRESSION lines on check_ms. (If the short-circuit
brings check_ms back within tolerance, the bench exit drops to 0
for this script. If not, additional optimization is needed — but
the prepend cost is the only known introduced overhead in mut.2.)
Task 4 — Codegen comment cleanup + unreachable blame fix
Files:
-
Modify:
crates/ailang-codegen/src/lambda.rs:88-99 -
Modify:
crates/ailang-codegen/src/lambda.rs:466-467 -
Modify:
crates/ailang-codegen/src/lib.rs:1749-1758 -
Step 1:
lambda.rs:88-99— replace Internal blame with unreachable!
Locate the existing path where a lambda capture is not found in
self.locals. The current arm raises CodegenError::Internal with
a message blaming the typechecker. Replace with:
None => {
// Iter mut.4-tidy: post-Task-2 the typechecker
// rejects this via `CheckError::MutVarCapturedByLambda`.
// Reaching this path means typecheck was skipped or
// a bug let the AST through — both are bugs in
// upstream layers, not in codegen.
unreachable!(
"lambda capture {name:?} not in locals — \
typecheck should have rejected via MutVarCapturedByLambda",
);
}
- Step 2:
lambda.rs:466-467— comment cleanup
The existing comment promises mut.2 rejects lambda captures of mut-vars. Update to:
// Mut-var captures by lambdas are rejected at typecheck by
// CheckError::MutVarCapturedByLambda (mut.4-tidy). codegen does
// not see them.
- Step 3:
lib.rs:1749-1758— remove stale mut.1-stub comment
The architect's report names this site as still describing the mut.1 stub state. Read the current comment and either delete it (if purely historical) or rewrite to reflect the mut.3 reality. The new comment should NOT mention "stub", "deferred to mut.3", or "Internal-error". A one-line comment summarising the arm's purpose ("Lower a mut-block: per-var alloca emit + body lower + scope exit") is sufficient.
- Step 4: Verify build green
Run: cargo build --workspace 2>&1 | tail -5
Expected: green.
Run: cargo test --workspace 2>&1 | tail -5
Expected: green.
Task 5 — lift.rs comment cleanup
Files:
-
Modify:
crates/ailang-check/src/lift.rs:379 -
Step 1: Update the comment
The existing comment promises mut.2 rejects lambda captures of mut-vars. Update to match the mut.4-tidy reality, in the same form as Task 4 Step 2.
- Step 2: Verify build green
Run: cargo build --workspace 2>&1 | tail -5
Expected: green.
Task 6 — DESIGN.md amendment
Files:
-
Modify:
docs/DESIGN.md:2369-2402(§"Term (expression)" mut/assign block) -
Step 1: Update the trailing paragraph
The current jsonc block ends with a paragraph describing the mut.1-stub state. Replace with:
Both shapes ship in fully-lowered form as of iter mut.3 (2026-05-15):
typecheck binds mut-vars in a lexical scope stack, codegen lowers
them as entry-block allocas, and `Term::Assign` emits a `store`
yielding the canonical Unit SSA. Mut-vars must be of one of the
stack-resident scalar types {Int, Float, Bool, Unit}; capturing a
mut-var into a lambda is rejected at typecheck via
`CheckError::MutVarCapturedByLambda`. See
`docs/specs/0029-mut-local.md`.
- Step 2: Verify schema-drift tests still pass
Run: cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5
Expected: PASS.
Task 7 — Spec amendment
Files:
-
Modify:
docs/specs/0029-mut-local.md(§"Out of scope" or §"Acceptance criteria") -
Step 1: Add bullet to §"Out of scope"
In the §"Out of scope" subsection, append a bullet:
- **Lambda capture of a mut-var.** A lambda body whose free vars
include a mut-var of an enclosing `Term::Mut` is rejected at
typecheck with `CheckError::MutVarCapturedByLambda`. Mut-vars are
alloca-resident and lexically scoped; lifting them to the heap
for closure-capture would require ref-types and the `!Mut`
effect, both of which are deferred to the follow-on
Stateful-islands milestone (the layered effect-handler + ref-type
combo). The rejection diagnostic was added in iter mut.4-tidy.
Task 8 — Negative fixture + driver test extension
Files:
-
Create:
examples/test_mut_var_captured_by_lambda.ail.json -
Modify:
crates/ailang-check/tests/mut_typecheck_pin.rs -
Modify:
crates/ailang-core/tests/carve_out_inventory.rs -
Step 1: Write the fixture
Create examples/test_mut_var_captured_by_lambda.ail.json:
{
"name": "test_mut_var_captured_by_lambda",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"ty": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" } },
"params": [],
"body": {
"t": "mut",
"vars": [
{ "name": "x", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }
],
"body": {
"t": "let",
"name": "f",
"value": {
"t": "lam",
"params": [],
"paramTypes": [],
"retType": { "k": "con", "name": "Int" },
"effects": [],
"body": { "t": "var", "name": "x" }
},
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
}
}
}
]
}
Verify the exact root-key + field-name shape against an existing
sibling fixture (e.g. examples/test_mut_assign_out_of_scope.ail.json).
- Step 2: Add driver test
In crates/ailang-check/tests/mut_typecheck_pin.rs:
#[test]
fn lambda_capturing_mut_var_emits_mut_var_captured_by_lambda() {
let codes = check_fixture("test_mut_var_captured_by_lambda.ail.json");
assert_eq!(codes, vec!["mut-var-captured-by-lambda".to_string()]);
}
- Step 3: Update carve-out inventory
In crates/ailang-core/tests/carve_out_inventory.rs, bump the
EXPECTED count from 12 → 13 (the new fixture joins the existing
twelve .ail.json carve-outs).
- Step 4: Run all driver tests
Run: cargo test --workspace -p ailang-check mut_typecheck_pin 2>&1 | tail -5
Expected: all six tests PASS (five from mut.2 + the new one).
Run: cargo test --workspace -p ailang-core carve_out_inventory 2>&1 | tail -3
Expected: PASS.
Run: cargo test --workspace 2>&1 | tail -5
Expected: full workspace green.
Self-review checklist
-
Drift coverage: all four architect items (two
[high]+ two[medium]) plus the bench check_ms regression are addressed. Architect[low]items (prose render of MutVar) deliberately deferred per architect's recommendation. -
Placeholder scan: grep for "TBD" / "TODO" / "implement later" / "similar to Task" / "appropriate error handling" — no hits in this plan.
-
Type-name consistency:
CheckError::MutVarCapturedByLambda,mut-var-captured-by-lambda(kebab code),collect_free_vars_in_term(local helper),mut_scope_stack,unreachable!— each appears identically across tasks. -
Step granularity: each step is 2-5 minutes.
-
No commit steps: verified.
Acceptance gate for iter mut.4-tidy
cargo build --workspacegreen.cargo test --workspacegreen, including the new negative-fixture pin and the carve_out_inventory bump.bench/compile_check.pycheck_ms regression closed (within tolerance vs. baseline).bench/check.pyandbench/cross_lang.pyunchanged from their pre-mut.4 state (tail-noise envelope holds; runtime stable).- All architect-flagged comments rewritten to reflect the post-mut.3 reality.
- The follow-on Stateful-islands milestone is now unblocked per the architect's gate.