From dfd19cbea696e031323188548cc2c2ad882c0811 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 20:11:58 -0500 Subject: [PATCH 1/4] Give socket 'data' listeners strings after setEncoding After socket.setEncoding("utf8"), Node passes strings to 'data' listeners, while @types/node types the chunk as a Buffer. An inline listener whose chunk is typed Buffer (annotated or inferred) received a Buffer. When a program can call setEncoding on a socket, such listeners now take the chunk as a checked dynamic value, so console.log, typeof, string methods and concatenation behave as in Node, and Buffer-only methods throw Node's TypeError. Programs without setEncoding on a socket compile to the same IR as before. --- docs/content/docs/limitations.mdx | 4 + .../limitations/socket-data-encoding.mdx | 1 + .../src/frontend/lowering/lower-server.ts | 82 +++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 4 + .../5972-net-set-encoding-listeners.ts.json | 6 ++ .../corpus/5972-net-set-encoding-listeners.ts | 42 ++++++++++ 6 files changed, 139 insertions(+) create mode 100644 docs/content/limitations/socket-data-encoding.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json create mode 100644 tests/corpus/5972-net-set-encoding-listeners.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 420af8200..ed2c5b1ec 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -208,6 +208,10 @@ title: "Limitations" ../limitations/dns-lookup-errors.mdx +### Socket data encoding + +../limitations/socket-data-encoding.mdx + ### UDP socket options ../limitations/udp-socket-options.mdx diff --git a/docs/content/limitations/socket-data-encoding.mdx b/docs/content/limitations/socket-data-encoding.mdx new file mode 100644 index 000000000..8094f8143 --- /dev/null +++ b/docs/content/limitations/socket-data-encoding.mdx @@ -0,0 +1 @@ +After `socket.setEncoding("utf8")`, an inline `'data'` listener receives strings, as in Node, whatever type its chunk parameter declares. A listener passed by reference, such as a named function whose parameter is typed `Buffer`, keeps its `Buffer` parameter, so a string chunk throws `TypeError: expected Uint8Array at $[0], got string`. Declare that parameter as `string` or `string | Buffer`, or pass an inline function. diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 8c8639d43..54d9adeb5 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2111,6 +2111,67 @@ function lowerNetServerMethodCall( ); } +/** Whether a setEncoding call can reach some net.Socket in this program: + * `x.setEncoding(...)` or `x["setEncoding"](...)` where `x` is a socket + * or an untyped value. Sockets that never get an encoding keep the Buffer + * chunk path; with one, Node's 'data' chunks are strings. */ +function socketEncodingReachable(lowerer: Lowerer): boolean { + if (lowerer.socketEncodingReachable !== null) return lowerer.socketEncodingReachable; + let reachable = false; + for (const file of lowerer.program.getSourceFiles()) { + if (file.isDeclarationFile || !file.text.includes("setEncoding")) continue; + ts.walkPreorder(file, (node) => { + if (reachable) return "skip"; + if (!ts.isCallExpression(node)) return undefined; + const callee = node.expression; + const receiver = + ts.isPropertyAccessExpression(callee) && callee.name.text === "setEncoding" + ? callee.expression + : ts.isElementAccessExpression(callee) && + ts.isStringLiteralLike(callee.argumentExpression) && + callee.argumentExpression.text === "setEncoding" + ? callee.expression + : null; + if (receiver === null) return undefined; + const kind = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(receiver))?.kind; + if (kind === undefined || kind === "netSocket" || kind === "dyn") reachable = true; + return undefined; + }); + if (reachable) break; + } + lowerer.socketEncodingReachable = reachable; + return reachable; +} + +/** An inline 'data' listener whose chunk parameter is typed Buffer, for a + * socket that may have an encoding set: the chunk binds as a checked + * dynamic value, so the body sees what Node passes (a string after + * setEncoding, a Buffer otherwise) whatever the annotation says. Null + * for other listener shapes. */ +function lowerEncodedDataListener(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + let fn = node; + while (ts.isParenthesizedExpression(fn)) fn = fn.expression; + if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return null; + if (fn.parameters.length !== 1) return null; + const param = fn.parameters[0]!; + if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return null; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (symbol === undefined) return null; + const previousBinding = lowerer.runtimeOptionalBindingTypes.get(symbol); + const previousImplicit = lowerer.implicitParamTypes; + const implicit = new Map(previousImplicit ?? []); + implicit.set(symbol, lowerer.checker.getUnknownType()); + lowerer.runtimeOptionalBindingTypes.set(symbol, DYN); + lowerer.implicitParamTypes = implicit; + try { + return lowerer.lowerExpr(fn); + } finally { + if (previousBinding === undefined) lowerer.runtimeOptionalBindingTypes.delete(symbol); + else lowerer.runtimeOptionalBindingTypes.set(symbol, previousBinding); + lowerer.implicitParamTypes = previousImplicit; + } +} + /** Method calls on net.Socket receivers: write/end/destroy/pipe and * on/once("data" | "end" | "close" | "error" | "connect"). Null for * other receivers. */ @@ -2565,6 +2626,27 @@ function lowerNetSocketMethodCall( loc, }; } + if (param !== null && param.kind === "bytes" && socketEncodingReachable(lowerer)) { + const encoded = lowerEncodedDataListener(lowerer, args[1]!); + // A listener defined elsewhere keeps its Buffer parameter; it takes + // the chunk through the checked boundary, so a string chunk fails + // the check instead of arriving as a Buffer. + const cb: IrExpr = + encoded ?? + ({ + kind: "dynCheck", + value: lowerSocketDynListener(lowerer, args[1]!, "data listeners"), + type: funcOf([DYN], VOID), + loc, + } as IrExpr); + return { + kind: "libCall", + fn: "net.sockOnData", + args: [receiver, cb, once], + type: VOID, + loc, + }; + } const { cb } = lowerCallbackArg( lowerer, args[1]!, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 61ccc76ce..3217802ad 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1769,6 +1769,10 @@ export class Lowerer { * typeParamBindings — the checker has no `T` to substitute, so the * binding rides the node-type accessor instead of mapType. */ implicitParamTypes: Map | null = null; + /** Whether some net.Socket in this program may get an encoding: a + * setEncoding call whose receiver is a socket or an untyped value. + * Computed once on first use (null until then). */ + socketEncodingReachable: boolean | null = null; /** IMPLICIT-ANY instances lowered EAGERLY at first demand (their return * types are inferred from the body — the call site needs them settled), * collected here for run()'s function list (the liftedFns discipline). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json new file mode 100644 index 000000000..efab76852 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5972-net-set-encoding-listeners.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5972-net-set-encoding-listeners.ts b/tests/corpus/5972-net-set-encoding-listeners.ts new file mode 100644 index 000000000..a7b49adaa --- /dev/null +++ b/tests/corpus/5972-net-set-encoding-listeners.ts @@ -0,0 +1,42 @@ +// After socket.setEncoding("utf8"), every 'data' listener receives the +// string Node passes, whether its chunk parameter is unannotated, +// annotated Buffer, string or string | Buffer. A Buffer-only method on +// the string throws Node's TypeError. A socket without an encoding in +// the same program still receives Buffers. +import * as net from "node:net"; + +const server = net.createServer((socket) => { + socket.on("data", (chunk) => { + console.log("server chunk:", typeof chunk, chunk.length, "bytes:", chunk[0]); + socket.end("hello wörld"); + }); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + const client = net.connect(port, "127.0.0.1", () => client.write("hi")); + client.setEncoding("utf8"); + let text = ""; + client.on("data", (chunk) => { + text += chunk; + console.log("unannotated:", typeof chunk, chunk.length, chunk); + console.log("unannotated upper:", chunk.toString().toUpperCase()); + }); + client.on("data", (chunk: Buffer) => { + console.log("annotated Buffer:", typeof chunk, chunk); + console.log("annotated concat:", "<" + chunk + ">"); + try { + console.log(chunk.readUInt8(0)); + } catch (error) { + console.log("readUInt8:", (error as Error).name, (error as Error).message); + } + }); + client.once("data", (chunk: string) => console.log("string:", chunk.split(" "))); + client.on("data", (chunk: string | Buffer) => + console.log("union:", typeof chunk === "string" ? "string " + chunk : "buffer"), + ); + client.on("close", () => { + console.log("text:", text); + server.close(); + }); +}); From 69a36083fc35f11ff26a9d8af42ca71bdd5184b4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 20:46:43 -0500 Subject: [PATCH 2/4] Record corpus costs for the net programs 5970 to 5972 The shard balance check needs measured costs for at least 90% of the corpus programs. Add plain and sanitized costs for 5970, 5971 and 5972, timed locally and scaled to the CI runner by comparable net programs. --- tests/harness/corpus-costs.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index f87883acd..ecd7f5d9b 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,6 +1841,9 @@ "5648-dns-lookup-forms.ts": 2992, "5649-dns-service-servers.ts": 2712, "5650-dns-lookup-errors-untyped.cjs": 2822, + "5970-net-connect-host-name.ts": 2400, + "5971-buffer-chunks-to-string.ts": 2700, + "5972-net-set-encoding-listeners.ts": 2550, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4465,6 +4468,9 @@ "5648-dns-lookup-forms.ts": 3416, "5649-dns-service-servers.ts": 4916, "5650-dns-lookup-errors-untyped.cjs": 3889, + "5970-net-connect-host-name.ts": 2800, + "5971-buffer-chunks-to-string.ts": 3100, + "5972-net-set-encoding-listeners.ts": 3000, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, From f291f1c275b91c99939152f36d56abe1bb12abc5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 20:50:59 -0500 Subject: [PATCH 3/4] Monomorphize destructured options parameters of package functions A package function that destructures its options object, such as makeClass({ Base, name }), now specializes per call site like positional parameters: each destructured name binds the static type of the matching property, so a class passed as { Base } keeps its static type and class extends Base builds a constructible subclass. Fixes #938 Co-authored-by: phall <25888173+phall1@users.noreply.github.com> --- .../frontend/lowering/generic-functions.ts | 142 +++++++++++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 25 ++- .../fixtures/npm-static/class-options-cli.ts | 15 ++ .../node_modules/class-options/classes.js | 31 ++++ .../node_modules/class-options/package.json | 1 + tests/harness/npm-static.test.ts | 34 +++++ 6 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/npm-static/class-options-cli.ts create mode 100644 tests/fixtures/npm-static/node_modules/class-options/classes.js create mode 100644 tests/fixtures/npm-static/node_modules/class-options/package.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 4cddcf5fd..bd785a256 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -94,6 +94,11 @@ export interface GenericFnInfo { * checker types key an instantiation, exactly the generic machinery — * the untyped params ARE the type parameters (see implicitCallInstance). */ implicitParams?: (ts.Symbol | null)[]; + /** Implicit-any monomorphization of destructured object parameters + * (implicitPatternsOf): parallel to decl.parameters, the destructured + * names where the slot binds the call site's object type. Computed on + * first use. */ + implicitPatterns?: (ImplicitPattern | null)[]; } export interface GenericInstance { @@ -1125,6 +1130,114 @@ function broadPromiseParam(lowerer: Lowerer, type: ts.Type): boolean { ); } +/** A destructured object parameter (`function f({ Base, name })`) whose + * names bind the call site argument's property types. */ +export interface ImplicitPattern { + elements: { property: string; symbol: ts.Symbol }[]; +} + +/** The destructured object parameters of a JS function-like that bind like + * implicit type parameters: every element a plain name (optionally renamed + * from an identifier or string key) with no default, no rest element, and + * no write in the body, under a broad inferred type. Parallel to + * decl.parameters. */ +export function implicitPatternsOf( + lowerer: Lowerer, + decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction, +): (ImplicitPattern | null)[] { + return decl.parameters.map((param): ImplicitPattern | null => { + if (!ts.isObjectBindingPattern(param.name) || !decl.body) return null; + if (param.dotDotDotToken || param.questionToken || param.initializer) return null; + const elements: ImplicitPattern["elements"] = []; + for (const element of param.name.elements) { + if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) + return null; + const key = element.propertyName ?? element.name; + if (!ts.isIdentifier(key) && !ts.isStringLiteral(key)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(element.name); + if (!symbol || paramWrittenInBody(lowerer, decl.body, symbol, element.name.text)) return null; + // A JSDoc or contextual type names the properties: keep its ABI. + if ((lowerer.typeOf(element.name).flags & ts.TypeFlags.Any) === 0) return null; + elements.push({ property: key.text, symbol }); + } + return elements.length > 0 ? { elements } : null; + }); +} + +/** The type a destructured parameter takes at one call site (the object's + * own representation, a record or a checked value) and the checker type + * each destructured name binds. An object literal argument types the names + * from its property expressions, which see the enclosing instance's + * bindings (`makeClass({ Base, name })` inside a specialized + * `extend(Base, name)`); any other argument uses its checker type. Null + * when the argument lacks a destructured property, or passes a class the + * callee itself defines: each such class would instantiate the callee + * again (a static `extend` calling `makeClass({ Base: this })`). */ +function implicitPatternBinding( + lowerer: Lowerer, + callee: ts.Node, + pattern: ImplicitPattern, + arg: ts.Expression, +): { record: IrType; types: Map } | null { + let literal: ts.Expression = arg; + while (ts.isParenthesizedExpression(literal)) literal = literal.expression; + const types = new Map(); + if (ts.isObjectLiteralExpression(literal)) { + const values = new Map(); + /** Shorthand properties name the binding they read. */ + const shorthand = new Map(); + for (const property of literal.properties) { + if (ts.isShorthandPropertyAssignment(property) && ts.isIdentifier(property.name)) { + values.set(property.name.text, property.name); + const read = lowerer.checker.getShorthandAssignmentValueSymbol(property); + if (read) shorthand.set(property.name.text, read); + } else if ( + ts.isPropertyAssignment(property) && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) + ) + values.set(property.name.text, property.initializer); + else return null; + } + for (const element of pattern.elements) { + const value = values.get(element.property); + if (!value) return null; + const read = shorthand.get(element.property); + const type = (read && lowerer.implicitParamTypes?.get(read)) ?? lowerer.typeOf(value); + types.set(element.symbol, lowerer.checker.getBaseTypeOfLiteralType(type)); + } + // The object keeps the representation the call site lowers it to; the + // destructured names read their static types back out of it. + const lowered = tryLowerExpression(lowerer, literal); + if (lowered?.type.kind !== "record" && lowered?.type.kind !== "dyn") return null; + if (lowered.type.kind === "record") { + for (const field of lowerer.shapes.get(lowered.type.shapeId)?.fields ?? []) + if (field.type.kind === "classval" && classDefinedIn(lowerer, field.type.className, callee)) + return null; + } + return { record: lowered.type, types }; + } + const argType = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(arg)); + const record = lowerer.mapTypeOf(argType); + if (record?.kind !== "record") return null; + for (const element of pattern.elements) { + const property = lowerer.checker.getPropertyOfType(argType, element.property); + if (!property) return null; + types.set(element.symbol, lowerer.checker.getTypeOfSymbol(property)); + } + return { record, types }; +} + +/** The class is declared inside `scope`. */ +function classDefinedIn(lowerer: Lowerer, className: string, scope: ts.Node): boolean { + for ( + let n: ts.Node | undefined = lowerer.classes.get(className)?.decl ?? undefined; + n; + n = n.parent + ) + if (n === scope) return true; + return false; +} + /** The implicit-type-parameter slots of a JS function-like: parallel to * decl.parameters, the param SYMBOL where the slot is a bindable * implicit param (identifier-named, broad inferred/JSDoc type, not @@ -1169,6 +1282,10 @@ export function implicitAnyParamSymbolsOf( hasImplicitParams = true; return sym; }); + // Destructured object parameters monomorphize too (implicitPatternsOf); + // their slots stay null here. + if (!hasImplicitParams && isJsSourceFile(decl.getSourceFile())) + hasImplicitParams = implicitPatternsOf(lowerer, decl).some((pattern) => pattern !== null); return hasImplicitParams ? out : null; } @@ -1440,7 +1557,27 @@ export function implicitCallInstance( ): GenericInstance { const shapes: ParamShape[] = []; const argTypes = new Map(); + const patterns = (info.implicitPatterns ??= implicitPatternsOf(lowerer, info.decl)); + /** The destructured names' bound types, which the object's own + * representation does not always carry. */ + const patternKey: string[] = []; info.decl.parameters.forEach((param, i) => { + const pattern = patterns[i]; + const patternArg = call.arguments[i]; + if (pattern && patternArg && !ts.isSpreadElement(patternArg)) { + // The argument's object type binds each destructured name, so a + // class passed as `{ Base }` keeps its static class type. + const bound = implicitPatternBinding(lowerer, info.decl, pattern, patternArg); + if (bound) { + for (const [symbol, type] of bound.types) { + argTypes.set(symbol, type); + const mapped = lowerer.mapTypeOf(type); + patternKey.push(mapped ? typeKey(mapped) : "?"); + } + shapes.push({ type: bound.record, mode: "required" }); + return; + } + } const sym = info.implicitParams![i]; if (!sym) { shapes.push(lowerer.paramShape(param)); @@ -1578,7 +1715,7 @@ export function implicitCallInstance( } shapes.push({ type: bound, mode: "required" }); }); - return internImplicitInstance(lowerer, call, info, shapes, argTypes); + return internImplicitInstance(lowerer, call, info, shapes, argTypes, patternKey.join(",")); } /** The all-dyn DEFAULT instance — today's compiled body exactly: what a @@ -1611,8 +1748,9 @@ function internImplicitInstance( info: GenericFnInfo, shapes: ParamShape[], argTypes: Map, + patternKey = "", ): GenericInstance { - const key = shapes.map((s) => typeKey(s.type)).join(","); + const key = shapes.map((s) => typeKey(s.type)).join(",") + (patternKey ? `|${patternKey}` : ""); let inst = info.instances.get(key); if (inst?.implicitState === "failed") lowerer.unsupported("SC1090", blame, `the failed specialization of '${info.baseName}'`); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 61ccc76ce..7f940c692 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -341,6 +341,7 @@ import { } from "./call-signatures.js"; import { implicitAnyParamSymbolsOf, + implicitLocalFnNodeOf, type GenericFnInfo, type GenericInstance, implicitMonoFile, @@ -1525,6 +1526,28 @@ function panicSafe(query: () => T, fallback: T): T { } } +/** The function's class expressions lower once per implicit-any instance. + * A function whose only implicit slots are destructured options and that + * is bound to a variable the implicit-instance probe rejects runs as one + * shared body, so its classes keep a single name. */ +function monomorphizedImplicit( + lowerer: Lowerer, + fn: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration, +): boolean { + const slots = implicitAnyParamSymbolsOf(lowerer, fn); + if (!slots) return false; + if (slots.some((slot) => slot !== null)) return true; + let binding: ts.Node | undefined = fn.parent; + while (binding && ts.isParenthesizedExpression(binding)) binding = binding.parent; + if ( + (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && + binding && + ts.isVariableDeclaration(binding) + ) + return implicitLocalFnNodeOf(lowerer, binding) !== null; + return true; +} + export class Lowerer { readonly frontendServices: FrontendServices | undefined; readonly checker: ts.TypeChecker; @@ -2787,7 +2810,7 @@ export class Lowerer { if (this.localClassInstantiations.some((entry) => entry.owner === parent)) break; if ( parent.typeParameters?.length || - (implicitMonoFile(parent.getSourceFile()) && implicitAnyParamSymbolsOf(this, parent)) + (implicitMonoFile(parent.getSourceFile()) && monomorphizedImplicit(this, parent)) ) return null; } diff --git a/tests/fixtures/npm-static/class-options-cli.ts b/tests/fixtures/npm-static/class-options-cli.ts new file mode 100644 index 000000000..9d9c4f902 --- /dev/null +++ b/tests/fixtures/npm-static/class-options-cli.ts @@ -0,0 +1,15 @@ +// A package factory that receives its base class through a destructured +// options object builds subclasses of program classes that construct and +// dispatch like Node. +import { Class, extend } from "class-options/classes.js"; + +class Animal extends Class("Animal") {} +class Dog extends extend(Animal, "Dog") {} +class Puppy extends extend(Dog, "Puppy") {} + +const rex = new Dog("Rex"); +const ada = new Animal("Ada"); +const bit = new Puppy("Bit"); +console.log(rex instanceof Dog, rex instanceof Animal, ada instanceof Dog, bit instanceof Animal); +console.log(rex.describe(), ada.describe(), bit.describe()); +console.log(rex.kind, rex.value, ada.kind, bit.label); diff --git a/tests/fixtures/npm-static/node_modules/class-options/classes.js b/tests/fixtures/npm-static/node_modules/class-options/classes.js new file mode 100644 index 000000000..e2ae647f8 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-options/classes.js @@ -0,0 +1,31 @@ +// A class factory that takes its base and settings as one options object, +// as Effect's makeClass does: `makeClass({ Base, name })` destructures them +// and builds `class extends Base`. +function makeClass({ Base, name, tag: label }) { + return class extends Base { + constructor(value) { + super(value); + this.kind = name; + this.label = label; + } + describe() { + return `${this.label}:${this.kind}:${this.value}`; + } + // Each class passes itself back as a base: a call site must not + // specialize makeClass on the classes makeClass itself defines. + static extend(name) { + return makeClass({ Base: this, name, tag: label }); + } + }; +} +class Root { + constructor(value) { + this.value = value; + } +} +export function Class(name) { + return makeClass({ Base: Root, name, tag: "root" }); +} +export function extend(Base, name) { + return makeClass({ Base, name, tag: "sub" }); +} diff --git a/tests/fixtures/npm-static/node_modules/class-options/package.json b/tests/fixtures/npm-static/node_modules/class-options/package.json new file mode 100644 index 000000000..3e2fb2b49 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-options/package.json @@ -0,0 +1 @@ +{ "name": "class-options", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } } diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 9324d1c7f..89bd2e0b8 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -618,6 +618,40 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { }, ); + test.each(["release", "dev"] as const)( + "a factory that destructures its base class from an options object builds static subclasses (%s)", + async (optimization) => { + const entry = join(pilotRoot, "class-options-cli.ts"); + const outDir = join(cacheDir, `class-options-${optimization}-${sanitize ? "san" : "plain"}`); + const result = await compile(entry, { + backend: "llvm", + npmStatic: ["class-options"], + sanitize, + optimization, + outDir, + outPath: join(outDir, "program"), + emitIr: true, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const [reference, native] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(result.binaryPath, []), + ]); + expect(reference.exitCode).toBe(0); + expect(native.stdout).toEqual(reference.stdout); + expect(comparableStderr(native.stderr)).toEqual(reference.stderr); + expect(native.exitCode).toBe(reference.exitCode); + // One makeClass instance per distinct base (Root, Animal, Dog) plus + // the generic one a class's own `extend` passing itself back reuses. + const ir = JSON.parse(readFileSync(result.irPath!, "utf8")) as { + functions: { name: string }[]; + }; + const instances = ir.functions.filter((fn) => /^%[\w.]*makeClass%\d+$/.test(fn.name)); + expect(instances.length).toBeLessThanOrEqual(4); + }, + ); + test.for([ ["escape-string-regexp", "escape-cli.ts"], ["slash", "slash-cli.ts"], From de4e10d542875e315668d84962700cc7dfb660fa Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 21:34:52 -0500 Subject: [PATCH 4/4] Shift the runtime in bench:runtime layouts The --layouts padding object was linked directly before the program object. The runtime is linked before the program object, so every layout moved only the program's code and left each runtime function at the same address. A change to the runtime's code size then moved its functions in all layouts at once, and the verdicts could not tell that placement change from a real one. The padding object now goes before every other input on the link line, so each layout shifts the runtime and the program code. --- scripts/bench-runtime.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs index ff9c9d690..4e4f2c245 100644 --- a/scripts/bench-runtime.mjs +++ b/scripts/bench-runtime.mjs @@ -56,9 +56,10 @@ Each checkout must have a built CLI (packages/cli/dist/bootstrap.js) and native artifacts for this host. Without --baseline, reports absolute times only. Node is timed in the same interleaved loop as the reference to beat (--no-node skips it). ---layouts=N (macOS/Linux) links every executable N times, shifting the program -object by 0, 16, 32, ... bytes of padding, and spreads the timed runs evenly over -those layouts, so verdicts cover code-placement luck instead of one placement. +--layouts=N (macOS/Linux) links every executable N times, shifting the runtime +and program code by 0, 16, 32, ... bytes of padding, and spreads the timed runs +evenly over those layouts, so verdicts cover code-placement luck instead of one +placement. Use it for small codegen changes (e.g. --layouts=4 --runs=16). Workload names are listed in benchmarks/runtime/workloads.json; for example @@ -103,10 +104,11 @@ const work = mkdtempSync( ); /* ── code-layout perturbation ──────────────────────────────────────────── */ -// Layout k > 0 links a retained k*16-byte padding object directly before the -// program object, through a SCRIPTC_LINKER wrapper that forwards to clang (the -// default linker driver on macOS and Linux). Code after that point moves as if -// earlier code had grown; any checkout's compiler can be perturbed this way. +// Layout k > 0 links a retained k*16-byte padding object before every other +// input, through a SCRIPTC_LINKER wrapper that forwards to clang (the default +// linker driver on macOS and Linux). The runtime is linked before the program +// object, so both move as if earlier code had grown; any checkout's compiler can +// be perturbed this way. function layoutTooling() { if (layouts === 1) return null; const root = join(work, "layouts"); @@ -137,12 +139,11 @@ const args = process.argv.slice(2); const pad = process.env.SCRIPTC_BENCH_LAYOUT_PAD; let out = args; if (pad && !args.some((a) => a.startsWith("-print-prog-name"))) { - const at = args.findIndex((a) => basename(a).startsWith(".scriptc-native-program-object-")); - if (at < 0) { + if (!args.some((a) => basename(a).startsWith(".scriptc-native-program-object-"))) { process.stderr.write("bench layout wrapper: no program object on the link line\\n"); process.exit(2); } - out = ["-Wl,-u,${symbol}", ...args.slice(0, at), pad, ...args.slice(at)]; + out = ["-Wl,-u,${symbol}", pad, ...args]; } const result = spawnSync("clang", out, { stdio: "inherit" }); process.exit(result.status ?? 1);