plan: prose-loop-binders.1 — loop-arm rewrite + 2 snapshot pins

Single-iteration, RED-first plan for the prose-loop-binders spec.
Task 1 pins the desired Option-A bytes in two new committed
.prose.txt fixtures + two snapshot tests (RED against the old
renderer). Task 2 rewrites the Term::Loop arm of write_term and
gates GREEN via the snapshot suite + two ail-prose CLI byte-matches
+ an exact untouched-set check. Recon-verified line anchors; planner
Step-5 self-review clean on all 8 items.
This commit is contained in:
2026-05-18 01:00:30 +02:00
parent c657e74f9c
commit 6533134dab
+321
View File
@@ -0,0 +1,321 @@
# 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. ✓