# 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, body: Box }`, `Term::Recur { args: Vec }`, `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`. 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 }` 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, body: Box, }, /// 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, }, ``` - [ ] **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 { 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 = 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 = 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 { self.expect_lparen("recur-term")?; self.expect_keyword("recur")?; let mut args: Vec = 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 = 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)` 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 = 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, out: &mut BTreeSet)` (`: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 = 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::>>()?, 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::>>()?, }), ``` - [ ] **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` 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 = 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 = 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": "", "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": "", "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.