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"],