From 026ecb927ed6286133c2e7f11335bef35da1759b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 07:58:33 +0200 Subject: [PATCH 1/6] =?UTF-8?q?test(9466):=20gap=20fixture=20=E2=80=94=20s?= =?UTF-8?q?ame-name=20classes=20at=20different=20lexical=20depths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the aliasing on unfixed origin/main: three depths, sibling module-top blocks, sibling blocks in a function, sibling functions, if/else + try/catch/finally + loop bodies, a shadowed class captured in a closure called after its block exits, instanceof across the shadowing boundary, .name (#9413 regression guard), and subclassing a shadowed class. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../test_gap_9466_shadowed_class_identity.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 test-files/test_gap_9466_shadowed_class_identity.ts diff --git a/test-files/test_gap_9466_shadowed_class_identity.ts b/test-files/test_gap_9466_shadowed_class_identity.ts new file mode 100644 index 0000000000..a294aef80f --- /dev/null +++ b/test-files/test_gap_9466_shadowed_class_identity.ts @@ -0,0 +1,173 @@ +// #9466: same-name class declarations at different lexical depths are DISTINCT +// classes. Perry disambiguates them with a compiler-internal registration key +// (`M$0` — see `maybe_rename_colliding_class`), but that key was minted once +// per NAME instead of once per SCOPE: a nested body inherited the enclosing +// body's alias and its `class M` aliased onto the SAME ClassId, so the third +// and every later same-name class silently ran an earlier one's body. Bare +// blocks never ran the disambiguation scan at all, so sibling `{ class X }` +// blocks collided too. +// +// Every arm distinguishes IDENTITY, not just dispatch: the `instanceof` arms +// are the ones that catch a fix that re-splits names without re-splitting +// class ids. + +// --- 1. three depths, three bodies --------------------------------------- +class M { v() { return "top"; } } +function h() { + class M { v() { return "outer"; } } + function h2() { + class M { v() { return "inner"; } } + return new M().v(); + } + return [new M().v(), h2()].join(","); +} +console.log("depths:", new M().v(), h()); + +// --- 1b. three depths with NO top-level declaration of the name ----------- +// The outermost declarer keeps the raw registration key; every nested one +// must still mint its own. +function deepA() { + class D { v() { return "d1"; } } + function deepB() { + class D { v() { return "d2"; } } + function deepC() { + class D { v() { return "d3"; } } + return new D().v(); + } + return [new D().v(), deepC()].join(","); + } + return [new D().v(), deepB()].join(","); +} +console.log("no-top:", deepA()); + +// --- 2a. sibling blocks at module top level ------------------------------- +{ class Blk { v() { return "b1"; } } console.log("blk1:", new Blk().v()); } +{ class Blk { v() { return "b2"; } } console.log("blk2:", new Blk().v()); } +{ class Blk { v() { return "b3"; } } console.log("blk3:", new Blk().v()); } + +// --- 2b. sibling blocks inside a function --------------------------------- +function blocksInFn() { + const out: string[] = []; + { class Q { v() { return "q1"; } } out.push(new Q().v()); } + { class Q { v() { return "q2"; } } out.push(new Q().v()); } + { class Q { v() { return "q3"; } } out.push(new Q().v()); } + return out.join(","); +} +console.log("fn-blocks:", blocksInFn()); + +// --- 2c. same-name classes in sibling functions --------------------------- +function sibA() { class S { v() { return "sA"; } } return new S().v(); } +function sibB() { class S { v() { return "sB"; } } return new S().v(); } +function sibC() { class S { v() { return "sC"; } } return new S().v(); } +console.log("siblings:", sibA(), sibB(), sibC()); + +// --- 2d. if/else branches and try/catch/finally are lexical scopes too ---- +class If1 { v() { return "if-top"; } } +function branches(flag: boolean) { + if (flag) { + class If1 { v() { return "then"; } } + return new If1().v(); + } else { + class If1 { v() { return "else"; } } + return new If1().v(); + } +} +console.log("branches:", branches(true), branches(false), new If1().v()); + +class T1 { v() { return "t-top"; } } +function tryCatchFinally() { + const out: string[] = []; + try { + class T1 { v() { return "try"; } } + out.push(new T1().v()); + throw new Error("x"); + } catch { + class T1 { v() { return "catch"; } } + out.push(new T1().v()); + } finally { + class T1 { v() { return "finally"; } } + out.push(new T1().v()); + } + out.push(new T1().v()); + return out.join(","); +} +console.log("try:", tryCatchFinally()); + +class L { v() { return "L-top"; } } +function loopBody() { + const acc: string[] = []; + for (let i = 0; i < 2; i++) { + class L { v() { return "L-body"; } } + acc.push(new L().v()); + } + acc.push(new L().v()); + return acc.join(","); +} +console.log("loop:", loopBody()); + +// --- 3. shadowed classes captured in closures, called after the block exits +const closures: Array<() => string> = []; +{ + class Cap { v() { return "cap1"; } } + closures.push(() => new Cap().v()); +} +{ + class Cap { v() { return "cap2"; } } + closures.push(() => new Cap().v()); +} +function capFn() { + class Cap { v() { return "cap-fn"; } } + return () => new Cap().v(); +} +closures.push(capFn()); +class Cap { v() { return "cap-top"; } } +closures.push(() => new Cap().v()); +console.log("closures:", closures.map((f) => f()).join(",")); + +// --- 4. instanceof across the shadowing boundary -------------------------- +class P { tag() { return "P-top"; } } +const topP = new P(); +function innerP() { + class P { tag() { return "P-inner"; } } + const p = new P(); + return { + inst: p, + ownIsInner: p instanceof P, + topIsInner: topP instanceof P, + cls: P as any, + }; +} +const r = innerP(); +console.log("io own-inner:", r.ownIsInner); +console.log("io top-is-inner:", r.topIsInner); +console.log("io inner-is-top:", r.inst instanceof P); +console.log("io top-is-top:", topP instanceof P); +console.log("io ctor-identity:", r.cls === P); +console.log("io proto-identity:", Object.getPrototypeOf(r.inst) === P.prototype); +console.log("io tags:", topP.tag(), r.inst.tag()); + +// --- 5. `.name` stays the SOURCE name for every one of them (#9413) ------- +function nameA() { class N {} return N.name; } +function nameB() { class N {} return N.name; } +function nameC() { function d() { class N {} return N.name; } return d(); } +class N {} +console.log("names:", N.name, nameA(), nameB(), nameC()); +console.log("ctor-names:", new M().constructor.name, r.inst.constructor.name); + +// --- 6. subclassing a shadowed class inside the inner scope --------------- +class B { who() { return "B-top"; } } +class SubTop extends B {} +function innerSub() { + class B { who() { return "B-inner"; } } + class Sub extends B { both() { return this.who() + "/sub"; } } + const s = new Sub(); + return [ + s.who(), + s.both(), + String(s instanceof B), + String(s instanceof Sub), + String(s instanceof SubTop), + ].join(","); +} +console.log("sub-top:", new SubTop().who(), new SubTop() instanceof B); +console.log("sub-inner:", innerSub()); From d484568088d8942ffeabacf98d34681827894526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 08:05:50 +0200 Subject: [PATCH 2/6] =?UTF-8?q?test(9466):=20cover=20every=20block-kind=20?= =?UTF-8?q?scope=20=E2=80=94=20switch=20cases=20and=20loop=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename scan was missing from ALL {}-shaped scopes, not just bare blocks, so the fixture now names each kind: bare block, if/else, loop body, try/catch/finally, and both switch forms (a bare case statement-list, which shares one switch block scope, and a braced case, which is its own). Two loop arms pin the span-keyed semantics: one declaration site is ONE class across iterations, its closures outlive the loop, and a per-iteration capture still gets three environments. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../test_gap_9466_shadowed_class_identity.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test-files/test_gap_9466_shadowed_class_identity.ts b/test-files/test_gap_9466_shadowed_class_identity.ts index a294aef80f..f6dbbf221d 100644 --- a/test-files/test_gap_9466_shadowed_class_identity.ts +++ b/test-files/test_gap_9466_shadowed_class_identity.ts @@ -171,3 +171,54 @@ function innerSub() { } console.log("sub-top:", new SubTop().who(), new SubTop() instanceof B); console.log("sub-inner:", innerSub()); + +// --- 7. switch: a bare case statement-list shares ONE switch block scope --- +class Sw { v() { return "sw-top"; } } +function switchBare(k: number) { + switch (k) { + case 1: + class Sw { v() { return "sw-case"; } } + return new Sw().v(); + default: + return "none"; + } +} +console.log("switch-bare:", switchBare(1), switchBare(2), new Sw().v()); + +// A braced case is its own block scope on top of the switch's. +class Sw2 { v() { return "sw2-top"; } } +function switchBraced(k: number) { + switch (k) { + case 1: { class Sw2 { v() { return "c1"; } } return new Sw2().v(); } + case 2: { class Sw2 { v() { return "c2"; } } return new Sw2().v(); } + default: return new Sw2().v(); + } +} +console.log("switch-braced:", switchBraced(1), switchBraced(2), switchBraced(3)); + +// --- 8. loop body: ONE declaration site, so ONE class for every iteration --- +// (the disambiguation is keyed on the declaration's source span, and every +// iteration shares that span). The closures must still hold the inner class +// after the loop exits, and the post-loop `new Lp()` must get the OUTER one. +class Lp { v() { return "lp-top"; } } +function loopSameClass() { + const fs: Array<() => string> = []; + for (let i = 0; i < 3; i++) { + class Lp { v() { return "lp-body"; } } + fs.push(() => new Lp().v()); + } + return fs.map((f) => f()).join(",") + "|" + new Lp().v(); +} +console.log("loop-same-class:", loopSameClass()); + +// Same shape with a per-iteration capture: one class, three environments. +class Cp { v() { return "cp-top"; } } +function loopCaptures() { + const fs: Array<() => string> = []; + for (let i = 0; i < 3; i++) { + class Cp { v() { return "cp" + i; } } + fs.push(() => new Cp().v()); + } + return fs.map((f) => f()).join(",") + "|" + new Cp().v(); +} +console.log("loop-captures:", loopCaptures()); From b48339976dc59dec75a0fdd7b0bff0202fc32927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 08:10:13 +0200 Subject: [PATCH 3/6] =?UTF-8?q?test(9466):=20instanceof=20arms=20that=20ac?= =?UTF-8?q?tually=20discriminate=20=E2=80=94=20block=20boundary=20+=20thir?= =?UTF-8?q?d=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arm 4's instanceof rows sit at TWO-scope depth, which the name-keyed disambiguation already handled, so they pass before and after: they guard the fix but do not demonstrate the gap. These two do — a block-scoped class (never lowered at all before the fix, so its instances were instances of the OUTER class) and a third-depth one (aliased onto the second's ClassId). Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../test_gap_9466_shadowed_class_identity.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test-files/test_gap_9466_shadowed_class_identity.ts b/test-files/test_gap_9466_shadowed_class_identity.ts index f6dbbf221d..b9470b41fd 100644 --- a/test-files/test_gap_9466_shadowed_class_identity.ts +++ b/test-files/test_gap_9466_shadowed_class_identity.ts @@ -222,3 +222,38 @@ function loopCaptures() { return fs.map((f) => f()).join(",") + "|" + new Cp().v(); } console.log("loop-captures:", loopCaptures()); + +// --- 9. instanceof across a BLOCK boundary, and at the THIRD depth --------- +// Arm 4's instanceof rows sit at two-scope depth, which the name-keyed +// disambiguation already handled; these two are where identity actually broke. +class Ib { tag() { return "ib-top"; } } +const ibTop = new Ib(); +let ibInnerInst: any = null; +let ibInnerCls: any = null; +{ + class Ib { tag() { return "ib-block"; } } + ibInnerInst = new Ib(); + ibInnerCls = Ib; +} +console.log("blk-io same-class:", ibInnerCls === Ib); +console.log("blk-io inner-is-top:", ibInnerInst instanceof Ib); +console.log("blk-io top-is-inner:", ibTop instanceof ibInnerCls); +console.log("blk-io proto:", Object.getPrototypeOf(ibInnerInst) === Ib.prototype); +console.log("blk-io tags:", ibTop.tag(), ibInnerInst.tag()); + +function ioDepth() { + class Id { tag() { return "id-1"; } } + function inner() { + class Id { tag() { return "id-2"; } } + return { inst: new Id(), cls: Id as any }; + } + const deep = inner(); + return [ + deep.inst.tag(), + String(deep.cls === Id), + String(deep.inst instanceof Id), + String(new Id() instanceof deep.cls), + ].join(","); +} +class Id { tag() { return "id-top"; } } +console.log("depth-io:", ioDepth(), new Id().tag()); From 25c5016f8dc415b4cdef18d7556947ea24421720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 08:10:59 +0200 Subject: [PATCH 4/6] fix(hir): disambiguate same-name classes per SCOPE, not per name (#9466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-name `class` declarations at different lexical depths aliased onto one ClassId and the inner body was silently dropped — wrong code, no diagnostic. Two defects, one symptom. 1. The "already renamed" guard was per NAME, not per SCOPE. `maybe_rename_colliding_class` returned early on `class_renames.contains_key(name)`. But `class_renames` is INHERITED by nested bodies — it is snapshotted and restored per body, so an enclosing body's alias is live while a nested one lowers. A nested body declaring the same name therefore took that early return and registered its `class X` under the OUTER body's key. Third and later occurrences shared one ClassId; whichever body lowered first won. The map value now carries the source span of the scope that minted the alias, so the guard means "THIS scope already renamed it" — the idempotence the guard existed for — while every nested scope mints its own. That span key is also what makes it safe to hook the scan at more than one funnel: a function body is scanned twice (Phase-1.5, then `lower_block_stmt`) and the matching key makes the second a no-op. Without it the second alias would strand that body's end-of-body capture re-registration on a stale key — the 2026-07-02 audit P0 that `capture_rereg_renamed_class.rs` guards. 2. Block scopes never ran the disambiguation scan at all. Only function bodies did, so two sibling `{ class Blk { … } }` blocks shared one ClassId and the second was never lowered. Measured on unfixed main, ALL of these ran the outer class: bare blocks (module-level and in-function), `if`/`else` branches, loop bodies, `try`/`catch`/`finally`, and both switch forms. `class` is block-scoped, so `enter_class_rename_scope` / `exit_class_rename_scope` now bracket every `{ … }`-shaped scope, deliberately mirroring `register_block_forward_lexicals` (#6062), which brackets the same boundary for TDZ names: record only what this scope changed, undo exactly that, so an alias owned by an enclosing scope survives. `lower_block_stmt` is the funnel `rebind_nested_forward_scope_lets` already documents for those scopes; the strict-mode branch of `lower_block_stmt_scoped` bypasses it, and switch case statement-lists are not `BlockStmt`s, so both take the bracket explicitly. This is an identity fix, not a naming one: each declaration gets its own ClassId, so `instanceof` across the shadowing boundary is right in both directions, `Object.getPrototypeOf` disagrees with the outer prototype, and `class Sub extends M` inside the inner scope extends the INNER `M`. `.name` keeps reporting the source name — the #9413 (PR #9465) display-name override lives on the same `lower_class_decl` site every new alias flows through, so it composes for free. Fixture: test-files/test_gap_9466_shadowed_class_identity.ts. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../9466-scope-aware-class-disambiguation.md | 59 +++++++++++++ crates/perry-hir/src/lower/context.rs | 53 +++++++++--- crates/perry-hir/src/lower/expr_function.rs | 2 +- .../perry-hir/src/lower/lowering_context.rs | 18 +++- crates/perry-hir/src/lower/stmt.rs | 13 ++- crates/perry-hir/src/lower_decl/block.rs | 84 ++++++++++++++++++- crates/perry-hir/src/lower_decl/body_stmt.rs | 13 ++- crates/perry-hir/src/lower_decl/mod.rs | 6 +- 8 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 changelog.d/9466-scope-aware-class-disambiguation.md diff --git a/changelog.d/9466-scope-aware-class-disambiguation.md b/changelog.d/9466-scope-aware-class-disambiguation.md new file mode 100644 index 0000000000..69e6525a2b --- /dev/null +++ b/changelog.d/9466-scope-aware-class-disambiguation.md @@ -0,0 +1,59 @@ +### Fixed + +- **Same-name `class` declarations at different lexical depths are distinct + classes again — the inner body is no longer silently dropped.** Perry keeps + two same-named classes apart by registering the second under a uniquified key + (`M$0`); the key was minted once per **name** instead of once per **scope**, so + the third and every later `class M` aliased onto the second's ClassId. Whichever + body registered first won, and the program ran the wrong methods with no + diagnostic: + + ```ts + class M { v() { return "top"; } } + function h() { + class M { v() { return "outer"; } } + function h2() { class M { v() { return "inner"; } } return new M().v(); } + return [new M().v(), h2()].join(","); + } + console.log(new M().v(), h()); // node: top outer,inner perry: top outer,outer + ``` + + Two defects, one symptom: + + 1. **The "already renamed" guard was per name, not per scope.** + `maybe_rename_colliding_class` returned early on + `class_renames.contains_key(name)` — but `class_renames` is inherited by + nested bodies (it is saved and restored per body, so an enclosing body's + alias is live while the nested one lowers). A nested body declaring the same + name therefore took the early return and registered its `class X` under the + **outer** body's key. The map now carries the source span of the scope that + minted each alias, so the guard means "this scope already renamed it" — the + idempotence the guard existed for — and every nested scope mints its own. + + 2. **Block scopes never ran the disambiguation scan at all.** Only function + bodies did, so two sibling `{ class Blk { … } }` blocks shared one ClassId + and the second ran the first's body: + + ```ts + { class Blk { v() { return "b1"; } } console.log(new Blk().v()); } // b1 + { class Blk { v() { return "b2"; } } console.log(new Blk().v()); } // node b2, perry b1 + ``` + + `class` is block-scoped, so the scan is now bracketed at every `{ … }`-shaped + scope — bare block, `if` / `else` branch, loop body, `try` / `catch` / + `finally`, and `switch` — mirroring `register_block_forward_lexicals` + (#6062), which brackets the same boundary for TDZ names: record only what the + scope changed, undo exactly that, so an alias owned by an enclosing scope + survives. + + This is an **identity** fix, not a naming one: each declaration now gets its own + ClassId, so `instanceof` across the shadowing boundary is correct in both + directions (an inner instance is not `instanceof` the outer class, and vice + versa), `Object.getPrototypeOf(inner) !== Outer.prototype`, and `class Sub + extends M` inside the inner scope extends the **inner** `M`. `.name` keeps + reporting the source name for all of them — the display-name override #9413 + (PR #9465) installed on this exact path is what carries it, and every newly + minted alias goes through the same `lower_class_decl` site that records it. + + Validated by `test-files/test_gap_9466_shadowed_class_identity.ts`, byte-identical + to `node --experimental-strip-types`. diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 532ead4347..4c045db809 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -466,20 +466,53 @@ impl LoweringContext { pub(crate) fn resolve_class_name(&self, name: &str) -> String { self.class_renames .get(name) - .cloned() + .map(|(registration_key, _)| registration_key.clone()) .unwrap_or_else(|| name.to_string()) } - /// Register a scope-local rename for `class X` when an outer/prior `class X` - /// is already registered (a distinct class that the name-keyed dedup would - /// otherwise skip). Returns immediately if no collision or already aliased. - /// Call from each body's Phase-1.5 class scan. - pub(crate) fn maybe_rename_colliding_class(&mut self, name: &str) { - if self.lookup_class(name).is_some() && !self.class_renames.contains_key(name) { - let unique = format!("{}${}", name, self.next_class_rename_id); - self.next_class_rename_id += 1; - self.class_renames.insert(name.to_string(), unique); + /// Mint a scope-local rename for `class X` when an outer/prior `class X` is + /// already registered (a distinct class that the name-keyed dedup would + /// otherwise skip). Returns `Some(displaced entry)` when it minted — so the + /// caller can restore exactly what it replaced — and `None` when no rename + /// was needed. + /// + /// #9466: the "already renamed" guard is per SCOPE, not per name. It used + /// to be `!class_renames.contains_key(name)`, but `class_renames` inherits + /// every enclosing body's aliases, so a NESTED body declaring the same name + /// took that branch and registered its class under the OUTER body's key. + /// The two source classes then shared one ClassId, whichever body lowered + /// first won, and the other's members silently vanished — no diagnostic. + /// Keying on `scope_key` (the declaring scope's source span) keeps the + /// idempotence the old guard existed for — one alias per scope, so a scope + /// scanned twice (a function body: Phase-1.5, then `lower_block_stmt`) + /// mints exactly once — while letting each nested scope mint its own. + pub(crate) fn mint_class_rename( + &mut self, + name: &str, + scope_key: u32, + ) -> Option> { + if self.lookup_class(name).is_none() { + return None; + } + if self + .class_renames + .get(name) + .is_some_and(|(_, key)| *key == scope_key) + { + return None; } + let unique = format!("{}${}", name, self.next_class_rename_id); + self.next_class_rename_id += 1; + Some( + self.class_renames + .insert(name.to_string(), (unique, scope_key)), + ) + } + + /// Single-name entry point for the function-body Phase-1.5 class scans, + /// which snapshot and restore the whole `class_renames` map themselves. + pub(crate) fn maybe_rename_colliding_class(&mut self, name: &str, scope_key: u32) { + let _ = self.mint_class_rename(name, scope_key); } /// Is `name` a user-declared `interface`? Interfaces are not classes, so diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index cbbbf6953e..16e1c3d0c5 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1151,7 +1151,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // shape `(function(e){…class s{…}…})(t)` declares superstruct's // `Struct` = `class s`, which collided with other `class s` in // the bundle and was dedup-skipped). See `class_renames`. - ctx.maybe_rename_colliding_class(class_decl.ident.sym.as_str()); + ctx.maybe_rename_colliding_class(class_decl.ident.sym.as_str(), block.span.lo.0); let cname = class_decl.ident.sym.to_string(); ctx.forward_class_decl_depth .entry(cname.clone()) diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index f744350041..ef37621c37 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -820,9 +820,21 @@ pub struct LoweringContext { /// name-keyed: `Expr::New { class_name }` / `ClassRef(name)`). When a body /// declares `class X` while an outer/prior `class X` is already registered, /// the body's X is renamed `X$` and `X -> X$` recorded so every - /// reference in that body binds to the lexically-correct class. Saved/ - /// restored per body in both `lower_fn_body_block_stmt` and `lower_fn_expr`. - pub(crate) class_renames: std::collections::HashMap, + /// reference in that body binds to the lexically-correct class. + /// + /// The value is `(registration key, scope key)`, where the scope key is the + /// source span of the LEXICAL SCOPE that minted the alias. #9466: without + /// it there was one alias per NAME — a nested body inherited the enclosing + /// body's alias, saw "already renamed", and registered its own `class X` + /// under the OUTER body's key, so the two classes shared one ClassId and + /// the inner body was silently dropped. With it there is one alias per + /// (name, scope), which is what a lexical declaration actually is. + /// + /// Bracketed at every scope that can declare a class: the function-body + /// Phase-1.5 scans in `lower_fn_body_block_stmt` / `lower_fn_expr` + /// (whole-map snapshot + restore), and `enter_class_rename_scope` / + /// `exit_class_rename_scope` for `{ … }`-shaped block scopes. + pub(crate) class_renames: std::collections::HashMap, /// Monotonic suffix source for `class_renames` unique names. pub(crate) next_class_rename_id: u32, /// Names of TOP-LEVEL `class X { … }` declarations in the module being diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index e0a0802be2..5acbea4c5a 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1822,9 +1822,19 @@ pub(crate) fn lower_stmt( // Case statement-lists share the switch's block scope without // being a `BlockStmt`, so they don't pass through // `lower_block_stmt` — re-bind their pre-registered - // forward-captured lets here (all cases up front: one scope). + // forward-captured lets here (all cases up front: one scope), and + // (#9466) disambiguate the `class` declarations they hold for the + // same reason. Every case shares ONE lexical scope, so they take + // one shared scope key: a second case re-declaring the name is a + // redeclaration, not a shadow. + let mut saved_class_renames = Vec::new(); for case in &switch_stmt.cases { rebind_nested_forward_scope_lets(ctx, &case.cons); + saved_class_renames.extend(enter_class_rename_scope( + ctx, + switch_stmt.span.lo.0, + &case.cons, + )); } for case in &switch_stmt.cases { @@ -1838,6 +1848,7 @@ pub(crate) fn lower_stmt( cases.push(SwitchCase { test, body }); } + exit_class_rename_scope(ctx, saved_class_renames); ctx.pop_block_scope(switch_scope_mark); module.init.push(Stmt::Switch { diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index f9cae5ccc9..c2315806a2 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -20,7 +20,22 @@ pub(crate) use var_names::{ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result> { rebind_nested_forward_scope_lets(ctx, &block.stmts); - lower_stmts_using_aware(ctx, &block.stmts) + // #9466: `class` is block-scoped, so a `class X` here is a DISTINCT class + // from any enclosing/prior `class X` and needs its own registration key. + // This is the funnel every `{}`-shaped scope shares — bare block, `if` / + // `else` branch, loop body, `try` / `catch` / `finally` — the same set + // `rebind_nested_forward_scope_lets` documents. Bracketed so the alias dies + // with the block. + // + // Keyed on the block's span: a FUNCTION body arrives here after + // `lower_fn_body_block_stmt`'s Phase-1.5 scan already aliased this same + // block, and the matching key makes this call a no-op rather than a second + // alias — which would strand that function's end-of-body capture + // re-registration on the now-stale key. + let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts); + let lowered = lower_stmts_using_aware(ctx, &block.stmts); + exit_class_rename_scope(ctx, saved_class_renames); + lowered } /// Make the forward-captured `let`/`const` bindings that @@ -524,7 +539,7 @@ pub fn lower_fn_body_block_stmt( // Disambiguate a distinct same-named class declared in this body so // its references don't bind to a colliding `class X` elsewhere in // the bundled module (see `class_renames`). - ctx.maybe_rename_colliding_class(class_decl.ident.sym.as_str()); + ctx.maybe_rename_colliding_class(class_decl.ident.sym.as_str(), block.span.lo.0); let cname = class_decl.ident.sym.to_string(); // Record the (shallowest) scope depth this class is declared at so a // later bare-ident reference can compare it against a same-named @@ -1025,13 +1040,22 @@ pub fn lower_block_stmt_scoped( block: &ast::BlockStmt, ) -> Result> { let mark = ctx.push_block_scope(); + // #9466: the strict-mode branch does NOT route through `lower_block_stmt`, + // so the block-scoped class disambiguation is bracketed here, around both + // branches. On the non-strict path `lower_block_stmt`'s own bracket sees + // this same span key and is a no-op. + let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts); // Via `lower_block_stmt` so this scope's pre-registered forward-captured // lets are re-bound at entry (`rebind_nested_forward_scope_lets`). let stmts = if ctx.current_strict { - lower_strict_block_fn_decls(ctx, block)? + lower_strict_block_fn_decls(ctx, block) } else { - lower_block_stmt(ctx, block)? + lower_block_stmt(ctx, block) }; + exit_class_rename_scope(ctx, saved_class_renames); + // `?` deliberately AFTER the rename restore but BEFORE `pop_block_scope`, + // preserving this function's original error control flow exactly. + let stmts = stmts?; ctx.pop_block_scope(mark); Ok(stmts) } @@ -1160,6 +1184,58 @@ fn register_block_forward_lexicals(ctx: &mut LoweringContext, stmts: &[ast::Stmt newly } +/// What one [`enter_class_rename_scope`] bracket displaced, keyed by source +/// name: `None` = the name had no active alias, `Some(entry)` = the enclosing +/// scope's alias to put back. See `LoweringContext::class_renames`. +pub(crate) type ClassRenameScopeSave = Vec<(String, Option<(String, u32)>)>; + +/// #9466: scope-entry hook for a `{ … }`-shaped lexical scope — disambiguate +/// every `class X` declared DIRECTLY in `stmts` and return what to hand to +/// [`exit_class_rename_scope`] on the way out. +/// +/// A `class` declaration is block-scoped, so a bare block, an `if`/`else` +/// branch, a loop body, a `try` / `catch` / `finally` block and a `switch` body +/// each shadow an enclosing same-named class exactly as a `let` does. Before +/// #9466 only FUNCTION bodies ran the disambiguation scan, so two sibling +/// `{ class X { … } }` blocks registered one ClassId between them and the +/// second block silently ran the first's body. +/// +/// Deliberately mirrors [`register_block_forward_lexicals`] (#6062), which +/// brackets this same boundary for TDZ names: record only what this call +/// changed and undo exactly that, so an alias owned by an enclosing scope +/// survives the block. +pub(crate) fn enter_class_rename_scope( + ctx: &mut LoweringContext, + scope_key: u32, + stmts: &[ast::Stmt], +) -> ClassRenameScopeSave { + let mut saved = ClassRenameScopeSave::new(); + for stmt in stmts { + let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt else { + continue; + }; + let name = class_decl.ident.sym.as_str(); + if let Some(displaced) = ctx.mint_class_rename(name, scope_key) { + saved.push((name.to_string(), displaced)); + } + } + saved +} + +/// Undo an [`enter_class_rename_scope`] bracket, innermost mint first. +pub(crate) fn exit_class_rename_scope(ctx: &mut LoweringContext, saved: ClassRenameScopeSave) { + for (name, displaced) in saved.into_iter().rev() { + match displaced { + Some(entry) => { + ctx.class_renames.insert(name, entry); + } + None => { + ctx.class_renames.remove(&name); + } + } + } +} + pub fn lower_stmts_using_aware( ctx: &mut LoweringContext, stmts: &[ast::Stmt], diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 59c2b347b5..f9be1c6e73 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1025,9 +1025,19 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Date: Wed, 2 Sep 2026 08:22:20 +0200 Subject: [PATCH 5/6] =?UTF-8?q?test(9466):=20drop=20the=20loop-capture=20s?= =?UTF-8?q?ub-arm=20=E2=80=94=20it=20discriminates=20a=20different=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `class Cp { v(){ return "cp" + i } }` in a loop body prints cp3,cp3,cp3 instead of cp0,cp1,cp2 — but that reproduces with NO name shadowing anywhere and is byte-identical before and after this fix, so it is the class-capture snapshot mechanism (one RegisterClassCaptures per class, refreshed at assignments and returns; a loop body has neither), not class identity. Filed separately; the fixture keeps discriminating one thing. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../test_gap_9466_shadowed_class_identity.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/test-files/test_gap_9466_shadowed_class_identity.ts b/test-files/test_gap_9466_shadowed_class_identity.ts index b9470b41fd..a680e562c1 100644 --- a/test-files/test_gap_9466_shadowed_class_identity.ts +++ b/test-files/test_gap_9466_shadowed_class_identity.ts @@ -211,17 +211,15 @@ function loopSameClass() { } console.log("loop-same-class:", loopSameClass()); -// Same shape with a per-iteration capture: one class, three environments. -class Cp { v() { return "cp-top"; } } -function loopCaptures() { - const fs: Array<() => string> = []; - for (let i = 0; i < 3; i++) { - class Cp { v() { return "cp" + i; } } - fs.push(() => new Cp().v()); - } - return fs.map((f) => f()).join(",") + "|" + new Cp().v(); -} -console.log("loop-captures:", loopCaptures()); +// NOT covered here: the same loop body where the class CAPTURES the loop +// variable (`class Cp { v() { return "cp" + i; } }`). Node gives one class +// with three environments (`cp0,cp1,cp2`); perry gives `cp3,cp3,cp3` because +// a class carries ONE `RegisterClassCaptures` snapshot, refreshed at +// assignments and returns — neither of which a loop body has. That is the +// class-capture mechanism, not class identity: it reproduces with NO name +// shadowing anywhere (`class Uniq` declared in a loop body, nothing else +// named Uniq in the program) and is byte-identical before and after this fix. +// Filed separately so this fixture keeps discriminating exactly one thing. // --- 9. instanceof across a BLOCK boundary, and at the THIRD depth --------- // Arm 4's instanceof rows sit at two-scope depth, which the name-keyed From 0bd261bcda9a70f7838b0f6996d768bbca478cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 08:22:33 +0200 Subject: [PATCH 6/6] =?UTF-8?q?test(9466):=20wording=20=E2=80=94=20the=20c?= =?UTF-8?q?apture=20gap=20is=20reported,=20not=20yet=20filed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- test-files/test_gap_9466_shadowed_class_identity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-files/test_gap_9466_shadowed_class_identity.ts b/test-files/test_gap_9466_shadowed_class_identity.ts index a680e562c1..c675a0cf77 100644 --- a/test-files/test_gap_9466_shadowed_class_identity.ts +++ b/test-files/test_gap_9466_shadowed_class_identity.ts @@ -219,7 +219,7 @@ console.log("loop-same-class:", loopSameClass()); // class-capture mechanism, not class identity: it reproduces with NO name // shadowing anywhere (`class Uniq` declared in a loop body, nothing else // named Uniq in the program) and is byte-identical before and after this fix. -// Filed separately so this fixture keeps discriminating exactly one thing. +// Reported separately so this fixture keeps discriminating exactly one thing. // --- 9. instanceof across a BLOCK boundary, and at the THIRD depth --------- // Arm 4's instanceof rows sit at two-scope depth, which the name-keyed