From 64b985fb5027431b432e50c1c58201ef7f1b6a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 15 May 2026 23:07:45 +0200 Subject: [PATCH] =?UTF-8?q?tests:=20#806=20=E2=80=94=20systematic=20harnes?= =?UTF-8?q?s=20for=20class-extends-factory=20/=20mixin=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `class X extends Fn(...)<...>` had zero systematic coverage. This harness covers eight independent sub-cases, each printing a stable `section: result` line so parity diffs pin exactly which sub-case regressed: 1. Bare factory — `class X extends makeBare()`. 2. Parameterized factory — `class X extends tagged("name", schema)`. 3. Captured-factory generic — `class X extends makeNamed("...")`. 4. Chained mixins — `class X extends WithA(WithB(WithC(Base)))`. 5. Mixin with super-arg forwarding. 6. Factory with static method. 7. Effect-shape double-call — `class X extends TaggedError()("tag", schema)`. 8. Parameterized mixin factory — `ParamMixin(seed)(Base)`. Verified against `node --experimental-strip-types` on macOS; baseline prints 22 stable lines. Perry currently passes sub-cases 2, 3, and the captured Effect shape (post-#740) but surfaces two real gaps: - **Bare factory** — `bare.kind: undefined` instead of `bare.kind: bare`. The base class's field initializer is dropped when the parent is a factory-returned class expression. - **Chained mixins** — `TypeError: value is not a function` thrown before construction; the layered `WithA(WithB(WithC(...)))` shape doesn't resolve through the mixin chain. Both are linked to the #321 Effect umbrella and added to `known_failures.json` so this PR doesn't break the parity gate. Each sub-case flips to PASS independently as the underlying lowering work lands. --- test-files/test_harness_class_mixins.ts | 189 ++++++++++++++++++++++++ test-parity/known_failures.json | 1 + 2 files changed, 190 insertions(+) create mode 100644 test-files/test_harness_class_mixins.ts diff --git a/test-files/test_harness_class_mixins.ts b/test-files/test_harness_class_mixins.ts new file mode 100644 index 00000000..0da2c7d9 --- /dev/null +++ b/test-files/test_harness_class_mixins.ts @@ -0,0 +1,189 @@ +// #806 — systematic harness for `class X extends Fn(...)<...>` patterns. +// +// Effect's TaggedError, Schema.Class, and similar factory-shaped bases +// exercise a corner of TS+JS that historically broke perry in subtle +// ways (e.g. #740 captured-factory + #809 cross-module spread). This +// file covers the family with small, independent assertions, each +// printing a stable line so the parity runner can diff vs Node. +// +// All assertions print a "section: result" line so a single grep tells +// you which sub-case regressed. + +// ── 1. Bare factory: `class X extends Fn()` ──────────────────────────────── +// The simplest dynamic-extends shape: a 0-arg factory returns a class, +// the user subclasses the result. Caught the #740 base-shape regression. +function makeBare() { + return class { + kind = "bare"; + hello(): string { + return "Hi from Bare"; + } + }; +} +class Bare extends makeBare() { + extra = 7; +} +const bare = new Bare(); +console.log("bare.kind:", bare.kind); +console.log("bare.hello:", bare.hello()); +console.log("bare.extra:", bare.extra); + +// ── 2. Parameterized factory: `class X extends Fn("tag", schema)` ────────── +// Effect's `Schema.Class` / `TaggedError` shape. The factory takes +// runtime args that get baked into the produced class's prototype. +function tagged(tag: string, fields: Record) { + return class { + readonly _tag = tag; + readonly _fields = fields; + }; +} +class MyTagged extends tagged("MyTag", { a: "string", b: "number" }) { + // Subclasses freely add fields/methods. The parameter-baked + // properties survive the subclass step. +} +const t = new MyTagged(); +console.log("tagged._tag:", t._tag); +console.log("tagged._fields.a:", t._fields.a); +console.log("tagged._fields.b:", t._fields.b); + +// ── 3. Captured-factory: `class X extends F()` ────────────────────────── +// Effect's TaggedError closes around a Constructable. The factory is +// captured-then-called — `F()` evaluates the call AFTER reading the +// generic, and Perry must preserve that. #740 root cause was the captured +// form silently aliasing to the un-captured base. +function makeNamed(name: T) { + return class { + name: T = name; + greet(): string { + // Cast the name to a primitive for printing. The test fixes T = string + // so this is a no-op cast in the type system but keeps the lowering + // honest about T's runtime shape. + return `Hello, ${String(this.name)}`; + } + }; +} +class Greeter extends makeNamed("world") { + emphasize(): string { + return this.greet() + "!"; + } +} +const g = new Greeter(); +console.log("captured.name:", g.name); +console.log("captured.greet:", g.greet()); +console.log("captured.emphasize:", g.emphasize()); + +// ── 4. Chained mixins: `class X extends M1(M2(M3(Base)))` ────────────────── +// Layered mixin pattern from older TypeScript codebases (sequelize-typescript, +// older Nest, etc.). Each layer wraps the prior class and adds a method. +class CoreBase { + core(): string { + return "core"; + } +} +type Ctor = new (...args: any[]) => T; +function WithA(B: TBase) { + return class extends B { + a(): string { + return "a"; + } + }; +} +function WithB(B: TBase) { + return class extends B { + b(): string { + return "b"; + } + }; +} +function WithC(B: TBase) { + return class extends B { + c(): string { + return "c"; + } + }; +} +class Chained extends WithA(WithB(WithC(CoreBase))) {} +const chained = new Chained(); +console.log("chained.core:", chained.core()); +console.log("chained.a:", chained.a()); +console.log("chained.b:", chained.b()); +console.log("chained.c:", chained.c()); + +// ── 5. Mixin that calls super() with args ────────────────────────────────── +// Constructor-arg forwarding through a mixin layer. Both Perry and Node +// must propagate the super-constructor's side effects. +class Logged { + log: string; + constructor(seed: string) { + this.log = "seed=" + seed; + } +} +function WithSuffix>(B: TBase) { + return class extends B { + constructor(seed: string) { + super(seed); + this.log += ":wrapped"; + } + }; +} +class WrappedLogged extends WithSuffix(Logged) {} +const wl = new WrappedLogged("alpha"); +console.log("super-args.log:", wl.log); + +// ── 6. Factory returning class expression with static method ────────────── +// Statics on factory-produced classes must survive the subclass step +// without losing their `this`-binding. +function makeWithStatic() { + return class { + static prefix(s: string): string { + return "STATIC:" + s; + } + instance(): string { + return "instance"; + } + }; +} +class StaticHost extends makeWithStatic() {} +console.log("static.prefix:", StaticHost.prefix("x")); +console.log("static.instance:", new StaticHost().instance()); + +// ── 7. Effect-shape: `class X extends TaggedError()("name", schema)` ──── +// Hot path: Effect's TaggedError is a double-call factory — the outer +// `TaggedError` is a curried generic, and calling it with no args +// returns the inner factory that *then* takes the tag + schema. This +// is the precise shape #740 fixed (class-extends-factory(captured)). +function TaggedError<_Self>() { + return (tag: Tag, schema: Record) => + class { + readonly _tag: Tag = tag; + readonly _schema = schema; + readonly cause?: unknown; + }; +} +class MyError extends TaggedError()("MyError", { code: "string" }) { + describe(): string { + return `${this._tag}(code:${this._schema.code})`; + } +} +const err = new MyError(); +console.log("effect._tag:", err._tag); +console.log("effect._schema.code:", err._schema.code); +console.log("effect.describe:", err.describe()); + +// ── 8. Mixin + parameterized generic combo ───────────────────────────────── +// The combination that #321 tracks for Effect end-to-end: a mixin that +// is itself a parameterized factory. +function ParamMixin(seed: T) { + return (B: TBase) => + class extends B { + seed: T = seed; + describeSeed(): string { + return "seed=" + String(this.seed); + } + }; +} +class Combined extends ParamMixin(42)(CoreBase) {} +const combined = new Combined(); +console.log("combo.core:", combined.core()); +console.log("combo.seed:", combined.seed); +console.log("combo.describeSeed:", combined.describeSeed()); diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index d14e5841..d4eb7189 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -1,5 +1,6 @@ { "issue_655_repro": "issue #655 repro \u2014 kept as the canonical regression-catcher; will flip to PASS when #655 lands.", + "test_harness_class_mixins": "#806 harness for `class X extends Fn(...)<...>` patterns. Two sub-cases surface real Perry gaps today: (1) bare-factory subclass loses the base's field initializer (`bare.kind: undefined` vs Node's `bare.kind: bare`); (2) chained mixin `WithA(WithB(WithC(Base)))` throws `TypeError: value is not a function` at construction. Captured-factory + Effect double-call shapes pass post-#740 \u2014 the harness verifies that survives. Linked to #321 umbrella; will flip to PASS as each sub-case lands.", "test_gap_array_methods": "Array method coverage probe \u2014 small divergences (e.g. flat/flatMap/finally specific edge cases). Targeted bisect needed; not a recent regression.", "test_issue_233_async_array_param_push": "Async array-param push edge case (#233) regresses on Linux CI but passes locally on macOS. Bisect to pin Linux-only divergence.", "test_issue_561_sigv4_chain": "SigV4 chain test (#561) regresses on Linux CI but passes locally on macOS. Likely related to crypto/HMAC ordering. Bisect needed.",