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.
42 KiB
loop-recur.2 — Typecheck Semantics (Component 4) — 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: Replace the iter-1 synth CheckError::Internal stub for
Term::Loop / Term::Recur with real typecheck semantics — binder
typing, positional recur arity + per-arg type checking via a
loop_stack frame, a new private verify_loop_body tail-position
pass, and the four Recur* diagnostics — so a sum_to-class loop
program passes ail check and the four negatives fire point-exactly.
Architecture: synth gains a loop_stack: &mut Vec<Vec<Type>>
param threaded exactly as mut.2's mut_scope_stack (stack of
ordered binder-type vectors — recur is positional, not
name-keyed, so unlike mut the frame is not an IndexMap; binder
names enter the ordinary locals scope exactly as Term::Let
binds its name). Term::Loop synths each binder init (later inits
- body see earlier binder names via
locals), pushes the ordered binder types ontoloop_stack, synths the body (loop's static type = body's type), pops.Term::Recurreadsloop_stack.last(): empty →RecurOutsideLoop; arg-count ≠ binder-count →RecurArityMismatch; per-arg subst-compare ≠ binder type →RecurTypeMismatch(Assign-style structural pre-check, NOTunify-propagate — see Boss call 1);recur's own result is a fresh metavar (it transfers control, never falls through). A NEW privateverify_loop_body(sibling of the spec-frozenverify_tail_positions) enforcesrecuronly in tail position of its enclosing loop body (RecurNotInTailPosition); it runs incheck_fnaftersynth, soRecurOutsideLoop/arity/type (synth) take code-precedence over the tail-position code by construction.
Tech Stack: ailang-check (lib.rs — CheckError, code,
ctx, synth, check_fn, new verify_loop_body); examples/
(4 negative .ail.json fixtures + 1 positive .ail);
ailang-core/tests/carve_out_inventory.rs; new
ailang-check/tests/loop_recur_typecheck_pin.rs;
ail/tests/ct1_check_cli.rs.
Boss design calls baked into this plan (settled — do not re-litigate)
-
RecurTypeMismatchis an Assign-style structural pre-check, not aunify-propagate. The recon found two in-file precedents: mut.2Term::Mut-init letsunify(&v.ty,&init_ty, subst)?propagate its ownCheckError::TypeMismatch(codetype-mismatch); mut.2Term::Assigndoes NOT — it synths the value,subst.applys both sides, structurally comparesapplied_declared != applied_value, and catch-wraps into the dedicatedAssignTypeMismatch. The spec §"Error handling" + §"Acceptance criteria" mandate a dedicatedrecur-type-mismatchcode that fires point-exactly on its negative fixture. Only the Assign-style mechanism satisfies that (aunify-propagate would surfacetype-mismatch, failing the acceptance). SoTerm::Recurper-arg checking mirrors theTerm::Assignarm (lib.rs:3716-3730) verbatim in mechanism: synth the arg,subst.applyboth the arg type and the binder type, structural!=, on mismatchErr(RecurTypeMismatch{position,expected,got}). Rationale is semantic (one in-repo mechanism for "declared-vs- actual at a binding site with its own point-exact code", consistent withAssignTypeMismatch), not effort. -
loop_stackelement type isVec<Type>(ordered binder types), NOTIndexMap<String,Type>. mut.2'smut_scope_stack: Vec<IndexMap<String,Type>>is name-keyed becauseassign/varresolve mut-vars by name.recurhas no by-name rebinding — it rebinds binders positionally. The frame must model exactly whatTerm::Recurconsumes: the ordered binder types, for arity (.len()) and per-position type checks. Binder names enter the ordinarylocals: &mut IndexMap<String,Type>exactly asTerm::Letbinds its name (lib.rs:3205-3218is the verbatim precedent) so later inits and the body resolve them through the normal locals/globals chain. The spec phrase "threaded exactly as mut.2'smut_scope_stack" governs the threading discipline (a&mut Vec<…>pushed/popped around the body, passed through every recursivesynth), not the element type. Semantic, not effort. -
Diagnostic-code precedence is by pass ordering, already correct.
RecurOutsideLoop/RecurArityMismatch/RecurTypeMismatchare raised insynth(runs atcheck_fn:1970);RecurNotInTailPositionis raised in the newverify_loop_body(runs after, analogous toverify_tail_positionsatcheck_fn:1980). Arecuroutside any loop therefore firesRecurOutsideLoop(synth, first), notRecurNotInTailPosition— exactly the spec-correct code. No explicit ordering logic is needed; the pass sequence is the mechanism.verify_loop_bodyis entered only on synth success, so by then everyrecuris known to be inside a loop with matching arity/types; the pass adds only the tail rule.
Files this plan creates or modifies
- Modify:
crates/ailang-check/src/lib.rs:705-area — 4 newCheckErrorvariants (afterMutVarCapturedByLambda:697, beforeInternal:703).:751-area — 4 newcode()arms (afterMutVarCapturedByLambda:750).:824-area — 2 newctx()arms (afterMutVarCapturedByLambda:822-824, before the_ =>catch-all:825).:2772—synthsignature: addloop_stackparam.- 22 internal recursive
synth(...)call sites +:1970(check_fn) +:2748(check_const) + test-module callers — threadloop_stack(build-gated; see Task 2). :3740-3742— replace the iter-1 Loop/RecurInternalstub with the two real arms.:1969-adjacent +:1970+ after:1980(check_fn) — declareloop_stack, thread into the synth call, invokeverify_loop_body.- new private
fn verify_loop_body(place immediately afterverify_tail_positions, which ends:2725).
- Create:
examples/test_recur_outside_loop.ail.json - Create:
examples/test_recur_arity_mismatch.ail.json - Create:
examples/test_recur_type_mismatch.ail.json - Create:
examples/test_recur_not_in_tail_position.ail.json - Reuse (no new file):
examples/loop_sum_to.ail(shipped iter-1 as the round-trip fixture) — now also the positiveail checkevidence: it returned theInternalstub in iter-1, must typecheck clean in iter-2. One fixture, two properties (round-trips AND typechecks); no near-duplicate is created. - Create:
crates/ailang-check/tests/loop_recur_typecheck_pin.rs(mirror ofmut_typecheck_pin.rs). - Modify:
crates/ailang-core/tests/carve_out_inventory.rs:6-40(header prose refresh + 4EXPECTEDentries; 13 → 17). - Modify:
crates/ail/tests/ct1_check_cli.rs— add a siblingcheck_human_mode_renders_recur_diagnostic_code_exactly_once. - No edit:
verify_tail_positions(:2607-2725, spec-frozen tail-app role — its iter-1 Loop/Recur descent arms stay); the drift trio (design_schema_drift/spec_drift/schema_coverage— iter-2 adds no schema);round_trip.rs(auto-discovers.ailonly; negatives are.ail.jsoncarve- outs, exempt by construction).
Task 1: The four Recur* CheckError variants + code() + ctx()
Files: Modify crates/ailang-check/src/lib.rs
- Step 1: Write the failing code() unit test
In crates/ailang-check/src/lib.rs, in the #[cfg(test)] mod tests (near the mut-family unit tests), add:
#[test]
fn recur_checkerror_codes_are_exact() {
assert_eq!(CheckError::RecurOutsideLoop.code(), "recur-outside-loop");
assert_eq!(
CheckError::RecurArityMismatch { expected: 2, got: 1 }.code(),
"recur-arity-mismatch"
);
assert_eq!(
CheckError::RecurTypeMismatch {
position: 0,
expected: "Int".into(),
got: "Bool".into(),
}
.code(),
"recur-type-mismatch"
);
assert_eq!(
CheckError::RecurNotInTailPosition.code(),
"recur-not-in-tail-position"
);
}
- Step 2: Run test to verify it fails to compile
Run: cargo test -p ailang-check --lib recur_checkerror_codes_are_exact 2>&1 | tail -5
Expected: FAIL — error[E0599]: no variant ... RecurOutsideLoop etc.
- Step 3: Add the four variants
In crates/ailang-check/src/lib.rs, immediately after the
MutVarCapturedByLambda { name: String } variant (ends :697),
before the /// Iter 22b.3 … doc + Internal(String) (:699-704),
add (bracket-[code]-free #[error(...)], F2 convention,
mirroring the mut.2 variant style at :661-697):
/// loop-recur iter 2: `Term::Recur` reached typecheck with no
/// lexically-enclosing `Term::Loop` (the `loop_stack` was empty).
#[error("`recur` is only valid inside a `loop` — there is no enclosing loop here")]
RecurOutsideLoop,
/// loop-recur iter 2: `Term::Recur`'s argument count differs from
/// the enclosing loop's binder count. `recur` rebinds every loop
/// binder positionally, so the counts must match exactly.
#[error("`recur` passes {got} argument(s) but the enclosing loop has {expected} binder(s) — recur must rebind every binder positionally")]
RecurArityMismatch { expected: usize, got: usize },
/// loop-recur iter 2: a `recur` argument's type differs from the
/// corresponding loop binder's declared type (0-based position).
#[error("`recur` argument {position} has type `{got}` but the enclosing loop's binder at that position is `{expected}` — every recur argument must match its binder's type")]
RecurTypeMismatch {
position: usize,
expected: String,
got: String,
},
/// loop-recur iter 2: `Term::Recur` appears somewhere other than
/// the tail position of its enclosing loop body. `recur` is a
/// structural back-jump; it cannot be a sub-expression.
#[error("`recur` must be in tail position of its enclosing `loop` body — it is a back-jump, not a value-producing sub-expression")]
RecurNotInTailPosition,
- Step 4: Add the four
code()arms
In pub fn code(&self)'s match self, immediately after
CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
(:750), before CheckError::Internal(_) => "internal", (:751),
add:
CheckError::RecurOutsideLoop => "recur-outside-loop",
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
CheckError::RecurNotInTailPosition => "recur-not-in-tail-position",
- Step 5: Add the two
ctx()arms
In pub fn ctx(&self)'s match self, immediately after
CheckError::MutVarCapturedByLambda { name } => { … } (ends
:824), before the _ => serde_json::Value::Object(...)
catch-all (:825), add (arity mirrors the ArityMismatch
{expected, actual} precedent at :763-765; type mirrors
AssignTypeMismatch's {name?,expected,actual} shape at
:816-818 but positional; RecurOutsideLoop /
RecurNotInTailPosition deliberately use the {} catch-all —
no actionable structured context beyond the message):
CheckError::RecurArityMismatch { expected, got } => {
serde_json::json!({"expected": expected, "actual": got})
}
CheckError::RecurTypeMismatch { position, expected, got } => {
serde_json::json!({"position": position, "expected": expected, "actual": got})
}
- Step 6: Run the code() test to verify it passes
Run: cargo test -p ailang-check --lib recur_checkerror_codes_are_exact 2>&1 | tail -5
Expected: PASS — test result: ok. 1 passed. (The crate compiles:
code() is exhaustive and now has all four arms; the synth
Loop/Recur stub still returns Internal and does not yet use the
new variants — that is Task 3.)
Task 2: synth signature + loop_stack threading
Files: Modify crates/ailang-check/src/lib.rs
Pure mechanical param threading. The synth Loop/Recur arm is
still the iter-1 Internal stub after this task (it does not
reference loop_stack), so the crate compiles once threading is
complete. The compiler is the exhaustive completeness oracle: a
missed caller is a hard error[E0061] this function takes N arguments.
- Step 1: Add the
loop_stackparameter tosynth
In pub(crate) fn synth(, immediately after the
mut_scope_stack: &mut Vec<IndexMap<String, Type>>, parameter
(:2772), add:
// loop-recur iter 2: per-walk loop-binder-type stack. Each frame
// is one `Term::Loop`'s binder types in declaration order
// (positional — `recur` rebinds binders by position, not name,
// so this is `Vec<Type>` not name-keyed like `mut_scope_stack`).
// Pushed on `Term::Loop` body entry, popped on exit; consulted
// innermost-first by `Term::Recur` for arity + per-position type.
// Position: mut_scope_stack-adjacent — both are per-fn-body
// lexical-control scope state threaded identically.
loop_stack: &mut Vec<Vec<Type>>,
- Step 2: Thread
loop_stackat every recursive call site
The threading rule is uniform: in every synth(...) call, insert
the loop_stack argument immediately after the mut_scope_stack
argument (positionally matching the signature). For the 22
recursive call sites inside fn synth (the in-scope loop_stack
param is threaded through): :3161, :3197, :3206, :3208,
:3220, :3222, :3223, :3241, :3336, :3345, :3363,
:3438, :3440, :3474, :3562, :3589, :3605, :3619,
:3649, and the three multi-line calls headed at :3674,
:3683, :3717. Example transform (the Term::Let arm at
:3206/:3208):
// before:
let v = synth(value, env, locals, mut_scope_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
// after:
let v = synth(value, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
-
Step 3: Thread
loop_stackat the external (non-recursive) callers -
check_fn:1970: handled in Task 4 Step 4 (it declares its own freshloop_stack). -
check_const:2748: a const value has no enclosing loop. Immediately before thesynth(&c.value, …)call, addlet mut loop_stack: Vec<Vec<Type>> = Vec::new();and pass&mut loop_stackimmediately after&mut mut_scope_stackin that call. (Anyrecurin a const value then correctly firesRecurOutsideLoop.) -
Step 4: Build-gated completeness sweep
Run: cargo build -p ailang-check 2>&1 | grep -E 'this function takes|error\[E0061\]' | sort -u
Expected first run: a list of remaining synth(...) callers in
the #[cfg(test)] module (the let err = synth(/let ty = synth(/
let _ = synth( sites the recon located at :4123, :4165,
:4208, :4313, :4353, :4392, :4494, :7233). For each:
these are loop-free unit-test call sites — insert a
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); before the call
(or reuse one already in scope) and pass &mut loop_stack
immediately after the mut_scope_stack/&mut mut_scope_stack
argument, exactly as Step 3.
Re-run the command until it prints nothing.
Then run: cargo build -p ailang-check 2>&1 | tail -2
Expected: PASS — Finished (0 errors). The threading is
complete iff the crate compiles; this is the exhaustive gate.
- Step 5: Confirm the suite still green (stub intact)
Run: cargo test -p ailang-check --lib 2>&1 | grep -E '^test result:' | tail -1
Expected: PASS — the synth Loop/Recur arm is still the iter-1
Internal stub (unchanged); no behaviour changed, only an unused
threaded param was added.
Task 3: Real synth Term::Loop + Term::Recur arms
Files: Modify crates/ailang-check/src/lib.rs
- Step 1: Write the failing positive-typecheck pin
Create crates/ailang-check/tests/loop_recur_typecheck_pin.rs
with the harness mirrored verbatim from mut_typecheck_pin.rs
(same examples_dir() + check_fixture() helpers) and this first
test:
//! loop-recur iter 2: pin tests that the loop/recur typecheck
//! fixtures produce the expected diagnostic codes (or zero
//! diagnostics for the positive sum_to-class case). Mirrors
//! `mut_typecheck_pin.rs`; negative fixtures live as `.ail.json`
//! so the diagnostic code is the load-bearing assertion.
use ailang_check::check_workspace;
use ailang_surface::load_workspace;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest)
.parent().expect("crates/ailang-check parent")
.parent().expect("crates/ parent")
.join("examples")
}
fn check_fixture(fixture_name: &str) -> Vec<String> {
let path = examples_dir().join(fixture_name);
let ws = load_workspace(&path)
.unwrap_or_else(|e| panic!("workspace `{fixture_name}` must load: {e:?}"));
let diags = check_workspace(&ws);
diags.iter().map(|d| d.code.clone()).collect()
}
#[test]
fn loop_sum_to_typechecks_clean() {
// examples/loop_sum_to.ail shipped in iter-1 as the round-trip
// fixture; in iter-2 it must ALSO typecheck clean (it returned
// the synth Internal stub in iter-1). One fixture, two
// properties — no near-duplicate corpus file.
let codes = check_fixture("loop_sum_to.ail");
assert!(codes.is_empty(), "expected zero diagnostics, got {codes:?}");
}
(The four negative tests are added in Task 5 Step 4 — same file.)
- Step 2: Run the positive pin RED (no fixture creation — reuse iter-1's)
examples/loop_sum_to.ail already exists (shipped iter-1,
commit a179ec3); do NOT create a new fixture.
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin loop_sum_to_typechecks_clean 2>&1 | tail -6
Expected: FAIL — codes == ["internal"] (the iter-1 synth
Loop/Recur stub returns CheckError::Internal; ail check
surfaces it as code internal, so the list is non-empty).
- Step 3: Replace the
Internalstub with the real arms
In crates/ailang-check/src/lib.rs, replace the iter-1 stub
(:3740-3742, verbatim):
Term::Loop { .. } | Term::Recur { .. } => Err(CheckError::Internal(
"Term::Loop/Term::Recur typecheck lands in loop-recur iter 2".into(),
)),
with the two real arms (binder-name scoping mirrors the
Term::Let arm :3205-3218 verbatim; per-arg type check mirrors
the Term::Assign arm :3716-3730 verbatim per Boss call 1;
fresh metavar via Subst::fresh(counter) per :57-61 /
:3038):
// loop-recur iter 2: each binder init is synthed in the
// outer scope plus already-declared binder names of THIS
// loop (declaration order; later inits + body see earlier
// binders via `locals`, exactly as `Term::Let` binds its
// name). The loop's static type is the body's type. The
// ordered binder types are pushed onto `loop_stack` for the
// body walk so an inner `Term::Recur` can position-check
// against them; popped on exit. Binder names are NOT in
// `loop_stack` (recur is positional, not by-name).
Term::Loop { binders, body } => {
let mut binder_tys: Vec<Type> = Vec::new();
let mut restore: Vec<(String, Option<Type>)> = Vec::new();
for b in binders {
let init_ty = synth(
&b.init, env, locals, mut_scope_stack, loop_stack, effects,
in_def, subst, counter, residuals, free_fn_calls, warnings,
)?;
unify(&b.ty, &init_ty, subst)?;
binder_tys.push(b.ty.clone());
let prev = locals.insert(b.name.clone(), b.ty.clone());
restore.push((b.name.clone(), prev));
}
loop_stack.push(binder_tys);
let body_result = synth(
body, env, locals, mut_scope_stack, loop_stack, effects,
in_def, subst, counter, residuals, free_fn_calls, warnings,
);
loop_stack.pop();
for (name, prev) in restore.into_iter().rev() {
match prev {
Some(p) => {
locals.insert(name, p);
}
None => {
locals.shift_remove(&name);
}
}
}
body_result
}
// loop-recur iter 2: recur re-enters the innermost enclosing
// loop, rebinding its binders positionally. RecurOutsideLoop
// when `loop_stack` is empty; RecurArityMismatch on count
// disagreement; RecurTypeMismatch via the Assign-style
// subst-apply-then-structural-compare (Boss call 1) so the
// dedicated code fires point-exactly. recur's OWN type is a
// fresh metavar — it transfers control and never falls
// through, so it unifies with whatever sibling branch the
// enclosing if/match requires.
Term::Recur { args } => {
let binder_tys = match loop_stack.last() {
None => return Err(CheckError::RecurOutsideLoop),
Some(tys) => tys.clone(),
};
if args.len() != binder_tys.len() {
return Err(CheckError::RecurArityMismatch {
expected: binder_tys.len(),
got: args.len(),
});
}
for (i, a) in args.iter().enumerate() {
let arg_ty = synth(
a, env, locals, mut_scope_stack, loop_stack, effects,
in_def, subst, counter, residuals, free_fn_calls, warnings,
)?;
let applied_binder = subst.apply(&binder_tys[i]);
let applied_arg = subst.apply(&arg_ty);
if applied_binder != applied_arg {
return Err(CheckError::RecurTypeMismatch {
position: i,
expected: ailang_core::pretty::type_to_string(&applied_binder),
got: ailang_core::pretty::type_to_string(&applied_arg),
});
}
}
Ok(Subst::fresh(counter))
}
- Step 4: Run the positive pin to verify it passes
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin loop_sum_to_typechecks_clean 2>&1 | tail -6
Expected: PASS — zero diagnostics (binder typing succeeds; recur
arity 2 == 2 binders, both Int, fresh-metavar result unifies
with the if's acc branch which is Int).
Task 4: verify_loop_body private pass + check_fn wiring
Files: Modify crates/ailang-check/src/lib.rs
- Step 1: Write the failing tail-position fixture + pin
Create examples/test_recur_not_in_tail_position.ail.json (the
spec §"Concrete code shapes" bad_recur program, canonical JSON;
envelope mirrors examples/test_mut_assign_outside_mut.ail.json):
{
"schema": "ailang/v0",
"name": "test_recur_not_in_tail_position",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "bad_recur",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [ "n" ],
"doc": "loop-recur iter 2: recur as an argument to + is NOT in tail position -> recur-not-in-tail-position.",
"body": {
"t": "loop",
"binders": [
{ "name": "i", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }
],
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{ "t": "recur", "args": [ { "t": "app", "fn": { "t": "var", "name": "+" }, "args": [ { "t": "var", "name": "i" }, { "t": "lit", "lit": { "kind": "int", "value": 1 } } ] } ] }
]
}
}
}
]
}
Add to crates/ailang-check/tests/loop_recur_typecheck_pin.rs:
#[test]
fn recur_not_in_tail_position_emits_recur_not_in_tail_position() {
let codes = check_fixture("test_recur_not_in_tail_position.ail.json");
assert_eq!(codes, vec!["recur-not-in-tail-position".to_string()]);
}
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin recur_not_in_tail_position 2>&1 | tail -6
Expected: FAIL — synth accepts it (arity 1 == 1, type Int),
returns a fresh metavar; nothing yet checks tail-position, so
codes is empty (or carries an unrelated code), not
["recur-not-in-tail-position"].
- Step 2: Add the
verify_loop_bodyprivate pass
In crates/ailang-check/src/lib.rs, immediately after
pub fn verify_tail_positions ends (:2725), add (structure
mirrors verify_tail_positions; in_loop_tail = "this position
is a tail position of the innermost enclosing loop body", false
outside any loop or in a non-tail sub-position; runs after synth so
every Recur reached here is already inside a valid loop):
/// loop-recur iter 2: enforces that every `Term::Recur` occurs in
/// tail position of its lexically-innermost enclosing `Term::Loop`
/// body. A NEW private pass — a *sibling* of
/// `verify_tail_positions` (which is spec-frozen for its tail-app
/// role and unchanged). Invoked from `check_fn` *after* `synth`, so
/// `RecurOutsideLoop`/arity/type (raised in `synth`) take code-
/// precedence; by the time a `Recur` is reached here it is known to
/// be inside a loop with matching arity/types — this pass adds only
/// the tail-position rule. `in_loop_tail` is the loop analogue of
/// `verify_tail_positions`' `is_tail`, scoped to the innermost loop.
fn verify_loop_body(t: &Term, in_loop_tail: bool) -> Result<()> {
match t {
Term::Lit { .. } | Term::Var { .. } => Ok(()),
Term::Recur { args } => {
if !in_loop_tail {
return Err(CheckError::RecurNotInTailPosition);
}
// recur args are evaluated in non-tail position; a recur
// nested inside a recur arg is itself non-tail.
for a in args {
verify_loop_body(a, false)?;
}
Ok(())
}
Term::Loop { binders, body } => {
// Binder inits run once on loop entry — not in the
// loop's tail scope. The loop body opens a FRESH
// innermost-loop tail scope (independent of where the
// loop itself sits — the loop's tail scope is intrinsic).
for b in binders {
verify_loop_body(&b.init, false)?;
}
verify_loop_body(body, true)
}
Term::App { callee, args, .. } => {
verify_loop_body(callee, false)?;
for a in args {
verify_loop_body(a, false)?;
}
Ok(())
}
Term::Do { args, .. } => {
for a in args {
verify_loop_body(a, false)?;
}
Ok(())
}
Term::Let { value, body, .. } => {
verify_loop_body(value, false)?;
verify_loop_body(body, in_loop_tail)
}
Term::If { cond, then, else_ } => {
verify_loop_body(cond, false)?;
verify_loop_body(then, in_loop_tail)?;
verify_loop_body(else_, in_loop_tail)
}
Term::Seq { lhs, rhs } => {
verify_loop_body(lhs, false)?;
verify_loop_body(rhs, in_loop_tail)
}
Term::Match { scrutinee, arms } => {
verify_loop_body(scrutinee, false)?;
for arm in arms {
verify_loop_body(&arm.body, in_loop_tail)?;
}
Ok(())
}
Term::Ctor { args, .. } => {
for a in args {
verify_loop_body(a, false)?;
}
Ok(())
}
Term::Lam { body, .. } => {
// A `recur` cannot cross a lambda boundary (different
// control frame). Reset: a recur inside a lam is outside
// every enclosing loop's tail scope — and synth's
// loop_stack likewise does not cross the lam, so such a
// recur is already a synth `RecurOutsideLoop`.
verify_loop_body(body, false)
}
Term::LetRec { body, in_term, .. } => {
verify_loop_body(body, false)?;
verify_loop_body(in_term, in_loop_tail)
}
Term::Clone { value } => verify_loop_body(value, in_loop_tail),
Term::ReuseAs { source, body } => {
verify_loop_body(source, false)?;
verify_loop_body(body, in_loop_tail)
}
Term::Mut { vars, body } => {
for v in vars {
verify_loop_body(&v.init, false)?;
}
verify_loop_body(body, in_loop_tail)
}
Term::Assign { value, .. } => verify_loop_body(value, false),
}
}
- Step 3: Declare
loop_stackincheck_fn
In check_fn, immediately after the mut_scope_stack
declaration (:1969,
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();),
add:
// loop-recur iter 2: fresh per-fn-body loop-binder-type stack.
// Empty at fn entry; pushed/popped by `Term::Loop` arms during
// the synth walk; discarded after the body type-checks.
let mut loop_stack: Vec<Vec<Type>> = Vec::new();
- Step 4: Thread
loop_stackinto thecheck_fnsynth call + invokeverify_loop_body
In check_fn, the synth(&f.body, …) call (:1970): insert
&mut loop_stack, immediately after &mut mut_scope_stack,:
// before:
let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
// after:
let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
Immediately after verify_tail_positions(&f.body, true)?;
(:1980), add (sibling pass; fn body is not itself inside any
loop → false):
// loop-recur iter 2: recur-in-tail-position verification. Runs
// after synth (so RecurOutsideLoop/arity/type take code-
// precedence) and alongside verify_tail_positions (sibling, not
// a repurpose — verify_tail_positions' tail-app role is frozen).
verify_loop_body(&f.body, false)?;
- Step 5: Run the tail-position pin to verify it passes
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin recur_not_in_tail_position 2>&1 | tail -6
Expected: PASS — codes == ["recur-not-in-tail-position"] (the
recur is an argument to +, a non-tail position).
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin loop_sum_to_typechecks_clean 2>&1 | tail -3
Expected: PASS — the positive sum_to loop still clean (its
recur IS in the if else-branch tail).
Task 5: Three remaining negative fixtures + carve-out + ct1 F2 + pin completion
Files: Create 3 .ail.json; modify
carve_out_inventory.rs, ct1_check_cli.rs,
loop_recur_typecheck_pin.rs.
- Step 1: Create
test_recur_outside_loop.ail.json
{
"schema": "ailang/v0",
"name": "test_recur_outside_loop",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" }, "effects": [] },
"params": [],
"doc": "loop-recur iter 2: recur with no enclosing loop -> recur-outside-loop.",
"body": { "t": "recur", "args": [ { "t": "lit", "lit": { "kind": "int", "value": 1 } } ] }
}
]
}
- Step 2: Create
test_recur_arity_mismatch.ail.json
{
"schema": "ailang/v0",
"name": "test_recur_arity_mismatch",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "bad_arity",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" }, "effects": [] },
"params": [],
"doc": "loop-recur iter 2: 2 binders, recur passes 1 arg -> recur-arity-mismatch.",
"body": {
"t": "loop",
"binders": [
{ "name": "a", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } },
{ "name": "b", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 1 } } }
],
"body": { "t": "recur", "args": [ { "t": "var", "name": "a" } ] }
}
}
]
}
- Step 3: Create
test_recur_type_mismatch.ail.json
{
"schema": "ailang/v0",
"name": "test_recur_type_mismatch",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "bad_type",
"type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" }, "effects": [] },
"params": [],
"doc": "loop-recur iter 2: binder is Int, recur arg is Bool -> recur-type-mismatch.",
"body": {
"t": "loop",
"binders": [
{ "name": "i", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }
],
"body": { "t": "recur", "args": [ { "t": "lit", "lit": { "kind": "bool", "value": true } } ] }
}
}
]
}
- Step 4: Complete the pin file
Append to crates/ailang-check/tests/loop_recur_typecheck_pin.rs:
#[test]
fn recur_outside_loop_emits_recur_outside_loop() {
let codes = check_fixture("test_recur_outside_loop.ail.json");
assert_eq!(codes, vec!["recur-outside-loop".to_string()]);
}
#[test]
fn recur_arity_mismatch_emits_recur_arity_mismatch() {
let codes = check_fixture("test_recur_arity_mismatch.ail.json");
assert_eq!(codes, vec!["recur-arity-mismatch".to_string()]);
}
#[test]
fn recur_type_mismatch_emits_recur_type_mismatch() {
let codes = check_fixture("test_recur_type_mismatch.ail.json");
assert_eq!(codes, vec!["recur-type-mismatch".to_string()]);
}
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin 2>&1 | grep -E '^test result:'
Expected: PASS — all 5 tests green (1 positive + 4 negatives,
each code point-exact via assert_eq!, non-vacuous).
- Step 5: Extend
carve_out_inventory.rs(13 → 17 + refresh stale header)
In crates/ailang-core/tests/carve_out_inventory.rs, in
const EXPECTED, immediately after the
"test_mut_var_captured_by_lambda.ail.json", line (the
// Iter mut.4-tidy entry), add:
// loop-recur iter 2 — negative typecheck fixtures
"test_recur_arity_mismatch.ail.json",
"test_recur_not_in_tail_position.ail.json",
"test_recur_outside_loop.ail.json",
"test_recur_type_mismatch.ail.json",
In the module doc-comment header (:6-19), replace the stale
line //! Twelve carve-outs post iter mut.2 (2026-05-15): with:
//! Seventeen carve-outs post iter loop-recur.2 (2026-05-17):
and immediately after the //! - §C4 (b) compile-time-embed: …
bullet block (ends :19), add a new bullet:
//! - loop-recur iter 2: 4 negative typecheck fixtures for the
//! four `Recur*` diagnostics (recur-outside-loop /
//! recur-arity-mismatch / recur-type-mismatch /
//! recur-not-in-tail-position) — the diagnostic code is the
//! load-bearing assertion, mirroring the §C4(a) / mut.2
//! negative-fixture convention.
(The pre-existing "Twelve"-vs-13 mut.4-tidy drift is corrected by this same refresh to the true post-iter-2 count of 17.)
Run: cargo test -p ailang-core --test carve_out_inventory 2>&1 | tail -3
Expected: PASS — examples/*.ail.json inventory == EXPECTED (17).
- Step 6: Add the ct1 F2 human-mode sibling test
In crates/ail/tests/ct1_check_cli.rs, immediately after
fn check_human_mode_renders_mut_diagnostic_code_exactly_once
(ends ~:252), add a sibling (verbatim same body shape, recur
cases, recur-appropriate name):
/// loop-recur iter 2: the four `Recur*` Display bodies must be
/// bracket-`[code]`-free (F2 convention) — the human-mode
/// formatter supplies `[<code>]` exactly once. Same observable
/// property as the mut sibling, over the four recur negatives.
#[test]
fn check_human_mode_renders_recur_diagnostic_code_exactly_once() {
let cases = [
("test_recur_outside_loop.ail.json", "recur-outside-loop"),
("test_recur_arity_mismatch.ail.json", "recur-arity-mismatch"),
("test_recur_type_mismatch.ail.json", "recur-type-mismatch"),
(
"test_recur_not_in_tail_position.ail.json",
"recur-not-in-tail-position",
),
];
for (fixture_name, code) in cases {
let fixture = examples_dir().join(fixture_name);
let output = Command::new(ail_bin())
.args(["check", fixture.to_str().unwrap()])
.output()
.expect("ail binary must launch");
assert!(
!output.status.success(),
"ail check (human mode) must fail on {fixture_name}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
let needle = format!("[{code}]");
let occurrences = stderr.matches(&needle).count();
assert_eq!(
occurrences, 1,
"expected `[{code}]` exactly once in human stderr, found {occurrences}; \
full stderr:\n{stderr}"
);
}
}
Run: cargo test -p ail --test ct1_check_cli check_human_mode_renders_recur 2>&1 | tail -4
Expected: PASS — each recur Display body is bracket-free; the
formatter prefixes [<code>] exactly once. (If FAIL with
"found 2": a Display #[error(...)] string in Task 1 Step 3
wrongly embedded the bracket — it must not; the bodies as
specified are bracket-free, so this stays green.)
Task 6: Infinite-loop acceptance + full suite + tail-app non-regression
Files: Create examples/loop_forever.ail; no source changes.
- Step 1: Infinite-loop typechecks (no termination claim)
Create examples/loop_forever.ail (a loop whose only path is
recur — no non-recur exit; must typecheck, asserting the spec
"no termination claim is made or enforced"):
(module loop_forever
(fn spin
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(loop (i (con Int) 0)
(recur (app + i 1))))))
Add to crates/ailang-check/tests/loop_recur_typecheck_pin.rs:
#[test]
fn infinite_loop_typechecks_clean_no_termination_claim() {
let codes = check_fixture("loop_forever.ail");
assert!(codes.is_empty(), "infinite loop must typecheck (no totality claim), got {codes:?}");
}
Run: cargo test -p ailang-check --test loop_recur_typecheck_pin infinite_loop 2>&1 | tail -4
Expected: PASS — zero diagnostics. (recur is the loop body's
sole expression = tail position; arity 1 == 1; type Int;
verify_loop_body accepts; no guardedness/totality pass exists,
per spec deliberate boundary. The fn return type Int unifies
with recur's fresh metavar.)
- Step 2: Full workspace suite
Run: cargo test --workspace 2>&1 | grep -E '^test result: ok' | awk '{p+=$4} END {print p" passed"}'; cargo test --workspace 2>&1 | grep -E 'FAILED|error\[|^test result: FAILED' | head
Expected: passed count ≥ the loop-recur.1 baseline (608) + the new
loop-recur.2 tests; zero FAILED/error[.
- Step 3:
tail-appnon-regression (Boss-call-1 carryover evidence)
Run: cargo test --workspace tail 2>&1 | grep -E '^test result:' | tail -4
Expected: PASS — every tail-app/tail-do/verify_tail_positions
test byte-identical (iter-2 added a sibling verify_loop_body
and did not touch verify_tail_positions; this re-confirms the
spec's frozen-tail-app-role acceptance criterion).
Acceptance criteria (this iteration)
synthtypesTerm::Loop(binder typing; loop type = body type; binder names inlocalsscope for later inits + body) andTerm::Recur(positional rebinding; fresh-metavar result); the iter-1CheckError::Internalstub is gone.- The four
Recur*codes fire point-exactly on their negative fixtures (assert_eq!, non-vacuous):recur-outside-loop,recur-arity-mismatch,recur-type-mismatch,recur-not-in-tail-position.recuroutside tail position is a compile error. - A
sum_to-class loop passesail check(it did not in iter-1); an infiniteloop(no non-recurexit) typechecks — no termination claim is made or enforced. verify_tail_positions/tail-app/tail-do/ Decision 8 byte-unchanged (verify_loop_body is a sibling, not a repurpose); NODiverge, NOverify_structural_recursion, NONonStructuralRecursion; codegen still the iter-1lower_termCodegenError::Internalstub (real lowering = iter 3).- The four Display bodies are bracket-
[code]-free (F2);carve_out_inventoryis 17 and its header prose is truthful (the pre-existing "Twelve"-vs-13 drift corrected in passing). cargo test --workspacegreen.
Cross-references
- Parent spec
docs/specs/0034-loop-recur.md(Component 4 = this iter; Component 5 codegen + the positive RUN-to-a-value / deep-nE2E = iter 3). - iter-1 (
a179ec3) shipped the additive nodes + thesynth/lower_termInternalstubs this iter'ssyntharm replaces (codegen stub stays). - mut.2 (
docs/specs/0029-mut-local.md) is the structural precedent:mut_scope_stackthreading discipline, the variant/code()/ctx()/bracket-free-Display pattern, the.ail.jsonnegative-fixture +carve_out_inventory+*_typecheck_pin.rs+ ct1-F2 convention. loop-recur.2 mirrors the mechanics; the frame element type differs by Boss call 2 (positionalVec<Type>, not name-keyed). - Boss calls 1-3 (RecurTypeMismatch mechanism; loop_stack element type; diagnostic-code precedence by pass ordering) are recorded here and must be mirrored into the per-iter journal at iter close.