From d02c85a6119ee2b64b61362a2884157a27f937e2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 21:23:53 -0700 Subject: [PATCH 1/4] Use classes that extend EventEmitter or a stream class as values Naming a class that extends EventEmitter, Readable, Writable, Duplex or Transform as a value was refused ("classes extending builtin bases as values"), and so was a class expression of that kind. The refusal also showed up as a deferred runtime fence at the class declaration, because the checked-dynamic tier reflects every class as a value. A user subclass always constructs through its own or synthesized constructor, whose super call runs the runtime initializer, so the class object's construct thunk serves it like any program class. EventTarget subclasses keep the refusal. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 21 ++----- .../6450-builtin-base-class-values.ts.json | 6 ++ ...51-builtin-base-class-expressions.cjs.json | 6 ++ .../corpus/6450-builtin-base-class-values.ts | 63 +++++++++++++++++++ .../6451-builtin-base-class-expressions.cjs | 33 ++++++++++ 5 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6450-builtin-base-class-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6451-builtin-base-class-expressions.cjs.json create mode 100644 tests/corpus/6450-builtin-base-class-values.ts create mode 100644 tests/corpus/6451-builtin-base-class-expressions.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8a5170a7a..81cf09e1f 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2212,11 +2212,7 @@ export function collectClassShapeInner( } if (lowerer.collectingExprClasses.has(decl)) { for (let parent = base; parent; parent = parent.base) { - if ( - parent.builtinEmitter || - parent.builtinStream !== undefined || - parent.builtinEventTarget !== undefined - ) { + if (parent.builtinEventTarget !== undefined) { lowerer.unsupported( "SC1090", decl, @@ -5132,16 +5128,11 @@ export function classValueRef(lowerer: Lowerer, info: ClassInfo, blame: ts.Node) loc: locOf(blame), }; } - if ( - lowerer.inheritsBuiltinEmitterCtor(info) || - inheritsBuiltinStreamCtor(lowerer, info) || - eventTargetRootOf(info) !== null || - (() => { - for (let c = info.base; c; c = c.base) - if (c.builtinEmitter || c.builtinStream !== undefined) return true; - return false; - })() - ) { + // Classes extending EventEmitter or a stream class construct through + // their own (or synthesized) `%C.constructor`, whose super call runs the + // runtime initializer, so the class object's construct thunk serves them + // like any program class. EventTarget subclasses keep the refusal. + if (eventTargetRootOf(info) !== null) { lowerer.unsupported( "SC1090", blame, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6450-builtin-base-class-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6450-builtin-base-class-values.ts.json new file mode 100644 index 000000000..bdd8fcbc8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6450-builtin-base-class-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6450-builtin-base-class-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6451-builtin-base-class-expressions.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6451-builtin-base-class-expressions.cjs.json new file mode 100644 index 000000000..34cde5811 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6451-builtin-base-class-expressions.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6451-builtin-base-class-expressions.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/6450-builtin-base-class-values.ts b/tests/corpus/6450-builtin-base-class-values.ts new file mode 100644 index 000000000..0dafa5d82 --- /dev/null +++ b/tests/corpus/6450-builtin-base-class-values.ts @@ -0,0 +1,63 @@ +// Classes extending Node builtin bases (Writable, Transform, EventEmitter) +// used as values: through a variable, a generic factory, an object table and +// an array, with instanceof against the class and its base. +import { EventEmitter } from "node:events"; +import { Readable, Transform, Writable } from "node:stream"; + +class Sink extends Writable { + readonly lines: string[] = []; + private readonly tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } + _write(chunk: Buffer, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + this.lines.push(this.tag + chunk.toString()); + cb(); + } +} + +class Upper extends Transform { + _transform(chunk: Buffer, _enc: BufferEncoding, cb: (err?: Error | null, data?: string) => void): void { + cb(null, chunk.toString().toUpperCase()); + } +} + +class Ticker extends EventEmitter { + count = 0; + tick(): void { + this.count++; + this.emit("tick", this.count); + } +} + +class Plain extends EventEmitter {} + +function build(ctor: new () => T): T { + return new ctor(); +} + +const SinkClass = Sink; +const s = new SinkClass("> "); +s.write("a"); +s.end("b", () => console.log("sink", JSON.stringify(s.lines), s instanceof Sink, s instanceof Writable)); + +const t = build(Ticker); +t.on("tick", (n: number) => console.log("tick", n)); +t.tick(); +t.tick(); +console.log("ticker", t instanceof Ticker, t instanceof EventEmitter, t.count); + +const p = build(Plain); +p.on("x", () => console.log("plain x")); +p.emit("x"); + +const registry = { upper: Upper }; +const u = new registry.upper(); +u.on("data", (d: Buffer) => console.log("upper", d.toString())); +u.end("hello"); + +const kinds = [Ticker, Ticker]; +console.log("names", kinds.map((k) => k.name).join(","), typeof Sink, Sink.name); +const r = Readable.from(["x"]); +console.log("readable", r instanceof Readable); diff --git a/tests/corpus/6451-builtin-base-class-expressions.cjs b/tests/corpus/6451-builtin-base-class-expressions.cjs new file mode 100644 index 000000000..64230a679 --- /dev/null +++ b/tests/corpus/6451-builtin-base-class-expressions.cjs @@ -0,0 +1,33 @@ +// Class expressions extending Node builtin bases (a bundler's output shape). +"use strict"; +const { Readable, Transform } = require("stream"); +const { EventEmitter } = require("events"); + +const Wrap = class extends Readable { + constructor(parts) { + super(); + this.parts = parts; + } + _read() { + const next = this.parts.shift(); + this.push(next === undefined ? null : next); + } +}; +const Bus = class extends EventEmitter {}; +const Lower = class extends Transform { + _transform(chunk, _enc, cb) { + cb(null, chunk.toString().toLowerCase()); + } +}; + +const r = new Wrap(["a", "b"]); +const chunks = []; +r.on("data", (c) => chunks.push(c.toString())); +r.on("end", () => console.log("wrap", chunks.join("+"), r instanceof Readable)); +const bus = new Bus(); +bus.on("ping", (n) => console.log("ping", n)); +bus.emit("ping", 1); +const l = new Lower(); +l.on("data", (d) => console.log("lower", d.toString())); +l.end("HeLLo"); +console.log(Wrap.name, Bus.name, typeof Lower); From 6197a45ab0796615bca979264168ce376be4a547 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:48:22 -0500 Subject: [PATCH 2/4] Refuse arguments through class values of ctor-less stream subclasses A class without a constructor that extends a stream class or EventEmitter inherits the runtime constructor, which takes no arguments here. Direct construction with arguments already reported that, but construction through the class's value (`new registry.upper({ ... })`) completed the arguments against the empty inherited signature and dropped them. Both paths now share refuseInheritedBuiltinCtorArgs, so the class-value path reports at the first argument with the same message. --- .../src/frontend/lowering/lower-classes.ts | 48 ++++++++++++------- ...builtin-base-class-value-arguments.ts.json | 6 +++ .../builtin-base-class-value-arguments.ts | 14 ++++++ .../builtin-base-class-value-arguments.ts.txt | 17 +++++++ 4 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-base-class-value-arguments.ts.json create mode 100644 tests/diagnostics/builtin-base-class-value-arguments.ts create mode 100644 tests/harness/__snapshots__/builtin-base-class-value-arguments.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 81cf09e1f..7f86de06c 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -8605,6 +8605,32 @@ function inheritsBuiltinStreamCtor(lowerer: Lowerer, info: ClassInfo): boolean { return false; } +/** `new C(args)` where C's constructor is the inherited runtime stream or + * EventEmitter constructor (a ctor-less chain into the base): that + * constructor takes zero arguments here (the options bag fences, like the + * super() form), so arguments report at the first one. Applies to direct + * construction and to construction through a class value of C. Stream + * subclasses come first: their chain roots at the emitter too, but the + * message should name the stream story. */ +function refuseInheritedBuiltinCtorArgs( + lowerer: Lowerer, + info: ClassInfo, + expr: ts.NewExpression, +): void { + const first = expr.arguments?.nodes[0]; + if (first === undefined) return; + if (inheritsBuiltinStreamCtor(lowerer, info)) { + lowerer.noLowering( + `new ${info.def.name.replace(/^%/, "")} with arguments through an inherited stream constructor`, + first, + "declare a constructor that passes an inline options object to super(...)", + ); + } + if (lowerer.inheritsBuiltinEmitterCtor(info)) { + lowerer.unsupported("SC1090", first, "EventEmitter constructor options ('captureRejections')"); + } +} + /** Preserve argument evaluation order and option presence through builtin * and inherited Error constructors. Checked-dynamic conversion retains * Error identity and distinguishes a missing cause from undefined. */ @@ -8729,24 +8755,7 @@ function lowerProgramClassNew( "construction through a function-local class's internal name", ); lowerer.noteEdge(`%${info.def.name}.constructor`); - // A ctor-less chain into an EventEmitter base inherits `new C()` — - // zero arguments (the options bag fences, like the super() form). - // Stream subclasses come first: their chain roots at the emitter - // too, but the message should name the stream story. - if (inheritsBuiltinStreamCtor(lowerer, info) && (expr.arguments?.nodes ?? []).length > 0) { - lowerer.noLowering( - `new ${info.def.name.replace(/^%/, "")} with arguments through an inherited stream constructor`, - expr.arguments!.nodes[0]!, - "declare a constructor that passes an inline options object to super(...)", - ); - } - if (lowerer.inheritsBuiltinEmitterCtor(info) && (expr.arguments?.nodes ?? []).length > 0) { - lowerer.unsupported( - "SC1090", - expr.arguments!.nodes[0]!, - "EventEmitter constructor options ('captureRejections')", - ); - } + refuseInheritedBuiltinCtorArgs(lowerer, info, expr); // A ctor-less chain into a builtin error base inherits both arguments; // each synthesized constructor forwards the same pair to its base. const args = lowerer.inheritsBuiltinErrorCtor(info) @@ -9994,6 +10003,9 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { `EventEmitter and stream class values constructed with options (construct with 'new ${info.def.name.slice(1)}({ ... })' directly)`, ); } + // A program class whose constructor is the inherited runtime one + // takes no arguments through its class value either. + refuseInheritedBuiltinCtorArgs(lowerer, info, expr); defineClassValueConstruct(lowerer, info); const args = lowerer.completeArgs( expr.arguments?.nodes ?? [], diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-base-class-value-arguments.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-base-class-value-arguments.ts.json new file mode 100644 index 000000000..622882c24 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-base-class-value-arguments.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/builtin-base-class-value-arguments.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/builtin-base-class-value-arguments.ts b/tests/diagnostics/builtin-base-class-value-arguments.ts new file mode 100644 index 000000000..db5be873b --- /dev/null +++ b/tests/diagnostics/builtin-base-class-value-arguments.ts @@ -0,0 +1,14 @@ +// A class without a constructor that extends a stream class inherits the +// runtime constructor, which takes no arguments here. Passing arguments +// through the class's value reports at the first argument, as direct +// construction does. +import { Transform } from "node:stream"; + +class Upper extends Transform {} +const registry = { upper: Upper }; +const upper = new registry.upper({ objectMode: true }); +upper.destroy(); + +const Kind = Upper; +const other = new Kind({ highWaterMark: 4 }); +other.destroy(); diff --git a/tests/harness/__snapshots__/builtin-base-class-value-arguments.ts.txt b/tests/harness/__snapshots__/builtin-base-class-value-arguments.ts.txt new file mode 100644 index 000000000..5e3af4f45 --- /dev/null +++ b/tests/harness/__snapshots__/builtin-base-class-value-arguments.ts.txt @@ -0,0 +1,17 @@ +builtin-base-class-value-arguments.ts:9:34 - error SC2020: 'new Upper with arguments through an inherited stream constructor' is part of the standard library types but has no scriptc lowering yet + + 8 | const registry = { upper: Upper }; + 9 | const upper = new registry.upper({ objectMode: true }); + | ^~~~~~~~~~~~~~~~~~~~ + 10 | upper.destroy(); + + hint: declare a constructor that passes an inline options object to super(...) + +builtin-base-class-value-arguments.ts:13:24 - error SC2020: 'new Upper with arguments through an inherited stream constructor' is part of the standard library types but has no scriptc lowering yet + + 12 | const Kind = Upper; + 13 | const other = new Kind({ highWaterMark: 4 }); + | ^~~~~~~~~~~~~~~~~~~~ + 14 | other.destroy(); + + hint: declare a constructor that passes an inline options object to super(...) \ No newline at end of file From 44f4e2e74c39f1ad1ad3fed7021a7547ed283183 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 22:35:23 -0700 Subject: [PATCH 3/4] Refuse inline stream completion callbacks that name the error Node passes write and end callbacks an error argument (null on success), but the native callbacks receive none, so an inline callback that names the parameter read undefined. It now refuses where it is written. The stream-shims npm case compiles now that stream subclasses are values; its remaining scenarios are deferred refusals, so it is recorded as one. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stream.ts | 26 ++++++++++++++++--- .../stream-completion-callback-error.ts.json | 6 +++++ .../6451-builtin-base-class-expressions.cjs | 13 ++++++++++ .../stream-completion-callback-error.ts | 11 ++++++++ .../stream-completion-callback-error.ts.txt | 17 ++++++++++++ tests/harness/inheritance.test.ts | 20 ++++++-------- tests/harness/npm.test.ts | 2 +- 7 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-completion-callback-error.ts.json create mode 100644 tests/diagnostics/stream-completion-callback-error.ts create mode 100644 tests/harness/__snapshots__/stream-completion-callback-error.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 5769e1e0a..64d2e60e8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -3654,8 +3654,10 @@ export function lowerStreamMethodCall( : lowerer.lowerExprExpecting(args[0]!, STRING); const fn = kind === "bytes" ? "writable.write" : "writable.writeStr"; if (cbArg !== undefined) { - const cb = lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); - if (cb.type.kind !== "func" || cb.type.params.length > 0) { + const cb = callbackDeclaresParameters(cbArg) + ? null + : lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); + if (cb === null || cb.type.kind !== "func" || cb.type.params.length > 0) { lowerer.noLowering( "write completion callbacks taking the error argument", cbArg, @@ -3712,8 +3714,10 @@ export function lowerStreamMethodCall( ); } if (cbArg !== undefined) { - const cb = lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); - if (cb.type.kind !== "func" || cb.type.params.length > 0) { + const cb = callbackDeclaresParameters(cbArg) + ? null + : lowerer.lowerExprExpecting(cbArg, funcOf([], VOID)); + if (cb === null || cb.type.kind !== "func" || cb.type.params.length > 0) { lowerer.noLowering( "end callbacks with parameters", cbArg, @@ -3978,3 +3982,17 @@ export function lowerStreamProperty( } return null; } + +/** Whether an inline completion callback declares a parameter. Node calls + * write and end callbacks with an error argument (null on success), but the + * native callback receives none, so the parameter would read undefined + * where Node passes null or the error. A function literal with parameters + * would otherwise adapt to the zero-parameter slot silently. */ +function callbackDeclaresParameters(node: ts.Expression): boolean { + let callback = node; + while (ts.isParenthesizedExpression(callback)) callback = callback.expression; + return ( + (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) && + callback.parameters.nodes.length > 0 + ); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-completion-callback-error.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-completion-callback-error.ts.json new file mode 100644 index 000000000..af31bb8cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/stream-completion-callback-error.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/stream-completion-callback-error.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6451-builtin-base-class-expressions.cjs b/tests/corpus/6451-builtin-base-class-expressions.cjs index 64230a679..6d9241b97 100644 --- a/tests/corpus/6451-builtin-base-class-expressions.cjs +++ b/tests/corpus/6451-builtin-base-class-expressions.cjs @@ -31,3 +31,16 @@ const l = new Lower(); l.on("data", (d) => console.log("lower", d.toString())); l.end("HeLLo"); console.log(Wrap.name, Bus.name, typeof Lower); +{ + // A class declared in a block is collected like a class expression. + class Once extends Readable { + _read() { + this.push(null); + } + } + function onReadable() { + console.log("block", stream.read() === null); + } + const stream = new Once(); + stream.once("readable", onReadable); +} diff --git a/tests/diagnostics/stream-completion-callback-error.ts b/tests/diagnostics/stream-completion-callback-error.ts new file mode 100644 index 000000000..eb73b485d --- /dev/null +++ b/tests/diagnostics/stream-completion-callback-error.ts @@ -0,0 +1,11 @@ +import { Writable } from "node:stream"; + +// Node passes write and end callbacks an error argument (null on success); +// the native callbacks receive none, so a callback that names it refuses. +const sink = new Writable({ + write(_chunk, _encoding, done) { + done(); + }, +}); +sink.write("a", (err) => console.log("written", err)); +sink.end((err?: Error | null) => console.log("ended", err)); diff --git a/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt new file mode 100644 index 000000000..f3b876cc7 --- /dev/null +++ b/tests/harness/__snapshots__/stream-completion-callback-error.ts.txt @@ -0,0 +1,17 @@ +stream-completion-callback-error.ts:10:17 - error SC2020: 'write completion callbacks taking the error argument' is part of the standard library types but has no scriptc lowering yet + + 9 | }); + 10 | sink.write("a", (err) => console.log("written", err)); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 11 | sink.end((err?: Error | null) => console.log("ended", err)); + + hint: () => void callbacks are supported (listen for 'error' to observe failures) + +stream-completion-callback-error.ts:11:10 - error SC2020: 'end callbacks with parameters' is part of the standard library types but has no scriptc lowering yet + + 10 | sink.write("a", (err) => console.log("written", err)); + 11 | sink.end((err?: Error | null) => console.log("ended", err)); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 12 | + + hint: () => void callbacks are supported \ No newline at end of file diff --git a/tests/harness/inheritance.test.ts b/tests/harness/inheritance.test.ts index c0ce3d7d4..27a176764 100644 --- a/tests/harness/inheritance.test.ts +++ b/tests/harness/inheritance.test.ts @@ -204,14 +204,11 @@ r.on("data", (b: Buffer) => console.log(b.toString())); expect(llvm).toContain("call void @scr_stream_init_readable(ptr "); }); - test("a runtime-fenced nested stream class emits the refusal without class helpers", async () => { - // The phase-1 reachable bug: a class declared inside a block (a - // runtime fence in JS) whose instances are captured emitted capture- - // box RC adapters for the uncollected class — a C compile error. - // run()'s unregistered-class sweep now rewrites such type slots to - // the inert f64 placeholder before emission (no instance can exist; - // every use traps), so the capture box is a PLAIN-kind box and the - // class never reaches the emitter at all. + test("a block-scoped stream class lowers natively with its class helpers", async () => { + // A class declared inside a block is collected like a class expression. + // Its captured instances once needed the unregistered-class sweep (the + // class was fenced); a builtin stream base no longer fences, so the + // class reaches the emitter with its object type and stream init. const llvm = await emittedLlvm( "stream-nested-fence", `const { Readable } = require("stream"); @@ -226,11 +223,10 @@ r.on("data", (b: Buffer) => console.log(b.toString())); `, "js", ); - expect(llvm).not.toContain("sc_retain_R_v"); expect(llvm).not.toContain("uncollected class"); - expect(llvm).not.toContain("%sc_o_R"); - expect(llvm).toMatch(/%sc_l_stream_0 = alloca ptr/); - expect(llvm).toContain("call void @scr_throw_error_msg_code"); + expect(llvm).toContain("%sc_o_R"); + expect(llvm).toContain("call void @scr_stream_init_readable(ptr "); + expect(llvm).not.toContain("call void @scr_throw_error_msg_code"); }); test("a getter-only override of a pair fills the set slot with a thrower", async () => { diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 54207f246..ac3210dee 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -104,7 +104,7 @@ const KNOWN: Record = { "package-imports": { build: "SC2013" }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { build: "SC1090" }, + "stream-shims": { run: "SC1090" }, "util-shims": { run: "SC2020" }, "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" }, From c475aabc2a1c8a8ce75a2738de0088c7218b1ebe Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 08:41:04 -0500 Subject: [PATCH 4/4] Refuse arguments to inherited stream constructors from untyped code Construction from checked-dynamic code (`new F(options)` with F an untyped value holding a ctor-less subclass of a stream class or EventEmitter) dispatched to the class's constructor and completed the arguments against the inherited signature, which takes none here, so the options were dropped. The checked construction dispatch now reports those arguments at run time with the message typed construction reports at compile time, located at the class: a fixed argument count refuses at the call, and a spread call refuses when its argument list is not empty. --- .../frontend/lowering/class-construction.ts | 48 ++++++++++++++++++- .../src/frontend/lowering/lower-classes.ts | 28 +++++++++++ tests/harness/errors.test.ts | 31 ++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-construction.ts b/packages/compiler/src/frontend/lowering/class-construction.ts index 7f4a003c9..30f504b4a 100644 --- a/packages/compiler/src/frontend/lowering/class-construction.ts +++ b/packages/compiler/src/frontend/lowering/class-construction.ts @@ -18,6 +18,7 @@ import type { Lowerer } from "./lowerer.js"; import type { ParamShape } from "./call-signatures.js"; import { classPrototypeData } from "./class-prototypes.js"; import { classInstanceOf } from "./class-dynamic-dispatch.js"; +import { inheritedBuiltinCtorFence } from "./lower-classes.js"; const PREFIX = "%class.construct:"; const PACKED = `${PREFIX}packed`; @@ -380,6 +381,29 @@ export class ClassConstructionDispatch { changed = true; continue; } + const type: IrType = { kind: "classval", className: name }; + const callee = varRef("p.0", DYN, loc); + // A constructor inherited from a runtime stream or EventEmitter + // takes no arguments here: arguments report at run time, as they do + // at compile time for typed construction. + const fence = inheritedBuiltinCtorFence(lowerer, info, loc); + if (fence !== null && fn.name !== PACKED && fn.params.length > 1) { + fn.body.unshift({ + kind: "if", + cond: { + kind: "libCall", + fn: "dyn.classIs", + args: [callee, { kind: "strLit", value: typeKey(type), type: STRING, loc }], + type: BOOL, + loc, + }, + then: [fence], + else_: null, + loc, + }); + changed = true; + continue; + } const args = constructorArguments( lowerer, info.ctorParams, @@ -388,8 +412,6 @@ export class ClassConstructionDispatch { fn.name === PACKED ? varRef("p.1", DYN, loc) : undefined, ); if (!args) continue; - const type: IrType = { kind: "classval", className: name }; - const callee = varRef("p.0", DYN, loc); const value = constructedValue(lowerer, info, { kind: "newValue", callee: { kind: "dynCheck", value: callee, type, loc }, @@ -400,6 +422,28 @@ export class ClassConstructionDispatch { const body: IrStmt[] = [ { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, ]; + // A spread call's argument count is known only at run time. + if (fence !== null && fn.name === PACKED) + body.unshift({ + kind: "if", + cond: { + kind: "bin", + op: ">", + left: { + kind: "libCall", + fn: "dyn.arrLen", + args: [varRef("p.1", DYN, loc)], + type: F64, + loc, + }, + right: { kind: "numLit", value: 0, type: F64, loc }, + type: BOOL, + loc, + }, + then: [fence], + else_: null, + loc, + }); fn.body.unshift({ kind: "if", cond: { diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 714588a57..cd0c6b8bd 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -8725,6 +8725,34 @@ function inheritsBuiltinStreamCtor(lowerer: Lowerer, info: ClassInfo): boolean { return false; } +/** The run-time form of refuseInheritedBuiltinCtorArgs, for construction + * from checked-dynamic code (`new F(options)` with F an untyped value + * holding C), whose argument count is known only to the caller: a + * runtimeFence located at C, or null when C's constructor takes its + * arguments. */ +export function inheritedBuiltinCtorFence( + lowerer: Lowerer, + info: ClassInfo, + loc: SrcLoc, +): IrStmt | null { + let feature: string | null = null; + if (inheritsBuiltinStreamCtor(lowerer, info)) + feature = `new ${info.def.name.replace(/^%/, "")} with arguments through an inherited stream constructor (declare a constructor that passes an inline options object to super(...))`; + else if (lowerer.inheritsBuiltinEmitterCtor(info)) + feature = "EventEmitter constructor options ('captureRejections')"; + if (feature === null) return null; + // Located at the class, whose constructor takes no arguments. + const at = info.decl !== null ? locOf(info.decl) : loc; + const diag = unsupportedDiag("SC1090", at, feature); + lowerer.runtimeFences.push(diag); + const line = + info.decl !== null + ? ts.getLineAndCharacterOfPosition(info.decl.getSourceFile(), at.start).line + 1 + : null; + const where = line !== null ? ` [${diag.code} at ${at.file}:${line}]` : ""; + return { kind: "runtimeFence", code: diag.code, message: `${diag.message}${where}`, loc }; +} + /** `new C(args)` where C's constructor is the inherited runtime stream or * EventEmitter constructor (a ctor-less chain into the base): that * constructor takes zero arguments here (the options bag fences, like the diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 1f3d9729b..f08ed45d7 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -359,6 +359,37 @@ const arr = tuple.map(() => new Date(0));`, expect(r).toEqual({ stdout: reference.stdout, stderr: reference.stderr, exitCode: 0 }); }); + test.each([ + ["an argument", "make(Upper, { objectMode: true });"], + ["a spread argument", "spread(Upper, [{ objectMode: true }]);"], + ])( + "%s through an untyped factory to an inherited stream constructor is a named deferred fence (JS lane)", + async (_, call) => { + // The constructor a ctor-less stream subclass inherits takes no + // arguments here; typed construction refuses them at compile time, + // and construction from untyped code refuses them at run time + // instead of constructing without them. + const r = await compileAndRun( + `inherited-stream-ctor-${call.startsWith("make") ? "args" : "spread"}`, + `'use strict'; +const { Transform } = require('node:stream'); +class Upper extends Transform {} +function make(F, options) { return new F(options); } +function bare(F) { return new F(); } +function spread(F, args) { return new F(...args); } +console.log('bare', bare(Upper) instanceof Upper, spread(Upper, []) instanceof Upper); +${call} +`, + "cjs", + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("bare true true\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: new Upper with arguments through an inherited stream constructor .* is not supported yet \[SC1090 at .*inherited-stream-ctor-\w+\.cjs:3\]\n$/, + ); + }, + ); + test("extending a property-assigned class ABOVE its assignment is a named deferred fence (JS lane)", async () => { // The property-assigned class-expression family (corpus 2032/2033 // pins what lowers): `exports.O = class extends exports.I {}` with