Skip to content

fix(codegen): resolve a native base class by class CHAIN, not by extends name (#6325, #6326, #6336)#6342

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/nativebase-native-base-class-registry
Open

fix(codegen): resolve a native base class by class CHAIN, not by extends name (#6325, #6326, #6336)#6342
proggeramlug wants to merge 2 commits into
mainfrom
fix/nativebase-native-base-class-registry

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Three issues, one root: perry installs a native base class's surface at super() time, and the trigger was keyed on the class naming that base literally in its own extends clause. A declaration-site name match is the wrong key, and it lost the base in three different ways:

shape what was missing issue
class M extends Map {} a class with no own constructor writes no super(), so the init never ran — the instance had no backing collection and therefore no methods at all #6325
class D extends B {}, class B extends EventEmitter {} an indirect subclass names an intermediate USER class, not the base — one hop and the emitter surface was gone #6326
new (class extends Event {})("tick") an immediately-constructed class expression never emitted its RegisterClassParentDynamic side effect, so the registry parent edge was never wired and every chain walk bailed #6336

The fix replaces the literal name test with "the class chain reaches the base" — the same walk node_stream_parent_kind already performed for the node:stream bases, generalized — and closes the class-expression registration hole so the chain is walkable in the first place. This is the mechanism PR #6311 established for EventTarget: wire the registry parent edge, then resolve by chain rather than by name.

Closes #6325, closes #6326, closes #6336.

What changed

perry-codegen/src/lower_call/new_helpers.rs — the shared mechanism.
native_instance_base_in_chain(ctx, class) walks extends_name through user classes that carry no constructor of their own and reports the native base the chain bottoms out in (EventEmitter, Map, Set, Event, CustomEvent). It stops at any ancestor that has a constructor: that ancestor's super() performs the init itself, and running it again would re-stamp the surface over live state — a fresh listener bag over an emitter whose ctor already registered listeners, a fresh empty backing over a seeded Map. A user class in the module shadows the builtin name, so a source-level class Map {} is never mistaken for the builtin. emit_native_instance_base_init emits the corresponding init and forwards the constructor args, which is exactly what the spec's implicit constructor(...args) { super(...args) } does — and is how new Seeded([["k", 9]]) seeds its backing and new (class extends Event {})("tick") gets its type.

The set is deliberately narrow: Error, the node:stream classes, Request/Response, Promise and Array each have their own construction machinery that the walk must not preempt.

perry-codegen/src/lower_call/new.rs — the implicit (no-own-ctor) new path now consults the chain walk instead of testing class.extends_name == Some("EventEmitter"). This is what makes class M extends Map {} work at all (#6325) and what carries the surface across a hop (#6326).

perry-codegen/src/expr/this_super_call.rs — the explicit-super() arm. When the immediate parent is a real user class but the chain bottoms out in a native base (class Counter extends B { constructor() { super(); … } }), the parent-chain walk found no constructor to inline and super() silently no-oped. It now installs the base surface. The direct form is untouched: the existing builtin arms only fire when the immediate parent name is the base, so they never see this shape.

perry-hir/src/lower/expr_new/non_ident.rsnew (class extends Base {})(). A class expression's Subclass → Parent registry edge is a side effect of evaluating the expression: lower_class_expr sequences a RegisterClassParentDynamic in front of the ClassRef it yields, which is why the const-bound form (const K = class extends Event {}) worked. This arm never went through lower_class_expr — it lowers the class straight to a New on a synthetic name — so for a class expression constructed in place the registration never ran. It now sequences the same registration in front of the construction. (Sequence yields its last element, so the new site still sees the instance.)

perry-runtime/src/object/map_set_subclass.rs + object/class_constructors.rssuper.<m>() into the Map/Set base. Making class M extends Map {} work makes overriding a collection method reachable for the first time, so the #6316/#6322 ordering discipline has to hold there too. The override already wins (it is a class-vtable method, and the backing redirect only fires on a dispatch miss), but super.get(k) returned undefined: EventEmitter and the node:stream classes install their surface as method closures, so an override displaces a real value that js_super_method_call_dynamic can still find — Map/Set have no closures, they redirect the operation onto a hidden backing collection, so there was nothing to find. super_collection_method runs the base operation on the backing directly. It is the last fallback in js_super_method_call_dynamic, after the class vtable and the prototype walk, so a genuine user-class parent method always wins. The receiver is rooted across js_map_set/js_set_add (both allocate, and both must return the receiver, so a stale bit pattern there would hand the override a dead this).

Repro matrix vs node --experimental-strip-types

main this PR node
class M extends Map {}; m.set("a",1) TypeError: set is not a function 1 1 1 1
class S extends Set {}; s.add("x") TypeError true 1 true 1
new Seeded([["k",9]])extends Map, no ctor TypeError 9 1 9 1
class LeafMap extends MidMap extends Map TypeError 3 1 true 3 1 true
Map subclass override + super.set/super.get TypeError 10 20 undefined 10 20 undefined
class D extends B extends EventEmitter: typeof d.on undefined function function
D2 → C2 → B → EventEmitter (deep chain) undefined function function
indirect subclass with its own ctor calling super() undefined function function
indirect subclass override + super.emit() undefined override runs, true override runs, true
new (class extends Event {})("tick") false undefined true tick true tick
const E2 = class extends Event {}; new E2("x") TypeError: Event is not a function true x true x
new (class extends EventEmitter {})() undefined function function
new (class extends Map {})() TypeError 1 1 true 1 1 true
controls — direct extends EventEmitter; extends Map with an explicit ctor; new (class extends UserBase {})(); EventEmitter override + super.emit (#6316) pass pass pass

Validation

Fixturestest-files/test_gap_6325_map_set_subclass.ts, test_gap_6326_indirect_native_base.ts, test_gap_6336_class_expr_builtin_parent.ts. Each is byte-identical to Node and each carries both a no-override control and an override-still-wins guard.

Gap-suite A/B, 299 tests, two genuinely distinct toolchains. This PR touches perry-runtime, so each side was built with its own perry binary and its own libperry_runtime.a / libperry_stdlib.a (via -p perry-runtime-static -p perry-stdlib-static — the rlib-only crates emit no archive), and PERRY_RUNTIME_DIR was pinned per side. Provenance was proved rather than assumed:

  • the baseline archive predates the first fix commit; the fixed archive postdates the runtime commit;
  • nm finds super_collection_method in the fixed archive and not in the baseline one;
  • holding the compiler fixed and swapping only PERRY_RUNTIME_DIR flips the behavior (override: undefined undefined undefinedoverride: 10 20 undefined), so the archive selection is demonstrably live.

Result: 294 identical / 5 changed → zero regressions. Three of the five are the new fixtures (fail on main, pass here). The other two are proven run-to-run noise, not build differences — re-running the baseline binary alone reproduces each:

  • test_gap_console_methods — a console.time wall-clock line (timer1: 0.012ms vs 0.034ms); the baseline binary alone prints 0.002ms / 0.005ms / 0.002ms across three runs.
  • test_gap_module_const_local_shadow — already in test-parity/known_failures.json; it prints uninitialized memory (x+1.2e-311), and the baseline binary alone yields a different garbage float on every run.

GC. Validated under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1, with PERRY_GC_DIAG=1 confirming the run was not vacuous: [gc-evac-policy] enabled=true reason=force … moved_objects=5727 moved_bytes=383472 released_original_objects=5727. The stress fixture churns allocations around every Map/Set write, emitter dispatch and super call, takes an explicit minor()/collect() each round, and then asserts the backing collections, the emitter bag and the Event fields all survived relocation intact. The evacuation verifier did not trip.

cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.

Not in scope

Summary by CodeRabbit

  • Bug Fixes
    • Fixed initialization for indirect subclasses of built-in types (Map, Set, EventEmitter, Event, CustomEvent), including ctor-less and mixed constructor chains.
    • Corrected super dispatch for Map/Set subclass overrides to reliably reach base implementations.
    • Ensured inline class expressions properly register built-in inheritance relationships, restoring correct instanceof and native surface APIs.
    • Preserved subclass fields, overridden methods, iteration, and collection operations across inheritance chains.
  • Tests
    • Added regression tests covering native subclass construction, inheritance-chain init, inline class expressions, and super behavior.

…nds name (#6325, #6326, #6336)

Perry installs a native base class's surface at super() time — an own-property
method bag for EventEmitter, a hidden backing MapHeader/SetHeader for Map/Set,
the standard event fields for Event/CustomEvent. The trigger was keyed on the
class naming that base LITERALLY in its own `extends` clause, which is the wrong
key and lost the base three ways:

  * #6325 — a class with NO own constructor writes no super() at all, so the
    init never ran. `class M extends Map {}` produced an instance with no
    backing collection and therefore no methods at all: `m.set("a", 1)` threw
    `TypeError: set is not a function`.
  * #6326 — an INDIRECT subclass names an intermediate USER class, not the base.
    `class D extends B {}` over `class B extends EventEmitter {}` inherited
    nothing one hop away: `typeof d.on` was `undefined`.
  * #6336 — `new (class extends Event {})("tick")` never emitted the class
    expression's RegisterClassParentDynamic side effect, so the registry parent
    edge was never wired, `instanceof Event` was false, and every chain walk
    that identifies a receiver by its base bailed.

native_instance_base_in_chain() replaces the literal name test: it walks
extends_name through user classes that carry no constructor of their own and
reports the native base the chain bottoms out in. It STOPS at any ancestor with
a constructor — that ancestor's super() performs the init itself, and running it
twice would re-stamp the surface over live state (a fresh listener bag over an
emitter whose ctor already registered listeners, a fresh empty backing over a
seeded Map). This generalizes the ctor-less walk node_stream_parent_kind already
performed for the node:stream bases. Consulted from both places a derived ctor
can reach the base: the implicit (no-own-ctor) `new` path in new.rs, and the
explicit-super() arm in this_super_call.rs, where a chain that bottoms out in a
native base previously found no ctor to inline and silently no-oped.

A class expression's parent edge is a SIDE EFFECT of evaluating the expression —
lower_class_expr sequences RegisterClassParentDynamic in front of the ClassRef it
yields, which is why the const-bound form worked. lower_new_non_ident never went
through it, lowering the class straight to a New on a synthetic name, so a class
expression constructed in place registered nothing. It now sequences the same
registration in front of the construction; Sequence yields its last element, so
the new site still sees the instance.

Making Map/Set subclassing work makes overriding a collection method reachable
for the first time, so the #6316/#6322 ordering discipline has to hold there too.
The override already wins (class-vtable method; the backing redirect only fires
on a dispatch miss), but super.get(k) returned undefined: EventEmitter and the
node:stream classes install their surface as method CLOSURES, so an override
displaces a real value js_super_method_call_dynamic can still find — Map/Set have
no closures, they redirect the OPERATION onto the hidden backing, so there was
nothing to find. super_collection_method runs the base operation on the backing
directly, as the LAST fallback in js_super_method_call_dynamic (after the class
vtable and the prototype walk), so a genuine user-class parent method always
wins. The receiver is rooted across js_map_set/js_set_add: both allocate and both
must return the receiver, so a stale bit pattern would hand the override a dead
`this`.

Fixtures: test_gap_6325_map_set_subclass.ts, test_gap_6326_indirect_native_base.ts,
test_gap_6336_class_expr_builtin_parent.ts — each byte-identical to node
--experimental-strip-types, each carrying a no-override control and an
override-still-wins guard. Full 299-test gap suite A/B'd against a pristine
origin/main build (its own perry binary AND its own libperry_runtime.a /
libperry_stdlib.a, since this touches the runtime): zero regressions. Validated
under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 with GC diagnostics
confirming a real evacuation ran (moved_objects=5727), verifier clean.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Native subclass construction now discovers supported native bases through inheritance chains, registers parents for immediately constructed class expressions, and dispatches super methods for Map/Set subclasses through their backing collections.

Changes

Native subclass initialization

Layer / File(s) Summary
Native base chain initialization
crates/perry-codegen/src/lower_call/..., test-files/test_gap_6326_indirect_native_base.ts
Native base detection and initialization now cover EventEmitter, Map, Set, Event, and CustomEvent across implicit constructors, explicit super(), indirect inheritance chains, and derived field initialization.

Class-expression parent registration

Layer / File(s) Summary
Class-expression parent registration
crates/perry-hir/src/lower/expr_new/non_ident.rs, test-files/test_gap_6336_class_expr_builtin_parent.ts
Class expressions with parents emit dynamic parent registration before construction, with coverage for builtin and user-defined parents.

Map/Set super-method dispatch

Layer / File(s) Summary
Map/Set super-method dispatch
crates/perry-runtime/src/object/{class_constructors,map_set_subclass}.rs, test-files/test_gap_6325_map_set_subclass.ts
Map/Set super calls dispatch supported operations through hidden backing collections while preserving subclass overrides and collection behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Constructor
  participant CodegenHelpers
  participant NativeRuntime
  Constructor->>CodegenHelpers: resolve native base in class chain
  CodegenHelpers->>NativeRuntime: emit EventEmitter, Map/Set, or Event initialization
  NativeRuntime-->>Constructor: initialized subclass instance
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5494: Related EventEmitter initialization for indirect native inheritance.
  • PerryTS/perry#5613: Related no-inherited-constructor and parent initialization handling in lower_new.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: resolving native base classes by inheritance chain rather than literal extends name.
Description check ✅ Passed The description covers the summary, concrete changes, linked issues, and validation, though some template sections are condensed.
Linked Issues check ✅ Passed The changes address #6325, #6326, and #6336 by walking class chains, initializing native bases, and fixing builtin parent registration.
Out of Scope Changes check ✅ Passed The extra runtime and test updates support the stated native-base chain fix and super dispatch behavior, so they are in scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/nativebase-native-base-class-registry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/expr/this_super_call.rs (1)

183-222: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate the native-base chain fix into Expr::SuperCallSpread
super(...arguments) still only special-cases direct Map/Set; it skips native_instance_base_in_chain, so indirect subclasses like class Counter extends B {} where B extends EventEmitter/Map/Set/Event/CustomEvent won’t get the native surface installed here. Mirror the same chain handling in this arm or narrow the fix’s scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/this_super_call.rs` around lines 183 - 222,
Update the Map/Set handling in Expr::SuperCallSpread to use
native_instance_base_in_chain rather than checking only the immediate
extends_name, so indirect subclasses also receive the native collection
initialization. Mirror the existing native-base chain behavior used by
Expr::SuperCall while preserving spread-argument extraction and field
initializer execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/this_super_call.rs`:
- Around line 844-878: In the native-base fallback branch of the super-call
lowering flow, change the apply_field_initializers_recursive call from
FieldInitMode::SelfOnly to FieldInitMode::AfterRoot. Keep the existing
current_class_name and post-super timing unchanged so all fields after the
native root, including ctor-less intermediate classes, are initialized.

---

Outside diff comments:
In `@crates/perry-codegen/src/expr/this_super_call.rs`:
- Around line 183-222: Update the Map/Set handling in Expr::SuperCallSpread to
use native_instance_base_in_chain rather than checking only the immediate
extends_name, so indirect subclasses also receive the native collection
initialization. Mirror the existing native-base chain behavior used by
Expr::SuperCall while preserving spread-argument extraction and field
initializer execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8d77711-6767-4e0e-b43a-eed6cb2bd4ef

📥 Commits

Reviewing files that changed from the base of the PR and between 735e894 and 33ec329.

📒 Files selected for processing (10)
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/new_helpers.rs
  • crates/perry-hir/src/lower/expr_new/non_ident.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/map_set_subclass.rs
  • test-files/test_gap_6325_map_set_subclass.ts
  • test-files/test_gap_6326_indirect_native_base.ts
  • test-files/test_gap_6336_class_expr_builtin_parent.ts

Comment thread crates/perry-codegen/src/expr/this_super_call.rs
The post-super() field pass used FieldInitMode::SelfOnly, which covers only
the leaf. AncestorsOnly (applied up-front) covers only the chain ROOT — and a
native base is the root and has no TS fields. So a ctor-less class in the
middle of the chain had no site that ran its initializers at all.

Two intermediates are needed to see it: with one, the leaf and the
root-adjacent class both happen to be covered.

  class A extends EventEmitter { aF = 'a-ok' }
  class B extends A             { bF = 'b-ok' }   // <- silently uninitialized
  class C extends B             { cF = 'c-ok'; constructor() { super() } }

  before: a-ok undefined c-ok
  after:  a-ok b-ok      c-ok   (matches node)

AfterRoot keeps chain[1..], i.e. everything below the native root, and is
equivalent to SelfOnly for the two-level case the existing tests cover.

Found by review on #6342.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/expr/this_super_call.rs (1)

190-222: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle indirect Map/Set bases in SuperCallSpread. This path only checks the immediate extends_name, so class C extends B { constructor() { super(...arguments) } } with class B extends Map {} still falls through to js_super_construct_apply, which doesn’t install the Map/Set backing for the indirect base. native_instance_base_in_chain needs to cover this arm too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/this_super_call.rs` around lines 190 - 222,
Update the Map/Set detection in SuperCallSpread to use
native_instance_base_in_chain rather than checking only the immediate
extends_name, so indirect subclasses such as B extends Map are recognized.
Preserve the existing map_set_kind initialization and field-initializer flow for
both direct and indirect Map/Set bases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-codegen/src/expr/this_super_call.rs`:
- Around line 190-222: Update the Map/Set detection in SuperCallSpread to use
native_instance_base_in_chain rather than checking only the immediate
extends_name, so indirect subclasses such as B extends Map are recognized.
Preserve the existing map_set_kind initialization and field-initializer flow for
both direct and indirect Map/Set bases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d896d57-314e-41c8-be83-fb8dad2d4914

📥 Commits

Reviewing files that changed from the base of the PR and between 33ec329 and 8fbdbb8.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/this_super_call.rs
  • test-files/test_gap_6326_indirect_native_base.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test-files/test_gap_6326_indirect_native_base.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment