From 33ec3298efb4f826df58b23efe3898f55f5860a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 08:00:02 +0200 Subject: [PATCH 1/2] fix(codegen): resolve a native base class by class CHAIN, not by extends name (#6325, #6326, #6336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../perry-codegen/src/expr/this_super_call.rs | 36 ++++ crates/perry-codegen/src/lower_call/mod.rs | 6 + crates/perry-codegen/src/lower_call/new.rs | 39 +++-- .../src/lower_call/new_helpers.rs | 164 +++++++++++++++++- .../perry-hir/src/lower/expr_new/non_ident.rs | 38 +++- .../src/object/class_constructors.rs | 14 +- .../src/object/map_set_subclass.rs | 107 ++++++++++++ test-files/test_gap_6325_map_set_subclass.ts | 117 +++++++++++++ .../test_gap_6326_indirect_native_base.ts | 105 +++++++++++ ...test_gap_6336_class_expr_builtin_parent.ts | 66 +++++++ 10 files changed, 676 insertions(+), 16 deletions(-) create mode 100644 test-files/test_gap_6325_map_set_subclass.ts create mode 100644 test-files/test_gap_6326_indirect_native_base.ts create mode 100644 test-files/test_gap_6336_class_expr_builtin_parent.ts diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 76a8ceb8da..646389944f 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -841,6 +841,42 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { lowered_args.push(lower_expr(ctx, a)?); } + // #6326: the parent is a real user class, but the chain BOTTOMS OUT + // in a native base whose surface perry stamps onto the instance — + // `class Counter extends B { constructor() { super(); … } }` with + // `class B extends EventEmitter {}`. The builtin arms above only fire + // when the IMMEDIATE parent name IS the base, so they never see this + // shape; and the parent-chain walk below finds no constructor to + // inline (no ancestor has one), so `super()` silently no-oped and the + // instance came out with no emitter/collection surface at all. + // + // The walk yields `None` the moment any ancestor has a constructor — + // that ancestor's own `super()` installs the base — so this arm fires + // exactly when nothing else will. + if let Some(base) = crate::lower_call::native_instance_base_in_chain(ctx, current_class) + { + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + crate::lower_call::emit_native_instance_base_init( + ctx, + base, + &this_box, + &lowered_args, + ); + // Spec: derived-class field initializers run AFTER `super()` + // returns. The ancestors' fields were applied up-front by the + // own-ctor path (`FieldInitMode::AncestorsOnly`), so only the + // leaf's remain — same call every other builtin arm makes. + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } + // Inline the parent constructor with the SAME this and a // fresh param scope for the parent's params. // diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index f9eec160c7..b3781e988a 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -122,6 +122,12 @@ pub(crate) use new_helpers::{ ctor_body_calls_super, ctor_body_closure_calls_super, ctor_body_has_value_return, ctor_body_uses_this, }; +// #6325 / #6326: the class-chain walk to a native base whose surface perry +// stamps onto the instance, plus its init emitter. Shared by the implicit +// (no-own-ctor) `new` path in `new.rs` and the explicit-`super()` arm in +// `expr/this_super_call.rs`, which are the two places a derived constructor can +// reach the base. +pub(crate) use new_helpers::{emit_native_instance_base_init, native_instance_base_in_chain}; // `extract_options_fields` is consumed by `expr.rs` as // `crate::lower_call::extract_options_fields` — keep that path stable. pub(crate) use options::extract_options_fields; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 3210db629c..306b7691dc 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1514,17 +1514,34 @@ fn lower_new_impl( found_inherited_ctor = true; } } - // #5137: implicit-ctor `class X extends EventEmitter {}` — install the - // emitter surface (the explicit-`super()` arm does this when a ctor is - // written). Gated `!has_imported_ctor` so an imported class whose real - // ctor lives in another module (commander's `Command`) still reaches - // the imported-ctor fallback below and runs its real `super()`. - if !found_inherited_ctor - && !has_imported_ctor - && class.extends_name.as_deref() == Some("EventEmitter") - { - crate::expr::lower_event_emitter_subclass_init(ctx, &obj_box); - found_inherited_ctor = true; + // #5137 / #6325 / #6326: implicit-ctor subclass of a native base whose + // surface perry stamps onto the INSTANCE — `EventEmitter`, `Map`/`Set`, + // `Event`/`CustomEvent`. The explicit-`super()` arm + // (`expr/this_super_call.rs`) installs it when a constructor is written; + // a class with no own constructor writes no `super()`, so the install + // has to happen here or the instance is left bare (`class M extends Map + // {}` → `m.set` is not a function). + // + // Keyed on the class CHAIN reaching the base rather than on a literal + // `extends` name: an INDIRECT subclass names an intermediate USER class + // (`class D extends B {}` with `class B extends EventEmitter {}`), so the + // old one-level name test lost the base entirely. The walk stops at any + // ancestor with a constructor — its `super()` does the install — so this + // never double-initializes. + // + // Gated `!has_imported_ctor` so an imported class whose real ctor lives + // in another module (commander's `Command`) still reaches the + // imported-ctor fallback below and runs its real `super()`. + if !found_inherited_ctor && !has_imported_ctor { + if let Some(base) = crate::lower_call::native_instance_base_in_chain(ctx, class) { + crate::lower_call::emit_native_instance_base_init( + ctx, + base, + &obj_box, + &lowered_args, + ); + found_inherited_ctor = true; + } } // Issue #573: if the parent walk reached an Error-like built-in // without finding any user-class constructor, synthesize the JS diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 9a536ab609..9ba7831af2 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -9,7 +9,169 @@ use perry_hir::{Class, Expr}; use crate::expr::FnCtx; -use crate::types::DOUBLE; +use crate::types::{DOUBLE, I32}; + +/// The native base classes perry models by STAMPING state onto the INSTANCE at +/// construction time instead of giving it a real builtin prototype: the instance +/// is a plain `ObjectHeader`, and `super()` installs the base's surface on it — +/// an own-property method bag for `EventEmitter`, a hidden backing +/// `MapHeader`/`SetHeader` for `Map`/`Set`, the standard event fields for +/// `Event`/`CustomEvent`. +/// +/// Because the install rides on `super()`, it used to be keyed on the class +/// naming the base LITERALLY in its own `extends` clause. That misses the base +/// in two directions: +/// +/// * 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 +/// collection storage and therefore no `set`/`get`/`size` (#6325); and +/// * an INDIRECT subclass names an intermediate USER class, not the base, so +/// the init never ran either — `class D extends B {}` with `class B extends +/// EventEmitter {}` produced an object with no emitter surface (#6326). +/// +/// Both are the same hole, and both close by triggering on "the class CHAIN +/// reaches the base" — see [`native_instance_base_in_chain`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum NativeInstanceBase { + EventEmitter, + Map, + Set, + Event, + CustomEvent, +} + +/// The native base a parent NAME denotes, if any. +/// +/// Deliberately narrow. The other builtin bases perry supports have their own +/// construction machinery that the chain walk must not preempt: `Error` & +/// friends are handled by the Error-init arms, the `node:stream` classes by +/// `node_stream_parent_kind` (which already walks the chain), `Request`/ +/// `Response` by the fetch-handle shims, `Promise` by the backing-cell shim, +/// and `Array` by `js_array_subclass_init` (whose `super(n)` argument is a +/// length, not an iterable — a no-arg implicit ctor has nothing to forward). +pub(crate) fn native_instance_base(name: &str) -> Option { + match name { + "EventEmitter" => Some(NativeInstanceBase::EventEmitter), + "Map" => Some(NativeInstanceBase::Map), + "Set" => Some(NativeInstanceBase::Set), + "Event" => Some(NativeInstanceBase::Event), + "CustomEvent" => Some(NativeInstanceBase::CustomEvent), + _ => None, + } +} + +/// The native base `class` ultimately derives from, found by walking +/// `extends_name` through user classes that carry no constructor of their own. +/// +/// The walk STOPS (yielding `None`) at any ancestor that HAS a constructor — +/// local or imported. Such an ancestor's `super()` performs the base init +/// itself, and running it a second time here would re-stamp the surface over +/// already-live state: a fresh listener bag over an emitter the ancestor's ctor +/// already registered listeners on, a fresh empty backing over a seeded Map. +/// This is the same ctor-less walk `node_stream_parent_kind` performs for the +/// classic `node:stream` bases — generalized, not invented. +/// +/// A user class in this module SHADOWS the builtin name: with `class Map {}` in +/// the source, `ctx.classes` resolves `Map` first, so the walk descends into the +/// user class and never reports a native base. +pub(crate) fn native_instance_base_in_chain( + ctx: &FnCtx<'_>, + class: &Class, +) -> Option { + let mut cur = class.extends_name.as_deref(); + for _ in 0..32 { + let name = cur?; + if ctx.imported_class_ctors.contains_key(name) { + return None; + } + match ctx.classes.get(name).copied() { + // A user class in this module: keep walking only while it delegates + // construction upward (no ctor of its own). + Some(parent) => { + if parent.constructor.is_some() { + return None; + } + cur = parent.extends_name.as_deref(); + } + // Not a class in this module — the chain has reached a builtin. + None => return native_instance_base(name), + } + } + None +} + +/// Install a native base's surface on `this_box`. +/// +/// `lowered_args` are the already-lowered constructor arguments. The JS spec's +/// implicit derived constructor is `constructor(...args) { super(...args) }`, so +/// forwarding them is exactly what a written-out `super(...)` would have done — +/// which is how `new Seeded([["k", 9]])` on a `class Seeded extends Map {}` +/// seeds its backing, and how `new (class extends Event {})("tick")` gets its +/// `type`. +pub(crate) fn emit_native_instance_base_init( + ctx: &mut FnCtx<'_>, + base: NativeInstanceBase, + this_box: &str, + lowered_args: &[String], +) { + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + match base { + NativeInstanceBase::EventEmitter => { + // The bare emitter seeds no state from its options bag, so the args + // (already lowered for their side effects) are not forwarded. + crate::expr::lower_event_emitter_subclass_init(ctx, this_box); + } + NativeInstanceBase::Map | NativeInstanceBase::Set => { + let kind: i32 = if base == NativeInstanceBase::Map { + 0 + } else { + 1 + }; + // `new Map(iterable)` / `new Set(iterable)` — extra args are ignored + // by the builtin, and `undefined` seeds an empty collection. + let iterable = lowered_args.first().cloned().unwrap_or(undef); + ctx.block().call( + DOUBLE, + "js_map_set_subclass_init", + &[ + (DOUBLE, this_box), + (I32, &kind.to_string()), + (DOUBLE, &iterable), + ], + ); + } + NativeInstanceBase::Event | NativeInstanceBase::CustomEvent => { + let arg0 = lowered_args + .first() + .cloned() + .unwrap_or_else(|| undef.clone()); + let arg1 = lowered_args + .get(1) + .cloned() + .unwrap_or_else(|| undef.clone()); + // `argc` drives the runtime's missing-`type` throw, matching Node's + // `new Event()` TypeError. + let argc = lowered_args.len().min(2).to_string(); + let is_custom = if base == NativeInstanceBase::CustomEvent { + "1" + } else { + "0" + } + .to_string(); + ctx.block().call( + DOUBLE, + "js_event_subclass_init", + &[ + (DOUBLE, this_box), + (DOUBLE, &arg0), + (DOUBLE, &arg1), + (I32, &argc), + (I32, &is_custom), + ], + ); + } + } +} /// Emit `js_promise_subclass_init(this, executor)` for a no-own-ctor /// `class X extends Promise {}` on the runtime `new X(executor)` path. Runs the diff --git a/crates/perry-hir/src/lower/expr_new/non_ident.rs b/crates/perry-hir/src/lower/expr_new/non_ident.rs index 367d79da26..6370abfcc7 100644 --- a/crates/perry-hir/src/lower/expr_new/non_ident.rs +++ b/crates/perry-hir/src/lower/expr_new/non_ident.rs @@ -134,6 +134,25 @@ pub(crate) fn lower_new_non_ident( let synthetic_name = format!("__anon_class_{}", ctx.fresh_class()); ctx.pending_class_inner_name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?; + // #6336: 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 + // (`const K = class extends Event {}` works because of it). This arm + // never went through `lower_class_expr`: it lowers the class straight to + // a `New` on the synthetic name, so for a class expression constructed + // IN PLACE the registration never ran and the instance came out + // parentless — `new (class extends Event {})("tick") instanceof Event` + // was `false`, and every chain walk that identifies a receiver by its + // base (`instanceof`, the native-base init, Event/EventTarget dispatch) + // bailed. Sequence the same registration in front of the `New` so the + // edge is wired before the constructor runs. + // + // Only heritage that resolves to a runtime VALUE carries `extends_expr` + // (a builtin like `Event`, a factory call, a captured local). A parent + // that is a known user class in this module carries a static `extends` + // link instead and needs no registration — which is why the user-parent + // form of this shape already worked. + let parent_expr = class.extends_expr.clone(); ctx.pending_classes.push(class); let mut args: Vec = new_expr .args @@ -173,12 +192,25 @@ pub(crate) fn lower_new_non_ident( .collect() }) .unwrap_or_default(); - return Ok(Expr::New { - class_name: synthetic_name, + let construct = Expr::New { + class_name: synthetic_name.clone(), args, type_args, byte_offset: new_byte_offset, - }); + }; + // The `Sequence` yields its LAST element, so the `new` site still sees + // the constructed instance — the registration is pure side effect, + // ordered before it. + let Some(parent_expr) = parent_expr else { + return Ok(construct); + }; + return Ok(Expr::Sequence(vec![ + Expr::RegisterClassParentDynamic { + class_name: synthetic_name, + parent_expr, + }, + construct, + ])); } let callee = Box::new(lower_expr(ctx, callee_expr)?); diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 08654264c1..d60470affe 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -689,7 +689,19 @@ unsafe fn call_displaced_native_base_method( ) -> f64 { let Some(method_value) = crate::node_stream::displaced_native_base_method(this_value, name) else { - return undef; + // #6325: the `Map`/`Set` bases serve their surface by redirecting the + // OPERATION onto a hidden backing collection, not by stamping method + // closures onto the instance — so an override displaces nothing and + // there is no closure here to call. `super.get(k)` / `super.set(k, v)` / + // … dispatch on the backing instead. Returns `undef` for a receiver with + // no backing, keeping the ordinary `super.m()`-miss contract. + let args: &[f64] = if args_ptr.is_null() || args_len == 0 { + &[] + } else { + std::slice::from_raw_parts(args_ptr, args_len) + }; + return crate::object::map_set_subclass::super_collection_method(this_value, name, args) + .unwrap_or(undef); }; // The stashed closure already captures the receiver in slot 0, but bind // IMPLICIT_THIS too: the shared emitter/stream stubs read their receiver diff --git a/crates/perry-runtime/src/object/map_set_subclass.rs b/crates/perry-runtime/src/object/map_set_subclass.rs index 90f6f80ef7..2cbb42cfe8 100644 --- a/crates/perry-runtime/src/object/map_set_subclass.rs +++ b/crates/perry-runtime/src/object/map_set_subclass.rs @@ -141,6 +141,113 @@ pub(crate) fn subclass_backing_for_default_iteration(value: f64) -> Option(…)` from inside a `class X extends Map | Set` OVERRIDE +/// (#6325). +/// +/// The other native bases perry models — `EventEmitter`, the `node:stream` +/// classes — install their surface as method CLOSURES on the instance, so an +/// override displaces a real value that `super.()` can still reach +/// (`node_stream::displaced_native_base_method`, #6316/#6322). Map/Set have no +/// such closures: their surface is served by redirecting the OPERATION onto the +/// hidden backing collection at each dispatch point. There is therefore nothing +/// for `js_super_method_call_dynamic` to find, and `super.get(k)` returned +/// `undefined` — the base was unreachable from an override. Run the base +/// operation on the backing directly instead. +/// +/// Returns `None` for a receiver with no backing, and for any name that is not a +/// base collection method, so an ordinary `super.m()` miss still yields +/// `undefined` (the #774 instance-field-shadow contract). +pub(crate) fn super_collection_method(this_value: f64, name: &str, args: &[f64]) -> Option { + let backing = subclass_backing_of(this_value)?; + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + // `js_map_set` / `js_set_add` allocate (entry storage, boxed keys) and can + // therefore GC-move the RECEIVER — which `Map.prototype.set` and + // `Set.prototype.add` must RETURN, so a stale bit pattern here would hand + // the override a dead `this`. Root it and re-read from the handle after the + // call. The backing `MapHeader`/`SetHeader` needs no handle: it is a + // registered, header-less allocation the GC never moves (the same reason the + // raw-collection dispatch in `native_call_method` holds it across calls). + let scope = crate::gc::RuntimeHandleScope::new(); + let this_handle = scope.root_nanbox_f64(this_value); + let boxed = |ptr: i64| f64::from_bits(JSValue::pointer(ptr as *const u8).bits()); + let boolean = |b: bool| f64::from_bits(JSValue::bool(b).bits()); + unsafe { + match backing { + CollectionBacking::Map(map) => match name { + "get" => Some(crate::map::js_map_get(map, *args.first()?)), + "set" => { + let key = *args.first()?; + let value = args.get(1).copied().unwrap_or(undefined); + crate::map::js_map_set(map, key, value); + Some(this_handle.get_nanbox_f64()) + } + "has" => Some(boolean(crate::map::js_map_has(map, *args.first()?) != 0)), + "delete" => Some(boolean(crate::map::js_map_delete(map, *args.first()?) != 0)), + "clear" => { + crate::map::js_map_clear(map); + Some(undefined) + } + "forEach" => { + let callback = *args.first()?; + let this_arg = args.get(1).copied().unwrap_or(undefined); + // The callback's 3rd argument must be the SUBCLASS instance, + // not the backing — same receiver-identity rule the ordinary + // dispatch path applies. + crate::map::js_map_foreach_with_collection( + map, + callback, + this_arg, + this_handle.get_nanbox_f64(), + ); + Some(undefined) + } + "keys" => Some(boxed(crate::collection_iter_object::js_map_keys_iter_obj( + map, + ))), + "values" => Some(boxed( + crate::collection_iter_object::js_map_values_iter_obj(map), + )), + "entries" | "Symbol.iterator" | "@@iterator" => Some(boxed( + crate::collection_iter_object::js_map_entries_iter_obj(map), + )), + _ => None, + }, + CollectionBacking::Set(set) => match name { + "add" => { + crate::set::js_set_add(set, *args.first()?); + Some(this_handle.get_nanbox_f64()) + } + "has" => Some(boolean(crate::set::js_set_has(set, *args.first()?) != 0)), + "delete" => Some(boolean(crate::set::js_set_delete(set, *args.first()?) != 0)), + "clear" => { + crate::set::js_set_clear(set); + Some(undefined) + } + "forEach" => { + let callback = *args.first()?; + let this_arg = args.get(1).copied().unwrap_or(undefined); + crate::set::js_set_foreach_with_collection( + set, + callback, + this_arg, + this_handle.get_nanbox_f64(), + ); + Some(undefined) + } + // `Set.prototype.keys` is an alias of `values`, and the default + // iterator is `values` — matching the builtin. + "keys" | "values" | "Symbol.iterator" | "@@iterator" => Some(boxed( + crate::collection_iter_object::js_set_values_iter_obj(set), + )), + "entries" => Some(boxed( + crate::collection_iter_object::js_set_entries_iter_obj(set), + )), + _ => None, + }, + } + } +} + /// `super()` for a `class X extends Map | Set`. `kind`: 0 = Map, 1 = Set. /// `iterable` is the (optional) first constructor argument; `undefined`/`null` /// seed an empty collection. diff --git a/test-files/test_gap_6325_map_set_subclass.ts b/test-files/test_gap_6325_map_set_subclass.ts new file mode 100644 index 0000000000..1262417ada --- /dev/null +++ b/test-files/test_gap_6325_map_set_subclass.ts @@ -0,0 +1,117 @@ +// #6325: `class M extends Map {}` / `extends Set {}` — an IMPLICIT (no own +// constructor) subclass of Map/Set produced an instance with no collection +// storage and therefore no collection methods at all: `m.set("a", 1)` threw +// `TypeError: set is not a function`. +// +// Perry models a Map/Set subclass instance as a plain object carrying a hidden +// BACKING `MapHeader`/`SetHeader` (see `object/map_set_subclass.rs`); the +// backing is allocated by `js_map_set_subclass_init`, which only ever ran from +// the EXPLICIT-`super()` lowering. A class with no own constructor never calls +// `super()` in source, so the implicit-default-ctor `new` path skipped the init +// and the instance stayed storage-less. +// +// The init now fires wherever the class CHAIN reaches Map/Set, not only where a +// literal `super()` appears. + +// ── the issue's exact repro: no ctor, no override ── +class M extends Map {} +const m = new M(); +m.set("a", 1); +console.log("map subclass get:", m.get("a"), "size:", m.size); + +class S extends Set {} +const s = new S(); +s.add("x"); +console.log("set subclass has:", s.has("x"), "size:", s.size); + +// ── the registry parent edge is wired, so `instanceof` holds ── +console.log("instanceof:", m instanceof Map, s instanceof Set); + +// ── explicit-ctor control (this path already worked — must keep working) ── +class WithCtor extends Map { + tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } +} +const wc = new WithCtor("t"); +wc.set("b", 2); +console.log("explicit ctor:", wc.get("b"), wc.size, wc.tag); + +// ── constructor argument seeds the backing on the implicit path too ── +class Seeded extends Map {} +const seeded = new Seeded([ + ["k", 9], + ["j", 8], +]); +console.log("seeded:", seeded.get("k"), seeded.get("j"), seeded.size); + +class SeededSet extends Set {} +const ss = new SeededSet([1, 2, 2, 3]); +console.log("seeded set:", ss.size, ss.has(2), ss.has(4)); + +// ── INDIRECT subclass: the chain reaches Map one hop away ── +class MidMap extends Map {} +class LeafMap extends MidMap {} +const leaf = new LeafMap(); +leaf.set("z", 3); +console.log("indirect:", leaf.get("z"), leaf.size, leaf instanceof Map); + +// ── the full collection surface, iteration included ── +const all = new M(); +all.set("one", 1); +all.set("two", 2); +const keys: string[] = []; +all.forEach((_v: number, k: string) => keys.push(k)); +console.log("forEach keys:", keys.join(",")); +console.log("spread:", JSON.stringify([...all])); +console.log("has/delete:", all.has("one"), all.delete("one"), all.size); +all.clear(); +console.log("cleared:", all.size); + +// ── a subclass OVERRIDE must win over the backing surface, and `super.()` +// from inside it must reach the base (the #6316/#6322 ordering discipline) ── +class Counting extends Map { + writes = 0; + set(k: string, v: number): this { + this.writes++; + return super.set(k, v); + } + get(k: string): number | undefined { + const raw = super.get(k); + return raw === undefined ? undefined : raw * 10; + } +} +const counting = new Counting(); +counting.set("a", 1); +counting.set("b", 2); +console.log("override:", counting.get("a"), counting.get("b"), counting.get("zz")); +console.log("override writes:", counting.writes, "size:", counting.size); + +class LoudSet extends Set { + added: number[] = []; + add(v: number): this { + this.added.push(v); + return super.add(v); + } +} +const loud = new LoudSet(); +loud.add(4); +loud.add(5); +console.log("set override:", loud.added.join(","), loud.size, loud.has(5)); + +// ── a non-collection user method on a Map subclass still dispatches normally ── +class Extra extends Map { + total(): number { + let t = 0; + this.forEach((v: number) => { + t += v; + }); + return t; + } +} +const extra = new Extra(); +extra.set("p", 3); +extra.set("q", 4); +console.log("user method:", extra.total(), extra.size); diff --git a/test-files/test_gap_6326_indirect_native_base.ts b/test-files/test_gap_6326_indirect_native_base.ts new file mode 100644 index 0000000000..96c55577d4 --- /dev/null +++ b/test-files/test_gap_6326_indirect_native_base.ts @@ -0,0 +1,105 @@ +// #6326: an INDIRECT subclass of a native base inherited nothing. +// +// Perry models `EventEmitter` (and the `node:stream` classes) by stamping the +// base's method surface onto the INSTANCE at construction time. That init only +// fired when the class named the base LITERALLY in its own `extends` clause, so +// `class B extends EventEmitter {}` + `class D extends B {}` lost the emitter +// surface entirely one hop away — `typeof d.on` was `undefined`. +// +// The trigger is now "the class CHAIN reaches the native base", walking through +// ctor-less user classes exactly the way `node_stream_parent_kind` already did +// for the stream bases. A user class WITH its own constructor stops the walk: +// its `super()` performs the init itself, and doing it twice would re-stamp the +// surface over a live emitter. + +import { EventEmitter } from "node:events"; + +// ── the issue's exact repro: one hop away, no constructors anywhere ── +class B extends EventEmitter {} +class D extends B {} +const d = new D(); +console.log("typeof d.on:", typeof d.on, "typeof d.emit:", typeof d.emit); +d.on("ping", (v: number) => console.log("d listener got:", v)); +console.log("d emit returned:", d.emit("ping", 42)); + +// ── deep chain: D2 → C2 → B → EventEmitter ── +class C2 extends B {} +class D2 extends C2 {} +const d2 = new D2(); +console.log("deep typeof:", typeof d2.on, typeof d2.emit); +d2.on("deep", (v: string) => console.log("d2 listener got:", v)); +console.log("deep emit returned:", d2.emit("deep", "hello")); + +// ── indirect subclass WITH its own constructor calling super() ── +class Counter extends B { + seen: number; + constructor(start: number) { + super(); + this.seen = start; + } + bump(): void { + this.seen++; + this.emit("bump", this.seen); + } +} +const counter = new Counter(10); +console.log("counter typeof:", typeof counter.on, "seen:", counter.seen); +counter.on("bump", (n: number) => console.log("counter bumped to:", n)); +counter.bump(); + +// ── an intermediate class WITH a constructor: its super() does the init, and +// the leaf must NOT double-install over it (listeners registered by the +// intermediate's ctor survive) ── +class Seeded extends EventEmitter { + constructor() { + super(); + this.on("seeded", (v: string) => console.log("seeded listener got:", v)); + } +} +class SeededLeaf extends Seeded {} +const sl = new SeededLeaf(); +console.log("seeded leaf typeof:", typeof sl.emit); +console.log("seeded leaf emit returned:", sl.emit("seeded", "kept")); + +// ── instance fields on both levels still initialize, and the emitter surface +// installed underneath them works ── +// +// (Deliberately EXERCISED, not just `typeof`-probed: a bare `typeof lf.on` +// value-read next to a declared-field read lets scalar replacement promote the +// instance to scalars — it never escapes — and a runtime-INSTALLED own property +// is invisible to that promotion, so the read comes back `undefined`. That is a +// pre-existing gap independent of the native base, and it reproduces on `main` +// for a DIRECT `class X extends EventEmitter { a = 1 }` too.) +class WithField extends EventEmitter { + base = "base"; +} +class LeafField extends WithField { + leaf = "leaf"; +} +const lf = new LeafField(); +lf.on("field", (v: string) => console.log("field listener got:", v)); +console.log("fields:", lf.base, lf.leaf, typeof lf.on); +console.log("fields emit returned:", lf.emit("field", "ok")); + +// ── an indirect subclass OVERRIDE still wins over the native base, and +// `super.()` reaches the base (#6316 / #6322 ordering discipline) ── +class Loud extends B { + emit(ev: string, ...args: any[]): boolean { + console.log("Loud.emit override ran:", ev); + return super.emit(ev, ...args); + } +} +const loud = new Loud(); +loud.on("x", (v: number) => console.log("loud listener got:", v)); +console.log("loud emit returned:", loud.emit("x", 7)); + +// ── direct-subclass control: unchanged behavior ── +class Plain extends EventEmitter {} +const plain = new Plain(); +plain.on("go", (v: number) => console.log("plain listener got:", v)); +console.log("plain emit returned:", plain.emit("go", 3)); + +// ── listenerCount / removeAllListeners reach the base through the chain ── +console.log("d listenerCount:", d.listenerCount("ping")); +d.removeAllListeners("ping"); +console.log("d after removeAll:", d.listenerCount("ping")); diff --git a/test-files/test_gap_6336_class_expr_builtin_parent.ts b/test-files/test_gap_6336_class_expr_builtin_parent.ts new file mode 100644 index 0000000000..b6e8e5d076 --- /dev/null +++ b/test-files/test_gap_6336_class_expr_builtin_parent.ts @@ -0,0 +1,66 @@ +// #6336: an IMMEDIATELY-CONSTRUCTED class expression — `new (class extends +// Base {})()` — never registered its `Subclass → Base` edge when `Base` was a +// builtin, so the instance came out parentless: `instanceof Base` was false and +// the inherited native surface read as `undefined`. +// +// The registry edge for a runtime-value parent is a SIDE EFFECT of evaluating +// the class 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. The +// callee-position form takes a different HIR path that lowered the class +// straight to a `New` — skipping the registration entirely. It now sequences the +// same registration in front of the construction. +// +// The class-chain native-base init (#6325/#6326) rides on the same fix: with the +// parent edge wired and the base reachable through the chain, an anonymous +// `class extends Map` / `class extends EventEmitter` gets its surface too. + +import { EventEmitter } from "node:events"; + +// ── the issue's exact repro: builtin parent, constructed in place ── +const ev = new (class extends Event {})("tick"); +console.log("anon Event:", ev instanceof Event, ev.type); + +// ── const-bound control: the shape that already worked ── +const E2 = class extends Event {}; +const e2 = new E2("bound"); +console.log("bound Event:", e2 instanceof Event, e2.type); + +// ── USER parent control: this shape always worked, and must keep working ── +class UserBase { + hello(): string { + return "hi"; + } +} +const p = new (class extends UserBase {})(); +console.log("anon user parent:", p instanceof UserBase, typeof p.hello, p.hello()); + +// ── an anonymous subclass with its own members on top of the builtin base ── +const tagged = new (class extends Event { + tag = "T"; + describe(): string { + return this.tag + ":" + this.type; + } +})("go"); +console.log("anon Event members:", tagged instanceof Event, tagged.describe()); + +// ── the native-base surface reaches an anonymous subclass too ── +const anonEmitter = new (class extends EventEmitter {})(); +console.log("anon emitter:", typeof anonEmitter.on, typeof anonEmitter.emit); +anonEmitter.on("hit", (v: number) => console.log("anon emitter got:", v)); +console.log("anon emitter emit:", anonEmitter.emit("hit", 9)); + +const anonMap = new (class extends Map {})(); +anonMap.set("k", 1); +console.log("anon map:", anonMap.get("k"), anonMap.size, anonMap instanceof Map); + +// ── a named class expression in callee position behaves the same ── +const named = new (class MyEvent extends Event {})("named"); +console.log("named class expr:", named instanceof Event, named.type); + +// ── constructed in place inside a FUNCTION body (the mixin-ish shape) ── +function makeEvent(t: string): Event { + return new (class extends Event {})(t); +} +const fromFn = makeEvent("fn"); +console.log("from function:", fromFn instanceof Event, fromFn.type); From 8fbdbb85bf36ad10b16da68012dac4d71e230e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 11:05:33 +0200 Subject: [PATCH 2/2] fix(codegen): init ctor-less intermediate classes after a native base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../perry-codegen/src/expr/this_super_call.rs | 12 ++++++---- .../test_gap_6326_indirect_native_base.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 646389944f..a24577f031 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -866,13 +866,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &lowered_args, ); // Spec: derived-class field initializers run AFTER `super()` - // returns. The ancestors' fields were applied up-front by the - // own-ctor path (`FieldInitMode::AncestorsOnly`), so only the - // leaf's remain — same call every other builtin arm makes. + // returns. The native base is the chain root and has no TS + // fields, so everything after it still needs initializing — + // including ctor-less intermediates, which write no `super()` + // of their own and so have no other site that would do it. + // `AncestorsOnly` only covers the root, so `SelfOnly` here + // would leave a middle class like `B` in + // `C -> B -> A -> EventEmitter` uninitialized. crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, - crate::lower_call::FieldInitMode::SelfOnly, + crate::lower_call::FieldInitMode::AfterRoot, )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } diff --git a/test-files/test_gap_6326_indirect_native_base.ts b/test-files/test_gap_6326_indirect_native_base.ts index 96c55577d4..92c33371b2 100644 --- a/test-files/test_gap_6326_indirect_native_base.ts +++ b/test-files/test_gap_6326_indirect_native_base.ts @@ -103,3 +103,26 @@ console.log("plain emit returned:", plain.emit("go", 3)); console.log("d listenerCount:", d.listenerCount("ping")); d.removeAllListeners("ping"); console.log("d after removeAll:", d.listenerCount("ping")); + +// ── ctor-less INTERMEDIATE classes must still run their field initializers ── +// The native base is the chain root and has no TS fields, so every class after +// it needs initializing — including intermediates that write no `super()` of +// their own. A middle class (`FieldB` below) was left uninitialized when the +// post-super() pass only covered the leaf. Two intermediates are required to +// expose it: with one, the leaf and the root-adjacent class both happen to be +// covered. (Reported by review on #6342.) +class FieldA extends EventEmitter { + aField = "a-ok"; +} +class FieldB extends FieldA { + bField = "b-ok"; +} +class FieldC extends FieldB { + cField = "c-ok"; + constructor() { + super(); + } +} +const fc = new FieldC(); +console.log("chain fields:", fc.aField, fc.bField, fc.cField); +console.log("chain emit:", typeof fc.emit);