From d302f354502b50396df6fc18a4bda23e5bc5aa61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 16 May 2026 05:42:10 +0200 Subject: [PATCH] fix(hir): mixin factories must register grandparent edge at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class expressions inside a function body — the canonical mixin shape `function WithA(B) { return class extends B {}; }` — pushed the synthetic class into `pending_classes` but never emitted a runtime parent-registration call. The class-decl arm at the top of `lower.rs` emits `Stmt::Expr(Expr::RegisterClassParentDynamic { … })` ONLY for top-level class declarations; an anonymous class produced by a factory got no equivalent side effect, so its `extends_expr` (a `LocalGet` of the factory's parameter `B`) was captured in `Class.extends_expr` but never evaluated. Surfaced by the #806 harness on this branch's parent. `class Chained extends WithA(WithB(WithC(CoreBase))) {}` — `Chained → Anon3` was wired (so `chained.a()` worked), but `Anon3 → Anon2 → Anon1 → CoreBase` was never registered. `chained.core()` walked Chained → Anon3 and stopped, throwing `TypeError: value is not a function`. The minimal repro is one level deep: `class X extends WithA(Base) {}` — `new X().baseMethod()` crashes; `const M = WithA(Base); class X extends M {}` works because the const path takes a different lowering branch that synthesizes a named class. Fix sits in the `ast::Expr::Class` arm of `lower_expr` (lower.rs:8570). When the lowered class has `extends_expr`, the arm now returns Expr::Sequence([ Expr::RegisterClassParentDynamic { class_name, parent_expr }, Expr::ClassRef(class_name), ]) instead of a bare `Expr::ClassRef`. Sequence is the existing comma-operator primitive — its codegen evaluates each element in order and returns the last, so the side effect runs every time the factory function executes and the value the call site sees is unchanged. No new HIR variant, no codegen changes, no boilerplate through walker/ monomorph/analysis/stable_hash/js_transform — the Sequence machinery already covers all of those. Validation: - `class Chained extends WithA(WithB(WithC(Base))) {}` now prints `core`/`a`/`b`/`c` matching Node. - `class X extends WithA(Base) {}` 1-deep variant prints `a`/`core` matching Node (was crashing before). - 4-test class smoke set (test_simple_class, test_get_prototype_of_instance, test_issue_711_function_prototype, test_gap_class_advanced) — all PASS byte-for-byte vs Node. - `cargo test --release -p perry-hir -p perry-codegen` — all green. The #806 harness still surfaces four unrelated gaps (bare-factory field initializer, super-arg propagation, static method dispatch, Effect double-call factory tag binding) — none cascade from this TypeError anymore, so they're each independently visible. --- crates/perry-hir/src/lower.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index 9078af17a..eb476d8a2 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -8572,8 +8572,31 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< let synthetic_name = ident_name.unwrap_or_else(|| format!("__anon_class_{}", ctx.fresh_class())); let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?; + // Mixin factories like `function WithA(B) { return class extends B {} }` + // produce a class whose super is the function-parameter `B` — a + // runtime value, not a statically-known class. The class-decl arm + // at the top of this file only pushes a `RegisterClassParentDynamic` + // statement for top-level class declarations; an anonymous class + // expression inside a function body never has that side effect + // fire, so `new (class extends WithA(Base) {})().baseMethod()` + // walks subclass → inner factory class and stops at the unwired + // grandparent edge (TypeError on the inherited method). Sequence + // the dynamic-parent registration in front of the ClassRef so the + // edge is wired every time the factory function executes; the + // Sequence yields its last element, so the value remains the + // ClassRef the call site expects. + let parent_expr = class.extends_expr.clone(); ctx.pending_classes.push(class); - Ok(Expr::ClassRef(synthetic_name)) + match parent_expr { + Some(p) => Ok(Expr::Sequence(vec![ + Expr::RegisterClassParentDynamic { + class_name: synthetic_name.clone(), + parent_expr: p, + }, + Expr::ClassRef(synthetic_name), + ])), + None => Ok(Expr::ClassRef(synthetic_name)), + } } ast::Expr::JSXElement(jsx) => lower_jsx_element(ctx, jsx), ast::Expr::JSXFragment(jsx) => lower_jsx_fragment(ctx, jsx),