Wrong code, not metadata: the second and third same-name class at different depths share one ClassId, so the inner definition's body is silently replaced by the outer one's.
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
Sibling blocks at module top level hit it too:
{ class Blk { v(){return "b1"} } console.log(new Blk().v()); } // b1 — correct
{ class Blk { v(){return "b2"} } console.log(new Blk().v()); } // node b2, perry b1
Mechanism (located, not fixed)
maybe_rename_colliding_class (crates/perry-hir/src/lower/context.rs:476) returns early when class_renames already holds the name, so a third occurrence reuses the second's uniquified key instead of minting its own — and sibling module-top blocks appear not to run the Phase-1.5 rename scan at all. Every same-name class after the second aliases onto one ClassId; whichever body registered first wins, and the program runs the wrong methods with no diagnostic.
Why this is worse than the #9413 name leak
#9413 (PR #9465) fixed the reported name — Made$0 → Made — but the underlying identity collision produces wrong execution, and it is exactly the shape of the OpenCode #9133 collision recorded in the per-module class-id work: reflection and dispatch keyed on a class id that two source classes share. Any codebase with a common class name (Point, Node, Item) declared in more than two scopes is exposed.
Verification bar
A gap fixture demonstrated failing on unfixed origin/main, byte-compared to node, covering: three same-name classes at three depths (each body distinguishable by a method's return value), sibling blocks at module top level, sibling blocks inside a function, same-name classes in sibling functions, a shadowed class captured in a closure and called after the block exits, and instanceof across the shadowing boundary (an inner instance must NOT be instanceof the outer class). The instanceof arm is the one that catches an aliasing fix that merely re-splits names without re-splitting ids.
Found while fixing #9413 (PR #9465).
Wrong code, not metadata: the second and third same-name class at different depths share one ClassId, so the inner definition's body is silently replaced by the outer one's.
Sibling blocks at module top level hit it too:
Mechanism (located, not fixed)
maybe_rename_colliding_class(crates/perry-hir/src/lower/context.rs:476) returns early whenclass_renamesalready holds the name, so a third occurrence reuses the second's uniquified key instead of minting its own — and sibling module-top blocks appear not to run the Phase-1.5 rename scan at all. Every same-name class after the second aliases onto one ClassId; whichever body registered first wins, and the program runs the wrong methods with no diagnostic.Why this is worse than the #9413 name leak
#9413 (PR #9465) fixed the reported name —
Made$0→Made— but the underlying identity collision produces wrong execution, and it is exactly the shape of the OpenCode #9133 collision recorded in the per-module class-id work: reflection and dispatch keyed on a class id that two source classes share. Any codebase with a common class name (Point,Node,Item) declared in more than two scopes is exposed.Verification bar
A gap fixture demonstrated failing on unfixed
origin/main, byte-compared to node, covering: three same-name classes at three depths (each body distinguishable by a method's return value), sibling blocks at module top level, sibling blocks inside a function, same-name classes in sibling functions, a shadowed class captured in a closure and called after the block exits, andinstanceofacross the shadowing boundary (an inner instance must NOT beinstanceofthe outer class). The instanceof arm is the one that catches an aliasing fix that merely re-splits names without re-splitting ids.Found while fixing #9413 (PR #9465).