# 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. ✓
