diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index bc9d321501..6f1457028c 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -97,6 +97,39 @@ "alloc_contexts": {}, "alloc_buckets": {} }, + { + "name": "fixture_ptr_shape_cjs_iife", + "role": "liveness", + "source": "benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts", + "floors": { + "ptr-shape": 1, + "ptr-shape-consumed": 1, + "ptr-numarray": 0, + "canonical-i32": 0, + "canonical-u32": 0, + "canonical-str": 0, + "int-valued-ta": 0, + "spec-abi-entry": 0, + "spec-abi-taptr-slot": 0 + }, + "candidates": { + "ptr-shape": 3, + "ptr-numarray": 0, + "canonical-slot": 1, + "int-valued-ta": 0, + "spec-abi": 0 + }, + "unconsumed_mechanisms": {}, + "consumption_sites": { + "class_field_get.shape_proven_load": 1, + "ptr_shape_set": 1 + }, + "alloc_contexts": {}, + "alloc_buckets": { + "ptr-shape | return | rule 1 (provenance) \u2014 already served by return-shape": 1, + "ptr-shape | return | rule 1 (provenance)": 1 + } + }, { "name": "fixture_alloc_buckets", "role": "liveness", @@ -805,5 +838,5 @@ "alloc_buckets": {} } ], - "generated_at": "2026-08-01T09:57:46.100718Z" + "generated_at": "2026-08-02T04:34:29.412488Z" } diff --git a/benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts new file mode 100644 index 0000000000..cc0aafb184 --- /dev/null +++ b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts @@ -0,0 +1,73 @@ +// Liveness fixture for the `Ptr` census key **inside an IIFE** (#7170 R1). +// +// `fixture_ptr_shape.ts` is the same proof at module scope, where `mk` and +// `run` are `hir.functions` entries. This file wraps identical code in +// `(function () { … })()` and nothing else. That one difference is what Perry's +// own `cjs_wrap` does to *every* CommonJS module (`compile/cjs_wrap/wrap.rs`, +// `const _cjs = (function() { … })();`), and #7170 §6 measured what it costs: +// +// probe wrapper result for `const p = mk(i)` +// p7_esm.ts none selected, and consumed +// p8_iife.ts (function(){ … })() NOT EVEN A CANDIDATE +// +// Inside the IIFE `mk` is not a function declaration the compiler can see — +// it lowers to `Stmt::Let { init: Expr::Closure }`, and `mk(i)` to +// `Call { callee: LocalGet(id) }`. #7107's producer walked `hir.functions` +// (empty here) and its caller-side seed accepted only `Expr::FuncRef`, so the +// whole return-shape mechanism was structurally unreachable across CommonJS — +// 91.6% of dependency-JS allocation sites (#7170 §2). +// +// This fixture is what makes that reachability falsifiable. Reverting either +// half of R1 — the closure arm of `collect_return_shape_functions`, or +// `callee_names_one_function`'s `LocalGet` arm — takes its `ptr-shape` count +// to zero while `fixture_ptr_shape.ts` stays green, because that one is at +// module scope and never needed either. +// +// Do not "tidy" this file: +// +// * Removing the IIFE turns it back into `fixture_ptr_shape.ts` and it stops +// testing anything R1 added. +// * Removing `p.x = p.x + 1` makes the object non-escaping, `escape_news.rs` +// deletes it outright, and the promotion becomes `unconsumed — +// scalar_replaced` (#7170 §6.1). The `ptr-shape-consumed` floor is what +// catches that, and the store is what satisfies it. +// * Reassigning `mk`, or declaring it twice, disqualifies the callee binding +// (`single_binding_closure_locals`) and takes the count to zero. +// * Deleting `maybe` removes the fixture's only UNSERVED return-position +// allocation, and with it the census's ability to catch +// `codegen/closure.rs` reporting every closure as served. See +// ALLOC_BUCKET_FLOORS in `scripts/compiler_output_harness/repsel_census.py` +// — this file is the only workload that lands both bucket rows in a +// `closure` region, and no compiler unit test can reach that wiring +// (they all set the report scope by hand). + +const _cjs = (function () { + function mk(i: number) { + return { x: i, y: i + 1 }; + } + // Deliberately NOT a return-shape producer: the second return is not a fresh + // allocation, so the returns disagree and `producer_return_class` refuses. + // Its `{ tag: n }` is therefore an unserved return-position allocation in a + // closure region — the anti-vacuity half of the served classification. + function maybe(n: number) { + if (n > 2) { + return { tag: n }; + } + return null; + } + function run(n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + const p = mk(i); + p.x = p.x + 1; + total = total + p.x + p.y; + } + if (maybe(n) !== null) { + total = total + 1; + } + return total; + } + return run(4); +})(); + +console.log("ptr_shape_cjs_iife:" + _cjs); diff --git a/changelog.d/7233-repsel-cjs-iife-return-shape.md b/changelog.d/7233-repsel-cjs-iife-return-shape.md new file mode 100644 index 0000000000..0784461180 --- /dev/null +++ b/changelog.d/7233-repsel-cjs-iife-return-shape.md @@ -0,0 +1,82 @@ +### Representation selection: `Ptr` return-shape facts now reach inside Perry's CommonJS IIFE (#7170 R1) + +`compile/cjs_wrap/wrap.rs` emits every CommonJS module body inside +`const _cjs = (function () { … })();`. Inside that wrapper a module-level +`function` declaration never reaches `hir.functions` — it lowers to +`Stmt::Let { init: Expr::Closure }`, and a call to it to +`Call { callee: LocalGet(id) }`. #7107's return-shape mechanism walked +`hir.functions` for producers and accepted only a bare `Expr::FuncRef` callee +for consumers, so it was **structurally unreachable across the whole CommonJS +ecosystem**: 91.6 % of dependency-JS `Ptr` allocation sites sit in +`closure` regions (#7170 §2/§6). This is the third time Perry's own CJS +scaffolding turned out to be the wall, after #7139 (the wrap preamble arming +the rule-5 barrier) and #7152/#7171 (`__cjs_module`). + +Both halves are extended, because both missed it: + +* **Producer** (`collectors/ptr_shape_returns.rs`): every `Expr::Closure` in + the module is now a candidate body, keyed by the `FuncId` the closure already + carries — the same module-wide `fresh_func` counter as `hir.functions`, so no + key can mean two things. A `Function` and a closure are the same thing to + this proof but carry differently-spelled context flags + (`Function::was_plain_async` versus `Module::async_step_closures`), so both + are projected onto one `ProducerBody` view and the closure arm cannot prove + something weaker than the function arm. +* **Consumer**: an `Expr::LocalGet` callee resolves through a new module-wide + binding proof, `collectors/spec_abi_sites.rs::single_binding_closure_locals` + — exactly one `Stmt::Let` with a closure init, never reassigned at any depth + in any body, never also a parameter or a `catch` binding. That is the same + statement `Expr::FuncRef` makes directly, and it is the only property the + seed needs of a callee: *which body runs*. Deliberately **not** + `FnCtx::local_closure_func_ids`, which `lower_call` pairs with a runtime + `js_typed_feedback_closure_direct_call_guard` because it is populated in + statement order. + +Box-backed bindings are admitted on purpose: a hoisted inner `function` +referenced from a sibling closure is `PreallocateBoxes`-boxed by construction +(`lower_decl/block.rs`), and that is the entire dependency-JS population. +Freshness is unchanged — the full Phase 3b proof still re-runs over the +producer's body. + +**Measured, on a real transpiled CommonJS module**, both arms pinned at one +SHA and compared on emitted IR with call sites checked: `Ptr` goes from +`selected 0 / consumed 0` to `selected 1 / consumed 2`, and the emitted IR +loses **25 opaque `js_*` call sites** — `js_object_get_field_by_name_f64` +19 → 13, and three whole typed-feedback guard diamonds +(`js_typed_feedback_object_get_field_by_name_f64` / +`observe_property_get` / `record_guard_pass` / `record_guard_fail` / +`record_fallback_call`, each 10 → 7). Three call sites are *added* and are +reported as the promotion's own cost: the guard-free store emits a direct +`js_write_barrier_slot` where `js_put_value_set_dyn_ic` did the barrier +internally, plus one slot-layout note and one string addref. + +**On the 197-module dependency corpus the mechanism fires 10 more times and +promotes nothing more**, and that is reported rather than smoothed: the +`return` allocation bucket moves 231 → 221 unserved and 4 → 14 served, while +corpus `selected`/`consumed` stay at 3/11. The producers R1 reaches are +*exported* helpers with no same-module `const x = f(…)` call site — #7170 R2's +cross-module half, which is structurally blocked because +`Expr::ExternFuncRef` carries no `FuncId`. A throwaway instrumented compiler +put a number on the residual wall: over the same corpus the first refusing +conjunct is the **return form** in 1397 of 1971 refusals, while +"can fall off the end" refuses exactly **one** body — so widening the producer +to conditional returns whose arms agree (R0 §3b measured 88 such sites) is the +next increment, not more consumer reach. + +Also fixes a latent hole this proof would otherwise have inherited: +`Expr::WithSet` carries its fallback `LocalId` in `WithSetFallback` rather than +in a child expression, so `spec_abi_sites::record_expr_use` — whose every other +arm delegates to the exhaustive `walk_expr_children` — had never recorded +`with (o) { x = v }` as a reassignment. `reassigned_locals` had been wrong +about that since it was written; the fix can only make it more conservative, +and only in a module containing `with`. + +New gates: census liveness fixture +`benchmarks/repsel_census/fixtures/fixture_ptr_shape_cjs_iife.ts` (floors held +in code) and behavioural gap test +`test-files/test_gap_repsel_cjs_iife_return_shape.ts`, registered in +`test-parity/gc_repsel_corpus.txt`. The fixture deliberately lands **two** +allocation buckets from inside one IIFE — a served return and an unserved one — +because the served flag for a closure region is set in `codegen/closure.rs`, +which no compiler unit test can reach: hard-coding it `false` or `true` was a +green hole across all 526 of them and is red only in the census. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 8281f432f0..b20e1d389a 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -777,7 +777,18 @@ pub(super) fn compile_closure( .get(&func_id) .cloned() .unwrap_or_else(|| format!("closure#{func_id}")); - let _opt_report_scope = crate::opt_report::enter_closure(&opt_report_name, func_id); + // #7170 R1: a closure CAN carry a return-shape fact now — the CommonJS + // wrapper's IIFE makes every module-level `function` declaration one — so + // the served classification has to be told, exactly as + // `codegen/function.rs` tells it. Read by nothing but the report. + let _opt_report_scope = crate::opt_report::enter_closure( + &opt_report_name, + func_id, + cross_module + .module_dispatch + .return_shape_class(func_id) + .is_some(), + ); let native_facts = crate::collectors::collect_native_region_fact_graph( body, &[], diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs index 2e399ac711..416614a577 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -477,22 +477,27 @@ fn a_return_in_a_plain_function_is_still_a_rule_1_denial() { ); } -/// #7170 §6, as a test rather than a comment: **91.6% of dependency-JS -/// allocation sites are in closure regions, and none of them is served.** -/// `collect_return_shape_functions` issues facts only for `hir.functions` -/// entries and the caller-side seed only fires on a bare `Expr::FuncRef` -/// callee, which a closure call never is. A classifier that keyed servedness -/// on the syntax of the returns alone — rather than on the fact — would mark -/// this whole population served and delete the wall it is supposed to measure. +/// A closure region **without** a return-shape fact is an ordinary rule-1 +/// denial — the closure half of `a_return_in_a_plain_function_…`. +/// +/// R0 asserted the stronger statement (*no* closure is ever served) and that +/// was the honest measurement then: `collect_return_shape_functions` issued +/// facts only for `hir.functions` entries and the caller-side seed only fired +/// on a bare `Expr::FuncRef` callee, which a closure call never is. #7170 R1 +/// makes both halves reach a closure, so the surviving assertion is the one +/// that still bites: servedness keys on the FACT, never on the syntax of the +/// returns. A classifier that read the return shape alone would mark this +/// whole population — 91.6% of dependency-JS allocation sites (#7170 §2) — +/// served and delete the wall it is supposed to measure. #[test] -fn a_closure_region_never_reports_a_served_return() { +fn a_closure_region_without_the_fact_is_still_a_rule_1_denial() { let c = class_with_fields("C", &["x"]); let mut classes = HashMap::new(); classes.insert("C".to_string(), &c); let stmts = vec![Stmt::Return(Some(new_c()))]; let session = Session::start(); - let _guard = crate::opt_report::enter_closure("mk", 7); + let _guard = crate::opt_report::enter_closure("mk", 7, false); let _ = run(&stmts, &classes); drop(_guard); let entries = session.entries(); @@ -502,8 +507,42 @@ fn a_closure_region_never_reports_a_served_return() { assert_eq!( rows[0].rule.as_deref(), Some("rule 1 (provenance)"), - "a closure body cannot carry a return-shape fact" + "servedness must come from the fact, not from the return's syntax" + ); + assert_eq!( + rows[0].tier, + Some(crate::opt_report::Tier::CompilerLimitation) + ); +} + +/// #7170 R1: a closure region **with** a return-shape fact reports its return +/// site as served, exactly as a function region does. +/// +/// This is what stops `codegen/closure.rs` reverting to R0's hard-coded +/// `false`: with the flag pinned off, every CommonJS module's producer sites +/// go back into the rule-1 bucket schedulers read, while the compiler is in +/// fact serving them. Paired with the test above, the two directions cannot +/// both be satisfied by a constant. +#[test] +fn a_closure_region_with_the_fact_reports_a_served_return() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + let stmts = vec![Stmt::Return(Some(new_c()))]; + + let session = Session::start(); + let _guard = crate::opt_report::enter_closure("mk", 7, true); + let _ = run(&stmts, &classes); + drop(_guard); + let entries = session.entries(); + + let rows = alloc_rows(&entries); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].rule.as_deref(), + Some("rule 1 (provenance) — already served by return-shape"), ); + assert_eq!(rows[0].tier, Some(crate::opt_report::Tier::Served)); } /// Only the RETURN position is served, across both nesting shapes a `return` diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs index 1dfe2fe07a..768ce13b82 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_returns.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns.rs @@ -87,18 +87,43 @@ use std::collections::{HashMap, HashSet}; -use perry_hir::{Class, Expr, Function, Module, Stmt}; +use perry_hir::types::Type; +use perry_hir::{Class, Expr, Module, Stmt}; use super::ptr_shape::{chain_admissible, ptr_shape_locals_enabled}; use super::ptr_shape_report as report; use super::ModuleDispatchFacts; -/// Module pre-pass: which module-level functions carry a return-shape fact. +/// The producer-side view of one candidate body — the three things +/// [`producer_return_class`] needs, and nothing else. +/// +/// #7170 R1: a `hir.functions` entry and an `Expr::Closure` are the same thing +/// to this proof, but they are different Rust types with differently-spelled +/// context flags (`Function::was_plain_async` versus +/// `Module::async_step_closures`). Projecting both onto one struct is what +/// stops the closure arm from quietly proving something weaker than the +/// function arm — the two callers below fill exactly the same fields. +struct ProducerBody<'a> { + /// Any context that routes body locals through a shared mutable cell, or + /// whose `return` is not a single-exit terminator: `async`, generator, and + /// the CPS-rewritten async-step form of either. + boxed_or_resumable: bool, + return_type: &'a Type, + body: &'a [Stmt], +} + +/// Module pre-pass: which bodies carry a return-shape fact — every +/// `hir.functions` entry (#7107) and every `Expr::Closure` (#7170 R1), keyed by +/// `FuncId`. /// /// `facts` must already have its barrier flags final and its own /// `return_shape_functions` map still EMPTY — the per-producer proof re-enters /// [`super::ptr_shape::collect_shape_proven_ptr_locals`], which consults that /// map, and an empty map is what makes the recursion impossible. +/// `facts.closure_bindings` is already populated when this runs and that is +/// harmless for the same reason: the caller-side seed +/// ([`find_return_shape_candidates`]) resolves a callee through it and then +/// asks `return_shape_class`, which is still empty, so no seed is taken. pub(crate) fn collect_return_shape_functions( facts: &ModuleDispatchFacts, hir: &Module, @@ -113,16 +138,150 @@ pub(crate) fn collect_return_shape_functions( .map(|c| (c.name.clone(), c)) .collect::>(); for f in &hir.functions { - if let Some(class_name) = producer_return_class(f, &classes, facts) { + let view = ProducerBody { + boxed_or_resumable: f.is_async || f.is_generator || f.was_plain_async, + return_type: &f.return_type, + body: &f.body, + }; + if let Some(class_name) = producer_return_class(&view, &classes, facts) { out.insert(f.id, class_name); } } + // #7170 R1: every closure literal in the module is a producer candidate + // too. Perry's own `cjs_wrap` emits each CommonJS module body inside an + // IIFE, so a module-level `function` declaration never reaches + // `hir.functions` at all — it lowers to `Stmt::Let { init: Expr::Closure }` + // inside that IIFE's body. #7170 §6 measured a probe that promotes and is + // consumed as a plain module and is *not even a candidate* wrapped, and + // §2 measured 91.6% of dependency-JS allocation sites in `closure` regions. + // + // The fact is keyed by `FuncId`. Lowering allocates closure ids and + // `hir.functions` ids from one module-wide counter (`fresh_func`), so + // within one lowering pass a key means one thing — but that is NOT true + // after every pass, and this map is read after all of them: + // + // * `monomorph::MonomorphizationContext::new` seeds its fresh ids at + // `max(hir.functions ids) + 1000`, computed over `hir.functions` + // ONLY. A module with few generic functions and many closures can hand + // a specialization the id of an existing closure. + // * any pass that clones a body without renumbering leaves two closures + // wearing one id. + // + // Neither is reachable today through this map alone — a monomorphized + // function and a closure lower to differently-named symbols — but a fact + // attributed to the wrong body is a guard-free load at the wrong offsets, + // so the key's uniqueness is ENFORCED here rather than assumed. Any + // `FuncId` claimed by more than one producer body loses its fact entirely, + // in both directions (closure-vs-closure and closure-vs-function). + let mut claims: HashMap = HashMap::new(); + let mut closure_facts: Vec<(u32, String)> = Vec::new(); + for_each_module_closure(hir, &mut |closure| { + let Expr::Closure { + func_id, + return_type, + body, + is_async, + is_generator, + .. + } = closure + else { + return; + }; + let func_id = *func_id; + *claims.entry(func_id).or_insert(0) += 1; + if claims[&func_id] > 1 { + return; + } + let view = ProducerBody { + // The closure spellings of the same three exclusions + // `codegen/closure.rs` maps onto the body gate's rule names: + // `is_async`, a `function*` expression, and the CPS-rewritten + // async closure (whose rewrite CLEARS `is_async`, so the flag alone + // would not catch it). A generator closure whose transform already + // ran has `is_generator` cleared too and is caught by + // `body_returns_generator_object`. + boxed_or_resumable: *is_async + || *is_generator + || hir.async_step_closures.contains(&func_id) + || crate::codegen::helpers::function_body_returns_generator_object(body), + return_type, + body, + }; + if let Some(class_name) = producer_return_class(&view, &classes, facts) { + closure_facts.push((func_id, class_name)); + } + }); + for (func_id, class_name) in closure_facts { + // A closure id that a `hir.functions` entry already claimed, or that a + // second closure also carries, describes two bodies. Drop the key, not + // just the new claim: the function-side fact is no more attributable + // than the closure-side one once the id is ambiguous. + if claims.get(&func_id).copied().unwrap_or(0) > 1 || out.contains_key(&func_id) { + out.remove(&func_id); + continue; + } + out.insert(func_id, class_name); + } + // A closure that carried NO fact still contests the key. + for (func_id, n) in &claims { + if *n > 1 { + out.remove(func_id); + } + } + for f in &hir.functions { + if claims.contains_key(&f.id) { + out.remove(&f.id); + } + } out } -/// The class a call to `f` provably returns, or `None`. +/// Visit every expression of every executable body in the module, including +/// inside nested closure bodies. +/// +/// Reuses `scalar_method_dispatch`'s own walker over the same body list +/// `collect_module_dispatch_facts` scans for barriers, so a closure this pass +/// proves and a barrier that pass finds are drawn from one set of bodies. +/// A closure that neither reaches simply carries no fact, which is the safe +/// direction. +fn for_each_module_closure(hir: &Module, f: &mut dyn FnMut(&Expr)) { + use super::scalar_method_dispatch::{for_each_expr, for_each_expr_in_stmts}; + + for_each_expr_in_stmts(&hir.init, f); + for func in &hir.functions { + for_each_expr_in_stmts(&func.body, f); + } + for c in &hir.classes { + if let Some(ctor) = &c.constructor { + for_each_expr_in_stmts(&ctor.body, f); + } + for m in c + .methods + .iter() + .chain(c.static_methods.iter()) + .chain(c.getters.iter().map(|(_, g)| g)) + .chain(c.setters.iter().map(|(_, s)| s)) + .chain(c.computed_members.iter().map(|m| &m.function)) + { + for_each_expr_in_stmts(&m.body, f); + } + for field in c.fields.iter().chain(c.static_fields.iter()) { + if let Some(init) = &field.init { + for_each_expr(init, f); + } + if let Some(key) = &field.key_expr { + for_each_expr(key, f); + } + } + for member in &c.computed_members { + for_each_expr(&member.key_expr, f); + } + } +} + +/// The class a call to this body provably returns, or `None`. fn producer_return_class( - f: &Function, + f: &ProducerBody<'_>, classes: &HashMap, facts: &ModuleDispatchFacts, ) -> Option { @@ -130,7 +289,7 @@ fn producer_return_class( // the async-to-generator transform boxes body locals into one shared // mutable cell, so no containment fact survives it. A generator's `return` // is also not a single-exit terminator in the sense rule 2 relies on. - if f.is_async || f.is_generator || f.was_plain_async { + if f.boxed_or_resumable { return None; } // GC: the caller's binding must be able to GET a shadow slot. @@ -146,7 +305,7 @@ fn producer_return_class( // Calls `pointer_locals`'s own predicate rather than restating it: a second // copy drifting by one `Type` variant is precisely how a value ends up // unrooted there while this pass treats it as a live movable pointer. - if super::pointer_locals::is_definitely_non_pointer_type(&f.return_type) { + if super::pointer_locals::is_definitely_non_pointer_type(f.return_type) { return None; } // The body must not be able to fall off its end (module doc). @@ -155,7 +314,7 @@ fn producer_return_class( _ => return None, } let mut returns = Vec::new(); - if !collect_own_returns(&f.body, &mut returns) { + if !collect_own_returns(f.body, &mut returns) { // A bare `return;` — the caller would see `undefined`. return None; } @@ -172,7 +331,7 @@ fn producer_return_class( Expr::LocalGet(id) => { // Resolved against the producer's own Phase 3b proof below; // find its declared class first so disagreement short-circuits. - let c = seeded_class_of_local(&f.body, *id)?; + let c = seeded_class_of_local(f.body, *id)?; (c, Some(*id)) } _ => return None, @@ -202,21 +361,21 @@ fn producer_return_class( // `codegen/module_globals_emit.rs` only ever records ids of top-level // `hir.init` lets, and every candidate here comes from a `Stmt::Let` // inside this function body. - let boxed = crate::boxed_vars::collect_boxed_vars(&f.body); + let boxed = crate::boxed_vars::collect_boxed_vars(f.body); let _quiet = report::SuppressScope::new(); // #7034 §3: the producer's own element-shape facts, so this body // proof reaches the same verdict the real pass will. Passing an empty // set instead would make the two disagree, and the aliasing check // below needs the facts anyway. let elements = super::ptr_shape_elements::collect_element_shape_facts( - &f.body, + f.body, &boxed, &HashMap::new(), classes, facts, ); let promoted = super::ptr_shape::collect_shape_proven_ptr_locals( - &f.body, + f.body, &boxed, &HashMap::new(), classes, @@ -357,13 +516,10 @@ pub(crate) fn find_return_shape_candidates( if boxed_vars.contains(id) || module_globals.contains_key(id) { return; } - // Only a direct `Expr::FuncRef` callee names one statically-known - // function — the same resolution `clamp3_functions` / hot-callee - // inlining already rely on. Anything computed could be rebound. - let Expr::FuncRef(func_id) = callee.as_ref() else { + let Some(func_id) = callee_names_one_function(callee, module_dispatch) else { return; }; - if let Some(class_name) = module_dispatch.return_shape_class(*func_id) { + if let Some(class_name) = module_dispatch.return_shape_class(func_id) { candidates.insert(*id, class_name.to_string()); seeded.insert(*id); } @@ -371,6 +527,37 @@ pub(crate) fn find_return_shape_candidates( seeded } +/// The one statically-known function a callee expression names, or `None`. +/// +/// * `Expr::FuncRef(id)` — a direct function symbol. The original #7107 form, +/// and the same resolution `clamp3_functions` / hot-callee inlining rely on. +/// * `Expr::LocalGet(id)` where `id` provably names one closure literal +/// module-wide (#7170 R1). Perry's own `cjs_wrap` IIFE lowers every CommonJS +/// module-level `function` declaration to a `Stmt::Let { init: Expr::Closure +/// }`, so the `FuncRef` form never occurs inside one and #7107 was +/// structurally unreachable across the CommonJS ecosystem (#7170 §6). +/// +/// The binding proof — exactly one `Stmt::Let`, never reassigned at any +/// depth in any body, never also a parameter or `catch` binding — lives in +/// `collectors/spec_abi_sites.rs::single_binding_closure_locals`, beside the +/// module-wide reassignment scan it is built from. It makes the same +/// statement about the callee that `FuncRef` makes directly, and it is the +/// only property this seed needs of a callee: **which body runs**. +/// Deliberately NOT the same statement as `FnCtx::local_closure_func_ids`, +/// which `lower_call` pairs with a runtime +/// `js_typed_feedback_closure_direct_call_guard` because it is populated in +/// statement order and a later rebinding invalidates it. +/// +/// Anything else — a property get, a computed callee, an `ExternFuncRef` — +/// could resolve to a different body, and yields `None`. +fn callee_names_one_function(callee: &Expr, module_dispatch: &ModuleDispatchFacts) -> Option { + match callee { + Expr::FuncRef(func_id) => Some(*func_id), + Expr::LocalGet(local_id) => module_dispatch.closure_binding_func(*local_id), + _ => None, + } +} + #[cfg(test)] #[path = "ptr_shape_returns_tests.rs"] mod tests; diff --git a/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs index b027eef098..a70062ff26 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_returns_tests.rs @@ -9,7 +9,7 @@ use super::*; use crate::collectors::PtrShapeLocal; use perry_hir::types::{FuncId, Type}; -use perry_hir::{ClassField, Param}; +use perry_hir::{ClassField, Function, Param}; fn field(name: &str) -> ClassField { ClassField { @@ -446,8 +446,13 @@ fn module_barrier_denies_every_fact() { assert_eq!(facts.return_shape_class(10), None); } -/// Only a direct `Expr::FuncRef` callee names a statically-known function. -/// A computed callee could be rebound between the fact and the call. +/// A callee that names nothing statically is not seeded. #7170 R1 widened the +/// resolution to a `LocalGet` whose binding provably names one closure literal +/// module-wide, and local 31 here is bound by nothing at all — so it resolves +/// to `None` and the seed is not taken, exactly as before R1. +/// +/// Sabotage: make `callee_names_one_function`'s `LocalGet` arm return a fixed +/// `FuncId` instead of consulting `closure_binding_func` and this fails. #[test] fn indirect_callee_is_not_seeded() { let (facts, c) = facts_for(vec![function(11, "mk", vec![Stmt::Return(Some(new_c()))])]); @@ -460,7 +465,7 @@ fn indirect_callee_is_not_seeded() { ty: Type::Any, mutable: false, init: Some(Expr::Call { - // A closure value in a local, not a FuncRef. + // A local the module never binds — nothing to resolve to. callee: Box::new(Expr::LocalGet(31)), args: Vec::new(), type_args: Vec::new(), @@ -588,3 +593,517 @@ fn boxed_producer_local_gets_no_fact() { let (facts, _) = facts_for(vec![function(16, "boxy", body)]); assert_eq!(facts.return_shape_class(16), None); } + +// ── #7170 R1: the mechanism inside Perry's own CommonJS IIFE ─────────────── +// +// `cjs_wrap` emits every CommonJS module body inside `const _cjs = (function +// () { … })();`, so a module-level `function` declaration never reaches +// `hir.functions` — it lowers to `Stmt::Let { init: Expr::Closure }` inside +// that IIFE, and a call to it to `Call { callee: LocalGet(id) }`. Both halves +// of #7107 missed it, and #7170 §2 measured 91.6% of dependency-JS allocation +// sites in `closure` regions as a result. +// +// The shapes below are transcribed from `--print-hir` of the §6 `p8_iife` +// probe, `PreallocateBoxes` included: a hoisted inner `function` referenced +// from a sibling closure is box-backed by construction +// (`lower_decl/block.rs`), so a proof that refused boxed callees would refuse +// the entire population this exists for. + +/// `Stmt::Let { id, init: Expr::Closure { func_id, body } }` — how a `function` +/// declaration inside a function body lowers. +fn let_closure(id: u32, name: &str, func_id: FuncId, body: Vec) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: false, + }), + } +} + +/// `const = ()` followed by `.x = 1`, the caller shape the +/// seed has to reach. The field store is deliberate: without it +/// `escape_news.rs` deletes the object outright and the promotion is +/// `unconsumed — scalar_replaced` (#7170 §6.1). +fn call_and_store(bind: u32, callee: Expr) -> Vec { + vec![ + Stmt::Let { + id: bind, + name: "p".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(callee), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }, + store_x(bind), + ] +} + +/// The `p8_iife` module: `const _cjs = (function () { function mk() { return +/// new C(); } function run() { const p = mk(); p.x = 1; return p; } return +/// run(); })();`, with `mk`'s binding box-backed exactly as the lowering +/// emits it. +/// +/// `extra_iife_stmts` is spliced in after the two declarations so a test can +/// add the one thing that must break the proof. +fn iife_module(extra_iife_stmts: Vec) -> Module { + let mut iife_body = vec![ + Stmt::PreallocateBoxes(vec![1]), + let_closure(1, "mk", 1, vec![Stmt::Return(Some(new_c()))]), + let_closure(2, "run", 3, run_body()), + ]; + iife_body.extend(extra_iife_stmts); + iife_body.push(Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::LocalGet(2)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }))); + + let mut hir = Module::new("t"); + hir.classes.push(class_c()); + hir.init = vec![Stmt::Let { + id: 0, + name: "_cjs".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Call { + callee: Box::new(match let_closure(99, "iife", 0, iife_body) { + Stmt::Let { init: Some(e), .. } => e, + _ => unreachable!(), + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + }]; + hir +} + +/// `run`'s body: `const p = mk(); p.x = 1; return p;` +fn run_body() -> Vec { + let mut b = call_and_store(9, Expr::LocalGet(1)); + b.push(Stmt::Return(Some(Expr::LocalGet(9)))); + b +} + +/// End to end, and the whole point of R1: the producer half reaches a closure +/// and the consumer half resolves a `LocalGet` callee to it. +/// +/// Sabotage, each alone: drop the `for_each_module_closure` loop in +/// `collect_return_shape_functions` (the fact disappears); make +/// `callee_names_one_function` accept only `Expr::FuncRef` (the seed +/// disappears). Either one takes this red while every #7107 test stays green, +/// which is exactly the state `main` is in. +#[test] +fn a_function_declared_inside_the_cjs_iife_is_a_producer_and_its_caller_is_seeded() { + let hir = iife_module(Vec::new()); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!( + facts.return_shape_class(1), + Some("C"), + "a closure literal must be able to carry a return-shape fact" + ); + assert_eq!( + facts.closure_binding_func(1), + Some(1), + "`mk`'s binding must resolve to the closure it is bound to" + ); + + let c = class_c(); + let classes = classes_of(&c); + assert!( + promote(&run_body(), &classes, &facts).contains_key(&9), + "`const p = mk()` inside the IIFE must be a Ptr candidate" + ); +} + +/// The `PreallocateBoxes` binding is not incidental: it is what the real +/// lowering emits for a hoisted `function` referenced from a sibling closure, +/// and it is the entire dependency-JS population. A binding proof that refused +/// box-backed callees would be green on every hand-written fixture and dead on +/// real code. +#[test] +fn a_box_backed_callee_binding_is_still_resolved() { + let hir = iife_module(Vec::new()); + assert!( + matches!( + first_iife_stmt(&hir), + Some(Stmt::PreallocateBoxes(ids)) if ids.contains(&1) + ), + "fixture premise: `mk`'s binding is box-backed" + ); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!(facts.closure_binding_func(1), Some(1)); +} + +fn first_iife_stmt(hir: &Module) -> Option<&Stmt> { + let Some(Stmt::Let { + init: Some(Expr::Call { callee, .. }), + .. + }) = hir.init.first() + else { + return None; + }; + let Expr::Closure { body, .. } = callee.as_ref() else { + return None; + }; + body.first() +} + +/// Assert that adding `extra` to the IIFE body kills the binding proof, and +/// that the seed it kills was really there without it. +fn binding_is_killed_by(extra: Vec, what: &str) { + let hir = iife_module(extra); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!( + facts.closure_binding_func(1), + None, + "{what} must disqualify the callee binding" + ); + let c = class_c(); + let classes = classes_of(&c); + assert!( + !promote(&run_body(), &classes, &facts).contains_key(&9), + "{what} must also stop the caller-side seed" + ); +} + +/// A reassignment ANYWHERE in the module — including inside a sibling closure, +/// which is where a CommonJS module actually puts them — means the callee no +/// longer names one body. +/// +/// Sabotage: drop the `!scan.writes.contains(id)` conjunct in +/// `single_binding_closure_locals` and this fails. +#[test] +fn a_reassigned_callee_binding_is_not_resolved() { + binding_is_killed_by( + vec![Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Undefined)))], + "a bare reassignment", + ); + // …and the same write hidden inside a closure body, which is the position + // a per-region scan would miss. + binding_is_killed_by( + vec![Stmt::Expr(Expr::Closure { + func_id: 77, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Undefined)))], + captures: vec![1], + mutable_captures: vec![1], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + })], + "a reassignment inside a sibling closure", + ); +} + +/// Two `Stmt::Let`s on one id (the `var` re-declaration shape) means the +/// binding is not unique, so the callee is whichever ran last. +/// +/// Sabotage: drop the `let_counts == 1` conjunct and this fails. +#[test] +fn a_twice_bound_callee_binding_is_not_resolved() { + binding_is_killed_by( + vec![let_closure(1, "mk", 42, vec![Stmt::Return(Some(new_c()))])], + "a second binding of the same id", + ); +} + +/// `with (o) { mk = v }` stores into the LOCAL when `o` does not bind the name. +/// The id lives in `WithSetFallback`, not in a child expression, so the +/// module-wide walker could not see it before R1 added the arm. +/// +/// Sabotage: delete the `Expr::WithSet` arm in +/// `spec_abi_sites.rs::record_expr_use` and this fails. +#[test] +fn a_with_statement_write_disqualifies_the_callee_binding() { + binding_is_killed_by( + vec![Stmt::Expr(Expr::WithSet { + object: Box::new(Expr::Undefined), + property: "mk".to_string(), + value: Box::new(Expr::Undefined), + fallback: perry_hir::WithSetFallback::Local(1), + strict: false, + })], + "a `with` fallback store", + ); +} + +/// A `catch (mk)` clause rebinds the id for the duration of the handler, and +/// `let_counts` cannot see it. +/// +/// Sabotage: drop the `c.param` recording in `spec_abi_sites.rs`'s `Stmt::Try` +/// arm and this fails. +#[test] +fn a_catch_bound_callee_id_is_not_resolved() { + binding_is_killed_by( + vec![Stmt::Try { + body: Vec::new(), + catch: Some(perry_hir::CatchClause { + param: Some((1, "mk".to_string())), + body: Vec::new(), + }), + finally: None, + }], + "a catch binding on the same id", + ); +} + +/// A parameter is written by the CALLER, which neither `let_counts` nor +/// `writes` records. `var` hoisting can reuse a parameter's id for a body +/// `var`, so a single-`Let`-and-no-writes id can still have held an argument +/// before that `Let` ran. +/// +/// Sabotage: drop `record_param_bindings` / the closure-param recording and +/// this fails. +#[test] +fn a_callee_id_that_is_also_a_parameter_is_not_resolved() { + let mut hir = iife_module(Vec::new()); + let mut f = function(60, "outer", vec![Stmt::Return(Some(new_c()))]); + f.params = vec![Param { + id: 1, + name: "mk".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }]; + hir.functions.push(f); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!(facts.closure_binding_func(1), None); +} + +/// A callee bound to something that is not a closure literal resolves to +/// nothing — the map is a whitelist, so widening the seed set cannot make one +/// of these appear. +#[test] +fn a_non_closure_binding_is_not_resolved() { + let hir = iife_module(Vec::new()); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!( + facts.closure_binding_func(0), + None, + "`_cjs` is bound to a Call, not a Closure" + ); + assert_eq!(facts.closure_binding_func(9), None, "`p` likewise"); +} + +/// Producer-side context exclusions, in the closure spellings. +/// +/// `is_async` and `is_generator` live on the closure; the CPS-rewritten async +/// closure CLEARS `is_async` and is identified only by +/// `Module::async_step_closures`, so the flag alone would let it through. +/// +/// Sabotage: drop any one conjunct of `boxed_or_resumable` in the closure arm +/// and the matching case here fails. +#[test] +fn an_async_or_generator_closure_producer_gets_no_fact() { + for (label, mutate) in [ + ( + "async", + Box::new(|hir: &mut Module| set_mk_flag(hir, true, false)) as Box, + ), + ( + "generator", + Box::new(|hir: &mut Module| set_mk_flag(hir, false, true)), + ), + ( + "async-step", + Box::new(|hir: &mut Module| { + hir.async_step_closures.insert(1); + }), + ), + ] { + let mut hir = iife_module(Vec::new()); + mutate(&mut hir); + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!( + facts.return_shape_class(1), + None, + "a {label} closure producer must carry no return-shape fact" + ); + } + // The control: untouched, the same fixture DOES carry the fact, so none of + // the three above is passing because the fixture stopped working. + let facts = super::super::collect_module_dispatch_facts(&iife_module(Vec::new())); + assert_eq!(facts.return_shape_class(1), Some("C")); +} + +fn set_mk_flag(hir: &mut Module, async_: bool, generator: bool) { + let Some(Stmt::Let { + init: Some(Expr::Call { callee, .. }), + .. + }) = hir.init.first_mut() + else { + panic!("fixture shape"); + }; + let Expr::Closure { body, .. } = callee.as_mut() else { + panic!("fixture shape"); + }; + for s in body.iter_mut() { + if let Stmt::Let { + id: 1, + init: + Some(Expr::Closure { + is_async, + is_generator, + .. + }), + .. + } = s + { + *is_async = async_; + *is_generator = generator; + return; + } + } + panic!("fixture shape: no `mk` binding"); +} + +/// Freshness still has to be discharged through the wrapper: a closure that +/// hands back something it did not allocate carries no fact, exactly as the +/// `hir.functions` arm requires. +/// +/// Sabotage: skip the `collect_shape_proven_ptr_locals` body proof for the +/// closure arm and this fails. +#[test] +fn a_closure_producer_returning_a_cached_value_gets_no_fact() { + let mut hir = iife_module(Vec::new()); + let Some(Stmt::Let { + init: Some(Expr::Call { callee, .. }), + .. + }) = hir.init.first_mut() + else { + panic!("fixture shape"); + }; + let Expr::Closure { body, .. } = callee.as_mut() else { + panic!("fixture shape"); + }; + for s in body.iter_mut() { + if let Stmt::Let { + id: 1, + init: Some(Expr::Closure { body, .. }), + .. + } = s + { + // `return CACHE` — a local this body never allocated. + *body = vec![Stmt::Return(Some(Expr::LocalGet(500)))]; + } + } + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!(facts.return_shape_class(1), None); +} + +/// The recursion invariant R1 inherits: `collect_return_shape_functions` +/// re-enters `collect_shape_proven_ptr_locals` over each producer body while +/// `return_shape_functions` is still empty. `closure_bindings` IS populated by +/// then, so the seed can now RESOLVE a callee during that re-entry — and must +/// still take no seed, because the class map it then consults is empty. +/// +/// `mk` calling `mk2` and `mk2` calling `mk` is the shape that would diverge. +#[test] +fn mutually_calling_closure_producers_terminate() { + let mut hir = Module::new("t"); + hir.classes.push(class_c()); + let mut inner = call_and_store(20, Expr::LocalGet(11)); + inner.push(Stmt::Return(Some(new_c()))); + let mut inner2 = call_and_store(21, Expr::LocalGet(10)); + inner2.push(Stmt::Return(Some(new_c()))); + hir.init = vec![ + let_closure(10, "mk", 1, inner), + let_closure(11, "mk2", 2, inner2), + ]; + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!(facts.return_shape_class(1), Some("C")); + assert_eq!(facts.return_shape_class(2), Some("C")); +} + +/// #7170 R1, key uniqueness. `return_shape_functions` is keyed by raw `FuncId` +/// and read after every transform. `monomorph::MonomorphizationContext::new` +/// seeds its fresh ids at `max(hir.functions ids) + 1000` computed over +/// `hir.functions` ONLY, so a module with few generic functions and many +/// closures can hand a specialization the id of an existing closure; a pass +/// that clones a body without renumbering does the same thing to two closures. +/// +/// A fact attributed to the wrong body is a guard-free load at the wrong +/// offsets. Both directions must therefore lose the key, not resolve it by +/// walk order. +/// +/// Sabotage: restore "first occurrence wins" (`if !seen.insert(func_id) +/// { return; }`) and both halves fail. +#[test] +fn a_contested_func_id_carries_no_fact() { + // (a) two closures wearing one id, only the FIRST of which is a producer. + let mut hir = Module::new("t"); + hir.classes.push(class_c()); + hir.init = vec![ + let_closure(10, "mk", 1, vec![Stmt::Return(Some(new_c()))]), + let_closure(11, "other", 1, vec![Stmt::Return(Some(Expr::Number(1.0)))]), + ]; + let facts = super::super::collect_module_dispatch_facts(&hir); + assert_eq!( + facts.return_shape_class(1), + None, + "a FuncId two closure bodies claim cannot carry a fact" + ); + + // The control: the same module with distinct ids does carry it, so (a) is + // not passing because the fixture stopped producing facts. + let mut ok = Module::new("t"); + ok.classes.push(class_c()); + ok.init = vec![ + let_closure(10, "mk", 1, vec![Stmt::Return(Some(new_c()))]), + let_closure(11, "other", 2, vec![Stmt::Return(Some(Expr::Number(1.0)))]), + ]; + assert_eq!( + super::super::collect_module_dispatch_facts(&ok).return_shape_class(1), + Some("C") + ); + + // (b) a `hir.functions` entry and a closure wearing one id — the + // monomorph-collision shape. The FUNCTION's fact goes too: once the id is + // ambiguous neither body is attributable. + let mut collide = Module::new("t"); + collide.classes.push(class_c()); + collide.functions = vec![function(1, "fn_mk", vec![Stmt::Return(Some(new_c()))])]; + collide.init = vec![let_closure(10, "mk", 1, vec![Stmt::Return(Some(new_c()))])]; + assert_eq!( + super::super::collect_module_dispatch_facts(&collide).return_shape_class(1), + None, + "a FuncId a function and a closure both claim cannot carry a fact" + ); + + // Control for (b): the function alone keeps its #7107 fact. + let mut alone = Module::new("t"); + alone.classes.push(class_c()); + alone.functions = vec![function(1, "fn_mk", vec![Stmt::Return(Some(new_c()))])]; + assert_eq!( + super::super::collect_module_dispatch_facts(&alone).return_shape_class(1), + Some("C") + ); +} diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index 231e621853..c8bbb51177 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -88,6 +88,20 @@ pub struct ModuleDispatchFacts { /// (`collectors/ptr_shape_returns.rs`); a call to such a function is then /// a rule-1 provenance seed exactly as `new C(...)` is. return_shape_functions: HashMap, + /// Representation-selection Phase 3b, #7170 R1: `LocalId` -> `FuncId` for + /// every local that provably names one closure literal, module-wide. + /// + /// Perry's own `cjs_wrap` puts every CommonJS module body in an IIFE, so a + /// module-level `function` declaration lowers to `Stmt::Let { init: + /// Expr::Closure }` — never a `hir.functions` entry — and a call to it to + /// `Call { callee: LocalGet(id) }`, never `Expr::FuncRef`, which is all + /// #7107's caller-side seed accepted. #7170 §6 measured that as 91.6% of + /// dependency-JS allocation sites. + /// + /// The proof is in `collectors/spec_abi_sites.rs` + /// (`single_binding_closure_locals`), beside the module-wide reassignment + /// scan it rests on. + closure_bindings: HashMap, } impl Default for ModuleDispatchFacts { @@ -101,6 +115,7 @@ impl Default for ModuleDispatchFacts { numarray_prototype_index_barriers: true, freeze_barrier_sites: true, return_shape_functions: HashMap::new(), + closure_bindings: HashMap::new(), } } } @@ -175,6 +190,15 @@ impl ModuleDispatchFacts { .get(&func_id) .map(String::as_str) } + + /// Representation-selection Phase 3b, #7170 R1: the `FuncId` that + /// `LocalGet(local_id)` in callee position provably names, or `None`. + /// + /// `None` is the safe direction everywhere it is read: the seed is simply + /// not taken, exactly as before R1. + pub(crate) fn closure_binding_func(&self, local_id: u32) -> Option { + self.closure_bindings.get(&local_id).copied() + } } /// Scan a whole module — top-level init, every function, and every class body @@ -188,6 +212,11 @@ pub fn collect_module_dispatch_facts(hir: &Module) -> ModuleDispatchFacts { numarray_prototype_index_barriers: false, freeze_barrier_sites: false, return_shape_functions: HashMap::new(), + // #7170 R1. Purely structural — no barrier flag feeds it, and it is + // read only through `closure_binding_func`, whose every consumer treats + // `None` as "take no seed". Computed here rather than lazily so the one + // module-wide walk it needs happens once. + closure_bindings: super::spec_abi_sites::single_binding_closure_locals(hir), }; // #7139: resolve the CommonJS wrap's `exports` / `require` scaffolding @@ -649,6 +678,7 @@ mod tests { numarray_prototype_index_barriers: false, freeze_barrier_sites: false, return_shape_functions: HashMap::new(), + closure_bindings: HashMap::new(), } } diff --git a/crates/perry-codegen/src/collectors/spec_abi_sites.rs b/crates/perry-codegen/src/collectors/spec_abi_sites.rs index 3e1b2cb9f6..676f73001b 100644 --- a/crates/perry-codegen/src/collectors/spec_abi_sites.rs +++ b/crates/perry-codegen/src/collectors/spec_abi_sites.rs @@ -141,6 +141,48 @@ pub(crate) fn local_is_reassigned(stmts: &[Stmt], id: u32) -> bool { reassigned_locals(stmts).contains(&id) } +/// Module-wide: every local that provably names ONE closure literal, mapped to +/// that closure's `FuncId` (#7170 R1). +/// +/// The binding must be, module-wide: +/// +/// * bound by **exactly one** `Stmt::Let` (`let_counts == 1`), whose init is an +/// `Expr::Closure`; +/// * never reassigned at any depth, in any body — `writes` already covers +/// `LocalSet` / `GlobalSet` / `Update` inside nested closures, which is the +/// position that matters here (the CommonJS shape this exists for reads the +/// binding from inside a *sibling* closure); +/// * never bound by anything other than that `Let` — a function/closure +/// parameter or a `catch` clause binding of the same id (`other_bindings`). +/// +/// Those three make "`LocalGet(id)` in callee position names the function +/// `Expr::Closure { func_id }` lowers" the same statement `Expr::FuncRef(id)` +/// makes directly, which is the only property +/// `collectors/ptr_shape_returns.rs`'s caller-side seed needs of a callee. +/// Deliberately **not** required: that the binding is unboxed or uncaptured. +/// A hoisted `function` declaration referenced from a sibling closure is +/// `PreallocateBoxes`-boxed by construction (`lower_decl/block.rs`), and that +/// is the entire population #7170 §6 measured — a box holds the same one +/// closure value the single `Let` wrote into it. +/// +/// This is NOT `FnCtx::local_closure_func_ids`, which `lower_call` pairs with a +/// runtime `js_typed_feedback_closure_direct_call_guard` precisely because it +/// is populated in statement order and a later rebinding can invalidate it. +/// A `Ptr` proof licenses a guard-free field load, so it needs the +/// static statement, not the speculative one. +pub(crate) fn single_binding_closure_locals(hir: &Module) -> HashMap { + let scan = scan_whole_module(hir); + scan.let_closures + .iter() + .filter(|(id, _)| { + scan.let_counts.get(*id).copied() == Some(1) + && !scan.writes.contains(*id) + && !scan.other_bindings.contains(*id) + }) + .map(|(id, func_id)| (*id, *func_id)) + .collect() +} + /// Module-wide structural facts gathered in one walk. #[derive(Default)] struct ModuleScan { @@ -162,6 +204,18 @@ struct ModuleScan { /// value would yield the box address, so these can never be `TaPtr` /// bindings. boxed_prealloc: HashSet, + /// #7170 R1: `Stmt::Let { id, init: Expr::Closure { func_id } }` — the id + /// the closure literal was bound to, and which function it lowers. Recorded + /// unconditionally; [`single_binding_closure_locals`] applies the + /// uniqueness and no-reassignment conditions. + let_closures: HashMap, + /// #7170 R1: ids bound by something that is not a `Stmt::Let` — a function + /// or closure **parameter**, or a `catch` clause binding. `let_counts` + /// cannot see either, and `var` hoisting can reuse a parameter's id for a + /// body `var` (`function f(x) { var x = function(){} }`), so a + /// single-`Let`-plus-no-writes id can still have held the caller's argument + /// before that `Let` ran. + other_bindings: HashSet, } fn record_expr_use(e: &Expr, depth: u32, scan: &mut ModuleScan) { @@ -215,6 +269,22 @@ fn record_expr_use(e: &Expr, depth: u32, scan: &mut ModuleScan) { record_expr_use(key, depth, scan); record_expr_use(value, depth, scan); } + // `with (o) { x = v }` stores into the LOCAL `x` when `o` does not bind + // the name, so the fallback names a reassignment target. The id lives + // in `WithSetFallback`, not in a child expression, so the walker below + // cannot reach it — a hole this scan carried since it was written, and + // one #7170 R1's callee proof would inherit. + Expr::WithSet { fallback, .. } => { + if let perry_hir::WithSetFallback::Local(id) + | perry_hir::WithSetFallback::SloppyImplicit(id) = fallback + { + scan.writes.insert(*id); + if depth > 0 { + scan.closure_refs.insert(*id); + } + } + perry_hir::walker::walk_expr_children(e, &mut |c| record_expr_use(c, depth, scan)); + } // `new Int32Array(src)` consumes `src` by COPY (non-view: the arg is // not an ArrayBuffer when the binding proof later demands it), so the // ctor-arg position cannot change `src`'s length afterwards. Length @@ -238,6 +308,9 @@ fn record_expr_use(e: &Expr, depth: u32, scan: &mut ModuleScan) { scan.closure_refs.insert(*c); } for p in params { + // #7170 R1: a parameter is a binding written by the CALLER, + // which `let_counts` and `writes` both miss. + scan.other_bindings.insert(p.id); if let Some(default) = &p.default { record_expr_use(default, depth + 1, scan); } @@ -257,6 +330,15 @@ fn record_expr_use(e: &Expr, depth: u32, scan: &mut ModuleScan) { } } +/// #7170 R1: parameter ids of a top-level function, constructor, method or +/// accessor. A parameter is bound by the caller, which neither `let_counts` +/// nor `writes` records. +fn record_param_bindings(params: &[perry_hir::Param], scan: &mut ModuleScan) { + for p in params { + scan.other_bindings.insert(p.id); + } +} + /// Receiver position of an element access: a bare local read there is /// length-safe (element loads/stores don't resize typed arrays and can't /// reassign the binding). Anything more complex is scanned normally. @@ -283,6 +365,10 @@ fn walk_stmt(s: &Stmt, depth: u32, scan: &mut ModuleScan) { if depth > 0 { scan.closure_refs.insert(*id); } + // #7170 R1: which closure literal this binding names, if any. + if let Some(Expr::Closure { func_id, .. }) = init { + scan.let_closures.insert(*id, *func_id); + } if let Some(e) = init { record_expr_use(e, depth, scan); } @@ -337,6 +423,11 @@ fn walk_stmt(s: &Stmt, depth: u32, scan: &mut ModuleScan) { } => { walk_stmts(body, depth, scan); if let Some(c) = catch { + // #7170 R1: the catch binding is a binding `let_counts` never + // sees. + if let Some((id, _)) = &c.param { + scan.other_bindings.insert(*id); + } walk_stmts(&c.body, depth, scan); } if let Some(f) = finally { @@ -366,23 +457,29 @@ fn scan_whole_module(hir: &Module) -> ModuleScan { let mut scan = ModuleScan::default(); walk_stmts(&hir.init, 0, &mut scan); for f in &hir.functions { + record_param_bindings(&f.params, &mut scan); walk_stmts(&f.body, 0, &mut scan); } for c in &hir.classes { if let Some(ctor) = &c.constructor { + record_param_bindings(&ctor.params, &mut scan); walk_stmts(&ctor.body, 0, &mut scan); } for m in c.methods.iter().chain(c.static_methods.iter()) { + record_param_bindings(&m.params, &mut scan); walk_stmts(&m.body, 0, &mut scan); } for (_, g) in &c.getters { + record_param_bindings(&g.params, &mut scan); walk_stmts(&g.body, 0, &mut scan); } for (_, s) in &c.setters { + record_param_bindings(&s.params, &mut scan); walk_stmts(&s.body, 0, &mut scan); } for cm in &c.computed_members { record_expr_use(&cm.key_expr, 0, &mut scan); + record_param_bindings(&cm.function.params, &mut scan); walk_stmts(&cm.function.body, 0, &mut scan); } for field in c.fields.iter().chain(c.static_fields.iter()) { diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index 771f958358..0fa3e0652e 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -556,15 +556,19 @@ struct Scope { /// (`collectors/ptr_shape_returns.rs`, #7107), so its `return new C(...)` /// sites already feed an existing mechanism. /// - /// Set by exactly one caller — [`enter_function_region`], from - /// `codegen/function.rs`, which is the only place that holds both the - /// `FuncId` and `ModuleDispatchFacts`. Every other region (method, closure, - /// module-init) leaves it `false`, and that is CORRECT rather than - /// conservative: `collect_return_shape_functions` issues facts only for - /// `hir.functions` entries, and the caller-side seed - /// (`find_return_shape_candidates`) only fires on a bare `Expr::FuncRef` - /// callee — which a closure call never is. #7170 §6 is precisely the - /// measurement that closures are *not* served. + /// Set by exactly two callers — [`enter_function_region`] from + /// `codegen/function.rs` and [`enter_closure`] from `codegen/closure.rs`, + /// the only two places that hold both a `FuncId` and `ModuleDispatchFacts`. + /// + /// #7170 R1: the closure arm is not a widening of the report, it tracks a + /// widening of the mechanism. R0 recorded here that a closure could never + /// be served, because `collect_return_shape_functions` issued facts only + /// for `hir.functions` entries and the caller-side seed fired only on a + /// bare `Expr::FuncRef`. R1 makes both halves reach a closure, so a closure + /// region CAN now be a producer and reporting otherwise would put a served + /// site back in the rule-1 bucket schedulers read. Method and module-init + /// regions still leave it `false` and that is still correct — neither is a + /// `FuncId`-keyed producer. return_shape_producer: bool, } @@ -704,7 +708,17 @@ pub(crate) fn enter(module: &str, function: &str, region: RegionKind) -> ScopeGu /// Like [`enter`], for a closure body. `func_id` resolves the per-element /// callback role recorded by [`scan_module`] — the honest hotness column for /// bodies that have no loop of their own (#7034 §8). -pub(crate) fn enter_closure(function: &str, func_id: u32) -> ScopeGuard { +/// +/// `return_shape_producer` comes from `ModuleDispatchFacts::return_shape_class` +/// at the call site, exactly as [`enter_function_region`] takes it. Before +/// #7170 R1 this was hard-coded `false` and that was a *measurement* — a +/// closure could not carry the fact. R1 makes it one, so the flag has to be +/// passed rather than assumed. +pub(crate) fn enter_closure( + function: &str, + func_id: u32, + return_shape_producer: bool, +) -> ScopeGuard { if !enabled() { return ScopeGuard { previous: None, @@ -716,10 +730,7 @@ pub(crate) fn enter_closure(function: &str, func_id: u32) -> ScopeGuard { function: function.to_string(), region: RegionKind::Closure, invoked_per_element: per_element_role(Some(func_id)), - // #7170 §6: a closure is never a return-shape producer — the caller-side - // seed requires a bare `Expr::FuncRef` callee. This `false` is the - // measurement, not a default. - return_shape_producer: false, + return_shape_producer, }; let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); ScopeGuard { diff --git a/scripts/compiler_output_harness/repsel_census.py b/scripts/compiler_output_harness/repsel_census.py index d63c12c9b8..1ecfca3a24 100644 --- a/scripts/compiler_output_harness/repsel_census.py +++ b/scripts/compiler_output_harness/repsel_census.py @@ -199,6 +199,21 @@ # CLAUDE.md failure mode 4, exactly. Three promotions: the pushed producer, # the `rows[i]` binding, and the `for…of` binding. "fixture_ptr_shape_elements": {"ptr-shape": 3, "ptr-shape-consumed": 3}, + # #7170 R1 (Perry's own CommonJS IIFE). Byte-for-byte the same proof as + # `fixture_ptr_shape`, wrapped in `(function () { … })()` and nothing else. + # Inside that wrapper `mk` is not a `hir.functions` entry but a + # `Stmt::Let { init: Expr::Closure }`, and `mk(i)` is + # `Call { callee: LocalGet(id) }` — neither of which #7107's two halves + # accepted, which is why 91.6% of dependency-JS allocation sites sat in + # `closure` regions with no mechanism reaching them (#7170 §2/§6). + # + # Reverting either half of R1 takes this fixture to 0 while + # `fixture_ptr_shape` stays at 1: that one is at module scope and never + # needed the closure arm. The `-consumed` floor is the second half of the + # assertion — the fixture carries an in-loop field store precisely so + # `escape_news.rs` cannot delete the object and report a promotion that + # emits nothing (#7170 §6.1). + "fixture_ptr_shape_cjs_iife": {"ptr-shape": 1, "ptr-shape-consumed": 1}, "fixture_ptr_numarray": {"ptr-numarray": 1}, "fixture_canonical_slots": { "canonical-i32": 1, @@ -320,7 +335,31 @@ #: coding `false` in `codegen/function.rs`, or dropping the fact conjunct in #: `deny_alloc_site`, takes it red while every compiler unit test still passes, #: because those set the report scope by hand. +#: #7170 R1 added a second workload to this table for a reason the first one +#: cannot cover. `fixture_alloc_buckets` is module-scope code, so its served row +#: exercises `codegen/function.rs`. The served flag for a CLOSURE region is set +#: in `codegen/closure.rs`, and **no compiler unit test can reach that wiring** — +#: every one of them sets the report scope by hand, so hard-coding `false` +#: (R0's shipped value) or `true` there leaves all 515 of them green. Measured: +#: both sabotage arms are green holes in `cargo test` and red only here. +#: +#: The two rows are the two directions, and `fixture_ptr_shape_cjs_iife` is +#: written to land one allocation in each from inside the same IIFE: +#: +#: * `mk` carries a return-shape fact -> its `{ x, y }` is SERVED +#: * `maybe` cannot (its returns disagree) -> its `{ tag }` is UNSERVED +#: +#: Pinning `false` empties the served row; pinning `true` empties the unserved +#: one. Neither can be satisfied by a constant. ALLOC_BUCKET_FLOORS: dict[str, dict[tuple[str, str, str], int]] = { + "fixture_ptr_shape_cjs_iife": { + ("ptr-shape", "return", "rule 1 (provenance)"): 1, + ( + "ptr-shape", + "return", + "rule 1 (provenance) — already served by return-shape", + ): 1, + }, "fixture_alloc_buckets": { ("ptr-shape", "constructor argument", "rule 1 (provenance)"): 1, ("ptr-shape", "object literal property value", "rule 1 (provenance)"): 1, @@ -1409,10 +1448,45 @@ def self_test(_args: argparse.Namespace) -> int: alloc_bucket_key("ptr-shape", c, r): 1 for c, r in bucket_rows }, buckets["alloc_buckets"] - fixture = next(iter(ALLOC_BUCKET_FLOORS)) - good = {fixture: {"counts": buckets["counts"], **buckets}} + # Named, not `next(iter(...))`: #7170 R1 added a second fixture to the + # table, and an implicit "first entry" would have silently retargeted every + # sabotage assertion below at a fixture with different bucket rows. + fixture = "fixture_alloc_buckets" + assert fixture in ALLOC_BUCKET_FLOORS, sorted(ALLOC_BUCKET_FLOORS) + # Every fixture in the table has to be satisfiable from its own floors, or + # the table describes a shape nothing produces. + good = { + name: { + "counts": buckets["counts"], + "alloc_buckets": { + alloc_bucket_key(a, c, r): n for (a, c, r), n in minimums.items() + }, + } + for name, minimums in ALLOC_BUCKET_FLOORS.items() + } + good[fixture]["alloc_buckets"] = dict(buckets["alloc_buckets"]) assert not check_alloc_bucket_floors(good), check_alloc_bucket_floors(good) + # #7170 R1: the closure-region fixture's two rows are the two DIRECTIONS of + # the served classification, and `codegen/closure.rs` is wiring no compiler + # unit test can reach. Dropping either row must be red on its own. + closure_fixture = "fixture_ptr_shape_cjs_iife" + assert closure_fixture in ALLOC_BUCKET_FLOORS, sorted(ALLOC_BUCKET_FLOORS) + for dropped, expect in ( + ("rule 1 (provenance) — already served by return-shape", "served"), + ("rule 1 (provenance)", "return"), + ): + one_sided = json.loads(json.dumps(good)) + one_sided[closure_fixture]["alloc_buckets"] = { + k: v + for k, v in one_sided[closure_fixture]["alloc_buckets"].items() + if not k.endswith("| " + dropped) + } + failures = check_alloc_bucket_floors(one_sided) + assert any( + closure_fixture in f and expect in f for f in failures + ), (dropped, failures) + # Re-merging the two literal buckets — the #7170 §5.1 defect — is red. merged = json.loads(json.dumps(good)) merged[fixture]["alloc_buckets"] = { diff --git a/test-files/test_gap_repsel_cjs_iife_return_shape.ts b/test-files/test_gap_repsel_cjs_iife_return_shape.ts new file mode 100644 index 0000000000..7e0498a905 --- /dev/null +++ b/test-files/test_gap_repsel_cjs_iife_return_shape.ts @@ -0,0 +1,202 @@ +// Representation-selection Phase 3b, #7170 R1: return-shape facts **inside an +// IIFE** (collectors/ptr_shape_returns.rs, collectors/spec_abi_sites.rs). +// +// `test_gap_repsel_return_shape.ts` is the same mechanism at module scope, +// where every producer is a `hir.functions` entry and every call site is an +// `Expr::FuncRef`. This file wraps the producers and their callers in +// `(function () { … })()` — which is exactly what Perry's own `cjs_wrap` does +// to every CommonJS module (`compile/cjs_wrap/wrap.rs`). Inside that wrapper a +// `function` declaration lowers to `Stmt::Let { init: Expr::Closure }` and a +// call to it to `Call { callee: LocalGet(id) }`, so #7107's two halves both +// missed it and 91.6% of dependency-JS allocation sites sat unreached +// (#7170 §2/§6). +// +// Every case must be BYTE-EXACT against the pinned Node oracle. The promotions +// are asserted structurally elsewhere (`benchmarks/repsel_census`'s +// `fixture_ptr_shape_cjs_iife`, floors held in code); this file is the +// behavioural guard, and a green run of it with zero promotions would be a +// vacuous pass, which is why the two are separate. +// +// Covered: +// 1. a closure producer reached through a captured, BOX-BACKED binding — the +// shape a hoisted inner `function` referenced from a sibling closure +// always takes (`lower_decl/block.rs` emits `PreallocateBoxes`), and the +// entire dependency-JS population; +// 2. an object-literal closure producer (`return { … }` -> __AnonShape_*); +// 3. GC movement between the provenance call and the field reads, inside the +// wrapper — the caller's bound slot is the only rewritable root, and the +// producer's frame is gone; +// 4. the numeric-field stand-down through a closure producer: NaN/Infinity/-0 +// stored by the producer into a field the caller's region never saw +// stored; +// 5. producers and callees that must NOT be reached: a reassigned binding, a +// twice-declared binding, an aliased cache, a fall-through producer, and a +// callee read out of an object (not a bare local). + +const lines: string[] = (function () { + const out: string[] = []; + + class Rec { + id: number; + name: string; + score: number; + constructor(id: number, name: string, score: number) { + this.id = id; + this.name = name; + this.score = score; + } + } + + // 1. Producer + caller, both closures inside the wrapper. `makeRec` is + // captured by `consume` and by `survivesGc`, so its binding is + // box-backed — a binding proof that refused boxed callees would refuse + // every real CommonJS module. + function makeRec(i: number): Rec { + const r = new Rec(i, "r" + i, 0); + r.score = r.id * 1.5; + r.score = r.score + 0.25; + return r; + } + + function consume(i: number): string { + const r = makeRec(i); + r.score = r.score + 1; + return r.name + ":" + r.score.toFixed(3) + ":" + r.id; + } + + // 2. Object-literal producer through the same wrapper. + function shapeOne(i: number) { + return { key: "k" + i, value: i * 2 }; + } + + function readShaped(i: number): string { + const s = shapeOne(i); + return s.key + "=" + (s.value + 1); + } + + // 4. Values the caller's region never saw stored. + class Mixed { + v: number; + tag: string; + constructor() { + this.v = 1; + this.tag = "m"; + } + } + + function makeMixed(kind: number): Mixed { + const m = new Mixed(); + m.v = 2; + if (kind === 1) { + m.v = NaN; + } else if (kind === 2) { + m.v = Infinity; + } else if (kind === 3) { + m.v = -0; + } + return m; + } + + function readMixed(kind: number): string { + const m = makeMixed(kind); + return m.tag + "|" + m.v + "|" + (m.v + 1) + "|" + Object.is(m.v, -0); + } + + // 3. GC movement. The churn must ESCAPE or scalar replacement deletes it and + // the arena never grows — a non-escaping loop drives ZERO collections and + // makes every GC arm inert against this file (#6942/#6946, the failure + // mode scripts/gc_repsel_matrix.sh exists to report). Keep the budget in + // sync with the matrix's liveness column. + let churnSink: unknown[] = []; + + function churn(i: number): void { + churnSink.push({ i: i, s: "c" + (i & 1023), a: [i, i + 1] }); + if (churnSink.length > 4096) { + churnSink = []; + } + } + + function survivesGc(n: number): string { + const survivor = makeRec(7); + let sink = 0; + for (let i = 0; i < n; i++) { + churn(i); + // Read AFTER the allocation safepoint, every iteration. If an evacuating + // scavenge moved `survivor` and the bound slot was not rewritten — or the + // raw pointer was CSE'd across the safepoint — this observes a stale + // address. + sink = sink + survivor.id; + } + return survivor.name + "/" + survivor.score.toFixed(2) + "/" + sink; + } + + // 5a. A REASSIGNED binding: the callee no longer names one body, so the seed + // must stand down. It is reassigned to a function that returns a + // different class, so a compiler that kept the fact would read the wrong + // offsets and this line would diverge. + let swappable = function (i: number): Rec { + return new Rec(i, "first", 1); + }; + function callSwappable(i: number): string { + const r = swappable(i); + return r.name + ":" + r.score; + } + const before = callSwappable(1); + swappable = function (i: number): Rec { + return new Rec(i, "second", 2); + }; + const after = callSwappable(1); + + // 5b. An aliased cache: `return CACHE` is not fresh, so no fact — and the + // caller must observe mutations made through the other alias. + let CACHE: Rec | null = null; + function getCached(): Rec { + if (CACHE === null) { + CACHE = new Rec(100, "cached", 0); + } + return CACHE; + } + + // 5c. A producer that can fall through to `undefined`. + function maybeRec(b: boolean): Rec | undefined { + if (b) { + return new Rec(5, "maybe", 5); + } + return undefined; + } + + // 5d. A callee read out of an object — not a bare local, so not resolvable. + const table = { mk: makeRec }; + function viaTable(i: number): string { + const r = table.mk(i); + return r.name + "!" + r.id; + } + + out.push(consume(3)); + out.push(consume(0)); + out.push(readShaped(4)); + out.push(readMixed(0)); + out.push(readMixed(1)); + out.push(readMixed(2)); + out.push(readMixed(3)); + out.push(survivesGc(120000)); + out.push("swap:" + before + "/" + after); + + const c1 = getCached(); + c1.id = 42; + const c2 = getCached(); + out.push("cache:" + c2.id + ":" + c2.name); + + const m1 = maybeRec(true); + out.push("maybe:" + (m1 === undefined ? "none" : m1.name + m1.score)); + const m2 = maybeRec(false); + out.push("maybe:" + (m2 === undefined ? "none" : "some")); + + out.push(viaTable(9)); + + return out; +})(); + +for (const line of lines) { + console.log(line); +} diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 8c681db114..497bbb9f15 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -48,6 +48,16 @@ test_gap_repsel_ptr_shape_barriers # is exactly the claim this phase's GC contract makes. test_gap_repsel_return_shape +# --- Phase 3b / #7170 R1: the same mechanism inside Perry's own CJS IIFE ----- +# `cjs_wrap` puts every CommonJS module body in `(function () { ... })()`, which +# turns every module-level `function` declaration into a closure -- so #7107's +# producer walk (`hir.functions`) and its caller-side seed (`Expr::FuncRef`) +# both missed it, and 91.6% of dependency-JS allocation sites were unreachable +# (#7170 §6). The GC claim is the same one and needs its own cells: the +# call-seeded local now lives in a CLOSURE region, whose shadow-slot binding is +# emitted by `codegen/closure.rs`, not `codegen/function.rs`. +test_gap_repsel_cjs_iife_return_shape + # --- Phase 3b / #7034 §3: array-element shape facts ------------------------- # `rows.push(row)` no longer disqualifies `row`, and `const r = rows[i]` under # an `i < rows.length` loop is rule-1 provenance. Every element local is an