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.
62 KiB
loop-recur.1 — Additive AST-Node Foundation — Implementation Plan
Parent spec:
docs/specs/0034-loop-recur.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: loop / recur become real, parseable, printable,
round-trippable, hash-stable, strictly-additive AST nodes; the whole
workspace compiles and cargo test --workspace is green — with NO
typecheck semantics and NO real codegen lowering yet.
Architecture: Three additive items in ailang-core::ast —
Term::Loop { binders: Vec<LoopBinder>, body: Box<Term> },
Term::Recur { args: Vec<Term> }, struct LoopBinder { name, ty, init } (the triple mirrors MutVar exactly). Every exhaustive
match-on-Term in the workspace has NO _ wildcard, so the two
new variants force an arm at ~30 sites across all six crates plus the
drift tests. Sites split into four classes: (a) structural
pass-through arms (mirror the existing Term::Mut/Term::Assign
arm shape, substituting binders/init/body and args); (b)
head-name helpers ("loop" / "recur" string); (c) the two
semantic dispatch points — typecheck synth and codegen
lower_term — which get CheckError::Internal / CodegenError::Internal
stubs in this iter exactly as mut.1 did (real semantics land in
iter 2 / iter 3); (d) schema-drift / hash anchors.
Tech Stack: ailang-core (ast, desugar, workspace, specs/form_a.md,
drift+hash tests), ailang-surface (parse, print), ailang-check
(lib, lift, mono, linearity, uniqueness, reuse_shape,
pre_desugar_validation), ailang-codegen (lib, lambda, escape),
ailang-prose (lib), ail (main), docs/DESIGN.md.
Boss design calls baked into this plan (do not re-litigate at execution time)
-
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 exhaustivematch-on-Termwith no_wildcard (crates/ailang-check/src/lib.rs:2672is the existingTerm::Mutarm inside it), so the additive variant requires two new arms there orailang-checkdoes 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-appnon-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 addedTerm::Mut/Term::Assignarms inside the same function). This is settled; the executor does not reopen it. -
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-codegenalso carries no-wildcard exhaustiveTermmatches (lambda.rs:478,escape.rs:197/389/506,lib.rs:1762,lib.rs:3094); the workspace will not compile — socargo test --workspace(the iter-1 goal) cannot be green — without arms there. mut.1 resolved exactly this by addingCodegenError::Internalstub at thelower_termdispatch 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. -
bindershas noskip_serializing_if. MirrorsTerm::Mut.varsexactly (the field is part of the shape; empty stays[]in canonical JSON).recur'sargslikewise plainVec<Term>. Whether ≥1 binder is required is a typecheck question deferred to iter 2; the parser here accepts zero-or-more binders (mirrorsparse_mut).
Files this plan creates or modifies
- Modify:
crates/ailang-core/src/ast.rs:543— addTerm::Loop/Term::Recurvariants;:584— addstruct LoopBinder;:952— two serde round-trip unit tests. - Modify:
crates/ailang-surface/src/parse.rs:1198head dispatch + unknown-head message; newparse_loop/parse_recurafterparse_assign(:1643);:2551parse unit tests. - Modify:
crates/ailang-surface/src/print.rs:596—Term::Loop/Term::Recurprint 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 workedsum_toprogram (round-trip auto-covered byround_trip.rs). - No edit:
crates/ailang-surface/tests/round_trip.rs(auto-discoversexamples/*.ail);crates/ailang-core/tests/carve_out_inventory.rs(iter-1 adds no.ail.jsonnegative 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::LoopandTerm::Recurvariants
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_loopandparse_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_precarms
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_vararms
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_termarms
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.rscollect_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.rsdesugar_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.rsfree_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.rssubst_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.rssubst_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.rsfind_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.rsany_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.rsany_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.rsany_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.rswalk_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.rswalk_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.rssubstitute_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.rslift_in_term(after theTerm::Mutrebuild 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.rsterm_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.rsrewrite_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.rsinterleave_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.rsverify_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.rshead-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.rssynthSTUB (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
CheckError — Err(CheckError::Internal(...)) is the correct
stub return.)
- Step 9:
linearity.rswalk(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.rshead-name helper (after:797)
After Term::Assign { .. } => "assign", (:797) following
Term::Mut { .. } => "mut" (:796), add:
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
- Step 11:
linearity.rsany_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.rswalk(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.rswalk(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.rswalk_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.rscollect_captures(after:478Mut 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.rswalk(after:203Mut 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.rsescapes(after:395Mut 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.rscollect_free_vars(after:506Mut 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.rslower_termSTUB (after theTerm::Mutarm 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.rssynth_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.rswalk_term(after:1534Mut 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.rsrewrite_term(after theTerm::Mutarm 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 LoopBinderexist as strictly-additive nodes with canonical JSON"t":"loop"/"t":"recur";bindershas noskip_serializing_if.(loop …)/(recur …)parse, print, and round-trip; the spec's workedsum_toprogram is a greenexamples/*.ailfixture underround_trip.rsauto-discovery.- Every exhaustive
match-on-Termin all six crates + the three drift tests has loop/recur arms;cargo build --workspaceandcargo test --workspaceare green. - Pre-existing canonical-JSON hashes are bit-stable
(
iter13a+ the newloop_recurhash pin both green). - NO typecheck semantics (
synthreturns theCheckError::Internalstub for Loop/Recur) and NO real codegen (lower_termreturns theCodegenError::Internalstub) — those are iters 2 and 3. tail-app/tail-do/ Decision-8 tests byte-identical; noDiverge, noverify_structural_recursion, noNonStructuralRecursion, noverify_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_positionsbyte-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.