Files
AILang/docs/plans/0079-prose-loop-binders.1.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

12 KiB

prose-loop-binders.1 — Implementation Plan

Parent spec: docs/specs/0036-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):

#[test]
fn snapshot_ordering_match() {
    check_snapshot("ordering_match");
}

with:

#[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):

        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:

        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/0079-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_loopsnapshot_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. ✓