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:
@@ -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. ✓
|
||||
Reference in New Issue
Block a user