# prose-loop-binders.1 — Implementation Plan > **Parent spec:** `docs/specs/2026-05-18-prose-loop-binders.md` > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Render `Term::Loop` in Form-B prose as a parenthesised init-list on the keyword (`loop(acc = 0, i = 1) { … }`) instead of bare `name = init;` statements inside the body block, so the projection stops implying C/Rust re-init-every-iteration semantics. **Architecture:** Single render arm rewrite in `ailang-prose` (`write_term`, `Term::Loop` case) plus two committed snapshot fixtures and two snapshot tests. Projection-only: loop/recur AST, Form-A, JSON-AST, typecheck, codegen and the Form-A↔JSON round-trip invariant are untouched. RED-first via the existing stem-explicit `check_snapshot` harness in `crates/ailang-prose/tests/snapshot.rs`. **Tech Stack:** `ailang-prose` (Rust), the `ail prose` CLI subcommand (`crates/ail`), the prose snapshot test harness. --- **Files this plan creates or modifies:** - Create: `examples/loop_sum_to_run.prose.txt` — committed snapshot expectation, multi-binder + non-recur-exit case. - Create: `examples/loop_forever_build.prose.txt` — committed snapshot expectation, single-binder + recur-only (no exit) case. - Modify: `crates/ailang-prose/src/lib.rs:938-955` — the `Term::Loop` arm of `write_term` (the sole `loop`-keyword text emitter). - Modify: `crates/ailang-prose/tests/snapshot.rs:99-102` — append two `#[test]` functions after `snapshot_ordering_match`. Untouched (asserted, verified in Task 2 Step 6): every other `examples/*.prose.txt`, every `examples/*.ail`, every `*.ail.json`, `crates/ailang-prose/src/lib.rs:1167` (`count_free_var` `Term::Loop` arm) and `:1341` (`subst_var_with_term` `Term::Loop` arm — also constructs a `Term::Loop` at ~1366; not text emission), the `Term::Recur` arm at lib.rs:956-965, `docs/DESIGN.md`, `docs/PROSE_ROUNDTRIP.md`, typecheck, codegen, runtime. --- ### Task 1: RED — pin the desired projection **Files:** - Create: `examples/loop_sum_to_run.prose.txt` - Create: `examples/loop_forever_build.prose.txt` - Modify: `crates/ailang-prose/tests/snapshot.rs:99-102` - [ ] **Step 1: Write the expected snapshot for `loop_sum_to_run`** Create `examples/loop_sum_to_run.prose.txt` with EXACTLY these bytes. The file ends with a single `\n` after the final `}` (no trailing blank line). Indentation is spaces, 2 per level. ``` // module loop_sum_to_run /// loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55. fn main() -> Unit with IO { print(sum_to(10)) } fn sum_to(n: Int) -> Int { loop(acc = 0, i = 1) { if i > n { acc } else { recur(acc + i, i + 1) } } } ``` - [ ] **Step 2: Write the expected snapshot for `loop_forever_build`** Create `examples/loop_forever_build.prose.txt` with EXACTLY these bytes. File ends with a single `\n` after the final `}`. The doc comment is three `///` lines exactly as below (the renderer hard-wraps the long doc string at this width — copy verbatim). ``` // module loop_forever_build /// loop-recur iter 3 — an infinite loop (no non-recur exit) must COMPILE /// (typechecks AND compiles; no termination claim). Build-only; by design never /// returns, so the binary is never executed. fn main() -> Unit with IO { print(spin(0)) } fn spin(n: Int) -> Int { loop(i = 0) { recur(i + 1) } } ``` - [ ] **Step 3: Append the two snapshot tests** In `crates/ailang-prose/tests/snapshot.rs`, replace the final function (lines 99-102): ```rust #[test] fn snapshot_ordering_match() { check_snapshot("ordering_match"); } ``` with: ```rust #[test] fn snapshot_ordering_match() { check_snapshot("ordering_match"); } #[test] fn snapshot_loop_sum_to_run() { check_snapshot("loop_sum_to_run"); } #[test] fn snapshot_loop_forever_build() { check_snapshot("loop_forever_build"); } ``` - [ ] **Step 4: Run the two new tests to verify they FAIL (RED)** Run: `cargo test -p ailang-prose snapshot_loop` Expected: 2 tests run, both FAIL. The filter substring `snapshot_loop` matches exactly the two functions added in Step 3 and no pre-existing test. Failure panic for each is the `check_snapshot` mismatch: `prose snapshot mismatch for loop_sum_to_run.` and `prose snapshot mismatch for loop_forever_build.` — because the current renderer still emits the old `loop {` / `name = init;` shape, which does not equal the Option-A bytes pinned in Steps 1-2. Result line shows `test result: FAILED. 0 passed; 2 failed`. (If instead it reports `0 passed; 0 filtered out` the filter did not resolve — STOP, the test names in Step 3 are wrong.) --- ### Task 2: GREEN — rewrite the `Term::Loop` render arm **Files:** - Modify: `crates/ailang-prose/src/lib.rs:938-955` - [ ] **Step 1: Rewrite the `Term::Loop` arm of `write_term`** In `crates/ailang-prose/src/lib.rs`, replace this exact arm (lib.rs:938-955): ```rust Term::Loop { binders, body } => { // loop-recur iter 1: minimal-correctness Form-B render. // Prose surface for loop is not yet designed; render the // shape so a reader sees it without overcommitting. out.push_str("loop {\n"); for b in binders { indent(out, level + 1); out.push_str(&b.name); out.push_str(" = "); write_term(out, &b.init, level + 1, owning_module); out.push_str(";\n"); } indent(out, level + 1); write_term(out, body, level + 1, owning_module); out.push('\n'); indent(out, level); out.push('}'); } ``` with: ```rust Term::Loop { binders, body } => { out.push_str("loop("); for (i, b) in binders.iter().enumerate() { if i > 0 { out.push_str(", "); } out.push_str(&b.name); out.push_str(" = "); write_term(out, &b.init, level, owning_module); } out.push_str(") {\n"); indent(out, level + 1); write_term(out, body, level + 1, owning_module); out.push('\n'); indent(out, level); out.push('}'); } ``` The init expressions now render at `level` (they sit on the keyword line, not the indented block), mirroring how the unchanged `Term::Recur` arm at lib.rs:956-965 renders its args. The stale "Prose surface for loop is not yet designed" comment is removed — it is now designed. The empty-binder case falls out for free: the `for` does not execute, `loop(` + `) {\n` still emit, producing `loop() { … }`. - [ ] **Step 2: Run the two snapshot tests to verify they PASS (GREEN)** Run: `cargo test -p ailang-prose snapshot_loop` Expected: 2 tests run, both PASS. `test result: ok. 2 passed; 0 failed`. - [ ] **Step 3: Run the full prose suite to verify no regression** Run: `cargo test -p ailang-prose` Expected: all tests PASS, `test result: ok.` with `0 failed`. No pre-existing snapshot changes — no other `examples/*.prose.txt` contains a loop, so no other `snapshot_*` test is affected. - [ ] **Step 4: Acceptance — `loop_sum_to_run` CLI byte-match** Run: `cargo run -q --bin ail -- prose examples/loop_sum_to_run.ail | diff - examples/loop_sum_to_run.prose.txt && echo MATCH` Expected: `MATCH` (empty `diff`, exit 0). Confirms the live CLI emits the pinned Option-A bytes: the `loop(acc = 0, i = 1) {` line carries the binders and the `{ … }` body contains no `acc = 0;` / `i = 1;` line. - [ ] **Step 5: Acceptance — `loop_forever_build` CLI byte-match** Run: `cargo run -q --bin ail -- prose examples/loop_forever_build.ail | diff - examples/loop_forever_build.prose.txt && echo MATCH` Expected: `MATCH` (empty `diff`, exit 0). Confirms `loop(i = 0) {` with `recur(i + 1)` as the body for the single-binder recur-only case. - [ ] **Step 6: Verify the untouched-set and clean up RED artefacts** Run: `rm -f examples/*.prose.txt.actual && git status --porcelain` Expected: the porcelain list contains EXACTLY these four paths and nothing else: ``` M crates/ailang-prose/src/lib.rs M crates/ailang-prose/tests/snapshot.rs ?? examples/loop_forever_build.prose.txt ?? examples/loop_sum_to_run.prose.txt ``` (Order may differ. The plan file `docs/plans/prose-loop-binders.1.md` is already committed by the Boss before this run, so it does NOT appear here — its absence is expected, not an anomaly. `docs/journals/` + stats files added by the implement skill are also expected and fine.) Crucially: NO other `examples/*.prose.txt` is modified, NO `examples/*.ail` or `*.ail.json` is modified, `docs/DESIGN.md` and `docs/PROSE_ROUNDTRIP.md` are NOT in the list. The `rm -f` removes any `*.prose.txt.actual` the RED run wrote into `examples/`. If any unexpected path appears, STOP and report. --- ## Self-review (planner Step 5) 1. **Spec coverage.** Spec §Goal / §"Concrete code shapes" (Implementation shape) → Task 2 Step 1 (the exact before→after arm, verbatim from spec lines 188-227). Spec §Components (two `.prose.txt` + two `#[test]`) → Task 1 Steps 1-3. Spec §"Testing strategy" RED→GREEN → Task 1 Step 4 (RED) + Task 2 Steps 2-3 (GREEN + no-regression). Spec §"Acceptance criteria" (CLI byte-match, no other snapshot diff, untouched set) → Task 2 Steps 4-6. Empty-binder case (spec §"Error handling") → covered by the Step-1 arm logic, noted in Task 2 Step 1. Every spec section has a task. ✓ 2. **Placeholder scan.** No "TBD"/"TODO"/"implement later"/"similar to Task"/"add appropriate error handling". Every code block is literal and complete; every Run step has a command and expected output. ✓ 3. **Type/name/path consistency.** `snapshot_loop_sum_to_run`, `snapshot_loop_forever_build`, `check_snapshot`, `examples/loop_sum_to_run.prose.txt`, `examples/loop_forever_build.prose.txt`, `crates/ailang-prose/src/lib.rs`, `crates/ailang-prose/tests/snapshot.rs` — identical across all tasks and the file-list. ✓ 4. **Step granularity.** Each step is one file write / one Edit / one command — 2-5 min. ✓ 5. **No commit steps.** None. The untouched-set check (Task 2 Step 6) uses `git status`, never `git commit`. ✓ 6. **Pin/replacement substring contiguity.** No `contains("…")`/grep test is shipped; the gate is whole-file byte-equality via `check_snapshot` + `diff`. The pinned `.prose.txt` IS the replacement target — they are the same artefact, authored once in Task 1, no second authoritative copy to drift. The `loop(acc = 0, i = 1) {` line in the Task-1 snapshot is a single contiguous line; the renderer (Task 2) emits it on one line (no soft-wrap). ✓ 7. **Compile-gate vs. deferred-caller ordering.** No signature change; one self-contained `match`-arm body rewrite, no caller touched, no crate-wide breakage between tasks. Task 1's RED gate (`cargo test`) compiles cleanly — adding `.prose.txt` files and two `check_snapshot`-calling test fns introduces no compile error; the tests FAIL at runtime (assertion), not at build. ✓ 8. **Verification-command filter strings resolve.** `cargo test -p ailang-prose snapshot_loop` — `snapshot_loop` is a substring of exactly the two new fns and no pre-existing test (verified: no existing `snapshot_*` name contains `loop`); Task 1 Step 4 explicitly states the "0 filtered out → STOP" guard so "nothing ran" cannot pass as "nothing failed". The two `ail prose | diff` commands assert via `diff` exit status + literal `MATCH`, not a filter. `git status --porcelain` enumerated against an exact expected list. ✓