From 8e6b01af85179c4f71c466ef3ca7f266b2c1bd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 09:46:36 +0200 Subject: [PATCH 1/3] fix(hir): bind the value of a mixin-factory result (#5952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const M = Mixin(Base)` where `Mixin` is the pre-scanned `function Mixin(B) { return class extends B {…} }` shape took the mixin fast path in `lower/stmt.rs`: it synthesized a real class under the binding name `M` and then `continue`d, never emitting the value binding for `M`. `new M()` and `instanceof M` kept working (both key off the class NAME), but `typeof M` / `M.name` / `String(M)` read an unassigned local and saw `undefined`. Emit `emit_class_expression_value_binding` before the `continue`, exactly as the sibling `const X = class {…}` arm does, so `M` holds `ClassRef("M")`. Also register the spec `.name`: the synthesized class is keyed by the binding name, but a directly-returned class expression gets no NamedEvaluation, so `M.name` is `""` (or the class expression's own name when it has one). `pre_scan_mixin_functions` now records that name. --- .../perry-hir/src/lower/lowering_context.rs | 28 +++++++++++-- crates/perry-hir/src/lower/mod.rs | 2 +- crates/perry-hir/src/lower/pre_scan.rs | 10 ++++- crates/perry-hir/src/lower/stmt.rs | 40 +++++++++++++++++-- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index a6a57c821..b661f87ce 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -52,6 +52,24 @@ pub(crate) struct PrivateScope { pub(crate) members: HashMap, } +/// A `function Mixin(B) { return class [Name] extends B { … } }` recorded by +/// `pre_scan_mixin_functions`, so a `const M = Mixin(Base)` call site can +/// synthesize a real class extending the concrete base. +#[derive(Debug, Clone)] +pub(crate) struct MixinFn { + /// The mixin function's single parameter — the name the returned class + /// `extends`. + pub(crate) param_name: String, + /// The returned class EXPRESSION's own name, if it has one (`return class + /// Named extends B {}`). `None` for the anonymous form, whose `.name` is + /// the empty string per spec — a directly-returned class expression gets + /// no NamedEvaluation from the call site's binding. + pub(crate) class_expr_name: Option, + /// The returned class's AST, copied verbatim; the call site rewrites its + /// `extends` clause to the concrete base. + pub(crate) class_ast: Box, +} + pub struct LoweringContext { /// Counter for generating unique local IDs pub(crate) next_local_id: LocalId, @@ -623,10 +641,12 @@ pub struct LoweringContext { /// name, so the New expression points at a real HIR class. pub(crate) class_expr_aliases: HashMap, /// Mixin functions: `function withName(B: Constructor) { return class extends B { ... } }`. - /// Maps mixin name → (param_name, captured class AST). Stub field - /// added to satisfy in-tree references; full mixin support is a - /// separate workstream. - pub(crate) mixin_funcs: HashMap)>, + /// Maps mixin name → (param_name, class-expression's own name, captured + /// class AST). The class-expression name is `None` for the common + /// anonymous `return class extends B {…}` form and drives the synthesized + /// class's user-visible `.name` (issue #5952). Stub field added to satisfy + /// in-tree references; full mixin support is a separate workstream. + pub(crate) mixin_funcs: HashMap, /// Set to the class name when lowering inside a class constructor body. /// Used to resolve `new.target` to a placeholder object whose `.name` /// returns the class name. None outside any constructor. diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index eef37c87f..e3ecea751 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -85,7 +85,7 @@ pub(crate) use widget_decl::*; // so spelling them out keeps the public-and-internal API stable. mod lowering_context; pub(crate) use lowering_context::{ - LoweringContext, PrivKind, PrivMember, PrivateScope, WithEnvFrame, + LoweringContext, MixinFn, PrivKind, PrivMember, PrivateScope, WithEnvFrame, }; mod locals; diff --git a/crates/perry-hir/src/lower/pre_scan.rs b/crates/perry-hir/src/lower/pre_scan.rs index 87dff6475..db4b88077 100644 --- a/crates/perry-hir/src/lower/pre_scan.rs +++ b/crates/perry-hir/src/lower/pre_scan.rs @@ -410,8 +410,14 @@ pub(crate) fn pre_scan_mixin_functions(ast_module: &ast::Module, ctx: &mut Lower return; } let fn_name = fn_decl.ident.sym.to_string(); - ctx.mixin_funcs - .insert(fn_name, (param_name, Box::new((*class_expr.class).clone()))); + ctx.mixin_funcs.insert( + fn_name, + crate::lower::MixinFn { + param_name, + class_expr_name: class_expr.ident.as_ref().map(|i| i.sym.to_string()), + class_ast: Box::new((*class_expr.class).clone()), + }, + ); } for item in &ast_module.body { match item { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index dd19adb69..8c688f5f3 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -805,8 +805,7 @@ pub(crate) fn lower_stmt( if let ast::Callee::Expr(callee_expr) = &call.callee { if let ast::Expr::Ident(fn_ident) = callee_expr.as_ref() { let fn_name = fn_ident.sym.to_string(); - if let Some((_param_name, mixin_class_box)) = - ctx.mixin_funcs.get(&fn_name).cloned() + if let Some(mixin) = ctx.mixin_funcs.get(&fn_name).cloned() { if call.args.len() == 1 { if let ast::Expr::Ident(base_ident) = @@ -819,7 +818,7 @@ pub(crate) fn lower_stmt( let bind_name = ident.id.sym.to_string(); if ctx.lookup_class(&bind_name).is_none() { let mut new_class = - (*mixin_class_box).clone(); + (*mixin.class_ast).clone(); let base_id = ast::Ident::new( base_class_name.clone().into(), base_ident.span, @@ -834,11 +833,46 @@ pub(crate) fn lower_stmt( &bind_name, false, )?; + // Issue #5952: the synthesized class is + // registered under the BINDING name so + // `new M()` / `instanceof M` resolve + // statically, but its user-visible + // `.name` is the class EXPRESSION's own + // name — the empty string for the + // canonical anonymous + // `return class extends B {…}` (a + // directly-returned class expression + // gets no NamedEvaluation from the call + // site's `const M =`). Record the + // override so codegen registers the + // spec name rather than the + // registration key. + ctx.class_display_names.insert( + lowered_class.id, + mixin + .class_expr_name + .clone() + .unwrap_or_default(), + ); push_class_dedup(module, lowered_class); ctx.class_expr_aliases.insert( bind_name.clone(), bind_name.clone(), ); + // Issue #5952: bind the VALUE too. The + // sibling `const X = class {…}` arm + // above ends with this call; this arm + // used to `continue` straight from the + // class synthesis, so `M` was never + // bound — `typeof M` / `M.name` / + // `String(M)` read an unassigned local + // (undefined) even though `new M()` and + // `instanceof M` worked (both key off + // the class NAME, not the binding). + emit_class_expression_value_binding( + ctx, module, &bind_name, mutable, + is_var, + ); continue; } } From a23446f543f97821435cba2170e9173beaf6d1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 09:58:06 +0200 Subject: [PATCH 2/3] fix(runtime): let a spec-anonymous class register its empty .name (#5952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_register_class_name` treated a zero-length name as a no-op, so the synthesized mixin class from `const M = Mixin(Base)` never landed in CLASS_NAMES and `M.name` read `undefined` instead of `""`. An anonymous class expression's `.name` IS the empty string; membership in CLASS_NAMES means 'this id is a real class', which stays true for an anonymous one. Registering an empty name makes the previously-unreachable `!name.is_empty()` filter in `prototype_equality` live and wrong — it would collapse an anonymous class's instances onto Object.prototype. Test the registration, not the name's emptiness. --- .../builtins/formatting/prototype_equality.rs | 21 +++++++++++-------- .../src/object/class_registry/class_meta.rs | 9 +++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/builtins/formatting/prototype_equality.rs b/crates/perry-runtime/src/builtins/formatting/prototype_equality.rs index d3defd01f..02fda94b9 100644 --- a/crates/perry-runtime/src/builtins/formatting/prototype_equality.rs +++ b/crates/perry-runtime/src/builtins/formatting/prototype_equality.rs @@ -75,16 +75,19 @@ pub(super) fn prototype_token(value: f64) -> Option { // while a `{}`-born object mutated afterwards keeps class_id 0 — both // have Object.prototype, so normalize unregistered ids to 0 or the // two would spuriously compare as prototype-different. + // + // The test is REGISTERED, not "registered under a non-empty name": an + // anonymous class expression (`const M = Mixin(Base)`, issue #5952) + // registers the empty string as its `.name` and is every bit a real + // constructor, so its instances must keep their own prototype identity + // rather than collapse onto Object.prototype. let class_id = (*obj).class_id; - let proto_class_id = if class_id != 0 - && crate::object::class_name_for_id(class_id) - .filter(|name| !name.is_empty()) - .is_none() - { - 0 - } else { - class_id - }; + let proto_class_id = + if class_id != 0 && crate::object::class_name_for_id(class_id).is_none() { + 0 + } else { + class_id + }; Some(CLASS_PROTO_NAMESPACE | proto_class_id as u64) } } diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 6c1e7aab6..706735a7c 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -30,9 +30,16 @@ pub static CLASS_NAMES: RwLock>> = RwLock::new(None) /// Register the user-visible name of a class so the V8 bridge can label /// the V8-side wrapper for nice `metatype.name` reads. Idempotent. +/// +/// A zero-length name is a legitimate registration, not a no-op: an anonymous +/// class expression's `.name` IS the empty string per spec (issue #5952 — +/// `const M = Mixin(Base)` over `function Mixin(B) { return class extends B {} }` +/// binds a class whose `name` is `""`). Membership in `CLASS_NAMES` means "this +/// id is a real class", which stays true for an anonymous one; only the null +/// pointer and the reserved id 0 are rejected. #[no_mangle] pub unsafe extern "C" fn js_register_class_name(class_id: u32, name_ptr: *const u8, name_len: u32) { - if class_id == 0 || name_ptr.is_null() || name_len == 0 { + if class_id == 0 || name_ptr.is_null() { return; } let slice = std::slice::from_raw_parts(name_ptr, name_len as usize); From 2285c4b2f8ac1329768ce32de57d3d70e996fa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 10:01:22 +0200 Subject: [PATCH 3/3] test(gap): mixin-factory binding + neighbourhood shapes (#5952) Byte-compared against node --experimental-strip-types. Covers the direct return, a named returned class expression, return-via-const, return-via-intermediate, two mixins composed, a mixin over a mixin result, and the static-parent captured-param factory whose specialization must survive. Fails on main at the first line (typeof undefined). --- .../test_gap_5952_mixin_factory_binding.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 test-files/test_gap_5952_mixin_factory_binding.ts diff --git a/test-files/test_gap_5952_mixin_factory_binding.ts b/test-files/test_gap_5952_mixin_factory_binding.ts new file mode 100644 index 000000000..fcb233533 --- /dev/null +++ b/test-files/test_gap_5952_mixin_factory_binding.ts @@ -0,0 +1,138 @@ +// Issue #5952 — a mixin factory that DIRECTLY returns a dynamic-parent class +// expression must still bind its result as a value. +// +// `function Mixin(B) { return class extends B {…} }` + `const M = Mixin(Base)` +// takes a fast path in HIR lowering that synthesizes a real class under the +// binding name `M`. That path used to synthesize the class and stop, never +// emitting the value binding — so `new M()` and `instanceof M` worked (both +// key off the class NAME) while `typeof M` / `M.name` / `M === M` read an +// unassigned local and saw `undefined`. +// +// Covers the neighbourhood: direct return, a NAMED returned class expression, +// return-via-const, return-via-intermediate, two mixins composed in one +// expression, a mixin over another mixin's result, and a static-parent +// captured-param factory (the class-factory specialization pass's own win, +// which must survive). + +class Base { + value = "base value"; + hello() { + return "base hello"; + } +} + +// (1) the reported shape: direct return of `class extends ` +function Greetable(B: any) { + return class extends B { + greet() { + return "hi"; + } + }; +} + +// (2) same, but the returned class expression has its own name +function Named(B: any) { + return class Marker extends B { + who() { + return "marker"; + } + }; +} + +// (3) bound to a const inside the factory, then returned +function ViaConst(B: any) { + const K = class extends B { + greet() { + return "const"; + } + }; + return K; +} + +// (4) bounced through an intermediate variable +function ViaTemp(B: any) { + const K = class extends B { + greet() { + return "temp"; + } + }; + const K2 = K; + return K2; +} + +// (5) a second mixin, for composition +function Serializable(B: any) { + return class extends B { + ser() { + return "ser"; + } + }; +} + +// (6) static-parent factory capturing a param — specialized per call site by +// `specialize_captured_class_factories`; the capture must stay baked in. +function makeTagged(tag: string) { + class Inner { + readonly _tag = tag; + who() { + return "tagged:" + this._tag; + } + } + return Inner; +} + +// --- (1) direct return ------------------------------------------------- +const M = Greetable(Base); +console.log("direct typeof:", typeof M); +console.log("direct name:", JSON.stringify(M.name)); +console.log("direct method:", new M().greet()); +console.log("direct inherited method:", (new M() as any).hello()); +console.log("direct inherited field:", (new M() as any).value); +console.log("direct instanceof self:", new M() instanceof M); +console.log("direct instanceof base:", new M() instanceof Base); +console.log("direct constructor:", (new M() as any).constructor === M); +console.log("direct prototype:", Object.getPrototypeOf(new M()) === M.prototype); +console.log("direct identity:", (M as any) !== (Base as any)); + +// --- (2) named class expression ---------------------------------------- +const N = Named(Base); +console.log("named typeof:", typeof N); +console.log("named name:", JSON.stringify(N.name)); +console.log("named method:", new N().who()); +console.log("named instanceof base:", new N() instanceof Base); + +// --- (3) returned via a const binding ----------------------------------- +const C = ViaConst(Base); +console.log("viaconst typeof:", typeof C); +console.log("viaconst method:", new C().greet()); +console.log("viaconst inherited method:", (new C() as any).hello()); +console.log("viaconst instanceof base:", new C() instanceof Base); + +// --- (4) returned via an intermediate variable --------------------------- +const T = ViaTemp(Base); +console.log("viatemp typeof:", typeof T); +console.log("viatemp method:", new T().greet()); +console.log("viatemp instanceof base:", new T() instanceof Base); + +// --- (5) two mixins composed in one expression --------------------------- +const Composed = Serializable(Greetable(Base) as any); +console.log("composed typeof:", typeof Composed); +console.log("composed outer:", new Composed().ser()); +console.log("composed inner:", (new Composed() as any).greet()); +console.log("composed instanceof base:", new Composed() instanceof Base); + +// --- (6) a mixin whose parent is itself a mixin result -------------------- +const Mid = Greetable(Base); +const Top = Serializable(Mid as any); +console.log("chained typeof mid:", typeof Mid); +console.log("chained typeof top:", typeof Top); +console.log("chained outer:", new Top().ser()); +console.log("chained instanceof base:", new Top() instanceof Base); +console.log("chained identity:", (Mid as any) !== (Top as any)); + +// --- (7) static-parent captured-param factory ----------------------------- +const Tagged = makeTagged("S"); +console.log("factory typeof:", typeof Tagged); +console.log("factory name:", JSON.stringify(Tagged.name)); +console.log("factory method:", new Tagged().who()); +console.log("factory captured field:", new Tagged()._tag);