diff --git a/crates/perry-transform/src/inline/factory_specialize.rs b/crates/perry-transform/src/inline/factory_specialize.rs index 12f9be737..6e3b6e38f 100644 --- a/crates/perry-transform/src/inline/factory_specialize.rs +++ b/crates/perry-transform/src/inline/factory_specialize.rs @@ -823,6 +823,15 @@ pub fn specialize_captured_class_factories(module: &mut Module) { *next_class_counter += 1; let mut cloned = class.clone(); cloned.name = cloned_name.clone(); + // When the parent arg specializes to a runtime value rather than a + // static class (`Serializable(Mid)` where `Mid` is a local binding, not + // a `ClassRef`), we can't wire a static `extends_name`. The factory + // Call — including the `RegisterClassParentDynamic` its lowered Sequence + // carried — is about to be replaced by a bare `ClassRef` below, which + // would drop the parent edge entirely: `new Top()` would then inherit + // nothing from `Mid` (no fields, no methods, `instanceof Mid` false). + // Re-emit the dynamic registration at the call site instead (#6356). + let mut dynamic_parent_expr: Option = None; if let Some(extends_expr) = cloned.extends_expr.as_mut() { substitute_locals(extends_expr, ¶m_subst, &mut next_id_seed); if let Expr::ClassRef(parent_name) = extends_expr.as_ref() { @@ -837,6 +846,8 @@ pub fn specialize_captured_class_factories(module: &mut Module) { .find(|c| &c.name == parent_name) .map(|c| c.id) }); + } else { + dynamic_parent_expr = Some((**extends_expr).clone()); } } // Filter out the capture ctor params + matching synthetic fields + @@ -914,13 +925,42 @@ pub fn specialize_captured_class_factories(module: &mut Module) { cloned.constructor = None; } } + // Assign a FRESH, unique class id. `class.clone()` above copied the + // template's id, and codegen keys the runtime class registry (methods, + // constructor, parent edges) by `c.id` (`codegen/mod.rs` builds + // `class_ids` from each class's `id`). Leaving the copy in place makes a + // clone collide with its template AND with sibling clones of the same + // template (`Serializable(Greetable(Base))` and `Serializable(Mid)` both + // clone `__anon_class_N`) — the distinct classes then share one registry + // slot, last-writer-wins on the constructor/parent edge, and a two-level + // dynamic mixin chain loses its intermediate parent (#6356). Ids past + // the current max are free: codegen derives its imported-class fallback + // range from the post-specialization max, and static parent edges are + // resolved by NAME, so only the numeric id has to be unique here. Later + // clones referencing this one as a static parent (see the `extends` + // lookup above) read the id assigned here, so assign before pushing. + let base_class_id = classes.iter().map(|c| c.id).max().unwrap_or(0) + 1; + cloned.id = base_class_id + new_classes.len() as u32; new_classes.push(cloned); // Replace the Call with `ClassRef(cloned_name)`. The Let's init is // now a plain ClassRef — the regular inliner won't touch it and // subsequent `new ()` sites will see it as an alias for the // specialized class via the existing `local_class_aliases` - // mechanism in codegen. - *expr = Expr::ClassRef(cloned_name); + // mechanism in codegen. When the parent is a runtime value, sequence + // the dynamic parent registration ahead of the ClassRef (the Sequence + // still yields the ClassRef as its value) so the parent edge is wired + // when this init runs — mirroring the class-expression lowering in + // `perry-hir`'s `arm_class.rs`. + *expr = match dynamic_parent_expr { + Some(parent_expr) => Expr::Sequence(vec![ + Expr::RegisterClassParentDynamic { + class_name: cloned_name.clone(), + parent_expr: Box::new(parent_expr), + }, + Expr::ClassRef(cloned_name), + ]), + None => Expr::ClassRef(cloned_name), + }; true } @@ -942,13 +982,21 @@ pub fn specialize_captured_class_factories(module: &mut Module) { // lets `lower_new` walk through the specialized class for inherited // constructors and field initializers instead of the unspecialized // anonymous original. + // + // `parent_expr` may be an `Expr::Sequence([RegisterClassParentDynamic, …, + // ClassRef()])` when a class DECLARATION extends a factory call + // with a runtime-value arg (`class Child extends Factory(runtimeVar) {}`): + // `rewrite_call_init` rewrote the inner factory Call to that Sequence + // (see `rewrite_to_specialized_class`). Resolve through `classref_name`, + // which unwraps a Sequence to its trailing `ClassRef`, so the child's + // static parent still hoists rather than falling back to the runtime edge. for stmt in &module.init { if let Stmt::Expr(Expr::RegisterClassParentDynamic { class_name, parent_expr, }) = stmt { - if let Expr::ClassRef(parent_name) = parent_expr.as_ref() { + if let Some(ref parent_name) = classref_name(parent_expr.as_ref()) { if let Some(child) = module.classes.iter_mut().find(|c| &c.name == class_name) { child.extends_name = Some(parent_name.clone()); if let Some(parent_cls) = new_classes diff --git a/test-files/test_gap_6356_dynamic_parent_mixin_chain.ts b/test-files/test_gap_6356_dynamic_parent_mixin_chain.ts new file mode 100644 index 000000000..118e8772c --- /dev/null +++ b/test-files/test_gap_6356_dynamic_parent_mixin_chain.ts @@ -0,0 +1,114 @@ +// Issue #6356 — a class whose parent is wired at runtime (via +// `RegisterClassParentDynamic`, i.e. any mixin/factory class that declines the +// HIR mixin fast path) must inherit the base's instance FIELD initializers and, +// through a TWO-LEVEL dynamic chain, the grandparent's methods + `instanceof`. +// +// These shapes route through `specialize_captured_class_factories` +// (`perry-transform`), which monomorphizes the returned class per call site. +// Two defects made the two-level chain fail: +// +// 1. The specialized clone reused the template class's `id` (`class.clone()` +// copies it). Codegen keys the runtime class registry by that id, so a +// clone collided with its template AND with sibling clones of the same +// template — the distinct classes shared one registry slot (last-writer- +// wins on the constructor / parent edge). +// 2. When the parent arg specialized to a runtime value (`Serializable(Mid)` +// where `Mid` is a local binding, not a static `ClassRef`), the factory +// Call — including the `RegisterClassParentDynamic` its Sequence carried — +// was replaced by a bare `ClassRef`, dropping the parent edge entirely. +// +// Net symptom before the fix: `new Serializable(Greetable(Base))()` / the +// `Top = Serializable(Mid)` chain lost `.value`, `greet()`, and +// `instanceof Mid`. See the matrix in #6355 (which deliberately left these +// cells out because they matched neither node nor a clean gap). + +class Base { + value = "base value"; + hello() { + return "base hello"; + } +} + +// A canonical single-statement mixin (takes the HIR fast path when bound +// directly, e.g. `const Mid = Greetable(Base)`). +function Greetable(B: any) { + return class extends B { + greet() { + return "hi"; + } + }; +} + +function Serializable(B: any) { + return class extends B { + ser() { + return "ser"; + } + }; +} + +// A factory whose body is NOT the fast-path shape (`const K = …; return K`), +// so it declines the fast path and is factory-specialized instead. +function ViaConst(B: any) { + const K = class extends B { + greet() { + return "const"; + } + }; + return K; +} + +// --- (1) return-via-const: inherited field through a dynamic parent --------- +const C = ViaConst(Base); +console.log("viaconst method:", new C().hello()); +console.log("viaconst field:", (new C() as any).value); +console.log("viaconst instanceof base:", new C() instanceof Base); + +// --- (2) two mixins composed in one expression (arg is a Call) ------------- +const Comp = Serializable(Greetable(Base) as any); +console.log("composed field:", (new Comp() as any).value); +console.log("composed outer:", new Comp().ser()); +console.log("composed inner:", (new Comp() as any).greet()); +console.log("composed instanceof base:", new Comp() instanceof Base); + +// --- (3) mixin over a mixin RESULT (the core two-level dynamic chain) ------- +const Mid = Greetable(Base); +const Top = Serializable(Mid as any); +console.log("mid field:", (new Mid() as any).value); +console.log("mid greet:", (new Mid() as any).greet()); +console.log("mid instanceof base:", new Mid() instanceof Base); +console.log("chained field:", (new Top() as any).value); +console.log("chained greet:", (new Top() as any).greet()); +console.log("chained ser:", new Top().ser()); +console.log("chained inherited method:", (new Top() as any).hello()); +console.log("chained instanceof base:", new Top() instanceof Base); +console.log("chained instanceof mid:", new Top() instanceof Mid); + +// --- (4) sibling specializations of one template must not collide ---------- +// `Comp` and `Top` are both clones of Serializable's returned class; with the +// id-collision bug they shared a class id and their parent edges clobbered each +// other. Re-read both after constructing the other to confirm independence. +const compAgain = new Comp() as any; +const topAgain = new Top() as any; +console.log("sibling comp instanceof base:", compAgain instanceof Base); +console.log("sibling top instanceof mid:", topAgain instanceof Mid); +console.log("sibling comp value:", compAgain.value); +console.log("sibling top value:", topAgain.value); + +// --- (5) class DECLARATION extending a factory call with a runtime-value arg +// `class Child extends Serializable(V)` rewrites the factory Call into a +// `Sequence([RegisterClassParentDynamic, ClassRef(clone)])`. The decl-time +// `RegisterClassParentDynamic { class_name: "Child", parent_expr: }` must still hoist the clone as `Child`'s static parent (the hoist +// resolves through the Sequence's trailing `ClassRef`), so `Child` inherits the +// base's field/method through the specialized clone. +const V: any = Base; +class Child extends Serializable(V) { + child() { + return "child"; + } +} +console.log("decl field:", (new Child() as any).value); +console.log("decl inherited method:", (new Child() as any).hello()); +console.log("decl own method:", new Child().child()); +console.log("decl instanceof base:", new Child() instanceof Base);