Files
AILang/docs/plans/0070-iter-mut.4-tidy.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

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/implement to 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 — add CheckError::MutVarCapturedByLambda { name } variant + code() arm mut-var-captured-by-lambda + ctx() arm + Display.
  • Modify: crates/ailang-check/src/lib.rs (Term::Lam arm of synth) — walk the lambda's body for free Vars that resolve to the enclosing mut_scope_stack; reject with MutVarCapturedByLambda if any match.
  • Modify: crates/ailang-check/src/lib.rs (Term::Var arm of synth) — short-circuit when mut_scope_stack.is_empty().
  • Modify: crates/ailang-codegen/src/lambda.rs:88-99 (capture-not- in-locals path) — replace the CodegenError::Internal(... blames typechecker ...) with unreachable!("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 the mut-var-captured-by-lambda diagnostic 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::Lam arm of synth)

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_empty guard
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.py to 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 --workspace green.
  • cargo test --workspace green, including the new negative-fixture pin and the carve_out_inventory bump.
  • bench/compile_check.py check_ms regression closed (within tolerance vs. baseline).
  • bench/check.py and bench/cross_lang.py unchanged 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.