From 3844423e974ff78c287921dfa4b10ac1d97a90a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 12:24:32 +0200 Subject: [PATCH 1/2] fix(classes): forward-declared lexical captures in class members (#6523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects broke a class whose members reference a const/let declared AFTER the class (legal JS; TDZ applies at call time): A. cic_class had no Constructor/StaticBlock arm and skipped method params, so a binding referenced only from the constructor was never pre-registered as a forward-captured lexical: collect_method_captures dropped it and the reference fell through to the global lookup — 'a is not defined' at new time. B. The #6465 ClassExprFresh decl-site capture snapshot lowered its captured_args with plain checked TDZ box reads. Defining a class whose captures were still in their dead zone threw 'Cannot access ... before initialization' at module init (bundled semver's Comparator — Next.js standalone servers died at boot). Bracket the loads in the same js_tdz_suppress window RegisterClassCaptures got in #6052; the suppressed slots snapshot undefined and the #6037 refresh statements re-register live values after each captured binding's initializer. --- .../src/expr/static_field_meta.rs | 14 +++ crates/perry-hir/src/lower_decl/block.rs | 33 +++++ .../test_gap_class_forward_capture_6523.ts | 115 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 test-files/test_gap_class_forward_capture_6523.ts diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index a391d5029f..12db3a835c 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -431,6 +431,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cap_len = captured_args.len().to_string(); let mut lowered_caps: Vec = Vec::with_capacity(captured_args.len()); let mut caps_arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); + // #6523: these capture loads are Perry-internal materialization + // at the class's DEFINITION site, same as the + // `RegisterClassCaptures` snapshot loads above (#6052). A + // captured `const` declared AFTER the class (bundled semver's + // `class Comparator` + trailing debug/require consts) is still + // in its dead zone here — legal JS, since TDZ applies at + // method-call time. Without the suppression window the checked + // box read threw "Cannot access undefined before + // initialization" while merely DEFINING the class. Suppressed + // loads snapshot `undefined`; the #6037 refresh statements + // re-register the live values right after each captured + // binding's initializer runs. + ctx.block().call_void("js_tdz_suppress_begin", &[]); for arg in captured_args { let v = lower_expr(ctx, arg)?; caps_arr = ctx.block().call( @@ -440,6 +453,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); lowered_caps.push(v); } + ctx.block().call_void("js_tdz_suppress_end", &[]); let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); let key_idx = ctx.strings.intern("__perry_ctor_caps"); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 2c3c35f351..60e9acea3e 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -434,15 +434,45 @@ fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet { + for p in &mm.function.params { + cic_pat(&p.pat, true, out); + } if let Some(b) = &mm.function.body { b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); } } ast::ClassMember::PrivateMethod(mm) => { + for p in &mm.function.params { + cic_pat(&p.pat, true, out); + } if let Some(b) = &mm.function.body { b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); } } + // #6523: the CONSTRUCTOR body runs at `new` time, not at class + // definition — a binding it references that is declared AFTER the + // class (`class C { constructor(){ a() } } const a = ...`) must be + // pre-registered as a forward-captured lexical exactly like a + // method-body reference. This arm was missing, so such refs never + // got a box: `collect_method_captures` dropped them (not in + // `ctx.locals` at the class decl) and the ref fell through to the + // global lookup — "a is not defined" at construction (bundled + // semver's Comparator debug/constant pattern). + ast::ClassMember::Constructor(ctor) => { + for p in &ctor.params { + match p { + ast::ParamOrTsParamProp::Param(p) => cic_pat(&p.pat, true, out), + ast::ParamOrTsParamProp::TsParamProp(tp) => { + if let ast::TsParamPropParam::Assign(a) = &tp.param { + cic_expr(&a.right, true, out); + } + } + } + } + if let Some(b) = &ctor.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } ast::ClassMember::ClassProp(p) => { if let Some(v) = &p.value { cic_expr(v, true, out); @@ -453,6 +483,9 @@ fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet { + sb.body.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } _ => {} } } diff --git a/test-files/test_gap_class_forward_capture_6523.ts b/test-files/test_gap_class_forward_capture_6523.ts new file mode 100644 index 0000000000..f36d275cf2 --- /dev/null +++ b/test-files/test_gap_class_forward_capture_6523.ts @@ -0,0 +1,115 @@ +// #6523: class members referencing a lexical binding (`const`/`let`) declared +// AFTER the class. Legal JS — TDZ applies at method-CALL time, and the +// bindings are initialized before any member runs — but Perry's class-capture +// machinery broke this two ways: +// +// A. Constructor-only references were invisible to the forward-capture +// pre-pass (`cic_class` had no Constructor arm), so the binding never got +// a box, `collect_method_captures` dropped it, and the reference fell +// through to the global lookup — "a is not defined" at `new` time. +// B. When the binding WAS pre-registered (also referenced by a method or +// closure), the #6465 `ClassExprFresh` decl-site capture snapshot did a +// CHECKED TDZ box read with no suppression window, so merely DEFINING the +// class threw "Cannot access ... before initialization" (bundled semver's +// `Comparator` — a Next.js standalone server died at boot). +// +// The observable is byte-for-byte identical to `node --experimental-strip-types`. + +// --- A: forward consts referenced ONLY from the constructor ----------------- +(function factoryA(e: any) { + const s = Symbol("x"); + class Comparator { + max: number; + static get ANY() { + return s; + } + constructor(v: string) { + a("dbg", v); + this.max = n; + } + } + e.exports = Comparator; + const a = function (tag: string, v: string) { + console.log("A: debug", tag, v); + }; + const n = 16; + const c = new Comparator("x"); + console.log("A: max =", c.max); + console.log("A: ANY =", String(Comparator.ANY)); +})({ exports: {} }); + +// --- B: the bundled-semver shape — methods + ctor + static getter reference +// --- a debug fn and sibling "require" consts declared BELOW the class ------- +(function factoryB(module_: any) { + const s = Symbol("SemVer ANY"); + class Comparator { + value: string = ""; + semver: any; + static get ANY() { + return s; + } + constructor(comp: string) { + debug("comparator", comp); + this.parse(comp); + if (this.semver === s) { + this.value = ""; + } else { + this.value = this.operator + this.semver; + } + debug("comp", this.value); + } + operator: string = ""; + parse(comp: string) { + const m = comp.match(re[COMPARATOR]); + if (!m) { + this.semver = s; + return; + } + this.operator = m[1] !== undefined ? m[1] : ""; + this.semver = m[2] !== undefined ? m[2] : s; + } + test(version: string) { + debug("Comparator.test", version, this.value); + return this.semver === s || version === this.semver; + } + } + module_.exports = Comparator; + const debug = (...args: any[]) => { + console.log("B: debug:", ...args); + }; + const re: RegExp[] = []; + const COMPARATOR = 0; + re[COMPARATOR] = /^(>=|<=|>|<|=)?(.+)$/; + + // Same-module construction (static `new` path, live cap args). + const gte = new Comparator(">=1.2.3"); + console.log("B: operator =", gte.operator, "semver =", gte.semver); + console.log("B: test =", gte.test("1.2.3")); + // Construction through the escaped class VALUE (dynamic path — replays the + // ctor with the decl-site/refreshed capture environment). + const Exported = module_.exports; + const any = new Exported(""); + console.log("B: any.value =", JSON.stringify(any.value)); + console.log("B: any.test =", any.test("9.9.9")); + console.log("B: ANY is s =", Exported.ANY === s); +})({ exports: {} }); + +// --- regression guard: capture declared BEFORE the class, reassigned after — +// --- the refresh must keep tracking (pre-existing behavior) ----------------- +(function factoryC() { + let x = 1; + class C { + m() { + return x; + } + } + x = 2; + console.log("C: m =", new C().m()); +})(); + +// --- regression guard: plain closures capturing forward consts keep working - +(function factoryD() { + const f = () => k * 2; + const k = 21; + console.log("D: f =", f()); +})(); From 761ebb98537e6ffa806b5feea6515b2dfbc67444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 17 Jul 2026 14:00:36 +0200 Subject: [PATCH 2/2] refactor(hir): shared cic_function traversal; include nested fn-decl param defaults CodeRabbit review: cic_decl's Decl::Fn arm skipped f.function.params, so a default-parameter expression in a nested function declaration missed capture analysis. Factor the params+body traversal into cic_function and reuse it for Decl::Fn, Expr::Fn, and class Method/PrivateMethod. --- crates/perry-hir/src/lower_decl/block.rs | 45 +++++++++--------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 60e9acea3e..ba00552f81 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -417,38 +417,32 @@ fn cic_decl(d: &ast::Decl, in_cl: bool, out: &mut std::collections::HashSet { - if let Some(b) = &f.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } + ast::Decl::Fn(f) => cic_function(&f.function, out), ast::Decl::Class(c) => cic_class(&c.class, in_cl, out), _ => {} } } +/// Param patterns (defaults evaluate at CALL time) + body of a closure-scoped +/// `ast::Function` — nested fn declarations/expressions and class methods all +/// share this traversal. +fn cic_function(f: &ast::Function, out: &mut std::collections::HashSet) { + for p in &f.params { + cic_pat(&p.pat, true, out); + } + if let Some(b) = &f.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } +} + fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet) { if let Some(sc) = &c.super_class { cic_expr(sc, in_cl, out); } for m in &c.body { match m { - ast::ClassMember::Method(mm) => { - for p in &mm.function.params { - cic_pat(&p.pat, true, out); - } - if let Some(b) = &mm.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } - ast::ClassMember::PrivateMethod(mm) => { - for p in &mm.function.params { - cic_pat(&p.pat, true, out); - } - if let Some(b) = &mm.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } + ast::ClassMember::Method(mm) => cic_function(&mm.function, out), + ast::ClassMember::PrivateMethod(mm) => cic_function(&mm.function, out), // #6523: the CONSTRUCTOR body runs at `new` time, not at class // definition — a binding it references that is declared AFTER the // class (`class C { constructor(){ a() } } const a = ...`) must be @@ -510,14 +504,7 @@ fn cic_expr(e: &ast::Expr, in_cl: bool, out: &mut std::collections::HashSet cic_expr(ex, true, out), } } - Fn(f) => { - for p in &f.function.params { - cic_pat(&p.pat, true, out); - } - if let Some(b) = &f.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } + Fn(f) => cic_function(&f.function, out), Class(c) => cic_class(&c.class, in_cl, out), Array(a) => a .elems