test: RED — lambda capturing a loop binder must fail ail check (loop-recur.tidy)

Milestone-close audit (architect, edd2558) surfaced a [high]
correctness defect: a (lam ...) body that references a loop binder
passes `ail check` (exit 0) then panics unreachable!() at codegen
(crates/ailang-codegen/src/lambda.rs:102) on type-correct input —
a DESIGN.md "Robustness against hallucinations" violation.

Root cause (debugger-confirmed): the Term::Lam escape guard at
crates/ailang-check/src/lib.rs:3608 inspects only mut_scope_stack;
loop binders live in `locals` (Term::Loop arm ~:3911, iter-2
design), so the mut.4-tidy MutVarCapturedByLambda-class rejection
has no loop-binder counterpart — the capture is accepted at check
and crashes codegen.

This RED commit pins the FIXED contract: the program must fail
`ail check` with code `loop-binder-captured-by-lambda` (symmetric
to mut-var-captured-by-lambda), NOT panic codegen. RED today
(left: [] vs right: ["loop-binder-captured-by-lambda"]); 6 prior
loop_recur_typecheck_pin tests still green. GREEN follows.
This commit is contained in:
2026-05-18 00:08:51 +02:00
parent edd2558d35
commit 39380d361d
2 changed files with 59 additions and 0 deletions
@@ -66,3 +66,20 @@ fn infinite_loop_typechecks_clean_no_termination_claim() {
let codes = check_fixture("loop_forever.ail"); let codes = check_fixture("loop_forever.ail");
assert!(codes.is_empty(), "infinite loop must typecheck (no totality claim), got {codes:?}"); assert!(codes.is_empty(), "infinite loop must typecheck (no totality claim), got {codes:?}");
} }
/// Property: a `Term::Lam` whose body captures a `Term::Loop` binder
/// must be rejected at `ail check` with a dedicated diagnostic,
/// symmetric to `mut-var-captured-by-lambda`. Loop binders are
/// alloca-resident and cannot escape their loop frame (iter-3 reuses
/// the `mut_var_allocas` registry, `std::mem::take`-emptied at the
/// lambda boundary), exactly the mut-var escape rule. Today the
/// escape guard inspects only `mut_scope_stack`; loop binders live
/// in `locals`, so check passes (exit 0) and codegen panics
/// `unreachable!()` at `crates/ailang-codegen/src/lambda.rs:102`.
/// This test pins the FIXED contract — RED until the guard also
/// rejects loop-binder captures.
#[test]
fn lambda_capturing_loop_binder_emits_loop_binder_captured_by_lambda() {
let codes = check_fixture("test_loop_binder_captured_by_lambda.ail.json");
assert_eq!(codes, vec!["loop-binder-captured-by-lambda".to_string()]);
}
@@ -0,0 +1,42 @@
{
"schema": "ailang/v0",
"name": "test_loop_binder_captured_by_lambda",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"doc": "loop-recur follow-on: a lambda inside a loop body that references the enclosing loop binder `acc` is rejected with loop-binder-captured-by-lambda. Loop binders are alloca-resident and lexically scoped (iter-3 reuses the mut_var_allocas registry, emptied at the lambda frame boundary); lifting them into a heap-closure env is the same deferred work as the mut-var case. Symmetric to mut-var-captured-by-lambda.",
"body": {
"t": "loop",
"binders": [
{
"name": "acc",
"type": { "k": "con", "name": "Int" },
"init": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
}
],
"body": {
"t": "let",
"name": "f",
"value": {
"t": "lam",
"params": [],
"paramTypes": [],
"retType": { "k": "con", "name": "Int" },
"effects": [],
"body": { "t": "var", "name": "acc" }
},
"body": { "t": "app", "fn": { "t": "var", "name": "f" }, "args": [] }
}
}
}
]
}