Skip to main content

lex_bytecode/
escape.rs

1//! Escape analysis for stack-allocatable aggregate sites —
2//! `Op::MakeRecord` and `Op::MakeTuple` (#464 + tuple widening).
3//!
4//! Walks every function's bytecode to classify each aggregate
5//! allocation site as either **stack-allocatable** (the value never
6//! leaves the function frame) or **escapes** (used as a closure
7//! capture, returned, stored in another aggregate, passed to a call,
8//! sent on a channel, etc.). The escape rules are identical for
9//! records and tuples — only the eventual codegen opcode differs —
10//! so a single `Slot::Agg(pc)` lattice value tracks both.
11//!
12//! ## Status
13//!
14//! - **Records** (`MakeRecord`): analysis + codegen complete (#464).
15//!   `compiler::apply_escape_lowering` rewrites non-escaping
16//!   `MakeRecord` to `AllocStackRecord`.
17//! - **Tuples** (`MakeTuple`): analysis only, this slice. Sites are
18//!   classified and reported with `SiteKind::Tuple`, but no codegen
19//!   consumes them yet — `apply_escape_lowering` only rewrites
20//!   `MakeRecord`, so reporting tuple sites is inert until a tuple
21//!   stack-alloc opcode lands. Mirrors how #464 sequenced records
22//!   (analysis → codegen → bench).
23//!
24//! ## Approach
25//!
26//! Abstract interpretation over the bytecode CFG. Each abstract
27//! state tracks:
28//! - per-stack-slot: `Slot::Agg(pc)` (the aggregate allocated at
29//!   `pc`, still local) or `Slot::Other` (anything else — int,
30//!   string, captured value, aggregate we've stopped tracking)
31//! - per-local: same `Slot` lattice, indexed by `locals[i]`
32//!
33//! At each op we update the abstract state and union any newly-
34//! observed escapes into a `HashSet<u32>` keyed by allocation pc.
35//! Worklist fixpoint iterates until no state changes — joins use a
36//! pointwise merge that downgrades `Agg(a) ⊔ Agg(b)` (a ≠ b) and
37//! `Agg(a) ⊔ Other` to `Other`, marking the involved sites as
38//! escaped (we can no longer prove they stay local from this
39//! merge point forward).
40//!
41//! ## Intra-procedural limit
42//!
43//! Calls (`Call`, `TailCall`, `CallClosure`) escape all argument
44//! aggregates — we can't see the callee's body from here. Inlining
45//! could recover the cross-fn case but is deliberately out of
46//! scope; the issue's wording ("function frame") matches
47//! intra-procedural.
48//!
49//! ## Conservative defaults
50//!
51//! Whenever the analysis can't prove an aggregate stays local, it
52//! defaults to *escaped*. False positives (sites flagged as
53//! escaping when they actually don't) cost a heap allocation per
54//! request — the existing baseline. False negatives (a flagged-
55//! local site that actually escapes) would corrupt memory under
56//! stack-alloc codegen, so the codegen step treats the analysis
57//! output as a *necessary* but not sufficient precondition and
58//! pairs it with an unconditional runtime fallback.
59
60use std::collections::{HashMap, HashSet};
61
62use crate::op::Op;
63use crate::program::Function;
64
65/// Scope policy for the escape analysis. Only `Op::Return` consults
66/// it — every other escape rule is identical across policies.
67///
68/// - `FrameScope` (default for #464 stack-record lowering): `Return`
69///   leaks its operand, because the returned value crosses the
70///   current function's frame into the caller.
71/// - `RequestScope` (for #463 arena routing): `Return` does NOT leak
72///   its operand — the value goes to the caller's stack and the
73///   caller is in the same request scope opened by
74///   `EffectHandler::enter_request_scope`. What the caller does with
75///   the returned value is an inter-procedural question, deliberately
76///   left to the caller's own conservative `Call` arm (which marks
77///   its args as escaping in the intra-procedural first cut). See
78///   `docs/design/arena-plumbing.md`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Policy {
81    FrameScope,
82    RequestScope,
83}
84
85/// Abstract value at a stack or local slot during the analysis.
86///
87/// Per-path refinement (#463 follow-up — precision pass): a slot can
88/// hold a **set** of allocation sites, not just one, because two
89/// branches of an `if`/`match` can each push a distinct `MakeRecord`
90/// / `MakeTuple` value onto the stack and the join point's "either-
91/// or" semantics must be modeled without losing tracking. The
92/// 3-variant shape keeps the singleton case zero-alloc — `Slot::Agg`
93/// is plain 8 bytes inline — and only branch-merges allocate.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub enum Slot {
96    /// Holds a single tracked aggregate (a record from
97    /// `Op::MakeRecord` or a tuple from `Op::MakeTuple`) allocated at
98    /// this pc. As long as every consumer reads this slot via a field
99    /// / element read (`GetField` / `GetElem`), `Pop`, or a
100    /// `StoreLocal`/`LoadLocal` round-trip, the site stays a
101    /// stack-alloc candidate. The escape rules are identical for both
102    /// aggregate kinds — only the codegen opcode differs — so the
103    /// analysis tracks them with one variant keyed on the alloc pc.
104    Agg(u32),
105    /// Holds one of N tracked aggregates, depending on which branch
106    /// reached this program point. Produced at join points where
107    /// distinct `Agg(p)` and `Agg(q)` flow together; the set remains
108    /// tracked across the merge so the eventual consumer (e.g.
109    /// `Return` under `Policy::RequestScope`) decides whether any
110    /// site leaks. Invariant: vec is sorted, dedup'd, length ≥ 2 —
111    /// `Slot::from_sites` normalizes single-element / empty inputs.
112    AggSet(Vec<u32>),
113    /// Anything else — primitives, already-escaped aggregates,
114    /// values produced by ops we don't model precisely. Treated
115    /// as not-a-tracked-aggregate for escape purposes.
116    Other,
117}
118
119impl Slot {
120    /// View this slot as a slice of tracked sites. Empty for
121    /// `Other`, singleton for `Agg`, the inner vec for `AggSet`.
122    /// Lets callers iterate sites uniformly across the three
123    /// variants — used by every consumer that needs to leak all
124    /// sites a slot might hold.
125    fn sites(&self) -> &[u32] {
126        match self {
127            Slot::Agg(p) => std::slice::from_ref(p),
128            Slot::AggSet(v) => v.as_slice(),
129            Slot::Other => &[],
130        }
131    }
132
133    /// Construct a slot from an arbitrary list of site pcs.
134    /// Normalizes: sorts + dedups + collapses empty → `Other`,
135    /// singleton → `Agg`. Used by `merge` and any other code that
136    /// produces a set-like slot.
137    fn from_sites(mut sites: Vec<u32>) -> Slot {
138        sites.sort_unstable();
139        sites.dedup();
140        match sites.len() {
141            0 => Slot::Other,
142            1 => Slot::Agg(sites[0]),
143            _ => Slot::AggSet(sites),
144        }
145    }
146
147    /// Pointwise merge for join points. Unions the site sets — both
148    /// sides stay tracked across the join, contra the pre-refinement
149    /// behavior of collapsing to `Other` (which also flagged both
150    /// sides as escaping). Under `Policy::RequestScope` this is the
151    /// whole point: a `match`-arm return like `match cond { true =>
152    /// {x:1}, false => {x:2} }` now produces an `AggSet([p, q])` at
153    /// the merge, which `Op::Return` passes through without leaking
154    /// (request-scope) instead of being forced into `Other` (escape).
155    fn merge(self, other: Slot) -> Slot {
156        if self == other { return self; }
157        let mut combined: Vec<u32> = Vec::with_capacity(
158            self.sites().len() + other.sites().len());
159        combined.extend_from_slice(self.sites());
160        combined.extend_from_slice(other.sites());
161        Slot::from_sites(combined)
162    }
163}
164
165/// Abstract state at one program point: the value stack from
166/// bottom up, plus a flat local-variable map.
167#[derive(Debug, Clone, PartialEq, Eq)]
168struct State {
169    stack: Vec<Slot>,
170    locals: Vec<Slot>,
171}
172
173impl State {
174    fn entry(locals_count: usize, arity: usize) -> Self {
175        // Function parameters land in the first `arity` locals;
176        // they're potentially-escaped values handed in by the
177        // caller, but the *sites* that produced them live in the
178        // caller's frame and aren't our concern. Treat as Other.
179        Self {
180            stack: Vec::new(),
181            locals: vec![Slot::Other; locals_count.max(arity)],
182        }
183    }
184
185    /// Merge `other` into `self`. Returns `(merged_state, escaped_sites)`.
186    ///
187    /// Per-path refinement: the merge itself **does not record any
188    /// escapes**. `Slot::merge` keeps both sides' sites tracked
189    /// across the join (`AggSet([p, q])` instead of `Other`), so the
190    /// downstream consumer ops decide whether any leak. The pre-
191    /// refinement collapse-to-Other behavior was the conservative
192    /// case the doc had labeled future work; this is that work.
193    ///
194    /// The one remaining escape source here is the dropped-tail case
195    /// for mismatched stack depths. That's a verifier-level bug
196    /// (#347 already checks shape-consistent merges), not the join-
197    /// point trap — those sites really are unreachable from the
198    /// merged state, so flagging them as escaped stays conservative-
199    /// correct.
200    fn merge_with(&self, other: &State) -> (State, HashSet<u32>) {
201        let mut escaped = HashSet::new();
202        let stack_len = self.stack.len().min(other.stack.len());
203        let mut stack = Vec::with_capacity(stack_len);
204        for i in 0..stack_len {
205            stack.push(self.stack[i].clone().merge(other.stack[i].clone()));
206        }
207        for tail in self.stack.iter().skip(stack_len).chain(other.stack.iter().skip(stack_len)) {
208            for &p in tail.sites() { escaped.insert(p); }
209        }
210        let local_len = self.locals.len().max(other.locals.len());
211        let mut locals = Vec::with_capacity(local_len);
212        for i in 0..local_len {
213            let a = self.locals.get(i).cloned().unwrap_or(Slot::Other);
214            let b = other.locals.get(i).cloned().unwrap_or(Slot::Other);
215            locals.push(a.merge(b));
216        }
217        (State { stack, locals }, escaped)
218    }
219}
220
221/// Per-function escape report — the artifact codegen consumes to
222/// decide where to emit a stack-alloc opcode vs the heap constructor.
223///
224/// Each entry is keyed by the allocation pc (the `Op::MakeRecord` or
225/// `Op::MakeTuple` site's index in the function's `code` vec) and
226/// tagged with its `SiteKind`. `escapes = false` means: across every
227/// reachable path through the function, the aggregate allocated here
228/// is only ever read locally (`GetField` / `GetElem`, dropped via
229/// `Pop`, round-tripped through locals) — never returned, captured,
230/// stored in another aggregate, or passed to a call.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct EscapeReport {
233    pub fn_name: String,
234    /// One entry per stack-allocatable aggregate (`MakeRecord` /
235    /// `MakeTuple`) site in the function, in pc order.
236    pub sites: Vec<EscapeSite>,
237}
238
239/// Which aggregate constructor an escape site is — determines which
240/// stack-alloc opcode a future codegen slice would emit for it
241/// (`AllocStackRecord` for records; tuple stack-alloc is not yet
242/// implemented, so `Tuple` sites are reported but not lowered).
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum SiteKind {
245    /// `Op::MakeRecord`: `shape_idx` is meaningful, `field_count` is
246    /// the record's field count.
247    Record,
248    /// `Op::MakeTuple`: `shape_idx` is unused (`0`), `field_count` is
249    /// the tuple arity.
250    Tuple,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub struct EscapeSite {
255    pub pc: u32,
256    pub kind: SiteKind,
257    pub shape_idx: u32,
258    pub field_count: u16,
259    pub escapes: bool,
260}
261
262/// Analyze every function in `functions`. Returns one
263/// `EscapeReport` per function that contains at least one
264/// `MakeRecord` site (functions with no record allocations are
265/// omitted — the consumer doesn't care about them).
266pub fn analyze_program(functions: &[Function]) -> Vec<EscapeReport> {
267    functions
268        .iter()
269        .filter_map(|f| {
270            let r = analyze_function(f);
271            (!r.sites.is_empty()).then_some(r)
272        })
273        .collect()
274}
275
276/// Analyze one function under the default `Policy::FrameScope`.
277/// See `analyze_function_with_policy` for the policy-parameterized
278/// form used by #463 arena routing.
279pub fn analyze_function(func: &Function) -> EscapeReport {
280    analyze_function_with_policy(func, Policy::FrameScope)
281}
282
283/// Analyze one function under the given scope policy. Cheap to call
284/// even when there are no aggregate sites (early-exits after the
285/// first pass over `code`).
286pub fn analyze_function_with_policy(func: &Function, policy: Policy) -> EscapeReport {
287    // Inventory the MakeRecord sites first. If there are none,
288    // skip the whole fixpoint — the function can't benefit from
289    // stack allocation anyway.
290    let sites: Vec<(u32, SiteKind, u32, u16)> = func
291        .code
292        .iter()
293        .enumerate()
294        .filter_map(|(pc, op)| match op {
295            Op::MakeRecord { shape_idx, field_count } => {
296                Some((pc as u32, SiteKind::Record, *shape_idx, *field_count))
297            }
298            Op::MakeTuple(arity) => {
299                Some((pc as u32, SiteKind::Tuple, 0, *arity))
300            }
301            _ => None,
302        })
303        .collect();
304    if sites.is_empty() {
305        return EscapeReport { fn_name: func.name.clone(), sites: vec![] };
306    }
307
308    let n = func.code.len();
309    let locals_count = func.locals_count as usize;
310    let arity = func.arity as usize;
311
312    // Per-pc in-states, computed by the fixpoint. None = unvisited.
313    let mut in_state: Vec<Option<State>> = vec![None; n];
314    let mut escaped: HashSet<u32> = HashSet::new();
315    // Containment map for deep-leaf widening: at each tracked-
316    // aggregate build site (MakeRecord / MakeTuple / AllocStack* /
317    // AllocArena*), the set of child sites whose values were popped
318    // as fields/elements. Used to transitively escape children when
319    // a parent escapes, instead of pessimistically leaking children
320    // at the build site. Accumulates monotonically across worklist
321    // iterations — sites can only be added.
322    let mut containment: HashMap<u32, HashSet<u32>> = HashMap::new();
323
324    let mut worklist: Vec<(usize, State)> = vec![(0, State::entry(locals_count, arity))];
325
326    while let Some((pc, incoming)) = worklist.pop() {
327        if pc >= n { continue; }
328
329        // Merge into existing in-state; only enqueue successors
330        // when something actually changed (fixpoint termination).
331        let (merged, new_escapes) = match &in_state[pc] {
332            Some(prev) => {
333                let (m, e) = prev.merge_with(&incoming);
334                if &m == prev && e.is_empty() {
335                    continue;
336                }
337                (m, e)
338            }
339            None => (incoming, HashSet::new()),
340        };
341        escaped.extend(new_escapes);
342        in_state[pc] = Some(merged.clone());
343
344        // Step the op, get the out-state + any successors.
345        let (out, succs, leaked) = step(pc, &func.code[pc], merged, policy, &mut containment);
346        escaped.extend(leaked);
347        for s in succs {
348            if s < n {
349                worklist.push((s, out.clone()));
350            }
351        }
352    }
353
354    // Deep-leaf widening: every escaped parent transitively escapes
355    // all sites the containment map records as living inside it. Run
356    // to fixpoint over the accumulated containment so chains
357    // (`a` in `b` in `c`, c escapes → b, a) propagate correctly.
358    let mut changed = true;
359    while changed {
360        changed = false;
361        let snapshot: Vec<u32> = escaped.iter().copied().collect();
362        for p in snapshot {
363            if let Some(contained) = containment.get(&p) {
364                for &c in contained {
365                    if escaped.insert(c) { changed = true; }
366                }
367            }
368        }
369    }
370
371    let report_sites = sites
372        .into_iter()
373        .map(|(pc, kind, shape_idx, field_count)| EscapeSite {
374            pc,
375            kind,
376            shape_idx,
377            field_count,
378            escapes: escaped.contains(&pc),
379        })
380        .collect();
381    EscapeReport { fn_name: func.name.clone(), sites: report_sites }
382}
383
384/// Apply one op to the abstract state. Returns the new state, the
385/// list of successor pcs (with their starting state being the
386/// returned state, except for control-flow ops where successors
387/// share the *same* state), and any sites that escaped during the
388/// step.
389fn step(
390    pc: usize,
391    op: &Op,
392    mut s: State,
393    policy: Policy,
394    containment: &mut HashMap<u32, HashSet<u32>>,
395) -> (State, Vec<usize>, HashSet<u32>) {
396    let mut escapes: HashSet<u32> = HashSet::new();
397
398    // Deep-leaf widening helper: pop `n` slots, but instead of
399    // leaking their sites immediately (as `pop_n_leak` does for
400    // genuine escape ops), record them as contained in the parent
401    // pc. The parent's later fate determines whether the children
402    // escape — transitive expansion at fixpoint exit propagates.
403    let pop_n_contain = |state: &mut State,
404                         n: usize,
405                         parent: u32,
406                         containment: &mut HashMap<u32, HashSet<u32>>| {
407        let entry = containment.entry(parent).or_default();
408        for _ in 0..n {
409            if let Some(top) = state.stack.pop() {
410                for &child in top.sites() {
411                    // Skip self-reference defensively (a parent
412                    // shouldn't appear in its own field operands;
413                    // if it ever does, a self-loop in containment
414                    // would still be sound but pointless).
415                    if child != parent { entry.insert(child); }
416                }
417            }
418        }
419    };
420
421    // Helper closures for the common pop-n / push patterns. `leak`
422    // iterates `slot.sites()` so multi-site slots (`AggSet`) leak
423    // every member — same conservative escape behavior as before for
424    // ops that genuinely escape, but now correctly handles the
425    // post-merge sets that the per-path lattice produces.
426    let leak = |slot: &Slot, into: &mut HashSet<u32>| {
427        for &p in slot.sites() { into.insert(p); }
428    };
429    let pop_n_leak = |state: &mut State, n: usize, esc: &mut HashSet<u32>| {
430        for _ in 0..n {
431            if let Some(top) = state.stack.pop() { leak(&top, esc); }
432        }
433    };
434    let pop_n_drop = |state: &mut State, n: usize| {
435        for _ in 0..n { state.stack.pop(); }
436    };
437
438    match op {
439        Op::PushConst(_) => { s.stack.push(Slot::Other); }
440        Op::Pop => { s.stack.pop(); /* drop — no escape on plain pop */ }
441        Op::Dup => {
442            // Aliasing breaks our linear-flow tracking. Anything
443            // duplicated escapes — both copies become Other.
444            if let Some(top) = s.stack.pop() {
445                leak(&top, &mut escapes);
446                s.stack.push(Slot::Other);
447                s.stack.push(Slot::Other);
448            }
449        }
450
451        Op::LoadLocal(i) => {
452            let slot = s.locals.get(*i as usize).cloned().unwrap_or(Slot::Other);
453            s.stack.push(slot);
454        }
455        Op::StoreLocal(i) => {
456            if let Some(top) = s.stack.pop() {
457                let i = *i as usize;
458                if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
459                // Round-tripping an aggregate through a local keeps it
460                // tracked. Overwriting a local that held a *different*
461                // tracked aggregate does NOT make the old one escape:
462                // Lex `let` bindings are immutable, so the compiler
463                // only reuses a slot once its previous occupant is out
464                // of scope (dead). Any real escape of that occupant —
465                // returned, passed to a call, stored into another
466                // aggregate — flows through a `Load → {Return, Call,
467                // MakeRecord/MakeTuple/...}` chain those ops already
468                // leak. Flagging it here was a pure false-positive
469                // that defeated stack-alloc for the common
470                // destructure-then-bind pattern (`compile_match`
471                // rewinds its `__scrut`/`__tuple` temp slots, so the
472                // enclosing `let` reuses them — see #464 tuple codegen).
473                s.locals[i] = top;
474            }
475        }
476
477        // Allocation site. Deep-leaf widening (#463 follow-up):
478        // record child sites as **contained in** this parent
479        // instead of leaking them at the build site. The parent's
480        // eventual fate (escape via Call/etc., or stay local)
481        // decides what happens to the children — caught at the
482        // post-fixpoint transitive-expansion pass.
483        Op::MakeRecord { field_count, .. } => {
484            pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
485            s.stack.push(Slot::Agg(pc as u32));
486        }
487        // #464 step 2: post-lowering form of MakeRecord. Same deep-
488        // leaf containment story so re-running the analysis on
489        // already-lowered code produces the same shape.
490        Op::AllocStackRecord { field_count, .. } => {
491            pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
492            s.stack.push(Slot::Agg(pc as u32));
493        }
494        // #463 slice 2a: post-lowering form of MakeRecord for the
495        // arena path. Same containment treatment.
496        Op::AllocArenaRecord { field_count, .. } => {
497            pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
498            s.stack.push(Slot::Agg(pc as u32));
499        }
500        // Tuple build sites: identical deep-leaf treatment to
501        // records. `GetElem` reads an element without escaping the
502        // tuple, exactly as `GetField` does for records.
503        Op::MakeTuple(n) => {
504            pop_n_contain(&mut s, *n as usize, pc as u32, containment);
505            s.stack.push(Slot::Agg(pc as u32));
506        }
507        // Post-lowering form of MakeTuple — same containment.
508        Op::AllocStackTuple { arity } => {
509            pop_n_contain(&mut s, *arity as usize, pc as u32, containment);
510            s.stack.push(Slot::Agg(pc as u32));
511        }
512        Op::AllocArenaTuple { arity } => {
513            pop_n_contain(&mut s, *arity as usize, pc as u32, containment);
514            s.stack.push(Slot::Agg(pc as u32));
515        }
516        // Lists and variants remain escape sinks for any tracked
517        // aggregate operand and don't create new tracked candidates —
518        // list stack-allocation needs variable-length arena handling
519        // (a later slice), and variants aren't a stack-alloc target.
520        Op::MakeList(n) => {
521            pop_n_leak(&mut s, *n as usize, &mut escapes);
522            s.stack.push(Slot::Other);
523        }
524        Op::MakeVariant { arity, .. } => {
525            pop_n_leak(&mut s, *arity as usize, &mut escapes);
526            s.stack.push(Slot::Other);
527        }
528        Op::MakeClosure { capture_count, .. } => {
529            pop_n_leak(&mut s, *capture_count as usize, &mut escapes);
530            s.stack.push(Slot::Other);
531        }
532
533        // Field / element reads — receiver is consumed but only to
534        // read a field. Doesn't escape; the receiver becomes
535        // unreferenced after the op.
536        Op::GetField { .. } => { s.stack.pop(); s.stack.push(Slot::Other); }
537        Op::GetElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
538        Op::TestVariant(_) => { /* peek-only */ s.stack.pop(); s.stack.push(Slot::Other); }
539        Op::GetVariant(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
540        Op::GetVariantArg(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
541        Op::GetListLen => { s.stack.pop(); s.stack.push(Slot::Other); }
542        Op::GetListElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
543        Op::GetListElemDyn => {
544            // pop [list, idx] → push elem
545            s.stack.pop(); s.stack.pop(); s.stack.push(Slot::Other);
546        }
547        Op::ListAppend => {
548            // pop [list, value]; value is now inside the list → escape
549            if let Some(value) = s.stack.pop() { leak(&value, &mut escapes); }
550            s.stack.pop(); // list itself
551            s.stack.push(Slot::Other);
552        }
553
554        // Control flow.
555        Op::Jump(off) => {
556            let target = (pc as i32 + 1 + off) as usize;
557            return (s, vec![target], escapes);
558        }
559        Op::JumpIf(off) | Op::JumpIfNot(off) => {
560            s.stack.pop(); // consumed Bool
561            let target = (pc as i32 + 1 + off) as usize;
562            return (s, vec![pc + 1, target], escapes);
563        }
564        Op::Return => {
565            let top = s.stack.pop();
566            // The only policy-sensitive arm: under FrameScope the
567            // returned value crosses our frame and leaks; under
568            // RequestScope it stays inside the request and does not.
569            if matches!(policy, Policy::FrameScope) {
570                if let Some(top) = top { leak(&top, &mut escapes); }
571            }
572            return (s, vec![], escapes);
573        }
574        Op::Panic(_) => {
575            return (s, vec![], escapes);
576        }
577        Op::TailCall { arity, .. } => {
578            pop_n_leak(&mut s, *arity as usize, &mut escapes);
579            return (s, vec![], escapes);
580        }
581        Op::Call { arity, .. } => {
582            pop_n_leak(&mut s, *arity as usize, &mut escapes);
583            s.stack.push(Slot::Other);
584        }
585        Op::CallClosure { arity, .. } => {
586            // pop arity args + 1 closure
587            pop_n_leak(&mut s, *arity as usize + 1, &mut escapes);
588            s.stack.push(Slot::Other);
589        }
590        Op::SortByKey { .. } | Op::ParallelMap { .. }
591        | Op::ListMap { .. } | Op::ListFilter { .. } => {
592            // pop [xs, f]; both escape
593            pop_n_leak(&mut s, 2, &mut escapes);
594            s.stack.push(Slot::Other);
595        }
596        Op::ListFold { .. } => {
597            // pop [xs, init, f]; all escape into the native op
598            pop_n_leak(&mut s, 3, &mut escapes);
599            s.stack.push(Slot::Other);
600        }
601        Op::EffectCall { arity, .. } => {
602            pop_n_leak(&mut s, *arity as usize, &mut escapes);
603            s.stack.push(Slot::Other);
604        }
605
606        // Pure arithmetic / comparison / logic / string ops. Their
607        // operands are primitives in well-typed code (the existing
608        // type checker rejects record-typed args to IntAdd etc.),
609        // so we don't expect Rec slots to flow in. If one does, the
610        // pop_n_drop loses the Rec without recording escape — but
611        // that would be a type-system bug surfaced elsewhere.
612        Op::IntAdd | Op::IntSub | Op::IntMul | Op::IntDiv | Op::IntMod
613        | Op::IntEq | Op::IntLt | Op::IntLe
614        | Op::FloatAdd | Op::FloatSub | Op::FloatMul | Op::FloatDiv
615        | Op::FloatEq | Op::FloatLt | Op::FloatLe
616        | Op::NumAdd | Op::NumSub | Op::NumMul | Op::NumDiv | Op::NumMod
617        | Op::NumEq | Op::NumLt | Op::NumLe
618        | Op::BoolAnd | Op::BoolOr
619        | Op::StrConcat | Op::StrEq | Op::BytesEq => {
620            pop_n_drop(&mut s, 2);
621            s.stack.push(Slot::Other);
622        }
623        Op::IntNeg | Op::FloatNeg | Op::NumNeg | Op::BoolNot
624        | Op::StrLen | Op::BytesLen => {
625            s.stack.pop();
626            s.stack.push(Slot::Other);
627        }
628
629        // Superinstructions (#461). All operate on Int locals and
630        // primitive stack values — they neither produce nor consume
631        // Rec slots. The trailing tombstones are inert; the verifier
632        // pattern (skip ahead by N) is mirrored here.
633        Op::LoadLocalAddIntConst { .. } => {
634            // +1 net (LoadLocal + PushConst + IntAdd)
635            s.stack.push(Slot::Other);
636        }
637        Op::LoadLocalAddIntConstStoreLocal { dest, .. } => {
638            // delta 0; updates a local with an Int. Overwriting a
639            // local doesn't escape its prior aggregate — same rule as
640            // plain StoreLocal above (the dest is Int-typed by this
641            // superinstruction's contract, so prev is never an
642            // aggregate in practice; relaxed for consistency).
643            let i = *dest as usize;
644            if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
645            s.locals[i] = Slot::Other;
646            return (s, vec![pc + 4], escapes);
647        }
648        Op::LoadLocalAddLocal { .. }
649        | Op::LoadLocalSubLocal { .. }
650        | Op::LoadLocalMulLocal { .. } => {
651            // +1 net (two LoadLocal + one binop)
652            s.stack.push(Slot::Other);
653            return (s, vec![pc + 3], escapes);
654        }
655        Op::LoadLocalGetField { .. } => {
656            // #461 slice 9: equivalent to LoadLocal + GetField —
657            // reads a field out of a local record (which does NOT
658            // escape the receiver, matching plain GetField) and
659            // pushes the field value (Other). Net delta +1; skip the
660            // single tombstone (pc+2). (Escape analysis runs before
661            // peephole, so this arm is exercised only if the analysis
662            // is ever re-run on fused code; it's here for exhaustive
663            // matching and forward-correctness.)
664            s.stack.push(Slot::Other);
665            return (s, vec![pc + 2], escapes);
666        }
667        Op::LoadLocalGetFieldAdd { .. }
668        | Op::LoadLocalGetFieldSub { .. }
669        | Op::LoadLocalGetFieldMul { .. } => {
670            // #461 slice 7/8: net delta on the value stack is 0 (pops
671            // prior Int top, pushes Int result). The receiver is read
672            // from a local — the analysis tracks locals separately,
673            // and reading a local doesn't escape its Rec slot (the
674            // round-trip-through-local rule from StoreLocal applies
675            // here too: the value stays referenced). We pop the
676            // existing top (the accumulator) and push a fresh Other
677            // (the result). Skip past the two tombstones.
678            s.stack.pop();
679            s.stack.push(Slot::Other);
680            return (s, vec![pc + 3], escapes);
681        }
682        Op::LoadLocalEqIntConstJumpIfNot { jump_offset, .. } => {
683            // delta 0; two successors (fall-through past tombstones,
684            // and the branch target relative to the original
685            // JumpIfNot's pc).
686            let target = (pc as i32 + 4 + jump_offset) as usize;
687            return (s, vec![pc + 4, target], escapes);
688        }
689        Op::LoadLocalStoreEqIntConstJumpIfNot { dst, jump_offset, .. } => {
690            // delta 0; also writes locals[dst] := locals[src].
691            // Treat the local update the same as StoreLocal of an
692            // Other (the scrutinee is an Int per slice-6's contract).
693            let i = *dst as usize;
694            if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
695            // Overwriting a local doesn't escape its prior aggregate
696            // (same rule as plain StoreLocal); dst is Int-typed here.
697            s.locals[i] = Slot::Other;
698            let target = (pc as i32 + 6 + jump_offset) as usize;
699            return (s, vec![pc + 6, target], escapes);
700        }
701    }
702
703    (s, vec![pc + 1], escapes)
704}
705
706/// Convenience wrapper over `analyze_program` returning a map
707/// keyed by `(fn_name, pc)` for direct lookup during codegen.
708pub fn build_escape_index(functions: &[Function]) -> HashMap<(String, u32), bool> {
709    let mut idx = HashMap::new();
710    for report in analyze_program(functions) {
711        for site in report.sites {
712            idx.insert((report.fn_name.clone(), site.pc), site.escapes);
713        }
714    }
715    idx
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::op::Op;
722    use crate::program::{Function, ZERO_BODY_HASH};
723
724    /// Helper: build a minimal Function with the given code and
725    /// just enough machinery for the analyzer.
726    fn func(name: &str, locals_count: u16, arity: u16, code: Vec<Op>) -> Function {
727        Function {
728            name: name.into(),
729            arity,
730            locals_count,
731            code,
732            effects: vec![],
733            body_hash: ZERO_BODY_HASH,
734            refinements: vec![],
735            field_ic_sites: 0,
736        }
737    }
738
739    /// Expectation helper: a list of `(pc, expected_escapes)` pairs.
740    fn assert_escapes(report: &EscapeReport, expected: &[(u32, bool)]) {
741        let got: Vec<(u32, bool)> = report.sites.iter().map(|s| (s.pc, s.escapes)).collect();
742        assert_eq!(got, expected,
743            "escape report for `{}` differs from expected", report.fn_name);
744    }
745
746    #[test]
747    fn record_built_and_dropped_does_not_escape() {
748        // PushConst PushConst MakeRecord Pop Return
749        let f = func("dropper", 0, 0, vec![
750            Op::PushConst(0),
751            Op::PushConst(1),
752            Op::MakeRecord { shape_idx: 0, field_count: 2 },
753            Op::Pop,
754            Op::PushConst(0),
755            Op::Return,
756        ]);
757        let r = analyze_function(&f);
758        assert_escapes(&r, &[(2, false)]);
759    }
760
761    #[test]
762    fn record_returned_escapes() {
763        let f = func("returner", 0, 0, vec![
764            Op::PushConst(0),
765            Op::PushConst(1),
766            Op::MakeRecord { shape_idx: 0, field_count: 2 },
767            Op::Return,
768        ]);
769        let r = analyze_function(&f);
770        assert_escapes(&r, &[(2, true)]);
771    }
772
773    #[test]
774    fn record_field_read_only_does_not_escape() {
775        // PushConst PushConst MakeRecord GetField Return (returns the field, not the record)
776        let f = func("reader", 0, 0, vec![
777            Op::PushConst(0),
778            Op::PushConst(1),
779            Op::MakeRecord { shape_idx: 0, field_count: 2 },
780            Op::GetField { name_idx: 0, site_idx: 0 },
781            Op::Return,
782        ]);
783        let r = analyze_function(&f);
784        assert_escapes(&r, &[(2, false)]);
785    }
786
787    #[test]
788    fn record_round_tripped_through_local_does_not_escape() {
789        let f = func("roundtrip", 1, 0, vec![
790            Op::PushConst(0),
791            Op::PushConst(1),
792            Op::MakeRecord { shape_idx: 0, field_count: 2 },
793            Op::StoreLocal(0),
794            Op::LoadLocal(0),
795            Op::GetField { name_idx: 0, site_idx: 0 },
796            Op::Return,
797        ]);
798        let r = analyze_function(&f);
799        assert_escapes(&r, &[(2, false)]);
800    }
801
802    #[test]
803    fn record_stored_into_outer_record_escapes() {
804        // Build inner, then build outer with inner as one of its fields.
805        let f = func("nest", 0, 0, vec![
806            Op::PushConst(0),
807            Op::PushConst(1),
808            Op::MakeRecord { shape_idx: 0, field_count: 2 }, // inner @ pc=2
809            Op::PushConst(2),
810            Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=4
811            Op::Return,                                       // outer escapes
812        ]);
813        let r = analyze_function(&f);
814        // inner escapes (captured in outer); outer escapes (returned).
815        assert_escapes(&r, &[(2, true), (4, true)]);
816    }
817
818    #[test]
819    fn record_passed_to_call_escapes() {
820        let f = func("passer", 0, 0, vec![
821            Op::PushConst(0),
822            Op::PushConst(1),
823            Op::MakeRecord { shape_idx: 0, field_count: 2 },
824            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
825            Op::Return,
826        ]);
827        let r = analyze_function(&f);
828        assert_escapes(&r, &[(2, true)]);
829    }
830
831    #[test]
832    fn record_captured_in_closure_escapes() {
833        let f = func("capturer", 0, 0, vec![
834            Op::PushConst(0),
835            Op::PushConst(1),
836            Op::MakeRecord { shape_idx: 0, field_count: 2 },
837            Op::MakeClosure { fn_id: 1, capture_count: 1 },
838            Op::Return,
839        ]);
840        let r = analyze_function(&f);
841        assert_escapes(&r, &[(2, true)]);
842    }
843
844    #[test]
845    fn record_in_one_branch_returned_escapes_after_merge() {
846        // if cond { rec1 } else { rec2 } — Return after merge.
847        // Under FrameScope, Return leaks every site in the merged
848        // `AggSet`, so both still escape. The precision refinement
849        // (sibling `merged_branch_records_kept_tracked_*` tests
850        // below) only changes the answer under RequestScope.
851        let f = func("brancher", 0, 1, vec![
852            Op::LoadLocal(0),                          // pc=0
853            Op::JumpIfNot(4),                          // pc=1; offset 4 → pc=6
854            Op::PushConst(0),                          // pc=2
855            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3 (then-branch)
856            Op::Jump(2),                               // pc=4; offset 2 → pc=7
857            Op::PushConst(1),                          // pc=5 (unreached fall-through dead code)
858            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6 (else-branch)
859            Op::Return,                                // pc=7 (merge + return)
860        ]);
861        let r = analyze_function(&f);
862        // Both record sites escape — Return sees a merged stack.
863        assert_escapes(&r, &[(3, true), (6, true)]);
864    }
865
866    /// Per-path precision refinement: under `RequestScope`, two
867    /// records produced in alternate `if`/`else` branches and merged
868    /// before `Return` stay tracked across the join (`AggSet([p,q])`)
869    /// and `Return` leaves them alone, so neither escapes. This is
870    /// the win that makes the `response_build`-shape `match`-arm
871    /// handlers arena-eligible.
872    #[test]
873    fn merged_branch_records_kept_tracked_under_request_scope() {
874        let f = func("brancher_req", 0, 1, vec![
875            Op::LoadLocal(0),
876            Op::JumpIfNot(4),
877            Op::PushConst(0),
878            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3
879            Op::Jump(2),
880            Op::PushConst(1),
881            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6
882            Op::Return,
883        ]);
884        let r = analyze_function_with_policy(&f, Policy::RequestScope);
885        // Neither site escapes — both stay in the merged AggSet
886        // through Return, which doesn't leak under RequestScope.
887        assert_escapes(&r, &[(3, false), (6, false)]);
888    }
889
890    /// Sibling guard: under `RequestScope`, if the merged set is
891    /// then passed to a `Call` (a hatch under both policies), every
892    /// site in the set still escapes. Confirms the leak helper
893    /// iterates `sites()` correctly across the multi-site variant.
894    #[test]
895    fn merged_branch_records_all_escape_at_call_under_request_scope() {
896        let f = func("brancher_then_call", 0, 1, vec![
897            Op::LoadLocal(0),
898            Op::JumpIfNot(4),
899            Op::PushConst(0),
900            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3
901            Op::Jump(2),
902            Op::PushConst(1),
903            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6
904            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 }, // pc=7
905            Op::Return,
906        ]);
907        let r = analyze_function_with_policy(&f, Policy::RequestScope);
908        // The Call consumes the merged AggSet → both sites leak.
909        assert_escapes(&r, &[(3, true), (6, true)]);
910    }
911
912    // ---- Deep-leaf widening (containment-tracking) ----
913
914    /// Inner record returned inside outer record — both arena-
915    /// eligible under request-scope. Pre-deep-leaf, `inner` was
916    /// leaked at the outer's `MakeRecord`; the transitive
917    /// expansion now leaves it alone because the outer doesn't
918    /// escape under `Policy::RequestScope`.
919    #[test]
920    fn deep_leaf_nested_records_both_arena_eligible() {
921        let f = func("nested_ret", 0, 0, vec![
922            Op::PushConst(0),
923            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
924            Op::PushConst(1),
925            Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=3, holds inner
926            Op::Return,
927        ]);
928        let r = analyze_function_with_policy(&f, Policy::RequestScope);
929        assert_escapes(&r, &[(1, false), (3, false)]);
930    }
931
932    /// 3-deep nesting: inner → middle → outer → Return. Under
933    /// `RequestScope` all three are arena-eligible — transitive
934    /// expansion in `analyze_function_with_policy` chains through
935    /// the containment map.
936    #[test]
937    fn deep_leaf_three_deep_chain_all_arena_eligible() {
938        let f = func("three_deep", 0, 0, vec![
939            Op::PushConst(0),
940            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
941            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // middle @ pc=2
942            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=3
943            Op::Return,
944        ]);
945        let r = analyze_function_with_policy(&f, Policy::RequestScope);
946        assert_escapes(&r, &[(1, false), (2, false), (3, false)]);
947    }
948
949    /// Soundness guard: when the outer reaches a real hatch
950    /// (`Call`), all contained children escape transitively. Same
951    /// chain shape as above but with the outer leaving via Call.
952    #[test]
953    fn deep_leaf_chain_all_escape_when_root_passed_to_call() {
954        let f = func("three_deep_call", 0, 0, vec![
955            Op::PushConst(0),
956            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
957            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // middle @ pc=2
958            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=3
959            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 }, // pc=4
960            Op::Return,
961        ]);
962        let r = analyze_function_with_policy(&f, Policy::RequestScope);
963        // outer escapes via Call → middle escapes via containment →
964        // inner escapes via containment. All three flagged.
965        assert_escapes(&r, &[(1, true), (2, true), (3, true)]);
966    }
967
968    /// Mixed: outer dropped, inner contained. Under FrameScope the
969    /// outer is dropped and never returned, so the transitive
970    /// expansion leaves inner alone too — even under FrameScope
971    /// this is a precision win over the pre-refinement behavior
972    /// that leaked inner at the outer's build site.
973    #[test]
974    fn deep_leaf_outer_popped_inner_stays_local_under_frame_scope() {
975        let f = func("nested_pop", 0, 0, vec![
976            Op::PushConst(0),
977            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
978            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=2
979            Op::Pop,
980            Op::PushConst(0),
981            Op::Return,
982        ]);
983        let r = analyze_function(&f); // FrameScope
984        assert_escapes(&r, &[(1, false), (2, false)]);
985    }
986
987    #[test]
988    fn two_sites_classified_independently() {
989        // One record returned, one popped — they should classify
990        // separately. Sequencing: build keeper, store it; build
991        // discard, pop it; load keeper, return.
992        let f = func("mixed", 1, 0, vec![
993            Op::PushConst(0),
994            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // keeper @ pc=1
995            Op::StoreLocal(0),
996            Op::PushConst(0),
997            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // discard @ pc=4
998            Op::Pop,
999            Op::LoadLocal(0),
1000            Op::Return,
1001        ]);
1002        let r = analyze_function(&f);
1003        assert_escapes(&r, &[(1, true), (4, false)]);
1004    }
1005
1006    #[test]
1007    fn function_with_no_record_sites_produces_empty_report() {
1008        let f = func("pure_arith", 0, 2, vec![
1009            Op::LoadLocal(0),
1010            Op::LoadLocal(1),
1011            Op::IntAdd,
1012            Op::Return,
1013        ]);
1014        let r = analyze_function(&f);
1015        assert!(r.sites.is_empty());
1016    }
1017
1018    #[test]
1019    fn analyze_program_skips_no_record_functions() {
1020        let f1 = func("noreds", 0, 0, vec![Op::PushConst(0), Op::Return]);
1021        let f2 = func("hasrec", 0, 0, vec![
1022            Op::PushConst(0),
1023            Op::MakeRecord { shape_idx: 0, field_count: 1 },
1024            Op::Return,
1025        ]);
1026        let reports = analyze_program(&[f1, f2]);
1027        assert_eq!(reports.len(), 1);
1028        assert_eq!(reports[0].fn_name, "hasrec");
1029    }
1030
1031    #[test]
1032    fn record_passed_to_effect_call_escapes() {
1033        let f = func("effecting", 0, 0, vec![
1034            Op::PushConst(0),
1035            Op::MakeRecord { shape_idx: 0, field_count: 1 },
1036            Op::EffectCall { kind_idx: 0, op_idx: 0, arity: 1, node_id_idx: 0 },
1037            Op::Return,
1038        ]);
1039        let r = analyze_function(&f);
1040        assert_escapes(&r, &[(1, true)]);
1041    }
1042
1043    #[test]
1044    fn record_duplicated_escapes() {
1045        // Dup is conservatively an escape — aliasing breaks the
1046        // linear-flow assumption.
1047        let f = func("duper", 0, 0, vec![
1048            Op::PushConst(0),
1049            Op::MakeRecord { shape_idx: 0, field_count: 1 },
1050            Op::Dup,
1051            Op::Pop,
1052            Op::Pop,
1053            Op::PushConst(0),
1054            Op::Return,
1055        ]);
1056        let r = analyze_function(&f);
1057        assert_escapes(&r, &[(1, true)]);
1058    }
1059
1060    #[test]
1061    fn record_in_list_escapes() {
1062        let f = func("listed", 0, 0, vec![
1063            Op::PushConst(0),
1064            Op::MakeRecord { shape_idx: 0, field_count: 1 },
1065            Op::MakeList(1),
1066            Op::Return,
1067        ]);
1068        let r = analyze_function(&f);
1069        assert_escapes(&r, &[(1, true)]);
1070    }
1071
1072    #[test]
1073    fn build_escape_index_keys_by_fn_and_pc() {
1074        let f = func("idx_test", 0, 0, vec![
1075            Op::PushConst(0),
1076            Op::MakeRecord { shape_idx: 0, field_count: 1 },
1077            Op::Pop,
1078            Op::PushConst(0),
1079            Op::Return,
1080        ]);
1081        let idx = build_escape_index(&[f]);
1082        assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&false));
1083    }
1084
1085    // ---- tuple widening (#464 tuple analysis slice) ----
1086
1087    #[test]
1088    fn tuple_built_and_dropped_does_not_escape() {
1089        let f = func("tup_drop", 0, 0, vec![
1090            Op::PushConst(0),
1091            Op::PushConst(1),
1092            Op::MakeTuple(2), // pc=2
1093            Op::Pop,
1094            Op::PushConst(0),
1095            Op::Return,
1096        ]);
1097        let r = analyze_function(&f);
1098        assert_escapes(&r, &[(2, false)]);
1099        assert_eq!(r.sites[0].kind, SiteKind::Tuple);
1100        assert_eq!(r.sites[0].field_count, 2);
1101    }
1102
1103    #[test]
1104    fn tuple_returned_escapes() {
1105        let f = func("tup_ret", 0, 0, vec![
1106            Op::PushConst(0),
1107            Op::PushConst(1),
1108            Op::MakeTuple(2), // pc=2
1109            Op::Return,
1110        ]);
1111        let r = analyze_function(&f);
1112        assert_escapes(&r, &[(2, true)]);
1113    }
1114
1115    #[test]
1116    fn tuple_elem_read_only_does_not_escape() {
1117        // Reading an element (GetElem) consumes the tuple locally,
1118        // mirroring GetField on a record — the tuple stays a candidate.
1119        let f = func("tup_read", 0, 0, vec![
1120            Op::PushConst(0),
1121            Op::PushConst(1),
1122            Op::MakeTuple(2), // pc=2
1123            Op::GetElem(0),
1124            Op::Return,
1125        ]);
1126        let r = analyze_function(&f);
1127        assert_escapes(&r, &[(2, false)]);
1128    }
1129
1130    #[test]
1131    fn tuple_round_tripped_through_local_does_not_escape() {
1132        let f = func("tup_rt", 1, 0, vec![
1133            Op::PushConst(0),
1134            Op::PushConst(1),
1135            Op::MakeTuple(2), // pc=2
1136            Op::StoreLocal(0),
1137            Op::LoadLocal(0),
1138            Op::GetElem(1),
1139            Op::Return,
1140        ]);
1141        let r = analyze_function(&f);
1142        assert_escapes(&r, &[(2, false)]);
1143    }
1144
1145    #[test]
1146    fn tuple_passed_to_call_escapes() {
1147        let f = func("tup_call", 0, 0, vec![
1148            Op::PushConst(0),
1149            Op::PushConst(1),
1150            Op::MakeTuple(2), // pc=2
1151            Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
1152            Op::Return,
1153        ]);
1154        let r = analyze_function(&f);
1155        assert_escapes(&r, &[(2, true)]);
1156    }
1157
1158    #[test]
1159    fn record_stored_into_tuple_dropped_outer_neither_escapes() {
1160        // Build a record, wrap it in a tuple, drop the tuple.
1161        // Pre-deep-leaf this asserted `[(1, true), (2, false)]` —
1162        // the record was leaked at the tuple's build site because
1163        // we conservatively assumed any captured value escaped.
1164        // With containment-tracking, the record's fate is bound to
1165        // the tuple's: the tuple is dropped (no escape), so via
1166        // transitive expansion the record doesn't escape either.
1167        // The win is real — both can live in the frame's stack-
1168        // record arena now.
1169        let f = func("rec_in_tup", 0, 0, vec![
1170            Op::PushConst(0),
1171            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=1
1172            Op::MakeTuple(1),                                // tup @ pc=2 holds rec
1173            Op::Pop,
1174            Op::PushConst(0),
1175            Op::Return,
1176        ]);
1177        let r = analyze_function(&f);
1178        assert_escapes(&r, &[(1, false), (2, false)]);
1179    }
1180
1181    #[test]
1182    fn tuple_stored_into_record_escapes() {
1183        let f = func("tup_in_rec", 0, 0, vec![
1184            Op::PushConst(0),
1185            Op::PushConst(1),
1186            Op::MakeTuple(2),                                // tup @ pc=2
1187            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=3 holds tup
1188            Op::Return,
1189        ]);
1190        let r = analyze_function(&f);
1191        assert_escapes(&r, &[(2, true), (3, true)]);
1192    }
1193
1194    #[test]
1195    fn tuple_and_record_sites_carry_distinct_kinds() {
1196        let f = func("mixed_kinds", 0, 0, vec![
1197            Op::PushConst(0),
1198            Op::MakeRecord { shape_idx: 7, field_count: 1 }, // pc=1 record
1199            Op::Pop,
1200            Op::PushConst(0),
1201            Op::PushConst(1),
1202            Op::MakeTuple(2), // pc=5 tuple
1203            Op::Pop,
1204            Op::PushConst(0),
1205            Op::Return,
1206        ]);
1207        let r = analyze_function(&f);
1208        assert_eq!(r.sites.len(), 2);
1209        assert_eq!((r.sites[0].pc, r.sites[0].kind, r.sites[0].shape_idx), (1, SiteKind::Record, 7));
1210        assert_eq!((r.sites[1].pc, r.sites[1].kind, r.sites[1].field_count), (5, SiteKind::Tuple, 2));
1211        assert!(!r.sites[0].escapes && !r.sites[1].escapes);
1212    }
1213
1214    #[test]
1215    fn aggregate_overwritten_in_dead_slot_does_not_escape() {
1216        // rec1 is stored into local 0, then local 0 is overwritten
1217        // with an Int and rec1 is never otherwise used. Lex's
1218        // immutable `let` bindings mean a slot is only reused once its
1219        // prior occupant is dead, so the overwrite must NOT flag rec1
1220        // as escaping (the relaxed StoreLocal rule — #464 tuple
1221        // codegen). Pre-relaxation this returned `true`.
1222        let f = func("overwrite", 1, 0, vec![
1223            Op::PushConst(0),
1224            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1225            Op::StoreLocal(0),
1226            Op::PushConst(0),
1227            Op::StoreLocal(0), // overwrite local 0 with an Int
1228            Op::PushConst(0),
1229            Op::Return,
1230        ]);
1231        let r = analyze_function(&f);
1232        assert_escapes(&r, &[(1, false)]);
1233    }
1234
1235    #[test]
1236    fn aggregate_loaded_then_returned_still_escapes() {
1237        // Soundness guard for the relaxed overwrite rule: an aggregate
1238        // stored in a local, then LOADED and returned, still escapes —
1239        // the `Return` leak catches it independent of any overwrite.
1240        let f = func("load_then_return", 1, 0, vec![
1241            Op::PushConst(0),
1242            Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1243            Op::StoreLocal(0),
1244            Op::LoadLocal(0),
1245            Op::Return, // returns rec1 → escapes
1246        ]);
1247        let r = analyze_function(&f);
1248        assert_escapes(&r, &[(1, true)]);
1249    }
1250
1251    #[test]
1252    fn tuple_only_function_now_produces_report() {
1253        // Pre-widening this function had no tracked sites and was
1254        // omitted from analyze_program; now its tuple site is reported.
1255        let f = func("tuponly", 0, 0, vec![
1256            Op::PushConst(0),
1257            Op::PushConst(1),
1258            Op::MakeTuple(2),
1259            Op::Pop,
1260            Op::PushConst(0),
1261            Op::Return,
1262        ]);
1263        let reports = analyze_program(&[f]);
1264        assert_eq!(reports.len(), 1);
1265        assert_eq!(reports[0].sites.len(), 1);
1266        assert_eq!(reports[0].sites[0].kind, SiteKind::Tuple);
1267    }
1268}