From 6533134dab888605c24fb2e50a65fcbf99c1897e Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 18 May 2026 01:00:30 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20prose-loop-binders.1=20=E2=80=94=20loop?= =?UTF-8?q?-arm=20rewrite=20+=202=20snapshot=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/prose-loop-binders.1.md | 321 +++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/plans/prose-loop-binders.1.md diff --git a/docs/plans/prose-loop-binders.1.md b/docs/plans/prose-loop-binders.1.md new file mode 100644 index 0000000..c5a335a --- /dev/null +++ b/docs/plans/prose-loop-binders.1.md @@ -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. ✓