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

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

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

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

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

62 KiB
Raw Blame History

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::astTerm::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:596Term::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:

    /// 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:

    /// 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:

/// 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:

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

                    "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:

                                `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:

    /// 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:

        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:

        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:

        // 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:

        // 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:

        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:

            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:

        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:

        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:

        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:

        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:

            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:

            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:

            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:

        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:

        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:

        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:

            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:

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

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

        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:

        // 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):

                        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:

        // 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 CheckErrorErr(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):

            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:

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

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

            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:

            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:

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

            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:

        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:

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

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

            // 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:

            // 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):

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

            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:

        (
            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:

            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:

        (
            "(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:

            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:

    TermLoop,
    TermRecur,

(b) in const EXPECTED_VARIANTS, after VariantTag::TermAssign, (:98), add:

    VariantTag::TermLoop,
    VariantTag::TermRecur,

(c) in fn visit_term, after the Term::Assign { value, .. } => { ... } arm (:243-246), before the match closing } (:247), add:

        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:

/// 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.