Files
AILang/docs/plans/0076-loop-recur.1.md
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

1702 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# loop-recur.1 — Additive AST-Node Foundation — Implementation Plan
> **Parent spec:** `docs/specs/0034-loop-recur.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** `loop` / `recur` become real, parseable, printable,
round-trippable, hash-stable, strictly-additive AST nodes; the whole
workspace compiles and `cargo test --workspace` is green — with NO
typecheck semantics and NO real codegen lowering yet.
**Architecture:** Three additive items in `ailang-core::ast`
`Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> }`,
`Term::Recur { args: Vec<Term> }`, `struct LoopBinder { name, ty,
init }` (the triple mirrors `MutVar` exactly). Every exhaustive
`match`-on-`Term` in the workspace has NO `_` wildcard, so the two
new variants force an arm at ~30 sites across all six crates plus the
drift tests. Sites split into four classes: (a) structural
pass-through arms (mirror the existing `Term::Mut`/`Term::Assign`
arm shape, substituting `binders`/`init`/`body` and `args`); (b)
head-name helpers (`"loop"` / `"recur"` string); (c) the two
*semantic* dispatch points — typecheck `synth` and codegen
`lower_term` — which get `CheckError::Internal` / `CodegenError::Internal`
**stubs** in this iter exactly as mut.1 did (real semantics land in
iter 2 / iter 3); (d) schema-drift / hash anchors.
**Tech Stack:** `ailang-core` (ast, desugar, workspace, specs/form_a.md,
drift+hash tests), `ailang-surface` (parse, print), `ailang-check`
(lib, lift, mono, linearity, uniqueness, reuse_shape,
pre_desugar_validation), `ailang-codegen` (lib, lambda, escape),
`ailang-prose` (lib), `ail` (main), `docs/DESIGN.md`.
---
## Boss design calls baked into this plan (do not re-litigate at execution time)
1. **`verify_tail_positions` "byte-unchanged" reading (spec Open
Q1).** The spec's "`verify_tail_positions` … byte-unchanged"
means its **tail-app verification role/behaviour** is unchanged,
NOT that the function's source text is frozen — the function is
an exhaustive `match`-on-`Term` with no `_` wildcard
(`crates/ailang-check/src/lib.rs:2672` is the existing
`Term::Mut` arm inside it), so the additive variant *requires*
two new arms there or `ailang-check` does not compile. The arms
added here (Task 5, Step 6) only define how the tail-app walker
*descends through two brand-new node kinds that no pre-existing
fixture contains*; they introduce zero behaviour change for any
pre-existing construct. The spec's own §"Testing strategy"
already operationalises "byte-unchanged" as
"`tail-app` non-regression — existing tail-app IR-snapshot and
e2e fixtures byte-identical" — that test (Task 7, Step 7) is the
acceptance evidence, not a frozen-source diff. mut.1 set the
exact precedent (it added `Term::Mut`/`Term::Assign` arms inside
the same function). This is settled; the executor does not
reopen it.
2. **Codegen is in iter-1 scope as stubs/pass-throughs (recon
under-scoped it).** The brainstorm carrier said "no codegen in
iter 1", meaning *no real loop-header/phi/back-edge lowering*
(correct — that is iter 3). But `ailang-codegen` also carries
no-wildcard exhaustive `Term` matches (`lambda.rs:478`,
`escape.rs:197/389/506`, `lib.rs:1762`, `lib.rs:3094`); the
workspace will not compile — so `cargo test --workspace` (the
iter-1 goal) cannot be green — without arms there. mut.1
resolved exactly this by adding **`CodegenError::Internal`
stub** at the `lower_term` dispatch and **real structural
pass-through** at the pure analysis walkers + `synth_with_extras`.
Task 6 mirrors mut.1 site-for-site. No real loop lowering is
written in this iter.
3. **`binders` has no `skip_serializing_if`.** Mirrors `Term::Mut.vars`
exactly (the field is part of the shape; empty stays `[]` in
canonical JSON). `recur`'s `args` likewise plain `Vec<Term>`.
Whether ≥1 binder is *required* is a typecheck question deferred
to iter 2; the parser here accepts zero-or-more binders (mirrors
`parse_mut`).
---
## Files this plan creates or modifies
- Modify: `crates/ailang-core/src/ast.rs:543` — add `Term::Loop` /
`Term::Recur` variants; `:584` — add `struct LoopBinder`; `:952`
— two serde round-trip unit tests.
- Modify: `crates/ailang-surface/src/parse.rs:1198` head dispatch +
unknown-head message; new `parse_loop` / `parse_recur` after
`parse_assign` (`:1643`); `:2551` parse unit tests.
- Modify: `crates/ailang-surface/src/print.rs:596``Term::Loop` /
`Term::Recur` print arms.
- Modify: `crates/ailang-core/specs/form_a.md:288` — grammar lines;
`:326` — notes paragraph.
- Modify: `crates/ailang-prose/src/lib.rs:937` (write_term_prec),
`:1135` (count_free_var), `:1287` (subst_var_with_term).
- Modify: `crates/ailang-core/src/desugar.rs` — 9 arm sites;
`crates/ailang-core/src/workspace.rs` — 2 arm sites.
- Modify: `crates/ailang-check/src/lib.rs` — substitute_rigids_in_term
(`:263`), verify_tail_positions (`:2681`), head-name helper
(`:3594`), synth STUB (`:3645`); `lift.rs` ×2; `mono.rs` ×2;
`linearity.rs` ×3; `uniqueness.rs` ×1; `reuse_shape.rs` ×1;
`pre_desugar_validation.rs` ×1.
- Modify: `crates/ailang-codegen/src/lambda.rs` ×1; `escape.rs` ×3;
`lib.rs` — lower_term STUB (`:1762`-region) + synth_with_extras
(`:3095`).
- Modify: `crates/ail/src/main.rs``:1534` (walk_term) + `:2754`
(rewrite_term) arm sites.
- Modify: `docs/DESIGN.md:2422``"t":"loop"` / `"t":"recur"`
schema blocks.
- Modify: `crates/ailang-core/tests/design_schema_drift.rs:153` +
`:174`; `spec_drift.rs:130` + `:153`; `schema_coverage.rs:51`,
`:98`, `:246`; `hash_pin.rs` — new pin test.
- Create: `examples/loop_sum_to.ail` — the spec's worked `sum_to`
program (round-trip auto-covered by `round_trip.rs`).
- No edit: `crates/ailang-surface/tests/round_trip.rs` (auto-discovers
`examples/*.ail`); `crates/ailang-core/tests/carve_out_inventory.rs`
(iter-1 adds no `.ail.json` negative fixtures — those are iter 2).
---
## Task 1: Core AST nodes + serde + round-trip unit tests
**Files:**
- Modify: `crates/ailang-core/src/ast.rs`
- [ ] **Step 1: Write the two failing round-trip unit tests**
In `crates/ailang-core/src/ast.rs`, inside `#[cfg(test)] mod tests`
(after `term_assign_round_trips_through_json`, before the closing
`}` at line 952), add:
```rust
/// loop-recur iter 1: pin the canonical-bytes shape of a
/// `Term::Loop` with one binder. Mirrors the mut-empty-vars pin:
/// `binders` stays present (no `skip_serializing_if`).
#[test]
fn term_loop_one_binder_serialises_with_explicit_binders_field() {
let t = Term::Loop {
binders: vec![LoopBinder {
name: "i".into(),
ty: Type::int(),
init: Term::Lit {
lit: Literal::Int { value: 0 },
},
}],
body: Box::new(Term::Var { name: "i".into() }),
};
let bytes = serde_json::to_string(&t).expect("serialise");
assert_eq!(
bytes,
r#"{"t":"loop","binders":[{"name":"i","type":{"t":"con","name":"Int","args":[]},"init":{"t":"lit","lit":{"kind":"int","value":0}}}],"body":{"t":"var","name":"i"}}"#,
);
let back: Term = serde_json::from_str(&bytes).expect("deserialise");
match back {
Term::Loop { binders, body } => {
assert_eq!(binders.len(), 1);
assert_eq!(binders[0].name, "i");
match *body {
Term::Var { name } => assert_eq!(name, "i"),
other => panic!("body mismatch: {other:?}"),
}
}
other => panic!("variant mismatch: {other:?}"),
}
}
/// loop-recur iter 1: round-trip a `Term::Recur` through JSON.
/// Pins `{ "t": "recur", "args": [...] }`.
#[test]
fn term_recur_round_trips_through_json() {
let t = Term::Recur {
args: vec![Term::Lit {
lit: Literal::Int { value: 1 },
}],
};
let bytes = serde_json::to_string(&t).expect("serialise");
assert_eq!(
bytes,
r#"{"t":"recur","args":[{"t":"lit","lit":{"kind":"int","value":1}}]}"#,
);
let back: Term = serde_json::from_str(&bytes).expect("deserialise");
match back {
Term::Recur { args } => assert_eq!(args.len(), 1),
other => panic!("variant mismatch: {other:?}"),
}
}
```
- [ ] **Step 2: Run tests to verify they fail to compile**
Run: `cargo test -p ailang-core --lib term_loop_one_binder term_recur_round_trips 2>&1 | tail -5`
Expected: FAIL — `error[E0599]: no variant ... Loop` / `Recur` /
`cannot find struct ... LoopBinder` (the variants do not exist yet).
- [ ] **Step 3: Add `Term::Loop` and `Term::Recur` variants**
In `crates/ailang-core/src/ast.rs`, immediately after the
`Term::Assign { name: String, value: Box<Term> }` variant (ends
line 542), before the enum's closing `}` (line 543), add:
```rust
/// loop-recur iter 1: a strict iteration block. `binders`
/// declares one or more loop parameters (name, type, init),
/// evaluated in order on loop entry; `body` is evaluated with
/// all binders in scope. The loop's value is `body`'s value on
/// the iteration that exits via a non-`recur` branch. Strictly
/// additive: pre-existing fixtures hash bit-identically because
/// none carry the `"t":"loop"` tag. `binders` has no
/// `skip_serializing_if` (mirrors `Term::Mut.vars` — the field
/// is part of the shape). Typecheck binder/recur semantics land
/// in loop-recur iter 2 (`synth` stubs with `CheckError::Internal`
/// in this iter); codegen (loop-header + per-binder phi +
/// back-edge) in iter 3 (`lower_term` stubs with
/// `CodegenError::Internal`). No totality claim — an infinite
/// loop is legal. See `docs/specs/0034-loop-recur.md`.
Loop {
binders: Vec<LoopBinder>,
body: Box<Term>,
},
/// loop-recur iter 1: re-enter the lexically innermost enclosing
/// `Term::Loop`, rebinding its binders positionally to `args`.
/// Transfers control (no fall-through); valid only in tail
/// position of its enclosing loop — enforced at typecheck in
/// iter 2 (`RecurNotInTailPosition`). Additive `"t":"recur"`
/// tag; pre-existing fixtures hash bit-identically.
Recur {
args: Vec<Term>,
},
```
- [ ] **Step 4: Add `struct LoopBinder`**
In `crates/ailang-core/src/ast.rs`, immediately after the
`struct MutVar { ... }` definition (ends line 584), add:
```rust
/// loop-recur iter 1: one binder of a [`Term::Loop`]. Mirrors
/// [`MutVar`]'s `(name, type, init)` triple exactly so the Form-A
/// surface vocabulary is shared (`(NAME TYPE INIT)`); it is a
/// nested field of `Term::Loop`, not a first-class `Term` (loop
/// binders cannot escape the enclosing loop). `recur` rebinds these
/// positionally per iteration. The `ty` JSON field is `"type"`,
/// matching `MutVar` and the spec schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopBinder {
/// The binder's lexical name. Within the enclosing `Term::Loop`,
/// a `Term::Var { name }` resolves to this binding.
pub name: String,
/// The binder's declared type.
#[serde(rename = "type")]
pub ty: Type,
/// Initial value, evaluated once on loop entry in scope of the
/// outer environment plus already-declared binders of the same
/// `Term::Loop` (declaration order).
pub init: Term,
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cargo test -p ailang-core --lib term_loop_one_binder term_recur_round_trips 2>&1 | tail -5`
Expected: PASS — `test result: ok. 2 passed`.
(`ailang-core` itself now compiles; downstream crates still fail
exhaustiveness — fixed in Tasks 2-7. Do not run `--workspace` yet.)
---
## Task 2: Surface parser + printer + grammar + parse unit tests
**Files:**
- Modify: `crates/ailang-surface/src/parse.rs`
- Modify: `crates/ailang-surface/src/print.rs`
- Modify: `crates/ailang-core/specs/form_a.md`
- [ ] **Step 1: Write the failing parser unit test**
In `crates/ailang-surface/src/parse.rs`, in the unit-tests module
near the existing mut-family parse tests (after line 2551), add:
```rust
#[test]
fn parse_loop_and_recur_round_trip_via_term() {
let src = "(loop (acc Int 0) (i Int 1) (if (app > i n) acc (recur (app + acc i) (app + i 1))))";
let mut p = Parser::new(lex(src).expect("lex"));
let t = p.parse_term().expect("parse loop");
match t {
ailang_core::ast::Term::Loop { binders, body } => {
assert_eq!(binders.len(), 2);
assert_eq!(binders[0].name, "acc");
assert_eq!(binders[1].name, "i");
// body is an `if` whose else-branch is a `recur` of 2 args
match *body {
ailang_core::ast::Term::If { else_, .. } => match *else_ {
ailang_core::ast::Term::Recur { args } => {
assert_eq!(args.len(), 2)
}
other => panic!("else not recur: {other:?}"),
},
other => panic!("body not if: {other:?}"),
}
}
other => panic!("not a loop: {other:?}"),
}
}
```
(If `Parser::new` / `lex` are not the exact local test helpers,
mirror the helper used by the adjacent `parse_mut` test verbatim —
do not invent a new harness.)
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p ailang-surface --lib parse_loop_and_recur 2>&1 | tail -5`
Expected: FAIL — unknown term head `loop` (parser has no arm yet).
- [ ] **Step 3: Add head-dispatch arms + extend unknown-head message**
In `crates/ailang-surface/src/parse.rs`, in the `match head { ... }`
at line 1198: add two arms immediately after the
`"assign" => self.parse_assign(),` line (1214):
```rust
"loop" => self.parse_loop(),
"recur" => self.parse_recur(),
```
In the same `match`'s `other =>` error message (the `format!`
string spanning lines 1219-1224), change the trailing
`` `assign`, `lit-unit` `` to:
```rust
`assign`, `loop`, `recur`, `lit-unit`"
```
(The whole message string must read ``mut`, `assign`, `loop`,
`recur`, `lit-unit`"` contiguously on its closing line — the
`spec_drift.rs` / unknown-head expectations key on substrings.)
- [ ] **Step 4: Add `parse_loop` and `parse_recur`**
In `crates/ailang-surface/src/parse.rs`, immediately after
`fn parse_assign` (ends line 1643), add:
```rust
/// loop-recur iter 1: `(loop (NAME TYPE INIT)* BODY_TERM+)` —
/// strict iteration block. Reads zero or more `(NAME TYPE INIT)`
/// binder triples (bare, no leading keyword — same triple as
/// `(var ...)`'s tail), then ≥ 1 trailing terms right-folded
/// into `Term::Seq` (mirrors `parse_mut`).
fn parse_loop(&mut self) -> Result<Term, ParseError> {
let head_pos = self.peek().map(|t| t.span.start).unwrap_or(0);
self.expect_lparen("loop-term")?;
self.expect_keyword("loop")?;
let mut binders: Vec<ailang_core::ast::LoopBinder> = Vec::new();
while matches!(self.peek(), Some(Token { tok: Tok::LParen, .. })) {
self.expect_lparen("loop-binder")?;
let name = self.expect_ident("loop-binder-name")?;
let ty = self.parse_type()?;
let init = self.parse_term()?;
self.expect_rparen("loop-binder")?;
binders.push(ailang_core::ast::LoopBinder { name, ty, init });
}
let mut body_stmts: Vec<Term> = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
body_stmts.push(self.parse_term()?);
}
if body_stmts.is_empty() {
return Err(ParseError::Production {
production: "loop-term",
message: "(loop ...) requires at least one body expression after binders".into(),
pos: head_pos,
});
}
self.expect_rparen("loop-term")?;
let mut body = body_stmts.pop().expect("non-empty after the check above");
while let Some(s) = body_stmts.pop() {
body = Term::Seq {
lhs: Box::new(s),
rhs: Box::new(body),
};
}
Ok(Term::Loop {
binders,
body: Box::new(body),
})
}
/// loop-recur iter 1: `(recur ARG*)` — re-enter the enclosing
/// loop. Positional args. The tail-position rule is enforced at
/// typecheck (iter 2, `recur-not-in-tail-position`); the parser
/// accepts the shape unconditionally.
fn parse_recur(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("recur-term")?;
self.expect_keyword("recur")?;
let mut args: Vec<Term> = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?);
}
self.expect_rparen("recur-term")?;
Ok(Term::Recur { args })
}
```
(The binder loop reads a bare `(NAME TYPE INIT)` — note: unlike
`parse_mut`'s `(var NAME TYPE INIT)`, the spec's surface is
`(loop (NAME TYPE INIT) … BODY)` with no `var` keyword inside the
binder. Confirm against the spec's worked example
`(loop (acc Int 0) (i Int 1) …)` — there is no `var`. The
`while matches! LParen` loop terminates when the next form is a
non-parenthesised term or the closing `)`; a parenthesised *body*
form like `(if …)` is correctly NOT consumed as a binder because a
binder's first inner token is an ident NAME, whereas `(if …)`'s is
a keyword — but the loop as written would mis-consume `(if …)` as a
binder. Guard the binder loop with: only treat a leading `(` as a
binder when its head is NOT a known term-head keyword. Implement the
guard as: `while self.peek_is_lparen() && !self.peek_inner_is_term_head()`
using the existing `peek_head_ident` helper —
`while matches!(self.peek_lparen_inner_head(), Some(h) if !is_term_head(h))`.
If no such helper exists, the robust shape mirrored from the spec is
to require the binder list to be syntactically distinct; the
**executor must verify** the spec's worked example parses to exactly
2 binders + an `if` body via the Step-1 test and adjust the binder
terminator predicate until it does. This is the one genuine parser
judgement in the task; the Step-1 test is the oracle.)
- [ ] **Step 5: Add printer arms**
In `crates/ailang-surface/src/print.rs`, in `fn write_term`'s
`match t { ... }`, immediately after the `Term::Assign { name,
value } => { ... }` arm (ends line 596), before the match's closing
`}` (line 597), add:
```rust
Term::Loop { binders, body } => {
// loop-recur iter 1: print as
// `(loop (NAME TYPE INIT)* STMT* FINAL_EXPR)`
// — inverse of parse_loop's binder read + Seq right-fold,
// mirroring the Term::Mut printer.
out.push_str("(loop");
for b in binders {
out.push_str(" (");
out.push_str(&b.name);
out.push(' ');
write_type(out, &b.ty);
out.push(' ');
write_term(out, &b.init, level);
out.push(')');
}
let mut cursor: &Term = body;
loop {
match cursor {
Term::Seq { lhs, rhs } => {
out.push(' ');
write_term(out, lhs, level);
cursor = rhs;
}
other => {
out.push(' ');
write_term(out, other, level);
break;
}
}
}
out.push(')');
}
Term::Recur { args } => {
out.push_str("(recur");
for a in args {
out.push(' ');
write_term(out, a, level);
}
out.push(')');
}
```
- [ ] **Step 6: Add grammar lines + notes to `form_a.md`**
In `crates/ailang-core/specs/form_a.md`, in the parenthesised-forms
code block, immediately after the `(assign NAME VALUE-TERM)` line
(288), add:
```
(loop (NAME TYPE INIT)* BODY-TERM+) ; strict iteration block (loop-recur)
(recur ARG*) ; re-enter enclosing loop (loop-recur)
```
In the `Notes:` list, immediately after the `(assign …)` note
paragraph (ends line 326), add:
```
- `loop` opens a strict iteration block. `(NAME TYPE INIT)` binder
triples (zero or more) are followed by a body of zero or more
Unit-typed statements and exactly one final expression (right-
folded into `Term::Seq` like `mut`). `recur` re-enters the
lexically innermost enclosing `loop`, rebinding its binders
positionally; `(recur ARG*)`'s arg count must equal the binder
count and `recur` must be in tail position of the loop body —
both enforced at typecheck (`recur-arity-mismatch`,
`recur-not-in-tail-position`). `loop`/`recur` make no termination
claim: a `loop` with no non-`recur` exit runs forever. See
`docs/specs/0034-loop-recur.md`.
```
- [ ] **Step 7: Run the parser test + surface build**
Run: `cargo test -p ailang-surface --lib parse_loop_and_recur 2>&1 | tail -5`
Expected: PASS. (Other crates still fail exhaustiveness; continue.)
---
## Task 3: Prose projection arms
**Files:**
- Modify: `crates/ailang-prose/src/lib.rs`
- [ ] **Step 1: Add `write_term_prec` arms**
In `crates/ailang-prose/src/lib.rs`, in `fn write_term_prec`'s
`match t`, immediately after the `Term::Assign { name, value } =>
{ ... }` arm (ends line 937), before the match's closing `}`
(line 938), add:
```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('}');
}
Term::Recur { args } => {
out.push_str("recur(");
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level, owning_module);
}
out.push(')');
}
```
- [ ] **Step 2: Add `count_free_var` arms**
In `crates/ailang-prose/src/lib.rs`, in `fn count_free_var`'s
`match`, immediately after the `Term::Assign { name: assign_name,
value } => { ... }` arm (ends line 1135), before the match closing
`}` (line 1136), add:
```rust
// loop-recur iter 1: a binder named `name` shadows the outer
// binding for later binder inits and the body. Inits before
// the shadowing binder still see the outer `name`.
Term::Loop { binders, body } => {
let mut total = 0usize;
let mut shadowed = false;
for b in binders {
if !shadowed {
total += count_free_var(name, &b.init);
}
if b.name == name {
shadowed = true;
}
}
if !shadowed {
total += count_free_var(name, body);
}
total
}
Term::Recur { args } => {
args.iter().map(|a| count_free_var(name, a)).sum()
}
```
- [ ] **Step 3: Add `subst_var_with_term` arms**
In `crates/ailang-prose/src/lib.rs`, in `fn subst_var_with_term`'s
`match t`, immediately after the `Term::Assign { name: assign_name,
value } => Term::Assign { ... }` arm (ends line 1287), before the
match closing `}` (line 1288), add:
```rust
// loop-recur iter 1: lexical-shadow semantics symmetric to
// count_free_var — once a binder named `name` is declared,
// later inits and the body are not rewritten.
Term::Loop { binders, body } => {
let mut shadowed = false;
let new_binders: Vec<ailang_core::ast::LoopBinder> = binders
.iter()
.map(|b| {
let init = if shadowed {
b.init.clone()
} else {
subst_var_with_term(&b.init, name, replacement)
};
if b.name == name {
shadowed = true;
}
ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init,
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var_with_term(body, name, replacement)
};
Term::Loop {
binders: new_binders,
body: Box::new(body_rw),
}
}
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| subst_var_with_term(a, name, replacement))
.collect(),
},
```
- [ ] **Step 4: Build prose crate**
Run: `cargo build -p ailang-prose 2>&1 | tail -3`
Expected: prose-crate-local exhaustiveness satisfied (it may still
fail building due to its dep on `ailang-check`; that is expected
until Task 5 — the acceptance gate for this task is that
`prose/src/lib.rs` has no `non-exhaustive patterns` error; grep the
output for `prose/src/lib.rs` — there must be none).
---
## Task 4: ailang-core walker arms (desugar + workspace)
**Files:**
- Modify: `crates/ailang-core/src/desugar.rs`
- Modify: `crates/ailang-core/src/workspace.rs`
Each step below adds two arms immediately after the existing
`Term::Assign` arm at the cited line. The arm shape mirrors the
adjacent `Term::Mut` arm at that site (substitute
`binders`/`b.init`/`body` for `vars`/`v.init`/`body`, and `args`
for the Recur form). The literal arm is given per site.
- [ ] **Step 1: `desugar.rs` `collect_used_in_term` (after `:360`)**
`collect_used_in_term` is a free fn (`fn collect_used_in_term(t:
&Term, used: &mut BTreeSet<String>)` at `:283`), not a method.
After the `Term::Assign { name, value } => { ... }` arm following
`Term::Mut` at `:349` (the Assign arm starts `:360`), add:
```rust
Term::Loop { binders, body } => {
for b in binders {
used.insert(b.name.clone());
collect_used_in_term(&b.init, used);
}
collect_used_in_term(body, used);
}
Term::Recur { args } => {
for a in args {
collect_used_in_term(a, used);
}
}
```
- [ ] **Step 2: `desugar.rs` `desugar_term` (after `:588`)**
The `Term::Mut` arm at `:559-588` extends scope per-var via
`inner.insert(v.name.clone(), ScopeEntry::LetBound)` (a
`BTreeMap`-style `scope` clone, `scope` is the second param of
`desugar_term`). After the `Term::Assign { name, value } =>
Term::Assign { ... }` arm following it (Assign starts `:589`), add:
```rust
Term::Loop { binders, body } => {
// loop-recur iter 1: structural recursion mirroring
// the Term::Mut arm. Each binder's init is desugared
// in scope of the outer env plus already-declared
// binders; the body sees all binders. The LetBound
// sentinel keeps generated fresh names off binder
// names.
let mut inner = scope.clone();
let new_binders: Vec<ailang_core::ast::LoopBinder> = binders
.iter()
.map(|b| {
let init = self.desugar_term(&b.init, &inner);
inner.insert(b.name.clone(), ScopeEntry::LetBound);
ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init,
}
})
.collect();
Term::Loop {
binders: new_binders,
body: Box::new(self.desugar_term(body, &inner)),
}
}
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| self.desugar_term(a, scope))
.collect(),
},
```
- [ ] **Step 3: `desugar.rs` `free_vars_in_term` (after `:1228`)**
`free_vars_in_term(t: &Term, bound: &BTreeSet<String>, out: &mut
BTreeSet<String>)` (`:1138`). The `Term::Mut` arm at `:1214-1227`
clones `bound` into a local `b`, walks each init under the growing
`b`, inserts each var name into `b`, then walks the body under `b`.
After the `Term::Assign` arm following it (Assign starts `:1228`),
add:
```rust
Term::Loop { binders, body } => {
// loop-recur iter 1: binder names bind inside the loop —
// each init sees the outer env plus already-declared
// binders; the body sees all. Mirrors the Term::Mut arm.
let mut b = bound.clone();
for bd in binders {
free_vars_in_term(&bd.init, &b, out);
b.insert(bd.name.clone());
}
free_vars_in_term(body, &b, out);
}
Term::Recur { args } => {
for a in args {
free_vars_in_term(a, bound, out);
}
}
```
- [ ] **Step 4: `desugar.rs` `subst_var` (after `:1416`)**
`pub fn subst_var(t: &Term, from: &str, to: &str) -> Term`
(`:1272`). The `Term::Mut` arm at `:1381-1414` is shadow-aware
(stop substituting once a var named `from` is declared). After the
`Term::Assign { name, value } => Term::Assign { ... }` arm
following it (Assign starts `:1416`), add:
```rust
Term::Loop { binders, body } => {
// loop-recur iter 1: binders lexically shadow outer
// names, symmetric to the Term::Mut arm — once a binder
// named `from` is declared, later inits and the body
// stop being substituted.
let mut shadowed = false;
let new_binders: Vec<ailang_core::ast::LoopBinder> = binders
.iter()
.map(|b| {
let init = if shadowed {
b.init.clone()
} else {
subst_var(&b.init, from, to)
};
if b.name == from {
shadowed = true;
}
ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init,
}
})
.collect();
let body_rw = if shadowed {
(**body).clone()
} else {
subst_var(body, from, to)
};
Term::Loop {
binders: new_binders,
body: Box::new(body_rw),
}
}
Term::Recur { args } => Term::Recur {
args: args.iter().map(|a| subst_var(a, from, to)).collect(),
},
```
- [ ] **Step 5: `desugar.rs` `subst_call_with_extras` (after `:1567`)**
After the `Term::Assign` arm following the `Term::Mut` rebuild at
`:1551`, add:
```rust
Term::Loop { binders, body } => Term::Loop {
binders: binders
.iter()
.map(|b| ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: subst_call_with_extras(&b.init, target, extras),
})
.collect(),
body: Box::new(subst_call_with_extras(body, target, extras)),
},
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| subst_call_with_extras(a, target, extras))
.collect(),
},
```
(Match `subst_call_with_extras`'s real param names from its
signature — mirror the local `Term::Mut` arm at `:1551`.)
- [ ] **Step 6: `desugar.rs` `find_non_callee_use` (after `:1636`)**
After the `Term::Assign` arm following `Term::Mut` at `:1629`, add:
```rust
Term::Loop { binders, body } => binders
.iter()
.find_map(|b| find_non_callee_use(&b.init, name))
.or_else(|| find_non_callee_use(body, name)),
Term::Recur { args } => {
args.iter().find_map(|a| find_non_callee_use(a, name))
}
```
- [ ] **Step 7: `desugar.rs` `any_nested_ctor` (after `:1689`)**
After the `Term::Assign` arm following `Term::Mut` at `:1686`, add:
```rust
Term::Loop { binders, body } => {
binders.iter().any(|b| any_nested_ctor(&b.init)) || any_nested_ctor(body)
}
Term::Recur { args } => args.iter().any(any_nested_ctor),
```
- [ ] **Step 8: `desugar.rs` `any_let_rec` (after `:1719`)**
After the `Term::Assign` arm following `Term::Mut` at `:1716`, add:
```rust
Term::Loop { binders, body } => {
binders.iter().any(|b| any_let_rec(&b.init)) || any_let_rec(body)
}
Term::Recur { args } => args.iter().any(any_let_rec),
```
- [ ] **Step 9: `desugar.rs` `any_lit_pattern` (after `:2840`)**
After the `Term::Assign` arm following `Term::Mut` at `:2837`, add:
```rust
Term::Loop { binders, body } => {
binders.iter().any(|b| any_lit_pattern(&b.init)) || any_lit_pattern(body)
}
Term::Recur { args } => args.iter().any(any_lit_pattern),
```
- [ ] **Step 10: `workspace.rs` `walk_term_embedded_types` (after `:1260`)**
After the `Term::Assign` arm following `Term::Mut` at `:1254`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
walk_type(&b.ty, f)?;
walk_term_embedded_types(&b.init, f)?;
}
walk_term_embedded_types(body, f)
}
Term::Recur { args } => {
for a in args {
walk_term_embedded_types(a, f)?;
}
Ok(())
}
```
- [ ] **Step 11: `workspace.rs` `walk_term` (after `:1394`)**
After the `Term::Assign { value, .. } => walk_term(value, f)` arm
following `Term::Mut` at `:1388`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
walk_term(&b.init, f)?;
}
walk_term(body, f)
}
Term::Recur { args } => {
for a in args {
walk_term(a, f)?;
}
Ok(())
}
```
- [ ] **Step 12: Build ailang-core**
Run: `cargo build -p ailang-core 2>&1 | tail -3`
Expected: PASS — `ailang-core` compiles (all its exhaustive
`Term` matches now have arms).
---
## Task 5: ailang-check walker arms + verify_tail_positions pass-through + synth STUB
**Files:**
- Modify: `crates/ailang-check/src/lib.rs`
- Modify: `crates/ailang-check/src/lift.rs`
- Modify: `crates/ailang-check/src/mono.rs`
- Modify: `crates/ailang-check/src/linearity.rs`
- Modify: `crates/ailang-check/src/uniqueness.rs`
- Modify: `crates/ailang-check/src/reuse_shape.rs`
- Modify: `crates/ailang-check/src/pre_desugar_validation.rs`
- [ ] **Step 1: `lib.rs` `substitute_rigids_in_term` (after `:263`)**
After the `Term::Assign { name, value } => Term::Assign { ... }`
arm following `Term::Mut` at `:249`, add:
```rust
Term::Loop { binders, body } => Term::Loop {
binders: binders
.iter()
.map(|b| ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: substitute_rigids(&b.ty, mapping),
init: substitute_rigids_in_term(&b.init, mapping),
})
.collect(),
body: Box::new(substitute_rigids_in_term(body, mapping)),
},
Term::Recur { args } => Term::Recur {
args: args
.iter()
.map(|a| substitute_rigids_in_term(a, mapping))
.collect(),
},
```
- [ ] **Step 2: `lift.rs` `lift_in_term` (after the `Term::Mut` rebuild at `:383`)**
The `Term::Mut` arm at `:383-395` is a method that threads
`self.lift_in_term(&v.init, locals, in_def)?`. After the
`Term::Assign { name, value } => Ok(Term::Assign { ... })` arm
following it (Assign at `:396-399`), add:
```rust
Term::Loop { binders, body } => Ok(Term::Loop {
binders: binders
.iter()
.map(|b| {
Ok(ailang_core::ast::LoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: self.lift_in_term(&b.init, locals, in_def)?,
})
})
.collect::<Result<Vec<_>>>()?,
body: Box::new(self.lift_in_term(body, locals, in_def)?),
}),
Term::Recur { args } => Ok(Term::Recur {
args: args
.iter()
.map(|a| self.lift_in_term(a, locals, in_def))
.collect::<Result<Vec<_>>>()?,
}),
```
- [ ] **Step 3: `lift.rs` `term_has_letrec` (after `:763`)**
After `Term::Assign { value, .. } => term_has_letrec(value)`
(`:763`) following `Term::Mut` at `:760`, add:
```rust
Term::Loop { binders, body } => {
binders.iter().any(|b| term_has_letrec(&b.init)) || term_has_letrec(body)
}
Term::Recur { args } => args.iter().any(term_has_letrec),
```
- [ ] **Step 4: `mono.rs` `rewrite_mono_calls` (after `:1243`)**
After the `Term::Assign` arm following `Term::Mut` at `:1237`
(in-place `&mut` walk), add (mirror the Mut arm's full call list
verbatim from `:1237-1242`):
```rust
Term::Loop { binders, body } => {
for b in binders.iter_mut() {
rewrite_mono_calls(&mut b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
Term::Recur { args } => {
for a in args.iter_mut() {
rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, caller_module, ordered_targets, cursor, locals);
}
}
```
- [ ] **Step 5: `mono.rs` `interleave_slots` (after `:1617`)**
After the `Term::Assign` arm following `Term::Mut` at `:1611`, add
(mirror the Mut arm's full call list from `:1611-1616`):
```rust
Term::Loop { binders, body } => {
for b in binders {
interleave_slots(&b.init, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
interleave_slots(body, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
Term::Recur { args } => {
for a in args {
interleave_slots(a, method_to_candidate_classes, poly_free_fns, poly_free_fn_ccounts, class_slots, free_fn_slots, class_cur, free_cur, locals, out);
}
}
```
- [ ] **Step 6: `lib.rs` `verify_tail_positions` (after `:2681`) — the Boss-call-1 site**
After `Term::Assign { value, .. } => verify_tail_positions(value,
false)` (`:2681`) following `Term::Mut` at `:2672`, before the
match closing `}` (`:2682`), add:
```rust
// loop-recur iter 1: binder inits are evaluated once on loop
// entry, NOT in tail position (mirror the Term::Mut arm
// above). The loop body inherits the enclosing tail position
// (the loop's value is the body's value on the exiting
// iteration). This arm only defines tail-app descent through
// the new node; it makes NO claim about recur tail-position
// (that is loop-recur iter 2's `verify_loop_body`). The
// tail-app verification role is unchanged — no pre-existing
// fixture contains a `loop`/`recur` node.
Term::Loop { binders, body } => {
for b in binders {
verify_tail_positions(&b.init, false)?;
}
verify_tail_positions(body, is_tail)
}
// recur transfers control and does not fall through, so there
// is no tail position to propagate. Its args are evaluated in
// non-tail position (call-argument-like). Introduces no
// tail-app violation.
Term::Recur { args } => {
for a in args {
verify_tail_positions(a, false)?;
}
Ok(())
}
```
- [ ] **Step 7: `lib.rs` head-name helper (after `:3594`)**
In the `match` whose arms map a `Term` to its tag string (the
`Term::Mut { .. } => "mut"` / `Term::Assign { .. } => "assign"`
arms at `:3593-3594`, ending with `Term::Ctor { .. } | Term::Lam
{ .. } => unreachable!()`), add immediately after the
`Term::Assign { .. } => "assign",` line (`:3594`):
```rust
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
```
- [ ] **Step 8: `lib.rs` `synth` STUB (after `:3645`) — Boss-call-2 site**
In `pub(crate) fn synth` (`:2719`), after the `Term::Assign { name,
value } => { ... }` arm following the `Term::Mut` arm at `:3614`
(the Assign arm ends around `:3680`; locate the arm immediately
before `synth`'s match closing brace), add:
```rust
// loop-recur iter 1: typecheck semantics (binder typing,
// recur arity/type unification, verify_loop_body
// tail-position, the four Recur* CheckError variants) land
// in loop-recur iter 2. This iter stubs the dispatch so the
// workspace compiles and round-trips; no loop/recur fixture
// is typechecked in iter 1. Mirrors mut.1's synth stub.
Term::Loop { .. } | Term::Recur { .. } => Err(CheckError::Internal(
"Term::Loop/Term::Recur typecheck lands in loop-recur iter 2".into(),
)),
```
(`CheckError::Internal(String)` is defined at `lib.rs:687`
`#[error("internal: {0}")]`. `synth` returns `Result<Type>` over
`CheckError` — `Err(CheckError::Internal(...))` is the correct
stub return.)
- [ ] **Step 9: `linearity.rs` `walk` (after `:607`)**
After the `Term::Assign { value, .. } => { ... }` arm following
`Term::Mut` at `:601`, add (mirror the Mut arm's `self.walk(...,
Position::...)` calls verbatim from `:601-606`):
```rust
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init, Position::Consume);
}
self.walk(body, pos);
}
Term::Recur { args } => {
for a in args {
self.walk(a, Position::Consume);
}
}
```
- [ ] **Step 10: `linearity.rs` head-name helper (after `:797`)**
After `Term::Assign { .. } => "assign",` (`:797`) following
`Term::Mut { .. } => "mut"` (`:796`), add:
```rust
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
```
- [ ] **Step 11: `linearity.rs` `any_sub_binder_consumed_for` (after `:935`)**
After the `Term::Assign` arm following `Term::Mut` at `:929`, add
(mirror the Mut arm's full call signature from `:929-933`):
```rust
Term::Loop { binders, body } => {
binders.iter().any(|b| {
any_sub_binder_consumed_for(&b.init, pname, uniq, def_name, ctors)
}) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors)
}
Term::Recur { args } => args.iter().any(|a| {
any_sub_binder_consumed_for(a, pname, uniq, def_name, ctors)
}),
```
- [ ] **Step 12: `uniqueness.rs` `walk` (after `:352`)**
After the `Term::Assign { value, .. } => { ... }` arm following
`Term::Mut` at `:346`, add (mirror the Mut arm's `self.walk`
calls + the `pos`/`Position::Consume` choice verbatim from
`:346-351`):
```rust
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init, Position::Consume);
}
self.walk(body, pos);
}
Term::Recur { args } => {
for a in args {
self.walk(a, Position::Consume);
}
}
```
- [ ] **Step 13: `reuse_shape.rs` `walk` (after `:267`)**
After `Term::Assign { value, .. } => self.walk(value)` (`:267`)
following `Term::Mut` at `:261`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
self.walk(&b.init);
}
self.walk(body);
}
Term::Recur { args } => {
for a in args {
self.walk(a);
}
}
```
- [ ] **Step 14: `pre_desugar_validation.rs` `walk_term` (after `:135`)**
After `Term::Assign { value, .. } => walk_term(value)` (`:135`)
following `Term::Mut` at `:129`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
walk_term(&b.init)?;
}
walk_term(body)
}
Term::Recur { args } => {
for a in args {
walk_term(a)?;
}
Ok(())
}
```
- [ ] **Step 15: Build ailang-check**
Run: `cargo build -p ailang-check 2>&1 | tail -3`
Expected: PASS — `ailang-check` compiles.
---
## Task 6: ailang-codegen arms (stub + pass-through) + ail/main.rs arms
**Files:**
- Modify: `crates/ailang-codegen/src/lambda.rs`
- Modify: `crates/ailang-codegen/src/escape.rs`
- Modify: `crates/ailang-codegen/src/lib.rs`
- Modify: `crates/ail/src/main.rs`
- [ ] **Step 1: `lambda.rs` `collect_captures` (after `:478` Mut arm)**
After the `Term::Assign` arm following `Term::Mut` at `:478`
(newly-bound roll-back pattern), add (mirror the Mut arm's
`Self::collect_captures(...)` signature + `bound.insert` /
`newly_bound` roll-back verbatim from `:478-`):
```rust
Term::Loop { binders, body } => {
let mut newly_bound: Vec<String> = Vec::new();
for b in binders {
Self::collect_captures(&b.init, bound, captures, captures_set, builtins, top_level);
if bound.insert(b.name.clone()) {
newly_bound.push(b.name.clone());
}
}
Self::collect_captures(body, bound, captures, captures_set, builtins, top_level);
for n in newly_bound {
bound.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
Self::collect_captures(a, bound, captures, captures_set, builtins, top_level);
}
}
```
- [ ] **Step 2: `escape.rs` `walk` (after `:203` Mut arm)**
After `Term::Assign { value, .. } => walk(value, out)` (`:203`)
following `Term::Mut` at `:197`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
walk(&b.init, out);
}
walk(body, out);
}
Term::Recur { args } => {
for a in args {
walk(a, out);
}
}
```
- [ ] **Step 3: `escape.rs` `escapes` (after `:395` Mut arm)**
After the `Term::Assign` arm following `Term::Mut` at `:389`, add:
```rust
Term::Loop { binders, body } => {
for b in binders {
if escapes(&b.init, tainted, false) {
return true;
}
}
escapes(body, tainted, in_tail)
}
Term::Recur { args } => {
for a in args {
if escapes(a, tainted, false) {
return true;
}
}
false
}
```
- [ ] **Step 4: `escape.rs` `collect_free_vars` (after `:506` Mut arm)**
After the `Term::Assign` arm following `Term::Mut` at `:506`
(add-then-walk-then-remove `newly` pattern), add (mirror the Mut
arm's `bound.insert`/`newly`/restore verbatim):
```rust
Term::Loop { binders, body } => {
let mut newly: Vec<String> = Vec::new();
for b in binders {
collect_free_vars(&b.init, bound, out);
if bound.insert(b.name.clone()) {
newly.push(b.name.clone());
}
}
collect_free_vars(body, bound, out);
for n in newly {
bound.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
collect_free_vars(a, bound, out);
}
}
```
- [ ] **Step 5: `lib.rs` `lower_term` STUB (after the `Term::Mut` arm at `:1762`)**
The `Term::Mut { vars, body } =>` arm at `:1762` has real mut.3
lowering and ends with the saved-binding restore loop (around
`:1804`+) followed by a `Term::Assign` arm. After that
`Term::Assign` arm, before the `lower_term` match's closing brace,
add the iter-1 STUB (real loop-header/phi/back-edge lowering is
loop-recur iter 3):
```rust
// loop-recur iter 1: real codegen (loop-header block,
// per-binder phi, back-edge br) lands in iter 3. This
// iter stubs the dispatch so the workspace compiles; no
// loop/recur program is codegen'd in iter 1. Mirrors
// mut.1's lower_term stub.
Term::Loop { .. } | Term::Recur { .. } => Err(CodegenError::Internal(
"Term::Loop/Term::Recur lowering lands in loop-recur iter 3".into(),
)),
```
(`CodegenError::Internal(String)` is the same constructor used at
`lib.rs:1779` and `:3069`.)
- [ ] **Step 6: `lib.rs` `synth_with_extras` (after `:3095`)**
After `Term::Assign { .. } => Ok(Type::unit())` (`:3095`) following
`Term::Mut { body, .. } => self.synth_with_extras(body, extras)`
(`:3094`), before the match closing `}` (`:3096`), add:
```rust
// loop-recur iter 1: a Term::Loop's static type is the
// body's type (mirror Term::Mut). Term::Recur does not
// fall through; a Unit stub is safe — this arm is never
// hit on the shipping path because lower_term stubs
// Loop/Recur, and iter 1 codegens no loop/recur program.
Term::Loop { body, .. } => self.synth_with_extras(body, extras),
Term::Recur { .. } => Ok(Type::unit()),
```
- [ ] **Step 7: `main.rs` `walk_term` (after `:1534` Mut arm)**
After the `Term::Assign` arm following `Term::Mut` at `:1520`
(newly-bound `scope` roll-back), add (mirror the Mut arm's
`scope.insert`/`newly`/`scope.remove` verbatim from `:1520-1534`):
```rust
Term::Loop { binders, body } => {
let mut newly = Vec::new();
for b in binders {
walk_term(&b.init, out, builtins, scope);
if scope.insert(b.name.clone()) {
newly.push(b.name.clone());
}
}
walk_term(body, out, builtins, scope);
for n in newly {
scope.remove(&n);
}
}
Term::Recur { args } => {
for a in args {
walk_term(a, out, builtins, scope);
}
}
```
- [ ] **Step 8: `main.rs` `rewrite_term` (after the `Term::Mut` arm at `:2754`)**
The `Term::Mut { vars, body } =>` arm at `:2754` rewrites each
`v.ty` via `rewrite_type` and each `v.init` + `body` via
`rewrite_term` (in-place `&mut`). After the `Term::Assign` arm
that follows it, add (mirror that arm's `rewrite_type` /
`rewrite_term` argument lists verbatim):
```rust
Term::Loop { binders, body } => {
for b in binders {
rewrite_type(
&mut b.ty,
owning_module,
local_types,
import_names,
changed,
);
rewrite_term(
&mut b.init,
owning_module,
local_types,
import_names,
changed,
);
}
rewrite_term(
body,
owning_module,
local_types,
import_names,
changed,
);
}
Term::Recur { args } => {
for a in args {
rewrite_term(
a,
owning_module,
local_types,
import_names,
changed,
);
}
}
```
- [ ] **Step 9: Build the whole workspace**
Run: `cargo build --workspace 2>&1 | tail -3`
Expected: PASS — every crate compiles (no `non-exhaustive
patterns` errors anywhere).
---
## Task 7: Schema/drift anchors + DESIGN.md + hash pin + positive fixture + full suite
**Files:**
- Modify: `docs/DESIGN.md`
- Modify: `crates/ailang-core/tests/design_schema_drift.rs`
- Modify: `crates/ailang-core/tests/spec_drift.rs`
- Modify: `crates/ailang-core/tests/schema_coverage.rs`
- Modify: `crates/ailang-core/tests/hash_pin.rs`
- Create: `examples/loop_sum_to.ail`
- [ ] **Step 1: Add `"t":"loop"` / `"t":"recur"` schema blocks to DESIGN.md**
In `docs/DESIGN.md`, immediately after the `{ "t": "assign",
"name": "<id>", "value": Term }` block (the `"value": Term }` line
is 2422), before the closing ```` ``` ```` fence (line 2423), add:
```
// loop-recur iter 1: strict iteration block. `binders` declares
// one or more loop parameters (name, type, init), evaluated in
// order on loop entry; `body` is in scope of all binders. The
// loop's value is `body`'s value on the iteration that exits via a
// non-`recur` branch. Strictly additive (no `skip_serializing_if`;
// pre-existing fixtures hash bit-identically — none carry the tag).
// No totality claim — an infinite loop is legal. See
// `docs/specs/0034-loop-recur.md`.
{ "t": "loop",
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
"body": Term }
// loop-recur iter 1: re-enter the lexically innermost enclosing
// `loop`, rebinding its binders positionally to `args`. Transfers
// control (no fall-through); valid only in tail position of its
// enclosing loop (enforced at typecheck, `recur-not-in-tail-position`).
{ "t": "recur",
"args": [ Term, ... ] }
```
(The literal substrings `"t": "loop"` and `"t": "recur"` each
appear contiguously on one line — the `design_schema_drift.rs`
exemplar anchors `r#""t": "loop""#` / `r#""t": "recur""#` are
matched by `data_model_section().contains(...)`; no soft-wrap may
split them. This is the planner self-review item-6 pin/replacement
contiguity check, satisfied here.)
- [ ] **Step 2: Extend `design_schema_drift.rs`**
In `crates/ailang-core/tests/design_schema_drift.rs`, in the
`exemplars` vec, immediately after the `assign` tuple (ends `:153`),
add:
```rust
(
r#""t": "loop""#,
Term::Loop {
binders: Vec::new(),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
),
(
r#""t": "recur""#,
Term::Recur { args: vec![] },
),
```
In the exhaustive tag `match term { ... }` (`:159-175`), after
`Term::Assign { .. } => "assign",` (`:174`), add:
```rust
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
```
- [ ] **Step 3: Extend `spec_drift.rs`**
In `crates/ailang-core/tests/spec_drift.rs`, in the `exemplars`
vec, immediately after the `(assign` tuple (ends `:130`), add:
```rust
(
"(loop",
Term::Loop {
binders: Vec::new(),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
),
(
"(recur",
Term::Recur { args: vec![] },
),
```
In the exhaustive `match term { ... }` (`:138-154`), after
`Term::Assign { .. } => "assign",` (`:153`), add:
```rust
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
```
(The anchors `(loop` / `(recur` are the grammar lines added to
`form_a.md` in Task 2 Step 6 — `FORM_A_SPEC.contains("(loop")`
passes because `(loop (NAME TYPE INIT)* …` is present contiguously.)
- [ ] **Step 4: Extend `schema_coverage.rs`**
In `crates/ailang-core/tests/schema_coverage.rs`:
(a) in `enum VariantTag`, after `TermAssign,` (`:51`), add:
```rust
TermLoop,
TermRecur,
```
(b) in `const EXPECTED_VARIANTS`, after `VariantTag::TermAssign,`
(`:98`), add:
```rust
VariantTag::TermLoop,
VariantTag::TermRecur,
```
(c) in `fn visit_term`, after the `Term::Assign { value, .. } =>
{ ... }` arm (`:243-246`), before the match closing `}` (`:247`),
add:
```rust
Term::Loop { binders, body } => {
observed.insert(VariantTag::TermLoop);
for b in binders {
visit_type(&b.ty, observed);
visit_term(&b.init, observed);
}
visit_term(body, observed);
}
Term::Recur { args } => {
observed.insert(VariantTag::TermRecur);
for a in args {
visit_term(a, observed);
}
}
```
- [ ] **Step 5: Add the hash-stability pin to `hash_pin.rs`**
In `crates/ailang-core/tests/hash_pin.rs`, immediately after the
`fn iter13a_schema_extension_preserves_pre_13a_hashes` test
(ends ~`:88`), add:
```rust
/// loop-recur iter 1 regression: adding `Term::Loop` / `Term::Recur`
/// (and `struct LoopBinder`) must NOT change canonical-JSON hashes
/// of any pre-loop-recur definition. The additive variant carries a
/// new `"t"` tag absent from every pre-existing fixture, so the
/// recorded hashes below must stay byte-identical. If this fires,
/// the extension is non-additive (a `skip_serializing_if` is missing
/// or an existing variant's shape drifted).
#[test]
fn loop_recur_schema_extension_preserves_pre_loop_recur_hashes() {
let examples = examples_dir();
let sum_mod = ailang_surface::load_module(&examples.join("sum.ail"))
.expect("examples/sum.ail loads");
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
let list_mod = ailang_surface::load_module(&examples.join("list.ail"))
.expect("examples/list.ail loads");
let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
}
```
- [ ] **Step 6: Create the positive round-trip fixture**
Create `examples/loop_sum_to.ail` with exactly the spec's worked
clause-1 program:
```
(module loop_sum_to
(fn sum_to
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (acc Int 0) (i Int 1)
(if (app > i n)
acc
(recur (app + acc i) (app + i 1)))))))
```
(If the local `examples/*.ail` convention has no explicit
`(module …)` wrapper, mirror an existing sibling fixture's header
shape verbatim — e.g. `examples/sum.ail` — do not invent one. The
fixture must `ail parse`-round-trip; it is NOT typechecked in
iter 1, so `ail check` failing with the iter-1 `synth`
`CheckError::Internal` stub is expected and is NOT this fixture's
gate. The gate is `round_trip.rs` idempotency only.)
- [ ] **Step 7: Run the full workspace suite + tail-app non-regression**
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: PASS — all tests green, including
`design_schema_drift`, `spec_drift`, `schema_coverage`,
`hash_pin::loop_recur_schema_extension_preserves_pre_loop_recur_hashes`,
`hash_pin::iter13a_schema_extension_preserves_pre_13a_hashes`
(still green — proves additivity), and
`round_trip::parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
(auto-covers the new `examples/loop_sum_to.ail`).
Run: `cargo test --workspace tail 2>&1 | tail -8`
Expected: PASS — every existing `tail-app` / `tail-do` test
byte-identical (this is the spec §"Testing strategy"
"`tail-app` non-regression" gate and the operational evidence for
Boss-call-1: `verify_tail_positions`' tail-app role is unchanged).
---
## Acceptance criteria (this iteration)
- `Term::Loop` / `Term::Recur` / `struct LoopBinder` exist as
strictly-additive nodes with canonical JSON `"t":"loop"` /
`"t":"recur"`; `binders` has no `skip_serializing_if`.
- `(loop …)` / `(recur …)` parse, print, and round-trip; the spec's
worked `sum_to` program is a green `examples/*.ail` fixture under
`round_trip.rs` auto-discovery.
- Every exhaustive `match`-on-`Term` in all six crates + the three
drift tests has loop/recur arms; `cargo build --workspace` and
`cargo test --workspace` are green.
- Pre-existing canonical-JSON hashes are bit-stable
(`iter13a` + the new `loop_recur` hash pin both green).
- NO typecheck semantics (`synth` returns the `CheckError::Internal`
stub for Loop/Recur) and NO real codegen (`lower_term` returns
the `CodegenError::Internal` stub) — those are iters 2 and 3.
- `tail-app` / `tail-do` / Decision-8 tests byte-identical; no
`Diverge`, no `verify_structural_recursion`, no
`NonStructuralRecursion`, no `verify_loop_body` (iter 2).
- `verify_tail_positions`' tail-app verification role is unchanged
(operational evidence: the tail-app non-regression test in
Task 7 Step 7), satisfying the spec's "byte-unchanged"
acceptance criterion under the Boss-call-1 reading.
## Cross-references
- Parent spec: `docs/specs/0034-loop-recur.md`
(Components 1/2/3/6 = this iter; Component 4 = iter 2;
Component 5 = iter 3).
- mut.1 precedent for the additive-node-across-no-wildcard-workspace
pattern + the `Internal`-stub-at-the-two-dispatch-points shape:
`docs/specs/0029-mut-local.md` §"Iteration mut.1".
- Boss-call-1 (`verify_tail_positions` byte-unchanged reading) and
Boss-call-2 (codegen in iter-1 as stubs) are recorded in the
plan header above and must be mirrored into the per-iter journal
at iter close.