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.
12 KiB
prose-loop-binders.1 — Implementation Plan
Parent spec:
docs/specs/0036-prose-loop-binders.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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— theTerm::Looparm ofwrite_term(the soleloop-keyword text emitter). - Modify:
crates/ailang-prose/tests/snapshot.rs:99-102— append two#[test]functions aftersnapshot_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::Looparm ofwrite_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_runCLI 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_buildCLI 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)
- 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. ✓ - 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. ✓
- 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. ✓ - Step granularity. Each step is one file write / one Edit / one command — 2-5 min. ✓
- No commit steps. None. The untouched-set check (Task 2
Step 6) uses
git status, nevergit commit. ✓ - Pin/replacement substring contiguity. No
contains("…")/grep test is shipped; the gate is whole-file byte-equality viacheck_snapshot+diff. The pinned.prose.txtIS the replacement target — they are the same artefact, authored once in Task 1, no second authoritative copy to drift. Theloop(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). ✓ - 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.txtfiles and twocheck_snapshot-calling test fns introduces no compile error; the tests FAIL at runtime (assertion), not at build. ✓ - Verification-command filter strings resolve.
cargo test -p ailang-prose snapshot_loop—snapshot_loopis a substring of exactly the two new fns and no pre-existing test (verified: no existingsnapshot_*name containsloop); Task 1 Step 4 explicitly states the "0 filtered out → STOP" guard so "nothing ran" cannot pass as "nothing failed". The twoail prose | diffcommands assert viadiffexit status + literalMATCH, not a filter.git status --porcelainenumerated against an exact expected list. ✓