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/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/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..d4cf82752 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;
@@ -1769,6 +1792,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). */
@@ -2787,7 +2814,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/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/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);
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();
+ });
+});
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/corpus-costs.json b/tests/harness/corpus-costs.json
index cf5c31ce1..d5a11992e 100644
--- a/tests/harness/corpus-costs.json
+++ b/tests/harness/corpus-costs.json
@@ -1850,6 +1850,9 @@
"5886-inspect-dates.ts": 2559,
"5887-inspect-typed-arrays.ts": 3448,
"5888-inspect-sorted.ts": 2998,
+ "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,
@@ -4483,6 +4486,9 @@
"5886-inspect-dates.ts": 2736,
"5887-inspect-typed-arrays.ts": 2997,
"5888-inspect-sorted.ts": 2861,
+ "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,
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"],