diff --git a/bench/orchestrator-stats/2026-05-17-iter-loop-recur.tidy.json b/bench/orchestrator-stats/2026-05-17-iter-loop-recur.tidy.json new file mode 100644 index 0000000..69c35b2 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-17-iter-loop-recur.tidy.json @@ -0,0 +1,12 @@ +{ + "iter_id": "loop-recur.tidy", + "date": "2026-05-17", + "mode": "mini", + "outcome": "DONE", + "tasks_total": 1, + "tasks_completed": 1, + "reloops_per_task": { "1": 0 }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/tests/ct1_check_cli.rs b/crates/ail/tests/ct1_check_cli.rs index 62d89bb..e924b51 100644 --- a/crates/ail/tests/ct1_check_cli.rs +++ b/crates/ail/tests/ct1_check_cli.rs @@ -226,6 +226,13 @@ fn check_human_mode_renders_mut_diagnostic_code_exactly_once() { "test_mut_var_captured_by_lambda.ail.json", "mut-var-captured-by-lambda", ), + // Iter loop-recur.tidy: symmetric loop-binder-capture variant. + // Its Display body is likewise bracket-`[code]`-free so the + // human formatter supplies `[]` exactly once. + ( + "test_loop_binder_captured_by_lambda.ail.json", + "loop-binder-captured-by-lambda", + ), ]; for (fixture_name, code) in cases { let fixture = examples_dir().join(fixture_name); diff --git a/crates/ailang-check/src/builtins.rs b/crates/ailang-check/src/builtins.rs index 6b5df35..62e8d09 100644 --- a/crates/ailang-check/src/builtins.rs +++ b/crates/ailang-check/src/builtins.rs @@ -351,7 +351,7 @@ mod tests { let mut warnings: Vec = Vec::new(); // loop-recur iter 2: test helper synths one term from top-of- // body — fresh empty loop-stack (mirrors mut_scope_stack). - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let ty = crate::synth( t, &env, @@ -598,7 +598,7 @@ mod tests { let mut warnings: Vec = Vec::new(); // loop-recur iter 2: test helper synths one term from top-of- // body — fresh empty loop-stack (mirrors mut_scope_stack). - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let err = crate::synth( &term, &env, diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index b177b8c..8b93375 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -696,6 +696,19 @@ pub enum CheckError { #[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] MutVarCapturedByLambda { name: String }, + /// loop-recur iter loop-recur.tidy: `Term::Lam` reached typecheck + /// inside a lexically-enclosing `Term::Loop`, and the lambda's body + /// references a loop binder declared by that enclosing loop. Loop + /// binders are alloca-resident (codegen reuses the + /// `mut_var_allocas` registry, `std::mem::take`-emptied at the + /// lambda frame boundary), exactly the mut-var escape rule. Symmetric + /// to `MutVarCapturedByLambda`: capturing one into a closure would + /// require lifting it to the heap (deferred) or rejecting the + /// capture; this milestone takes the latter route rather than + /// panicking codegen on type-correct input. + #[error("loop binder `{name}` cannot be captured by a lambda — loop binders are alloca-resident and do not escape their enclosing loop. Either move the lambda outside the loop, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] + LoopBinderCapturedByLambda { name: String }, + /// 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")] @@ -774,6 +787,7 @@ impl CheckError { CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch", CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type", CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda", + CheckError::LoopBinderCapturedByLambda { .. } => "loop-binder-captured-by-lambda", CheckError::RecurOutsideLoop => "recur-outside-loop", CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch", CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch", @@ -852,6 +866,9 @@ impl CheckError { CheckError::MutVarCapturedByLambda { name } => { serde_json::json!({"name": name}) } + CheckError::LoopBinderCapturedByLambda { name } => { + serde_json::json!({"name": name}) + } CheckError::RecurArityMismatch { expected, got } => { serde_json::json!({"expected": expected, "actual": got}) } @@ -2006,7 +2023,7 @@ fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec) -> Result< // 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::new(); + let mut loop_stack: Vec> = Vec::new(); 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)?; unify(&ret_ty, &body_ty, &mut subst)?; // mq.3: surface synth-time warnings into the caller-supplied @@ -2893,7 +2910,7 @@ fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec) -> R let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: a const value has no enclosing loop; any // `recur` in a const body correctly fires `RecurOutsideLoop`. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let v = synth(&c.value, env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; out_warnings.extend(warnings); unify(&c.ty, &v, &mut subst)?; @@ -2919,15 +2936,18 @@ pub(crate) fn synth( // name through it. Position: locals-adjacent because both are // per-fn-body lexical scope state. mut_scope_stack: &mut Vec>, - // 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` 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>, + // loop-recur iter 2: per-walk loop-binder stack. Each frame is one + // `Term::Loop`'s binders in declaration order. `recur` rebinds + // binders by position, not name, so the type list is positional; + // iter loop-recur.tidy added the binder name alongside the type + // (so `Term::Lam`'s escape guard can reject a free var that is an + // in-scope loop binder, symmetric to `mut_scope_stack`). Pushed on + // `Term::Loop` body entry, popped on exit; consulted innermost-first + // by `Term::Recur` for arity + per-position type, and across all + // frames by `Term::Lam` for the escape check. Position: + // mut_scope_stack-adjacent — both are per-fn-body lexical-control + // scope state threaded identically. + loop_stack: &mut Vec>, effects: &mut BTreeSet, in_def: &str, subst: &mut Subst, @@ -3605,7 +3625,16 @@ pub(crate) fn synth( // `MutVarCapturedByLambda`. Reuses `desugar::free_vars_in_term` // (same walker `lift.rs` uses) which honours pattern bindings // inside `Term::Match` arms. - if !mut_scope_stack.is_empty() { + // Iter loop-recur.tidy: symmetrically reject + // lambda-captures-of-loop-binder. Loop binders are + // alloca-resident and `std::mem::take`-emptied at the + // lambda frame boundary in codegen, exactly like mut-vars + // — capturing one panics `unreachable!()` at codegen, so + // the type checker rejects it here instead. The free-var + // scan is shared with the mut-scope check below; gated on + // a non-empty enclosing scope so lambdas outside any + // mut/loop scope typecheck unchanged. + if !mut_scope_stack.is_empty() || !loop_stack.is_empty() { let mut lam_bound: BTreeSet = BTreeSet::new(); for p in params { lam_bound.insert(p.clone()); @@ -3620,6 +3649,13 @@ pub(crate) fn synth( }); } } + for frame in loop_stack.iter() { + if frame.iter().any(|(n, _)| n == free_name) { + return Err(CheckError::LoopBinderCapturedByLambda { + name: free_name.clone(), + }); + } + } } } @@ -3894,12 +3930,13 @@ pub(crate) fn synth( // 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). + // ordered binders (name + type) are pushed onto `loop_stack` + // for the body walk so an inner `Term::Recur` can + // position-check the types and `Term::Lam`'s escape guard + // (iter loop-recur.tidy) can reject capture of a binder name; + // popped on exit. Term::Loop { binders, body } => { - let mut binder_tys: Vec = Vec::new(); + let mut binder_frame: Vec<(String, Type)> = Vec::new(); let mut restore: Vec<(String, Option)> = Vec::new(); for b in binders { let init_ty = synth( @@ -3907,11 +3944,11 @@ pub(crate) fn synth( in_def, subst, counter, residuals, free_fn_calls, warnings, )?; unify(&b.ty, &init_ty, subst)?; - binder_tys.push(b.ty.clone()); + binder_frame.push((b.name.clone(), b.ty.clone())); let prev = locals.insert(b.name.clone(), b.ty.clone()); restore.push((b.name.clone(), prev)); } - loop_stack.push(binder_tys); + loop_stack.push(binder_frame); let body_result = synth( body, env, locals, mut_scope_stack, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings, @@ -3939,13 +3976,13 @@ pub(crate) fn synth( // through, so it unifies with whatever sibling branch the // enclosing if/match requires. Term::Recur { args } => { - let binder_tys = match loop_stack.last() { + let binder_frame = match loop_stack.last() { None => return Err(CheckError::RecurOutsideLoop), - Some(tys) => tys.clone(), + Some(frame) => frame.clone(), }; - if args.len() != binder_tys.len() { + if args.len() != binder_frame.len() { return Err(CheckError::RecurArityMismatch { - expected: binder_tys.len(), + expected: binder_frame.len(), got: args.len(), }); } @@ -3954,7 +3991,7 @@ pub(crate) fn 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_binder = subst.apply(&binder_frame[i].1); let applied_arg = subst.apply(&arg_ty); if applied_binder != applied_arg { return Err(CheckError::RecurTypeMismatch { @@ -4342,7 +4379,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; @@ -4383,7 +4420,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut frame: IndexMap = IndexMap::new(); frame.insert("x".into(), Type::float()); // inner mut-var mut_scope_stack.push(frame); @@ -4427,7 +4464,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut outer: IndexMap = IndexMap::new(); outer.insert("x".into(), Type::int()); mut_scope_stack.push(outer); @@ -4544,7 +4581,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; @@ -4587,7 +4624,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; @@ -4629,7 +4666,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; @@ -4684,6 +4721,17 @@ mod tests { let _ = e.ctx(); } + /// Iter loop-recur.tidy: `CheckError::LoopBinderCapturedByLambda` + /// carries the stable kebab code `loop-binder-captured-by-lambda` + /// and a non-panicking `ctx()` arm carrying the captured name — + /// symmetric to `mut_var_captured_by_lambda_diagnostic_has_kebab_code`. + #[test] + fn loop_binder_captured_by_lambda_diagnostic_has_kebab_code() { + let e = CheckError::LoopBinderCapturedByLambda { name: "acc".into() }; + assert_eq!(e.code(), "loop-binder-captured-by-lambda"); + let _ = e.ctx(); + } + /// loop-recur iter 2: the four `Recur*` `CheckError` variants carry /// the exact stable kebab codes the spec acceptance mandates /// (`recur-outside-loop` / `recur-arity-mismatch` / @@ -4760,7 +4808,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; @@ -4779,6 +4827,56 @@ mod tests { } } + /// Iter loop-recur.tidy: a lambda nested inside a `Term::Loop` + /// whose body references a loop binder declared by the enclosing + /// loop is rejected with `LoopBinderCapturedByLambda`. The + /// captured-name field reports the offending binder. Structurally + /// symmetric to + /// `lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda`. + #[test] + fn lambda_inside_loop_capturing_binder_emits_loop_binder_captured_by_lambda() { + // (loop (acc : Int = 0) + // (let f = (lam () : Int -> acc) in 0)) + let term = Term::Loop { + binders: vec![ailang_core::ast::LoopBinder { + name: "acc".into(), + ty: Type::int(), + init: Term::Lit { lit: Literal::Int { value: 0 } }, + }], + body: Box::new(Term::Let { + name: "f".into(), + value: Box::new(Term::Lam { + params: vec![], + param_tys: vec![], + ret_ty: Box::new(Type::int()), + effects: vec![], + body: Box::new(Term::Var { name: "acc".into() }), + }), + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }), + }; + let env = Env::default(); + let mut locals: IndexMap = IndexMap::new(); + let mut mut_scope_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); + let mut effects: BTreeSet = BTreeSet::new(); + let mut subst = Subst::default(); + let mut counter: u32 = 0; + let mut residuals: Vec = Vec::new(); + let mut free_fn_calls: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let err = synth( + &term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, + "", &mut subst, &mut counter, &mut residuals, + &mut free_fn_calls, &mut warnings, + ) + .expect_err("lambda capturing loop binder must be rejected"); + match err { + CheckError::LoopBinderCapturedByLambda { name } => assert_eq!(name, "acc"), + other => panic!("expected LoopBinderCapturedByLambda, got {other:?}"), + } + } + fn fn_def(name: &str, ty: Type, params: Vec<&str>, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), @@ -7502,7 +7600,7 @@ mod tests { let mut mut_scope_stack: Vec> = Vec::new(); // loop-recur iter 2: loop-free unit-test call site — fresh // empty loop-stack, mirroring the mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let mut effects: BTreeSet = BTreeSet::new(); let mut subst = Subst::default(); let mut counter: u32 = 0; diff --git a/crates/ailang-check/src/lift.rs b/crates/ailang-check/src/lift.rs index d2dbba3..90035c3 100644 --- a/crates/ailang-check/src/lift.rs +++ b/crates/ailang-check/src/lift.rs @@ -746,7 +746,7 @@ impl<'a> Lifter<'a> { // loop-recur iter 2: lift's letrec-capture synth re-entry walks // one term at a time from a top-of-body position; fresh empty // loop-stack is correct (any `Term::Loop` pushes its own frame). - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); let ty = synth(t, &self.env, locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?; Ok(subst.apply(&ty)) } diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index 7092055..f9a8ac4 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -720,7 +720,7 @@ pub fn collect_mono_targets( // loop-recur iter 2: mono re-synth begins from top-of-body — // empty loop-stack (any `Term::Loop` pushes its own frame as the // walk descends), mirroring the fresh mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); crate::synth( &f.body, &env, @@ -1366,7 +1366,7 @@ pub(crate) fn collect_residuals_ordered( // loop-recur iter 2: mono re-synth begins from top-of-body — // empty loop-stack (any `Term::Loop` pushes its own frame as the // walk descends), mirroring the fresh mut-scope above. - let mut loop_stack: Vec> = Vec::new(); + let mut loop_stack: Vec> = Vec::new(); crate::synth( &f.body, &env, diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 4d847af..3f47f8b 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -714,14 +714,20 @@ struct Emitter<'a> { /// dec'ing, because Implicit and Borrow do not carry the "caller /// handed off ownership" signal that makes the dec safe. current_param_modes: BTreeMap, - /// Iter mut.3: per-fn map of mut-var name → (alloca SSA name, - /// AIL element type). Populated when entering a `Term::Mut` - /// block; entries removed (or restored to their prior binding) - /// when leaving the block. Consulted by the `Term::Var` arm of - /// `lower_term` before `self.locals`, matching the typecheck- - /// side mut-scope-stack precedence (`synth` in `ailang-check` - /// walks `mut_scope_stack` innermost-first before falling - /// through to locals/globals). + /// Per-fn map of name → (alloca SSA name, AIL element type) for + /// the two alloca-resident binding classes. Populated on entry + /// to a `Term::Mut` block (iter mut.3 — mut-vars) AND on entry + /// to a `Term::Loop` (iter loop-recur.3 — loop binders ride the + /// same alloca representation; the `Term::Loop` arm inserts each + /// binder here and `recur` stores into the slot). Entries are + /// removed (or restored to their prior binding) on block exit. + /// Consulted by the `Term::Var` arm of `lower_term` before + /// `self.locals` — the single load path serving both classes. + /// Note: only the mut-var half mirrors a typecheck-side + /// `mut_scope_stack`; loop binders are tracked typecheck-side in + /// the ordinary `locals` plus `loop_stack` (positional), not in + /// `mut_scope_stack` — the shared codegen map is a + /// representation detail, not a scope-precedence claim. mut_var_allocas: BTreeMap, /// loop-recur iter 3: stack of enclosing `Term::Loop` codegen /// frames. Each frame is `(header_block_label, binder_slots)` diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 1567918..d51428b 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -548,12 +548,18 @@ pub enum Term { /// 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/2026-05-17-loop-recur.md`. + /// is part of the shape). Typecheck (loop-recur.2): `synth` + /// types each binder init, the loop's type is the body's type; + /// `recur` arity/type is checked positionally via `loop_stack` + /// and `verify_loop_body` enforces `recur`-in-tail-position; a + /// lambda capturing a binder is rejected + /// (`loop-binder-captured-by-lambda`, loop-recur.tidy). Codegen + /// (loop-recur.3): binders lower to entry-block allocas reached + /// from a `loop.header` block; `recur` stores + back-edges; the + /// loop-carried SSA / phi form is produced by `clang -O2` + /// mem2reg (NOT hand-emitted phi). No totality claim — an + /// infinite loop is legal. See + /// `docs/specs/2026-05-17-loop-recur.md`. Loop { binders: Vec, body: Box, diff --git a/crates/ailang-core/tests/carve_out_inventory.rs b/crates/ailang-core/tests/carve_out_inventory.rs index 29de783..8b011fb 100644 --- a/crates/ailang-core/tests/carve_out_inventory.rs +++ b/crates/ailang-core/tests/carve_out_inventory.rs @@ -3,7 +3,7 @@ //! under `examples/*.ail.json` after milestone close. The list is //! hardcoded; any change is a deliberate, brainstorm-level decision. //! -//! Seventeen carve-outs post iter loop-recur.2 (2026-05-17): +//! Eighteen carve-outs post iter loop-recur.tidy (2026-05-18): //! - §C4 (a) subject-matter: 7 fixtures from form-a.1 milestone //! close (canonical-form rejection / unbound / class-def rejection). //! - Iter mut.2: 5 negative typecheck fixtures for the new mut / @@ -23,6 +23,10 @@ //! recur-not-in-tail-position) — the diagnostic code is the //! load-bearing assertion, mirroring the §C4(a) / mut.2 //! negative-fixture convention. +//! - loop-recur iter loop-recur.tidy: 1 negative typecheck fixture +//! for the lambda-capture-of-loop-binder rejection +//! (loop-binder-captured-by-lambda) — symmetric to the mut.4-tidy +//! `test_mut_var_captured_by_lambda.ail.json` carve-out. use std::collections::BTreeSet; @@ -43,6 +47,8 @@ const EXPECTED: &[&str] = &[ "test_mut_var_unsupported_type.ail.json", // Iter mut.4-tidy — lambda-capture-of-mut-var rejection "test_mut_var_captured_by_lambda.ail.json", + // Iter loop-recur.tidy — lambda-capture-of-loop-binder rejection + "test_loop_binder_captured_by_lambda.ail.json", // loop-recur iter 2 — negative typecheck fixtures "test_recur_arity_mismatch.ail.json", "test_recur_not_in_tail_position.ail.json", diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ae32d8a..1736750 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2451,7 +2451,10 @@ them as entry-block allocas, and `Term::Assign` emits a `store` yielding the canonical Unit SSA. Mut-vars must be of one of the stack-resident scalar types `{Int, Float, Bool, Unit}`; capturing a mut-var into a lambda body is rejected at typecheck via -`CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). See +`CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). Loop binders +are alloca-resident under the same scheme, so capturing a `loop` +binder into a lambda body is rejected by the symmetric +`CheckError::LoopBinderCapturedByLambda` (iter loop-recur.tidy). See `docs/specs/2026-05-15-mut-local.md`. **`Literal`**: diff --git a/docs/journals/2026-05-17-iter-loop-recur.tidy.md b/docs/journals/2026-05-17-iter-loop-recur.tidy.md new file mode 100644 index 0000000..e827fcd --- /dev/null +++ b/docs/journals/2026-05-17-iter-loop-recur.tidy.md @@ -0,0 +1,133 @@ +# iter loop-recur.tidy — lambda capturing a loop binder must fail `ail check`, not panic codegen + +**Date:** 2026-05-18 +**Started from:** 39380d361d530a95a4f1c718c0e201f8312d3ac4 +**Status:** DONE +**Tasks completed:** 1 of 1 + +## Summary + +A `Term::Lam` whose body captured an enclosing `Term::Loop` binder +passed `ail check` (exit 0) and then panicked `unreachable!()` at +`crates/ailang-codegen/src/lambda.rs:102` — type-correct input +crashing the compiler. This iter adds the symmetric escape guard: +such a capture now fails `ail check` with the stable kebab code +`loop-binder-captured-by-lambda`, exactly as mut.4-tidy did for +`mut-var-captured-by-lambda`. The fix is the minimal mechanism that +reuses the scope structure the `Term::Loop` arm already maintains. + +## Per-task notes + +- iter loop-recur.tidy.1: GREEN side of the debug RED test + `lambda_capturing_loop_binder_emits_loop_binder_captured_by_lambda`. + Added `CheckError::LoopBinderCapturedByLambda { name }` + (bracket-free F2 Display), its `code()` arm + (`loop-binder-captured-by-lambda`), and its `ctx()` arm + (`{name}`, mirroring `MutVarCapturedByLambda`). Extended + `loop_stack`'s element type from `Vec` to + `Vec<(String, Type)>` so the existing per-loop scope frame also + carries binder names (the `Term::Recur` arm reads `.1` for the + positional type check; the `Term::Lam` escape guard reads `.0`). + The `Term::Lam` arm's existing free-var scan (shared + `free_vars_in_term` walker) gained a parallel pass over + `loop_stack` next to the `mut_scope_stack` pass; its gate widened + to `!mut_scope_stack.is_empty() || !loop_stack.is_empty()`. + Mirrored the two mut.4-tidy in-source unit tests + (kebab-code test + synth-level capturing test). Lockstep: + `carve_out_inventory.rs` 17→18 (header count word + new bullet + + EXPECTED entry), `ct1_check_cli.rs` mut-F2 sibling `cases` gained + the new fixture (where `mut-var-captured-by-lambda` is asserted — + the exact precedent). `docs/DESIGN.md` one-line note adjacent to + the `MutVarCapturedByLambda` mention. + +## Root cause + +The `Term::Lam` escape guard iterated only `mut_scope_stack` to +reject lambda captures of alloca-resident locals. Loop binders are +inserted into the ordinary `locals` by the `Term::Loop` arm and +never entered any escape-checked scope, so a lambda body referencing +a loop binder was accepted by `ail check`. Codegen's lambda capture +resolver searches only `self.locals` after `mut_var_allocas` is +`std::mem::take`-emptied at the lambda frame boundary, so the +loop-binder reference resolved to nothing and hit `unreachable!()`. +Loop binders are alloca-resident under the same scheme as mut-vars +and obey the same no-escape-into-lambda rule. + +## Design note — why extend `loop_stack` rather than add a parallel stack + +The carrier offered "reuse whatever scope structure the `Term::Loop` +arm already maintains" as an explicit minimal option. `loop_stack` +is already threaded through every `synth` recursive call and is +pushed/popped at exactly the loop scope boundary. Extending its +frame element from `Type` to `(String, Type)` preserves all existing +push/pop discipline and `Term::Recur` positional-type semantics +unchanged — it is not a redesign. The alternative (a parallel +`mut_scope_stack`-shaped parameter) would have touched all 35 +recursive `synth` call sites plus declarations, strictly larger. +Element-type extension is the minimal correct mechanism. + +## Concerns + +(none) + +## Known debt + +(none) + +## Files touched + +- `crates/ailang-check/src/lib.rs` — variant, code/ctx/Display, the + `Term::Loop` push + `Term::Recur` read + `Term::Lam` escape guard, + two in-source unit tests +- `crates/ailang-check/src/builtins.rs` — `loop_stack` decl type +- `crates/ailang-check/src/lift.rs` — `loop_stack` decl type +- `crates/ailang-check/src/mono.rs` — `loop_stack` decl type (x2) +- `crates/ailang-core/tests/carve_out_inventory.rs` — 17→18 lockstep +- `crates/ail/tests/ct1_check_cli.rs` — mut-F2 sibling `cases` +- `docs/DESIGN.md` — one-line symmetric-rule note + +## Milestone-close audit resolution (Boss-side) + +The loop/recur milestone-close `audit` (architect drift review + +bench gate) produced four items; this tidy commit resolves all: + +- **`[high]` lambda-captures-loop-binder codegen crash** — fixed by + this iter's GREEN (the symmetric `loop-binder-captured-by-lambda` + guard). RED test committed separately as the audit trail + (`39380d3`); GREEN + the items below fold into this commit. +- **`[medium]` `mut_var_allocas` rustdoc lied about dual use** — + `crates/ailang-codegen/src/lib.rs` rustdoc said "Populated when + entering a `Term::Mut` block" / "matching the typecheck-side + mut-scope-stack precedence"; as of iter-3 loop binders ride the + same map. Boss-edited to present-tense dual-use truth (mut-vars + AND loop binders; the shared map is a representation detail, not + a scope-precedence claim). Doc-honesty class. +- **`[medium]` `Term::Loop` doc-comment stale "per-binder phi"** — + `crates/ailang-core/src/ast.rs` still described iter-1's + forward-looking `synth`/`lower_term` `Internal` stubs + "per-binder + phi"; iters 2/3 shipped real typecheck + alloca+mem2reg lowering + (Boss-call-1 explicitly rejected hand-emitted phi). Boss-edited to + the real shipped state. Doc-honesty class. +- **`[low]` recon-undercount 3× pattern** — added a P2 `[todo]` to + `docs/roadmap.md` (`ailang-plan-recon` cross-crate-caller-undercount + countermeasure; pairs with the planner Step-5 items 7+8 added this + milestone — those scrub the plan, this scrubs the recon). Not a + milestone-blocking fix; tracked forward. +- **Bench gate (audit Step 2)** — `bench/check.py` / + `compile_check.py` / `cross_lang.py` all exit 0; 25 metrics, 0 + regressed, 25 stable. **Pristine** — exactly as the spec's + acceptance criteria expect for a strictly-additive surface no + hot-path bench exercises; the pre-existing P2 `*.bump_s` + hardware-staleness drift did not trip this run. **carry-on**: no + baseline update, no ratify entry (Iron Law — nothing intentionally + moved a metric). + +**Milestone-loop-recur tidy: resolved.** Architect drift cleared +(the `[high]` is fixed code, the two `[medium]` are doc-honest, the +`[low]` is roadmap-tracked); bench pristine carry-on. The loop/recur +milestone is audit-clean; the only remaining pipeline step before +full close is `fieldtest` (user-visible Form-A surface). + +## Stats + +bench/orchestrator-stats/2026-05-17-iter-loop-recur.tidy.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 081e970..386d227 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -84,3 +84,4 @@ - 2026-05-17 — iter loop-recur.1: standalone-`loop`/`recur` milestone (1 of 3) — the strictly-additive AST-node foundation. `Term::Loop { binders: Vec, body }` / `Term::Recur { args }` / `struct LoopBinder` (mirrors `MutVar`'s `(name,type,init)` triple; `binders` no `skip_serializing_if`) added end-to-end across all six crates' no-`_`-wildcard exhaustive `Term` matches (~30 sites incl. one recon-undercount integration-test pin): Form-A `parse_loop`/`parse_recur` (binder/body boundary disambiguated by an `is_term_head_kw` guard — the plan's flagged sole parser judgement) + print + `form_a.md` grammar/notes + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + DESIGN.md §Data-model `"t":"loop"`/`"t":"recur"` blocks + design_schema_drift/spec_drift/schema_coverage anchors + a new `loop_recur` hash pin (additivity proven: iter13a + new pin both green, pre-existing canonical-JSON bytes byte-stable) + `examples/loop_sum_to.ail` round-trip fixture. NO typecheck semantics + NO real codegen by design: the two semantic dispatch points stubbed `synth`→`CheckError::Internal` / `lower_term`→`CodegenError::Internal` exactly as mut.1; pure analysis walkers (escape/lambda/`synth_with_extras`) get real structural pass-through. Two binding Boss design calls (mirrored into the journal): (1) `verify_tail_positions` "byte-unchanged" = tail-app *role* unchanged not source-frozen — it is an exhaustive no-wildcard match so two additive descent-only arms are mandatory; acceptance evidence is the tail-app non-regression test (all `tail`-filtered tests byte-identical), mut.1 set the precedent; (2) codegen IS iter-1 scope as stubs/pass-throughs (no real loop-header/phi/back-edge — that is iter 3). Three plan-pseudo-vs-reality substitutions, all intent-preserving, recurring `feedback_specs_need_concrete_code`/`feedback_plan_pseudo_vs_reality` class: serde `Type::Con` literal `{"t":"con",…,"args":[]}`→real `{"k":"con","name":"Int"}`; bare `Int`→`(con Int)` in binder triples (bare `Int` parses as `Type::Var`); `ailang_core::ast::LoopBinder`→unqualified inside ailang-core. Boss forward-fix folded in: Concern 2 surfaced that the committed specs themselves (`2026-05-17-loop-recur.md` headline + `bad_recur`, `2026-05-17-llm-surface-discipline.md` §5) wrote bare `Int` — corrected all three snippets to `(con Int)` (doc-honesty, same class as the mono.rs header; no design/schema/assumption change, no re-brainstorm). `cargo test --workspace` 600→608 / 0 red (Boss-reran independently). Component 4 (typecheck) = iter 2; Component 5 (codegen) + positive/deep-`n` E2E = iter 3 → 2026-05-17-iter-loop-recur.1.md - 2026-05-17 — iter loop-recur.2: standalone-`loop`/`recur` milestone (2 of 3) — Component 4 typecheck semantics. The iter-1 `synth` `CheckError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real binder typing (each init synthed in outer scope + already-declared binder names of THIS loop via ordinary `locals` save/restore mirroring `Term::Let` verbatim; loop's static type = body type) + positional `recur` arity/per-arg-type checking via a new `loop_stack: &mut Vec>` frame threaded exactly as mut.2's `mut_scope_stack` (every recursive + cross-module + test synth caller, compile-swept). New private `verify_loop_body(t,in_loop_tail)` pass — a SIBLING of the byte-frozen `verify_tail_positions` (0 deletions, tail-app role untouched), invoked in `check_fn` after it so synth-raised codes take precedence by pass ordering. Four `Recur*` `CheckError` variants (`RecurOutsideLoop`, `RecurArityMismatch{expected,got}`, `RecurTypeMismatch{position,expected,got}`, `RecurNotInTailPosition`) + `code()` + 2 `ctx()` arms, bracket-`[code]`-free Display (F2); four negative `.ail.json` fixtures fire their codes point-exactly (`assert_eq!`, non-vacuous) via a new `loop_recur_typecheck_pin.rs` (7 tests) + a ct1 F2 human-mode sibling; `carve_out_inventory` 13→17 with the pre-existing mut.4-tidy "Twelve"-vs-13 stale-header drift corrected in passing. iter-1's `loop_sum_to.ail` round-trip fixture now ALSO typechecks clean (one fixture, two properties — no near-duplicate); `loop_forever.ail` (loop whose only path is `recur`) typechecks clean, asserting the spec "no termination claim is made or enforced". Three binding Boss design calls (mirrored into the journal): (1) `RecurTypeMismatch` is an Assign-style `subst.apply`-both-then-structural-`!=` pre-check, NOT a `unify`-propagate (a propagate would surface generic `type-mismatch` and fail the point-exact-code acceptance); (2) `loop_stack` element type is positional `Vec` NOT name-keyed `IndexMap` (recur rebinds by position; binder names live in `locals`); (3) diagnostic-code precedence is by pass ordering (synth before verify_loop_body), no explicit logic. NO codegen (iter-1 `lower_term` `CodegenError::Internal` stub stays — iter 3); NO `Diverge`/`verify_structural_recursion`/guardedness (spec deliberate boundary). Two DONE_WITH_CONCERNS, both Task 2, both journalled: (a) a plan-ordering defect — Task 2's `cargo build` 0-errors gate is unsatisfiable while `check_fn` (deferred by the plan to Task 4) is an unthreaded caller; orchestrator pulled the byte-identical declaration+threading forward to Task 2, left only the `verify_loop_body` invocation in Task 4 (no semantic change, only sequencing); (b) recon-undercount of cross-module synth callers (`builtins.rs`×2, `lift.rs:746`, `mono.rs:720`/`:1361`), the recurring mut.2-class, resolved via the plan's own designated compile-sweep oracle. Boss systemic fix folded into this commit: planner SKILL.md Step-5 self-review gains item 7 (compile-gate vs. deferred-caller ordering) so the Concern-(a) class is scrubbed at plan time, not discovered at execution. `cargo test --workspace` 608→616 / 0 red (Boss-reran independently); `verify_tail_positions`/tail-app byte-frozen confirmed by diff-hunk inspection. Component 5 (codegen loop-header/phi/back-edge) + the positive RUN-to-value / deep-`n` E2E = iteration 3 → 2026-05-17-iter-loop-recur.2.md - 2026-05-17 — iter loop-recur.3: standalone-`loop`/`recur` milestone (3 of 3, TERMINAL) — Component 5 codegen + run-to-value E2E. The iter-1 `lower_term` `CodegenError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real LLVM-IR lowering: loop binders are loop-carried values lowered as entry-block allocas (the mut.3 `pending_entry_allocas` mechanism) registered in the EXISTING `mut_var_allocas` map so the EXISTING `Term::Var` load path resolves them with zero new Var code (representation-sharing only — Var-lowering byte-unchanged); a fresh `loop.header.` block reached by an unconditional `br` from the pre-header; `recur` lowers ALL args to SSA BEFORE any store (simultaneous positional rebind), stores into the binder allocas, back-edges `br` to the header, and sets the SINGLE existing `block_terminated` field at its OWN new emit site (a parallel SET call-site) so the loop's exit value is the emergent product of the existing `if`/`match` join — no separate loop-result phi/exit-block. A new `loop_frames` codegen stack lets `recur` find its target header+slots; saved/reset/restored at the single lambda-lowering boundary exactly as mut.3's `mut_var_allocas` triple. `clang -O2` mem2reg promotes the binder allocas to the phi nodes the spec's secondary implementation-shape describes. Four binding Boss design calls (mirrored into the journal), all on architectural-consistency / spec-pinned-invariant grounds (NOT effort): (1) alloca+mem2reg NOT hand-emitted phi (the linear-emit architecture has no phi-with-body-discovered-back-edge-predecessor precedent; the `if` arm sidesteps by opening its join last — a loop header cannot be opened last; mem2reg yields identical optimized output; spec's "phi" is the explicitly-secondary impl-shape the spec subordinates to the planner's exact-bytes authority); (2) reuse `mut_var_allocas` + the existing `Term::Var` load path (byte-unchanged; representation-sharing, the iter-2 AST/typecheck binder distinction is post-typecheck-irrelevant to codegen); (3) emergent loop-exit via the existing if/match join once recur terminates its block, no separate result phi; (4) reuse the single `block_terminated` field, recur sets it at its own emit site, ZERO edits to any existing SET/READ site (a second field would force editing every READ site — the opposite of the spec-pinned "tail-app's use byte-unchanged"). Diff confirmed surgical by hunk-header inspection: codegen/lib.rs = exactly 3 hunks (field + init + the stub→2-arms replacement), codegen/lambda.rs = exactly 2 hunks (save + restore); NO hunk at any existing `block_terminated` SET/READ site, at tail-app/tail-do lowering, or at `verify_tail_positions`. Three new `.ail` fixtures: `loop_sum_to_run.ail`→`55` (run-to-value), `loop_sum_to_deep.ail`→`500000500000` (1e6 iterations via the back-edge, no stack growth — the spec clause-2 correctness claim made executable), `loop_forever_build.ail` (infinite loop, no non-recur exit, `ail build` exit 0 — "typechecks AND compiles, no termination claim"; never executed). Codegen-ONLY iteration: no schema/AST/serde/typecheck change; hash pins (`loop_recur` + iter13a) + drift trio + `carve_out_inventory` confirmed green UNTOUCHED (not modified); the 3 new `.ail` fixtures are round-trip-auto-covered, NOT carve-outs. One DONE_WITH_CONCERNS (T3): the plan's literal `cargo test --workspace tail` filter resolves to ZERO tests (real guards are `*_tail_position_*`/`*musttail*`); orchestrator ran the gate via real names + the authoritative full-suite 619/0 which subsumes it — recurring `feedback_plan_pseudo_vs_reality` class, no behaviour change. Boss systemic fix folded into this commit: planner SKILL.md Step-5 gains item 8 (verification-command filter strings must resolve to ≥1 named test, or use the unfiltered suite + a count assertion) — the SECOND planner-meta-gap this milestone surfaced (iter-2 added item 7). `cargo test --workspace` 616→619 / 0 red (Boss-reran independently); the 3 loop/recur e2e explicitly green (55 / 500000500000 / infinite-compiles). **All three components shipped — the loop/recur milestone is structurally complete; milestone-close (mandatory `audit`, then `fieldtest` since loop/recur is user-visible Form-A surface) is the Boss's next pipeline step.** → 2026-05-17-iter-loop-recur.3.md +- 2026-05-18 — iter loop-recur.tidy: milestone-close audit resolution (RED `39380d3` + this GREEN). Architect drift review found a `[high]` correctness defect: a `(lam ...)` body capturing an enclosing `Term::Loop` binder passed `ail check` (exit 0) then panicked `unreachable!()` at `crates/ailang-codegen/src/lambda.rs:102` on type-correct input — a DESIGN.md "Robustness against hallucinations" violation. Root cause (debugger-confirmed RED-first): the `Term::Lam` escape guard at `crates/ailang-check/src/lib.rs:3608` iterated only `mut_scope_stack`; loop binders live in the ordinary `locals` (iter-2 design) and never entered any escape-checked scope, so the mut.4-tidy `MutVarCapturedByLambda`-class rejection had no loop-binder counterpart. Fix (GREEN, implement mini-mode): new `CheckError::LoopBinderCapturedByLambda { name }` (bracket-`[code]`-free F2 Display) + `code()` `loop-binder-captured-by-lambda` + `ctx() {name}`, symmetric to `MutVarCapturedByLambda`; the `Term::Lam` escape guard gained a parallel pass over `loop_stack` (gate widened to `!mut_scope_stack.is_empty() || !loop_stack.is_empty()`). Minimal-mechanism design call: `loop_stack`'s frame element extended `Vec` → `Vec<(String, Type)>` so the already-threaded per-loop scope frame also carries binder names (`Term::Recur` still reads `.1` BY POSITION — Boss-call-2's positional-recur invariant preserved verbatim, the name is a second field consumed only by the escape guard, a consumer iter-2 did not anticipate; the parallel-stack alternative would touch all 35 `synth` call sites, strictly larger). Codegen byte-unchanged (typecheck-only fix; the lambda.rs:102 `unreachable!()` is now genuinely unreachable, kept as a defensive assertion). Two mut.4-tidy-mirrored in-source unit tests; lockstep `carve_out_inventory` 17→18 + `ct1_check_cli` mut-F2 sibling `cases`; DESIGN.md one-line symmetric-rule note. Boss folded into this GREEN commit: the two `[medium]` doc-honesty edits the audit also surfaced (`mut_var_allocas` rustdoc now states its mut-var+loop-binder dual use truthfully; `Term::Loop` doc-comment now describes the real shipped iter-2/3 typecheck+alloca+mem2reg state, not iter-1's stale "per-binder phi" stub forward-look) + a `[low]` P2 roadmap `[todo]` (the recon-undercount 3×-pattern → an `ailang-plan-recon` agent-definition countermeasure; pairs with planner Step-5 items 7+8). Bench gate (audit Step 2): `check.py`/`compile_check.py`/`cross_lang.py` all exit 0, 25 metrics 0 regressed — **pristine** (strictly-additive surface, no hot-path; the pre-existing P2 `*.bump_s` hardware-staleness drift did not trip) → carry-on, no baseline/ratify. `cargo test --workspace` 619→622 / 0 red (Boss-reran independently incl. hash_pin 11/0 — the ast.rs/codegen doc-comment edits moved no canonical-JSON hash). **The loop/recur milestone is audit-clean** — architect drift cleared, bench pristine; the only remaining pipeline step before full close is `fieldtest` (loop/recur is user-visible Form-A surface) → 2026-05-17-iter-loop-recur.tidy.md diff --git a/docs/roadmap.md b/docs/roadmap.md index d936669..61355e6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,12 +36,16 @@ work progresses. ## P0 — In flight - [~] **\[milestone\]** Standalone `loop` / `recur` — strictly-additive - iteration surface lowering to the existing `tail-app` back-edge, with - one closed rule (`recur` valid only in tail position of its enclosing - `loop`). De-bundled re-attempt of the reverted Iteration-discipline - it.1 core only: no totality claim, no guardedness pass, no `Diverge`, - `tail-app` byte-unchanged. Spec written + grounding-checked + user- - approved 2026-05-17 (97c1ed1). + iteration surface, `recur` valid only in tail position. All three + iterations + a tidy shipped: iter 1 `a179ec3` (additive AST nodes), + iter 2 `1566ce0` (typecheck), iter 3 `edd2558` (codegen + run-to-value + E2E), tidy (lambda-captures-loop-binder rejected at check, symmetric + to mut.4-tidy). Milestone-close `audit` clean: architect drift + resolved (the `[high]` codegen crash fixed in the tidy + two + `[medium]` doc-honesty edits folded in), bench pristine (25 metrics, + 0 regressed — strictly-additive surface, no hot-path touched). + **Remaining: `fieldtest`** (loop/recur is user-visible Form-A + surface) — the only step before full close. - context: `docs/specs/2026-05-17-loop-recur.md`; principles entry `docs/specs/2026-05-17-llm-surface-discipline.md` §6.2; revert rationale `docs/specs/2026-05-16-iteration-discipline-revert.md`. @@ -254,6 +258,23 @@ work progresses. intra-crate links). All predate the design-md-consolidation milestone; treat as a one-off sweep. - context: JOURNAL 2026-05-10 ("Audit close: design-md-consolidation"). +- [ ] **\[todo\]** `ailang-plan-recon` cross-crate-caller-undercount + countermeasure — the recon agent hand-lists exhaustive-`match`/caller + sites and has under-counted the true blast radius **three times in + one milestone** (loop-recur.1 walker arms, loop-recur.2 cross-module + `synth` callers, loop-recur.tidy implicit). Each was caught only by + the implement orchestrator's compile-driven sweep, not by the plan. + The pattern is structural: a hand-enumerated site list for a + workspace-wide signature/exhaustive-match change is inherently + lossy. Tighten `skills/planner/agents/ailang-plan-recon.md` so that + for any signature-change / additive-enum-variant scope the recon + REPORTS the compile-driven enumeration command as the authoritative + site set (and frames its own hand-list as advisory), rather than + presenting the hand-list as complete. No language change; an + agent-definition discipline fix. + - context: loop/recur milestone-close audit (architect `[low]`, + 2026-05-18); pairs with the planner Step-5 items 7+8 added the + same milestone (those scrub the *plan*; this scrubs the *recon*). - [ ] **\[todo\]** `design_schema_drift.rs` fidelity widening — current test checks anchor *presence* anywhere in DESIGN.md; the audit found that `[high]` schema gaps in §"Data model"