2ee97943bd
GREEN side of the RED audit-trail commit 39380d3. The loop/recur
milestone-close audit found a [high] correctness defect — a lambda
capturing a loop binder passed `ail check` then panicked
unreachable!() at codegen (crates/ailang-codegen/src/lambda.rs:102)
on type-correct input. Fixed symmetrically to mut.4-tidy: new
CheckError::LoopBinderCapturedByLambda (code
loop-binder-captured-by-lambda, bracket-free F2 Display), the
Term::Lam escape guard gained a parallel loop_stack pass. Minimal
mechanism: loop_stack element Vec<Type> -> Vec<(String,Type)> so
the already-threaded per-loop frame carries binder names; recur
still reads .1 by position (Boss-call-2 positional invariant
preserved verbatim — the name is a second field for the escape
guard only, a consumer iter-2 did not anticipate). Codegen
byte-unchanged (typecheck-only fix). carve_out 17->18 + ct1-F2 +
DESIGN.md note lockstep.
Folded in (Boss-side, audit resolution): the two [medium]
doc-honesty edits the audit surfaced (mut_var_allocas rustdoc now
states its mut-var+loop-binder dual use; Term::Loop doc-comment
now describes the real shipped state, not iter-1's stale
"per-binder phi" forward-look) + a [low] P2 roadmap todo
(plan-recon undercount countermeasure, pairs with planner Step-5
items 7+8). Bench gate: all 3 scripts exit 0, 25 metrics 0
regressed — pristine, carry-on (no baseline/ratify).
cargo test --workspace 619 -> 622 / 0 red (Boss-reran
independently, hash_pin 11/0). The loop/recur milestone is
audit-clean; fieldtest is the only remaining step before close.
134 lines
6.3 KiB
Markdown
134 lines
6.3 KiB
Markdown
# 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<Type>` 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
|