Files
AILang/docs/plans/0080-remove-mut-var-assign.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

25 KiB
Raw Blame History

Remove mut / var / assign — Implementation Plan (iteration 1, terminal)

Parent spec: docs/specs/0037-remove-mut-var-assign.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Remove the mut/var/assign construct from AILang entirely (AST + surface + checker + codegen + DESIGN.md + fixtures + drift trio), atomically, with loop/recur + let/if as the surviving forms.

Architecture: A Rust exhaustive-match enum-variant removal is atomic by language design — there is no compile-preserving intermediate, and the workspace test suite is necessarily red between the Rust cut and the fixture/doc/drift cleanup. Therefore this is one terminal iteration: Task 1 writes the RED must-fail pins; Task 2 is the atomic Rust cut (gate: cargo build --workspace 0 errors + Task-1 pins GREEN; full suite intentionally red, stated); Tasks 36 land the dangling-pin deletions, DESIGN.md, fixtures, carve-out/roadmap; the full cargo test --workspace green gate + loop/recur non-regression is the final step of Task 6 only.

Tech Stack: ailang-core (ast/desugar/workspace), ailang-surface (parse/print), ailang-check (synth/escape/CheckError), ailang-codegen (lib/escape/lambda), ailang-prose, ail (main), the drift-trio + e2e + round-trip test crates.


Files this plan creates or modifies:

  • Create: crates/ailang-surface/tests/mut_removed_pin.rs — RED→GREEN must-fail pins (mut keyword rejected; {"t":"mut"} serde-rejected)
  • Modify: crates/ailang-core/src/ast.rs:516-617 (delete Term::Mut/Term::Assign/MutVar; in-source test arms 964/981/994)
  • Modify: crates/ailang-core/src/desugar.rs (Mut/Assign arms — lines in Task 2 step)
  • Modify: crates/ailang-core/src/workspace.rs:1254/1261, 1401/1407
  • Modify: crates/ailang-check/src/lib.rs (synth arms, 4 CheckError, mut_scope_stack param + all callers, escape guard 3637, match arms)
  • Modify: crates/ailang-check/src/{linearity,uniqueness,reuse_shape,pre_desugar_validation}.rs (Mut/Assign arms)
  • Modify: crates/ailang-check/src/{builtins,lift,mono}.rs (mut_scope_stack external callers)
  • Modify: crates/ailang-codegen/src/lib.rs (lower arms 1779/1834, mut_var_allocasbinder_allocas, synth_with_extras 3237/3238)
  • Modify: crates/ailang-codegen/src/escape.rs (Mut/Assign arms 197/203, 400/408, 533/548)
  • Modify: crates/ailang-codegen/src/lambda.rs:486 (Mut arm; rename save/restore 144/152/265/267)
  • Modify: crates/ailang-prose/src/lib.rs:909/933, 1140/1159, 1300/1333
  • Modify: crates/ailang-surface/src/parse.rs (dispatch 1213/1214, parse_mut 1587+, parse_assign 1637+, reservation 1676-1677, grammar doc-comment 70-72, test arms 2597-2656)
  • Modify: crates/ailang-surface/src/print.rs:551-602
  • Modify: crates/ail/src/main.rs:1520/1538, 2772/2797
  • Delete: crates/ailang-check/tests/mut_typecheck_pin.rs (whole file)
  • Modify: crates/ail/tests/ct1_check_cli.rs:209-211 (drop mut case)
  • Modify: crates/ailang-core/tests/schema_coverage.rs:50-51, 99-100, 239/247
  • Modify: crates/ailang-core/tests/design_schema_drift.rs:141, 148, 184/185
  • Modify: crates/ailang-core/tests/spec_drift.rs:163/164
  • Modify: docs/DESIGN.md:2404-2422, 2448-2458, 2891-2912
  • Modify (rewrite): examples/mut.ail, examples/mut_counter.ail, examples/mut_sum_floats.ail, examples/fieldtest/mut-local_{1,2,3,4}*.ail
  • Delete: examples/fieldtest/mut-local_5_lambda_capture_probe.ail, examples/fieldtest/mut-local_6_diag_probe.ail, examples/test_mut_var_captured_by_lambda.ail.json
  • Modify: crates/ailang-core/tests/carve_out_inventory.rs:6-57 (EXPECTED 18→12, drop 6 mut entries)
  • Modify: docs/roadmap.md:439-445 (delete false-foundation sub-bullet)
  • Test (non-regression, unchanged, must stay green): crates/ail/tests/e2e.rs:2863/2876 (mut_counter/mut_sum_floats →55) and the loop/recur e2e + loop_recur_typecheck_pin.rs::lambda_capturing_loop_binder_emits_loop_binder_captured_by_lambda

Task 1: RED must-fail pins (the removal made executable)

Files:

  • Create: crates/ailang-surface/tests/mut_removed_pin.rs

  • Step 1: Write the failing pins

//! Pins the mut/var/assign removal: the keyword no longer parses and
//! the canonical JSON tag is an unknown serde variant. RED until the
//! Task-2 atomic cut lands.
use ailang_surface::parse;

#[test]
fn mut_keyword_is_rejected_by_form_a_parse() {
    let src = "(module m (fn f (type (fn-type (params) (ret (con Int)))) (params) (body (mut (var x (con Int) 0) (assign x (app + x 1)) x))))";
    let r = parse(src);
    assert!(r.is_err(), "mut keyword must no longer parse, got Ok");
}

#[test]
fn assign_keyword_is_rejected_by_form_a_parse() {
    let src = "(module m (fn f (type (fn-type (params) (ret (con Int)))) (params) (body (assign x 1))))";
    assert!(parse(src).is_err(), "assign keyword must no longer parse, got Ok");
}

#[test]
fn json_tag_mut_is_unknown_serde_variant() {
    let j = r#"{"t":"mut","vars":[],"body":{"t":"lit","lit":{"kind":"int","value":0}}}"#;
    let r: Result<ailang_core::ast::Term, _> = serde_json::from_str(j);
    assert!(r.is_err(), "{{\"t\":\"mut\"}} must be an unknown variant, got Ok");
}

#[test]
fn json_tag_assign_is_unknown_serde_variant() {
    let j = r#"{"t":"assign","name":"x","value":{"t":"lit","lit":{"kind":"int","value":1}}}"#;
    let r: Result<ailang_core::ast::Term, _> = serde_json::from_str(j);
    assert!(r.is_err(), "{{\"t\":\"assign\"}} must be an unknown variant, got Ok");
}
  • Step 2: Run to verify RED

Run: cargo test --workspace -p ailang-surface --test mut_removed_pin Expected: 4 tests, all FAIL (today mut/assign parse fine and the serde tags deserialize), e.g. mut keyword must no longer parse, got Ok.


Task 2: The atomic Rust cut

Single logical unit: the enum-variant removal forces every exhaustive Term match arm + the mut_scope_stack signature + all its callers to change together. Gate at task end is cargo build --workspace 0 errors plus Task-1 pins GREEN. The rest of cargo test --workspace is expected red here (fixtures still contain mut, drift trio still asserts deleted anchors) — that is cleaned in Tasks 36; do not attempt to green it in Task 2.

  • Step 1: Delete the AST variants + struct (ailang-core/src/ast.rs)

Delete Term::Mut (lines 516532, doc + body) and Term::Assign (533542). Delete struct MutVar (590617). Keep Term::Loop/ Term::Recur (543575) and struct LoopBinder (619638); in LoopBinder's doc (620,625) drop the now-dangling MutVar cross-reference phrase, leaving the sentence standing alone. Delete the in-source #[cfg(test)] round-trip helpers/arms at 956974 (Term::Mut) and 9771004 (Term::Assign, incl. arm 994).

  • Step 2: Delete every exhaustive Term::Mut/Term::Assign match arm

At each location below delete the Term::Mut { .. } => … and/or Term::Assign { .. } => … arm (110 lines each; an exhaustive no-_ match — the arm head is Term::Mut { vars, body } / Term::Assign { name, value }). Leave the sibling Term::Loop/ Term::Recur arms intact.

ailang-core/src/desugar.rs:        349,360 · 571,601 · 1257,1271 · 1440,1475 · 1647,1663 · 1742,1749 · 1806,1809 · 1840,1843 · 2965,2968
ailang-core/src/workspace.rs:      1254,1261 (walk_term_embedded_types) · 1401,1407 (walk_term)
ailang-check/src/lib.rs:           249,260 (type-subst rebuild) · 2752,2761 (verify_tail_positions) · 2880,2886 (verify_loop_body) · 3829,3830 (string-tag arm)
ailang-check/src/linearity.rs:     601,607 · 807,808 · 942,947
ailang-check/src/uniqueness.rs:    346,352
ailang-check/src/reuse_shape.rs:   261,267
ailang-check/src/pre_desugar_validation.rs: 129,135
ailang-codegen/src/lib.rs:         3237,3238 (synth_with_extras)
ailang-codegen/src/escape.rs:      197,203 · 400,408 · 533,548 (collect_free_vars — Term::Loop arm at ~555 SURVIVES)
ailang-codegen/src/lambda.rs:      486 (Term::Mut arm; Term::Loop arm 516 SURVIVES)
ailang-prose/src/lib.rs:           909,933 · 1140,1159 · 1300,1333
ail/src/main.rs:                   1520,1538 (Display/walker) · 2772,2797 (type-rewrite walker)

(The ailang-check/src/lib.rs synth arms 3852/3888, the codegen lower_term arms 1779/1834, and the surface ctor/print sites are their own steps below.)

  • Step 3: Surface — delete the parser, dispatch, reservation, grammar (ailang-surface/src/parse.rs)

  • Delete dispatch lines 1213 ("mut" => self.parse_mut()) and 1214 ("assign" => self.parse_assign()).

  • Delete fn parse_mut (from 1587 expect_keyword("mut") through the Term::Mut ctor at 1623, whole fn) and fn parse_assign (1637 through Term::Assign ctor 1641, whole fn).

  • In the head-ident reservation list, delete "mut" and "assign" at 16761677; keep "loop" | "recur" (16781679).

  • In the grammar doc-comment EBNF block delete the three lines //! mut-term ::= …, //! var-decl ::= …, //! assign-term ::= … (the Iter mut.1 lines at ~7072).

  • Delete the in-source #[cfg(test)] mut/assign parse tests at 25972644 and the arm at 2656 / test arms 2606,2627,2633.

  • Step 4: Surface — delete the print arms (ailang-surface/src/print.rs)

Delete the Term::Mut arm (551585) and Term::Assign arm (586602). Keep the surrounding Term::Loop/Term::Recur arms.

  • Step 5: Checker — delete the 4 CheckError variants + registry/ctx (ailang-check/src/lib.rs)

Delete variant defs: MutAssignOutOfScope (661665), AssignTypeMismatch (670675), UnsupportedMutVarType (682686), MutVarCapturedByLambda (697). Delete their code() arms (786789) and ctx() arms (857, 860, 863, 866). Keep LoopBinderCapturedByLambda and all Recur* variants/codes/ctx untouched.

  • Step 6: Checker — delete the synth arms + mut_scope_stack push/pop (ailang-check/src/lib.rs)

Delete the Term::Mut synth arm (38523883, incl. the UnsupportedMutVarType raise 3856, push/pop 3867/3872 and 3876/3881) and the Term::Assign synth arm (38883925, incl. MutAssignOutOfScope 3905, AssignTypeMismatch 3918). Delete the Term::Var-resolution read of mut_scope_stack at 29872988 (the Term::Var arm itself stays; only the mut-scope lookup block goes — loop binder names live in locals, not here).

  • Step 7: Checker — remove the mut_scope_stack parameter from synth and ALL callers (ailang-check)

This is a crate-wide signature change; every site below must change in this step so the Step-9 cargo build gate is satisfiable.

  • Remove the mut_scope_stack: &mut Vec<…> formal param from synth (decl 2938) and from its two local-decl helpers (2022, 2910).
  • Delete the argument at every internal recursive synth call: lib.rs 3339,3375,3384,3386,3398,3400,3401,3419,3514,3523,3541, 3616,3618,3668,3756,3783,3799,3813,3843,3869,3878,3912,3943,3953, 3991 and the helper call sites 2027, 2914.
  • Delete the argument at the external callers: builtins.rs 345/359 and 592/606; lift.rs 745/750; mono.rs 719/728 and 1365/1374.
  • Delete the argument at the test-only synth callers in lib.rs: 4379/4393, 4420/4439, 4464/4486, 4581/4592, 4624/4635, 4666/4677, 4808/4819, 4860/4869, 7600/7614.

(loop_stack is a separate param and stays at every one of these sites — only mut_scope_stack is removed.)

  • Step 8: Checker — simplify the shared Term::Lam escape guard, keep the loop half (ailang-check/src/lib.rs:3620-3660)

Change the gate at 3637 from if !mut_scope_stack.is_empty() || !loop_stack.is_empty() { to if !loop_stack.is_empty() {. Delete the mut half (36453651, the for frame in mut_scope_stack … MutVarCapturedByLambda loop). Keep verbatim: the shared free-var scan (36383643) and the loop half (36523658, for frame in loop_stack … LoopBinderCapturedByLambda).

  • Step 9: Codegen — delete the lower arms; keep + rename the alloca machinery (ailang-codegen)

  • lib.rs: delete the Term::Mut lower_term arm (17791825) and Term::Assign arm (18341855).

  • lib.rs: rename the field mut_var_allocasbinder_allocas at the SURVIVING sites only: decl 731, ctor-init 871, clear 1077, Term::Var load 1405, Term::Loop arm 1899/1914/1917, synth_with_extras Term::Var 3014. (Sites 1805/1817/1820 and 1836 vanish with the deleted arms — nothing to rename there.) Update the field's doc-comment to state it holds loop-binder allocas (drop any "mut-var" wording — make it truthful, no post-mortem).

  • lib.rs: pending_entry_allocas — the mut-arm push at 1787 vanishes with Step 2/this step's arm deletion; keep decl 748, init 873, clear 1078, fn-body splice 1265/1271/1279, loop-arm 1886 (all loop/shared, unchanged).

  • lambda.rs: rename the save/restore locals to match (saved_mut_allocas 144 → saved_binder_allocas, saved_pending_allocas 152 unchanged name is fine; restore 265/267). The Term::Mut arm at 486 was deleted in Step 2; Term::Loop arm 516 stays.

  • escape.rs: the Mut/Assign arms (197/203, 400/408, 533/548) were deleted in Step 2 — nothing further here.

  • Step 10: Compile gate + Task-1 pins GREEN

Run: cargo build --workspace 2>&1 | tail -5 Expected: Finished / 0 errors. (If any error[E0...], a match arm or mut_scope_stack caller was missed — fix before proceeding; do NOT add a _ => wildcard.)

Run: cargo test --workspace -p ailang-surface --test mut_removed_pin Expected: 4 tests, all PASS.

Run (sanity, expected RED suite elsewhere — informational only): cargo test --workspace 2>&1 | tail -3 — failures in fixture/drift tests here are EXPECTED and resolved in Tasks 36.


Task 3: Delete the dangling test pins

Files: crates/ailang-check/tests/mut_typecheck_pin.rs (delete), crates/ail/tests/ct1_check_cli.rs, crates/ailang-core/tests/{schema_coverage,design_schema_drift,spec_drift}.rs

  • Step 1: Delete the whole-file mut pin

rm crates/ailang-check/tests/mut_typecheck_pin.rs (all 6 tests pin removed CheckError codes — no surviving behaviour to assert).

  • Step 2: Drop the mut case in ct1_check_cli.rs

At 209211 remove the check_human_mode_renders_mut_diagnostic_code_exactly_once test (or just its mut table row if the test also covers a surviving code — read 200230 first; the recon located the mut row at 210211). The loop/recur F2 sibling test stays.

  • Step 3: Drop the mut entries in the drift trio

  • schema_coverage.rs: delete the test-local enum variants TermMut (50) / TermAssign (51), the expected-set entries (99100), and the visit_term arms (239, 247).

  • design_schema_drift.rs: delete the "t": "mut" anchor (141), the "t": "assign" anchor (148), and the tag-helper arms (184, 185).

  • spec_drift.rs: delete the tag-helper arms (163, 164).

(These drop in lockstep with the DESIGN.md schema deletion in Task 4 — drift trio asserts DESIGN.md and schema agree; both sides of the mut anchor go together.)


Task 4: DESIGN.md hard-delete (present-tense, no nostalgia)

Files: docs/DESIGN.md

  • Step 1: Delete the data-model entries

Delete the Term::Mut JSONC block (24042413) and the Term::Assign JSONC block (24152422). Keep the loop/recur blocks (24242441).

  • Step 2: Delete the mut prose, keep+reword the loop half

Delete 24482454 (the mut-specific post-schema paragraph). Lines 24542458 are the loop-binder half (Loop binders are alloca-resident … LoopBinderCapturedByLambda (iter loop-recur.tidy)) — keep it, and reword its opening so it stands alone (no "likewise"/"the same" back-reference to the deleted mut sentence). Delete the "Local mutable state" feature prose (28912910) and the mut-specific accumulator-over-iteration idiom line (2912). Do not touch DESIGN.md 99/106107 (the clause-3 rationale prose — it names iterated-mutable-state as a bug class, not as a live construct).

  • Step 3: Verify no residue

Run: grep -n -iE '\bmut\b|\bvar-decl\b|\bassign\b|MutVar|Term::Mut|Term::Assign' docs/DESIGN.md Expected: no line that documents the construct. (Allowed survivors: the clause-3 prose at ~99/106 saying "iterated-mutable-state"; any loop-binder sentence. If a mut-as-construct line remains, delete it. There must be zero aspirational/post-mortem mention.)


Task 5: Fixture rewrites + rejection-probe deletions

Files: examples/mut.ail, examples/mut_counter.ail, examples/mut_sum_floats.ail, examples/fieldtest/mut-local_{1,2,3,4}*.ail (rewrite); examples/fieldtest/mut-local_5_lambda_capture_probe.ail, examples/fieldtest/mut-local_6_diag_probe.ail, examples/test_mut_var_captured_by_lambda.ail.json (delete)

  • Step 1: Delete the three rejection-probe fixtures
rm examples/fieldtest/mut-local_5_lambda_capture_probe.ail \
   examples/fieldtest/mut-local_6_diag_probe.ail \
   examples/test_mut_var_captured_by_lambda.ail.json

(Their sole purpose is the removed mut-specific rejection; no let/if equivalent exists.)

  • Step 2: Rewrite examples/mut.ail (6 fns, faithful 1:1 let-threading)

Replace each (body …):

;; mut_empty
(body 0)
;; mut_single_var      → 1
(body (let x 0 (let x (app + x 1) x)))
;; mut_two_vars        → 2.0
(body (let sum 0.0 (let count 0 (let sum (app + sum 1.0) (let count (app + count 1) (app + sum (app int_to_float count)))))))
;; mut_nested_shadow   → 101
(body (let x 10 (let x (app + x 1) (let x 100 (let x (app + x 1) x)))))
;; mut_returns_bool    → true
(body (let flag false (let flag true flag)))
;; mut_returns_unit    → unit
(body (let u (lit-unit) (let u (lit-unit) u)))

Update each (doc …) line to describe the let form (drop the "Iter mut.1 — …" wording; present-tense, e.g. "Nested let shadowing; inner binding is the block value."). No mut/var/ assign token may remain in the file.

  • Step 3: Rewrite mut_counter.ail / mut_sum_floats.ail (spec's fixed shape)

mut_counter.ail main body → (body (app print (app sum_helper 1 10 0))). mut_sum_floats.ail main body → (body (app print (app sum_helper 1.0 10.0 0.0))). sum_helper unchanged in both. Update the two (doc …) lines to present-tense (drop "Iter mut.3"; e.g. "Sum 1..10 via a tail-recursive helper. Expected stdout: 55.").

  • Step 4: Rewrite mut-local_1..4 (faithful)
;; mut-local_1_factorial :: main body  → prints 120
(body (app print (let prod 1 (let prod (app * prod 1) (let prod (app * prod 2) (let prod (app * prod 3) (let prod (app * prod 4) (let prod (app * prod 5) prod))))))))

;; mut-local_2_classify_temp :: classify body  → classify 22 = 2
(body (let code 0 (let code (if (app < t 0) 0 (if (app < t 15) 1 (if (app < t 28) 2 3))) code)))

;; mut-local_3_horner :: main body  → prints 18
(body (app print (let acc 2.0 (let acc (app - (app * acc 2.5) 3.0) (let acc (app + (app * acc 2.5) 5.0) (let acc (app - (app * acc 2.5) 7.0) acc))))))

;; mut-local_4_has_small_factor :: has_small_factor body  → has_small_factor 91 = true
(body (let found false (let found (if (app == (app % n 2) 0) true found) (let found (if (app == (app % n 3) 0) true found) (let found (if (app == (app % n 5) 0) true found) (let found (if (app == (app % n 7) 0) true found) found))))))

Replace the leading ; comment block of each file with a short present-tense one-liner (no "Why this fits mut-local's scope", no "Iter", no post-mortem). The main fns of _2/_4 are unchanged. No mut/var/assign token may remain.

  • Step 5: Verify the rewrites parse, run, and round-trip

Run: cargo run -q -p ail -- run examples/mut_counter.ail → Expected stdout: 55 Run: cargo run -q -p ail -- run examples/mut_sum_floats.ail → Expected stdout: 55 Run: cargo run -q -p ail -- run examples/fieldtest/mut-local_1_factorial.ail → Expected stdout: 120 Run: cargo run -q -p ail -- run examples/fieldtest/mut-local_3_horner.ail → Expected stdout: 18 Run: for f in examples/mut.ail examples/fieldtest/mut-local_2_classify_temp.ail examples/fieldtest/mut-local_4_has_small_factor.ail; do cargo run -q -p ail -- check "$f"; done Expected: each exits 0 (no diagnostics).


Task 6: carve-out + roadmap, then the final green gate

Files: crates/ailang-core/tests/carve_out_inventory.rs, docs/roadmap.md

  • Step 1: carve_out_inventory.rs EXPECTED 18 → 12

In const EXPECTED (3357) delete the 6 mut carve-out entries at 4349 (test_mut_assign_out_of_scope, test_mut_assign_outside_mut, test_mut_assign_type_mismatch, test_mut_nested_shadow_legal, test_mut_var_unsupported_type, test_mut_var_captured_by_lambda). Update the count in the test's assertion / any literal 18 to 12. Update the header doc (629) to drop the mut carve-out lines; keep the loop carve-out lines (incl. test_loop_binder_captured_by_lambda.ail.json, which survives).

  • Step 2: docs/roadmap.md — delete the false-foundation sub-bullet

Delete lines 439445 (the Sub-milestone 1 closed 2026-05-15: **mut-local** — sealed-by-construction mut/var/assign… sub-bullet). Keep the Stateful-islands milestone header at 421435 (decoupled but surviving per spec §Goal) — but if any line in 421435 still asserts mut-local is the foundation, reword it to state Stateful-islands has no existing scaffolding (present-tense honest; the spec's decoupling). Do not delete the milestone entry itself.

  • Step 3: FINAL green gate — full suite + loop/recur non-regression

Run: cargo test --workspace 2>&1 | tail -5 Expected: test result: ok for every crate; 0 failed. (Count must be ≥ the pre-iteration green count minus the 6 deleted mut_typecheck_pin tests minus the dropped ct1 mut test; no other test lost.)

Run: cargo test --workspace -p ailang-check --test loop_recur_typecheck_pin lambda_capturing_loop_binder_emits_loop_binder_captured_by_lambda Expected: PASS (the shared escape guard's loop half survived).

Run: cargo run -q -p ail -- run examples/loop_sum_to_run.ail55; cargo run -q -p ail -- run examples/loop_sum_to_deep.ail500000500000; cargo build -q -p ail then cargo run -q -p ail -- build examples/loop_forever_build.ail exits 0 (loop/recur codegen byte-unaffected by the rename).

Run: cargo run -q -p ail -- parse examples/loop_sum_to_run.ail | cargo run -q -p ail -- render - | cargo run -q -p ail -- parse - Expected: byte-identical to cargo run -q -p ail -- parse examples/loop_sum_to_run.ail (Roundtrip Invariant holds for surviving modules; adjust to the actual ail round-trip CLI form if - stdin is unsupported — use roundtrip_cli test names instead: cargo test --workspace -p ail --test roundtrip_cli, expected PASS).


Self-review (planner Step 5)

  1. Spec coverage: Component 1→T2.S1-2; C2→T2.S3-4; C3→T2.S5-8; C4→T2.S9; C5→T5; C6→T4; C7→T6.S1-2; the must-fail executable pin (spec §"Concrete code shapes"/§Testing)→T1+T2.S10; loop/recur non-regression (spec §Error handling/§Testing)→T6.S3. All spec sections mapped.
  2. Placeholder scan: no "TBD/TODO/implement later/similar to Task/add appropriate". The carve-out integer is concrete (18→12, recon-pinned). One conditional in T6.S3 (round-trip CLI form) has a named concrete fallback (roundtrip_cli test), not a placeholder.
  3. Type/path consistency: mut_scope_stack, loop_stack, mut_var_allocasbinder_allocas, binder_allocas, LoopBinderCapturedByLambda used consistently across T2 steps.
  4. Step granularity: the only large step is T2 (atomic by Rust semantics — cannot be split without a non-compiling intermediate or a forbidden _ => wildcard); its sub-steps are each a bounded delete-list. Documented as the irreducible unit.
  5. No commit steps: none present.
  6. Pin/replacement substring contiguity: T1's pins assert is_err() (no .contains(substring) against a verbatim doc body), and T4.S3 is a grep audit not a presence-pin paired with a verbatim replacement — the line-wrap family does not apply here.
  7. Compile-gate vs deferred-caller: T2.S7 threads the synth signature change AND every caller (internal + builtins/lift/mono
    • test) in the same step; the only cargo build 0-errors gate (T2.S10) sits after S7, never before a caller it needs. The full cargo test gate is the last step of the last task (T6.S3), after every test-affecting change.
  8. Verification-filter strings resolve: --test mut_removed_pin is a file this plan creates (T1); --test loop_recur_typecheck_pin
    • the named fn was ratified green by the grounding-check; --test roundtrip_cli exists (recon C-Q2); the e2e mut_counter_prints_55/mut_sum_floats_prints_55 are run via ail run on the rewritten fixtures (T5.S5) rather than a name filter, asserting the 55 literal directly. No filter resolves to zero tests.