From 84cf0ec19fd9288034678b8ce9aa5c09fcdba916 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:54:02 -0500 Subject: [PATCH 01/44] Inspect dynamic classes, functions and %o arguments as Node does - The inspector throws the 'not supported yet' Error for checked-dynamic values it does not render (a promise, a URL, a RegExp reached through `unknown`). Its calls were not marked as throwing, so `console.log("x", p)` printed "x " and the program went on; records and arrays with such a field printed `{ a: }` and `[ ]`. insp.dyn and insp.dynS are now may-throw library calls, so the Error propagates from the call that inspects the value, before anything is written. - A class reached through `unknown` prints `[class C]`, `[class C extends B]` or `[class (anonymous)]` instead of `[Function: C]`. Functions and classes reached as dynamic values print their enumerable own properties (`[Function: f] { x: 1 }`, `[class S extends A] { count: 3 }`), cycles through them as `[Circular *N]`, and `[Function]` past the depth limit. Class objects carry their base class's name. A compiled class's static fields are module globals that the class object does not carry, so inspecting a class that declares static fields through `unknown` throws the 'not supported yet' Error. - util.format's %o renders a checked-dynamic argument with showHidden: an array's `[length]` entry and an object's non-enumerable own properties, string- and symbol-keyed, in brackets. An array with more than 100 output entries keeps the last one out of the grid, as Node's groupArrayElements does. %o of a function, an Error, a typed array or a native handle other than Map, Set and Date throws the 'not supported yet' Error. ScrClassObj has two new fields, so RUNTIME_ABI_VERSION is now 27. --- packages/compiler/src/backend/llvm/classes.ts | 10 +- .../compiler/src/backend/llvm/emit-module.ts | 2 +- packages/compiler/src/backend/llvm/emitter.ts | 9 +- packages/compiler/src/backend/llvm/layouts.ts | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-classes.ts | 8 + .../src/frontend/lowering/lower-console.ts | 3 +- .../src/frontend/lowering/lower-inspect.ts | 32 ++- packages/compiler/src/ir/builtin-effects.ts | 7 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/src/ir/int32-slots.test.ts | 1 + packages/compiler/src/ir/int32-slots.ts | 1 + packages/compiler/src/ir/ir.ts | 4 + .../corpus/6343-inspect-class-values.ts.json | 6 + .../6344-inspect-function-properties.cjs.json | 6 + .../tests/corpus/6357-format-o-hidden.ts.json | 6 + .../corpus/6358-format-o-hidden-js.cjs.json | 6 + packages/runtime/src/scr_error.h | 6 + packages/runtime/src/scr_inspect.c | 187 +++++++++++++++--- packages/runtime/src/scr_inspect.h | 1 + packages/runtime/src/scr_object.c | 2 + tests/corpus/6343-inspect-class-values.ts | 31 +++ .../6344-inspect-function-properties.cjs | 34 ++++ tests/corpus/6357-format-o-hidden.ts | 19 ++ tests/corpus/6358-format-o-hidden-js.cjs | 9 + tests/fixtures/self-hosting/llvm-layouts.ts | 3 +- .../6343-inspect-class-values.ts.json | 1 + .../6344-inspect-function-properties.cjs.json | 1 + .../corpus-costs/6357-format-o-hidden.ts.json | 1 + .../6358-format-o-hidden-js.cjs.json | 1 + tests/harness/dyncheck.test.ts | 88 +++++++++ 31 files changed, 447 insertions(+), 45 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6343-inspect-class-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6344-inspect-function-properties.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6357-format-o-hidden.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6358-format-o-hidden-js.cjs.json create mode 100644 tests/corpus/6343-inspect-class-values.ts create mode 100644 tests/corpus/6344-inspect-function-properties.cjs create mode 100644 tests/corpus/6357-format-o-hidden.ts create mode 100644 tests/corpus/6358-format-o-hidden-js.cjs create mode 100644 tests/harness/corpus-costs/6343-inspect-class-values.ts.json create mode 100644 tests/harness/corpus-costs/6344-inspect-function-properties.cjs.json create mode 100644 tests/harness/corpus-costs/6357-format-o-hidden.ts.json create mode 100644 tests/harness/corpus-costs/6358-format-o-hidden-js.cjs.json diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 3073bd1bdc..8b20ec2640 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -1363,18 +1363,18 @@ export function emitClassShapes( * statics plus their construct thunks — allocate, run the constructor over * a +1 `this`, and hand the remaining +1 out. No pending check is needed: * in-tier constructors are throw-free by construction. - * `classObjs` maps className → { nameSym } registered during body emission - * (the literal interned while the table was open). */ + * `classObjs` maps className → { nameSym, baseNameSym } registered during + * body emission (the literals interned while the table was open). */ export function emitClassObjDefs( host: ClassHost, metaMap: Map, - classObjs: Map, + classObjs: Map, fnByName: Map, llType: (t: IrType) => string, threadLocal = false, ): string[] { const out: string[] = []; - for (const [className, { nameSym }] of classObjs) { + for (const [className, { nameSym, baseNameSym }] of classObjs) { const meta = metaMap.get(className); if (!meta) throw new InternalCompilerError( @@ -1400,7 +1400,7 @@ export function emitClassObjDefs( const ctorArgs = params.map((p, i) => `${llType(p.type)} %a${i}`); const classObj = `@${mangleClassObj(className)} = internal ${threadLocal ? "thread_local " : ""}global %ScrClassObj ` + - `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym}, ${host.sizeType} 0, ${host.sizeType} ${meta.def.jsLength ?? (meta.def.runtime ? 1 : 0)}, ptr null, ptr null } ; class ${className}`; + `{ ${host.sizeType} -1, ${host.sizeType} ${intervalMeta.pre}, ${host.sizeType} ${intervalMeta.post}, ptr @${mangleCtorThunk(className)}, ptr ${nameSym}, ${host.sizeType} 0, ${host.sizeType} ${meta.def.jsLength ?? (meta.def.runtime ? 1 : 0)}, ptr null, ptr null, ptr ${baseNameSym ?? "null"}, ${host.sizeType} ${meta.def.ownStaticFields ? 1 : 0} } ; class ${className}`; if (meta.def.runtime) { // A runtime-provided class: %C.construct allocates through the // runtime and returns the instance. diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 3d9d410720..8da4bb1405 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -483,7 +483,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // class-object shape { rc, pre, post, ctor, name } — field reads on // builtin errors and classval loads GEP through these. `%ScrError = type { ${emitter.sizeType}, ptr, ptr, ptr, ptr, ptr, i8, i8, i8, i8, i8, ptr, ptr }`, - `%ScrClassObj = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`, + `%ScrClassObj = type { ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr, ptr, ${emitter.sizeType} }`, // The runtime emitter prefix { rc, vt, reg, cls } — user subclasses // embed it (classes.ts), and bare-emitter GEPs address through it. `%ScrEmitter = type { ${emitter.sizeType}, ptr, ptr, ptr }`, diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index bfde59a309..0b4ce184a6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1000,7 +1000,7 @@ export class LlEmitter { /** Class objects (classes as first-class values): className → the * interned .name literal ref — registered during body emission, the * statics and construct thunks assemble around the bodies. */ - readonly classObjs = new Map(); + readonly classObjs = new Map(); private readonly unionFieldReadGroups = new Map(); /** Preorder intervals of the runtime error classes under THIS module's * class-forest numbering (main() stamps scr_error_vts with them, exactly @@ -3638,7 +3638,12 @@ export class LlEmitter { classObjSym(className: string): string { if (!this.classObjs.has(className)) { const meta = this.classMetaOf(className); - this.classObjs.set(className, { nameSym: this.internLiteral(classJsName(meta.def)) }); + // util.inspect prints `[class C extends B]` with the base's name. + const baseName = meta.base ? classJsName(meta.base.def) : ""; + this.classObjs.set(className, { + nameSym: this.internLiteral(classJsName(meta.def)), + baseNameSym: baseName ? this.internLiteral(baseName) : null, + }); } return mangleClassObj(className); } diff --git a/packages/compiler/src/backend/llvm/layouts.ts b/packages/compiler/src/backend/llvm/layouts.ts index 8e1f0a4c12..5c426e9103 100644 --- a/packages/compiler/src/backend/llvm/layouts.ts +++ b/packages/compiler/src/backend/llvm/layouts.ts @@ -17,7 +17,7 @@ export function emitLlvmLayouts( host: ClassHost, mod: IrModule, classes: Map, - classObjects: Map, + classObjects: Map, functions: Map, llType: (type: IrType) => string, nullable?: NullableRefFields, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9a65d9a242..be65956584 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1029,6 +1029,7 @@ export const LIB_FN_SYMS: Record = { "dyn.generatorThis": "scr_gen_receiver", "insp.dyn": "scr_insp_dyn", "insp.dynS": "scr_insp_dyn_s", + "insp.dynHidden": "scr_insp_dyn_hidden", "fs.readFileSyncDyn": "scr_fs_read_file_sync_dyn", "fs.readFileEncoded": "scr_fs_read_file_encoded", "fs.readFdEncoded": "scr_fs_read_fd_encoded", diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index d8ccc15b7b..3c294bb66f 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -4395,6 +4395,14 @@ export function collectClassShapeInner( // INSTANTIATION prints its family's name — JS has one `Box`. jsName: jsNameOverride ?? decl.name?.text ?? "", ...(base ? { base: base.def.name } : {}), + ...(decl.members.nodes.some( + (member) => + ts.isPropertyDeclaration(member) && + !ts.isPrivateIdentifier(member.name) && + member.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword), + ) + ? { ownStaticFields: true as const } + : {}), jsLength: ctor?.parameters?.nodes.findIndex( (parameter) => parameter.initializer || parameter.dotDotDotToken, diff --git a/packages/compiler/src/frontend/lowering/lower-console.ts b/packages/compiler/src/frontend/lowering/lower-console.ts index eec3083423..b45218bd06 100644 --- a/packages/compiler/src/frontend/lowering/lower-console.ts +++ b/packages/compiler/src/frontend/lowering/lower-console.ts @@ -249,7 +249,8 @@ export function lowerConsoleArg( // inspect at the rest-args depth 2 (formatWithOptions) — scalar // kinds byte-exactly, boxed functions as [Function: name] / // [Function (anonymous)], composites through the dyn walk - // (insp.dyn). Never throws — Node's console.log never does. + // (insp.dyn). A value the inspector does not render throws the + // 'not supported yet' Error before anything is written. if (lowered.type.kind === "dyn") { return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 47f490c7ba..71a7ea3278 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -2607,10 +2607,21 @@ function formatSArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S } /** %O (depth 2) / %o (showHidden, depth 4) — inspect with the spec's - * option deltas; %o fences over arrays (showHidden adds their hidden - * [length] entry, which has no lowering). */ + * option deltas. A checked-dynamic argument renders through the runtime's + * showHidden walk; static %o fences over arrays and nested dynamic values + * (showHidden adds an array's hidden [length] entry, which the static + * rendering has no lowering for). */ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: SrcLoc): IrExpr { const value = lowerer.lowerExpr(node); + if (depth === 4 && value.type.kind === "dyn") { + return { + kind: "libCall", + fn: "insp.dynHidden", + args: [value, numLit(4, loc)], + type: STRING, + loc, + }; + } if (value.type.kind === "bytes") { lowerer.noLowering( `util.format %${depth === 4 ? "o" : "O"} of typed-array values`, @@ -2626,11 +2637,11 @@ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S node, reason, ); - if (depth === 4 && typeTreeHasArray(lowerer, value.type, new Set())) { + if (depth === 4 && typeTreeHasHidden(lowerer, value.type, new Set())) { lowerer.noLowering( "util.format %o over arrays", node, - "%o renders with showHidden (arrays gain a hidden [length] entry) — use %O for the default rendering", + "%o renders with showHidden (arrays gain a hidden [length] entry) — use %O for the default rendering, or pass the value as unknown", ); } return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(depth, loc), loc); @@ -3031,35 +3042,36 @@ export function lowerFormatCall( } /** Arrays anywhere in the tree — the %o (showHidden) gate. */ -function typeTreeHasArray(lowerer: Lowerer, t: IrType, visiting: Set): boolean { +function typeTreeHasHidden(lowerer: Lowerer, t: IrType, visiting: Set): boolean { switch (t.kind) { case "array": + case "dyn": return true; case "record": { if (visiting.has(t.shapeId)) return false; visiting.add(t.shapeId); const shape = lowerer.shapes.get(t.shapeId); if (shape?.tuple) return true; - return (shape?.fields ?? []).some((f) => typeTreeHasArray(lowerer, f.type, visiting)); + return (shape?.fields ?? []).some((f) => typeTreeHasHidden(lowerer, f.type, visiting)); } case "map": return ( - typeTreeHasArray(lowerer, t.key, visiting) || typeTreeHasArray(lowerer, t.value, visiting) + typeTreeHasHidden(lowerer, t.key, visiting) || typeTreeHasHidden(lowerer, t.value, visiting) ); case "set": - return typeTreeHasArray(lowerer, t.elem, visiting); + return typeTreeHasHidden(lowerer, t.elem, visiting); case "union": { if (visiting.has(t.unionId)) return false; visiting.add(t.unionId); return (lowerer.unions.get(t.unionId)?.arms ?? []).some((a) => - typeTreeHasArray(lowerer, a, visiting), + typeTreeHasHidden(lowerer, a, visiting), ); } case "object": { if (visiting.has(t.className)) return false; visiting.add(t.className); return (lowerer.classes.get(t.className)?.def.fields ?? []).some((f) => - typeTreeHasArray(lowerer, f.type, visiting), + typeTreeHasHidden(lowerer, f.type, visiting), ); } case "bytes": diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 16db45b6c5..229fd8c28d 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -174,6 +174,13 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "num.toStringRadix", "num.toStringRadixF64", "insp.jsonDyn", + // A checked-dynamic value the inspector does not render (a promise, a + // URL, a RegExp) throws the 'not supported yet' Error. + "insp.dyn", + "insp.dynS", + // %o's showHidden walk refuses the kinds whose hidden entries it cannot + // render. + "insp.dynHidden", // diagnostics_channel: publish runs subscribers synchronously (a throw // propagates — the documented divergence from triggerUncaughtException); // the subscribe/unsubscribe forms throw Node's ERR_INVALID_ARG_TYPE for diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index fcdbe86947..9f224c72a2 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4290,6 +4290,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "insp.errorCause": { argTypes: [null, F64, F64], result: STRING }, "insp.dyn": { argTypes: [DYN, F64, F64], result: STRING }, "insp.dynS": { argTypes: [DYN, F64], result: STRING }, + /** util.format's %o over a checked-dynamic value: inspect with + * showHidden. Kinds whose hidden entries have no rendering throw the + * 'not supported yet' Error. */ + "insp.dynHidden": { argTypes: [DYN, F64], result: STRING }, "insp.begin": { argTypes: [F64], result: VOID }, /** util.inspect's breakLength, compact (0 for false) and sorted for the * renders that follow; the call site restores 80, 3 and false afterwards. */ diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index be933ab385..f64e2de1fe 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -281,6 +281,7 @@ test("dynamic stores disqualify the field names they can reach", () => { "dyn.typeof", "insp.dyn", "insp.dynS", + "insp.dynHidden", "dyn.objCreate", "dyn.getPrototype", "dyn.nameClassPrototype", diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 21223862a4..221692e3fc 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -407,6 +407,7 @@ const DYN_READ_ONLY = new Set([ "fs.utimesSync", "insp.dyn", "insp.dynS", + "insp.dynHidden", // The dynamic-tier operators run their dyn twins (scr_js_value.c); the // conversions move a value between dyn and jsval, and js.toF64 is // dynCheck's number check. diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 4a395feb2c..5852eb8a63 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1432,6 +1432,10 @@ export interface IrClassDef { jsName?: string; /** JavaScript constructor arity, ending before the first default or rest parameter. */ jsLength?: number; + /** The class declares public static fields. util.inspect prints them + * after the class tag; a class object reached through `unknown` does + * not carry them unless the runtime holds its static table. */ + ownStaticFields?: true; /** Zero-argument native helper returning the shared prototype data object. * Used when materializing an instance's own-property view. */ prototypeDataHelper?: string; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6343-inspect-class-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6343-inspect-class-values.ts.json new file mode 100644 index 0000000000..4fa8625ac4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6343-inspect-class-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6343-inspect-class-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6344-inspect-function-properties.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6344-inspect-function-properties.cjs.json new file mode 100644 index 0000000000..0030751407 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6344-inspect-function-properties.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6344-inspect-function-properties.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6357-format-o-hidden.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6357-format-o-hidden.ts.json new file mode 100644 index 0000000000..cf0cd31d1e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6357-format-o-hidden.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6357-format-o-hidden.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6358-format-o-hidden-js.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6358-format-o-hidden-js.cjs.json new file mode 100644 index 0000000000..73d4eefa6d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6358-format-o-hidden-js.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6358-format-o-hidden-js.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_error.h b/packages/runtime/src/scr_error.h index a7c921f236..17d217cdc8 100644 --- a/packages/runtime/src/scr_error.h +++ b/packages/runtime/src/scr_error.h @@ -48,6 +48,12 @@ typedef struct ScrClassObj { size_t length; struct ScrBox *prototype_data; struct ScrBox *static_data; + /* The base class's name, which util.inspect prints as + * `[class C extends B]`; NULL without a named base. */ + const ScrStr *base_name; + /* Nonzero when the class declares public static fields, which the + * class object does not carry without static_data. */ + size_t own_static_fields; struct ScrBox *caps[]; } ScrClassObj; diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index a4d124ac3c..2146488024 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -324,6 +324,9 @@ typedef struct { static SCR_TL InspSeenEnt *g_seen; static SCR_TL size_t g_nseen; +/* util.format's %o (scr_insp_dyn_hidden): this render shows hidden own + * properties, like util.inspect's showHidden. */ +static SCR_TL bool g_show_hidden; static SCR_TL size_t g_seen_cap; /* Detection-numbered circular targets (persist across the call). */ static SCR_TL const void *g_circ[64]; @@ -1375,18 +1378,25 @@ static ScrStr *scr_insp_dyn_collection(ScrDyn *d, double recurse, double depth, return out; } -/* An own enumerable symbol-keyed property of `d` (borrowed), or NULL. */ +/* An own symbol-keyed property of `d` (borrowed) this render shows, or + * NULL: enumerable ones, and under showHidden the non-enumerable ones too. */ static ScrDynEntry *scr_insp_symbol_entry(ScrDyn *d, size_t i) { ScrDynEntry *ent = scr_dyn_own_symbol_entry(d, d->symbol_keys->v.arr.items[i]); - return ent && ent->enumerable ? ent : NULL; + return ent && (ent->enumerable || g_show_hidden) ? ent : NULL; } /* An ordinary object: `Name { key: value }` with its constructor name * `cls` (NULL for a plain object). Node's default view lists the own * enumerable string keys, then the own enumerable symbol keys. */ +/* An own entry this render shows: enumerable ones, and under showHidden + * the non-enumerable ones too (never the encoding's `%` markers). */ +static bool insp_obj_shows(const ScrDynEntry *ent) { + return ent->enumerable || (g_show_hidden && !(ent->key_len && ent->key[0] == '%')); +} + static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char *cls) { size_t shown = 0; - for (size_t i = 0; i < d->v.obj.len; i++) shown += d->v.obj.entries[i].enumerable; + for (size_t i = 0; i < d->v.obj.len; i++) shown += insp_obj_shows(&d->v.obj.entries[i]); size_t nsymbols = d->symbol_keys ? d->symbol_keys->v.arr.len : 0; for (size_t i = 0; i < nsymbols; i++) shown += scr_insp_symbol_entry(d, i) != NULL; if (shown == 0) { @@ -1402,10 +1412,17 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char scr_insp_begin(recurse + 1); for (size_t i = 0; i < d->v.obj.len; i++) { ScrDynEntry *ent = &d->v.obj.entries[i]; - if (!ent->enumerable) continue; + if (!insp_obj_shows(ent)) continue; ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; - insp_key_into(&eb, ent->key, ent->key_len); + if (ent->enumerable) { + insp_key_into(&eb, ent->key, ent->key_len); + } else { + /* formatProperty's hidden key: the key's rendering in brackets. */ + ib_char(&eb, '['); + insp_key_into(&eb, ent->key, ent->key_len); + ib_char(&eb, ']'); + } ib_cstr(&eb, ": "); ib_bytes(&eb, val->data, val->len); scr_str_release(val); @@ -1419,7 +1436,10 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char ScrStr *key = scr_insp_dyn(d->symbol_keys->v.arr.items[i], recurse + 1, depth); ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; + /* A hidden symbol key is bracketed like a hidden string key. */ + if (!ent->enumerable) ib_char(&eb, '['); ib_bytes(&eb, key->data, key->len); + if (!ent->enumerable) ib_char(&eb, ']'); ib_cstr(&eb, ": "); ib_bytes(&eb, val->data, val->len); scr_str_release(key); @@ -1480,8 +1500,114 @@ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth) { return out; } +/* Node's function rendering: [Function: name] / [Function (anonymous)], + * and [class C extends B] / [class (anonymous)] for a class. The boxed + * name is the compiler's best-effort static spelling (SEMANTICS.md — + * reference-site naming). Enumerable own properties (a class's static + * fields, properties assigned to a function) follow in braces; past the + * depth limit such a function is [Function]. Borrows d; +1. */ +static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { + const char *name = d->v.fn.name; + const ScrClassObj *cls = d->v.fn.class_obj; + ScrClosure *owner = scr_closure_identity(d->v.fn.clo); + ScrBox *props = owner->props ? owner->props : cls ? cls->static_data : NULL; + ScrDyn *table = props ? (ScrDyn *)scr_box_get_ref(props) : NULL; /* +1 */ + InspBuf out = {0}; + if (cls) { + ib_cstr(&out, "[class "); + ib_cstr(&out, name && name[0] ? name : "(anonymous)"); + const ScrDyn *base = table ? table->prototype : NULL; + if (cls->base_name && cls->base_name->len) { + ib_cstr(&out, " extends "); + ib_bytes(&out, cls->base_name->data, cls->base_name->len); + } else if (base && base->kind == SCR_DYN_FUNC && base->v.fn.name && base->v.fn.name[0]) { + ib_cstr(&out, " extends "); + ib_cstr(&out, base->v.fn.name); + } + ib_char(&out, ']'); + } else if (name && name[0]) { + ib_cstr(&out, "[Function: "); + ib_cstr(&out, name); + ib_char(&out, ']'); + } else { + ib_cstr(&out, "[Function (anonymous)]"); + } + ScrStr *tag = ib_take(&out); + if (cls && !props && cls->own_static_fields) { + /* Node prints the static fields after the tag; the class object + * does not hold them (the reflection fence's stance). */ + scr_str_release(tag); + static const char message[] = "util.inspect of a class with static fields through unknown is not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + return scr_str_new("", 0); + } + size_t shown = 0; + if (table && table->kind == SCR_DYN_OBJ) { + for (size_t i = 0; i < table->v.obj.len; i++) shown += table->v.obj.entries[i].enumerable; + size_t nsymbols = table->symbol_keys ? table->symbol_keys->v.arr.len : 0; + for (size_t i = 0; i < nsymbols; i++) shown += scr_insp_symbol_entry(table, i) != NULL; + } + if (shown == 0) { + scr_dyn_release(table); + return tag; + } + if (recurse > depth) { + scr_dyn_release(table); + scr_str_release(tag); + return scr_str_new("[Function]", 10); + } + /* A function already on the traversal stack renders as [Circular *N], + * keyed by the closure every box of the function shares. */ + double circular = scr_insp_circ_check(owner); + if (circular > 0) { + scr_dyn_release(table); + scr_str_release(tag); + return scr_insp_circular(circular); + } + char *prefix = malloc(tag->len + 1); + if (!prefix) scr_trap("scriptc: out of memory\n"); + memcpy(prefix, tag->data, tag->len); + prefix[tag->len] = '\0'; + scr_str_release(tag); + size_t nseen = g_nseen; + g_pending_seen = owner; + ScrStr *rendered = scr_insp_obj(table, recurse, depth, prefix); + g_pending_seen = NULL; + if (g_nseen > nseen) g_nseen = nseen; + free(prefix); + scr_dyn_release(table); + return rendered; +} + +/* Under showHidden, the kinds whose hidden entries this inspector does not + * render (a function's length/name/prototype, an error's stack, a typed + * array's byte fields, a native handle's internals): the kind's name, NULL + * for the kinds it renders. */ +static const char *insp_hidden_refusal(const ScrDyn *d) { + switch (d->kind) { + case SCR_DYN_FUNC: return "function"; + case SCR_DYN_BYTES: return d->buffer ? "Buffer" : d->v.bytes->is_data_view ? "DataView" : "typed array"; + case SCR_DYN_OBJ: return scr_dyn_obj_get(d, "%error", 6) != NULL ? "Error" : NULL; + case SCR_DYN_HANDLE: + return d->v.handle.tag == SCR_DYNH_MAP || d->v.handle.tag == SCR_DYNH_SET || + d->v.handle.tag == SCR_DYNH_DATE + ? NULL + : scr_dyn_handle_cls(d); + default: return NULL; + } +} + ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->kind == SCR_DYN_PROXY) return scr_insp_dyn(d->v.proxy.target, recurse, depth); + const char *refused = g_show_hidden ? insp_hidden_refusal(d) : NULL; + if (refused) { + InspBuf out = {0}; + ib_cstr(&out, "util.format %o of "); + ib_cstr(&out, refused); + ib_cstr(&out, " values is not supported yet"); + scr_throw_error(SCR_ERR_ERROR, ib_take(&out)); + return scr_str_new("", 0); + } switch (d->kind) { case SCR_DYN_NULL: return scr_str_new("null", 4); @@ -1499,14 +1625,14 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { return scr_insp_str(d->v.str); case SCR_DYN_ARR: { size_t len = d->v.arr.len; - if (len == 0) return scr_str_new("[]", 2); + if (len == 0 && !g_show_hidden) return scr_str_new("[]", 2); if (recurse > depth) return scr_str_new("[Array]", 7); scr_insp_begin(recurse + 1); /* Node's maxArrayLength (100) counts output entries; a run of holes * is one `` entry (formatSpecialArray). */ const unsigned char *presence = d->v.arr.presence; - size_t i = 0; - for (size_t shown = 0; i < len && shown < 100; shown++) { + size_t i = 0, shown = 0; + for (; i < len && shown < 100; shown++) { if (presence && !presence[i]) { size_t next = i + 1; while (next < len && !presence[next]) next++; @@ -1529,10 +1655,26 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { scr_insp_entry(m, d->v.arr.items[i]->kind == SCR_DYN_NUM); scr_str_release(m); } + if (g_show_hidden) { + /* The own non-enumerable `length`, after the items. Node's + * grouping reads value[k] for every output position k, this one + * included. */ + size_t at = shown + (more ? 1 : 0); + char line[48]; + int n = snprintf(line, sizeof line, "[length]: %zu", len); + ScrStr *s = scr_str_new(line, (size_t)n); + scr_insp_entry(s, at < len && (!presence || presence[at]) && + d->v.arr.items[at]->kind == SCR_DYN_NUM); + scr_str_release(s); + } ScrStr *base = scr_str_new("", 0); ScrStr *b0 = scr_str_new("[", 1); ScrStr *b1 = scr_str_new("]", 1); - ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, more); + /* groupArrayElements leaves the last entry out of the grid when + * there are more than maxArrayLength (100) entries: the "... N more + * items" tail, or under showHidden the `[length]` entry. */ + bool trailing = shown + (more ? 1 : 0) + (g_show_hidden ? 1 : 0) > 100; + ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, trailing); scr_str_release(base); scr_str_release(b0); scr_str_release(b1); @@ -1549,21 +1691,8 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { * the bare marker (where a plain object says [Object]). */ return scr_insp_obj(d, recurse, depth, scr_dyn_class_name(d)); } - case SCR_DYN_FUNC: { - /* Node's function rendering: [Function: name] / [Function - * (anonymous)]. The boxed name is the compiler's best-effort static - * spelling (SEMANTICS.md — reference-site naming). */ - const char *name = d->v.fn.name; - InspBuf out = {0}; - if (name && name[0]) { - ib_cstr(&out, "[Function: "); - ib_cstr(&out, name); - ib_char(&out, ']'); - } else { - ib_cstr(&out, "[Function (anonymous)]"); - } - return ib_take(&out); - } + case SCR_DYN_FUNC: + return scr_insp_dyn_func(d, recurse, depth); case SCR_DYN_HANDLE: { if (d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || d->v.handle.tag == SCR_DYNH_SHARED_ARRAY_BUFFER) { @@ -1734,6 +1863,16 @@ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth) { return scr_insp_dyn(d, 0, depth); } +/* util.format's %o over a dyn value: util.inspect with showHidden at the + * given depth. */ +ScrStr *scr_insp_dyn_hidden(ScrDyn *d, double depth) { + bool outer = g_show_hidden; + g_show_hidden = true; + ScrStr *out = scr_insp_dyn(d, 0, depth); + g_show_hidden = outer; + return out; +} + /* ── console.table cells ─────────────────────────────────────────────── */ size_t scr_insp_display_width(const char *data, size_t len) { return insp_width(data, len); } diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 5ac3aa9dd2..9bf18a23c0 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -47,6 +47,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth); ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth); /* format's %s / rest-args twin: dyn strings pass verbatim. */ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth); +ScrStr *scr_insp_dyn_hidden(ScrDyn *d, double depth); /* %o: showHidden */ void scr_insp_begin(double recurse); /* util.inspect's breakLength, compact (0 for false) and sorted for the * renders that follow; the call site restores the defaults (80, 3, false) diff --git a/packages/runtime/src/scr_object.c b/packages/runtime/src/scr_object.c index 7cafbeca3d..2f47a1c7be 100644 --- a/packages/runtime/src/scr_object.c +++ b/packages/runtime/src/scr_object.c @@ -33,6 +33,8 @@ ScrClassObj *scr_classobj_new(const ScrClassObj *template, size_t ncaps) { c->length = template->length; c->prototype_data = NULL; c->static_data = NULL; + c->base_name = template->base_name; + c->own_static_fields = template->own_static_fields; scr_obj_alloc_note(); return c; } diff --git a/tests/corpus/6343-inspect-class-values.ts b/tests/corpus/6343-inspect-class-values.ts new file mode 100644 index 0000000000..5e9931c8cb --- /dev/null +++ b/tests/corpus/6343-inspect-class-values.ts @@ -0,0 +1,31 @@ +// Classes reached through `unknown` print as Node prints them: +// `[class C]`, `[class C extends B]` with the runtime class's base, and +// `[class (anonymous)]`, at the top level and inside arrays and records. +import { inspect } from "node:util"; + +class A { + x = 1; +} +class B extends A { + y = 2; +} +class C extends B {} +class MyErr extends Error {} +const Anon = [class {}][0]!; +const Sub = class extends A {}; + +const a: unknown = A; +const b: unknown = B; +console.log(a, b); +console.log([a], { b }); +const values: unknown[] = [C, MyErr, Anon, Sub]; +console.log(values); +console.log(inspect({ deep: { values } }, { depth: 0 }), inspect(values, { depth: 0 })); +console.log(String(typeof values[0])); + +// A slot typed as the base holds a subclass at runtime. +const pick = (k: number): typeof A => (k === 0 ? A : k === 1 ? B : C); +for (let k = 0; k < 3; k++) { + const picked: unknown = pick(k); + console.log(k, picked); +} diff --git a/tests/corpus/6344-inspect-function-properties.cjs b/tests/corpus/6344-inspect-function-properties.cjs new file mode 100644 index 0000000000..94db45a402 --- /dev/null +++ b/tests/corpus/6344-inspect-function-properties.cjs @@ -0,0 +1,34 @@ +'use strict'; +// Functions and classes reached as dynamic values print their enumerable +// own properties after the tag, as Node prints them: a class's static +// properties, properties assigned to a function, cycles through them, +// and `[Function]` past the depth limit. +const util = require('util'); + +function f() {} +f.x = 1; +f.label = 'f'; +function g() {} +g.self = g; +function plain() {} +class A {} +class S extends A { + static get hidden() { + return 1; + } +} +S.count = 3; +function show(value) { + console.log(value); +} +show(f); +show(g); +show(S); +show(A); +show(plain); +function shallow(value) { + console.log(util.inspect({ value }, { depth: 0 })); +} +shallow(f); +shallow(S); +shallow(A); diff --git a/tests/corpus/6357-format-o-hidden.ts b/tests/corpus/6357-format-o-hidden.ts new file mode 100644 index 0000000000..9e48ed4ba6 --- /dev/null +++ b/tests/corpus/6357-format-o-hidden.ts @@ -0,0 +1,19 @@ +// util.format's %o over values reached through `unknown` renders with +// showHidden, as Node does: arrays show their hidden `[length]` (with +// Node's grouping), at every depth, inside Maps and records too. +import { format } from "node:util"; +const cases: [string, unknown][] = [ + ["ten", Array.from({ length: 10 }, (_, i) => i)], + ["wide", Array.from({ length: 120 }, (_, i) => i)], + ["hundred", Array.from({ length: 100 }, (_, i) => i)], + ["hundred-one", Array.from({ length: 101 }, (_, i) => i)], + ["hundred-strs", Array.from({ length: 100 }, (_, i) => "s" + i)], + ["seven", ["aaaaaaaaaaaaaaaaaaaa", "b", "c", "d", "e", "f", "g"]], + ["strs", Array.from({ length: 30 }, (_, i) => "s" + i)], + ["holes", [1, , 3]], + ["empty", []], + ["nested", { list: [[]], m: new Map([[1, [2]]]) }], + ["deep", [[[[[[1]]]]]]], + ["date", [new Date(0)]], +]; +for (const [k, v] of cases) console.log(k, format("%o", v)); diff --git a/tests/corpus/6358-format-o-hidden-js.cjs b/tests/corpus/6358-format-o-hidden-js.cjs new file mode 100644 index 0000000000..df54b2714a --- /dev/null +++ b/tests/corpus/6358-format-o-hidden-js.cjs @@ -0,0 +1,9 @@ +'use strict'; +// %o shows an object's non-enumerable own properties as `[key]`, symbol +// keys included. +const util = require('util'); +const hidden = Object.defineProperty(JSON.parse('{"shown":1}'), 'secret', { value: [2], enumerable: false }); +Object.defineProperty(hidden, 'a-b', { value: 'x', enumerable: false }); +Object.defineProperty(hidden, Symbol('tag'), { value: 3, enumerable: false }); +console.log(util.format('%o and %O', hidden, hidden)); +console.log(util.format('%o', [hidden])); diff --git a/tests/fixtures/self-hosting/llvm-layouts.ts b/tests/fixtures/self-hosting/llvm-layouts.ts index 6ddf3ca3a7..36d55bddf2 100644 --- a/tests/fixtures/self-hosting/llvm-layouts.ts +++ b/tests/fixtures/self-hosting/llvm-layouts.ts @@ -119,7 +119,8 @@ try { }, ); virtualBorrows = new VirtualBorrows(mod, graph, functions, lifetimes.borrowed); - const classObjects = new Map(request.classObjects.map((name) => [name, { nameSym: `@name_${mangleClassObj(name)}` }])); + const classObjects: Map = new Map(); + for (const name of request.classObjects) classObjects.set(name, { nameSym: `@name_${mangleClassObj(name)}`, baseNameSym: null }); const typeName = (type: IrType): string => type.kind === "void" ? "void" : type.kind === "f64" || type.kind === "date" ? "double" : type.kind === "bool" ? "i1" : "ptr"; // Mirrors the emitter: whole-program int32 fields store the exact i32. const layouts = emitLlvmLayouts(host, mod, graph, classObjects, functions, typeName, undefined, analyzeInt32Slots(mod)); diff --git a/tests/harness/corpus-costs/6343-inspect-class-values.ts.json b/tests/harness/corpus-costs/6343-inspect-class-values.ts.json new file mode 100644 index 0000000000..fa2590573b --- /dev/null +++ b/tests/harness/corpus-costs/6343-inspect-class-values.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 4700 } diff --git a/tests/harness/corpus-costs/6344-inspect-function-properties.cjs.json b/tests/harness/corpus-costs/6344-inspect-function-properties.cjs.json new file mode 100644 index 0000000000..71bf780f8d --- /dev/null +++ b/tests/harness/corpus-costs/6344-inspect-function-properties.cjs.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3500 } diff --git a/tests/harness/corpus-costs/6357-format-o-hidden.ts.json b/tests/harness/corpus-costs/6357-format-o-hidden.ts.json new file mode 100644 index 0000000000..f351a50448 --- /dev/null +++ b/tests/harness/corpus-costs/6357-format-o-hidden.ts.json @@ -0,0 +1 @@ +{ "plain": 2300, "san": 2100 } diff --git a/tests/harness/corpus-costs/6358-format-o-hidden-js.cjs.json b/tests/harness/corpus-costs/6358-format-o-hidden-js.cjs.json new file mode 100644 index 0000000000..1f8ff72b2c --- /dev/null +++ b/tests/harness/corpus-costs/6358-format-o-hidden-js.cjs.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 2000 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 6a2d4dc618..dab51a4300 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -1278,4 +1278,92 @@ console.log("done", caught); expect(r.exitCode).toBe(0); expect(r.stdout).toBe("done 100\n"); }); + + test("an inspection refusal throws before console.log writes", async () => { + // A promise reached through `unknown` has no dynamic rendering; the + // refusal must stop the call, not print a partial line and continue. + const r = await compileAndRun( + "inspect-refusal-unwind", + `import { inspect } from "node:util"; +const p: unknown = Promise.resolve(3); +function attempt(label: string, f: () => void): void { + try { + f(); + console.log(label, "returned"); + } catch (e) { + console.log(label, (e as Error).message); + } +} +attempt("log", () => console.log("x", p)); +attempt("inspect", () => console.log(inspect(p).length)); +attempt("record", () => console.log({ a: p })); +attempt("array", () => console.log([p])); +console.log("before"); +console.log(p); +console.log("after"); +`, + ); + const refusal = "util.inspect of a promise value is not supported yet"; + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe( + ["log", "inspect", "record", "array"].map((l) => `${l} ${refusal}\n`).join("") + "before\n", + ); + expect(r.stderr).toContain(`Uncaught Error: ${refusal}`); + }); + + test("inspecting a compiled class with static fields through unknown refuses", async () => { + // Node prints `[class S] { count: 3 }`; the class object does not + // carry compiled static fields, so the inspector refuses rather than + // print `[class S]`. A class without static fields still prints. + const r = await compileAndRun( + "inspect-class-statics", + `class Plain {} +class S extends Plain { + static count = 3; +} +const plain: unknown = Plain; +const s: unknown = S; +console.log(plain); +try { + console.log(s); +} catch (e) { + console.log((e as Error).message); +} +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + "[class Plain]\nutil.inspect of a class with static fields through unknown is not supported yet\n", + ); + }); + + test("%o refuses the dynamic kinds whose hidden entries have no rendering", async () => { + // Node's showHidden adds a function's length/name/prototype, an + // error's stack and a typed array's byte fields; %o throws the 'not + // supported yet' Error for them instead of printing without them. + const r = await compileAndRun( + "format-o-hidden-refusals", + `import { format } from "node:util"; +function f(): void {} +const values: [string, unknown][] = [["fn", f], ["err", new Error("e")], ["u8", new Uint8Array(1)], ["arr", [1]]]; +for (const [label, value] of values) { + try { + console.log(label, format("%o", value)); + } catch (e) { + console.log(label, (e as Error).message); + } +} +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "fn util.format %o of function values is not supported yet", + "err util.format %o of Error values is not supported yet", + "u8 util.format %o of typed array values is not supported yet", + "arr [ 1, [length]: 1 ]", + "", + ].join("\n"), + ); + }); }); From 35f2d8f791022b779972e54a55d2b369ab657fef Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 13:07:47 -0500 Subject: [PATCH 02/44] Report dictionary copies of `{}` views, and keep method calls direct after writes into dictionaries of dictionaries Fixes #1408 Co-authored-by: Chris Ainsley --- .../record-identity-and-property-order.mdx | 1 + .../compiler/src/diagnostics/diagnostic.ts | 10 +- .../lowering/class-method-mutations.ts | 16 ++- .../src/frontend/lowering/divergences.ts | 110 +++++++++++++++++- .../test/class-method-mutations.test.ts | 42 +++++++ ...onary-of-dictionaries-keep-methods.ts.json | 6 + ...dictionary-of-dictionaries-keep-methods.ts | 38 ++++++ .../dictionary-view-copies.ts | 29 +++++ tests/coverage-fixtures/no-divergences.ts | 11 +- .../coverage-dictionary-view-copies.txt | 21 ++++ ...onary-of-dictionaries-keep-methods.ts.json | 1 + tests/harness/coverage.test.ts | 14 +++ 12 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts.json create mode 100644 tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts create mode 100644 tests/coverage-fixtures/dictionary-view-copies.ts create mode 100644 tests/harness/__snapshots__/coverage-dictionary-view-copies.txt create mode 100644 tests/harness/corpus-costs/7010-dictionary-of-dictionaries-keep-methods.ts.json diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index 0381803f6a..224757d526 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -1,6 +1,7 @@ A record converted to a record type with a different set of properties, such as a narrower shape or one that adds optional properties, is copied. Mutations through the converted reference do not affect the original, and the copy is not `===` to it. Empty record layouts are also snapshots because they have no property storage. +A class instance or record held as `{}`, `object` or `unknown` and then converted to a dictionary type such as `Record` is copied as well; a dictionary of `unknown` values keeps the original object. `scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal. Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 17cb2efc09..47db2193b3 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -129,6 +129,7 @@ export function widthCopyDivergenceDiag( useText: string, site: "call" | "declaration" | "cast" | "element", loc: SrcLoc, + dictionary = false, ): ScrDiagnostic { const how = site === "call" @@ -142,11 +143,16 @@ export function widthCopyDivergenceDiag( use === "write" ? `the write to '${useText}' updates only the copy, while under Node it updates the original object` : `'${useText}' compares the copy, which is never the original object, while under Node it is the same object`; + const cause = dictionary + ? "converting an object held as '{}', 'object' or 'unknown' to a dictionary type" + : "converting to a record type with other properties"; return { code: "SC6001", - message: `${how} copies the object (converting to a record type with other properties compiles as a field copy): ${effect}`, + message: `${how} copies the object (${cause} compiles as a field copy): ${effect}`, loc, - hint: `declare the parameter or binding with the value's full type ('${sourceType}'), or make the shape a class — class instances keep their identity when passed as a base class`, + hint: dictionary + ? `keep the value at its own type ('${sourceType}'), or declare the dictionary's values as 'unknown', which keeps the original object` + : `declare the parameter or binding with the value's full type ('${sourceType}'), or make the shape a class — class instances keep their identity when passed as a base class`, }; } diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index abbaf3a754..7629377d47 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -125,9 +125,12 @@ export function collectClassMethodMutations( }; // Whether no function value can inhabit `type`. Primitives qualify, and so // do non-callable object types a function is not assignable to: one that - // requires a property functions lack, or a weak (all-optional) type that + // requires a property functions lack, a weak (all-optional) type that // shares no property with functions (TypeScript's weak-type rule rejects - // callable sources too). + // callable sources too), or a dictionary whose string index values are + // neither `any` nor a type parameter: a function type has no index + // signature, and only an `any` index admits a source without one (an + // instantiation can make a type parameter `any`). const functionFree = (type: ts.Type): boolean => { if (type.isUnionType()) return ts.constituentTypes(type).every(functionFree); if ( @@ -149,7 +152,14 @@ export function collectClassMethodMutations( ) return false; const properties = lowerer.checker.getPropertiesOfType(type); - if (properties.length === 0) return false; + if (properties.length === 0) + return lowerer.checker + .getIndexInfosOfType(type) + .some( + (index) => + (index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && + (index.type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Instantiable)) === 0, + ); const required = properties.filter( (property) => (property.flags & ts.SymbolFlags.Optional) === 0, ); diff --git a/packages/compiler/src/frontend/lowering/divergences.ts b/packages/compiler/src/frontend/lowering/divergences.ts index 1c362cb09f..f766aba14a 100644 --- a/packages/compiler/src/frontend/lowering/divergences.ts +++ b/packages/compiler/src/frontend/lowering/divergences.ts @@ -180,7 +180,15 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, arg, param.type!)); if (copy === undefined || copy === null) return; out.push( - widthCopyDivergenceDiag(copy.source, copy.target, use.kind, use.text, "call", locOf(arg)), + widthCopyDivergenceDiag( + copy.source, + copy.target, + use.kind, + use.text, + "call", + locOf(arg), + copy.dictionary, + ), ); }); } @@ -200,6 +208,7 @@ export function scanDivergences( use.text, "declaration", locOf(decl.initializer!), + copy.dictionary, ), ); } @@ -215,7 +224,15 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, source, type)); if (copy === undefined || copy === null) continue; out.push( - widthCopyDivergenceDiag(copy.source, copy.target, use.kind, use.text, "cast", locOf(source)), + widthCopyDivergenceDiag( + copy.source, + copy.target, + use.kind, + use.text, + "cast", + locOf(source), + copy.dictionary, + ), ); } for (const assignment of assignments) { @@ -240,6 +257,7 @@ export function scanDivergences( use.text, "declaration", locOf(assignment.right), + copy.dictionary, ), ); } @@ -254,7 +272,15 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, item, element)); if (copy === undefined || copy === null) continue; out.push( - widthCopyDivergenceDiag(copy.source, copy.target, "write", use, "element", locOf(item)), + widthCopyDivergenceDiag( + copy.source, + copy.target, + "write", + use, + "element", + locOf(item), + copy.dictionary, + ), ); break; } @@ -653,14 +679,23 @@ function enclosingScope(node: ts.Node): ts.Node | undefined { * instance flowing into a plain record type that does not keep class * identity (ClassProtocols marks the targets a class instance visibly * flows into; a value reaching another record type is a field copy). */ +/** The types a width copy converts between, and whether the target is a + * dictionary. */ +interface WidthCopy { + source: string; + target: string; + dictionary?: boolean; +} + function widthCopyBetween( lowerer: Lowerer, source: ts.Expression, targetNode: ts.TypeNode, -): { source: string; target: string } | null { +): WidthCopy | null { const checker = lowerer.checker; const s = checker.getTypeAtLocation(source); const t = checker.getTypeFromTypeNode(targetNode); + if (isDictionaryType(checker, t)) return dictionaryViewCopy(lowerer, source, s, t); if (!isPlainRecordType(checker, t)) return null; const classSource = isProgramClassType(checker, s); if (!classSource && !isPlainRecordType(checker, s)) return null; @@ -680,6 +715,73 @@ function widthCopyBetween( return { source: checker.typeToString(s), target: checker.typeToString(t) }; } +/** A copy made by converting a checked-dynamic view (`{}`, `object`, + * `unknown`) to a dictionary type that compiles to a native record (a + * dictionary of `unknown` values stays checked-dynamic and keeps the + * object). A dictionary of that type comes back out of the view as itself, + * so a copy is reported only when the view is a constant binding + * initialized with a class instance or a record: that object is converted + * field by field into a new dictionary. */ +function dictionaryViewCopy( + lowerer: Lowerer, + source: ts.Expression, + s: ts.Type, + t: ts.Type, +): WidthCopy | null { + const checker = lowerer.checker; + if (!isDynamicViewType(checker, s)) return null; + if (lowerer.mapTypeOf(t)?.kind !== "record") return null; + while (ts.isParenthesizedExpression(source) || ts.isNonNullExpression(source)) + source = source.expression; + if (!ts.isIdentifier(source)) return null; + const decl = checker.getSymbolAtLocation(source)?.valueDeclaration; + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + decl.initializer === undefined || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & ts.NodeFlags.Const) + ) + return null; + let origin = decl.initializer; + while ( + ts.isParenthesizedExpression(origin) || + ts.isNonNullExpression(origin) || + ts.isAsExpression(origin) || + ts.isTypeAssertion(origin) + ) + origin = origin.expression; + const o = checker.getTypeAtLocation(origin); + if (!isProgramClassType(checker, o) && !isPlainRecordType(checker, o)) return null; + return { source: checker.typeToString(o), target: checker.typeToString(t), dictionary: true }; +} + +/** `{}`, `object` and `unknown`: types whose values lower to checked + * dynamic storage that keeps the original object. */ +function isDynamicViewType(checker: ts.TypeChecker, type: ts.Type): boolean { + if (type.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) return true; + return ( + (type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + checker.getIndexInfosOfType(type).length === 0 && + checker.getCallSignatures(type).length === 0 && + checker.getConstructSignatures(type).length === 0 + ); +} + +/** A string-keyed dictionary with no declared properties + * (`Record`, `{ [key: string]: number }`). */ +function isDictionaryType(checker: ts.TypeChecker, type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + if (checker.isArrayType(type) || checker.isTupleType(type)) return false; + if (checker.getCallSignatures(type).length > 0) return false; + if (checker.getConstructSignatures(type).length > 0) return false; + if (checker.getPropertiesOfType(type).length > 0) return false; + return checker + .getIndexInfosOfType(type) + .some((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0); +} + function isProgramClassType(checker: ts.TypeChecker, type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const symbol = type.getSymbol(); diff --git a/packages/compiler/test/class-method-mutations.test.ts b/packages/compiler/test/class-method-mutations.test.ts index b508a39495..3221e44ee6 100644 --- a/packages/compiler/test/class-method-mutations.test.ts +++ b/packages/compiler/test/class-method-mutations.test.ts @@ -135,3 +135,45 @@ test("a reflection call with a well-known symbol key keeps checked dispatch", as const callers = await callersOf(`${range}${write}`, "%Range.sym:iterator"); expect(callers.filter((name) => !name.startsWith("%method.value:"))).toEqual([]); }); + +const groups = ` +class Tally { + n = 0; + add(): void { this.n++; } +} +interface Settings { groups: VALUE } +function addGroup(settings: Settings, name: string): void { + settings.groups[name] = {}; +} +const settings: Settings = { groups: {} }; +const tally = new Tally(); +addGroup(settings, String(tally.n)); +tally.add(); +console.log(tally.n, Object.keys(settings.groups)); +`; + +test("a computed write into a dictionary of dictionaries leaves method calls direct", async () => { + const source = groups.replace("VALUE", "Record>"); + expect(await callersOf(source, "%dyn.class.properties")).toEqual([]); +}); + +test("a computed write into a dictionary that can hold functions keeps checked dispatch", async () => { + for (const value of [ + "Record | (() => void)>", + "Record>", + ]) { + const source = groups.replace("VALUE", value); + expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]); + } +}); + +test("a computed write into a generic dictionary of dictionaries keeps checked dispatch", async () => { + const source = groups + .replace( + "interface Settings { groups: VALUE }", + "interface Settings { groups: Record> }", + ) + .replace("function addGroup(settings: Settings,", "function addGroup(settings: Settings,") + .replace("const settings: Settings =", "const settings: Settings ="); + expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts.json new file mode 100644 index 0000000000..cc3c9dabe6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts b/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts new file mode 100644 index 0000000000..a53e82f839 --- /dev/null +++ b/tests/corpus/7010-dictionary-of-dictionaries-keep-methods.ts @@ -0,0 +1,38 @@ +// A string-keyed dictionary whose values are dictionaries cannot hold a +// function, so storing into it under a key known only at runtime leaves +// class methods in place: their calls stay direct, and a record a method +// returns is the record the instance holds, so writes through it are kept. +interface Data { + items: string[]; +} +class Store { + data: Data = { items: [] }; + get(): Data { + return this.data; + } +} +class Counter { + count = 0; + bump(): number { + return ++this.count; + } +} +interface Settings { + groups: Record>; +} +function addGroup(settings: Settings, name: string): void { + settings.groups[name] = {}; +} + +const settings: Settings = { groups: {} }; +const name = process.argv.length > 99 ? "never" : "colours"; +addGroup(settings, name); +const group = settings.groups[name]; +if (group !== undefined) group["sky"] = "blue"; +const store = new Store(); +store.get().items.push("a"); +store.get().items.push("b"); +const counter = new Counter(); +let total = 0; +for (let i = 0; i < 1000; i++) total += counter.bump(); +console.log(JSON.stringify(settings.groups), store.data.items.join(","), total); diff --git a/tests/coverage-fixtures/dictionary-view-copies.ts b/tests/coverage-fixtures/dictionary-view-copies.ts new file mode 100644 index 0000000000..bfcfa61f44 --- /dev/null +++ b/tests/coverage-fixtures/dictionary-view-copies.ts @@ -0,0 +1,29 @@ +// A class instance or record held as `{}`, `object` or `unknown` keeps the +// original object, and converting it to a dictionary type copies it field +// by field. Each site below then writes through or compares the copy. +class Counter { + label = "c"; + add(): number { + return this.label.length; + } +} +const key = process.argv.length > 5 ? "z" : "b"; + +const counter = new Counter(); +const view: {} = counter; +const table: Record = view; +table[key] = "y"; + +const point = { a: "x" }; +const loose: object = point; +const dict = loose as Record; +dict[key] = "y"; + +const held: unknown = { a: "x" }; +const fromUnknown = held as Record; +console.log(fromUnknown === held); + +const empty: {} = {}; +const filled: Record = empty; +filled[key] = "y"; +console.log(JSON.stringify(counter), JSON.stringify(point), JSON.stringify(empty)); diff --git a/tests/coverage-fixtures/no-divergences.ts b/tests/coverage-fixtures/no-divergences.ts index 3739336e3c..ca76bcc324 100644 --- a/tests/coverage-fixtures/no-divergences.ts +++ b/tests/coverage-fixtures/no-divergences.ts @@ -1,6 +1,6 @@ // Ordinary code: identity-preserving calls, declaration-ordered literals, -// Latin-script comparisons, related-class casts, ISO dates and views of the -// global object report no divergence. +// Latin-script comparisons, related-class casts, ISO dates, views of the +// global object and a dictionary held as `{}` report no divergence. interface Item { name: string; qty: number; @@ -38,3 +38,10 @@ const COUNT = Symbol.for("no-divergences.count"); const globalState = globalThis as { [COUNT]?: { n: number } }; globalState[COUNT] = { n: 1 }; console.log(globalState[COUNT]?.n); + +// A dictionary held as `{}` comes back out as itself. +const counts: Record = { a: "x" }; +const opaque: {} = counts; +const again: Record = opaque; +again[counts["a"] ?? "a"] = "y"; +console.log(JSON.stringify(counts)); diff --git a/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt b/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt new file mode 100644 index 0000000000..2359a4ffa8 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt @@ -0,0 +1,21 @@ +scriptc coverage tests/coverage-fixtures/dictionary-view-copies.ts + + statements analyzed 18 + compile statically 18 (100%) + in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) + + fully static — all analyzed statements compile to native code. + + differs from Node 4 sites (compiles; the native program can behave differently) + ×1 SC6001 storing a 'Counter' in a 'Record<…>' binding copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'table[key]' upda… + at tests/coverage-fixtures/dictionary-view-copies.ts:14:39 + hint: keep the value at its own type ('Counter'), or declare the dictionary's values as 'unknown', which keeps the original object + ×1 SC6001 storing a '{}' in a 'Record<…>' binding copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'filled[key]' updates … + at tests/coverage-fixtures/dictionary-view-copies.ts:27:40 + hint: keep the value at its own type ('{}'), or declare the dictionary's values as 'unknown', which keeps the original object + ×1 SC6001 asserting a '{ a: string; }' as 'Record<…>' copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'dict[key]' update… + at tests/coverage-fixtures/dictionary-view-copies.ts:19:14 + hint: keep the value at its own type ('{ a: string; }'), or declare the dictionary's values as 'unknown', which keeps the original object + ×1 SC6001 asserting a '{ a: string; }' as 'Record<…>' copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): 'fromUnknown === held' compares… + at tests/coverage-fixtures/dictionary-view-copies.ts:23:21 + hint: keep the value at its own type ('{ a: string; }'), or declare the dictionary's values as 'unknown', which keeps the original object \ No newline at end of file diff --git a/tests/harness/corpus-costs/7010-dictionary-of-dictionaries-keep-methods.ts.json b/tests/harness/corpus-costs/7010-dictionary-of-dictionaries-keep-methods.ts.json new file mode 100644 index 0000000000..cc07613e75 --- /dev/null +++ b/tests/harness/corpus-costs/7010-dictionary-of-dictionaries-keep-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2184, "san": 1198 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 86d202b461..f3958c5fe7 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -107,6 +107,20 @@ test("record width copies report at every storing site", async () => { ); }); +test("dictionary conversions of checked-dynamic views report copies", async () => { + const { coverage } = analyze(fixture("dictionary-view-copies.ts")); + expect(coverage.diagnostics).toEqual([]); + expect((coverage.divergences ?? []).map((d) => d.code)).toEqual([ + "SC6001", + "SC6001", + "SC6001", + "SC6001", + ]); + await expect(report(fixture("dictionary-view-copies.ts"))).toMatchFileSnapshot( + "__snapshots__/coverage-dictionary-view-copies.txt", + ); +}); + test("ordinary code reports no divergence", () => { const { coverage } = analyze(fixture("no-divergences.ts")); expect(coverage.diagnostics).toEqual([]); From 961aad6ac99b9b3cf8d101c24415cb5b60d9a616 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:37:10 -0500 Subject: [PATCH 03/44] Keep the identity of records and class instances converted to dictionaries A record or class instance converted to a string-keyed dictionary type (`Record`, `{ [key: string]: V }`) was copied field by field into a native dictionary record, directly, through a dictionary parameter, or through a `{}`, `object` or `unknown` binding or parameter. Writes through the dictionary missed the original object, and the dictionary was never `===` to it, where under Node it is the same object. ClassProtocols now marks a dictionary value type when a dictionary of that value type receives an existing object (a binding, member, element or `this` whose type is a program class instance, a record with declared properties, or a `{}`, `object` or `unknown` binding or parameter that holds one), and the program writes through, compares or passes to a whole-object observer a dictionary of that value type. usesCheckedIdentity answers true for those dictionaries, so they lower to checked-dynamic storage, which holds the original object. Dictionaries of `unknown` or `any` values are checked-dynamic already and are not marked. The dictionary case of SC6001 is removed, since these conversions now keep the object. --- .../record-identity-and-property-order.mdx | 3 +- .../compiler/src/diagnostics/diagnostic.ts | 10 +- .../src/frontend/lowering/class-protocols.ts | 205 +++++++++++++++++- .../src/frontend/lowering/divergences.ts | 110 +--------- .../compiler/test/dictionary-views.test.ts | 77 +++++++ ...7012-dictionary-views-keep-objects.ts.json | 6 + .../7013-dictionary-view-parameter.ts.json | 6 + .../7012-dictionary-views-keep-objects.ts | 61 ++++++ .../corpus/7013-dictionary-view-parameter.ts | 11 + .../dictionary-view-copies.ts | 29 --- tests/coverage-fixtures/no-divergences.ts | 7 + .../coverage-dictionary-view-copies.txt | 21 -- ...7012-dictionary-views-keep-objects.ts.json | 1 + .../7013-dictionary-view-parameter.ts.json | 1 + tests/harness/coverage.test.ts | 14 -- 15 files changed, 382 insertions(+), 180 deletions(-) create mode 100644 packages/compiler/test/dictionary-views.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7012-dictionary-views-keep-objects.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7013-dictionary-view-parameter.ts.json create mode 100644 tests/corpus/7012-dictionary-views-keep-objects.ts create mode 100644 tests/corpus/7013-dictionary-view-parameter.ts delete mode 100644 tests/coverage-fixtures/dictionary-view-copies.ts delete mode 100644 tests/harness/__snapshots__/coverage-dictionary-view-copies.txt create mode 100644 tests/harness/corpus-costs/7012-dictionary-views-keep-objects.ts.json create mode 100644 tests/harness/corpus-costs/7013-dictionary-view-parameter.ts.json diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index 224757d526..2299122a4c 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -1,10 +1,11 @@ A record converted to a record type with a different set of properties, such as a narrower shape or one that adds optional properties, is copied. Mutations through the converted reference do not affect the original, and the copy is not `===` to it. Empty record layouts are also snapshots because they have no property storage. -A class instance or record held as `{}`, `object` or `unknown` and then converted to a dictionary type such as `Record` is copied as well; a dictionary of `unknown` values keeps the original object. `scriptc coverage` and `scriptc build` report `SC6001` where a copied value is then written or compared, whether the record was passed as an argument, stored in a typed binding, asserted with `as`, assigned to a typed binding later, or stored as an element of a typed array literal. Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. +A record or class instance converted to a string-keyed dictionary type such as `Record`, directly or through a `{}`, `object` or `unknown` binding or parameter, keeps its identity when the program writes through, compares or inspects values of that dictionary type. +Every dictionary type with the same value type then uses checked dynamic storage, which is slower than a native dictionary. Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js. These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects. A type whose values are only read field by field, from fields the class does not write after construction and does not implement as accessors, stays a native record and holds a copy. diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 47db2193b3..17cb2efc09 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -129,7 +129,6 @@ export function widthCopyDivergenceDiag( useText: string, site: "call" | "declaration" | "cast" | "element", loc: SrcLoc, - dictionary = false, ): ScrDiagnostic { const how = site === "call" @@ -143,16 +142,11 @@ export function widthCopyDivergenceDiag( use === "write" ? `the write to '${useText}' updates only the copy, while under Node it updates the original object` : `'${useText}' compares the copy, which is never the original object, while under Node it is the same object`; - const cause = dictionary - ? "converting an object held as '{}', 'object' or 'unknown' to a dictionary type" - : "converting to a record type with other properties"; return { code: "SC6001", - message: `${how} copies the object (${cause} compiles as a field copy): ${effect}`, + message: `${how} copies the object (converting to a record type with other properties compiles as a field copy): ${effect}`, loc, - hint: dictionary - ? `keep the value at its own type ('${sourceType}'), or declare the dictionary's values as 'unknown', which keeps the original object` - : `declare the parameter or binding with the value's full type ('${sourceType}'), or make the shape a class — class instances keep their identity when passed as a base class`, + hint: `declare the parameter or binding with the value's full type ('${sourceType}'), or make the shape a class — class instances keep their identity when passed as a base class`, }; } diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 76091b13e5..4edf1ab7e8 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -10,6 +10,9 @@ export class ClassProtocols { private readonly accepted = new Map(); /** What markIdentityFlows learned about the program, while it runs. */ private facts: FlowFacts | null = null; + /** The value types of the string-keyed dictionaries that keep checked + * identity (see markDictionaryViews). */ + private readonly dictionaryViews = new Set(); constructor( private readonly checker: ts.TypeChecker, @@ -323,6 +326,7 @@ export class ClassProtocols { else if (names !== "all") names.add(name); } } + this.markDictionaryViews(sites, sourceNodes, observedNodes, writeNodes); for (let i = 0; i < looseArguments.length; i++) { const type = typeOf(looseNodes[i]!); if (type === undefined) continue; @@ -697,6 +701,163 @@ export class ClassProtocols { return source !== undefined && this.isProgramClassInstance(source); } + /** String-keyed dictionaries (`Record`, `{ [key: string]: V }`) + * that receive an existing record or class instance. Natively a + * dictionary is a key/value record, and converting another object into + * one copies its fields, so writes through the dictionary and identity + * checks would miss the original. When the program writes through, or + * observes, a value of such a dictionary type, every dictionary with that + * value type keeps checked identity instead (records with the same value + * type share one native layout, so values pass between them). A source + * counts when it is an existing object (a binding, member, element or + * `this`, not a fresh literal or call result) whose type is a program + * class instance or a record with declared properties, or a constant + * `{}`, `object` or `unknown` binding initialized with one. */ + private markDictionaryViews( + sites: readonly { + source: ts.Expression; + target: () => ts.Type | undefined; + contextual?: ts.Expression; + }[], + sourceNodes: readonly ts.Expression[], + observedNodes: readonly ts.Expression[], + writeNodes: readonly ts.Expression[], + ): void { + // The calls the sites' arguments belong to, by callee name, built on + // first use. + let calls: Map | null = null; + const callsNamed = (name: string): readonly (ts.CallExpression | ts.NewExpression)[] => { + if (calls === null) { + calls = new Map(); + for (const site of sites) { + const call = site.contextual?.parent; + if (call === undefined || (!ts.isCallExpression(call) && !ts.isNewExpression(call))) + continue; + const callee = call.expression; + const named = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : undefined; + if (named === undefined) continue; + const list = calls.get(named) ?? []; + if (!list.includes(call)) list.push(call); + calls.set(named, list); + } + } + return calls.get(name) ?? []; + }; + const candidates: { index: number; sourceType: ts.Type }[] = []; + for (let i = 0; i < sites.length; i++) { + const source = sourceNodes[i]!; + if (!isExistingObject(source)) continue; + const sourceType = attempt(() => this.checker.getTypeAtLocation(source)); + if (sourceType !== undefined && this.dictionarySource(source, sourceType, callsNamed)) + candidates.push({ index: i, sourceType }); + } + if (candidates.length === 0) return; + // One batch for the contextual targets of the candidate sites. + attempt(() => + this.checker.prefetchContextualTypes( + candidates.flatMap(({ index }) => { + const contextual = sites[index]!.contextual; + return contextual === undefined ? [] : [contextual]; + }), + ), + ); + const received = new Set(); + for (const { index, sourceType } of candidates) { + const target = attempt(sites[index]!.target); + const parts = + target === undefined ? [] : target.isUnionType() ? ts.constituentTypes(target) : [target]; + for (const part of parts) { + const value = this.dictionaryValueType(part); + // A dictionary of `unknown` or `any` values is checked-dynamic already. + if ( + value !== undefined && + (value.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) === 0 && + this.dictionaryValueType(sourceType) !== value + ) + received.add(value); + } + } + if (received.size === 0) return; + // The dictionary values the program writes through or observes. + const seen = new Set(); + for (const node of [...writeNodes, ...observedNodes]) { + const type = attempt(() => this.checker.getTypeAtLocation(node)); + const parts = + type === undefined ? [] : type.isUnionType() ? ts.constituentTypes(type) : [type]; + for (const part of parts) { + const value = this.dictionaryValueType(part); + if (value !== undefined) seen.add(value); + } + } + for (const value of received) if (seen.has(value)) this.dictionaryViews.add(value); + } + + /** Whether `source` holds an existing object that a dictionary + * conversion would copy. */ + private dictionarySource( + source: ts.Expression, + type: ts.Type, + callsNamed: (name: string) => readonly (ts.CallExpression | ts.NewExpression)[], + ): boolean { + if (this.isProgramClassInstance(type) || this.isRecordWithFields(type)) return true; + if (!isDynamicView(this.checker, type) || !ts.isIdentifier(source)) return false; + const decl = attempt(() => this.checker.getSymbolAtLocation(source))?.valueDeclaration; + if (decl === undefined) return false; + const holdsObject = (expr: ts.Expression): boolean => { + const held = attempt(() => this.checker.getTypeAtLocation(this.flowSourceNode(expr))); + return ( + held !== undefined && (this.isProgramClassInstance(held) || this.isRecordWithFields(held)) + ); + }; + if (ts.isParameter(decl)) { + // A parameter holds what its callers pass: the calls of the function + // by name whose signature resolves to it. + const fn = decl.parent; + if (fn === undefined || !ts.isFunctionLike(fn)) return false; + const index = fn.parameters.nodes.indexOf(decl); + const name = functionName(fn); + if (name === undefined || index < 0) return false; + return callsNamed(name).some((call) => { + const argument = call.arguments?.nodes[index]; + return ( + argument !== undefined && + !ts.isSpreadElement(argument) && + attempt(() => this.checker.getResolvedSignature(call)?.declaration) === fn && + holdsObject(argument) + ); + }); + } + return ( + ts.isVariableDeclaration(decl) && + decl.initializer !== undefined && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 && + holdsObject(decl.initializer) + ); + } + + /** A program object type with declared properties that is not a class + * instance, array, tuple, function or library type. */ + private isRecordWithFields(type: ts.Type): boolean { + return this.isStructuralTarget(type) && this.checker.getPropertiesOfType(type).length > 0; + } + + /** The value type of a string-keyed dictionary with no declared + * properties, or undefined for any other type. */ + private dictionaryValueType(type: ts.Type): ts.Type | undefined { + if ((type.flags & ts.TypeFlags.Object) === 0) return undefined; + if (this.checker.isArrayType(type) || this.checker.isTupleType(type)) return undefined; + if (this.checker.getCallSignatures(type).length > 0) return undefined; + if (this.checker.getPropertiesOfType(type).length > 0) return undefined; + return this.checker + .getIndexInfosOfType(type) + .find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type; + } + private isProgramClassInstance(type: ts.Type): boolean { const symbol = type.getSymbol(); return ( @@ -822,7 +983,11 @@ export class ClassProtocols { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const previous = this.answers.get(type); if (previous !== undefined) return previous; - const answer = this.protocol(type); + const dictionaryValue = this.dictionaryValueType(type); + const answer = + dictionaryValue !== undefined + ? (this.index(), this.dictionaryViews.has(dictionaryValue)) + : this.protocol(type); this.answers.set(type, answer); return answer; } @@ -922,6 +1087,44 @@ const IDENTITY_METHODS = new Set([ "includes", ]); +/** An expression that denotes an object that already exists, not a fresh + * literal or call result. */ +function isExistingObject(node: ts.Expression): boolean { + return ( + ts.isIdentifier(node) || + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) || + node.kind === ts.SyntaxKind.ThisKeyword + ); +} + +/** `{}`, `object` and `unknown`: types whose values are checked-dynamic + * and keep the original object. */ +function isDynamicView(checker: ts.TypeChecker, type: ts.Type): boolean { + if (type.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) return true; + return ( + (type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + checker.getIndexInfosOfType(type).length === 0 && + checker.getCallSignatures(type).length === 0 && + checker.getConstructSignatures(type).length === 0 + ); +} + +/** The name calls use for a function, method or function-valued binding. */ +function functionName(fn: ts.Node): string | undefined { + if ((ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name !== undefined) + return ts.isIdentifier(fn.name) ? fn.name.text : undefined; + if ( + (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && + fn.parent !== undefined && + ts.isVariableDeclaration(fn.parent) && + ts.isIdentifier(fn.parent.name) + ) + return fn.parent.name.text; + return undefined; +} + function isNullishLiteral(node: ts.Expression): boolean { while (ts.isParenthesizedExpression(node)) node = node.expression; return ( diff --git a/packages/compiler/src/frontend/lowering/divergences.ts b/packages/compiler/src/frontend/lowering/divergences.ts index f766aba14a..1c362cb09f 100644 --- a/packages/compiler/src/frontend/lowering/divergences.ts +++ b/packages/compiler/src/frontend/lowering/divergences.ts @@ -180,15 +180,7 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, arg, param.type!)); if (copy === undefined || copy === null) return; out.push( - widthCopyDivergenceDiag( - copy.source, - copy.target, - use.kind, - use.text, - "call", - locOf(arg), - copy.dictionary, - ), + widthCopyDivergenceDiag(copy.source, copy.target, use.kind, use.text, "call", locOf(arg)), ); }); } @@ -208,7 +200,6 @@ export function scanDivergences( use.text, "declaration", locOf(decl.initializer!), - copy.dictionary, ), ); } @@ -224,15 +215,7 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, source, type)); if (copy === undefined || copy === null) continue; out.push( - widthCopyDivergenceDiag( - copy.source, - copy.target, - use.kind, - use.text, - "cast", - locOf(source), - copy.dictionary, - ), + widthCopyDivergenceDiag(copy.source, copy.target, use.kind, use.text, "cast", locOf(source)), ); } for (const assignment of assignments) { @@ -257,7 +240,6 @@ export function scanDivergences( use.text, "declaration", locOf(assignment.right), - copy.dictionary, ), ); } @@ -272,15 +254,7 @@ export function scanDivergences( const copy = safely(() => widthCopyBetween(lowerer, item, element)); if (copy === undefined || copy === null) continue; out.push( - widthCopyDivergenceDiag( - copy.source, - copy.target, - "write", - use, - "element", - locOf(item), - copy.dictionary, - ), + widthCopyDivergenceDiag(copy.source, copy.target, "write", use, "element", locOf(item)), ); break; } @@ -679,23 +653,14 @@ function enclosingScope(node: ts.Node): ts.Node | undefined { * instance flowing into a plain record type that does not keep class * identity (ClassProtocols marks the targets a class instance visibly * flows into; a value reaching another record type is a field copy). */ -/** The types a width copy converts between, and whether the target is a - * dictionary. */ -interface WidthCopy { - source: string; - target: string; - dictionary?: boolean; -} - function widthCopyBetween( lowerer: Lowerer, source: ts.Expression, targetNode: ts.TypeNode, -): WidthCopy | null { +): { source: string; target: string } | null { const checker = lowerer.checker; const s = checker.getTypeAtLocation(source); const t = checker.getTypeFromTypeNode(targetNode); - if (isDictionaryType(checker, t)) return dictionaryViewCopy(lowerer, source, s, t); if (!isPlainRecordType(checker, t)) return null; const classSource = isProgramClassType(checker, s); if (!classSource && !isPlainRecordType(checker, s)) return null; @@ -715,73 +680,6 @@ function widthCopyBetween( return { source: checker.typeToString(s), target: checker.typeToString(t) }; } -/** A copy made by converting a checked-dynamic view (`{}`, `object`, - * `unknown`) to a dictionary type that compiles to a native record (a - * dictionary of `unknown` values stays checked-dynamic and keeps the - * object). A dictionary of that type comes back out of the view as itself, - * so a copy is reported only when the view is a constant binding - * initialized with a class instance or a record: that object is converted - * field by field into a new dictionary. */ -function dictionaryViewCopy( - lowerer: Lowerer, - source: ts.Expression, - s: ts.Type, - t: ts.Type, -): WidthCopy | null { - const checker = lowerer.checker; - if (!isDynamicViewType(checker, s)) return null; - if (lowerer.mapTypeOf(t)?.kind !== "record") return null; - while (ts.isParenthesizedExpression(source) || ts.isNonNullExpression(source)) - source = source.expression; - if (!ts.isIdentifier(source)) return null; - const decl = checker.getSymbolAtLocation(source)?.valueDeclaration; - if ( - decl === undefined || - !ts.isVariableDeclaration(decl) || - decl.initializer === undefined || - !ts.isVariableDeclarationList(decl.parent) || - !(decl.parent.flags & ts.NodeFlags.Const) - ) - return null; - let origin = decl.initializer; - while ( - ts.isParenthesizedExpression(origin) || - ts.isNonNullExpression(origin) || - ts.isAsExpression(origin) || - ts.isTypeAssertion(origin) - ) - origin = origin.expression; - const o = checker.getTypeAtLocation(origin); - if (!isProgramClassType(checker, o) && !isPlainRecordType(checker, o)) return null; - return { source: checker.typeToString(o), target: checker.typeToString(t), dictionary: true }; -} - -/** `{}`, `object` and `unknown`: types whose values lower to checked - * dynamic storage that keeps the original object. */ -function isDynamicViewType(checker: ts.TypeChecker, type: ts.Type): boolean { - if (type.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) return true; - return ( - (type.flags & ts.TypeFlags.Object) !== 0 && - checker.getPropertiesOfType(type).length === 0 && - checker.getIndexInfosOfType(type).length === 0 && - checker.getCallSignatures(type).length === 0 && - checker.getConstructSignatures(type).length === 0 - ); -} - -/** A string-keyed dictionary with no declared properties - * (`Record`, `{ [key: string]: number }`). */ -function isDictionaryType(checker: ts.TypeChecker, type: ts.Type): boolean { - if ((type.flags & ts.TypeFlags.Object) === 0) return false; - if (checker.isArrayType(type) || checker.isTupleType(type)) return false; - if (checker.getCallSignatures(type).length > 0) return false; - if (checker.getConstructSignatures(type).length > 0) return false; - if (checker.getPropertiesOfType(type).length > 0) return false; - return checker - .getIndexInfosOfType(type) - .some((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0); -} - function isProgramClassType(checker: ts.TypeChecker, type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const symbol = type.getSymbol(); diff --git a/packages/compiler/test/dictionary-views.test.ts b/packages/compiler/test/dictionary-views.test.ts new file mode 100644 index 0000000000..b841447cd4 --- /dev/null +++ b/packages/compiler/test/dictionary-views.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; + +/** The IR type kind of each named module-level binding. */ +async function globalKinds(source: string): Promise> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-dictionary-views-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + return Object.fromEntries((mod.globals ?? []).map((g) => [g.name, g.type.kind])); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const program = (body: string): string => ` +const key = String(process.argv.length); +const counts: Record = { a: 1 }; +counts[key] = 2; +${body} +console.log(JSON.stringify(counts)); +`; + +test("a record written through a dictionary keeps that dictionary type checked", async () => { + const kinds = await globalKinds( + program(`const point: { a: string } = { a: "x" }; +const table: Record = point; +table[key] = "y"; +console.log(JSON.stringify(point));`), + ); + expect(kinds["table"]).toBe("dyn"); + // Dictionaries of other value types keep their native layout. + expect(kinds["counts"]).toBe("record"); +}); + +test("a record reaching a dictionary through a {} parameter keeps it checked", async () => { + const kinds = await globalKinds( + program(`function stamp(value: {}): void { + const inner: Record = value; + inner[key] = "y"; +} +const point: { a: string } = { a: "x" }; +stamp(point); +const other: Record = { b: "z" }; +console.log(JSON.stringify(point), other["b"]);`), + ); + expect(kinds["other"]).toBe("dyn"); +}); + +test("dictionaries keep their native layout without a record flowing in", async () => { + const kinds = await globalKinds( + program(`const table: Record = { a: "x" }; +table[key] = "y"; +const copy: Record = table; +copy[key] = "z"; +console.log(JSON.stringify(table));`), + ); + expect(kinds["table"]).toBe("record"); + expect(kinds["copy"]).toBe("record"); +}); + +test("a record converted to a dictionary that is only read keeps the native layout", async () => { + const kinds = await globalKinds( + program(`const point: { a: string } = { a: "x" }; +const table: Record = point; +console.log(table["a"]);`), + ); + expect(kinds["table"]).toBe("record"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7012-dictionary-views-keep-objects.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7012-dictionary-views-keep-objects.ts.json new file mode 100644 index 0000000000..6d7944cf7e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7012-dictionary-views-keep-objects.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7012-dictionary-views-keep-objects.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7013-dictionary-view-parameter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7013-dictionary-view-parameter.ts.json new file mode 100644 index 0000000000..41f80c41e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7013-dictionary-view-parameter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7013-dictionary-view-parameter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7012-dictionary-views-keep-objects.ts b/tests/corpus/7012-dictionary-views-keep-objects.ts new file mode 100644 index 0000000000..8ede809267 --- /dev/null +++ b/tests/corpus/7012-dictionary-views-keep-objects.ts @@ -0,0 +1,61 @@ +// A record or class instance converted to a string-keyed dictionary is +// the same object: writes through the dictionary reach it, and the +// dictionary is === to it. +const key = process.argv.length > 99 ? "never" : "added"; +type Point = { a: string }; + +// A type-literal record stored straight in a dictionary binding. +const direct: Point = { a: "x" }; +const directTable: Record = direct; +directTable[key] = "y"; +console.log(JSON.stringify(direct), (directTable as unknown) === direct); + +// The same record through a `{}` view. +const viewed: Point = { a: "x" }; +const view: {} = viewed; +const viewTable: Record = view; +viewTable[key] = "y"; +console.log(JSON.stringify(viewed)); + +// A declared index type, and a parameter. +interface Labels { + [name: string]: string; +} +function label(table: Labels, name: string): void { + table[name] = "set"; +} +const passed: Point = { a: "x" }; +label(passed, key); +console.log(JSON.stringify(passed)); + +// A `{}` parameter converted inside the function, called with a record +// and with a dictionary. +function viaView(value: {}): void { + const table: Record = value; + table[key] = "y"; +} +const record: Point = { a: "x" }; +viaView(record); +const dictionary: Record = { a: "x" }; +viaView(dictionary); +console.log(JSON.stringify(record), JSON.stringify(dictionary)); + +// A class instance through a `{}` view: a computed write replaces a method. +class Greeter { + name = "g"; + greet(): string { + return "hello " + this.name; + } +} +const greeter = new Greeter(); +const greeterView: {} = greeter; +const slots: Record = greeterView; +slots[process.argv.length > 99 ? "never" : "greet"] = "replaced"; +slots["name"] = "renamed"; +console.log(typeof greeter.greet, greeter.name, JSON.stringify(greeter)); + +// Deleting through the dictionary removes the key from the object. +const pruned: { a: string; b?: string } = { a: "x", b: "y" }; +const prunedTable: Record = pruned; +delete prunedTable[process.argv.length > 99 ? "never" : "b"]; +console.log(JSON.stringify(pruned), "b" in pruned); diff --git a/tests/corpus/7013-dictionary-view-parameter.ts b/tests/corpus/7013-dictionary-view-parameter.ts new file mode 100644 index 0000000000..859f22c235 --- /dev/null +++ b/tests/corpus/7013-dictionary-view-parameter.ts @@ -0,0 +1,11 @@ +// A record passed to a `{}` parameter and converted to a dictionary inside +// the function is the same object: the write through the dictionary +// reaches the caller's record. +const key = process.argv.length > 99 ? "never" : "added"; +function stamp(value: {}): void { + const table: Record = value; + table[key] = "y"; +} +const record: { a: string } = { a: "x" }; +stamp(record); +console.log(JSON.stringify(record), key in record); diff --git a/tests/coverage-fixtures/dictionary-view-copies.ts b/tests/coverage-fixtures/dictionary-view-copies.ts deleted file mode 100644 index bfcfa61f44..0000000000 --- a/tests/coverage-fixtures/dictionary-view-copies.ts +++ /dev/null @@ -1,29 +0,0 @@ -// A class instance or record held as `{}`, `object` or `unknown` keeps the -// original object, and converting it to a dictionary type copies it field -// by field. Each site below then writes through or compares the copy. -class Counter { - label = "c"; - add(): number { - return this.label.length; - } -} -const key = process.argv.length > 5 ? "z" : "b"; - -const counter = new Counter(); -const view: {} = counter; -const table: Record = view; -table[key] = "y"; - -const point = { a: "x" }; -const loose: object = point; -const dict = loose as Record; -dict[key] = "y"; - -const held: unknown = { a: "x" }; -const fromUnknown = held as Record; -console.log(fromUnknown === held); - -const empty: {} = {}; -const filled: Record = empty; -filled[key] = "y"; -console.log(JSON.stringify(counter), JSON.stringify(point), JSON.stringify(empty)); diff --git a/tests/coverage-fixtures/no-divergences.ts b/tests/coverage-fixtures/no-divergences.ts index ca76bcc324..70409d3192 100644 --- a/tests/coverage-fixtures/no-divergences.ts +++ b/tests/coverage-fixtures/no-divergences.ts @@ -45,3 +45,10 @@ const opaque: {} = counts; const again: Record = opaque; again[counts["a"] ?? "a"] = "y"; console.log(JSON.stringify(counts)); + +// A record stored in a dictionary binding and written through it stays +// the same object. +const named: { first: string } = { first: "a" }; +const byName: Record = named; +byName[named.first] = "b"; +console.log(JSON.stringify(named)); diff --git a/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt b/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt deleted file mode 100644 index 2359a4ffa8..0000000000 --- a/tests/harness/__snapshots__/coverage-dictionary-view-copies.txt +++ /dev/null @@ -1,21 +0,0 @@ -scriptc coverage tests/coverage-fixtures/dictionary-view-copies.ts - - statements analyzed 18 - compile statically 18 (100%) - in dynamic tier 3 (operate on values typed only at runtime; --explain-dynamic lists them) - - fully static — all analyzed statements compile to native code. - - differs from Node 4 sites (compiles; the native program can behave differently) - ×1 SC6001 storing a 'Counter' in a 'Record<…>' binding copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'table[key]' upda… - at tests/coverage-fixtures/dictionary-view-copies.ts:14:39 - hint: keep the value at its own type ('Counter'), or declare the dictionary's values as 'unknown', which keeps the original object - ×1 SC6001 storing a '{}' in a 'Record<…>' binding copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'filled[key]' updates … - at tests/coverage-fixtures/dictionary-view-copies.ts:27:40 - hint: keep the value at its own type ('{}'), or declare the dictionary's values as 'unknown', which keeps the original object - ×1 SC6001 asserting a '{ a: string; }' as 'Record<…>' copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): the write to 'dict[key]' update… - at tests/coverage-fixtures/dictionary-view-copies.ts:19:14 - hint: keep the value at its own type ('{ a: string; }'), or declare the dictionary's values as 'unknown', which keeps the original object - ×1 SC6001 asserting a '{ a: string; }' as 'Record<…>' copies the object (converting an object held as '{}', 'object' or 'unknown' to a dictionary type compiles as a field copy): 'fromUnknown === held' compares… - at tests/coverage-fixtures/dictionary-view-copies.ts:23:21 - hint: keep the value at its own type ('{ a: string; }'), or declare the dictionary's values as 'unknown', which keeps the original object \ No newline at end of file diff --git a/tests/harness/corpus-costs/7012-dictionary-views-keep-objects.ts.json b/tests/harness/corpus-costs/7012-dictionary-views-keep-objects.ts.json new file mode 100644 index 0000000000..dcf40a9fae --- /dev/null +++ b/tests/harness/corpus-costs/7012-dictionary-views-keep-objects.ts.json @@ -0,0 +1 @@ +{ "plain": 2368, "san": 3286 } diff --git a/tests/harness/corpus-costs/7013-dictionary-view-parameter.ts.json b/tests/harness/corpus-costs/7013-dictionary-view-parameter.ts.json new file mode 100644 index 0000000000..9118817686 --- /dev/null +++ b/tests/harness/corpus-costs/7013-dictionary-view-parameter.ts.json @@ -0,0 +1 @@ +{ "plain": 1466, "san": 2392 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index f3958c5fe7..86d202b461 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -107,20 +107,6 @@ test("record width copies report at every storing site", async () => { ); }); -test("dictionary conversions of checked-dynamic views report copies", async () => { - const { coverage } = analyze(fixture("dictionary-view-copies.ts")); - expect(coverage.diagnostics).toEqual([]); - expect((coverage.divergences ?? []).map((d) => d.code)).toEqual([ - "SC6001", - "SC6001", - "SC6001", - "SC6001", - ]); - await expect(report(fixture("dictionary-view-copies.ts"))).toMatchFileSnapshot( - "__snapshots__/coverage-dictionary-view-copies.txt", - ); -}); - test("ordinary code reports no divergence", () => { const { coverage } = analyze(fixture("no-divergences.ts")); expect(coverage.diagnostics).toEqual([]); From c009b582e2da398c6e229fab779b9354325fae96 Mon Sep 17 00:00:00 2001 From: Luis Alvarez D Date: Thu, 22 Oct 2026 23:05:28 -0500 Subject: [PATCH 04/44] Support split CommonJS require bindings --- .../frontend/lowering/cjs-inline-require.ts | 323 ++++++++++++++++++ .../lowering/expressions/identifiers.ts | 26 ++ .../src/frontend/lowering/lower-modules.ts | 24 +- .../src/frontend/lowering/lower-stmts.ts | 52 +++ .../compiler/src/frontend/lowering/lowerer.ts | 23 +- .../split-cjs-require-binding/main.cjs.json | 12 + .../split-cjs-require-binding/chain-wrap.cjs | 3 + .../split-cjs-require-binding/chain.cjs | 1 + .../corpus/split-cjs-require-binding/lib.cjs | 5 + .../corpus/split-cjs-require-binding/main.cjs | 55 +++ .../split-cjs-require-binding/package.json | 1 + .../reexport-extra.cjs | 6 + .../split-cjs-require-binding/reexport.cjs | 4 + .../split-cjs-require-binding/whole.cjs | 4 + .../split-cjs-require-binding/main.cjs.json | 1 + 15 files changed, 536 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json create mode 100644 tests/corpus/split-cjs-require-binding/chain-wrap.cjs create mode 100644 tests/corpus/split-cjs-require-binding/chain.cjs create mode 100644 tests/corpus/split-cjs-require-binding/lib.cjs create mode 100644 tests/corpus/split-cjs-require-binding/main.cjs create mode 100644 tests/corpus/split-cjs-require-binding/package.json create mode 100644 tests/corpus/split-cjs-require-binding/reexport-extra.cjs create mode 100644 tests/corpus/split-cjs-require-binding/reexport.cjs create mode 100644 tests/corpus/split-cjs-require-binding/whole.cjs create mode 100644 tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index 2b28c65334..eb05550c0f 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -28,6 +28,7 @@ import { isModuleExportsAccess, locOf, npmStaticDepSf7, + requireBindingEarlyUse7, requireSpecOf, resolveImport, } from "../program.js"; @@ -43,6 +44,328 @@ export interface InlineRequire { dep: ts.SourceFile; } +/** A top-level `var m; m = require("./x")` binding. Node evaluates the + * require at the assignment; the binding is alias plumbing exactly like + * `const m = require("./x")` for every later read. */ +export interface SplitRequireBinding { + decl: ts.VariableDeclaration; + assign: ts.BinaryExpression; + call: ts.CallExpression; + spec: string; + dep: ts.SourceFile; +} + +function symbolOfIdentifierValue(lowerer: Lowerer, ident: ts.Identifier): ts.Symbol | undefined { + const parent = ident.parent; + // A destructuring shorthand target resolves to the property symbol at + // the name, but reads/writes the variable through its value symbol. + if (ts.isShorthandPropertyAssignment(parent) && parent.name === ident) { + return ( + lowerer.checker.getShorthandAssignmentValueSymbol(parent) ?? + lowerer.checker.getSymbolAtLocation(ident) + ); + } + return lowerer.checker.getSymbolAtLocation(ident); +} + +function destructuringContainerOf(node: ts.Node): ts.Node | null { + const parent = node.parent; + if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return parent; + if (ts.isPropertyAssignment(parent) && parent.initializer === node) return parent; + if (ts.isSpreadElement(parent) && parent.expression === node) return parent; + if (ts.isSpreadAssignment(parent) && parent.expression === node) return parent; + return null; +} + +function writeTargetOf(node: ts.Node): ts.Node | null { + let target = node; + for (;;) { + const parent = target.parent; + if ( + ts.isParenthesizedExpression(parent) || + ts.isArrayLiteralExpression(parent) || + ts.isObjectLiteralExpression(parent) + ) { + target = parent; + continue; + } + const container = destructuringContainerOf(target); + if (container === null) break; + target = container; + } + const parent = target.parent; + if ( + ts.isBinaryExpression(parent) && + parent.left === target && + ts.isAssignmentOperator(parent.operatorToken.kind) + ) + return parent; + if ( + (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + parent.operand === target && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken) + ) + return parent; + if ((ts.isForInStatement(parent) || ts.isForOfStatement(parent)) && parent.initializer === target) + return parent; + return null; +} + +/** The split form of a top-level require binding: `var m; m = require("./x")`. + * Exactly one write, at a top-level `m = require("")` statement. + * Null for the ordinary `const m = require("./x")` declaration and for + * every form that needs runtime module selection. */ +export function splitRequireBindingOf( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): SplitRequireBinding | null { + // Cheap shape gate before the cache: ordinary declarations never enter the + // per-Lowerer map. + if (decl.initializer !== undefined || !ts.isIdentifier(decl.name)) return null; + const list = decl.parent as ts.VariableDeclarationList; + if ((list.flags & ts.NodeFlags.Const) !== 0) return null; + const stmt = list.parent; + if (!ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) return null; + const cached = lowerer.cjsSplitRequireBindings.get(decl); + if (cached !== undefined) return cached; + const result = computeSplitRequireBinding(lowerer, decl); + lowerer.cjsSplitRequireBindings.set(decl, result); + return result; +} + +function computeSplitRequireBinding( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): SplitRequireBinding | null { + const sf = ts.getSourceFileOfNode(decl); + if (!isCjsJsFile(sf, lowerer.program)) return null; + if (!sf.text.includes("require")) return null; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + if (symbol === undefined) return null; + // Merged declarations make the binding's initialization order ambiguous; + // keep ordinary storage instead of guessing. + let declarations = 0; + for (const other of symbol.declarations) { + if (!ts.isVariableDeclaration(other) || ++declarations > 1) return null; + } + if (declarations !== 1) return null; + + // Find the one top-level `m = ...` statement first: most uninitialized + // variables never reach that shape, so they avoid the full-file write scan. + let assign: ts.BinaryExpression | null = null; + for (const stmt of sf.statements.nodes) { + if (!ts.isExpressionStatement(stmt)) continue; + const expr = stmt.expression; + if ( + !ts.isBinaryExpression(expr) || + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(expr.left) || + expr.left.text !== decl.name.text || + lowerer.checker.getSymbolAtLocation(expr.left) !== symbol + ) + continue; + if (assign !== null) return null; + assign = expr; + } + if (assign === null) return null; + + const req = inlineProgramRequireOf(lowerer, assign.right); + if (req === null) return null; + + // Any other write makes the value depend on runtime control flow. + let writes = 0; + ts.walkPreorder(sf, (node) => { + if (!ts.isIdentifier(node) || node.text !== decl.name.text) return; + if (symbolOfIdentifierValue(lowerer, node) !== symbol) return; + if (writeTargetOf(node) === null) return; + if (++writes > 1) return "stop"; + }); + if (writes !== 1) return null; + + return { decl, assign, call: req.call, spec: req.spec, dep: req.dep }; +} + +/** The split binding an identifier names, or null. */ +export function splitRequireBindingOfIdent( + lowerer: Lowerer, + ident: ts.Identifier, +): SplitRequireBinding | null { + const symbol = symbolOfIdentifierValue(lowerer, ident); + return symbol === undefined ? null : splitBindingOfSymbol(lowerer, symbol); +} + +/** The exact top-level assignment statement that initializes `expr`, or null. */ +export function splitRequireAssignmentOf( + lowerer: Lowerer, + expr: ts.Expression, +): SplitRequireBinding | null { + if ( + !ts.isBinaryExpression(expr) || + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(expr.left) + ) + return null; + const bindingOfIdent = splitRequireBindingOfIdent(lowerer, expr.left); + return bindingOfIdent !== null && bindingOfIdent.assign === expr ? bindingOfIdent : null; +} + +/** The whole-export symbol a module's `module.exports = ` names, + * or null when the module is a table. */ +export function wholeExportSymbolOfModule(lowerer: Lowerer, dep: ts.SourceFile): ts.Symbol | null { + const cached = lowerer.cjsWholeExportSymbols.get(dep); + if (cached !== undefined) return cached; + if (cjsNamespaceModule(lowerer, dep)) { + lowerer.cjsWholeExportSymbols.set(dep, null); + return null; + } + const moduleSymbol = lowerer.checker.getSymbolAtLocation(dep); + let symbol = moduleSymbol?.getExports()?.get("export=" as ts.__String); + if (symbol === undefined) { + lowerer.cjsWholeExportSymbols.set(dep, null); + return null; + } + if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol); + lowerer.cjsWholeExportSymbols.set(dep, symbol); + return symbol; +} + +function splitBindingOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): SplitRequireBinding | null { + const decl = symbol.declarations.find(ts.isVariableDeclaration); + return decl === undefined ? null : splitRequireBindingOf(lowerer, decl); +} + +/** The target whole-export symbol behind a split-binding symbol. */ +function splitWholeExportSymbolOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const binding = splitBindingOfSymbol(lowerer, symbol); + return binding === null ? null : wholeExportSymbolOfModule(lowerer, binding.dep); +} + +/** True when a CommonJS module has a live member export (`exports.x` or + * `module.exports.x`) after all `module.exports =` replacements. Those + * members need a mutable whole-export root. */ +export function cjsHasLiveMemberExports(lowerer: Lowerer, sf: ts.SourceFile): boolean { + const cached = lowerer.cjsLiveMemberExports.get(sf); + if (cached !== undefined) return cached; + const stmts = sf.statements.nodes; + let lastTable = -1; + for (let i = 0; i < stmts.length; i++) { + if (cjsExportAssignmentOf(stmts[i]!)?.kind === "table") lastTable = i; + } + let live = false; + // Only members attached after the last replacement survive in Node. + if (lastTable >= 0) { + for (let i = lastTable + 1; i < stmts.length; i++) { + const stmt = stmts[i]!; + if (cjsExportAssignmentOf(stmt)?.kind === "member" && cjsExportDiscardReason(stmt) === null) { + live = true; + break; + } + } + } + lowerer.cjsLiveMemberExports.set(sf, live); + return live; +} + +/** The wrapper's export= symbol when a split binding is re-exported whole + * (`module.exports = m`) and that assignment owns storage for later member + * writes. Importers must read the wrapper's storage, not the split + * binding's underlying value. */ +function splitWholeExportStorageSymbolOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const binding = splitBindingOfSymbol(lowerer, symbol); + if (binding === null) return null; + if (wholeExportSymbolOfModule(lowerer, binding.dep) === null) return null; + const sf = ts.getSourceFileOfNode(binding.decl); + if (!cjsHasLiveMemberExports(lowerer, sf)) return null; + const whole = cjsWholeExportValueOf(sf); + if (whole === null) return null; + let rhs: ts.Expression = whole.rhs; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isIdentifier(rhs) || lowerer.checker.getSymbolAtLocation(rhs) !== symbol) return null; + const moduleSymbol = lowerer.checker.getSymbolAtLocation(sf); + let exportSymbol = moduleSymbol?.getExports()?.get("export=" as ts.__String); + if (exportSymbol === undefined) return null; + if (exportSymbol.flags & ts.SymbolFlags.Alias) + exportSymbol = lowerer.checker.getAliasedSymbol(exportSymbol); + return exportSymbol; +} + +/** The source file whose `module.exports =` assignment declares `symbol`. */ +function moduleExportSourceFileOf(symbol: ts.Symbol): ts.SourceFile | null { + for (const decl of symbol.declarations) { + if ( + ts.isBinaryExpression(decl) && + isModuleExportsAccess(decl.left) && + ts.isExpressionStatement(decl.parent) && + ts.isSourceFile(decl.parent.parent) + ) + return decl.getSourceFile(); + } + return null; +} + +/** One hop from a wrapper's export= symbol to the value it re-exports + * (`module.exports = require(...)` or `= `), or null. A + * wrapper with live members owns storage for them, so it is not followed. */ +function wholeReexportTargetOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const sf = moduleExportSourceFileOf(symbol); + if (sf === null) return null; + // A wrapper with live members owns storage for them; importers must keep + // reading that root rather than the inner value. + if (cjsHasLiveMemberExports(lowerer, sf)) return null; + const whole = cjsWholeExportValueOf(sf); + if (whole === null) return null; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + if (inner !== null) return wholeExportSymbolOfModule(lowerer, inner.dep); + let rhs: ts.Expression = whole.rhs; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isIdentifier(rhs)) return null; + const rhsSymbol = lowerer.checker.getSymbolAtLocation(rhs); + return rhsSymbol === undefined ? null : splitWholeExportSymbolOfSymbol(lowerer, rhsSymbol); +} + +/** Follow split bindings and whole re-exports to the ultimate value symbol. */ +function followSplitWholeTarget(lowerer: Lowerer, candidate: ts.Symbol | null): ts.Symbol | null { + if (candidate === null) return null; + let symbol = candidate; + const seen = new Set(); + for (let depth = 0; depth < 32 && !seen.has(symbol); depth++) { + seen.add(symbol); + const next = + splitWholeExportSymbolOfSymbol(lowerer, symbol) ?? wholeReexportTargetOf(lowerer, symbol); + if (next === null) break; + symbol = next; + } + return symbol; +} + +/** The target whole-export symbol behind `module.exports = `, + * letting importers of that wrapper resolve to the real value symbol. */ +export function splitWholeExportAliasTargetOf( + lowerer: Lowerer, + symbol: ts.Symbol, +): ts.Symbol | null { + const storage = splitWholeExportStorageSymbolOf(lowerer, symbol); + if (storage !== null) return followSplitWholeTarget(lowerer, storage); + const direct = followSplitWholeTarget(lowerer, splitWholeExportSymbolOfSymbol(lowerer, symbol)); + if (direct !== null) return direct; + return followSplitWholeTarget(lowerer, wholeReexportTargetOf(lowerer, symbol)); +} + +/** The first read of a split binding that code before its assignment can + * reach, using the same reachability model as ordinary require bindings. */ +export function splitRequireEarlyReadOf( + lowerer: Lowerer, + binding: SplitRequireBinding, +): ts.Identifier | null { + const sf = ts.getSourceFileOfNode(binding.decl); + const stmt = binding.assign.parent; + // `binding` was recognized only from a top-level `m = require(...)` + // statement, so the statement index is always present. + const index = sf.statements.nodes.indexOf(stmt as ts.ExpressionStatement); + return requireBindingEarlyUse7(lowerer.program, sf, index, binding.decl, []); +} + /** A `require("")` call of the CommonJS global in a CommonJS JS * file whose target is a compiled program module (relative, aliased, or an * opted-in --npm-static package). Builtins, JSON documents and diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index b5f99d6826..d032cb2638 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -22,6 +22,11 @@ import { } from "../../../ir/ir.js"; import { lowerNodeModuleIdentifier } from "../lower-node-module.js"; import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js"; +import { + splitRequireBindingOfIdent, + wholeExportSymbolOfModule, + wholeExportValue, +} from "../cjs-inline-require.js"; import { captureContextArguments } from "../function-context.js"; import { argumentsOwner, typedArgumentsCandidate } from "../typed-arguments.js"; import { @@ -920,6 +925,27 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // Client`): the member holds that binding's value. const memberAlias = cjsMemberAliasValueOf(lowerer, expr); if (memberAlias !== null) return lowerer.lowerExpr(memberAlias); + // `var m; m = require("./x")` as a bare value: a whole-export target is + // the exported value; a table target has no first-class module object. + { + const split = splitRequireBindingOfIdent(lowerer, expr); + if (split !== null) { + if (wholeExportSymbolOfModule(lowerer, split.dep) !== null) { + const value = wholeExportValue(lowerer, split.dep, expr); + if (value !== null) return value; + lowerer.unsupported( + "SC1090", + expr, + `the whole export of '${split.spec}' as a value (its 'module.exports =' value has no static representation)`, + ); + } + lowerer.unsupported( + "SC1090", + expr, + `the exports object of '${split.spec}' as a value (its exports compile to a static table: read require(${JSON.stringify(split.spec)}).member directly)`, + ); + } + } // Preflight guarantees no unresolved identifiers; anything else here // is a blocked declaration's binding (the SC2004 cascade) or a // binding form we don't model yet. diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 6ca21ddaaa..a86decf4c6 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -42,10 +42,14 @@ import { } from "./cycle-initialization.js"; import { markRequireEarlyBindings } from "./require-initialization.js"; import { + cjsHasLiveMemberExports, cjsTableMethodEntry, inlineProgramRequireOf, nestedCjsExportAssignmentsOf, requireNestedPatternsOf, + splitRequireBindingOf, + splitRequireBindingOfIdent, + wholeExportSymbolOfModule, } from "./cjs-inline-require.js"; import { isDeadTscHelperDecl, tscInteropBindingOfDecl } from "./cjs-tsc-interop.js"; import { collectJsonRequires, isJsonRequireDecl } from "./json-require.js"; @@ -1588,14 +1592,26 @@ export function collectGlobals( // require() of a program module is not: the exporter re-exports // that module's exports, which requirers resolve through the // checker's alias (the statement lowers to the dep's %init). + // A split binding re-exported whole with later member writes needs + // a mutable root: `module.exports = m; module.exports.extra = 1` + // extends the SAME object Node exposes. Without later members the + // binder is pure alias plumbing (no storage). + const splitWholeBinding = ts.isIdentifier(rhs) + ? splitRequireBindingOfIdent(lowerer, rhs) + : null; + const hasMemberExports = cjsHasLiveMemberExports(lowerer, sf); + const splitWhole = + splitWholeBinding !== null && + wholeExportSymbolOfModule(lowerer, splitWholeBinding.dep) !== null && + hasMemberExports; const factoryValued = - !ts.isIdentifier(rhs) && + (!ts.isIdentifier(rhs) || splitWhole) && !ts.isClassExpression(rhs) && !ts.isObjectLiteralExpression(rhs) && !( requireSpecOf(rhs) !== null && inlineProgramRequireOf(lowerer, rhs) !== null && - !sf.statements.nodes.some((s) => cjsExportAssignmentOf(s)?.kind === "member") + !hasMemberExports ) && cjsExportTargetLiteral(rhs, sf) === null; if (cjsScalarLiteral(rhs) || fnValued || factoryValued) { @@ -1608,6 +1624,9 @@ export function collectGlobals( // A returned object remains the live CommonJS root; later // exports.member writes must update that same open object. if (factoryValued && t?.kind === "record" && lowerer.dynConvertible(t)) t = DYN; + // A split whole-export root is extended by later member writes; + // keep it checked-dynamic so those writes reach the same object. + if (splitWhole && t && t.kind !== "dyn") t = DYN; if (t && t.kind !== "void") { const g: IrGlobal = { id: `%g.${tag}exports`, @@ -2699,6 +2718,7 @@ function noteVarGlobalEntryInit(lowerer: Lowerer, sf: ts.SourceFile, g: IrGlobal * plumbing (lowerVarDeclarator): no storage, the reads resolve through to * the module. JSON documents and native FFI requires keep their storage. */ function requireAliasDecl(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if (splitRequireBindingOf(lowerer, decl) !== null) return true; return ( decl.initializer !== undefined && requireSpecOf(decl.initializer) !== null && diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index b04ba0b098..d10d3b9d29 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -36,6 +36,11 @@ import { cjsTableMethodEntry, inlineProgramRequireOf, requireNestedPatternsOf, + splitRequireAssignmentOf, + splitRequireBindingOf, + splitRequireBindingOfIdent, + splitRequireEarlyReadOf, + wholeExportSymbolOfModule, } from "./cjs-inline-require.js"; import { cjsExportMemberWriteGlobalOf, @@ -1811,6 +1816,23 @@ function lowerVarDeclarator( isConst: boolean, isLet: boolean, ): IrStmt[] { + // `var m; m = require("./x")`: the declaration is alias plumbing; the + // assignment owns the module's run-once %init (cjs-inline-require.ts). + // A read that code before the assignment can reach keeps Node's hoisted + // `undefined` divergence visible at build time, like a var require + // declaration below runnable code. + const split = splitRequireBindingOf(lowerer, decl); + if (split !== null) { + const early = splitRequireEarlyReadOf(lowerer, split); + if (early !== null) { + lowerer.unsupported( + "SC1013", + early, + `var require() binding '${(decl.name as ts.Identifier).text}' below code that can reach it (Node reads undefined before the require runs, which is not modeled — assign the require above the code that can run first)`, + ); + } + return []; + } // The canonical ESM bridge to a compiled program module. Like a // top-level CommonJS require declaration, the binding is alias // plumbing while the dependency evaluates at this statement's @@ -7015,6 +7037,26 @@ function lowerCjsSingleValueExport( return lowerer.requireInitStmt(req.spec, req.call) ?? { kind: "block", body: [], loc }; } if (ts.isIdentifier(rhs)) { + const split = splitRequireBindingOfIdent(lowerer, rhs); + if (split !== null) { + if (wholeExportSymbolOfModule(lowerer, split.dep) === null) { + lowerer.unsupported( + "SC1090", + rhs, + `the exports object of '${split.spec}' as a value (its exports compile to a static table: read require(${JSON.stringify(split.spec)}).member directly)`, + ); + } + const root = lowerer.globalsByDeclNode.get(expr); + if (root !== undefined) { + return { + kind: "assign", + localId: root.id, + value: lowerer.lowerExprExpecting(rhs, root.type), + loc: locOf(rhs), + }; + } + return { kind: "block", body: [], loc }; + } const sym = lowerer.resolveValueSymbol(rhs); if (!sym) return null; if ( @@ -7924,6 +7966,16 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm if (!ts.isIdentifier(expr.left)) { lowerer.unsupported("SC1090", expr.left, "assignment to non-variables"); } + const splitAssign = splitRequireAssignmentOf(lowerer, expr); + if (splitAssign !== null) { + return ( + lowerer.requireInitStmt(splitAssign.spec, splitAssign.call) ?? { + kind: "block", + body: [], + loc: locOf(expr), + } + ); + } const target = lowerer.resolveWritable(expr.left); if (!target) lowerer.rejectUnresolved( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c4d24cef17..f1e7606f2b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -331,10 +331,13 @@ import { } from "./lower-modules.js"; import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js"; import { + type SplitRequireBinding, cjsNamespaceModule, cjsWholeExportValueOf, inlineNamespaceRequireOf, inlineProgramRequireOf, + splitRequireBindingOf, + splitWholeExportAliasTargetOf, } from "./cjs-inline-require.js"; import { tscInteropModuleOf } from "./cjs-tsc-interop.js"; import { usesCjsExportsIdentity } from "./cjs-exports-object.js"; @@ -2176,6 +2179,14 @@ export class Lowerer { /** Per CommonJS file using top-level `this`: the global holding the * object that stands for its original exports (lowerCjsTopLevelThis). */ readonly cjsExportsThis = new Map(); + /** `var m; m = require("./x")` alias bindings (cjs-inline-require.ts). + * Null entries cache declarations that keep ordinary storage. */ + readonly cjsSplitRequireBindings = new Map(); + /** Per-module whole-export symbol (cjs-inline-require.ts). Null means the + * module's exports are a table. */ + readonly cjsWholeExportSymbols = new Map(); + /** Per-module live member-export classification (cjs-inline-require.ts). */ + readonly cjsLiveMemberExports = new Map(); /** Inline program-module require calls whose %init the enclosing member * read or member call is already sequencing (cjs-inline-require.ts). */ readonly inlineRequireInitDone = new Set(); @@ -3711,6 +3722,11 @@ export class Lowerer { // registry (globals, fn signatures, classes) applies unchanged. const cjsValue = this.cjsExportValueSymbol(symbol); if (cjsValue) symbol = cjsValue; + // `var m; m = require("./x"); module.exports = m` over a whole-export + // target: TS types the wrapper's export= as `any`, so reconnect it to + // the target's value symbol explicitly. + const splitAlias = splitWholeExportAliasTargetOf(this, symbol); + if (splitAlias) symbol = splitAlias; this.flushDeferred(symbol); return symbol; } @@ -4104,8 +4120,8 @@ export class Lowerer { const decl = decls.find(ts.isImportClause) ?? decls[0]; if (!decl) return null; if (ts.isImportClause(decl)) return this.cjsDefaultImportDepOf(decl); - if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || !decl.initializer) - return null; + if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null; + if (decl.initializer === undefined) return splitRequireBindingOf(this, decl)?.dep ?? null; { const interop = tscInteropModuleOf(this, expr); if (interop !== null) return cjsNamespaceModule(this, interop.dep) ? interop.dep : null; @@ -4210,6 +4226,9 @@ export class Lowerer { // a binding (cjs-inline-require.ts sequences the module's %init). if (!ts.isIdentifier(expr)) return this.cjsModuleRefDepOf(expr) !== null; const sym = this.checker.getSymbolAtLocation(expr); + const splitDecl = sym?.declarations.find(ts.isVariableDeclaration); + const split = splitDecl === undefined ? null : splitRequireBindingOf(this, splitDecl); + if (split !== null) return cjsNamespaceModule(this, split.dep); if (this.localModuleBindingDepOf(expr) === null) return false; if (this.createRequireWholeExportSymbolOf(expr) !== null) return false; // SINGLE-VALUE exporters (`module.exports = Countdown` / `= double` / diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json new file mode 100644 index 0000000000..c01617f80f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json @@ -0,0 +1,12 @@ +{ + "order": [ + "/tests/corpus/split-cjs-require-binding/whole.cjs", + "/tests/corpus/split-cjs-require-binding/reexport.cjs", + "/tests/corpus/split-cjs-require-binding/reexport-extra.cjs", + "/tests/corpus/split-cjs-require-binding/lib.cjs", + "/tests/corpus/split-cjs-require-binding/chain.cjs", + "/tests/corpus/split-cjs-require-binding/chain-wrap.cjs", + "/tests/corpus/split-cjs-require-binding/main.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/split-cjs-require-binding/chain-wrap.cjs b/tests/corpus/split-cjs-require-binding/chain-wrap.cjs new file mode 100644 index 0000000000..435dcaeab1 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/chain-wrap.cjs @@ -0,0 +1,3 @@ +var chained; +chained = require("./chain.cjs"); +module.exports = chained; diff --git a/tests/corpus/split-cjs-require-binding/chain.cjs b/tests/corpus/split-cjs-require-binding/chain.cjs new file mode 100644 index 0000000000..a279f456e4 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/chain.cjs @@ -0,0 +1 @@ +module.exports = require("./whole.cjs"); diff --git a/tests/corpus/split-cjs-require-binding/lib.cjs b/tests/corpus/split-cjs-require-binding/lib.cjs new file mode 100644 index 0000000000..b318eb85c8 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/lib.cjs @@ -0,0 +1,5 @@ +console.log("lib: init"); +exports.n = 7; +exports.f = function (x) { + return x * 2; +}; diff --git a/tests/corpus/split-cjs-require-binding/main.cjs b/tests/corpus/split-cjs-require-binding/main.cjs new file mode 100644 index 0000000000..974421bd33 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/main.cjs @@ -0,0 +1,55 @@ +console.log("main: start"); +var lib, whole; +lib = require("./lib.cjs"); +whole = require("./whole.cjs"); +console.log("main: after", lib.n, lib.f(3), whole(4)); +exports.fromLib = lib.f; +exports.whole = whole; +console.log("main: exported", exports.fromLib(5), whole(6)); +console.log("main: identity", exports.fromLib === lib.f, exports.whole === whole); +const re = require("./reexport.cjs"); +console.log("main: reexport", re(7)); +let later; +later = require("./lib.cjs"); +console.log("main: let", later.n); +const rx = require("./reexport-extra.cjs"); +console.log("main: reexport extra", rx(8), rx.extra); +const splitObject = { whole }; +console.log("main: shorthand value", splitObject.whole(10)); +var libAgain; +libAgain = require("./lib.cjs"); +const { n: splitN, f: splitF } = libAgain; +console.log("main: destructured split", splitN, splitF(4)); +var chained; +chained = require("./chain-wrap.cjs"); +console.log("main: chained", chained(11)); + +// Any later write, including a destructuring target, keeps ordinary storage. +var shorthand; +shorthand = require("./whole.cjs"); +({ shorthand } = { shorthand: "shorthand" }); +console.log("main: shorthand write", shorthand); +var renamed; +renamed = require("./whole.cjs"); +({ value: renamed } = { value: "renamed" }); +console.log("main: renamed write", renamed); +var nested; +nested = require("./whole.cjs"); +({ a: { nested } } = { a: { nested: "nested" } }); +console.log("main: nested write", nested); +var rest; +rest = require("./whole.cjs"); +({ ...rest } = { a: 1, b: 2 }); +console.log("main: rest write", rest.a, rest.b); +var array; +array = require("./whole.cjs"); +[array] = ["array"]; +console.log("main: array write", array); +var arrayRest; +arrayRest = require("./whole.cjs"); +[...arrayRest] = [1, 2]; +console.log("main: array-rest write", arrayRest[0], arrayRest[1]); +var each; +each = require("./whole.cjs"); +for ({ each } of [{ each: "each" }]) {} +console.log("main: for-of write", each); diff --git a/tests/corpus/split-cjs-require-binding/package.json b/tests/corpus/split-cjs-require-binding/package.json new file mode 100644 index 0000000000..a3c15a7a63 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/split-cjs-require-binding/reexport-extra.cjs b/tests/corpus/split-cjs-require-binding/reexport-extra.cjs new file mode 100644 index 0000000000..584de98fec --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/reexport-extra.cjs @@ -0,0 +1,6 @@ +var whole; +whole = require("./whole.cjs"); +console.log("extra: before", whole.extra, whole(9)); +module.exports = whole; +module.exports.extra = 1; +console.log("extra: after", whole.extra, module.exports.extra); diff --git a/tests/corpus/split-cjs-require-binding/reexport.cjs b/tests/corpus/split-cjs-require-binding/reexport.cjs new file mode 100644 index 0000000000..9a931bdefb --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/reexport.cjs @@ -0,0 +1,4 @@ +console.log("reexport: init"); +var whole; +whole = require("./whole.cjs"); +module.exports = whole; diff --git a/tests/corpus/split-cjs-require-binding/whole.cjs b/tests/corpus/split-cjs-require-binding/whole.cjs new file mode 100644 index 0000000000..31b476c4ec --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/whole.cjs @@ -0,0 +1,4 @@ +console.log("whole: init"); +module.exports = function whole(x) { + return x + 1; +}; diff --git a/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json b/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json new file mode 100644 index 0000000000..9d570f7000 --- /dev/null +++ b/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 12000 } From 9c9fb6531a8857425049ffb700069ff008c3e730 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 03:07:29 -0500 Subject: [PATCH 05/44] Replay class objects with their base names, and restore the inspector after a refusal - The emitter journal that forked workers merge (#1919) records each class object entry whole, and the entries carry baseNameSym beside nameSym. The replay cast named only nameSym, which the native compiler refuses (SC1090), so the self-hosted LLVM emitter did not build. - An inspection refusal (the 'not supported yet' Error of insp.dyn) propagates out of the emitted inspect helpers before they close their frames and before util.inspect's layout options are restored. The open frames, their indentation and the custom layout then shaped every later console.log and util.inspect. A top-level render through helpers that can reach a checked-dynamic view, a generator or a class instance now runs in a lifted wrapper that takes insp.mark first and calls insp.unwind in a finally. A render under breakLength, compact or sorted restores the default layout in a finally the same way. - A non-enumerable accessor that %o shows, and any accessor of a checked-dynamic object, renders as [Getter], [Setter] or [Getter/Setter], as Node prints it, instead of its value slot. - Under %o, an array's non-enumerable own properties follow its named ones in brackets (`[ 1, 2, [length]: 2, foo: 'x', [hid]: 3 ]`), as Node prints them; corpus 6357 covers [length] ahead of named properties. --- packages/compiler/src/backend/llvm/emitter.ts | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/lower-inspect.ts | 174 +++++++++++++++--- .../compiler/src/ir/builtin-signatures.ts | 5 + packages/runtime/src/scr_inspect.c | 73 ++++++-- packages/runtime/src/scr_inspect.h | 4 + tests/corpus/6357-format-o-hidden.ts | 18 +- tests/harness/dyncheck.test.ts | 66 +++++++ 8 files changed, 306 insertions(+), 38 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 004277a24b..7c6fd7a47f 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4859,7 +4859,7 @@ export class LlEmitter { return; } case "classObj": - addEntry(this.classObjs, key, value as { nameSym: string }); + addEntry(this.classObjs, key, value as { nameSym: string; baseNameSym: string | null }); return; case "retainWrapper": addEntry(this.nullableUnions.retainWrappers, key, value as string); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index dfc5327e21..fc4b1337b3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -663,6 +663,8 @@ export const LIB_FN_SYMS: Record = { "insp.moreItems": "scr_insp_more_items", "insp.end": "scr_insp_end", "insp.nestedBegin": "scr_insp_nested_begin", + "insp.mark": "scr_insp_mark", + "insp.unwind": "scr_insp_unwind", "insp.nestedEnd": "scr_insp_nested_end", // Circular references over cycle-capable composites (Node's // seen/circular machinery — none of these throw; refWrap borrows its diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index cda4d29727..349fec1206 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -900,6 +900,112 @@ export function inspectExpr( recurse: IrExpr, depth: IrExpr, loc: SrcLoc, +): IrExpr { + const render = inspectExprBody(lowerer, t, value, recurse, depth, loc); + // A top-level render through emitted helpers that can reach a refusal + // (the 'not supported yet' Error of insp.dyn) unwinds the inspector to + // its state before the render on every exit: the Error propagates out of + // the helpers before they close their frames. + if ( + render.kind !== "call" || + recurse.kind !== "numLit" || + recurse.value !== 0 || + !inspectMayRefuse(lowerer, t) + ) + return render; + return withInspectUnwind(lowerer, render, t, loc); +} + +/** A top-level helper render (`call helper(v, r, d)`) through a lifted + * wrapper that takes insp.mark first and runs insp.unwind however the + * render ends: `try { return helper(v, r, d); } finally { insp.unwind(); }`. + * A seqExpr cannot hold the try. */ +function withInspectUnwind( + lowerer: Lowerer, + render: IrExpr & { kind: "call" }, + t: IrType, + loc: SrcLoc, +): IrExpr { + const key = `unwind:${render.callee}`; + let name = lowerer.inspectHelpers.get(key); + if (name === undefined) { + name = keyedHelperName(lowerer, "%util.inspUnwound", key); + lowerer.inspectHelpers.set(key, name); + const params = [ + { localId: "v.0", name: "v", type: t }, + { localId: "r.0", name: "r", type: F64 }, + { localId: "d.0", name: "d", type: F64 }, + ]; + const call = (fn: "insp.mark" | "insp.unwind"): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "libCall", fn, args: [], type: VOID, loc }, + loc, + }); + lowerer.liftedFns.push({ + name, + params, + returnType: STRING, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body: [ + call("insp.mark"), + { + kind: "tryCatch", + tryBody: [ + { + kind: "return", + value: { + kind: "call", + callee: render.callee, + args: params.map((p) => varRef(p.localId, p.type, loc)), + type: STRING, + loc, + }, + loc, + }, + ], + catchBody: null, + catchLocalId: null, + finallyBody: [call("insp.unwind")], + loc, + }, + ], + loc, + }); + } + return { ...render, callee: name }; +} + +/** Whether inspecting a `t` value can end in an inspection refusal: `t` + * or a type reachable from it is a checked-dynamic value, a generator, a + * Dirent row or a class instance, which can render through a + * checked-dynamic view. */ +function inspectMayRefuse(lowerer: Lowerer, t: IrType): boolean { + const seen = new Set(); + const walk = (u: IrType): boolean => { + if (u.kind === "dyn" || u.kind === "generator") return true; + if (u.kind === "record") { + const shape = lowerer.shapes.get(u.shapeId); + if (shape && isDirentShape(shape)) return true; + } + // A class instance can render through its checked-dynamic view, which + // a helper built earlier may only learn at the end of lowering + // (classInspectsAddedProperties). + if (u.kind === "object") return true; + const k = typeKey(u); + if (seen.has(k)) return false; + seen.add(k); + return typeConstituents(lowerer, u).some(walk); + }; + return walk(t); +} + +function inspectExprBody( + lowerer: Lowerer, + t: IrType, + value: IrExpr, + recurse: IrExpr, + depth: IrExpr, + loc: SrcLoc, ): IrExpr { // A Dirent row renders through its dyn view: Node's `Dirent { ... }` // with the Symbol(type) key. @@ -2350,40 +2456,55 @@ function parseInspectOptions( /** A render under non-default breakLength/compact/sorted: the value is evaluated * first, the layout applies to this render only, and the defaults return - * afterwards. */ + * however the render ends, an inspection refusal included. The render runs + * in a lifted function, `(v) => { insp.layout(...); try { return render(v); } + * finally { insp.layout(80, 3, false); } }`, since a seqExpr cannot hold the + * try; `tag` names the render for the function's key. */ function withInspectLayout( lowerer: Lowerer, options: InspectOptions, value: IrExpr, + tag: string, render: (value: IrExpr) => IrExpr, loc: SrcLoc, ): IrExpr { if (options.breakLength === 80 && options.compact === 3 && !options.sorted) return render(value); - const input = lowerer.declareHiddenLocal("%inspectInput", value.type); - const text = lowerer.declareHiddenLocal("%inspectText", STRING); - const layout = (breakLength: number, compact: number, sorted: boolean): IrStmt => ({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "insp.layout", - args: [numLit(breakLength, loc), numLit(compact, loc), boolLit(sorted, loc)], - type: VOID, + const key = `layout:${tag}:${typeKey(value.type)}:${options.breakLength}:${options.compact}:${options.sorted}:${options.depth}`; + let name = lowerer.inspectHelpers.get(key); + if (name === undefined) { + name = keyedHelperName(lowerer, "%util.inspLayout", key); + lowerer.inspectHelpers.set(key, name); + const layout = (breakLength: number, compact: number, sorted: boolean): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "insp.layout", + args: [numLit(breakLength, loc), numLit(compact, loc), boolLit(sorted, loc)], + type: VOID, + loc, + }, loc, - }, - loc, - }); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: input.id, init: value, loc }, - layout(options.breakLength, options.compact, options.sorted), - { kind: "varDecl", localId: text.id, init: render(varRef(input.id, value.type, loc)), loc }, - layout(80, 3, false), - ], - result: varRef(text.id, STRING, loc), - type: STRING, - loc, - }; + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "v.0", name: "v", type: value.type }], + returnType: STRING, + locals: [{ id: "v.0", name: "v", type: value.type, mutable: false }], + body: [ + layout(options.breakLength, options.compact, options.sorted), + { + kind: "tryCatch", + tryBody: [{ kind: "return", value: render(varRef("v.0", value.type, loc)), loc }], + catchBody: null, + catchLocalId: null, + finallyBody: [layout(80, 3, false)], + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [value], type: STRING, loc }; } /** `util.inspect(fn)` / `util.inspect(Cls)` over a DIRECT identifier @@ -2500,6 +2621,7 @@ export function lowerInspectValue( lowerer, options, value, + "typedArray", (input) => ({ kind: "libCall", fn: "insp.typedArray", @@ -2525,6 +2647,7 @@ export function lowerInspectValue( lowerer, options, value, + "dirDyn", (input) => ({ kind: "libCall", fn: "console.dirDyn", @@ -2539,6 +2662,7 @@ export function lowerInspectValue( lowerer, options, value, + "inspect", (input) => inspectExpr(lowerer, value.type, input, numLit(0, loc), numLit(depth, loc), loc), loc, ); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a316b5dd5e..6dc8a68267 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4414,6 +4414,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * `prefix inner` re-indented at the caller's level (+1). */ "insp.nestedBegin": { argTypes: [], result: VOID }, "insp.nestedEnd": { argTypes: [STRING, STRING], result: STRING }, + /** Around a top-level render that an inspection refusal can end: mark + * records the inspector's frame and nesting depth, unwind (in the + * render's finally) closes what the render left open above it. */ + "insp.mark": { argTypes: [], result: VOID }, + "insp.unwind": { argTypes: [], result: VOID }, /** node:string_decoder's utf8 StringDecoder (scr_string.c): the decoder * value is a one-field record whose f64 PACKS the pending partial * sequence (count + up to 3 raw bytes — Node buffers at most 3 for diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 9875a7077c..e63c1cec15 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -646,6 +646,54 @@ ScrStr *scr_insp_nested_end(ScrStr *prefix, ScrStr *inner) { return ib_take(&out); } +/* The inspector's state at the start of a top-level render. A render that + * an inspection refusal ends leaves its compiled frames open: the Error + * propagates out of the emitted helpers before their scr_insp_end and + * scr_insp_nested_end calls. The emitted render takes a mark first and + * unwinds to it on every exit (lower-inspect.ts), so later renders start + * from the state the refused one found. */ +typedef struct { + size_t nframes; + size_t nsaved; + size_t nseen; + size_t indent; + double cur_depth; +} InspMark; + +static SCR_TL InspMark *g_marks; +static SCR_TL size_t g_nmarks; +static SCR_TL size_t g_marks_cap; + +void scr_insp_mark(void) { + if (g_nmarks == g_marks_cap) { + g_marks_cap = g_marks_cap ? g_marks_cap * 2 : 4; + g_marks = realloc(g_marks, g_marks_cap * sizeof(InspMark)); + if (!g_marks) insp_oom(); + } + g_marks[g_nmarks++] = (InspMark){g_nframes, g_nsaved, g_nseen, g_indent, g_cur_depth}; +} + +void scr_insp_unwind(void) { + InspMark m = g_marks[--g_nmarks]; + while (g_nframes > m.nframes) { + InspFrame *f = &g_frames[--g_nframes]; + for (size_t i = 0; i < f->len; i++) scr_str_release(f->items[i]); + free(f->items); + } + while (g_nsaved > m.nsaved) { + InspSaved *sv = &g_saved[--g_nsaved]; + free(g_seen); + g_seen = sv->seen; + g_seen_cap = sv->seen_cap; + memcpy(g_circ, sv->circ, sizeof g_circ); + g_ncirc = sv->ncirc; + } + if (g_nseen > m.nseen) g_nseen = m.nseen; + g_indent = m.indent; + g_cur_depth = m.cur_depth; + g_pending_seen = NULL; +} + /* ── string values (formatPrimitive's string half) ───────────────────── */ /* Byte offset of UTF-16 unit `units` — when the boundary splits an astral @@ -1422,7 +1470,7 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char for (size_t i = 0; i < d->v.obj.len; i++) { ScrDynEntry *ent = &d->v.obj.entries[i]; if (!insp_obj_shows(ent)) continue; - ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); + ScrStr *val = ent->accessor ? insp_accessor_tag(ent) : scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; if (ent->enumerable) { insp_key_into(&eb, ent->key, ent->key_len); @@ -1443,7 +1491,7 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char ScrDynEntry *ent = scr_insp_symbol_entry(d, i); if (!ent) continue; ScrStr *key = scr_insp_dyn(d->symbol_keys->v.arr.items[i], recurse + 1, depth); - ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); + ScrStr *val = ent->accessor ? insp_accessor_tag(ent) : scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; /* A hidden symbol key is bracketed like a hidden string key. */ if (!ent->enumerable) ib_char(&eb, '['); @@ -1635,10 +1683,11 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { case SCR_DYN_ARR: { size_t len = d->v.arr.len; /* Named and noncanonical numeric properties follow the elements as - * `key: value` entries (Node's array extras). */ + * `key: value` entries (Node's array extras); under showHidden the + * own `length` comes first, and non-enumerable properties show too. */ const ScrDyn *props = d->v.arr.properties; size_t nprops = 0; - for (size_t i = 0; props && i < props->v.obj.len; i++) nprops += props->v.obj.entries[i].enumerable; + for (size_t i = 0; props && i < props->v.obj.len; i++) nprops += insp_obj_shows(&props->v.obj.entries[i]); if (len == 0 && nprops == 0 && !g_show_hidden) return scr_str_new("[]", 2); if (recurse > depth) return scr_str_new("[Array]", 7); scr_insp_begin(recurse + 1); @@ -1672,9 +1721,9 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { scr_str_release(m); } if (g_show_hidden) { - /* The own non-enumerable `length`, after the items and before the - * other own properties. Node's grouping reads value[k] for every - * output position k, this one included. */ + /* The own non-enumerable `length`, after the items. Node's + * grouping reads value[k] for every output position k, this one + * included. */ char line[48]; int n = snprintf(line, sizeof line, "[length]: %zu", len); ScrStr *s = scr_str_new(line, (size_t)n); @@ -1685,10 +1734,13 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } for (size_t k = 0; props && k < props->v.obj.len; k++) { const ScrDynEntry *ent = &props->v.obj.entries[k]; - if (!ent->enumerable) continue; + if (!insp_obj_shows(ent)) continue; ScrStr *val = ent->accessor ? insp_accessor_tag(ent) : scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; + /* formatProperty's hidden key: the key's rendering in brackets. */ + if (!ent->enumerable) ib_char(&eb, '['); insp_key_into(&eb, ent->key, ent->key_len); + if (!ent->enumerable) ib_char(&eb, ']'); ib_cstr(&eb, ": "); ib_bytes(&eb, val->data, val->len); scr_str_release(val); @@ -1702,9 +1754,8 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { entries++; } /* Node's grouping leaves out the last entry when there are more - * entries than maxArrayLength (the `... more items` entry when the - * array has no properties, or under showHidden the `[length]` - * entry). */ + * entries than maxArrayLength (100): the `... more items` entry, + * `[length]` under showHidden, or the last property. */ bool last_out = entries > 100; ScrStr *base = scr_str_new("", 0); ScrStr *b0 = scr_str_new("[", 1); diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 9bf18a23c0..f36335a3b2 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -76,6 +76,10 @@ ScrStr *scr_insp_end(ScrStr *base, ScrStr *b0, ScrStr *b1, double recurse, * caller's level (+1; borrows both). */ void scr_insp_nested_begin(void); ScrStr *scr_insp_nested_end(ScrStr *prefix, ScrStr *inner); +/* Mark the inspector's state before a top-level render, and return to the + * mark when the render ends, normally or by an inspection refusal. */ +void scr_insp_mark(void); +void scr_insp_unwind(void); /* The buf.read* / buf.write* numeric families on u8 bytes, Node-exact: * boundsError's ladder (non-integer offset → 'It must be an integer', a diff --git a/tests/corpus/6357-format-o-hidden.ts b/tests/corpus/6357-format-o-hidden.ts index 9e48ed4ba6..44ed1d772b 100644 --- a/tests/corpus/6357-format-o-hidden.ts +++ b/tests/corpus/6357-format-o-hidden.ts @@ -1,6 +1,7 @@ // util.format's %o over values reached through `unknown` renders with // showHidden, as Node does: arrays show their hidden `[length]` (with -// Node's grouping), at every depth, inside Maps and records too. +// Node's grouping), at every depth, inside Maps and records too, ahead of +// an array's named properties. import { format } from "node:util"; const cases: [string, unknown][] = [ ["ten", Array.from({ length: 10 }, (_, i) => i)], @@ -17,3 +18,18 @@ const cases: [string, unknown][] = [ ["date", [new Date(0)]], ]; for (const [k, v] of cases) console.log(k, format("%o", v)); +// The hidden `[length]` comes before an array's named properties, and the +// grid's last-entry rule counts both. +type Bag = Record; +const named = [1, 2]; +(named as unknown as Bag).foo = "x"; +const tagged = Array.from({ length: 100 }, (_, i) => i); +(tagged as unknown as Bag).tag = "t"; +const bare: number[] = []; +(bare as unknown as Bag).k = 1; +for (const [k, v] of [ + ["named", named], + ["tagged", tagged], + ["bare", bare], +] as [string, unknown][]) + console.log(k, format("%o", v)); diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index dab51a4300..21e67d085a 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -1311,6 +1311,72 @@ console.log("after"); expect(r.stderr).toContain(`Uncaught Error: ${refusal}`); }); + test("accessors reached through unknown render as Node's accessor tags", async () => { + const r = await compileAndRun( + "inspect-accessor-tags", + `import { format } from "node:util"; +const o: unknown = JSON.parse('{"x":1}'); +Object.defineProperty(o, "g", { get: () => 2, enumerable: true }); +Object.defineProperty(o, "h", { get: () => 3, enumerable: false }); +Object.defineProperty(o, "gs", { get: () => 4, set: (_v: number) => {}, enumerable: false }); +Object.defineProperty(o, "s", { set: (_v: number) => {}, enumerable: true }); +console.log(o); +console.log(format("%o", o)); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "{ x: 1, g: [Getter], s: [Setter] }", + "{", + " x: 1,", + " g: [Getter],", + " [h]: [Getter],", + " [gs]: [Getter/Setter],", + " s: [Setter]", + "}", + "", + ].join("\n"), + ); + }); + + test("a refused inspection leaves later renders at the default layout", async () => { + // The refusal's Error skips the call site's layout restore and the + // render's own exits; later renders must not keep the custom layout, + // the indentation, showHidden or open frames of the abandoned one. + const r = await compileAndRun( + "inspect-refusal-state", + `import { format, inspect } from "node:util"; +const p: unknown = Promise.resolve(1); +const rec: unknown = { a: [1], b: p }; +try { + inspect({ a: [1, 2, 3, 4, 5, 6, 7, 8], b: p }, { breakLength: 20 }); +} catch {} +try { + format("%o", rec); +} catch {} +const long: unknown = JSON.parse(JSON.stringify(Array.from({ length: 12 }, (_, i) => "item" + i))); +console.log(long); +console.log(format("%O", [1])); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "[", + " 'item0', 'item1',", + " 'item2', 'item3',", + " 'item4', 'item5',", + " 'item6', 'item7',", + " 'item8', 'item9',", + " 'item10', 'item11'", + "]", + "[ 1 ]", + "", + ].join("\n"), + ); + }); + test("inspecting a compiled class with static fields through unknown refuses", async () => { // Node prints `[class S] { count: 3 }`; the class object does not // carry compiled static fields, so the inspector refuses rather than From fb3806ce42a7717c84e4ce4e285bf62caf86d35a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 03:54:26 -0500 Subject: [PATCH 06/44] Search TypedArrays, read stdin through 'readable', and assign constructor-function globals - TypedArray indexOf, lastIndexOf and includes on every element kind, typed and checked-dynamic: strict equality (SameValueZero for includes) and fromIndex as ToIntegerOrInfinity. A Buffer reached through a Uint8Array value keeps Buffer's search. - process.stdin 'readable' with read() and read(size), including sizes above the read-ahead mark and input a once('readable') listener leaves for a later 'data' listener. for await over process.stdin yields Buffers. - Module bindings holding `new F()` of a constructor function can be read and reassigned from other functions. --- .../compiler/src/backend/llvm/expr-bytes.ts | 16 +++ .../src/frontend/lowering/containers/bytes.ts | 49 +++++++- .../src/frontend/lowering/lower-modules.ts | 1 - .../frontend/lowering/object-factory-new.ts | 31 +++-- packages/compiler/src/ir/ir.ts | 7 ++ packages/compiler/src/ir/validate.ts | 4 + .../corpus/7680-typed-array-search.ts.json | 6 + .../7681-typed-array-search-dynamic.mjs.json | 6 + .../7682-constructor-function-globals.js.json | 6 + packages/runtime/src/scr_bytes.c | 44 +++++++ packages/runtime/src/scr_bytes.h | 2 + packages/runtime/src/scr_dyn_invoke.c | 15 +++ packages/runtime/src/scr_events.c | 115 ++++++++++++++++-- tests/corpus/7680-typed-array-search.ts | 63 ++++++++++ .../7681-typed-array-search-dynamic.mjs | 28 +++++ .../7682-constructor-function-globals.js | 55 +++++++++ .../event-loop/stdin-for-await-buffers.mjs | 10 ++ .../event-loop/stdio-readable-large-size.mjs | 9 ++ .../event-loop/stdio-readable-once-rest.mjs | 12 ++ .../event-loop/stdio-readable-size.mjs | 18 +++ tests/fixtures/event-loop/stdio-readable.mjs | 19 +++ .../7680-typed-array-search.ts.json | 1 + .../7681-typed-array-search-dynamic.mjs.json | 1 + .../7682-constructor-function-globals.js.json | 1 + tests/harness/event-loop-cases.ts | 49 ++++++++ 25 files changed, 543 insertions(+), 25 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json create mode 100644 tests/corpus/7680-typed-array-search.ts create mode 100644 tests/corpus/7681-typed-array-search-dynamic.mjs create mode 100644 tests/corpus/7682-constructor-function-globals.js create mode 100644 tests/fixtures/event-loop/stdin-for-await-buffers.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-large-size.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-once-rest.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable-size.mjs create mode 100644 tests/fixtures/event-loop/stdio-readable.mjs create mode 100644 tests/harness/corpus-costs/7680-typed-array-search.ts.json create mode 100644 tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json create mode 100644 tests/harness/corpus-costs/7682-constructor-function-globals.js.json diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 41ddf2b9d8..2a368f0e26 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -936,6 +936,22 @@ export function emitBytesIntrinsic( B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`); return { name: t, type: e.type }; } + case "indexOfElem": + case "lastIndexOfElem": + case "includesElem": { + const includes = method === "includesElem"; + const idx = call( + "scr_bytes_ta_index_of", + "double (ptr, double, double, i1 zeroext, i1 zeroext, i1 zeroext)", + `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]?.name ?? f64Lit(0)}, i1 ${args.length > 1}, i1 ${method !== "lastIndexOfElem"}, i1 ${includes}`, + false, + false, + ); + if (!includes) return idx; + const t = B.tmp(); + B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`); + return { name: t, type: e.type }; + } case "fillElem": // Per-element TypedArray fill (non-u8): slice-style index // defaults, never throws; the receiver comes back +1. diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 995cb177aa..9cf68da9f4 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -907,9 +907,10 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { * (shared view), toReversed, copyWithin, set(src, * offset?), and the u8-only Buffer surface: toString(enc?) plus the * whole numeric read/write family (fixed widths BE/LE and the - * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS). Everything - * else the lib declares (fill, indexOf, reverse, ...) falls through to - * the SC2020 member fence. Null when this isn't a bytes method call. */ + * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS), and + * TypedArray fill, indexOf, lastIndexOf and includes. Everything else the + * lib declares (reverse, sort, ...) falls through to the SC2020 member + * fence. Null when this isn't a bytes method call. */ export function lowerBytesMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -1101,6 +1102,43 @@ export function lowerBytesMethodCall( loc, }; } + if ( + (name === "indexOf" || name === "lastIndexOf" || name === "includes") && + !(() => { + const symbol = lowerer.checker.getSymbolAtLocation(access.name); + return ( + symbol !== undefined && + symbol.declarations.some( + (declaration) => + ts.isInterfaceDeclaration(declaration.parent) && + declaration.parent.name.text === "Buffer", + ) + ); + })() + ) { + // TypedArray search: strict equality (includes: SameValueZero) with a + // relative fromIndex. Buffer's same-named methods keep their byte, + // string and Buffer needles. + if (nArgs < 1 || nArgs > 2 || call.arguments.nodes.some(ts.isSpreadElement)) { + lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call); + } + const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); + const args = call.arguments.nodes.map((a) => lowerer.lowerExprExpecting(a, F64)); + const method = + name === "indexOf" + ? "indexOfElem" + : name === "lastIndexOf" + ? "lastIndexOfElem" + : "includesElem"; + return { + kind: "bytesIntrinsic", + method, + receiver, + args, + type: name === "includes" ? BOOL : F64, + loc, + }; + } if (name === "set") { if (nArgs < 1 || nArgs > 2) { lowerer.noLowering(`.set with ${nArgs} arguments on typed arrays`, call); @@ -1210,9 +1248,8 @@ export function lowerBytesMethodCall( // The Buffer-declared comparison/search/mutation surface. All of // these resolve against the Buffer interface (the checker keeps most // off plain typed arrays; where the lib DOES declare a same-named - // TypedArray member — indexOf, includes, fill — the semantics differ, - // so only Buffer-declared resolutions lower and u8 receivers gate the - // rest). + // TypedArray member — indexOf, includes, fill — the semantics differ: + // those resolutions lowered above, and u8 receivers gate the rest). const declOnBuffer = (() => { const nameSym = lowerer.checker.getSymbolAtLocation(access.name); return ( diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5238333ad8..af2ed4a2cb 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2361,7 +2361,6 @@ export function collectGlobals( // References from separately-declared functions cascade to // their own per-site runtime fences. const factoryType = - !isLet && isJsSourceFile(sf) && !decl.type && !hasJsTypeAnnotation(decl) && diff --git a/packages/compiler/src/frontend/lowering/object-factory-new.ts b/packages/compiler/src/frontend/lowering/object-factory-new.ts index bcb675692a..c672f82e29 100644 --- a/packages/compiler/src/frontend/lowering/object-factory-new.ts +++ b/packages/compiler/src/frontend/lowering/object-factory-new.ts @@ -6,10 +6,14 @@ import { bindingNeverReassigned } from "./binding-analysis.js"; import { implicitDefaultInstance } from "./generic-functions.js"; import { type FnSig } from "./call-signatures.js"; -/** An ordinary constructor's explicit object return replaces its receiver. - * Without observing this or new.target, it can use the function's call ABI. */ -function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { - if (!ts.isIdentifier(expr.expression) || lowerer.peekLocal(expr.expression)) return false; +/** The JavaScript function declaration (or never-reassigned function + * expression binding) `new` calls, when it is an ordinary constructor: not + * a generator or async function. */ +function jsConstructorFunctionOf( + lowerer: Lowerer, + expr: ts.NewExpression, +): ts.FunctionDeclaration | ts.FunctionExpression | null { + if (!ts.isIdentifier(expr.expression) || lowerer.peekLocal(expr.expression)) return null; // This probe also sees ordinary classes during global collection. Leave // their deferred diagnostics for the class declaration's lowering. const wasCollecting = lowerer.collecting; @@ -20,7 +24,7 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { } finally { lowerer.collecting = wasCollecting; } - if (!symbol) return false; + if (!symbol) return null; const owner = symbol.declarations.find( (node) => (ts.isFunctionDeclaration(node) && node.body !== undefined) || @@ -45,7 +49,15 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { declaration.asteriskToken || declaration.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ) - return false; + return null; + return declaration; +} + +/** An ordinary constructor's explicit object return replaces its receiver. + * Without observing this or new.target, it can use the function's call ABI. */ +function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { + const declaration = jsConstructorFunctionOf(lowerer, expr); + if (!declaration?.body) return false; const objectReturn = (expression: ts.Expression): boolean => ts.isObjectLiteralExpression(expression) || ts.isArrayLiteralExpression(expression) || @@ -81,8 +93,13 @@ function isObjectFactoryNew(lowerer: Lowerer, expr: ts.NewExpression): boolean { /** Collection must register the shared result before reaching factory * bodies: those bodies can reference later globals in the same module. */ +/** The storage of a JavaScript module binding initialized with `new` of an + * ordinary function: a factory's result and a constructor function's + * receiver (`const head = new Segment()`) are both checked-dynamic + * values, which functions declared elsewhere in the module read through + * the global. */ export function objectFactoryGlobalType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { - return isObjectFactoryNew(lowerer, expr) ? DYN : null; + return jsConstructorFunctionOf(lowerer, expr) !== null ? DYN : null; } export function objectFactorySignature(lowerer: Lowerer, expr: ts.NewExpression): FnSig | null { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 6d0760b819..a26cb638f0 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2484,6 +2484,13 @@ export type IrBytesIntrinsicMethod = * (same observable number-fill result; Buffer's throwing offset * validation). */ | "fillElem" + /** TypedArray.prototype.indexOf / lastIndexOf / includes on any + * receiver: [f64 searchElement, f64 fromIndex?] → f64 index (-1 when + * absent) or bool; strict equality (includes: SameValueZero), a present + * fromIndex counts from the end when negative, never throws. */ + | "indexOfElem" + | "lastIndexOfElem" + | "includesElem" | "copy" | "swap16" | "swap32" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 02825f0deb..77909fb52b 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2196,6 +2196,10 @@ function validateFunction( fillNum: { argTypes: [F64, F64, F64], minArgs: 1, result: BYTES_U8 }, // Per-element TypedArray fill (any elem — the non-u8 fill path). fillElem: { argTypes: [F64, F64, F64], minArgs: 1, result: bytesOf(recv.elem) }, + // TypedArray search (any elem). + indexOfElem: { argTypes: [F64, F64], minArgs: 1, result: F64 }, + lastIndexOfElem: { argTypes: [F64, F64], minArgs: 1, result: F64 }, + includesElem: { argTypes: [F64, F64], minArgs: 1, result: BOOL }, fillStr: { argTypes: [STRING, STRING, F64, F64], minArgs: 2, result: BYTES_U8 }, copy: { argTypes: [BYTES_U8, F64, F64, F64], minArgs: 1, result: F64 }, swap16: { argTypes: [], minArgs: 0, result: BYTES_U8 }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json new file mode 100644 index 0000000000..d93d9afdca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7680-typed-array-search.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7680-typed-array-search.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json new file mode 100644 index 0000000000..ab91940c08 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7681-typed-array-search-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7681-typed-array-search-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json new file mode 100644 index 0000000000..26d05338c3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7682-constructor-function-globals.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7682-constructor-function-globals.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7bcfd0cfb7..26f1a57a52 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -3300,6 +3300,50 @@ double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd) return scr_bytes_index_of(b, &needle, off, 1, fwd); } +/* TypedArray.prototype.indexOf and lastIndexOf (strict equality, so NaN + * never matches) and includes (`same_value_zero`: NaN matches NaN). `from` + * is the fromIndex argument when `has_from` (ToIntegerOrInfinity, negative + * counts from the end), else the default: 0, or len - 1 searching + * backwards. Returns the element index, or -1. Never throws. */ +double scr_bytes_ta_index_of(const ScrBytes *b, double v, double from, bool has_from, bool forward, + bool same_value_zero) { + // A Buffer viewed as a Uint8Array keeps Buffer's own search methods, whose + // byte needle and byteOffset rules differ (an absent offset is NaN there). + if (scr_bytes_is_buffer(b)) return scr_bytes_index_of_num(b, v, has_from ? from : NAN, forward); + size_t len = b->len; + if (len == 0) return -1; + double k = has_from ? (from != from ? 0 : trunc(from)) : forward ? 0 : (double)len - 1; + bool nan = v != v; + if (nan && !same_value_zero) return -1; + bool bytes = (b->elem == SCR_BYTES_U8 || b->elem == SCR_BYTES_U8C) && !b->shared; + if (forward) { + if (k >= (double)len) return -1; + if (k < 0) k = k + (double)len < 0 ? 0 : k + (double)len; + size_t start = (size_t)k; + if (bytes) { + if (!(v >= 0 && v <= 255 && v == trunc(v))) return -1; + const uint8_t *hit = memchr(b->data + start, (int)v, len - start); + return hit ? (double)(hit - b->data) : -1; + } + for (size_t i = start; i < len; i++) { + double e = scr_bytes_load(b, i); + if (e == v || (nan && e != e)) return (double)i; + } + return -1; + } + if (k < 0) { + k += (double)len; + if (k < 0) return -1; + } else if (k > (double)len - 1) { + k = (double)len - 1; + } + for (size_t i = (size_t)k + 1; i-- > 0;) { + double e = scr_bytes_load(b, i); + if (e == v || (nan && e != e)) return (double)i; + } + return -1; +} + /* fill's shared core over a normalized byte pattern. nargs counts the * present offset/end args. An EMPTY pattern zero-fills when zero_ok * (Node's '' string) and is the constant TypeError otherwise (an empty diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 9b382dda5f..13b31b1831 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -499,6 +499,8 @@ ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n); void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name); double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, double align, bool fwd); double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd); +double scr_bytes_ta_index_of(const ScrBytes *b, double v, double from, bool has_from, bool forward, + bool same_value_zero); ScrBytes *scr_bytes_fill(ScrBytes *b, const ScrBytes *pattern, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_num(ScrBytes *b, double v, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc, diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 4039fda94d..d57ca24acc 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -1704,6 +1704,21 @@ static ScrDyn *scr_dyn_invoke_impl( scr_str_release(joined); return out; } + if (!recv->buffer && + ((mid == DYN_M_indexOf) || (mid == DYN_M_lastIndexOf) || (mid == DYN_M_includes))) { + /* TypedArray search: strict equality (includes: SameValueZero), so a + * needle that is not a number matches nothing; fromIndex counts as + * given whenever it is passed, undefined included. */ + double from = 0; + if (argc > 1) { + if (!scr_dyn_number_coerce_js(args[1], &from)) return NULL; + } + bool includes = mid == DYN_M_includes; + double found = argc > 0 && args[0]->kind == SCR_DYN_NUM + ? scr_bytes_ta_index_of(bytes, args[0]->v.num, from, argc > 1, mid != DYN_M_lastIndexOf, includes) + : -1; + return includes ? scr_dyn_new_bool(found >= 0) : scr_dyn_new_num(found); + } if (dyn_bytes_proto_real(mid)) { dyn_throw_unsupported(scr_bytes_elem_name(bytes->elem), method); return NULL; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 3391f54b1d..bc554a445a 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -390,6 +390,13 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; +/* 'readable' listeners (paused mode, as Node's once one is added): input + * stays buffered for read(), and the event fires on the next tick when + * input arrives while a read wanted more (`need`), and once at EOF. */ +static SCR_TL ScrStdinEndL *scr_stdin_readable = NULL; +static SCR_TL size_t scr_stdin_nreadable = 0, scr_stdin_readable_cap = 0; +static SCR_TL bool scr_stdin_readable_need = false; +static SCR_TL bool scr_stdin_readable_queued = false; #ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until @@ -475,6 +482,78 @@ void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { scr_stdin_nend++; } +static void scr_stdin_finish(bool fire_end); + +static void scr_stdin_emit_readable(void) { + scr_stdin_readable_queued = false; + size_t length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + if (scr_stdin_eof || scr_stdin_destroyed || (!length && !scr_stdin_read_eof)) return; + size_t n = scr_stdin_nreadable; + ScrStdinEndL *snap = malloc((n ? n : 1) * sizeof *snap); + if (!snap) scr_events_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = scr_stdin_readable[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (snap[i].once) { + for (size_t j = 0; j < scr_stdin_nreadable; j++) { + if (scr_stdin_readable[j].cb == snap[i].cb) { + scr_closure_release(scr_stdin_readable[j].cb); + memmove(scr_stdin_readable + j, scr_stdin_readable + j + 1, + (scr_stdin_nreadable - j - 1) * sizeof *scr_stdin_readable); + scr_stdin_nreadable--; + break; + } + } + } + if (!scr_exc_pending() && !scr_stdin_eof && !scr_stdin_destroyed) + ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); + length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + /* Another event is due once the buffer reaches the 64 KiB mark, or the + * larger size a listener's read(size) asked for. */ + scr_stdin_readable_need = + !scr_stdin_read_eof && (length <= 65536 || length < scr_stdin_read_demand); +} + +static void scr_stdin_queue_readable(void) { + if (scr_stdin_readable_queued || !scr_stdin_nreadable) return; + scr_stdin_readable_queued = true; + scr_next_tick_raw(scr_stdin_emit_readable); +} + +/* Adding a 'readable' listener starts reading ahead to the 64 KiB mark; a + * buffer that already holds input fires the event at once (next tick). */ +static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { + if (scr_stdin_eof || scr_stdin_destroyed) { + scr_closure_release(cb); + return; + } + if (scr_stdin_nreadable == scr_stdin_readable_cap) { + scr_stdin_readable_cap = scr_stdin_readable_cap ? scr_stdin_readable_cap * 2 : 2; + scr_stdin_readable = realloc(scr_stdin_readable, scr_stdin_readable_cap * sizeof *scr_stdin_readable); + if (!scr_stdin_readable) scr_events_oom(); + } + scr_stdin_readable[scr_stdin_nreadable].cb = cb; + scr_stdin_readable[scr_stdin_nreadable].once = once; + scr_stdin_nreadable++; + scr_stdin_readable_need = true; + if (scr_stdin_read_demand < 65536) scr_stdin_read_demand = 65536; + if (scr_stdin_buffer && scr_stdin_buffer->len) scr_stdin_queue_readable(); +} + +/* The input ended: with 'readable' listeners the event fires once more and + * 'end' waits for the read that finds nothing left; otherwise 'end' fires + * once the buffer is drained. */ +static void scr_stdin_reached_eof(void) { + scr_stdin_read_eof = true; + if (scr_stdin_nreadable) scr_stdin_queue_readable(); + else if (!scr_stdin_buffer) scr_stdin_finish(true); +} + void scr_stdin_on_error(ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); @@ -528,6 +607,10 @@ static void scr_stdin_drop_listeners(void) { for (size_t i = 0; i < scr_stdin_ndata; i++) scr_closure_release(scr_stdin_data[i].cb); for (size_t i = 0; i < scr_stdin_nend; i++) scr_closure_release(scr_stdin_end[i].cb); for (size_t i = 0; i < scr_stdin_nerr; i++) scr_closure_release(scr_stdin_err[i].cb); + for (size_t i = 0; i < scr_stdin_nreadable; i++) scr_closure_release(scr_stdin_readable[i].cb); + free(scr_stdin_readable); + scr_stdin_readable = NULL; + scr_stdin_nreadable = scr_stdin_readable_cap = 0; free(scr_stdin_data); free(scr_stdin_end); free(scr_stdin_err); @@ -627,7 +710,8 @@ static void scr_stdin_deliver(ScrBytes *chunk); /* A chunk that arrived (moves): delivered, or buffered while paused or * without a consumer. */ static void scr_stdin_arrived(ScrBytes *chunk) { - if (scr_stdin_paused || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { + if (scr_stdin_paused || scr_stdin_nreadable || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { + if (scr_stdin_nreadable && scr_stdin_readable_need) scr_stdin_queue_readable(); if (scr_stdin_buffer) { size_t before = scr_stdin_buffer->len; ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + chunk->len)); @@ -645,7 +729,11 @@ static void scr_stdin_arrived(ScrBytes *chunk) { static void scr_stdin_service(void) { if (!scr_stdin_watching()) return; - if (!scr_stdin_paused && scr_stdin_buffer) { + /* Buffered input goes to a consumer: 'data' listeners, a resumed + * stream, or a parked for-await read. After the last 'readable' listener + * it stays buffered until one arrives, as in Node. */ + if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffer && + (scr_stdin_ndata > 0 || scr_stdin_resumed || scr_stdin_waiter != NULL)) { ScrBytes *chunk = scr_stdin_buffer; scr_stdin_buffer = NULL; scr_stdin_deliver(chunk); @@ -653,7 +741,7 @@ static void scr_stdin_service(void) { return; } if (scr_stdin_read_eof) { - if (!scr_stdin_buffer) scr_stdin_finish(true); + if (!scr_stdin_buffer && !scr_stdin_nreadable) scr_stdin_finish(true); return; } if (scr_stdin_fed) { @@ -662,10 +750,7 @@ static void scr_stdin_service(void) { bool ended = false; ScrBytes *chunk = scr_stdin_fed_take(&ended); if (chunk) scr_stdin_arrived(chunk); - else if (ended) { - scr_stdin_read_eof = true; - if (!scr_stdin_buffer) scr_stdin_finish(true); - } + else if (ended) scr_stdin_reached_eof(); return; } #ifdef _WIN32 @@ -727,8 +812,7 @@ static void scr_stdin_service(void) { return; } if (n == 0) { - scr_stdin_read_eof = true; - if (!scr_stdin_buffer) scr_stdin_finish(true); + scr_stdin_reached_eof(); return; } ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); @@ -802,6 +886,8 @@ static void scr_stdin_deliver(ScrBytes *chunk) { #endif ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; + /* Node's stdin yields Buffers. */ + chunk->is_buffer = true; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, scr_bytes_release_v, NULL); scr_promise_release(p); @@ -878,6 +964,7 @@ static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { } #endif static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } +static void scr_stdio_readable(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); ScrDyn *arg = scr_dyn_from_error(error); @@ -985,6 +1072,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { if (scr_stdin_read_eof && wanted > available) wanted = available; if (wanted == 0 || wanted > available) { if (!scr_stdin_eof && !scr_stdin_destroyed) { + scr_stdin_readable_need = true; scr_stdin_read_demand = wanted > 65536 ? wanted : 65536; if (scr_stdin_read_eof) scr_next_tick_raw(scr_stdio_finish_read); } @@ -995,6 +1083,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { scr_bytes_release(scr_stdin_buffer); scr_stdin_buffer = rest; scr_stdin_read_demand = 65536; + if (!rest && !scr_stdin_read_eof) scr_stdin_readable_need = true; scr_stdin_deliver(chunk); ScrDyn *result = scr_dyn_new_chunk(chunk); scr_bytes_release(chunk); @@ -1150,12 +1239,14 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; + bool readable = stream->fd == 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 8 && + memcmp(event, "readable", 8) == 0; #ifdef SCR_STDIO_EXTENDED bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && memcmp(event, "resize", 6) == 0; - if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { + if ((!data && !end && !error && !readable && !resize) || (stream->fd != 0 && !error && !resize)) { #else - if ((!data && !end && !error) || (stream->fd != 0 && !error)) { + if ((!data && !end && !error && !readable) || (stream->fd != 0 && !error)) { #endif result = scr_stdio_refusal("event"); goto done; @@ -1175,6 +1266,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); #endif if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); + if (readable) scr_stdin_on_readable(scr_stdio_callback(listener, (void *)scr_stdio_readable), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { #define REMOVE_STDIO_LISTENER(list, count, fire) \ @@ -1187,6 +1279,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } if (data) { REMOVE_STDIO_LISTENER(scr_stdin_data, scr_stdin_ndata, scr_stdio_data); } if (end) { REMOVE_STDIO_LISTENER(scr_stdin_end, scr_stdin_nend, scr_stdio_end); } + if (readable) { REMOVE_STDIO_LISTENER(scr_stdin_readable, scr_stdin_nreadable, scr_stdio_readable); } if (error) { REMOVE_STDIO_LISTENER(scr_stdin_err, scr_stdin_nerr, scr_stdio_error); } #undef REMOVE_STDIO_LISTENER } diff --git a/tests/corpus/7680-typed-array-search.ts b/tests/corpus/7680-typed-array-search.ts new file mode 100644 index 0000000000..353736e098 --- /dev/null +++ b/tests/corpus/7680-typed-array-search.ts @@ -0,0 +1,63 @@ +// TypedArray.prototype.indexOf, lastIndexOf and includes on every numeric +// element kind: strict equality (NaN never found) for indexOf/lastIndexOf, +// SameValueZero (NaN found, -0 equals 0) for includes, and fromIndex as +// ToIntegerOrInfinity, counted from the end when negative. +const bytes = new Uint8Array([1, 2, 10, 3, 10, 255]); +console.log("u8", bytes.indexOf(10), bytes.indexOf(10, 3), bytes.indexOf(10, 5), bytes.lastIndexOf(10)); +console.log("u8 from", bytes.indexOf(10, -2), bytes.indexOf(10, -100), bytes.indexOf(1, Infinity), bytes.indexOf(10, 2.9)); +console.log("u8 last", bytes.lastIndexOf(10, 3), bytes.lastIndexOf(10, -3), bytes.lastIndexOf(1, -100), bytes.lastIndexOf(255, 99)); +console.log("u8 values", bytes.indexOf(256 + 10), bytes.indexOf(-246), bytes.indexOf(10.5), bytes.includes(255), bytes.includes(0)); +console.log("u8 includes from", bytes.includes(1, 1), bytes.includes(255, -1), bytes.includes(2, -5)); + +const clamped = new Uint8ClampedArray([0, 300, -5, 127.5]); +console.log("u8c", clamped.indexOf(255), clamped.indexOf(0, 1), clamped.lastIndexOf(0), clamped.indexOf(128)); + +const signed = new Int8Array([-1, 127, -128, 0]); +console.log("i8", signed.indexOf(-1), signed.indexOf(255), signed.indexOf(-128), signed.lastIndexOf(0)); + +const shorts = new Int16Array([5, -300, 5, 7]); +const ushorts = new Uint16Array([65535, 1, 65535]); +console.log("16", shorts.indexOf(-300), shorts.lastIndexOf(5), ushorts.indexOf(-1), ushorts.lastIndexOf(65535, 1)); + +const ints = new Int32Array([-2147483648, 0, 2147483647, 0]); +const uints = new Uint32Array([4294967295, 3]); +console.log("32", ints.indexOf(2147483647), ints.lastIndexOf(0), uints.indexOf(4294967295), uints.includes(-1)); + +const floats = new Float64Array([1.5, NaN, -0, 0, Infinity]); +console.log("f64", floats.indexOf(NaN), floats.lastIndexOf(NaN), floats.includes(NaN), floats.indexOf(0), floats.indexOf(-0)); +console.log("f64 more", floats.lastIndexOf(0), floats.indexOf(Infinity), floats.includes(-Infinity), floats.includes(NaN, 2)); + +const singles = new Float32Array([1.1, 0.5, NaN]); +console.log("f32", singles.indexOf(1.1), singles.indexOf(Math.fround(1.1)), singles.indexOf(0.5), singles.includes(NaN), singles.indexOf(NaN)); + +const empty = new Float64Array(0); +console.log("empty", empty.indexOf(0), empty.lastIndexOf(0), empty.includes(0), empty.includes(0, -1)); + +// Views search their own window of the buffer. +const buffer = new ArrayBuffer(16); +const whole = new Uint8Array(buffer); +whole.set([9, 9, 1, 2, 9, 3, 9, 9]); +const view = new Uint8Array(buffer, 2, 4); +const sub = whole.subarray(3, 6); +console.log("views", view.indexOf(9), view.lastIndexOf(9), view.indexOf(1), sub.indexOf(9), sub.includes(1), sub.lastIndexOf(3)); +const words = new Uint16Array(buffer, 2, 3); +console.log("word view", words.indexOf(0x0201), words.length); + +// Shared memory searches the same way. +const shared = new Int32Array(new SharedArrayBuffer(16)); +shared[1] = 7; +shared[3] = 7; +console.log("shared", shared.indexOf(7), shared.lastIndexOf(7), shared.includes(0), shared.indexOf(7, 2)); + +// A loop that finds every occurrence, as a line splitter does. +const text = new TextEncoder().encode("one\ntwo\n\nthree\n"); +const lines: number[] = []; +for (let at = text.indexOf(10); at !== -1; at = text.indexOf(10, at + 1)) lines.push(at); +console.log("lines", lines.join(",")); + +// A Buffer passed as a Uint8Array keeps Buffer's search: a number needle +// is taken as a byte, and a NaN byteOffset searches the whole buffer. +function searchView(view: Uint8Array): string { + return [view.indexOf(97 + 256), view.lastIndexOf(98, NaN), view.includes(353), view.indexOf(98, -1)].join(","); +} +console.log(searchView(Buffer.from("abcab")), searchView(new Uint8Array([97, 98, 99, 97, 98]))); diff --git a/tests/corpus/7681-typed-array-search-dynamic.mjs b/tests/corpus/7681-typed-array-search-dynamic.mjs new file mode 100644 index 0000000000..643ba0cfa2 --- /dev/null +++ b/tests/corpus/7681-typed-array-search-dynamic.mjs @@ -0,0 +1,28 @@ +// TypedArray search on untyped values (parameters of untyped functions and +// object members): needles that are not numbers are never found (strict +// equality), fromIndex may be undefined, a string or a fraction, and +// Buffers keep their own indexOf, which wraps number needles and takes +// strings. +function views(bytes, floats, shorts) { + return { bytes, floats, shorts }; +} +const { bytes, floats, shorts } = views( + new Uint8Array([1, 2, 10, 3, 10]), + new Float64Array([0.5, NaN, -0]), + new Int16Array([-1, 300, -1]), +); +console.log(bytes.indexOf(10), bytes.indexOf(10, 3), bytes.lastIndexOf(10), bytes.includes(3), bytes.includes(4)); +console.log(bytes.lastIndexOf(1, undefined), bytes.lastIndexOf(10), bytes.indexOf(10, undefined), bytes.includes(1, undefined)); +console.log(bytes.indexOf(10, '3'), bytes.indexOf(10, 2.9), bytes.indexOf(10, -1), bytes.lastIndexOf(10, -2), bytes.indexOf(10, null)); +console.log(bytes.indexOf('10'), bytes.includes('1'), bytes.indexOf(10n), bytes.indexOf(), bytes.includes(), bytes.indexOf(266)); +console.log(floats.indexOf(NaN), floats.includes(NaN), floats.indexOf(0), floats.lastIndexOf(-0), floats.includes(0.5, 1)); +console.log(shorts.indexOf(-1), shorts.lastIndexOf(-1), shorts.indexOf(300, -2), shorts.includes(65535)); +const holder = { view: bytes.subarray(2) }; +console.log(holder.view.indexOf(10), holder.view.lastIndexOf(10), holder.view.includes(1)); +const buffer = Buffer.from([1, 2, 10, 3, 10]); +const boxed = [buffer]; +console.log(boxed[0].indexOf(10), boxed[0].indexOf(266), boxed[0].indexOf('\n'), boxed[0].includes(Buffer.from([3, 10]))); +console.log(boxed[0].subarray(3).indexOf(10), Buffer.isBuffer(boxed[0].subarray(3)), boxed[0].lastIndexOf(10, -2)); +let count = 0; +for (let at = bytes.indexOf(10); at !== -1; at = bytes.indexOf(10, at + 1)) count++; +console.log('count', count); diff --git a/tests/corpus/7682-constructor-function-globals.js b/tests/corpus/7682-constructor-function-globals.js new file mode 100644 index 0000000000..58292e4acb --- /dev/null +++ b/tests/corpus/7682-constructor-function-globals.js @@ -0,0 +1,55 @@ +// Module bindings that hold `new F()` of an ordinary constructor function +// (const, let and var), read and reassigned by functions declared elsewhere +// in the module, such as the head and tail of a linked list. +'use strict'; +function Segment(prev) { + this.prev = prev; + this.next = null; + this.data = new Array(4); + this.pos = 0; +} +const head = new Segment(); +let current = head; +var spare = new Segment(head); + +function append(value) { + if (current.pos === current.data.length) { + current = current.next || (current.next = new Segment(current)); + } + current.data[current.pos++] = value; +} + +function collect() { + const out = []; + for (let node = head; node; node = node.next) out.push(node.data.slice(0, node.pos).join('')); + return out.join('|'); +} + +function reset() { + current = head; + for (let node = head; node; node = node.next) node.pos = 0; +} + +for (const ch of 'abcdefghij') append(ch); +console.log(collect(), current === head, current.prev === head.next ? 'two' : 'more'); +reset(); +append('z'); +console.log(collect(), current === head, head.next !== null); +console.log(spare.prev === head, spare instanceof Segment, typeof spare.data); +spare = new Segment(spare); +console.log(spare.prev.prev === head); + +function Counter(start) { + this.value = start; +} +Counter.prototype.bump = function () { + return ++this.value; +}; +let counter = new Counter(10); +function bumpTwice() { + counter.bump(); + return counter.bump(); +} +console.log(bumpTwice(), counter.value); +counter = new Counter(0); +console.log(bumpTwice()); diff --git a/tests/fixtures/event-loop/stdin-for-await-buffers.mjs b/tests/fixtures/event-loop/stdin-for-await-buffers.mjs new file mode 100644 index 0000000000..d64ae460a4 --- /dev/null +++ b/tests/fixtures/event-loop/stdin-for-await-buffers.mjs @@ -0,0 +1,10 @@ +// for await over process.stdin yields Buffers: Buffer.isBuffer holds and +// indexOf takes a byte and a start offset (only chunking-independent facts +// are printed). +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +console.log('buffers', chunks.every((chunk) => Buffer.isBuffer(chunk))); +const all = Buffer.concat(chunks); +const newlines = []; +for (let at = all.indexOf(10); at !== -1; at = all.indexOf(10, at + 1)) newlines.push(at); +console.log('newlines', newlines.join(','), all.subarray(newlines[0] + 1).indexOf('#')); diff --git a/tests/fixtures/event-loop/stdio-readable-large-size.mjs b/tests/fixtures/event-loop/stdio-readable-large-size.mjs new file mode 100644 index 0000000000..f1f44edce9 --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-large-size.mjs @@ -0,0 +1,9 @@ +// read(size) above the 64 KiB read-ahead mark: 'readable' keeps firing as +// input arrives until the requested size is buffered. +const input = process.stdin; +const sizes = []; +input.on('readable', () => { + let chunk; + while ((chunk = input.read(150000)) !== null) sizes.push(chunk.length); +}); +input.on('end', () => console.log(sizes.join(','))); diff --git a/tests/fixtures/event-loop/stdio-readable-once-rest.mjs b/tests/fixtures/event-loop/stdio-readable-once-rest.mjs new file mode 100644 index 0000000000..81eae144b2 --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-once-rest.mjs @@ -0,0 +1,12 @@ +// A once('readable') listener that reads part of the input: the rest stays +// buffered for a 'data' listener added afterwards. +const input = process.stdin; +input.once('readable', () => { + const head = input.read(3); + console.log('head', head === null ? null : head.toString('ascii')); + setTimeout(() => { + const rest = []; + input.on('data', (chunk) => rest.push(chunk.toString('ascii'))); + input.on('end', () => console.log('rest', rest.join(''))); + }, 50); +}); diff --git a/tests/fixtures/event-loop/stdio-readable-size.mjs b/tests/fixtures/event-loop/stdio-readable-size.mjs new file mode 100644 index 0000000000..9ff76dafeb --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-size.mjs @@ -0,0 +1,18 @@ +// Fixed-size reads of stdin: read(n) answers null until n bytes are +// buffered, and the rest once the input ended. +const input = process.stdin; +const chunks = []; +const counts = []; +input.on('readable', () => { + let reads = 0; + let chunk; + while ((chunk = input.read(4)) !== null) { + chunks.push(chunk.toString('ascii')); + reads++; + } + counts.push(reads); +}); +input.on('end', () => { + console.log(chunks.join('|')); + console.log(counts.join(',')); +}); diff --git a/tests/fixtures/event-loop/stdio-readable.mjs b/tests/fixtures/event-loop/stdio-readable.mjs new file mode 100644 index 0000000000..324a4b57b9 --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable.mjs @@ -0,0 +1,19 @@ +// process.stdin in paused mode through 'readable': each event drains the +// buffer with read(), one more event follows EOF, and 'end' comes after the +// read that finds the buffer empty. +const input = process.stdin; +const events = []; +let text = ''; +input.on('readable', () => { + let reads = 0; + let chunk; + while ((chunk = input.read()) !== null) { + text += String(chunk); + reads++; + } + events.push(`readable ${reads}`); +}); +input.on('end', () => { + console.log(events.join(', ')); + console.log(JSON.stringify(text), input.readableLength); +}); diff --git a/tests/harness/corpus-costs/7680-typed-array-search.ts.json b/tests/harness/corpus-costs/7680-typed-array-search.ts.json new file mode 100644 index 0000000000..37f8b6e89d --- /dev/null +++ b/tests/harness/corpus-costs/7680-typed-array-search.ts.json @@ -0,0 +1 @@ +{ "plain": 2204, "san": 3060 } diff --git a/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json b/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json new file mode 100644 index 0000000000..b6fcd5effb --- /dev/null +++ b/tests/harness/corpus-costs/7681-typed-array-search-dynamic.mjs.json @@ -0,0 +1 @@ +{ "plain": 1649, "san": 958 } diff --git a/tests/harness/corpus-costs/7682-constructor-function-globals.js.json b/tests/harness/corpus-costs/7682-constructor-function-globals.js.json new file mode 100644 index 0000000000..90fcede922 --- /dev/null +++ b/tests/harness/corpus-costs/7682-constructor-function-globals.js.json @@ -0,0 +1 @@ +{ "plain": 1623, "san": 962 } diff --git a/tests/harness/event-loop-cases.ts b/tests/harness/event-loop-cases.ts index 40bc7988d1..1b75defaa0 100644 --- a/tests/harness/event-loop-cases.ts +++ b/tests/harness/event-loop-cases.ts @@ -34,6 +34,55 @@ export const eventLoopCases: EventLoopCase[] = [ fixture: "stdio-paused-exit.mjs", script: { writes: [], end: false }, }, + { + title: "stdio values: 'readable' drains with read(), then fires once more at EOF", + fixture: "stdio-readable.mjs", + script: { + writes: [ + { delayMs: 0, data: "alpha " }, + { delayMs: 60, data: "beta\n" }, + ], + end: true, + }, + }, + { + title: "stdio values: 'readable' with read(size) holds a partial tail until EOF", + fixture: "stdio-readable-size.mjs", + script: { writes: [{ delayMs: 0, data: "abcdefghij" }], end: true }, + }, + { + title: "stdio values: input a once('readable') listener leaves stays buffered for 'data'", + fixture: "stdio-readable-once-rest.mjs", + script: { writes: [{ delayMs: 0, data: "abcdefgh" }], end: true }, + }, + { + title: "stdio values: 'readable' with a read(size) above the read-ahead mark", + fixture: "stdio-readable-large-size.mjs", + script: { + writes: [ + { delayMs: 0, data: "a".repeat(70000) }, + { delayMs: 30, data: "b".repeat(70000) }, + { delayMs: 30, data: "c".repeat(60000) }, + ], + end: true, + }, + }, + { + title: "stdio values: 'readable' on an immediately ended pipe", + fixture: "stdio-readable.mjs", + script: { writes: [], end: true }, + }, + { + title: "for-await: process.stdin yields Buffers", + fixture: "stdin-for-await-buffers.mjs", + script: { + writes: [ + { delayMs: 0, data: "#one\nabcd\n" }, + { delayMs: 40, data: "#two\nefgh\n" }, + ], + end: true, + }, + }, { title: "read-all: one instant write, then EOF", fixture: "stdin-read-all.ts", From 2c44d3e3abe229cab1062e29b23dbe1bfc781948 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 05:15:45 -0500 Subject: [PATCH 07/44] Stop reading stdin at the read-ahead mark while 'readable' listeners hold it - With 'readable' listeners the stream stays paused, as in Node: 'data' listeners, resume() and a parked for-await read no longer keep the loop reading once the held input reaches the 64 KiB mark (or a larger read(size)); reading resumes when a read drains it. - Held input is a list of chunks with a running length, so an arrival appends without copying what is already held, and read() joins only the bytes it returns. - New event-loop case: 'readable', 'data' and resume() with no read() for 400 ms while 1 MiB arrives. --- packages/runtime/src/scr_events.c | 123 +++++++++++++----- .../stdio-readable-backpressure.mjs | 27 ++++ tests/harness/event-loop-cases.ts | 11 ++ 3 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/event-loop/stdio-readable-backpressure.mjs diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index bc554a445a..6e0bf51467 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -388,7 +388,12 @@ static SCR_TL bool scr_stdin_destroyed = false; static SCR_TL bool scr_stdin_paused = false; static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; -static SCR_TL ScrBytes *scr_stdin_buffer; +/* Input held for read() and later consumers, as Node's BufferList: the + * chunks in arrival order and their total length, so an arrival appends + * without copying what is already held. */ +static SCR_TL ScrBytes **scr_stdin_held = NULL; +static SCR_TL size_t scr_stdin_nheld = 0, scr_stdin_held_cap = 0; +static SCR_TL size_t scr_stdin_buffered = 0; static SCR_TL size_t scr_stdin_read_demand; /* 'readable' listeners (paused mode, as Node's once one is added): input * stays buffered for read(), and the event fires on the next tick when @@ -398,6 +403,66 @@ static SCR_TL size_t scr_stdin_nreadable = 0, scr_stdin_readable_cap = 0; static SCR_TL bool scr_stdin_readable_need = false; static SCR_TL bool scr_stdin_readable_queued = false; +/* Appends an arrived chunk (moves) to the held input. */ +static void scr_stdin_hold(ScrBytes *chunk) { + if (chunk->len == 0) { + scr_bytes_release(chunk); + return; + } + if (scr_stdin_nheld == scr_stdin_held_cap) { + scr_stdin_held_cap = scr_stdin_held_cap ? scr_stdin_held_cap * 2 : 4; + scr_stdin_held = realloc(scr_stdin_held, scr_stdin_held_cap * sizeof *scr_stdin_held); + if (!scr_stdin_held) scr_events_oom(); + } + scr_stdin_held[scr_stdin_nheld++] = chunk; + scr_stdin_buffered += chunk->len; +} + +/* The first n held bytes as one chunk (+1), 0 < n <= scr_stdin_buffered. + * A whole first chunk moves out without a copy; otherwise the bytes are + * joined once, and a partly taken chunk keeps its rest. */ +static ScrBytes *scr_stdin_take(size_t n) { + ScrBytes *first = scr_stdin_held[0]; + size_t used = 0; + ScrBytes *out; + if (n == first->len) { + out = first; + used = 1; + } else if (n < first->len) { + out = scr_bytes_slice(first, 0, (double)n); + scr_stdin_held[0] = scr_bytes_slice(first, (double)n, (double)first->len); + scr_bytes_release(first); + } else { + out = scr_bytes_new(SCR_BYTES_U8, (double)n); + size_t at = 0; + while (at < n) { + ScrBytes *chunk = scr_stdin_held[used]; + size_t part = chunk->len <= n - at ? chunk->len : n - at; + memcpy(out->data + at, chunk->data, part); + at += part; + if (part == chunk->len) { + scr_bytes_release(chunk); + used++; + } else { + scr_stdin_held[used] = scr_bytes_slice(chunk, (double)part, (double)chunk->len); + scr_bytes_release(chunk); + } + } + } + memmove(scr_stdin_held, scr_stdin_held + used, (scr_stdin_nheld - used) * sizeof *scr_stdin_held); + scr_stdin_nheld -= used; + scr_stdin_buffered -= n; + return out; +} + +static void scr_stdin_drop_held(void) { + for (size_t i = 0; i < scr_stdin_nheld; i++) scr_bytes_release(scr_stdin_held[i]); + free(scr_stdin_held); + scr_stdin_held = NULL; + scr_stdin_nheld = scr_stdin_held_cap = 0; + scr_stdin_buffered = 0; +} + #ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until * set) and the StringDecoder's packed pending partial sequence, so a @@ -486,7 +551,7 @@ static void scr_stdin_finish(bool fire_end); static void scr_stdin_emit_readable(void) { scr_stdin_readable_queued = false; - size_t length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + size_t length = scr_stdin_buffered; if (scr_stdin_eof || scr_stdin_destroyed || (!length && !scr_stdin_read_eof)) return; size_t n = scr_stdin_nreadable; ScrStdinEndL *snap = malloc((n ? n : 1) * sizeof *snap); @@ -512,7 +577,7 @@ static void scr_stdin_emit_readable(void) { scr_closure_release(snap[i].cb); } free(snap); - length = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + length = scr_stdin_buffered; /* Another event is due once the buffer reaches the 64 KiB mark, or the * larger size a listener's read(size) asked for. */ scr_stdin_readable_need = @@ -542,7 +607,7 @@ static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { scr_stdin_nreadable++; scr_stdin_readable_need = true; if (scr_stdin_read_demand < 65536) scr_stdin_read_demand = 65536; - if (scr_stdin_buffer && scr_stdin_buffer->len) scr_stdin_queue_readable(); + if (scr_stdin_buffered) scr_stdin_queue_readable(); } /* The input ended: with 'readable' listeners the event fires once more and @@ -551,7 +616,7 @@ static void scr_stdin_on_readable(ScrClosure *cb /*moves*/, bool once) { static void scr_stdin_reached_eof(void) { scr_stdin_read_eof = true; if (scr_stdin_nreadable) scr_stdin_queue_readable(); - else if (!scr_stdin_buffer) scr_stdin_finish(true); + else if (!scr_stdin_buffered) scr_stdin_finish(true); } void scr_stdin_on_error(ScrClosure *cb /*moves*/, ScrChildErrFn fn, bool once) { @@ -595,10 +660,15 @@ bool scr_stdin_ended(void) { return scr_stdin_eof || scr_stdin_destroyed; } * alive (a paused Node stdin with only those never flows — the process * exits with them registered). */ static bool scr_stdin_watching(void) { - return !scr_stdin_eof && !scr_stdin_destroyed && - ((!scr_stdin_paused && (scr_stdin_ndata > 0 || scr_stdin_resumed)) || - scr_stdin_waiter != NULL || - (!scr_stdin_read_eof && scr_stdin_read_demand > (scr_stdin_buffer ? scr_stdin_buffer->len : 0))); + if (scr_stdin_eof || scr_stdin_destroyed) return false; + /* 'readable' listeners keep the stream paused, as in Node, where + * resume() and 'data' listeners do not make it flow: it reads ahead only + * while a read wants more than is held (the 64 KiB mark, or a larger + * read(size)), and stops until something drains it. */ + if (scr_stdin_nreadable) return !scr_stdin_read_eof && scr_stdin_read_demand > scr_stdin_buffered; + return (!scr_stdin_paused && (scr_stdin_ndata > 0 || scr_stdin_resumed)) || + scr_stdin_waiter != NULL || + (!scr_stdin_read_eof && scr_stdin_read_demand > scr_stdin_buffered); } bool scr_stdin_pending(void) { return scr_stdin_watching(); } @@ -676,8 +746,7 @@ static void scr_stdin_finish(bool fire_end) { void scr_stdin_destroy(void) { if (scr_stdin_destroyed) return; scr_stdin_destroyed = true; - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = NULL; + scr_stdin_drop_held(); scr_stdin_read_demand = 0; scr_stdin_settle_done(); scr_stdin_drop_listeners(); @@ -712,15 +781,7 @@ static void scr_stdin_deliver(ScrBytes *chunk); static void scr_stdin_arrived(ScrBytes *chunk) { if (scr_stdin_paused || scr_stdin_nreadable || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { if (scr_stdin_nreadable && scr_stdin_readable_need) scr_stdin_queue_readable(); - if (scr_stdin_buffer) { - size_t before = scr_stdin_buffer->len; - ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + chunk->len)); - memcpy(joined->data, scr_stdin_buffer->data, before); - memcpy(joined->data + before, chunk->data, chunk->len); - scr_bytes_release(scr_stdin_buffer); - scr_bytes_release(chunk); - scr_stdin_buffer = joined; - } else scr_stdin_buffer = chunk; + scr_stdin_hold(chunk); return; } scr_stdin_deliver(chunk); @@ -732,16 +793,15 @@ static void scr_stdin_service(void) { /* Buffered input goes to a consumer: 'data' listeners, a resumed * stream, or a parked for-await read. After the last 'readable' listener * it stays buffered until one arrives, as in Node. */ - if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffer && + if (!scr_stdin_paused && !scr_stdin_nreadable && scr_stdin_buffered && (scr_stdin_ndata > 0 || scr_stdin_resumed || scr_stdin_waiter != NULL)) { - ScrBytes *chunk = scr_stdin_buffer; - scr_stdin_buffer = NULL; + ScrBytes *chunk = scr_stdin_take(scr_stdin_buffered); scr_stdin_deliver(chunk); scr_bytes_release(chunk); return; } if (scr_stdin_read_eof) { - if (!scr_stdin_buffer && !scr_stdin_nreadable) scr_stdin_finish(true); + if (!scr_stdin_buffered && !scr_stdin_nreadable) scr_stdin_finish(true); return; } if (scr_stdin_fed) { @@ -1058,7 +1118,7 @@ static void scr_stdio_queue_write_error(ScrStdio *stream, ScrDyn *callback, int } static void scr_stdio_finish_read(void) { - if (scr_stdin_read_eof && !scr_stdin_buffer && !scr_stdin_eof && !scr_stdin_destroyed) + if (scr_stdin_read_eof && !scr_stdin_buffered && !scr_stdin_eof && !scr_stdin_destroyed) scr_stdin_finish(true); } @@ -1067,7 +1127,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { if (!all && (size->kind != SCR_DYN_NUM || !isfinite(size->v.num) || size->v.num < 0 || size->v.num > 1073741824 || floor(size->v.num) != size->v.num)) return scr_stdio_refusal("read size"); - size_t available = scr_stdin_buffer ? scr_stdin_buffer->len : 0; + size_t available = scr_stdin_buffered; size_t wanted = all ? available : (size_t)size->v.num; if (scr_stdin_read_eof && wanted > available) wanted = available; if (wanted == 0 || wanted > available) { @@ -1078,10 +1138,8 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { } return scr_dyn_new_null(); } - ScrBytes *chunk = scr_bytes_slice(scr_stdin_buffer, 0, (double)wanted); - ScrBytes *rest = wanted < available ? scr_bytes_slice(scr_stdin_buffer, (double)wanted, (double)available) : NULL; - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = rest; + ScrBytes *chunk = scr_stdin_take(wanted); + bool rest = scr_stdin_buffered > 0; scr_stdin_read_demand = 65536; if (!rest && !scr_stdin_read_eof) scr_stdin_readable_need = true; scr_stdin_deliver(chunk); @@ -1339,7 +1397,7 @@ static ScrDyn *scr_stdio_get(void *ptr, const char *key, size_t length) { return dimension < 0 ? scr_dyn_retain(scr_dyn_undefined()) : scr_dyn_new_num(dimension); } if (stream->fd == 0 && length == 14 && memcmp(key, "readableLength", 14) == 0) - return scr_dyn_new_num(scr_stdin_buffer ? (double)scr_stdin_buffer->len : 0); + return scr_dyn_new_num((double)scr_stdin_buffered); if (stream->fd == 0 && length == 5 && memcmp(key, "isRaw", 5) == 0) return scr_process_is_tty(0) ? scr_dyn_new_bool(scr_stdio_raw) : scr_dyn_retain(scr_dyn_undefined()); if (stream->fd == 0 && length == 9 && memcmp(key, "destroyed", 9) == 0) return scr_dyn_new_bool(scr_stdin_destroyed); @@ -1624,8 +1682,7 @@ static void scr_events_cleanup_atexit(void) { if (scr_context_is_main()) scr_sig_cleanup(); scr_stdin_settle_done(); scr_stdin_drop_listeners(); - scr_bytes_release(scr_stdin_buffer); - scr_stdin_buffer = NULL; + scr_stdin_drop_held(); if (scr_stdio_initialized) scr_stdio_cleanup(); } diff --git a/tests/fixtures/event-loop/stdio-readable-backpressure.mjs b/tests/fixtures/event-loop/stdio-readable-backpressure.mjs new file mode 100644 index 0000000000..df2afb18fc --- /dev/null +++ b/tests/fixtures/event-loop/stdio-readable-backpressure.mjs @@ -0,0 +1,27 @@ +// 'readable', 'data' and resume() together, with no read() for a while: +// 'readable' keeps the stream paused, so it stops reading at its read-ahead +// mark until a read drains it, and 'data' sees what read() returns. +const input = process.stdin; +let draining = false; +let peak = 0; +let dataBytes = 0; +let readBytes = 0; +const drain = () => { + let chunk; + while ((chunk = input.read()) !== null) readBytes += chunk.length; +}; +input.on('data', (chunk) => { + dataBytes += chunk.length; +}); +input.on('readable', () => { + peak = Math.max(peak, input.readableLength); + if (draining) drain(); +}); +input.resume(); +setTimeout(() => { + peak = Math.max(peak, input.readableLength); + console.log('held below 128 KiB while not draining', peak <= 131072); + draining = true; + drain(); +}, 400); +input.on('end', () => console.log('read', readBytes, 'data', dataBytes)); diff --git a/tests/harness/event-loop-cases.ts b/tests/harness/event-loop-cases.ts index 1b75defaa0..50a7d2b853 100644 --- a/tests/harness/event-loop-cases.ts +++ b/tests/harness/event-loop-cases.ts @@ -67,6 +67,17 @@ export const eventLoopCases: EventLoopCase[] = [ end: true, }, }, + { + title: "stdio values: 'readable' with 'data' and resume() holds input at the read-ahead mark", + fixture: "stdio-readable-backpressure.mjs", + script: { + writes: Array.from({ length: 16 }, (_, i) => ({ + delayMs: i === 0 ? 0 : 5, + data: String.fromCharCode(97 + i).repeat(65536), + })), + end: true, + }, + }, { title: "stdio values: 'readable' on an immediately ended pipe", fixture: "stdio-readable.mjs", From 5a829bd225082bae509c41aede5be68e5a2c9d83 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 11:16:10 -0500 Subject: [PATCH 08/44] Compile unions of typed-array classes --- packages/compiler/src/backend/llvm/emitter.ts | 18 +- .../compiler/src/backend/llvm/union-repr.ts | 21 +- .../lowering/containers/array-construction.ts | 5 + .../src/frontend/lowering/containers/bytes.ts | 60 ++ .../lowering/expressions/array-literals.ts | 5 + .../lowering/expressions/element-access.ts | 103 ++++ .../lowering/expressions/property-access.ts | 8 + .../frontend/lowering/expressions/updates.ts | 38 ++ .../src/frontend/lowering/indexed-unions.ts | 538 ++++++++++++++++++ .../src/frontend/lowering/lower-stmts.ts | 94 +-- packages/compiler/src/ir/integer-loops.ts | 21 +- .../corpus/7745-typed-array-unions.ts.json | 6 + .../7747-typed-array-class-values.ts.json | 6 + ...48-typed-array-number-array-unions.ts.json | 6 + .../test/typed-array-union-emission.test.ts | 56 ++ tests/corpus/7745-typed-array-unions.ts | 71 +++ tests/corpus/7747-typed-array-class-values.ts | 42 ++ .../7748-typed-array-number-array-unions.ts | 78 +++ .../7745-typed-array-unions.ts.json | 1 + .../7747-typed-array-class-values.ts.json | 1 + ...48-typed-array-number-array-unions.ts.json | 1 + 21 files changed, 1131 insertions(+), 48 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/indexed-unions.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json create mode 100644 packages/compiler/test/typed-array-union-emission.test.ts create mode 100644 tests/corpus/7745-typed-array-unions.ts create mode 100644 tests/corpus/7747-typed-array-class-values.ts create mode 100644 tests/corpus/7748-typed-array-number-array-unions.ts create mode 100644 tests/harness/corpus-costs/7745-typed-array-unions.ts.json create mode 100644 tests/harness/corpus-costs/7747-typed-array-class-values.ts.json create mode 100644 tests/harness/corpus-costs/7748-typed-array-number-array-unions.ts.json diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4aa9b65ada..5f51a19cad 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3803,8 +3803,17 @@ export class LlEmitter { /** A union value's tag (i32): the box's tag word, or the NULL test of a * nullable union's pointer. */ + /** A box's tag and payload words are written only where the box is + * built (runtime constructors and untagged stack stores), so their loads + * carry their own alias tags: element and field stores cannot change + * them, and loops keep them hoisted. */ unionTag(uName: string, unionId: string): string { - return emitUnionTag(this.B, this.nullableUnions.get(unionId), uName); + return emitUnionTag( + this.B, + this.nullableUnions.get(unionId), + uName, + this.memoryAliasAttachment("union.tag"), + ); } /** Guard for helpers that build or read a union's `%ScrUnion` box @@ -3820,7 +3829,12 @@ export class LlEmitter { /** The BORROWED payload pointer of a ref arm (scr_union_peek inlined — * the runtime's is a static inline); a nullable union is its payload. */ unionPeek(uName: string, unionId: string): string { - return emitUnionPeek(this.B, this.nullableUnions.get(unionId), uName); + return emitUnionPeek( + this.B, + this.nullableUnions.get(unionId), + uName, + this.memoryAliasAttachment("union.payload"), + ); } /** Emits `switch` over a union's tag with one block per arm; each arm diff --git a/packages/compiler/src/backend/llvm/union-repr.ts b/packages/compiler/src/backend/llvm/union-repr.ts index 959f9e51e6..8c6fb4c858 100644 --- a/packages/compiler/src/backend/llvm/union-repr.ts +++ b/packages/compiler/src/backend/llvm/union-repr.ts @@ -8,8 +8,14 @@ import type { BlockBuilder } from "./blocks.js"; import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js"; -/** The i32 tag of a union value. */ -export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u: string): string { +/** The i32 tag of a union value. `alias` is the tag word's alias + * attachment (", !tbaa !N"), when the caller has one. */ +export function emitUnionTag( + B: BlockBuilder, + nullable: PointerUnion | null, + u: string, + alias = "", +): string { const t = B.tmp(); if (nullable?.repr === "shapes") { B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`); @@ -31,7 +37,7 @@ export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u: } const p = B.tmp(); B.line(`${p} = getelementptr inbounds %ScrUnion, ptr ${u}, i64 0, i32 1`); - B.line(`${t} = load i32, ptr ${p}`); + B.line(`${t} = load i32, ptr ${p}${alias}`); return t; } @@ -81,12 +87,17 @@ export function emitNullableIsTag( } /** The BORROWED payload pointer of a reference arm. */ -export function emitUnionPeek(B: BlockBuilder, nullable: PointerUnion | null, u: string): string { +export function emitUnionPeek( + B: BlockBuilder, + nullable: PointerUnion | null, + u: string, + alias = "", +): string { if (nullable) return u; const p = B.tmp(); const t = B.tmp(); B.line(`${p} = getelementptr inbounds %ScrUnion, ptr ${u}, i64 0, i32 5`); - B.line(`${t} = load ptr, ptr ${p}`); + B.line(`${t} = load ptr, ptr ${p}${alias}`); return t; } diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 1f10640c03..1ca51f526e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1,4 +1,5 @@ import { keyedHelperName } from "../stable-names.js"; +import { indexedUnionArms, lowerIndexedUnionToArray } from "../indexed-unions.js"; import { generatorDrain } from "../iterator-adapters.js"; import { identityPreservingWidening } from "../coercions/identity.js"; import { liftedFreshSpread } from "../coercions/fresh-spread.js"; @@ -1054,6 +1055,10 @@ export function lowerArrayFromCall( loc, }; } + // A union of typed arrays and number arrays (indexed-unions.ts). + const indexedArms = indexedUnionArms(lowerer, src.type); + if (indexedArms && args.length === 1) + return lowerIndexedUnionToArray(lowerer, src, indexedArms, loc, true); if (src.type.kind === "set" && args.length === 1) { return { kind: "setIntrinsic", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 995cb177aa..11f56986b6 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1,4 +1,5 @@ import { keyedHelperName } from "../stable-names.js"; +import { allBytesArms, indexedUnionArms, lowerIndexedUnionMethod } from "../indexed-unions.js"; import { boolLit, dynUndefinedExpr, numLit, varRef } from "../../../ir/build.js"; import { BUF_NUM_METHODS } from "./buffer-numeric-methods.js"; import * as ts from "../../ts7/adapter.js"; @@ -910,6 +911,62 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS). Everything * else the lib declares (fill, indexOf, reverse, ...) falls through to * the SC2020 member fence. Null when this isn't a bytes method call. */ +/** The element-kind-generic typed-array methods on a union of typed + * arrays (indexed-unions.ts): the arguments lower as for one typed array, + * and each arm runs the method on its own receiver. Null for the methods + * that need a narrowing to one kind first. */ +function lowerUnionBytesMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + name: string, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(call))) return null; + const nodes = call.arguments.nodes; + if (nodes.some(ts.isSpreadElement)) return null; + const loc = locOf(call); + const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + const optional = (i: number, fallback: IrExpr, type: IrType = F64): IrExpr => + nodes[i] ? lowerOptionalArgument(lowerer, nodes[i]!, type, fallback) : fallback; + let method: IrBytesIntrinsicMethod; + let args: () => IrExpr[]; + let result: "receiver" | IrType; + if ((name === "slice" || name === "subarray") && nodes.length <= 2) { + method = name; + args = () => [optional(0, num(0)), optional(1, num(Infinity))]; + result = "receiver"; + } else if (name === "fill" && nodes.length <= 3) { + method = "fillElem"; + args = () => [optional(0, num(0)), optional(1, num(0)), optional(2, num(Infinity))]; + result = "receiver"; + } else if (name === "copyWithin" && nodes.length <= 3) { + method = "copyWithin"; + args = () => [optional(0, num(0)), optional(1, num(0)), optional(2, num(Infinity))]; + result = "receiver"; + } else if (name === "toReversed" && nodes.length === 0) { + method = "toReversed"; + args = () => []; + result = "receiver"; + } else if (name === "join" && nodes.length <= 1) { + method = "join"; + args = () => [optional(0, { kind: "strLit", value: ",", type: STRING, loc }, STRING)]; + result = STRING; + } else if (name === "set" && (nodes.length === 1 || nodes.length === 2)) { + const source = lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!)); + method = source?.kind === "bytes" ? "setFrom" : "setFromDyn"; + args = () => { + const src = lowerer.lowerExpr(nodes[0]!); + const first = src.type.kind === "bytes" ? src : lowerer.coerceInto(nodes[0]!, src, DYN); + return nodes.length === 2 ? [first, optional(1, num(0))] : [first]; + }; + result = VOID; + } else return null; + const receiver = lowerer.lowerExpr(access.expression); + const arms = indexedUnionArms(lowerer, receiver.type); + if (!arms || !allBytesArms(arms)) return null; + return lowerIndexedUnionMethod(lowerer, receiver, arms, method, args(), result, loc); +} + export function lowerBytesMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -918,6 +975,9 @@ export function lowerBytesMethodCall( if (lowerer.chainBlocked(access, call)) return null; const name = access.name.text; const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + const unionArms = receiverIr ? indexedUnionArms(lowerer, receiverIr) : null; + if (unionArms && allBytesArms(unionArms) && lowerer.isStdlibMember(access)) + return lowerUnionBytesMethodCall(lowerer, call, access, name); if (receiverIr?.kind !== "bytes") return null; if (!lowerer.isStdlibMember(access)) return null; const loc = locOf(call); diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 6c7ce814e7..c1b54a417e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -1,4 +1,5 @@ /* Array literals. */ +import { indexedUnionArms, lowerIndexedUnionToArray } from "../indexed-unions.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer, neverTaintedJsType } from "../lowerer.js"; import { notIterableSpelling } from "../iterable-spelling.js"; @@ -504,6 +505,10 @@ export function lowerArrayLiteral( loc: locOf(el), }; } + // A union of typed arrays and number arrays (indexed-unions.ts): + // each arm's elements as a number[]. + const indexedArms = indexedUnionArms(lowerer, src.type); + if (indexedArms) src = lowerIndexedUnionToArray(lowerer, src, indexedArms, locOf(el)); // `[...obj]` over a class whose [Symbol.iterator] is a generator // method spreads the generator that method returns. src = classGeneratorIterable(lowerer, src) ?? src; diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 0ea94ec7ad..e61e7f4349 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -71,6 +71,13 @@ import { isClassCallback } from "../class-callbacks.js"; import { isKeyedFieldAccess } from "./keyed-field-targets.js"; import { lowerFieldCompoundValue } from "./updates.js"; import { readsHeldEvolvingArray } from "../lower-stmts.js"; +import { + type IndexedArm, + indexedUnionArms, + lowerIndexedUnionNumberRead, + lowerIndexedUnionRead, + lowerIndexedUnionWrite, +} from "../indexed-unions.js"; /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` @@ -299,6 +306,31 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre } } + // An element read of a union of typed arrays and number arrays: each + // arm's own read, dispatched on the tag (indexed-unions.ts). + const indexedArms = receiverIr ? indexedUnionArms(lowerer, receiverIr) : null; + if (indexedArms && receiverIr && !isJsSourceFile(ts.getSourceFileOfNode(expr))) { + let recv = lowerer.lowerExpr(expr.expression); + if (recv.type.kind === "dyn") recv = lowerer.coerceInto(expr.expression, recv, receiverIr); + const arms = indexedUnionArms(lowerer, recv.type); + if (arms) { + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); + if ( + (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + lowerer.nanCodedReads?.isNumericRead(expr) === true + ) + return lowerIndexedUnionNumberRead(lowerer, recv, arms, index, locOf(expr)); + return lowerIndexedUnionRead(lowerer, recv, arms, index, locOf(expr)); + } + } + // Typed-array element read `b[i]` — like arrayGet with a scalar // result; any invalid index traps (the array discipline; JS would // read undefined — divergence 4's policy). @@ -1518,6 +1550,9 @@ export function lowerElementCompoundParts( }); return { expr: { kind: "seqExpr", stmts, result: value, type: DYN, loc }, stmts: null }; } + const indexedArms = indexedUnionArms(lowerer, checked.type); + if (indexedArms && !isJsSourceFile(ts.getSourceFileOfNode(expr))) + return lowerIndexedUnionCompound(lowerer, expr, op, checked, indexedArms); const receiverType = checked.type.kind === "array" || checked.type.kind === "bytes" ? checked.type @@ -1702,6 +1737,54 @@ export function lowerElementCompoundParts( }; } +/** `u[i] op= v` on a union of typed arrays and number arrays: the + * receiver and index evaluate once, the old element reads as a number (NaN + * for an invalid index, as in the typed-array form), and the result is + * stored through the union's element write. */ +function lowerIndexedUnionCompound( + lowerer: Lowerer, + expr: ts.BinaryExpression, + op: CompoundOp, + receiver: IrExpr, + arms: readonly IndexedArm[], +): { expr: IrExpr | null; stmts: IrStmt[] | null } { + const target = expr.left as ts.ElementAccessExpression; + const loc = locOf(expr); + const index = lowerOptionalIndex( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + locOf(target.argumentExpression), + target.argumentExpression, + ); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); + const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); + const indexLocal = lowerer.declareHiddenLocal("%compoundIndex", F64); + const receiverRef = (): IrExpr => varRef(receiverLocal.id, receiver.type, loc); + const indexRef = (): IrExpr => varRef(indexLocal.id, F64, loc); + const oldValue = lowerIndexedUnionNumberRead(lowerer, receiverRef(), arms, indexRef(), loc); + const rhs = lowerOptionalNumber(lowerer, lowerer.lowerExpr(expr.right), loc); + if (rhs.type.kind !== "f64") compoundUnsupported(lowerer, expr, op, F64, rhs.type); + const computed: IrExpr = { kind: "bin", op, left: oldValue, right: rhs, type: F64, loc }; + const resultLocal = lowerer.declareHiddenLocal("%compoundResult", F64); + const resultRef = (): IrExpr => varRef(resultLocal.id, F64, loc); + return { + expr: { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + { kind: "varDecl", localId: indexLocal.id, init: index, loc }, + { kind: "varDecl", localId: resultLocal.id, init: computed, loc }, + lowerIndexedUnionWrite(lowerer, receiverRef(), arms, indexRef(), resultRef(), loc), + ], + result: resultRef(), + type: F64, + loc, + }, + stmts: null, + }; +} + /** ToString of one stabilized `string[]` element read for `+=`: a missing * element concatenates as "undefined". */ function arrayStringOperand(arr: IrExpr, index: IrExpr, loc: SrcLoc): IrExpr { @@ -2243,6 +2326,26 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): }; } } + // An element write into a union of typed arrays and number arrays + // (indexed-unions.ts). + const indexedArms = receiverIr ? indexedUnionArms(lowerer, receiverIr) : null; + if (indexedArms && receiverIr && !isJsSourceFile(ts.getSourceFileOfNode(expr))) { + let recv = lowerer.lowerExpr(target.expression); + if (recv.type.kind === "dyn") recv = lowerer.coerceInto(target.expression, recv, receiverIr); + const arms = indexedUnionArms(lowerer, recv.type); + if (arms) { + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + locOf(target.argumentExpression), + target.argumentExpression, + ); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); + const value = lowerer.lowerExprExpecting(expr.right, F64); + return lowerIndexedUnionWrite(lowerer, recv, arms, index, value, locOf(expr)); + } + } if (receiverIr?.kind !== "array") { lowerer.unsupported("SC1090", target, "assignment to non-array elements"); } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 7b12297a8e..1c544b5c54 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,4 +1,5 @@ /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ +import { indexedUnionArms, lowerIndexedUnionMember } from "../indexed-unions.js"; import { keyedHelperName } from "../stable-names.js"; import { staticallySelected } from "../static-typeof.js"; import * as ts from "../../ts7/adapter.js"; @@ -3345,6 +3346,13 @@ export function lowerUnionProperty( loc: locOf(expr), }; } + // A union of typed arrays and number arrays: each arm's own length + // (and, for typed arrays only, byteLength, byteOffset, BYTES_PER_ELEMENT). + const indexedArms = indexedUnionArms(lowerer, value.type); + if (indexedArms) { + const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr)); + if (member) return member; + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 04ca23a96a..394dcafead 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,4 +1,9 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ +import { + indexedUnionArms, + lowerIndexedUnionNumberRead, + lowerIndexedUnionWrite, +} from "../indexed-unions.js"; import * as ts from "../../ts7/adapter.js"; import { lowerOptionalIndex, @@ -386,6 +391,39 @@ export function lowerIncDec( loc, })); } + // A union of typed arrays and number arrays (indexed-unions.ts): the + // element read as a number and the union's element write. + const indexedArms = indexedUnionArms(lowerer, raw.type); + if ( + ts.isElementAccessExpression(access) && + indexedArms && + !isJsSourceFile(ts.getSourceFileOfNode(expr)) + ) { + const receiver = save(raw, "%incrementArray"); + const index = save( + lowerOptionalIndex( + lowerer, + lowerer.lowerExpr(access.argumentExpression), + locOf(access.argumentExpression), + access.argumentExpression, + ), + "%incrementIndex", + ); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", access, "incrementing non-number array keys"); + return finish( + lowerIndexedUnionNumberRead(lowerer, receiver, indexedArms, index, loc), + (value) => + lowerIndexedUnionWrite( + lowerer, + receiver, + indexedArms, + index, + lowerer.coerceInto(access, value, F64), + loc, + ), + ); + } // A TS member statically typed number on a checked-dynamic receiver (a // class-backed protocol view): the expanded form `o.v = o.v + 1`, its // read checked into number like any other read of the member. diff --git a/packages/compiler/src/frontend/lowering/indexed-unions.ts b/packages/compiler/src/frontend/lowering/indexed-unions.ts new file mode 100644 index 0000000000..aa93871c9e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/indexed-unions.ts @@ -0,0 +1,538 @@ +import { keyedHelperName } from "./stable-names.js"; +import { helperLoc, varRef } from "../../ir/build.js"; +import { + BOOL, + BYTES_ELEMENT_SIZE, + F64, + VOID, + type IrBytesIntrinsicMethod, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, + type SrcLoc, + typeKey, +} from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { arrayNumberRead, lowerSafeBytesRead, lowerSafeIndexRead } from "./array-values.js"; +import type { Lowerer } from "./lowerer.js"; + +/** Unions of number-indexed containers: typed arrays of any element kind + * (`Uint8Array | Uint16Array | Uint32Array`, as lru-cache picks by size) + * and `number[]` arms. + * + * The union keeps its ordinary tagged representation. Each operation on it + * lowers to a lifted helper per union and operation that tests the tag, + * narrows to the arm and runs the arm's own operation: the inline load or + * store of a typed array's element kind, or the dense read or write of a + * number array. The helpers are small, so LLVM inlines them, and a loop + * over one union value tests a loop-invariant tag. */ + +/** One arm: its tag and its type (bytes, or an array of numbers). */ +export interface IndexedArm { + tag: number; + type: IrType & ({ kind: "bytes" } | { kind: "array" }); +} + +/** The arms of a union whose every arm is a typed array or a `number[]`, + * or null. Unions with any other arm (undefined included) are not + * indexed unions: their reads need a narrowing first. */ +export function indexedUnionArms(lowerer: Lowerer, type: IrType): IndexedArm[] | null { + if (type.kind !== "union") return null; + const arms = lowerer.unions.get(type.unionId)?.arms; + if (!arms || arms.length < 2) return null; + const result: IndexedArm[] = []; + for (const [tag, arm] of arms.entries()) { + if (arm.kind === "bytes") result.push({ tag, type: arm }); + else if (arm.kind === "array" && arm.elem.kind === "f64") result.push({ tag, type: arm }); + else return null; + } + return result; +} + +/** Whether every arm is a typed array (so typed-array-only members such + * as `byteLength` apply). */ +export function allBytesArms(arms: readonly IndexedArm[]): boolean { + return arms.every((arm) => arm.type.kind === "bytes"); +} + +type ArmBody = (receiver: IrExpr, arm: IndexedArm, args: readonly IrExpr[]) => IrStmt[]; + +/** The helper `name(u, ...args)`: for each arm but the last, a tag test + * that runs the arm's statements; the last arm's statements run without a + * test (the union holds one of its arms). */ +function dispatchHelper( + lowerer: Lowerer, + key: string, + prefix: string, + union: IrType, + arms: readonly IndexedArm[], + params: readonly { name: string; type: IrType }[], + returnType: IrType, + body: ArmBody, + loc: SrcLoc, +): string { + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, prefix, key); + lowerer.arrHofHelpers.set(key, name); + const at = helperLoc(loc); + if (union.kind !== "union") throw new Error("indexed-union helper over a non-union"); + const u = varRef("u.0", union, at); + const args = params.map((p) => varRef(`${p.name}.0`, p.type, at)); + const stmts: IrStmt[] = []; + arms.forEach((arm, i) => { + const receiver: IrExpr = { + kind: "unionNarrow", + unionId: union.unionId, + tag: arm.tag, + value: u, + type: arm.type, + loc: at, + }; + const armStmts = body(receiver, arm, args); + if (i === arms.length - 1) { + stmts.push(...armStmts); + return; + } + stmts.push({ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: union.unionId, + tag: arm.tag, + negated: false, + value: u, + type: BOOL, + loc: at, + }, + then: armStmts, + else_: null, + loc: at, + }); + }); + const fn: IrFunction = { + name, + params: [ + { localId: "u.0", name: "u", type: union }, + ...params.map((p) => ({ localId: `${p.name}.0`, name: p.name, type: p.type })), + ], + returnType, + locals: [ + { id: "u.0", name: "u", type: union, mutable: false }, + ...params.map((p) => ({ id: `${p.name}.0`, name: p.name, type: p.type, mutable: false })), + ], + body: stmts, + loc: at, + }; + lowerer.liftedFns.push(fn); + return name; +} + +const returning = (value: IrExpr, loc: SrcLoc): IrStmt[] => [{ kind: "return", value, loc }]; + +/** Safe-read helpers mapped to their numeric twins, for the fusion of an + * immediate ToNumber (tryLowerNumericIndexRead). */ +const numericTwins = new WeakMap>(); + +/** `u[i]` read as an element or undefined (an invalid index), as the + * typed-array and number-array reads answer. */ +export function lowerIndexedUnionRead( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + index: IrExpr, + loc: SrcLoc, +): IrExpr { + const resultT = lowerer.withUndefinedArm(F64); + const key = `idxUnion:get:${unionKey(receiver.type)}`; + const name = dispatchHelper( + lowerer, + key, + "%union.idxOr", + receiver.type, + arms, + [{ name: "i", type: F64 }], + resultT, + (arm, info, [i]) => + returning( + info.type.kind === "bytes" + ? lowerSafeBytesRead(lowerer, arm, i!, loc) + : lowerSafeIndexRead(lowerer, arm, i!, loc)!, + loc, + ), + loc, + ); + const numeric = indexedUnionNumberHelper(lowerer, receiver.type, arms, loc); + let twins = numericTwins.get(lowerer); + if (!twins) numericTwins.set(lowerer, (twins = new Map())); + twins.set(name, numeric); + return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc }; +} + +/** `u[i]` read as a number: an invalid index reads as NaN, which is + * ToNumber(undefined). */ +export function lowerIndexedUnionNumberRead( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + index: IrExpr, + loc: SrcLoc, +): IrExpr { + if (inlineDispatch(receiver, [index])) + return inlineArms(receiver, arms, F64, (arm, info) => numberRead(arm, info, index, loc), loc); + const name = indexedUnionNumberHelper(lowerer, receiver.type, arms, loc); + return { kind: "call", callee: name, args: [receiver, index], type: F64, loc }; +} + +function numberRead(arm: IrExpr, info: IndexedArm, index: IrExpr, loc: SrcLoc): IrExpr { + return info.type.kind === "bytes" + ? { + kind: "bytesIntrinsic", + method: "get", + receiver: arm, + args: [index], + type: F64, + loc, + invalidNaN: true, + } + : arrayNumberRead(arm, index, loc); +} + +function indexedUnionNumberHelper( + lowerer: Lowerer, + union: IrType, + arms: readonly IndexedArm[], + loc: SrcLoc, +): string { + return dispatchHelper( + lowerer, + `idxUnion:num:${unionKey(union)}`, + "%union.getNumber", + union, + arms, + [{ name: "i", type: F64 }], + F64, + (arm, info, [i]) => returning(numberRead(arm, info, i!, loc), loc), + loc, + ); +} + +/** The numeric twin of an indexed-union read helper call, or null. */ +export function indexedUnionNumericTwin(lowerer: Lowerer, call: IrExpr): IrExpr | null { + if (call.kind !== "call") return null; + const twin = numericTwins.get(lowerer)?.get(call.callee); + return twin === undefined ? null : { ...call, callee: twin, type: F64 }; +} + +/** `u[i] = v` for a number `v`: each arm's element store (a typed array + * converts the number to its element kind and ignores an invalid index; a + * number array stores at the index). */ +export function lowerIndexedUnionWrite( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + index: IrExpr, + value: IrExpr, + loc: SrcLoc, +): IrStmt { + // In place, the value evaluates before the arms read the receiver and + // index: only when it cannot rebind either. + if (inlineDispatch(receiver, [index]) && !rebinds(value, [receiver, index])) { + const union = receiver.type; + const valueLocal = lowerer.declareHiddenLocal("%elementValue", F64); + const v: IrExpr = { kind: "varRef", localId: valueLocal.id, type: F64, loc }; + let chain: IrStmt[] | null = null; + for (let i = arms.length - 1; i >= 0; i--) { + const info = arms[i]!; + const arm: IrExpr = { + kind: "unionNarrow", + unionId: union.kind === "union" ? union.unionId : "", + tag: info.tag, + value: receiver, + type: info.type, + loc, + }; + const store: IrStmt = + info.type.kind === "bytes" + ? { kind: "bytesSet", arr: arm, index, value: v, loc } + : { kind: "arraySet", arr: arm, index, value: v, loc }; + chain = + chain === null + ? [store] + : [ + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: union.kind === "union" ? union.unionId : "", + tag: info.tag, + negated: false, + value: receiver, + type: BOOL, + loc, + }, + then: [store], + else_: chain, + loc, + }, + ]; + } + return { + kind: "block", + body: [{ kind: "varDecl", localId: valueLocal.id, init: value, loc }, ...chain!], + loc, + }; + } + const name = dispatchHelper( + lowerer, + `idxUnion:set:${unionKey(receiver.type)}`, + "%union.idxSet", + receiver.type, + arms, + [ + { name: "i", type: F64 }, + { name: "v", type: F64 }, + ], + VOID, + (arm, info, [i, v]) => [ + info.type.kind === "bytes" + ? { kind: "bytesSet", arr: arm, index: i!, value: v!, loc } + : { kind: "arraySet", arr: arm, index: i!, value: v!, loc }, + { kind: "return", value: null, loc }, + ], + loc, + ); + return { + kind: "exprStmt", + expr: { kind: "call", callee: name, args: [receiver, index, value], type: VOID, loc }, + loc, + }; +} + +/** The members every arm answers: `length` for all, and `byteLength`, + * `byteOffset` and `BYTES_PER_ELEMENT` when every arm is a typed array. */ +export function lowerIndexedUnionMember( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + member: string, + loc: SrcLoc, +): IrExpr | null { + const bytesOnly = member !== "length"; + if ( + member !== "length" && + member !== "byteLength" && + member !== "byteOffset" && + member !== "BYTES_PER_ELEMENT" + ) + return null; + if (bytesOnly && !allBytesArms(arms)) return null; + const memberOf = (arm: IrExpr, info: IndexedArm): IrExpr => + info.type.kind === "array" + ? { kind: "arrIntrinsic", method: "length", receiver: arm, args: [], type: F64, loc } + : member === "BYTES_PER_ELEMENT" + ? { kind: "numLit", value: BYTES_ELEMENT_SIZE[info.type.elem], type: F64, loc } + : { kind: "bytesIntrinsic", method: member, receiver: arm, args: [], type: F64, loc }; + if (inlineDispatch(receiver, [])) return inlineArms(receiver, arms, F64, memberOf, loc); + const name = dispatchHelper( + lowerer, + `idxUnion:${member}:${unionKey(receiver.type)}`, + `%union.${member}`, + receiver.type, + arms, + [], + F64, + (arm, info) => returning(memberOf(arm, info), loc), + loc, + ); + return { kind: "call", callee: name, args: [receiver], type: F64, loc }; +} + +function unionKey(type: IrType): string { + return type.kind === "union" ? String(type.unionId) : "?"; +} + +/** A typed-array method on a union whose every arm is a typed array: the + * arguments evaluate once in the caller, and each arm runs the same + * `bytesIntrinsic` on its own receiver. `result` "receiver" is a method + * that answers a typed array of the receiver's kind (subarray, slice, + * fill, ...): each arm wraps its answer back into the union. */ +export function lowerIndexedUnionMethod( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + method: IrBytesIntrinsicMethod, + args: readonly IrExpr[], + result: "receiver" | IrType, + loc: SrcLoc, +): IrExpr { + const union = receiver.type; + if (union.kind !== "union") throw new Error("indexed-union method over a non-union"); + const resultT = result === "receiver" ? union : result; + const params = args.map((arg, i) => ({ name: `a${i}`, type: arg.type })); + const signature = args.map((arg) => typeKey(arg.type)).join(","); + const name = dispatchHelper( + lowerer, + `idxUnion:${method}(${signature}):${unionKey(union)}`, + `%union.${method}`, + union, + arms, + params, + resultT, + (arm, info, refs) => { + const call: IrExpr = { + kind: "bytesIntrinsic", + method, + receiver: arm, + args: [...refs], + type: result === "receiver" ? info.type : result, + loc, + }; + if (result === "receiver") + return returning( + { + kind: "unionWrap", + unionId: union.unionId, + tag: info.tag, + value: call, + type: union, + loc, + }, + loc, + ); + if (result.kind === "void") + return [ + { kind: "exprStmt", expr: call, loc }, + { kind: "return", value: null, loc }, + ]; + return returning(call, loc); + }, + loc, + ); + return { kind: "call", callee: name, args: [receiver, ...args], type: resultT, loc }; +} + +/** The elements of `u` as a `number[]`: a typed-array arm drains its + * elements into a fresh array. A number-array arm is the array itself for + * consumers that copy it (spreads), or with `fresh` a dense copy whose + * holes read as undefined (Array.from). */ +export function lowerIndexedUnionToArray( + lowerer: Lowerer, + receiver: IrExpr, + arms: readonly IndexedArm[], + loc: SrcLoc, + fresh = false, +): IrExpr { + const arrayT: IrType = { kind: "array", elem: F64 }; + const name = dispatchHelper( + lowerer, + `idxUnion:${fresh ? "from" : "toArray"}:${unionKey(receiver.type)}`, + fresh ? "%union.arrayFrom" : "%union.toArray", + receiver.type, + arms, + [], + arrayT, + (arm, info) => + returning( + info.type.kind === "bytes" + ? { + kind: "bytesIntrinsic", + method: "toArray", + receiver: arm, + args: [], + type: arrayT, + loc, + } + : fresh + ? { + kind: "arrIntrinsic", + method: "reverse", + receiver: { + kind: "arrIntrinsic", + method: "toReversed", + receiver: arm, + args: [], + type: arrayT, + loc, + }, + args: [], + type: arrayT, + loc, + } + : arm, + loc, + ), + loc, + ); + return { kind: "call", callee: name, args: [receiver], type: arrayT, loc }; +} + +/** Whether a dispatch can run in place instead of through a helper call: + * the receiver is a plain read, so testing and narrowing it once per arm + * reads the same value, and the other operands are plain reads or + * literals. The arms' own reads then stay visible to the integer range + * analysis (a loop bounded by `u.length` keeps an integer counter). */ +function inlineDispatch(receiver: IrExpr, operands: readonly IrExpr[]): boolean { + return ( + receiver.kind === "varRef" && operands.every((e) => e.kind === "varRef" || e.kind === "numLit") + ); +} + +/** Whether evaluating `e` can rebind one of the plain reads `reads`. */ +function rebinds(e: IrExpr, reads: readonly IrExpr[]): boolean { + const ids = new Set(reads.flatMap((r) => (r.kind === "varRef" ? [r.localId] : []))); + let found = false; + const expr = (x: IrExpr): boolean => { + if ((x.kind === "assignExpr" || x.kind === "incDec") && ids.has(x.localId)) found = true; + return !found && everyExprChild(x, expr, stmt); + }; + const stmt = (x: IrStmt): boolean => { + if ((x.kind === "assign" || x.kind === "varDecl") && ids.has(x.localId)) found = true; + return !found && everyStmtChild(x, expr, stmt); + }; + expr(e); + return found; +} + +/** The in-place form of a dispatch: a ternary chain over the arms' tags, + * the last arm untested. */ +function inlineArms( + receiver: IrExpr, + arms: readonly IndexedArm[], + type: IrType, + arm: (narrowed: IrExpr, info: IndexedArm) => IrExpr, + loc: SrcLoc, +): IrExpr { + const union = receiver.type; + if (union.kind !== "union") throw new Error("indexed-union dispatch over a non-union"); + const narrowed = (info: IndexedArm): IrExpr => ({ + kind: "unionNarrow", + unionId: union.unionId, + tag: info.tag, + value: receiver, + type: info.type, + loc, + }); + let result = arm(narrowed(arms[arms.length - 1]!), arms[arms.length - 1]!); + for (let i = arms.length - 2; i >= 0; i--) { + const info = arms[i]!; + result = { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: union.unionId, + tag: info.tag, + negated: false, + value: receiver, + type: BOOL, + loc, + }, + then: arm(narrowed(info), info), + else_: result, + type, + loc, + }; + } + return result; +} diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 66ecea6021..046c03aa2b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,4 +1,11 @@ import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js"; +import { + allBytesArms, + indexedUnionArms, + lowerIndexedUnionMember, + lowerIndexedUnionNumberRead, + lowerIndexedUnionRead, +} from "./indexed-unions.js"; import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; import { hostsAll } from "./expressions/hosted-narrowing.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; @@ -10132,13 +10139,8 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { loc: locOf(iterSrc), }; } - if (iterable.type.kind === "bytes") { - return lowerForOfBytes( - lowerer, - stmt, - iterable as IrExpr & { type: IrType & { kind: "bytes" } }, - labels, - ); + if (iterable.type.kind === "bytes" || indexedUnionArms(lowerer, iterable.type)) { + return lowerForOfBytes(lowerer, stmt, iterable, labels); } if (iterable.type.kind !== "array") { // The lib types strings as iterable too, so those for-ofs typecheck; @@ -10696,7 +10698,7 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { function lowerForOfBytes( lowerer: Lowerer, stmt: ts.ForOfStatement, - iterable: IrExpr & { type: IrType & { kind: "bytes" } }, + iterable: IrExpr, labels?: string[], ): IrStmt { const loc = locOf(stmt); @@ -10790,7 +10792,16 @@ function lowerForOfStoredNumericIterator( const loc = locOf(stmt); const sourceT = state.sourceType; - if (!((sourceT.kind === "array" && sourceT.elem.kind === "f64") || sourceT.kind === "bytes")) { + // A union of typed arrays and number arrays (indexed-unions.ts) reads + // through its arms' own length and element reads. + const unionArms = indexedUnionArms(lowerer, sourceT); + if ( + !( + (sourceT.kind === "array" && sourceT.elem.kind === "f64") || + sourceT.kind === "bytes" || + unionArms + ) + ) { throw new InternalCompilerError("internal: retained numeric iterator has a non-numeric source"); } @@ -10798,35 +10809,44 @@ function lowerForOfStoredNumericIterator( const doneRef = (): IrExpr => varRef(state.doneLocalId, BOOL, loc); const sourceRef = (): IrExpr => varRef(state.sourceLocalId, sourceT, loc); const sourceLength = (): IrExpr => - sourceT.kind === "array" - ? { - kind: "arrIntrinsic", - method: "length", - receiver: sourceRef(), - args: [], - type: F64, - loc, - } - : { - kind: "bytesIntrinsic", - method: "length", - receiver: sourceRef(), - args: [], - type: F64, - loc, - }; - const valueT = sourceT.kind === "array" ? arrayValueType(lowerer, F64) : F64; + unionArms + ? lowerIndexedUnionMember(lowerer, sourceRef(), unionArms, "length", loc)! + : sourceT.kind === "array" + ? { + kind: "arrIntrinsic", + method: "length", + receiver: sourceRef(), + args: [], + type: F64, + loc, + } + : { + kind: "bytesIntrinsic", + method: "length", + receiver: sourceRef(), + args: [], + type: F64, + loc, + }; + const valueT = + sourceT.kind === "array" || (unionArms && !allBytesArms(unionArms)) + ? arrayValueType(lowerer, F64) + : F64; const sourceValue = (): IrExpr => - sourceT.kind === "array" - ? arrayValueRead(lowerer, sourceRef(), indexRef(), F64, loc) - : { - kind: "bytesIntrinsic", - method: "get", - receiver: sourceRef(), - args: [indexRef()], - type: F64, - loc, - }; + unionArms + ? allBytesArms(unionArms) + ? lowerIndexedUnionNumberRead(lowerer, sourceRef(), unionArms, indexRef(), loc) + : lowerIndexedUnionRead(lowerer, sourceRef(), unionArms, indexRef(), loc) + : sourceT.kind === "array" + ? arrayValueRead(lowerer, sourceRef(), indexRef(), F64, loc) + : { + kind: "bytesIntrinsic", + method: "get", + receiver: sourceRef(), + args: [indexRef()], + type: F64, + loc, + }; const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; lowerer.scopes.push(new Map()); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index d6942718a0..2f7d2bea65 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -119,13 +119,26 @@ function stableLoopValue( stableLoopValue(value.receiver, locals, body, invariant, counter) ); case "bytesIntrinsic": - case "arrIntrinsic": + case "arrIntrinsic": { + // The receiver may be one arm of a union binding (indexed-unions.ts). + const receiver = + value.receiver.kind === "unionNarrow" ? value.receiver.value : value.receiver; return ( (value.method === "length" || (value.kind === "bytesIntrinsic" && value.method === "byteLength")) && - value.receiver.kind === "varRef" && - (value.kind === "bytesIntrinsic" || !invariant || stableLength(value.receiver.localId)) && - stableLoopValue(value.receiver, locals, body, invariant, counter) + receiver.kind === "varRef" && + (value.kind === "bytesIntrinsic" || !invariant || stableLength(receiver.localId)) && + stableLoopValue(receiver, locals, body, invariant, counter) + ); + } + // A union binding dispatched on its tag (indexed-unions.ts): stable + // when the binding is and every arm is. + case "ternary": + return ( + value.cond.kind === "unionIsTag" && + stableLoopValue(value.cond.value, locals, body, invariant, counter) && + stableLoopValue(value.then, locals, body, invariant, counter, stableLength) && + stableLoopValue(value.else_, locals, body, invariant, counter, stableLength) ); default: return false; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json new file mode 100644 index 0000000000..c85ce647a3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7742-typed-array-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json new file mode 100644 index 0000000000..63b6307eaf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7744-typed-array-class-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json new file mode 100644 index 0000000000..b2274d160b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7743-typed-array-number-array-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/typed-array-union-emission.test.ts b/packages/compiler/test/typed-array-union-emission.test.ts new file mode 100644 index 0000000000..743788af06 --- /dev/null +++ b/packages/compiler/test/typed-array-union-emission.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-typed-array-unions-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(llvm: string, name: string): string { + return new RegExp(`^define [^@]*@sc_b?f_${name}(\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; +} + +const source = ` +type UintArray = Uint8Array | Uint16Array | Uint32Array; +const pick = (max: number, n: number): UintArray => + max <= 256 ? new Uint8Array(n) : max <= 65536 ? new Uint16Array(n) : new Uint32Array(n); +function total(xs: UintArray): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s += xs[i]!; + return s; +} +function first(max: number): number { + return pick(max, 4)[0]!; +} +const xs = pick(Number(process.argv.length) * 100000, 8); +for (let i = 0; i < xs.length; i++) xs[i] = i; +console.log(total(xs), first(10)); +`; + +test("element reads of a union of typed arrays load each arm's own element kind in place", async () => { + const llvm = await emitLlvm(source); + const total = body(llvm, "total"); + // One load per arm, each of its element width, behind the union's tag. + for (const width of ["i8", "i16", "i32"]) expect(total).toMatch(new RegExp(`load ${width}, ptr`)); + expect(total).not.toContain("@sc_bf__x25_union_getNumber"); + // The loop over the union's length keeps an integer counter. + expect(total).toMatch(/alloca i64/); +}); + +test("a typed-array union read through a computed receiver goes through one helper", async () => { + const llvm = await emitLlvm(source); + // The receiver is a call: evaluated once, then dispatched by the helper. + expect(body(llvm, "first")).toContain("@sc_bf__x25_union_getNumber"); +}); diff --git a/tests/corpus/7745-typed-array-unions.ts b/tests/corpus/7745-typed-array-unions.ts new file mode 100644 index 0000000000..57c220dcc1 --- /dev/null +++ b/tests/corpus/7745-typed-array-unions.ts @@ -0,0 +1,71 @@ +// Unions of typed-array classes: every element operation runs on each arm. + +type UintArray = Uint8Array | Uint16Array | Uint32Array; +type Samples = Int16Array | Float32Array | Float64Array; + +function uintArray(max: number, size: number): UintArray { + return max <= 2 ** 8 + ? new Uint8Array(size) + : max <= 2 ** 16 + ? new Uint16Array(size) + : new Uint32Array(size); +} + +function samples(kind: number, size: number): Samples { + return kind === 0 + ? new Int16Array(size) + : kind === 1 + ? new Float32Array(size) + : new Float64Array(size); +} + +function sum(xs: UintArray): number { + let total = 0; + for (let i = 0; i < xs.length; i++) total += xs[i]!; + return total; +} + +function describe(xs: UintArray): string { + if (xs instanceof Uint8Array) return `u8 ${xs.length}`; + if (xs instanceof Uint16Array) return `u16 ${xs[0]}`; + return `u32 ${xs.BYTES_PER_ELEMENT}`; +} + +for (const max of [200, 60000, 5000000]) { + const xs = uintArray(max, 6); + // Stores convert to each arm's element kind (wrapping past its range). + for (let i = 0; i < xs.length; i++) xs[i] = i * 70000 + 255; + console.log(xs.length, xs.byteLength, xs.byteOffset, xs.BYTES_PER_ELEMENT); + console.log(xs[0], xs[5], xs[6], xs[-1], sum(xs)); + xs[1] += 3; + xs[2]++; + --xs[3]; + xs[4] = -1; + xs[5] = 2.75; + console.log(xs[1], xs[2], xs[3], xs[4], xs[5]); + // Writes past the end are ignored, and reads there are undefined. + xs[10] = 9; + console.log(xs[10] === undefined, xs[10] ?? "missing"); + let iterated = 0; + for (const v of xs) iterated += v; + console.log(iterated, [...xs].length, Array.from(xs)[1]); + const part = xs.subarray(1, 4); + part[0] = 42; + console.log(part.length, xs[1], xs.slice(2, 4).length, xs.slice(2, 4)[0] === xs[2]); + xs.fill(7, 4); + xs.copyWithin(0, 4, 5); + console.log(xs.join("-"), xs.toReversed()[0]); + xs.set([1, 2], 2); + console.log(xs[2], xs[3], describe(xs)); +} + +for (const kind of [0, 1, 2]) { + const s = samples(kind, 4); + s[0] = 1.5; + s[1] = -40000; + s[2] = 0.1; + s[3] = s[0]! * 3; + console.log(s.BYTES_PER_ELEMENT, s[0], s[1], s[2], s[3]); + console.log(s); + console.log(s instanceof Float32Array, s instanceof Int16Array); +} diff --git a/tests/corpus/7747-typed-array-class-values.ts b/tests/corpus/7747-typed-array-class-values.ts new file mode 100644 index 0000000000..523896c960 --- /dev/null +++ b/tests/corpus/7747-typed-array-class-values.ts @@ -0,0 +1,42 @@ +// Typed-array unions chosen through class values, and held in fields, +// parameters, returns and records. + +type UintArray = Uint8Array | Uint16Array | Uint32Array; + +const pick = (max: number): { new (n: number): UintArray } => + max <= 2 ** 8 ? Uint8Array : max <= 2 ** 16 ? Uint16Array : Uint32Array; + +class Stack { + heap: UintArray; + length = 0; + constructor(max: number) { + const Heap = pick(max); + this.heap = new Heap(max); + } + push(n: number): void { + this.heap[this.length++] = n; + } + pop(): number | undefined { + return this.heap[--this.length]; + } +} + +for (const max of [10, 1000, 100000]) { + const stack = new Stack(max); + for (let i = 1; i <= 4; i++) stack.push(i * 250); + console.log(stack.heap.BYTES_PER_ELEMENT, stack.pop(), stack.pop(), stack.length); +} + +function grow(xs: UintArray, by: number): UintArray { + const Next = pick(xs.length * by); + const out = new Next(xs.length * by); + out.set(xs); + return out; +} + +const record: { data: UintArray; label: string } = { data: new Uint8Array([1, 2, 3]), label: "r" }; +record.data = grow(record.data, 100); +record.data[299] = 65535; +console.log(record.label, record.data.length, record.data[0], record.data[2], record.data[299]); +record.data = grow(record.data, 1000); +console.log(record.data.BYTES_PER_ELEMENT, record.data.length, record.data[1]); diff --git a/tests/corpus/7748-typed-array-number-array-unions.ts b/tests/corpus/7748-typed-array-number-array-unions.ts new file mode 100644 index 0000000000..706ed37b56 --- /dev/null +++ b/tests/corpus/7748-typed-array-number-array-unions.ts @@ -0,0 +1,78 @@ +// Unions of typed arrays and number arrays, as a cache picks its index +// arrays by capacity: a linked list over next/prev index arrays. + +type NumberArray = Uint8Array | Uint16Array | Uint32Array | number[]; + +function indexArray(max: number): NumberArray { + if (max <= 2 ** 8) return new Uint8Array(max); + if (max <= 2 ** 16) return new Uint16Array(max); + if (max <= 2 ** 32) return new Uint32Array(max); + return new Array(4).fill(0); +} + +class Order { + next: NumberArray; + prev: NumberArray; + head = 0; + tail = 0; + size = 0; + + constructor(max: number, force = false) { + this.next = force ? new Array(max).fill(0) : indexArray(max); + this.prev = force ? new Array(max).fill(0) : indexArray(max); + } + + push(index: number): void { + if (this.size === 0) { + this.head = this.tail = index; + } else { + this.next[this.tail] = index; + this.prev[index] = this.tail; + this.tail = index; + } + this.size++; + } + + moveToTail(index: number): void { + if (index === this.tail) return; + if (index === this.head) this.head = this.next[index]!; + else this.next[this.prev[index]!] = this.next[index]!; + this.prev[this.next[index]!] = this.prev[index]!; + this.next[this.tail] = index; + this.prev[index] = this.tail; + this.tail = index; + } + + walk(): number[] { + const out: number[] = []; + for (let i = this.head, n = 0; n < this.size; n++, i = this.next[i]!) out.push(i); + return out; + } +} + +for (const [max, force] of [ + [8, false], + [300, false], + [70000, false], + [8, true], +] as const) { + const order = new Order(max, force); + for (let i = 0; i < 6; i++) order.push(i); + order.moveToTail(0); + order.moveToTail(3); + order.moveToTail(5); + console.log(max, force, order.walk().join(","), order.next.length, order.prev[3]); +} + +// The number-array arm reads holes and missing indexes as undefined, +// and writes past its length grow it. +const arms: NumberArray[] = [new Uint8Array(3), [1, 2, 3]]; +for (const a of arms) { + a[1] = 300; + a[2] += 0.5; + a[5] = 8; + console.log(a.length, a[1], a[2], a[4], a[5]); + let total = 0; + for (const v of a) total += v ?? -1; + console.log(total, [...a].length, Array.from(a).length); +} diff --git a/tests/harness/corpus-costs/7745-typed-array-unions.ts.json b/tests/harness/corpus-costs/7745-typed-array-unions.ts.json new file mode 100644 index 0000000000..ee546c4fd4 --- /dev/null +++ b/tests/harness/corpus-costs/7745-typed-array-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 3450, "san": 4000 } diff --git a/tests/harness/corpus-costs/7747-typed-array-class-values.ts.json b/tests/harness/corpus-costs/7747-typed-array-class-values.ts.json new file mode 100644 index 0000000000..89e0d92e71 --- /dev/null +++ b/tests/harness/corpus-costs/7747-typed-array-class-values.ts.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 2350 } diff --git a/tests/harness/corpus-costs/7748-typed-array-number-array-unions.ts.json b/tests/harness/corpus-costs/7748-typed-array-number-array-unions.ts.json new file mode 100644 index 0000000000..89fb8ddd2b --- /dev/null +++ b/tests/harness/corpus-costs/7748-typed-array-number-array-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 2550, "san": 2300 } From e901526c8b1386f76869734669657b3e6101537b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 11:35:14 -0500 Subject: [PATCH 09/44] Point the renamed order-parity baselines at their corpus programs --- .../compiler/src/backend/llvm/expr-bytes.ts | 17 +++++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-effects.ts | 3 + .../src/frontend/lowering/containers/bytes.ts | 68 ++++++++++++++++++- .../src/frontend/lowering/lower-inspect.ts | 20 +++--- .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/ir/ir.ts | 8 +++ packages/compiler/src/ir/validate.ts | 3 + .../corpus/7745-typed-array-unions.ts.json | 2 +- .../7747-typed-array-class-values.ts.json | 2 +- ...48-typed-array-number-array-unions.ts.json | 2 +- .../7749-typed-array-element-searches.ts.json | 6 ++ .../7750-typed-array-union-inspect.ts.json | 6 ++ packages/runtime/src/scr_bytes.c | 45 ++++++++++++ packages/runtime/src/scr_bytes.h | 8 +++ packages/runtime/src/scr_inspect.c | 6 ++ packages/runtime/src/scr_inspect.h | 4 ++ .../7749-typed-array-element-searches.ts | 22 ++++++ .../corpus/7750-typed-array-union-inspect.ts | 14 ++++ .../7749-typed-array-element-searches.ts.json | 1 + .../7750-typed-array-union-inspect.ts.json | 1 + 21 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7749-typed-array-element-searches.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-typed-array-union-inspect.ts.json create mode 100644 tests/corpus/7749-typed-array-element-searches.ts create mode 100644 tests/corpus/7750-typed-array-union-inspect.ts create mode 100644 tests/harness/corpus-costs/7749-typed-array-element-searches.ts.json create mode 100644 tests/harness/corpus-costs/7750-typed-array-union-inspect.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 41ddf2b9d8..4d27fa647e 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -936,6 +936,23 @@ export function emitBytesIntrinsic( B.line(`${t} = fcmp one double ${idx.name}, ${f64Lit(-1)}`); return { name: t, type: e.type }; } + case "indexOfElem": + case "lastIndexOfElem": + return call( + "scr_bytes_index_of_elem", + "double (ptr, double, double, i1 zeroext)", + `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]!.name}, i1 ${method === "lastIndexOfElem"}`, + false, + false, + ); + case "includesElem": + return call( + "scr_bytes_includes_elem", + "zeroext i1 (ptr, double, double)", + `ptr ${r.name}, double ${args[0]!.name}, double ${args[1]!.name}`, + false, + false, + ); case "fillElem": // Per-element TypedArray fill (non-u8): slice-style index // defaults, never throws; the receiver comes back +1. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index af32c69368..072e65bba7 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -649,6 +649,7 @@ export const LIB_FN_SYMS: Record = { "process.stderrWriteBytes": "scr_process_stderr_write_bytes", "insp.buffer": "scr_insp_buffer", "insp.typedArray": "scr_insp_typed_array", + "insp.bytes": "scr_insp_bytes", // util.inspect (scr_inspect.c): all args borrowed; string results +1; // the begin/entry/key/moreItems/end accumulator drives the emitted // container walks. None of these throw. diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index 6ebe1222b0..43a9176d54 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -181,6 +181,9 @@ function expressionPreservesEdges( case "bytesIntrinsic": return ( e.method === "get" || + e.method === "indexOfElem" || + e.method === "lastIndexOfElem" || + e.method === "includesElem" || e.method === "length" || e.method === "byteLength" || e.method === "byteOffset" || diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 11f56986b6..c804d62e16 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -41,7 +41,7 @@ import { lowerStaticallyUndefinedArgument, } from "../optional-arguments.js"; import { lowerArraySpreadItems, lowerCheckedArrayFrom } from "./array-construction.js"; -import { lowerToNumberArgument } from "../lower-exprs.js"; +import { lowerOptionalNumber, lowerToNumberArgument } from "../lower-exprs.js"; import { lowerBytesIteratorValue } from "../iterator-adapters.js"; import { lowerArrayCallback } from "./callback-arguments.js"; import { callbackSite, findPredicateOk, predicateToBoolean } from "./array-callbacks.js"; @@ -911,6 +911,45 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { * variable-width read/writeUIntLE quartet — BUF_NUM_METHODS). Everything * else the lib declares (fill, indexOf, reverse, ...) falls through to * the SC2020 member fence. Null when this isn't a bytes method call. */ +/** The [needle, fromIndex] arguments of TypedArray indexOf, lastIndexOf + * and includes. A present fromIndex converts even when it is undefined + * (NaN, which ToIntegerOrInfinity makes 0); an absent one is 0, or + * +Infinity for lastIndexOf, which starts at the last element. */ +function elementSearchArgs(lowerer: Lowerer, call: ts.CallExpression, name: string): IrExpr[] { + const nodes = call.arguments.nodes; + const loc = locOf(call); + const needle = lowerer.lowerExprExpecting(nodes[0]!, F64); + const from: IrExpr = nodes[1] + ? lowerOptionalNumber(lowerer, lowerer.lowerExpr(nodes[1]), locOf(nodes[1]), nodes[1]) + : ({ + kind: "numLit", + value: name === "lastIndexOf" ? Infinity : 0, + type: F64, + loc, + } satisfies IrExpr); + // An undefined fromIndex is NaN once converted, which starts at 0. + if (from.kind === "unitLit") return [needle, { kind: "numLit", value: NaN, type: F64, loc }]; + if (from.type.kind === "undefinedT" || from.type.kind === "void") + return [ + needle, + { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: from, loc }], + result: { kind: "numLit", value: NaN, type: F64, loc }, + type: F64, + loc, + }, + ]; + if (from.type.kind !== "f64") lowerer.noLowering(`.${name} with a non-number fromIndex`, call); + return [needle, from]; +} + +const ELEMENT_SEARCHES: Readonly> = { + indexOf: "indexOfElem", + lastIndexOf: "lastIndexOfElem", + includes: "includesElem", +}; + /** The element-kind-generic typed-array methods on a union of typed * arrays (indexed-unions.ts): the arguments lower as for one typed array, * and each arm runs the method on its own receiver. Null for the methods @@ -947,6 +986,10 @@ function lowerUnionBytesMethodCall( method = "toReversed"; args = () => []; result = "receiver"; + } else if (Object.hasOwn(ELEMENT_SEARCHES, name) && (nodes.length === 1 || nodes.length === 2)) { + method = ELEMENT_SEARCHES[name]!; + args = () => elementSearchArgs(lowerer, call, name); + result = name === "includes" ? BOOL : F64; } else if (name === "join" && nodes.length <= 1) { method = "join"; args = () => [optional(0, { kind: "strLit", value: ",", type: STRING, loc }, STRING)]; @@ -1024,6 +1067,29 @@ export function lowerBytesMethodCall( if (value) return value; } } + // TypedArray indexOf / lastIndexOf / includes (the Buffer-declared forms, + // which also take string and Buffer needles, lowered above). + if ( + Object.hasOwn(ELEMENT_SEARCHES, name) && + !lowerer.checker + .getSymbolAtLocation(access.name) + ?.declarations.some( + (d) => ts.isInterfaceDeclaration(d.parent) && d.parent.name.text === "Buffer", + ) + ) { + if (nArgs < 1 || nArgs > 2 || call.arguments.nodes.some(ts.isSpreadElement)) { + lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call); + } + const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr); + return { + kind: "bytesIntrinsic", + method: ELEMENT_SEARCHES[name]!, + receiver, + args: elementSearchArgs(lowerer, call, name), + type: name === "includes" ? BOOL : F64, + loc, + }; + } if (name === "toReversed") { if (nArgs !== 0) { lowerer.noLowering(`.toReversed with ${nArgs} arguments on typed arrays`, call); diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 5266d5c4eb..d914faa5d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -736,11 +736,7 @@ function inspectSupport( case "array": return inspectSupport(lowerer, t.elem, visiting, out); case "bytes": - // A Uint8Array and a Buffer share the u8 representation and print - // differently; the other element kinds are never Buffers. - return t.elem === "u8" - ? "Uint8Array and Buffer values inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)" - : null; + return null; case "record": { if (visiting.has(t.shapeId)) { out.recursive = true; @@ -950,7 +946,9 @@ export function inspectExpr( loc, }; } - return { kind: "libCall", fn: "insp.buffer", args: [value], type: STRING, loc }; + // A Uint8Array, a Buffer and a DataView share the u8 representation + // and print differently: the view's own flags pick. + return { kind: "libCall", fn: "insp.bytes", args: [value, recurse, depth], type: STRING, loc }; case "dyn": return { kind: "libCall", fn: "insp.dyn", args: [value, recurse, depth], type: STRING, loc }; case "generator": @@ -2181,15 +2179,17 @@ export function lowerConsoleInspectArg( ); } if (!isBufferTypeName(tname)) { + // A u8 value the checker types as a Uint8Array may still hold a + // Buffer, which Node prints as one: the view's flags pick. return { kind: "libCall", - fn: "insp.typedArray", + fn: t.elem === "u8" ? "insp.bytes" : "insp.typedArray", args: [value, numLit(0, loc), numLit(2, loc)], type: STRING, loc, }; } - return inspectExpr(lowerer, t, value, numLit(0, loc), numLit(2, loc), loc); + return { kind: "libCall", fn: "insp.buffer", args: [value], type: STRING, loc }; } if (t.kind === "void") { // Node prints "undefined" for a void call's result; a void expression @@ -2510,7 +2510,7 @@ export function lowerInspectValue( value, (input) => ({ kind: "libCall", - fn: "insp.typedArray", + fn: value.type.kind === "bytes" && value.type.elem === "u8" ? "insp.bytes" : "insp.typedArray", args: [input, numLit(0, loc), numLit(options.depth, loc)], type: STRING, loc, @@ -2519,7 +2519,7 @@ export function lowerInspectValue( ); } // Buffers render fully at any depth and on one line (custom inspect). - return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(2, loc), loc); + return { kind: "libCall", fn: "insp.buffer", args: [value], type: STRING, loc }; } const walk = { recursive: false }; const reason = inspectSupport(lowerer, value.type, new Set(), walk); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 1be0f95cd1..071e9c59e1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4408,6 +4408,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "insp.buffer": { argTypes: [BYTES_U8], result: STRING }, /** A numeric typed array that is not a Buffer (any element kind). */ "insp.typedArray": { argTypes: [null, F64, F64], result: STRING }, + /** A u8 byte view in a union arm, element or field: a DataView, Buffer + * or Uint8Array by its own flags. */ + "insp.bytes": { argTypes: [null, F64, F64], result: STRING }, "insp.error": { argTypes: [null, F64, F64], result: STRING }, "insp.errorHasCause": { argTypes: [null], result: BOOL }, "insp.errorBagHasProperties": { argTypes: [DYN], result: BOOL }, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 878088d65b..c6842f9e4d 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2474,6 +2474,14 @@ export type IrBytesIntrinsicMethod = | "indexOfNum" | "lastIndexOfNum" | "includesNum" + /** TypedArray.prototype.indexOf / lastIndexOf / includes on any element + * kind: [f64 needle, f64 fromIndex]. indexOf and lastIndexOf compare + * with strict equality (NaN is never found, -0 matches 0), includes with + * SameValueZero (NaN is found). An omitted fromIndex is 0, or +Infinity + * for lastIndexOf (which clamps to the last index). */ + | "indexOfElem" + | "lastIndexOfElem" + | "includesElem" | "fill" | "fillNum" | "fillStr" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index a404c918ae..c319cef15e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2192,6 +2192,9 @@ function validateFunction( indexOfNum: { argTypes: [F64, F64], minArgs: 1, result: F64 }, lastIndexOfNum: { argTypes: [F64, F64], minArgs: 1, result: F64 }, includesNum: { argTypes: [F64, F64], minArgs: 1, result: BOOL }, + indexOfElem: { argTypes: [F64, F64], minArgs: 2, result: F64 }, + lastIndexOfElem: { argTypes: [F64, F64], minArgs: 2, result: F64 }, + includesElem: { argTypes: [F64, F64], minArgs: 2, result: BOOL }, fill: { argTypes: [BYTES_U8, F64, F64], minArgs: 1, result: BYTES_U8 }, fillNum: { argTypes: [F64, F64, F64], minArgs: 1, result: BYTES_U8 }, // Per-element TypedArray fill (any elem — the non-u8 fill path). diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json index c85ce647a3..9c770f8dd8 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7745-typed-array-unions.ts.json @@ -1,6 +1,6 @@ { "order": [ - "/tests/corpus/7742-typed-array-unions.ts" + "/tests/corpus/7745-typed-array-unions.ts" ], "diags": [] } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json index 63b6307eaf..a18e9e7df1 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7747-typed-array-class-values.ts.json @@ -1,6 +1,6 @@ { "order": [ - "/tests/corpus/7744-typed-array-class-values.ts" + "/tests/corpus/7747-typed-array-class-values.ts" ], "diags": [] } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json index b2274d160b..783fb14350 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7748-typed-array-number-array-unions.ts.json @@ -1,6 +1,6 @@ { "order": [ - "/tests/corpus/7743-typed-array-number-array-unions.ts" + "/tests/corpus/7748-typed-array-number-array-unions.ts" ], "diags": [] } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7749-typed-array-element-searches.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7749-typed-array-element-searches.ts.json new file mode 100644 index 0000000000..8a1372ceab --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7749-typed-array-element-searches.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7749-typed-array-element-searches.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-typed-array-union-inspect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-typed-array-union-inspect.ts.json new file mode 100644 index 0000000000..26cc319829 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7750-typed-array-union-inspect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7750-typed-array-union-inspect.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7bcfd0cfb7..dd6c60783c 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -3300,6 +3300,51 @@ double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd) return scr_bytes_index_of(b, &needle, off, 1, fwd); } +/* The first index a TypedArray search from `from` starts at + * (ToIntegerOrInfinity, then relative to the end when negative), or false + * when it starts past the last element. */ +static bool scr_bytes_search_start(size_t len, double from, size_t *start) { + double n = from != from ? 0 : trunc(from); + if (n >= (double)len) return false; + if (n < 0) n = n + (double)len < 0 ? 0 : n + (double)len; + *start = (size_t)n; + return true; +} + +double scr_bytes_index_of_elem(const ScrBytes *b, double x, double from, bool last) { + SCR_BYTES_SNAPSHOT(b); + size_t len = b->len; + size_t size = scr_bytes_elem_size(b->elem); + if (len == 0 || x != x) return -1; + if (!last) { + size_t k; + if (!scr_bytes_search_start(len, from, &k)) return -1; + for (; k < len; k++) + if (scr_bytes_decode(b->elem, b->data + k * size) == x) return (double)k; + return -1; + } + double n = from != from ? 0 : trunc(from); + double k = n >= 0 ? (n < (double)len - 1 ? n : (double)len - 1) : (double)len + n; + if (k < 0) return -1; + for (size_t i = (size_t)k + 1; i-- > 0;) + if (scr_bytes_decode(b->elem, b->data + i * size) == x) return (double)i; + return -1; +} + +bool scr_bytes_includes_elem(const ScrBytes *b, double x, double from) { + SCR_BYTES_SNAPSHOT(b); + size_t len = b->len; + size_t size = scr_bytes_elem_size(b->elem); + size_t k; + if (len == 0 || !scr_bytes_search_start(len, from, &k)) return false; + bool nan = x != x; + for (; k < len; k++) { + double v = scr_bytes_decode(b->elem, b->data + k * size); + if (v == x || (nan && v != v)) return true; + } + return false; +} + /* fill's shared core over a normalized byte pattern. nargs counts the * present offset/end args. An EMPTY pattern zero-fills when zero_ok * (Node's '' string) and is the constant TypeError otherwise (an empty diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 9b382dda5f..1ba7a7c8b0 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -499,6 +499,14 @@ ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n); void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name); double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, double align, bool fwd); double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd); +/* TypedArray indexOf (last false) and lastIndexOf (last true) over any + * element kind, with strict equality: NaN is never found and -0 matches 0. + * `from` is the fromIndex argument (ToIntegerOrInfinity applies); the + * caller passes 0, or +Infinity for lastIndexOf, when it is omitted. */ +double scr_bytes_index_of_elem(const ScrBytes *b, double x, double from, bool last); +/* TypedArray includes: indexOf's search from `from` with SameValueZero, + * so NaN is found. */ +bool scr_bytes_includes_elem(const ScrBytes *b, double x, double from); ScrBytes *scr_bytes_fill(ScrBytes *b, const ScrBytes *pattern, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_num(ScrBytes *b, double v, double nargs, double offset, double end); ScrBytes *scr_bytes_fill_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc, diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 1dce8d3c2f..522a569774 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1489,6 +1489,12 @@ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth) { return out; } +ScrStr *scr_insp_bytes(ScrBytes *b, double recurse, double depth) { + if (b->is_data_view) return scr_insp_data_view(b, recurse, depth); + if (b->is_buffer) return scr_insp_buffer(b); + return scr_insp_typed_array(b, recurse, depth); +} + ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->kind == SCR_DYN_PROXY) return scr_insp_dyn(d->v.proxy.target, recurse, depth); switch (d->kind) { diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 5ac3aa9dd2..af146c7d3f 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -45,6 +45,10 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth); /* A numeric typed array that is not a Buffer: `Uint8Array(n) [ ... ]`, * `[Uint8Array]` past the depth limit. Borrows b; +1. */ ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth); +/* A u8 byte view whose static type does not say which one it is (a union + * arm, an element or field): a DataView, a Buffer () or a + * Uint8Array, by the view's own flags. */ +ScrStr *scr_insp_bytes(ScrBytes *b, double recurse, double depth); /* format's %s / rest-args twin: dyn strings pass verbatim. */ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth); void scr_insp_begin(double recurse); diff --git a/tests/corpus/7749-typed-array-element-searches.ts b/tests/corpus/7749-typed-array-element-searches.ts new file mode 100644 index 0000000000..7cae267ecf --- /dev/null +++ b/tests/corpus/7749-typed-array-element-searches.ts @@ -0,0 +1,22 @@ +// TypedArray indexOf, lastIndexOf and includes on every element kind, on +// Buffers (their own search), and on unions of typed arrays: strict +// equality for indexOf/lastIndexOf, SameValueZero for includes. +const f = new Float64Array([1, NaN, -0, 0, 2.5, NaN, 1]); +console.log(f.indexOf(NaN), f.includes(NaN), f.lastIndexOf(NaN)); +console.log(f.indexOf(0), f.indexOf(-0), f.lastIndexOf(0), f.lastIndexOf(-0), f.includes(-0)); +console.log(f.indexOf(1, 1), f.indexOf(1, -1), f.indexOf(1, -100), f.indexOf(1, 100), f.indexOf(1, 1.9)); +console.log(f.lastIndexOf(1, 5), f.lastIndexOf(1, -2), f.lastIndexOf(1, -100), f.lastIndexOf(1, 100), f.lastIndexOf(1, undefined)); +console.log(f.includes(2.5, 4), f.includes(2.5, 5), f.includes(2.5, -3), f.includes(NaN, 6)); +const u = new Uint8Array([0, 255, 3, 255]); +console.log(u.indexOf(255), u.indexOf(-1), u.indexOf(511), u.indexOf(3.5), u.includes(NaN), u.lastIndexOf(255)); +const i = new Int16Array([-5, 7, -5]); +console.log(i.indexOf(-5), i.lastIndexOf(-5), i.includes(7), i.indexOf(65531)); +const h = new Float32Array([0.1, 0.5, 16777217]); +console.log(h.indexOf(0.1), h.indexOf(0.5), h.includes(16777216), h.indexOf(16777217)); +const c = new Uint8ClampedArray([300, -4]); +console.log(c.indexOf(255), c.indexOf(0), new Uint32Array(0).indexOf(0), new Uint32Array(0).includes(0)); +const b = Buffer.from([1, 2, 3, 2]); +console.log(b.indexOf(2), b.indexOf(258), b.lastIndexOf(2), b.includes("\u0003")); +type UintArray = Uint8Array | Uint16Array | Uint32Array; +const mk = (n: number): UintArray => (n > 1 ? new Uint16Array([1, 700, 1]) : new Uint8Array([1, 188, 1])); +for (const x of [mk(1), mk(2)]) console.log(x.indexOf(700), x.lastIndexOf(1), x.includes(188), x.indexOf(1, 1)); diff --git a/tests/corpus/7750-typed-array-union-inspect.ts b/tests/corpus/7750-typed-array-union-inspect.ts new file mode 100644 index 0000000000..aebb0dbdf1 --- /dev/null +++ b/tests/corpus/7750-typed-array-union-inspect.ts @@ -0,0 +1,14 @@ +// Printing u8 byte views whose static type does not say what they are: +// union arms, elements and fields print as Node does by the value's kind. +type UintArray = Uint8Array | Uint16Array | Uint32Array; +const mk = (n: number): UintArray => (n > 1 ? new Uint16Array([1, 700]) : new Uint8Array([1, 188])); +const a = mk(1), b = mk(2); +console.log(a, b); +console.log([a, b]); +const bufArm: Uint8Array | Uint16Array = Buffer.from([1, 2, 3]); +console.log(bufArm); +const u8s: Uint8Array[] = [new Uint8Array([9]), Buffer.from([10, 11])]; +console.log(u8s); +const rec: { bytes: Uint8Array; view: DataView } = { bytes: new Uint8Array(2), view: new DataView(new ArrayBuffer(2)) }; +console.log(rec); +console.log(new Uint8Array([5, 6]), Buffer.from([7])); diff --git a/tests/harness/corpus-costs/7749-typed-array-element-searches.ts.json b/tests/harness/corpus-costs/7749-typed-array-element-searches.ts.json new file mode 100644 index 0000000000..7d76a0583c --- /dev/null +++ b/tests/harness/corpus-costs/7749-typed-array-element-searches.ts.json @@ -0,0 +1 @@ +{ "plain": 2650, "san": 2850 } diff --git a/tests/harness/corpus-costs/7750-typed-array-union-inspect.ts.json b/tests/harness/corpus-costs/7750-typed-array-union-inspect.ts.json new file mode 100644 index 0000000000..c0aef3bb72 --- /dev/null +++ b/tests/harness/corpus-costs/7750-typed-array-union-inspect.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3900 } From 70e6e9b59182af4fda8f40a48d4b923c963ba090 Mon Sep 17 00:00:00 2001 From: Luis Alvarez D Date: Fri, 23 Oct 2026 11:40:15 -0500 Subject: [PATCH 10/44] Fence split requires that close a CommonJS cycle --- .../frontend/lowering/cjs-inline-require.ts | 7 ----- .../compiler/src/frontend/lowering/lowerer.ts | 7 +++++ tests/harness/errors.test.ts | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index eb05550c0f..3d28dfae3a 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -431,13 +431,6 @@ export function inlineNamespaceRequireOf( /** `value` evaluated after the required module's run-once %init (and its * module-registry link) at the require's own position. */ export function withRequireInit(lowerer: Lowerer, req: InlineRequire, value: IrExpr): IrExpr { - if (lowerer.inlineRequireCycleCalls.has(req.call)) { - lowerer.unsupported( - "SC1090", - req.call, - `require() of '${req.spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`, - ); - } // Every inline program require is a preflight module edge, so its module // has an initializer; never read its exports without evaluating it. if (!lowerer.initNameOf.has(req.dep)) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 43303b4c63..2b76080fd5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4851,6 +4851,13 @@ export class Lowerer { * their external dependencies by name). Null for everything else * (builtins load nothing; the rest kept its preflight fence). */ requireInitStmt(spec: string, node: ts.Node): IrStmt | null { + if (this.inlineRequireCycleCalls.has(node)) { + this.unsupported( + "SC1090", + node, + `require() of '${spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`, + ); + } if (canonicalBuiltinModule(spec) === "punycode") return this.punycodeRequireWarning(node); // Project requires resolve within the program; another bare require can // be a program-module edge when it names an opted-in --npm-static package diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 319e7329f8..f379973b34 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -325,6 +325,36 @@ console.log(cells.join(",")); ); }); + test("a split require closing a CommonJS cycle throws the partial-exports fence", async () => { + // Node answers the closing require with the dependency's partial exports + // ({ fromA: 1 } at this point), which the static export model cannot + // represent. The split assignment must hit the same fence as an inline + // require in expression position instead of reading uninitialized + // export storage. + const r = await compileAndRun( + "split-require-cycle", + `require("./a.cjs"); +`, + "cjs", + { + "a.cjs": `exports.fromA = 1; +const b = require("./b.cjs"); +exports.fromB = b.fromB; +`, + "b.cjs": `exports.fromB = 2; +var a; +a = require("./a.cjs"); +console.log(a.fromA, a.fromB); +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch( + /^Uncaught Error: require\(\) of '\.\/a\.cjs' while it is still evaluating \(a require cycle: Node answers its partial exports, which the static export table does not model\) is not supported yet \[SC1090 at .*b\.cjs:3\]\n$/, + ); + }); + test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => { // Effect-free surplus args drop at compile time, JS-exact (corpus // 1760 — the test-string-decoder writeSequences shape). An effectful From c016b97acb4a67cabd18527c2635de773834c144 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 12:54:17 -0500 Subject: [PATCH 11/44] Fix CI on typed-array unions --- .../src/frontend/lowering/containers/bytes.ts | 5 ++ .../src/frontend/lowering/indexed-unions.ts | 49 +++++++++++++---- .../src/frontend/lowering/lower-inspect.ts | 13 ++++- .../compiler/src/frontend/lowering/lowerer.ts | 3 ++ .../test/typed-array-union-emission.test.ts | 52 +++++++++++++++++++ tests/diagnostics/index-signature-values.ts | 4 -- .../index-signature-values.ts.txt | 15 ++---- 7 files changed, 115 insertions(+), 26 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index c804d62e16..1c5513506d 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -961,6 +961,11 @@ function lowerUnionBytesMethodCall( name: string, ): IrExpr | null { if (isJsSourceFile(ts.getSourceFileOfNode(call))) return null; + // A Buffer arm's methods differ from a typed array's (slice aliases, fill + // checks its range, searches take strings and wrap numbers), so such a + // union narrows to its arms first. + const receiverName = lowerer.checker.typeToString(lowerer.typeOf(access.expression)); + if (/(^|\| )(NonShared)?Buffer\b/.test(receiverName)) return null; const nodes = call.arguments.nodes; if (nodes.some(ts.isSpreadElement)) return null; const loc = locOf(call); diff --git a/packages/compiler/src/frontend/lowering/indexed-unions.ts b/packages/compiler/src/frontend/lowering/indexed-unions.ts index aa93871c9e..774f3aef37 100644 --- a/packages/compiler/src/frontend/lowering/indexed-unions.ts +++ b/packages/compiler/src/frontend/lowering/indexed-unions.ts @@ -131,10 +131,6 @@ function dispatchHelper( const returning = (value: IrExpr, loc: SrcLoc): IrStmt[] => [{ kind: "return", value, loc }]; -/** Safe-read helpers mapped to their numeric twins, for the fusion of an - * immediate ToNumber (tryLowerNumericIndexRead). */ -const numericTwins = new WeakMap>(); - /** `u[i]` read as an element or undefined (an invalid index), as the * typed-array and number-array reads answer. */ export function lowerIndexedUnionRead( @@ -164,9 +160,7 @@ export function lowerIndexedUnionRead( loc, ); const numeric = indexedUnionNumberHelper(lowerer, receiver.type, arms, loc); - let twins = numericTwins.get(lowerer); - if (!twins) numericTwins.set(lowerer, (twins = new Map())); - twins.set(name, numeric); + lowerer.indexedUnionNumericTwins.set(name, numeric); return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc }; } @@ -221,7 +215,7 @@ function indexedUnionNumberHelper( /** The numeric twin of an indexed-union read helper call, or null. */ export function indexedUnionNumericTwin(lowerer: Lowerer, call: IrExpr): IrExpr | null { if (call.kind !== "call") return null; - const twin = numericTwins.get(lowerer)?.get(call.callee); + const twin = lowerer.indexedUnionNumericTwins.get(call.callee); return twin === undefined ? null : { ...call, callee: twin, type: F64 }; } @@ -479,12 +473,49 @@ function inlineDispatch(receiver: IrExpr, operands: readonly IrExpr[]): boolean ); } -/** Whether evaluating `e` can rebind one of the plain reads `reads`. */ +/** Expressions that run no program code: reads, literals and operators + * over primitive values. */ +const CODE_FREE_EXPRS = new Set([ + "numLit", + "boolLit", + "strLit", + "unitLit", + "varRef", + "unary", + "logical", + "ternary", + "toBool", + "strEq", + "strCmp", + "arrayGet", + "arrayHas", + "arrayState", + "bytesIntrinsic", + "fieldGet", + "recordGet", + "unionNarrow", + "unionIsTag", + "unionDisc", + "upcast", + "nullish", +]); +const PRIMITIVE_OPERANDS = new Set(["f64", "bool", "string", "bigint"]); + +/** Whether evaluating `e` can rebind one of the plain reads `reads`: by + * assigning it, or by running program code (a call, a getter, a + * conversion) that could assign a captured binding. */ function rebinds(e: IrExpr, reads: readonly IrExpr[]): boolean { const ids = new Set(reads.flatMap((r) => (r.kind === "varRef" ? [r.localId] : []))); let found = false; const expr = (x: IrExpr): boolean => { if ((x.kind === "assignExpr" || x.kind === "incDec") && ids.has(x.localId)) found = true; + else if (x.kind === "bin") { + if (!PRIMITIVE_OPERANDS.has(x.left.type.kind) || !PRIMITIVE_OPERANDS.has(x.right.type.kind)) + found = true; + } else if (x.kind === "call") { + // The union dispatch helpers only read and store elements. + if (!x.callee.startsWith("%union.")) found = true; + } else if (!CODE_FREE_EXPRS.has(x.kind)) found = true; return !found && everyExprChild(x, expr, stmt); }; const stmt = (x: IrStmt): boolean => { diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index d914faa5d1..fc52ed5557 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -948,7 +948,13 @@ export function inspectExpr( } // A Uint8Array, a Buffer and a DataView share the u8 representation // and print differently: the view's own flags pick. - return { kind: "libCall", fn: "insp.bytes", args: [value, recurse, depth], type: STRING, loc }; + return { + kind: "libCall", + fn: "insp.bytes", + args: [value, recurse, depth], + type: STRING, + loc, + }; case "dyn": return { kind: "libCall", fn: "insp.dyn", args: [value, recurse, depth], type: STRING, loc }; case "generator": @@ -2510,7 +2516,10 @@ export function lowerInspectValue( value, (input) => ({ kind: "libCall", - fn: value.type.kind === "bytes" && value.type.elem === "u8" ? "insp.bytes" : "insp.typedArray", + fn: + value.type.kind === "bytes" && value.type.elem === "u8" + ? "insp.bytes" + : "insp.typedArray", args: [input, numLit(0, loc), numLit(options.depth, loc)], type: STRING, loc, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3c74cfbadb..090c957ad1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2896,6 +2896,9 @@ export class Lowerer { /** util.inspect's per-type traversal helpers (%util.insp.N), interned * by typeKey — the assertHelpers pattern with its own namespace. */ readonly inspectHelpers = new Map(); + /** Indexed-union safe-read helpers mapped to their numeric twins, for the + * fusion of an immediate ToNumber (tryLowerNumericIndexRead). */ + readonly indexedUnionNumericTwins = new Map(); private readonly unionLiteralOwners = new Map< string, { owners: Map | null; shapes: number; unions: number } diff --git a/packages/compiler/test/typed-array-union-emission.test.ts b/packages/compiler/test/typed-array-union-emission.test.ts index 743788af06..5aaf846ed8 100644 --- a/packages/compiler/test/typed-array-union-emission.test.ts +++ b/packages/compiler/test/typed-array-union-emission.test.ts @@ -54,3 +54,55 @@ test("a typed-array union read through a computed receiver goes through one help // The receiver is a call: evaluated once, then dispatched by the helper. expect(body(llvm, "first")).toContain("@sc_bf__x25_union_getNumber"); }); + +const stores = ` +type Wide = Uint8Array | Uint16Array; +const pick = (wide: boolean): Wide => (wide ? new Uint16Array(4) : new Uint8Array(4)); +function fill(xs: Wide): void { + for (let i = 0; i < xs.length; i++) xs[i] = i + 1; +} +function rebind(): void { + let u = pick(false); + let i = 0; + const swap = (): number => { + u = pick(true); + i = 2; + return 3; + }; + u[i] = swap(); + console.log(u, i); +} +fill(pick(process.argv.length > 9)); +rebind(); +`; + +test("a typed-array union store runs in place unless its value can rebind the receiver or index", async () => { + const llvm = await emitLlvm(stores); + expect(body(llvm, "fill")).not.toContain("@sc_bf__x25_union_idxSet"); + // The call may assign the captured receiver and index, which the store + // read before it: the helper evaluates them first. + expect(body(llvm, "rebind")).toContain("@sc_bf__x25_union_idxSet"); +}); + +test("typed-array methods on a union with a Buffer arm need a narrowing first", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-typed-array-unions-")); + try { + const entry = join(dir, "main.ts"); + await writeFile( + entry, + `import { Buffer } from "node:buffer"; +const u: Buffer | Uint16Array = process.argv.length > 9 ? new Uint16Array(2) : Buffer.alloc(2); +console.log(u.slice(1)); +`, + ); + const result = await compile(entry, { + outDir: dir, + outPath: join(dir, "main.ll"), + outputKind: "llvm", + }); + expect(result.ok).toBe(false); + expect(JSON.stringify(result.diagnostics)).toContain("slice"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/diagnostics/index-signature-values.ts b/tests/diagnostics/index-signature-values.ts index 6c53b141c5..1f3a01ba51 100644 --- a/tests/diagnostics/index-signature-values.ts +++ b/tests/diagnostics/index-signature-values.ts @@ -8,10 +8,6 @@ const bySymbol: { [s: symbol]: number } = {}; console.log(bySymbol); -// Storage is supported, but composite typed-array inspection is not. -const buffers: Record = {}; -console.log(buffers); - // Dual signatures whose value types intern DIFFERENTLY cannot share the // one store (tsc requires only assignability, but the store gives one // answer per key). diff --git a/tests/harness/__snapshots__/index-signature-values.ts.txt b/tests/harness/__snapshots__/index-signature-values.ts.txt index bbbed7fe2a..ea36a9c76f 100644 --- a/tests/harness/__snapshots__/index-signature-values.ts.txt +++ b/tests/harness/__snapshots__/index-signature-values.ts.txt @@ -7,18 +7,11 @@ index-signature-values.ts:8:7 - error SC2006: values of type '{ [s: symbol]: num hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering -index-signature-values.ts:13:13 - error SC1090: console.log of '{ [key: string]: Uint8Array }' values (Uint8Array and Buffer values inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)) are not supported yet +index-signature-values.ts:14:7 - error SC2006: values of type '{ [k: string]: string | number; [n: number]: number; }' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown') - 12 | const buffers: Record = {}; - 13 | console.log(buffers); - | ^~~~~~~ - 14 | - -index-signature-values.ts:18:7 - error SC2006: values of type '{ [k: string]: string | number; [n: number]: number; }' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown') - - 17 | // answer per key). - 18 | const dual: { [k: string]: string | number; [n: number]: number } = {}; + 13 | // answer per key). + 14 | const dual: { [k: string]: string | number; [n: number]: number } = {}; | ^~~~ - 19 | console.log(dual); + 15 | console.log(dual); hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering \ No newline at end of file From e3b42a311d988a04ad38cd805045adc616eec8b3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 13:10:16 -0500 Subject: [PATCH 12/44] Add --target-cpu to compile x86-64 programs for a microarchitecture level Release builds compile for the x86-64 baseline, so LLVM vectorizes with SSE2 only. `--target-cpu=` compiles the program's code for a higher level: every emitted function carries the level as its `target-cpu`, so LLVM can use AVX2, BMI2, FMA or AVX-512. The executable then runs only on CPUs of that level. - `native` resolves to the highest level the build machine supports, read from /proc/cpuinfo on Linux and `sysctl machdep.cpu` on macOS. - The level is part of the executable cache key; `baseline` and builds without the option produce the same output and keys as before. - The runtime pack keeps its baseline code. - Targets other than x86-64 refuse a level (SC3002). The CLI's cached bootstrap route leaves builds with the option to the full compiler. - `bench:runtime` takes `--baseline-target-cpu` and `--candidate-target-cpu`. --- docs/content/docs/cli.mdx | 40 ++++++ packages/cli/src/bootstrap.ts | 3 + packages/cli/test/target-cpu.test.ts | 66 ++++++++++ .../compiler/src/backend/llvm/emit-module.ts | 23 +++- .../compiler/src/backend/llvm/emitter.test.ts | 10 ++ packages/compiler/src/backend/llvm/emitter.ts | 7 ++ .../compiler/src/backend/target-cpu.test.ts | 51 ++++++++ packages/compiler/src/backend/target-cpu.ts | 119 ++++++++++++++++++ packages/compiler/src/cli/command.ts | 29 ++++- packages/compiler/src/cli/usage.ts | 7 ++ packages/compiler/src/compile-types.ts | 7 ++ .../src/executable/executable-cache.test.ts | 2 + .../src/executable/executable-cache.ts | 5 + packages/compiler/src/index.ts | 11 ++ packages/compiler/src/native/prepare.ts | 2 + scripts/bench-runtime.mjs | 24 +++- 16 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 packages/cli/test/target-cpu.test.ts create mode 100644 packages/compiler/src/backend/target-cpu.test.ts create mode 100644 packages/compiler/src/backend/target-cpu.ts diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 8b03536205..9fa5e94475 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -135,6 +135,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--detached-exit
On Linux, when the program calls process.exit(), hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.
+
--target-cpu <baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>
+
On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, baseline, runs on any x86-64 CPU. A higher level lets LLVM use more instructions, and the executable then runs only on CPUs that support that level. native uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See Target CPU.
+
--profile-generate <pattern>
On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where %p expands to the process ID; the LLVM_PROFILE_FILE environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with --optimization=release or speed.
@@ -228,6 +231,43 @@ $ scriptc build app.ts --optimization=speed --profile-use app.profdata Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time. +## Target CPU + +By default, x86-64 executables run on any x86-64 CPU, so the compiler uses only the baseline instruction set (SSE2). `--target-cpu` compiles the program's code for a higher x86-64 microarchitecture level. Each level includes the ones below it: + + + + + + + + + + + + + + + + + + + + + + +
LevelAdds
x86-64-v2SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT
x86-64-v3AVX, AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE
x86-64-v4AVX-512 (F, BW, CD, DQ, VL)
+ +```console +$ scriptc build app.ts --target-cpu=x86-64-v3 -o app +``` + +Loops over typed arrays and numbers gain the most, because LLVM vectorizes them with wider registers. Some loops run slower at a higher level, so measure the program on the machines it will run on. + +An executable built for a level runs only on CPUs that support that level. On an older CPU, it crashes with an illegal-instruction signal (`SIGILL`) when it reaches an instruction the CPU lacks. A named level is portable to every machine of that level. `native` selects the highest level the build machine supports, so use it only for executables that run on the machine that built them or on identical machines. + +The level applies to the program's own code. The runtime pack keeps its baseline code. Builds for different levels never share cached artifacts, and `baseline` builds are the same as builds without the option. + ## LLVM backend scriptc generates LLVM IR and compiles it to native code. Executable builds link the result with the matching precompiled runtime pack. Use --emit=llvm to inspect the generated code. diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 242a7c6f7d..f14866a9c0 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -70,6 +70,8 @@ async function tryFastPath(): Promise { values["profile-generate"] !== undefined || values["profile-use"] !== undefined || values.sparse || + // The full compiler resolves `native` against the build host. + values["target-cpu"] !== undefined || (values["external-types"] ?? []).length > 0 ) return null; @@ -200,6 +202,7 @@ async function coverageFastPath( values.emit !== undefined || values.strip || values["detached-exit"] || + values["target-cpu"] !== undefined || values.lib || values.profile !== undefined || values.ffi !== undefined || diff --git a/packages/cli/test/target-cpu.test.ts b/packages/cli/test/target-cpu.test.ts new file mode 100644 index 0000000000..c5852fb408 --- /dev/null +++ b/packages/cli/test/target-cpu.test.ts @@ -0,0 +1,66 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { hostTargetCpu } from "../../compiler/src/backend/target-cpu.js"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const repoRoot = join(import.meta.dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); +const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js"); +const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); +const host = hostTargetCpu(); +const runsV3 = host === "x86-64-v3" || host === "x86-64-v4"; + +const program = `const a = new Int32Array(4096); +for (let i = 0; i < a.length; i++) a[i] = (i * 2654435761) | 0; +let s = 0; +for (let r = 0; r < 64; r++) for (let i = 0; i < a.length; i++) s = (s + a[i]!) | 0; +console.log(s); +`; + +test.runIf(process.platform === "linux" && process.arch === "x64")( + "--target-cpu compiles the program for the level without mixing cache variants", + async () => { + const dir = await mkdtemp(join("/tmp", "scriptc-target-cpu-")); + const entry = join(dir, "main.ts"); + const outPath = join(dir, "program"); + const llvmPath = join(dir, "main.ll"); + const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") }; + const build = async (args: string[], bootstrap = false): Promise => { + await execFileAsync( + process.execPath, + [ + ...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]), + "build", + entry, + "-o", + outPath, + ...args, + ], + { env, maxBuffer: 4 * 1024 * 1024 }, + ); + return readFile(llvmPath, "utf8"); + }; + try { + await writeFile(entry, program); + const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`; + const baseline = await build([]); + expect(baseline).not.toContain('"target-cpu"'); + expect(await build(["--target-cpu=baseline"])).toBe(baseline); + const v3 = await build(["--target-cpu=x86-64-v3"]); + expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m); + if (runsV3) expect((await execFileAsync(outPath)).stdout).toBe(expected); + // The bootstrap's cached route builds without a level. + expect(await build([], true)).toBe(baseline); + expect((await execFileAsync(outPath)).stdout).toBe(expected); + await expect(build(["--target-cpu=haswell"])).rejects.toThrow(/unknown target CPU "haswell"/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 16a1b16014..e5258e5839 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -64,6 +64,7 @@ import { literalEqualityDefinition } from "./string-literal-equality.js"; import { STRING_READ_HELPERS, stringScanDecl } from "./string-reads.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; import { InternalCompilerError } from "../../errors.js"; +import { targetCpuAttribute } from "../target-cpu.js"; /** The function bodies emitted in `chunks` contiguous ranges, each by its * own emitter (LlEmitter.chunkEmitter), then merged in function order: the @@ -191,6 +192,20 @@ function readRange(dir: string, range: number): { result: ChunkResult; texts: st } } +/** The attribute groups of the emitted functions: #0 for every function, + * #1 for coroutine bodies and #2 for out-of-line record clones. Each carries + * the build's `--target-cpu` level. */ +function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] { + const cpu = targetCpuAttribute(emitter.targetCpu); + return [ + `attributes #0 = { sanitize_address${cpu} }`, + ...(emitter.usesCoroutines + ? [`attributes #1 = { sanitize_address presplitcoroutine${cpu} }`] + : []), + ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address${cpu} }`] : []), + ]; +} + export function emitModuleParts(emitter: LlEmitter): string[] { // Function bodies first (the literal/unit/fn-value tables fill as they // emit), then the file assembles around them — the runtime ABI’s order. @@ -972,9 +987,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // symbols specified by the library IR instead. for (const line of emitter.emitLibDefs(globals, globalReleaseLines, stamps)) out.push(line); out.push(...emitter.inlineRcTail(flushedDecls)); - out.push(`attributes #0 = { sanitize_address }`); - if (emitter.usesCoroutines) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`); - if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`); + out.push(...functionAttributes(emitter, hasNoInlineRecordClone)); out.push(``); out.push(...emitter.fieldAliasMetadata()); if (emitter.debug !== null) out.push(emitter.debug.render()); @@ -1122,9 +1135,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // sanitize_address is inert under the plain pipeline; the sanitized // lane's -fsanitize=address link activates instrumentation over the // emitted functions too (the runtime TUs get theirs from clang). - `attributes #0 = { sanitize_address }`, - ...(emitter.usesCoroutines ? [`attributes #1 = { sanitize_address presplitcoroutine }`] : []), - ...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address }`] : []), + ...functionAttributes(emitter, hasNoInlineRecordClone), ``, ); if (emitter.mod.workers) { diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index e6334ab4c7..7dffc91835 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -202,6 +202,16 @@ test("detached exit defines the runtime opt-in symbol only when requested", () = expect(detached.replace("@scr_detached_exit = constant i8 1\n", "")).toBe(plain); }); +test("a target CPU level reaches every function attribute group and nothing else", () => { + const module = moduleFor([call()]); + const plain = emitLlvmModule(module); + expect(plain).not.toContain("target-cpu"); + const v3 = emitLlvmModule(module, { targetCpu: "x86-64-v3" }); + expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m); + expect(v3.replaceAll(' "target-cpu"="x86-64-v3"', "")).toBe(plain); + expect(emitLlvmModule(module, { targetCpu: "baseline" })).toBe(plain); +}); + test("inline RC inlines every fast path at speed and keeps box releases as calls in release", () => { for (const boxed of [false, true]) { const module = moduleFor(Array.from({ length: 4 }, call), { boxed }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4aa9b65ada..2e46f60b96 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,3 +1,4 @@ +import type { TargetCpu } from "../target-cpu.js"; import { relieveHeapPressure } from "../../heap-pressure.js"; import { wasmBoundsChecks } from "./wasm-modules.js"; import { jsTruthy } from "./jsval.js"; @@ -482,6 +483,10 @@ export interface LlvmTargetOptions { * store the instance's address over 8 in 32 bits (classes.ts * compactRefLoadLines). Elsewhere they keep 8-byte pointers. */ lowRegion?: boolean; + /** The x86-64 level the program's code may require (`--target-cpu`): + * every emitted function carries it as its `target-cpu`. Unset, code + * runs on any x86-64 CPU. */ + targetCpu?: TargetCpu; } /** LlvmTargetOptions.lowRegion for an executable: Linux targets without @@ -1048,6 +1053,7 @@ export class LlEmitter { readonly emitLibraryIdentity: boolean; readonly runtimeAbiMarker: boolean; readonly detachedExit: boolean; + readonly targetCpu: TargetCpu | undefined; readonly wasmBoundsChecks: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the runtime ABI’s determinism discipline. */ @@ -1630,6 +1636,7 @@ export class LlEmitter { this.emitLibraryIdentity = options.emitLibraryIdentity !== false; this.runtimeAbiMarker = options.runtimeAbiMarker === true; this.detachedExit = options.detachedExit === true; + this.targetCpu = options.targetCpu; this.wasmBoundsChecks = options.wasmBoundsChecks === true || wasmBoundsChecks({ diff --git a/packages/compiler/src/backend/target-cpu.test.ts b/packages/compiler/src/backend/target-cpu.test.ts new file mode 100644 index 0000000000..428caee326 --- /dev/null +++ b/packages/compiler/src/backend/target-cpu.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "vitest"; +import { + isTargetCpuOption, + targetCpuAttribute, + targetCpuField, + targetCpuKeyParts, + targetCpuTargetRefusal, + x86Level, +} from "./target-cpu.js"; + +const V2 = ["cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"]; +const V3 = ["avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "abm", "movbe", "xsave"]; +const V4 = ["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"]; +const level = (flags: string[]) => x86Level((flag) => flags.includes(flag)); + +test("a host's level is the highest one whose features and lower levels' features it has", () => { + expect(level([])).toBe("baseline"); + expect(level(V2)).toBe("x86-64-v2"); + expect(level([...V2, ...V3])).toBe("x86-64-v3"); + expect(level([...V2, ...V3, ...V4])).toBe("x86-64-v4"); + // AVX-512 without BMI2 is still v2. + expect(level([...V2, ...V3.filter((f) => f !== "bmi2"), ...V4])).toBe("x86-64-v2"); + expect(level([...V3, ...V4])).toBe("baseline"); +}); + +test("the baseline adds no key part, attribute or option field", () => { + for (const cpu of [undefined, "baseline"] as const) { + expect(targetCpuKeyParts(cpu)).toEqual([]); + expect(targetCpuAttribute(cpu)).toBe(""); + expect(targetCpuField(cpu)).toEqual({}); + expect(targetCpuTargetRefusal(cpu, "linux", "arm64")).toBeNull(); + } + expect(targetCpuKeyParts("x86-64-v3")).toEqual(["target-cpu-x86-64-v3"]); + expect(targetCpuAttribute("x86-64-v4")).toBe(' "target-cpu"="x86-64-v4"'); + expect(targetCpuField("x86-64-v2")).toEqual({ targetCpu: "x86-64-v2" }); +}); + +test("a level is refused for targets that are not x86-64", () => { + expect(targetCpuTargetRefusal("x86-64-v3", "linux", "x64")).toBeNull(); + expect(targetCpuTargetRefusal("x86-64-v3", "darwin", "arm64")).toBe( + "--target-cpu=x86-64-v3 requires an x86-64 target", + ); + expect(targetCpuTargetRefusal("x86-64-v3", "wasi", "x64")).not.toBeNull(); +}); + +test("the option accepts the levels and native", () => { + for (const value of ["baseline", "x86-64-v2", "x86-64-v3", "x86-64-v4", "native"]) + expect(isTargetCpuOption(value)).toBe(true); + for (const value of ["x86-64", "haswell", "", undefined]) + expect(isTargetCpuOption(value)).toBe(false); +}); diff --git a/packages/compiler/src/backend/target-cpu.ts b/packages/compiler/src/backend/target-cpu.ts new file mode 100644 index 0000000000..228652ab31 --- /dev/null +++ b/packages/compiler/src/backend/target-cpu.ts @@ -0,0 +1,119 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { nativeCodegenTarget } from "./targets.js"; + +/** The x86-64 microarchitecture levels an executable may require + * (`--target-cpu`). `baseline` is the x86-64 baseline every release build + * targets; the other levels are LLVM's `x86-64-v2` to `x86-64-v4`. */ +export type TargetCpu = "baseline" | "x86-64-v2" | "x86-64-v3" | "x86-64-v4"; + +/** The values `--target-cpu` accepts: a level, or `native` for the highest + * level the build host supports. */ +export const TARGET_CPU_OPTIONS = ["baseline", "x86-64-v2", "x86-64-v3", "x86-64-v4", "native"]; + +export function isTargetCpuOption(value: unknown): value is TargetCpu | "native" { + return typeof value === "string" && TARGET_CPU_OPTIONS.includes(value); +} + +/** The CPU features each level adds to the one below it, as Linux names + * them in /proc/cpuinfo (`pni` is SSE3, `abm` includes LZCNT). */ +const LEVEL_FLAGS: readonly [TargetCpu, readonly string[]][] = [ + ["x86-64-v2", ["cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"]], + ["x86-64-v3", ["avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "abm", "movbe", "xsave"]], + ["x86-64-v4", ["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"]], +]; + +/** The macOS `machdep.cpu` spellings of the same features. */ +const DARWIN_FLAGS: Readonly> = { + cx16: "CX16", + lahf_lm: "LAHF", + popcnt: "POPCNT", + pni: "SSE3", + sse4_1: "SSE4.1", + sse4_2: "SSE4.2", + ssse3: "SSSE3", + avx: "AVX1.0", + avx2: "AVX2", + bmi1: "BMI1", + bmi2: "BMI2", + f16c: "F16C", + fma: "FMA", + abm: "LZCNT", + movbe: "MOVBE", + xsave: "XSAVE", + avx512f: "AVX512F", + avx512bw: "AVX512BW", + avx512cd: "AVX512CD", + avx512dq: "AVX512DQ", + avx512vl: "AVX512VL", +}; + +/** The highest level whose features (and every lower level's) are all in + * `has`. */ +export function x86Level(has: (flag: string) => boolean): TargetCpu { + let level: TargetCpu = "baseline"; + for (const [next, flags] of LEVEL_FLAGS) { + if (!flags.every(has)) break; + level = next; + } + return level; +} + +/** The highest level the build host supports, or null when the host is not + * an x86-64 Linux or macOS machine. */ +export function hostTargetCpu( + platform: string = process.platform, + arch: string = process.arch, +): TargetCpu | null { + if (arch !== "x64") return null; + try { + if (platform === "linux") { + const info = readFileSync("/proc/cpuinfo", "utf8"); + const line = /^flags\s*:(.*)$/m.exec(info); + if (!line) return null; + const flags = new Set(line[1]!.trim().split(/\s+/)); + return x86Level((flag) => flags.has(flag)); + } + if (platform === "darwin") { + const text = execFileSync( + "sysctl", + ["-n", "machdep.cpu.features", "machdep.cpu.leaf7_features", "machdep.cpu.extfeatures"], + { encoding: "utf8" }, + ); + const flags = new Set(text.trim().split(/\s+/)); + return x86Level((flag) => flags.has(DARWIN_FLAGS[flag]!)); + } + } catch { + return null; + } + return null; +} + +/** Cache-key parts for a target level; empty for the baseline so existing + * keys are unchanged. */ +export function targetCpuKeyParts(cpu: TargetCpu | undefined): string[] { + return cpu === undefined || cpu === "baseline" ? [] : [`target-cpu-${cpu}`]; +} + +/** The function attribute that makes LLVM generate code for the level, or + * "" for the baseline. */ +export function targetCpuAttribute(cpu: TargetCpu | undefined): string { + return cpu === undefined || cpu === "baseline" ? "" : ` "target-cpu"="${cpu}"`; +} + +/** The options field for a level: omitted for the baseline. */ +export function targetCpuField(cpu: TargetCpu | undefined): { targetCpu?: TargetCpu } { + return cpu === undefined || cpu === "baseline" ? {} : { targetCpu: cpu }; +} + +/** The error for a level on a target that is not x86-64, or null. */ +export function targetCpuTargetRefusal( + cpu: TargetCpu | undefined, + platform: string, + architecture: string = nativeCodegenTarget()?.architecture ?? process.arch, +): string | null { + if (cpu === undefined || cpu === "baseline") return null; + if (architecture !== "x64" || platform === "wasi") + return `--target-cpu=${cpu} requires an x86-64 target`; + return null; +} diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index 3285aa7fa6..57b15349ec 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -15,6 +15,13 @@ import { buildEnvelope } from "../diagnostics/envelope.js"; import { renderWarnings } from "../diagnostics/render.js"; import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js"; import { isNativeOptimization } from "../backend/optimization.js"; +import { + hostTargetCpu, + isTargetCpuOption, + TARGET_CPU_OPTIONS, + targetCpuField, + type TargetCpu, +} from "../backend/target-cpu.js"; import { setProvenanceSources } from "../frontend/provenance-registry.js"; import type { CliHost, NativeCacheWarmProfile } from "./host.js"; import { resolveOutputOptions } from "./output-options.js"; @@ -96,6 +103,7 @@ async function main(args: string[], host: CliHost): Promise { values.strip || values["windows-subsystem"] !== undefined || values["detached-exit"] || + values["target-cpu"] !== undefined || values["profile-generate"] !== undefined || values["profile-use"] !== undefined || values.sparse || @@ -154,6 +162,7 @@ async function main(args: string[], host: CliHost): Promise { values.sanitize || values["windows-subsystem"] !== undefined || values["detached-exit"] || + values["target-cpu"] !== undefined || values["profile-generate"] !== undefined || values["profile-use"] !== undefined || (values["npm-static"] ?? []).length > 0 || @@ -210,6 +219,7 @@ async function main(args: string[], host: CliHost): Promise { values.ffi !== undefined || values["windows-subsystem"] !== undefined || values["detached-exit"] || + values["target-cpu"] !== undefined || values["profile-generate"] !== undefined || values["profile-use"] !== undefined || (values["npm-static"] ?? []).length > 0 || @@ -218,7 +228,7 @@ async function main(args: string[], host: CliHost): Promise { values["explain-dynamic"] !== undefined ) { fail( - "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", + "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", ); } const profilePath = resolve(profileArg); @@ -261,6 +271,9 @@ async function main(args: string[], host: CliHost): Promise { if (command === "coverage" && values.strip) { fail(`--strip is a build/run option\n\n${USAGE}`); } + if (command === "coverage" && values["target-cpu"] !== undefined) { + fail(`--target-cpu is a build/run option\n\n${USAGE}`); + } if (command === "coverage" && values["detached-exit"]) { fail(`--detached-exit is a build/run option\n\n${USAGE}`); } @@ -377,6 +390,19 @@ async function main(args: string[], host: CliHost): Promise { if (windowsSubsystem !== undefined && command === "coverage") { fail(`--windows-subsystem is only supported for executable builds\n\n${USAGE}`); } + const targetCpuOption = values["target-cpu"]; + if (targetCpuOption !== undefined && !isTargetCpuOption(targetCpuOption)) { + fail( + `unknown target CPU "${targetCpuOption}" (supported: ${TARGET_CPU_OPTIONS.join(", ")})\n\n${USAGE}`, + ); + } + let targetCpu: TargetCpu | undefined = targetCpuOption === "native" ? undefined : targetCpuOption; + if (targetCpuOption === "native") { + const level = hostTargetCpu(); + if (level === null) + fail(`--target-cpu=native needs an x86-64 Linux or macOS build host; name a level instead`); + targetCpu = level; + } const output = command === "coverage" ? null @@ -498,6 +524,7 @@ async function main(args: string[], host: CliHost): Promise { ...(values.strip ? { strip: true } : {}), ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}), ...(values["detached-exit"] ? { detachedExit: true } : {}), + ...targetCpuField(targetCpu), // The instrumented program resolves a relative pattern against its own // working directory; resolve it here, where the user wrote it. ...(values["profile-generate"] === undefined diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index c4d07656af..01afbdd417 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -54,6 +54,12 @@ Options: a helper process so the parent sees the exit sooner. The helper keeps the executable mapped and its memory charged for up to ~100 ms after exit (opt in; --emit=exe only) + --target-cpu + x86-64: compile the program's code for a CPU level + (default: baseline, which runs on any x86-64 CPU). The + executable then requires that level: v3 adds AVX2, BMI2 + and FMA, v4 adds AVX-512. native uses the build host's + level. The runtime keeps its baseline code --profile-generate Linux: instrument the executable for profile-guided optimization. Each run writes a raw profile to the @@ -123,6 +129,7 @@ export const CLI_OPTIONS = { strip: { type: "boolean", default: false }, "windows-subsystem": { type: "string" }, "detached-exit": { type: "boolean", default: false }, + "target-cpu": { type: "string" }, "profile-generate": { type: "string" }, "profile-use": { type: "string" }, sparse: { type: "boolean", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 9ecd75974a..c50931c6a9 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -1,3 +1,4 @@ +import type { TargetCpu } from "./backend/target-cpu.js"; import type { WindowsSubsystem } from "./backend/targets.js"; import type { NativeLinkInfo } from "./backend/native-link-info.js"; import type { NativeOptimization } from "./backend/optimization.js"; @@ -36,6 +37,12 @@ export interface CompileBaseOptions { * executable mapped and the memory charged for up to ~100 ms after exit. * Executables only; no effect on other platforms. */ detachedExit?: boolean; + /** The x86-64 microarchitecture level the executable's code may require + * (`x86-64-v2` to `x86-64-v4`). The program's functions are compiled for + * that level, and the executable then runs only on CPUs that support it. + * Unset or `baseline`, the code runs on any x86-64 CPU. x86-64 targets + * only; the runtime pack keeps its baseline code. */ + targetCpu?: TargetCpu; /** Instrument the executable for profile-guided optimization. Each run * writes a raw profile to this path pattern (`%p` expands to the process * id; `LLVM_PROFILE_FILE` overrides the pattern at run time). Linux diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 5cf8c65887..923730e178 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -235,6 +235,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke { ...f.options, optimization: "dev" }, { ...f.options, optimization: "speed" }, { ...f.options, detachedExit: true }, + { ...f.options, targetCpu: "x86-64-v3" }, + { ...f.options, targetCpu: "x86-64-v4" }, { ...f.options, profile: "profile-generate:/tmp/%p.profraw" }, { ...f.options, profile: "profile-use:digest" }, { ...f.options, checker: "tsc-ts-executable-identity" }, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 7b7febc06a..343b034308 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -18,6 +18,7 @@ import { compilerReleaseVersion } from "../library/sidecar.js"; import { nativeArtifactDependenciesStillMatch } from "../backend/native/artifact-stamps.js"; import { type NativeArtifactDependency } from "../backend/native/contracts.js"; import { optimizationKeyParts } from "../backend/optimization.js"; +import { type TargetCpu, targetCpuKeyParts } from "../backend/target-cpu.js"; import { privateSiblingPath } from "../backend/build-cache.js"; import type { CompilerImplementationDependency } from "../library/compiler-self-identity.js"; import { @@ -138,6 +139,8 @@ export interface EarlyExecutableCacheOptions { windowsSubsystem?: "gui"; /** Omitted unless --detached-exit is set. */ detachedExit?: true; + /** Omitted for the x86-64 baseline (`--target-cpu`). */ + targetCpu?: TargetCpu; /** Omitted without --profile-generate/--profile-use; otherwise the * pattern, or the digest of the profile's bytes. */ profile?: string; @@ -279,6 +282,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.strip ? ["strip"] : []), ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), + ...targetCpuKeyParts(options.targetCpu), ...(options.profile !== undefined ? [options.profile] : []), ...(options.checker !== undefined ? [`checker:${options.checker}`] : []), ...(options.youngNursery ? ["young-nursery"] : []), @@ -321,6 +325,7 @@ function routeKey(options: Omit] [--baseline=] [--workloads=a,b] [--runs=15] [--warmup=2] [--timeout=120] [--layouts=1] [--json=] [--baseline-optimization=release] [--candidate-optimization=release] + [--baseline-target-cpu=] [--candidate-target-cpu=] [--no-lock] [--no-node] [--keep] Each checkout must have a built CLI (packages/cli/dist/bootstrap.js) and native @@ -69,7 +72,10 @@ Workload names are the file names in benchmarks/runtime/workloads/*.json; for ex --baseline-optimization/--candidate-optimization pick each contender's --optimization posture (default release). The same checkout may be both contenders, e.g. --baseline=. --candidate-optimization=speed compares speed with -release. cold_build_ms records each contender's first, uncached build.`); +release. --baseline-target-cpu/--candidate-target-cpu pass --target-cpu to each +contender's builds, e.g. --baseline=. --candidate-target-cpu=x86-64-v3 compares +x86-64-v3 code with the baseline. cold_build_ms records each contender's first, +uncached build.`); process.exit(0); } const runs = Number(values.runs); @@ -428,7 +434,7 @@ function revision(root) { }); return r.stdout.trim() + (dirty.stdout.trim() ? "+dirty" : ""); } -function build(label, root, workload, layout, optimization) { +function build(label, root, workload, layout, optimization, targetCpu) { const cli = cliFor(root); const out = join(work, label, layout === 0 ? workload.name : `${workload.name}.layout${layout}`); mkdirSync(dirname(out), { recursive: true }); @@ -444,7 +450,15 @@ function build(label, root, workload, layout, optimization) { const entry = join(suiteRoot, workload.entry); const result = run( process.execPath, - [cli, "build", entry, `--optimization=${optimization}`, "-o", out], + [ + cli, + "build", + entry, + `--optimization=${optimization}`, + ...(targetCpu === undefined ? [] : [`--target-cpu=${targetCpu}`]), + "-o", + out, + ], { env, timeout: 900_000, @@ -467,6 +481,7 @@ const contenders = [ label: "baseline", root: resolve(values.baseline), optimization: values["baseline-optimization"], + targetCpu: values["baseline-target-cpu"], }, ] : []), @@ -474,6 +489,7 @@ const contenders = [ label: "candidate", root: resolve(values.candidate), optimization: values["candidate-optimization"], + targetCpu: values["candidate-target-cpu"], }, ]; const report = { @@ -580,7 +596,7 @@ try { entry.results[c.label] = result; result.binaries = []; for (let layout = 0; layout < layouts && result.status === undefined; layout++) { - const built = build(c.label, c.root, w, layout, c.optimization); + const built = build(c.label, c.root, w, layout, c.optimization, c.targetCpu); result.build_ms += Math.round(built.buildMs); if (layout === 0) result.cold_build_ms = Math.round(built.buildMs); if (!built.ok) { From 7cdfe16c9b3849f3bd75efcb1e352ecae0561eac Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 14:24:38 -0500 Subject: [PATCH 13/44] Cap the Linux test step's memory and spread memory-heavy side files The Linux test shards run their suites in a transient systemd scope with MemoryMax=13G and no swap, below the runner's 16 GB. A process tree that grows past the cap loses its largest process to the kernel, and the job log names the failing test, where the runner itself ran out of memory and left no log. Hosts without systemd run the suites directly. The side-queue balancer also takes a table of memory-heavy files, with their measured peak. They run whole in the cost-balanced task, and each goes to the shard with the least memory-heavy work so far, after the time-balanced files. decl-boundary.test.ts, which peaks at about 1.8 GB alone and had no time measurement, so vitest's file sharding put it in shard 11, is the first entry. It now runs in shard 1 (plain) and 6 (san). --- .github/workflows/ci.yml | 7 +++++ scripts/ci-test-plan.mjs | 36 ++++++++++++++++++++++--- tests/harness/ci-test-plan.test.ts | 43 +++++++++++++++++++++++++++--- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3bf0e770f..5ab1d93312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,9 +216,16 @@ jobs: # Start the corpus immediately beside a serial side queue. One shared # worker budget covers both processes; file and case shard axes stay # separate so every fixture still runs exactly once in each lane. + # The tests run in a transient scope capped below the runner's 16 GB: + # past 13 GB the kernel kills the scope's largest process, so the job + # log names the test instead of the runner itself running out of memory. - name: Tests (corpus and integration suites) run: | ulimit -c 0 + if [ -d /run/systemd/system ]; then + exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=13G -p MemorySwapMax=0 \ + --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" node scripts/ci-test.mjs + fi node scripts/ci-test.mjs tests_macos: diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 2b76b52d62..184f04d1c7 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -42,22 +42,38 @@ export const testCosts = JSON.parse( readFileSync(new URL("./ci-test-costs.json", import.meta.url), "utf8"), ); +// Side-queue files whose process tree, run alone, peaks far above a plain +// vitest run (about 1 GB): megabytes at the peak, measured with CI's settings. +// They run beside the corpus task's three workers, so no two of them share a +// shard while there are shards without one. +export const memoryHeavyFiles = { + "tests/harness/decl-boundary.test.ts": 1838, +}; + // The shard (1-based) of each measured side-queue file and isolated suite: // longest first to the shard with the least work so far, counting about a // second per file for vitest to import it. Ties go to the highest shard, so the // longest files start from the last shard while the cost-balanced corpus and // three.js cases (tests/harness/shard.ts) place their longest from the first. -// An isolated suite without a measurement weighs as much as the median -// measured file. Files without a measurement are left to vitest's --shard, -// which splits them by count. +// An isolated suite or memory-heavy file without a measurement weighs as much +// as the median measured file. Memory-heavy files are placed after the others, +// each on the shard with the least memory-heavy work so far and, among those, +// the least work. Files without a +// measurement are left to vitest's --shard, which splits them by count. export function sideOwners(flavor, count) { const costs = testCosts[flavor]; const known = Object.values(costs).sort((a, b) => a - b); const median = known.length === 0 ? 0 : known[Math.floor(known.length / 2)]; const weight = (file) => (costs[file] ?? median) + 1000; - const files = [...new Set([...Object.keys(costs), ...isolatedFiles.map(([, file]) => file)])]; + const heavy = Object.keys(memoryHeavyFiles).sort( + (a, b) => memoryHeavyFiles[b] - memoryHeavyFiles[a] || (a < b ? -1 : a > b ? 1 : 0), + ); + const files = [ + ...new Set([...Object.keys(costs), ...isolatedFiles.map(([, file]) => file)]), + ].filter((file) => !(file in memoryHeavyFiles)); files.sort((a, b) => weight(b) - weight(a) || (a < b ? -1 : a > b ? 1 : 0)); const loads = new Array(count).fill(0); + const memory = new Array(count).fill(0); const owners = new Map(); for (const file of files) { let lightest = count - 1; @@ -66,6 +82,18 @@ export function sideOwners(flavor, count) { loads[lightest] += weight(file); owners.set(file, lightest + 1); } + for (const file of heavy) { + let lightest = count - 1; + for (let shard = count - 2; shard >= 0; shard--) + if ( + memory[shard] < memory[lightest] || + (memory[shard] === memory[lightest] && loads[shard] < loads[lightest]) + ) + lightest = shard; + memory[lightest] += memoryHeavyFiles[file]; + loads[lightest] += weight(file); + owners.set(file, lightest + 1); + } return owners; } diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index 34d61346f8..9ff730ddff 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -11,6 +11,8 @@ import { caseShardedSideFiles, isolatedFiles, leakSuppressions, + memoryHeavyFiles, + sideOwners, testCosts, } from "../../scripts/ci-test-plan.mjs"; import { runCiTestPlan } from "../../scripts/ci-test.mjs"; @@ -40,20 +42,27 @@ test.for([1, 3, 5, 10, 20])( file, ).toHaveLength(everyShard ? count : 1); } - // Each measured file runs on exactly one shard, outside vitest's file shard. + // Each measured or memory-heavy file runs on exactly one shard, outside + // vitest's file shard. const isolated = new Set(isolatedFiles.map(([, file]) => file)); const balanced = plans.flatMap((plan) => plan.side .filter((task) => task.name === "cost-balanced tests") .flatMap((task) => task.args.slice(2)), ); - const measured = Object.keys(testCosts[flavor]).filter((file) => !isolated.has(file)); + const measured = [ + ...new Set([...Object.keys(testCosts[flavor]), ...Object.keys(memoryHeavyFiles)]), + ].filter((file) => !isolated.has(file)); expect(measured.length).toBeGreaterThan(0); expect(balanced.toSorted()).toEqual(measured.toSorted()); for (const plan of plans) { expect(plan.corpus.args).toEqual(["test", ...corpusFiles]); expect(plan.side[0]!.args).toContain(`--shard=${plan.corpus.env.SCRIPTC_TEST_SHARD}`); - for (const file of [...fileExclusions, ...Object.keys(testCosts[flavor])]) + for (const file of [ + ...fileExclusions, + ...Object.keys(testCosts[flavor]), + ...Object.keys(memoryHeavyFiles), + ]) expect(plan.side[0]!.args).toContain(`--exclude=${file}`); const cache = plan.side.find((task) => task.name === "native-cache correctness"); expect(Boolean(cache)).toBe(flavor === "plain"); @@ -103,6 +112,26 @@ test("the side queue balances measured files across the twenty Linux shards", () } }); +test("memory-heavy side files run whole in the cost-balanced task, on shards of their own", () => { + for (const flavor of ["plain", "san"] as const) { + const owners = sideOwners(flavor, 20); + const heavy = Object.keys(memoryHeavyFiles); + const shards = heavy.map((file) => owners.get(file)); + expect(shards.every((shard) => shard !== undefined)).toBe(true); + expect(new Set(shards).size).toBe(Math.min(heavy.length, 20)); + for (const file of heavy) { + const plan = ciTestPlan({ shard: `${owners.get(file)}/20`, flavor }); + const balanced = plan.side.find((task) => task.name === "cost-balanced tests")!; + expect(balanced.args).toContain(file); + // The file-sharded task leaves it out on every shard. + for (let shard = 1; shard <= 20; shard++) + expect(ciTestPlan({ shard: `${shard}/20`, flavor }).side[0]!.args).toContain( + `--exclude=${file}`, + ); + } + } +}); + test.for([1, 2, 4, 8])( "CI overlaps reserved lanes within a budget of %i workers", async (workers) => { @@ -265,7 +294,13 @@ test("pull requests run the full suite on Linux and the darwin suite on macOS", ); expect(coreStep).toBeGreaterThan(-1); expect(coreStep).toBeLessThan(linux.steps.length - 1); - expect(linux.steps.at(-1)!.run).toBe("ulimit -c 0\nnode scripts/ci-test.mjs\n"); + // Linux runs the suites in a memory-capped scope where systemd manages the + // runner, and directly elsewhere. + const linuxRun = linux.steps.at(-1)!.run as string; + expect(linuxRun.startsWith("ulimit -c 0\n")).toBe(true); + expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=13G -p MemorySwapMax=0"); + expect(linuxRun).toMatch(/-- env PATH="\$PATH" HOME="\$HOME" node scripts\/ci-test\.mjs\n/); + expect(linuxRun.endsWith("fi\nnode scripts/ci-test.mjs\n")).toBe(true); expect(macos.steps.at(-1)!.run).toBe("node scripts/ci-test.mjs"); for (const job of [linux, macos]) expect(job.strategy.matrix.flavor).toEqual(["plain", "san"]); }); From 4b6a6e6bc484f0cad83712064dd552a426f178a5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:34:59 -0500 Subject: [PATCH 14/44] Convert non-null asserted operands that hold undefined or null as Node does `x!` performs no runtime check, but the compiler extracted the asserted arm with a checked narrow, so a value that still held undefined or null threw "TypeError: undefined is not representable in the target union" wherever it was used. In Node the value reaches the operator, which converts it. When `x!` is an operand of an arithmetic, bitwise or relational operator, `+`, a compound assignment, a unary `-`, `+` or `~`, or an argument of a Math function, and `x` is a union of numbers, strings, booleans, null and undefined, the operand is now converted with ToNumber: undefined is NaN and null is 0. As an operand of an equality operator, of `!`, of a template span, of `+` with a string, or of a comparison with a string, the union itself reaches the operator, so `x! === 0` is false and `${x!}` is "undefined". Array and typed-array element reads keep their handling of a missing element, and `+` whose result TypeScript types as a string keeps the checked narrow where Node's result is NaN. `x == null` on a union with both null and undefined arms evaluated its operand twice, so an operand other than a plain read was refused. It is now evaluated once into a hidden local. Corpus 7392. --- .../frontend/lowering/expressions/binary.ts | 33 ++--- .../src/frontend/lowering/lower-exprs.ts | 132 +++++++++++++++++- .../corpus/7392-non-null-operands.ts.json | 6 + tests/corpus/7392-non-null-operands.ts | 115 +++++++++++++++ tests/diagnostics/unions.ts | 8 -- tests/harness/__snapshots__/unions.ts.txt | 17 +-- .../7392-non-null-operands.ts.json | 1 + 7 files changed, 275 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7392-non-null-operands.ts.json create mode 100644 tests/corpus/7392-non-null-operands.ts create mode 100644 tests/harness/corpus-costs/7392-non-null-operands.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index b26a3dd9ff..00a23caf5a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -243,37 +243,38 @@ function lowerLooseNullCompare( // No unit arms: never null-ish (defensive — the fold below). return { kind: "boolLit", value: negated, type: BOOL, loc }; } - const isTag = (tag: number): IrExpr => ({ + const isTag = (tag: number, value: IrExpr): IrExpr => ({ kind: "unionIsTag", unionId: ut.unionId, tag, negated, - value: other, + value, type: BOOL, loc, }); - if (tags.length === 1) return isTag(tags[0]!); + if (tags.length === 1) return isTag(tags[0]!, other); // Both null AND undefined arms: tag-in-set as a short-circuit pair - // (De Morgan for `!=`). The operand IrExpr rides both tests, so the - // backend emits it per test — restricted to re-emittable pure reads; - // anything effectful gets its own actionable fence (the generic - // SC1040 hint would wrongly suggest `== null` as the fix). + // (De Morgan for `!=`). The operand rides both tests, so an operand + // that is not a plain read (`m.get(k)! == null`) is evaluated once + // into a hidden local first. + let value = other; + let prefix: IrStmt[] = []; if (!isSafeToRepeat(other)) { - lowerer.unsupported( - "SC1090", - expr, - `'== null' / '!= null' on a '${lowerer.fmt(ut)}' value that isn't a plain read ` + - `(both unit arms need the operand twice — bind it to a const first)`, - ); + const tmp = lowerer.declareHiddenLocal("%nullish", ut); + value = varRef(tmp.id, ut, loc); + prefix = [{ kind: "varDecl", localId: tmp.id, init: other, loc }]; } - return { + const test: IrExpr = { kind: "logical", op: negated ? "&&" : "||", - left: isTag(tags[0]!), - right: isTag(tags[1]!), + left: isTag(tags[0]!, value), + right: isTag(tags[1]!, value), type: BOOL, loc, }; + return prefix.length === 0 + ? test + : { kind: "seqExpr", stmts: prefix, result: test, type: BOOL, loc }; } if (other.type.kind === "void") { return null; // no static tag to test — keep the fence diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c1c5ef8c8d..d62d2cfc3f 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -72,7 +72,7 @@ import { import { nsMemberIdentOf } from "./lower-namespaces.js"; import { bindingNeverReassigned } from "./binding-analysis.js"; import { implicitMonoFile } from "./generic-functions.js"; -import { lowerTaggedTemplate } from "./lower-calls.js"; +import { lowerPrimitiveUnionNumber, lowerTaggedTemplate } from "./lower-calls.js"; import { unitOnlyUnion } from "../type-mapper.js"; import { lowerYield } from "./lower-generators.js"; import { unionWideningTags } from "../../ir/analysis.js"; @@ -426,6 +426,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { return inner; const use = runtimeOptionalUseOf(expr); if (runtimeOptionalAssertionErases(lowerer, expr, inner, use)) return inner; + const operand = assertionOperand(lowerer, expr, inner, target ?? null, loc); + if (operand) return operand; if (target && target.kind !== "union" && !typeEquals(target, inner.type)) { const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc); if (helper) { @@ -933,6 +935,134 @@ export function assertedElementKeepsUndefined( ); } +/** `x!` as the operand of an operator that converts its operands: an + * arithmetic, bitwise, relational or equality operator, `+`, a compound + * assignment, a unary `-`, `+`, `~` or `!`, a template span, or an argument + * of a Math function. `!` performs no runtime check, + * so a value that still holds undefined or null reaches the operator, which + * converts it as JavaScript does: NaN or 0 in arithmetic, false in a + * comparison, "undefined" or "null" in a string. Returns the operand to + * use, or null where the assertion narrows as usual. A numeric use gets the + * value converted with ToNumber; other uses get the union itself. */ +function assertionOperand( + lowerer: Lowerer, + expr: ts.NonNullExpression, + inner: IrExpr, + target: IrType | null, + loc: SrcLoc, +): IrExpr | null { + if (inner.type.kind !== "union" || target === null || target.kind === "union") return null; + const arms = lowerer.unions.get(inner.type.unionId)?.arms; + if ( + !arms?.some(isUnitType) || + !arms.every( + (arm) => + arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || isUnitType(arm), + ) + ) + return null; + // Array and typed-array element reads keep their own handling of a + // missing element. + let operand: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if (ts.isElementAccessExpression(operand)) { + const kind = lowerer.mapTypeOf(lowerer.typeOf(operand.expression))?.kind; + if (kind === "array" || kind === "bytes") return null; + } + const use = convertingUseOf(lowerer, expr); + if (use === null) return null; + if (use === "value") return inner; + return lowerPrimitiveUnionNumber(lowerer, inner, expr, loc); +} + +/** How the converting use of `x!` (see assertionOperand) reads it: + * "number" where the use applies ToNumber, "value" where the consumer + * lowers the union itself (strings, equality, truthiness), null for other + * uses. */ +function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" | "value" | null { + let outer: ts.Expression = expr; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "value" : null; + if (ts.isPrefixUnaryExpression(parent)) { + if (parent.operator === ts.SyntaxKind.ExclamationToken) return "value"; + return parent.operator === ts.SyntaxKind.MinusToken || + parent.operator === ts.SyntaxKind.PlusToken || + parent.operator === ts.SyntaxKind.TildeToken + ? "number" + : null; + } + if (ts.isCallExpression(parent) && parent.arguments.nodes.includes(outer)) { + const callee = parent.expression; + return ts.isPropertyAccessExpression(callee) && + stdlibGlobalNameOf(lowerer, callee.expression) === "Math" + ? "number" + : null; + } + if (!ts.isBinaryExpression(parent)) return null; + const op = parent.operatorToken.kind; + if (EQUALITY_OPERATORS.has(op)) return "value"; + const other = parent.left === outer ? parent.right : parent.left; + const isString = (node: ts.Expression): boolean => + (lowerer.typeOf(node).flags & ts.TypeFlags.StringLike) !== 0; + // With a string on either side, `+` concatenates and a comparison + // compares strings. `undefined + 1` and `undefined + undefined` are NaN, + // which a string-typed result cannot hold, so `+` takes the union only + // with a string partner that is not itself asserted. + if (op === ts.SyntaxKind.PlusToken || op === ts.SyntaxKind.PlusEqualsToken) { + if (op === ts.SyntaxKind.PlusEqualsToken && parent.right !== outer) return null; + let partner = other; + while (ts.isParenthesizedExpression(partner)) partner = partner.expression; + if (isString(other)) return ts.isNonNullExpression(partner) ? null : "value"; + return isString(expr) ? null : "number"; + } + if (RELATIONAL_OPERATORS.has(op)) return isString(other) || isString(expr) ? "value" : "number"; + if (NUMERIC_OPERATORS.has(op)) return "number"; + return NUMERIC_COMPOUNDS.has(op) && parent.right === outer ? "number" : null; +} + +const EQUALITY_OPERATORS = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); + +const RELATIONAL_OPERATORS = new Set([ + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); + +const NUMERIC_OPERATORS = new Set([ + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ts.SyntaxKind.AsteriskAsteriskToken, + ts.SyntaxKind.AmpersandToken, + ts.SyntaxKind.BarToken, + ts.SyntaxKind.CaretToken, + ts.SyntaxKind.LessThanLessThanToken, + ts.SyntaxKind.GreaterThanGreaterThanToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, +]); + +const NUMERIC_COMPOUNDS = new Set([ + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, +]); + export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7392-non-null-operands.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7392-non-null-operands.ts.json new file mode 100644 index 0000000000..be7ffaa537 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7392-non-null-operands.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7392-non-null-operands.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7392-non-null-operands.ts b/tests/corpus/7392-non-null-operands.ts new file mode 100644 index 0000000000..40e3367afa --- /dev/null +++ b/tests/corpus/7392-non-null-operands.ts @@ -0,0 +1,115 @@ +// Non-null assertions on values that still hold undefined or null, used as +// operands of operators that convert them: arithmetic, comparisons, string +// conversion, Math functions, and loose equality with null. `!` +// performs no runtime check, so the operator sees undefined or null and +// converts it as JavaScript does. + +interface Settings { + limit?: number; + label?: string; + enabled?: boolean; + ratio: number | null; + note?: string | null; +} + +const empty: Settings = { ratio: null }; +const full: Settings = { limit: 4, label: "abc", enabled: true, ratio: 0.5, note: "n" }; +const counts: Record = { a: 1, b: 2 }; +const sizes = new Map([["small", 1]]); +const values = [3, 1, 4]; + +function show(v: unknown): string { + return Object.is(v, -0) ? "-0" : String(v); +} + +function line(label: string, parts: unknown[]): void { + console.log(label.padEnd(12), parts.map(show).join(" ")); +} + +for (const s of [empty, full]) { + const tag = s === empty ? "empty" : "full"; + line(`${tag} arith`, [ + s.limit! + 1, + s.limit! - 1, + s.limit! * 2, + s.limit! / 2, + s.limit! % 3, + s.limit! ** 2, + -s.limit!, + +s.limit!, + s.ratio! + 1, + s.ratio! * 4, + -s.ratio!, + s.limit! - s.ratio!, + ]); + line(`${tag} bits`, [s.limit! | 0, s.limit! & 6, s.limit! << 1, s.limit! >>> 0, ~s.limit!, s.ratio! ^ 1]); + line(`${tag} compare`, [ + s.limit! < 5, + s.limit! >= 0, + s.ratio! < 1, + s.ratio! > -1, + s.limit! === 4, + s.limit! !== 4, + s.ratio! == 0, + s.label! < "b", + s.label! >= "a", + s.label! === "abc", + s.enabled! === true, + s.limit! > s.ratio!, + ]); + line(`${tag} strings`, [ + `${s.limit!}`, + `${s.ratio!}`, + `${s.enabled!}`, + `[${s.label!}]`, + `${s.note!}`, + "n=" + s.limit!, + s.limit! + "!", + s.label! + "?", + "<" + s.note!, + ]); + line(`${tag} convert`, [ + Math.round(s.limit!), + Math.max(s.limit!, 1), + Math.min(s.ratio!, 1), + !s.enabled!, + !s.ratio!, + ]); +} + +line("record", [counts["z"]! + 1, counts["a"]! + 1, counts["z"]! > 0, `${counts["z"]!}`]); +line("map", [sizes.get("large")! * 2, sizes.get("small")! * 2, `${sizes.get("large")!}`]); +line("find", [values.find((v) => v > 9)! + 1, values.find((v) => v > 3)! + 1]); + +let total = 0; +let text = ""; +let mask = 7; +for (const key of ["a", "b", "c", "a"]) { + total += counts[key]!; + text += counts[key]!; + text += ","; + mask &= counts[key]!; +} +line("compound", [total, text, mask]); + +const tally: Record = {}; +for (const word of ["x", "y", "x"]) tally[word] = tally[word]! + 1; +line("tally", [tally["x"], tally["y"]]); + +type Cell = number | string | null | undefined; +for (const cell of [3, "4", null, undefined] as Cell[]) { + const m = new Map([["k", cell]]); + line("cell", [`${m.get("k")!}`, m.get("k")! === 3, String(m.get("k")!)]); +} + +// Loose equality with null tests both unit arms of a value read once. +const optional = new Map([["one", 1], ["none", null]]); +let reads = 0; +function lookup(key: string): string | null | undefined { + reads++; + return key === "a" ? "x" : key === "b" ? null : undefined; +} +for (const key of ["one", "none", "missing"]) { + line(`loose ${key}`, [optional.get(key)! == null, optional.get(key)! != undefined, optional.get(key)! == 1]); +} +line("loose call", [lookup("a") == null, lookup("b") == null, lookup("c") != null, reads]); diff --git a/tests/diagnostics/unions.ts b/tests/diagnostics/unions.ts index ccf62f40e9..ddacb8015e 100644 --- a/tests/diagnostics/unions.ts +++ b/tests/diagnostics/unions.ts @@ -60,14 +60,6 @@ const bareN = null; // honest text (JS prints "[object Object]") and stays fenced. const templated = `${mkAB()}`; -// `x == null` lowers as the null-or-undefined tag test — but a union with -// BOTH unit arms needs the operand twice, so an effectful operand (a call) -// gets its own fence pointing at a const binding. -function mkNullish(): string | null | undefined { - return null; -} -const looseCall = mkNullish() == null; - // A DECLARED `T | string` param infers nothing for T (no union // unification); the body's use of T then fails with the instantiation. function tagOr(x: T | string): T { diff --git a/tests/harness/__snapshots__/unions.ts.txt b/tests/harness/__snapshots__/unions.ts.txt index bba468c40e..8e801b0fec 100644 --- a/tests/harness/__snapshots__/unions.ts.txt +++ b/tests/harness/__snapshots__/unions.ts.txt @@ -14,16 +14,9 @@ unions.ts:61:22 - error SC1090: string conversions of unions with object arms (n | ^~~~~~ 62 | -unions.ts:69:19 - error SC1090: '== null' / '!= null' on a 'null | string | undefined' value that isn't a plain read (both unit arms need the operand twice — bind it to a const first) is not supported yet +unions.ts:92:10 - error SC1090: reading 'valueOf' on a union-typed value (every arm must be an object/record with a same-typed field 'valueOf'; narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - 68 | } - 69 | const looseCall = mkNullish() == null; - | ^~~~~~~~~~~~~~~~~~~ - 70 | - -unions.ts:100:10 - error SC1090: reading 'valueOf' on a union-typed value (every arm must be an object/record with a same-typed field 'valueOf'; narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - - 99 | function chainSubUnion(x: number | string | undefined): number { - 100 | return x?.valueOf === undefined ? 0 : 1; - | ^~~~~~~~~~ - 101 | } \ No newline at end of file + 91 | function chainSubUnion(x: number | string | undefined): number { + 92 | return x?.valueOf === undefined ? 0 : 1; + | ^~~~~~~~~~ + 93 | } \ No newline at end of file diff --git a/tests/harness/corpus-costs/7392-non-null-operands.ts.json b/tests/harness/corpus-costs/7392-non-null-operands.ts.json new file mode 100644 index 0000000000..a591cc420b --- /dev/null +++ b/tests/harness/corpus-costs/7392-non-null-operands.ts.json @@ -0,0 +1 @@ +{ "plain": 4200, "san": 4800 } From b80666adb4376215dc507991e942da0a3b0a339a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:40:07 -0500 Subject: [PATCH 15/44] Keep null and undefined through non-null assertions into nullable destinations JavaScript erases `x!`. scriptc lowered it on a union as a checked narrowing to the asserted type, so a value that still held null or undefined threw a TypeError, even where it went straight into a return value or argument whose type holds it: `return cache.get(key)!` from a function that returns `T | null`, with null stored in the map, threw where Node returns null, and `String(names.get(key)!)` threw where Node returns "null". A non-null assertion that is a returned value, an arrow function's body, or a call or new argument now keeps the value when the destination type is any or unknown, or holds the null or undefined the assertion removes; the destination's coercion re-tags it. Bindings and assignments keep the checked narrowing, because the checker narrows them to the asserted type for later reads. The native compiler hit this in the bytes-loop versioning analysis, which caches null results that way (`if (shapes.has(e)) return shapes.get(e)!;`): it refused programs whose counted loop reads or writes several typed arrays at one masked index with SC3004, including tsc-ts's release build, which the Node-built compiler compiled. typed-array-masked-index-stores.ts is such a program; the LLVM emitter self-hosting test now emits and runs it with the natively built emitter. --- .../src/frontend/lowering/lower-exprs.ts | 38 +++++++++++++++ ...null-assertion-nullish-destination.ts.json | 6 +++ .../typed-array-masked-index-stores.ts.json | 6 +++ ...-non-null-assertion-nullish-destination.ts | 47 +++++++++++++++++++ .../corpus/typed-array-masked-index-stores.ts | 40 ++++++++++++++++ ...null-assertion-nullish-destination.ts.json | 1 + .../typed-array-masked-index-stores.ts.json | 1 + .../harness/self-hosting-llvm-emitter.test.ts | 15 ++++-- 8 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json create mode 100644 tests/corpus/7189-non-null-assertion-nullish-destination.ts create mode 100644 tests/corpus/typed-array-masked-index-stores.ts create mode 100644 tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json create mode 100644 tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c1c5ef8c8d..4f49428bcf 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -426,6 +426,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { return inner; const use = runtimeOptionalUseOf(expr); if (runtimeOptionalAssertionErases(lowerer, expr, inner, use)) return inner; + // A destination that holds null or undefined keeps the value, as `!` + // does in JavaScript (`return cache.get(key)!` from a function that + // returns `T | null`); the destination's own coercion re-tags it. + if (assertionDestinationHoldsNullish(lowerer, expr, inner.type)) return inner; if (target && target.kind !== "union" && !typeEquals(target, inner.type)) { const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc); if (helper) { @@ -933,6 +937,40 @@ export function assertedElementKeepsUndefined( ); } +/** Whether `node!` is a returned value, an arrow function's body or a call + * argument whose destination type (the return or parameter type) is `any` + * or `unknown` (as for `String(x!)` and `Number(x!)`) or holds null or + * undefined that the union `inner` holds and the assertion would remove. + * Bindings and assignments are left out: the checker narrows them to the + * asserted type for later reads. */ +function assertionDestinationHoldsNullish( + lowerer: Lowerer, + node: ts.NonNullExpression, + inner: IrType & { kind: "union" }, +): boolean { + const holdsNull = lowerer.armTag(inner.unionId, NULL_T) >= 0; + const holdsUndefined = lowerer.armTag(inner.unionId, UNDEFINED_T) >= 0; + if (!holdsNull && !holdsUndefined) return false; + let outer: ts.Expression = node; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + const destination = + (ts.isReturnStatement(parent) && parent.expression === outer) || + (ts.isArrowFunction(parent) && parent.body === outer) || + ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && + parent.arguments?.nodes.includes(outer) === true); + if (!destination) return false; + const contextual = lowerer.checker.getContextualType(outer); + if (!contextual) return false; + const nullish = + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + (holdsNull ? ts.TypeFlags.Null : 0) | + (holdsUndefined ? ts.TypeFlags.Undefined : 0); + if ((contextual.flags & ts.TypeFlags.Union) === 0) return (contextual.flags & nullish) !== 0; + return constituentTypes(contextual).some((type) => (type.flags & nullish) !== 0); +} + export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json new file mode 100644 index 0000000000..dc0d277ee2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7189-non-null-assertion-nullish-destination.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7189-non-null-assertion-nullish-destination.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json new file mode 100644 index 0000000000..dde5a96fac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-masked-index-stores.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/typed-array-masked-index-stores.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7189-non-null-assertion-nullish-destination.ts b/tests/corpus/7189-non-null-assertion-nullish-destination.ts new file mode 100644 index 0000000000..0386e07130 --- /dev/null +++ b/tests/corpus/7189-non-null-assertion-nullish-destination.ts @@ -0,0 +1,47 @@ +// `!` has no runtime effect: a value asserted non-null that still holds +// null or undefined reaches a return value, an arrow function's result or +// an argument whose declared type holds it (or is any or unknown), unchanged. +interface Shape { + size: number; +} + +const shapes = new Map(); +function shapeOf(key: string): Shape | null { + if (shapes.has(key)) return shapes.get(key)!; + const result = key.length > 3 ? { size: key.length } : null; + shapes.set(key, result); + return result; +} +console.log(shapeOf("ab"), shapeOf("abcd")); +console.log(shapeOf("ab"), shapeOf("abcd"), shapes.size); + +const counts = new Map([["a", 1]]); +function countOf(key: string): number | undefined { + return counts.get(key)!; +} +console.log(countOf("a"), countOf("b")); + +function describe(shape: Shape | null): string { + return shape === null ? "none" : `size ${shape.size}`; +} +console.log(describe(shapes.get("ab")!), describe(shapes.get("abcd")!)); + +class Slot { + readonly label: string; + constructor(shape: Shape | null) { + this.label = shape === null ? "empty" : `slot ${shape.size}`; + } +} +console.log(new Slot(shapes.get("ab")!).label, new Slot(shapes.get("abcd")!).label); + +const lookup = (key: string): Shape | null => shapes.get(key)!; +console.log(lookup("ab"), lookup("abcd")?.size); + +const names = new Map([["a", null], ["b", "bee"]]); +const amounts = new Map([["a", undefined], ["b", 2]]); +function kindOf(value: unknown): string { + return value === null ? "null" : typeof value; +} +console.log(String(names.get("a")!), String(names.get("b")!)); +console.log(Number(amounts.get("a")!), Number(amounts.get("b")!)); +console.log(kindOf(names.get("a")!), kindOf(amounts.get("a")!), kindOf(names.get("b")!)); diff --git a/tests/corpus/typed-array-masked-index-stores.ts b/tests/corpus/typed-array-masked-index-stores.ts new file mode 100644 index 0000000000..88a25b4c50 --- /dev/null +++ b/tests/corpus/typed-array-masked-index-stores.ts @@ -0,0 +1,40 @@ +// Several typed array accesses in one counted loop share an index that is +// not an affine function of the counter (a masked or remaindered counter). +// The loop versioning analysis records that such an index has no affine +// shape and reads the recorded result again at each later access. +const length = 64; +const bytes = new Uint8Array(length); +const words = new Int32Array(16); +const totals = new Float64Array(16); + +for (let i = 0; i < bytes.length; i++) { + const slot = i & 15; + words[slot] = words[slot]! + i; + totals[slot] = totals[slot]! + i * 0.5; + bytes[i] = slot; +} +console.log(Array.from(words).join(",")); +console.log(Array.from(totals).join(",")); +console.log(bytes.reduce((sum, value) => sum + value, 0)); + +const ring = new Uint16Array(8); +const seen = new Uint8Array(8); +for (let i = 0; i < length; i++) { + const at = i % 8; + ring[at] = ring[at]! * 3 + i; + seen[at] = seen[at]! + 1; +} +console.log(Array.from(ring).join(","), Array.from(seen).join(",")); + +function scatter(source: Uint8Array, mask: number): Uint32Array { + const out = new Uint32Array(mask + 1); + const counts = new Uint32Array(mask + 1); + for (let i = 0; i < source.length; i++) { + const bucket = (source[i]! + i) & mask; + out[bucket] = out[bucket]! + source[i]!; + counts[bucket] = counts[bucket]! + 1; + } + for (let i = 0; i < out.length; i++) out[i] = out[i]! * 10 + counts[i]!; + return out; +} +console.log(Array.from(scatter(bytes, 7)).join(",")); diff --git a/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json b/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json new file mode 100644 index 0000000000..7473d65aa0 --- /dev/null +++ b/tests/harness/corpus-costs/7189-non-null-assertion-nullish-destination.ts.json @@ -0,0 +1 @@ +{ "plain": 2653, "san": 3208 } diff --git a/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json b/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json new file mode 100644 index 0000000000..3e45ee5660 --- /dev/null +++ b/tests/harness/corpus-costs/typed-array-masked-index-stores.ts.json @@ -0,0 +1 @@ +{ "plain": 2791, "san": 3186 } diff --git a/tests/harness/self-hosting-llvm-emitter.test.ts b/tests/harness/self-hosting-llvm-emitter.test.ts index cb9cd69c4c..a81bac16ca 100644 --- a/tests/harness/self-hosting-llvm-emitter.test.ts +++ b/tests/harness/self-hosting-llvm-emitter.test.ts @@ -92,6 +92,7 @@ const programs = [ "local-union-storage.ts", "class-callback-storage.ts", "byte-record-windows.ts", + "typed-array-masked-index-stores.ts", "constant-callback-dispatch.ts", "constant-number-remainders.ts", "numeric-call-specialization.ts", @@ -99,6 +100,10 @@ const programs = [ "native-worker-roots/main.ts", "native-worker-shared-views.ts", ]; +// Debug info turns the emitter's loop optimizations off. These programs are +// emitted without it, so the compiled emitter runs its loop analyses on them +// (typed-array loop versioning, which caches null index shapes). +const withoutDebugInfo = new Set(["typed-array-masked-index-stores.ts"]); for (const backend of ["llvm"] as const) { test( @@ -227,10 +232,12 @@ for (const backend of ["llvm"] as const) { if (!lowered.ok) throw new Error(`${source}: ${lowered.diagnostics.map((d) => d.message).join("\n")}`); const mod = deserializeModule(readFileSync(irPath, "utf8")); - const request: LlvmEmitterRequest = llvmEmitterRequest({ - debug: true, - sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], - }); + const request: LlvmEmitterRequest = withoutDebugInfo.has(source) + ? llvmEmitterRequest() + : llvmEmitterRequest({ + debug: true, + sources: [{ file: sourcePath, text: readFileSync(sourcePath, "utf8") }], + }); const text = await emit(mod, source, request); const llvmPath = join(dir, "program.ll"); const outPath = executable("program"); From 0dfea3c6420a8f3558fc55e34ce4a8e00844e5b6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 15:15:33 -0700 Subject: [PATCH 16/44] Reject class capsules of other classes without materializing them A dynMatch helper for a class target tested the capsule's class key and, when it did not match, materialized the capsule's view and matched that. A materialized view is a plain object, which never matches a class target, so the answer was always false: the helper now answers false directly. Checked casts from unknown to unions of classes no longer build a view per non-matching arm (1,000 casts x 2,000 rounds: 255 ms -> 67 ms). Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 5 +- .../7991-unknown-class-union-casts.ts.json | 6 ++ .../corpus/7991-unknown-class-union-casts.ts | 70 +++++++++++++++++++ .../7991-unknown-class-union-casts.ts.json | 1 + 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7991-unknown-class-union-casts.ts.json create mode 100644 tests/corpus/7991-unknown-class-union-casts.ts create mode 100644 tests/harness/corpus-costs/7991-unknown-class-union-casts.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 1367987960..0e220d3499 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -979,7 +979,10 @@ export class LlDyn { const lPlain = B.newLabel("dm.tr.plain"); B.condBr(capsule, lCapsule, lPlain); B.startBlock(lCapsule); - if (preserveRefs) { + // A class target matches only capsules of that class (tested above): + // a materialized view is a plain object, which never carries a class + // brand, so materializing one cannot change the answer. + if (preserveRefs || (t.kind === "object" && t.className !== "%Error")) { B.terminate(`ret i1 false`); } else { const materialized = B.tmp(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7991-unknown-class-union-casts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7991-unknown-class-union-casts.ts.json new file mode 100644 index 0000000000..68185e8374 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7991-unknown-class-union-casts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7991-unknown-class-union-casts.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7991-unknown-class-union-casts.ts b/tests/corpus/7991-unknown-class-union-casts.ts new file mode 100644 index 0000000000..6560abbc2b --- /dev/null +++ b/tests/corpus/7991-unknown-class-union-casts.ts @@ -0,0 +1,70 @@ +// Checked casts from `unknown` to unions of classes: each instance selects +// its own arm, whatever the order of the arms, including subclasses, arrays +// of instances, instances inside records, and values of no listed class. +class Circle { + readonly r: number; + constructor(r: number) { + this.r = r; + } + area(): number { + return Math.round(Math.PI * this.r * this.r); + } +} +class Square { + readonly side: number; + constructor(side: number) { + this.side = side; + } + area(): number { + return this.side * this.side; + } +} +class Rect { + readonly w: number; + readonly h: number; + constructor(w: number, h: number) { + this.w = w; + this.h = h; + } + area(): number { + return this.w * this.h; + } +} +class Tile extends Square { + label(): string { + return `tile ${this.side}`; + } +} + +type Shape = Circle | Square | Rect; + +function describe(value: unknown): string { + try { + const shape = value as Shape; + const kind = shape instanceof Circle ? "circle" : shape instanceof Rect ? "rect" : "square"; + return `${kind} ${shape.area()}`; + } catch (error) { + return `rejected: ${(error as Error).name}`; + } +} + +const values: unknown[] = [new Circle(2), new Square(3), new Rect(2, 5), new Tile(4), { side: 3 }, 7, null]; +for (const value of values) console.log(describe(value)); + +const reversed = values.slice(0, 4).reverse() as Shape[]; +console.log(reversed.map((s) => s.area()).join(",")); + +const boxed: unknown = { first: new Rect(1, 2), second: new Circle(1) }; +const pair = boxed as { first: Shape; second: Shape }; +console.log(pair.first.area(), pair.second.area(), pair.first instanceof Rect); + +const tiles: unknown = [new Tile(1), new Tile(2)]; +const asSquares = tiles as Square[]; +console.log(asSquares.map((t) => t.area()).join(" "), asSquares[0] instanceof Tile); + +let total = 0; +for (let i = 0; i < 1000; i++) { + const v: unknown = i % 3 === 0 ? new Circle(1) : i % 3 === 1 ? new Square(2) : new Rect(1, 3); + total += (v as Shape).area(); +} +console.log(total); diff --git a/tests/harness/corpus-costs/7991-unknown-class-union-casts.ts.json b/tests/harness/corpus-costs/7991-unknown-class-union-casts.ts.json new file mode 100644 index 0000000000..263ca9783b --- /dev/null +++ b/tests/harness/corpus-costs/7991-unknown-class-union-casts.ts.json @@ -0,0 +1 @@ +{ "plain": 4400 } From 6b1a56a49ae607a87f3111bd3a1e327924459d33 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:26:12 -0500 Subject: [PATCH 17/44] Check target CPU levels in the native CLI and honor them for assembly and object builds --- packages/cli/test/target-cpu.test.ts | 34 ++++++++++++++ packages/compiler/src/backend/target-cpu.ts | 3 +- packages/compiler/src/index.ts | 7 ++- packages/compiler/src/native/prepare.test.ts | 27 ++++++++++- packages/compiler/src/native/prepare.ts | 15 +++++- .../compiler/test/target-cpu-emit.test.ts | 46 +++++++++++++++++++ 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/target-cpu-emit.test.ts diff --git a/packages/cli/test/target-cpu.test.ts b/packages/cli/test/target-cpu.test.ts index c5852fb408..ca52435643 100644 --- a/packages/cli/test/target-cpu.test.ts +++ b/packages/cli/test/target-cpu.test.ts @@ -64,3 +64,37 @@ test.runIf(process.platform === "linux" && process.arch === "x64")( }, 240_000, ); + +test("--target-cpu levels are refused for arm64 and WASI targets", async () => { + const dir = await mkdtemp(join("/tmp", "scriptc-target-cpu-refusal-")); + const entry = join(dir, "main.ts"); + try { + await writeFile(entry, program); + for (const target of ["aarch64-unknown-linux-gnu", "wasm32-wasi"]) { + for (const kind of ["llvm", "asm", "obj", "exe"]) { + const build = execFileAsync( + process.execPath, + [ + "--import", + tsxLoader, + cliEntry, + "build", + entry, + `--emit=${kind}`, + "-o", + join(dir, `out-${kind}`), + "--target-cpu=x86-64-v3", + ], + { + env: { ...process.env, SCRIPTC_TARGET: target, SCRIPTC_CACHE_DIR: join(dir, "cache") }, + }, + ); + await expect(build, `${target} ${kind}`).rejects.toThrow( + /--target-cpu=x86-64-v3 requires an x86-64 target/, + ); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/compiler/src/backend/target-cpu.ts b/packages/compiler/src/backend/target-cpu.ts index 228652ab31..51db87bbb6 100644 --- a/packages/compiler/src/backend/target-cpu.ts +++ b/packages/compiler/src/backend/target-cpu.ts @@ -1,6 +1,5 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; -import { nativeCodegenTarget } from "./targets.js"; /** The x86-64 microarchitecture levels an executable may require * (`--target-cpu`). `baseline` is the x86-64 baseline every release build @@ -110,7 +109,7 @@ export function targetCpuField(cpu: TargetCpu | undefined): { targetCpu?: Target export function targetCpuTargetRefusal( cpu: TargetCpu | undefined, platform: string, - architecture: string = nativeCodegenTarget()?.architecture ?? process.arch, + architecture: string, ): string | null { if (cpu === undefined || cpu === "baseline") return null; if (architecture !== "x64" || platform === "wasi") diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 2aa173b2c3..5024efbec4 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -807,6 +807,7 @@ async function prepareExecutableInput( lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true), ...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}), ...inlineRcOption(opts.optimization, opts.sanitize === true), + ...targetCpuField(opts.targetCpu), }); } catch (err) { if (!(err instanceof LlvmUnsupportedError)) throw err; @@ -1446,7 +1447,11 @@ async function compileTracked( } } } - const targetCpuRefusal = targetCpuTargetRefusal(opts.targetCpu, buildPlatform); + const targetCpuRefusal = targetCpuTargetRefusal( + opts.targetCpu, + buildPlatform, + nativeCodegenTarget()?.architecture ?? process.arch, + ); if (targetCpuRefusal !== null) { return { ok: false, diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 112484f4af..c5d3c162d9 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; -import { nativeCodegenTarget } from "../backend/targets.js"; +import { + LINUX_ARM64_GNU_TARGET, + nativeCodegenTarget, + WASM32_WASI_TARGET, +} from "../backend/targets.js"; import { runFrontend, type FrontendFactory } from "../frontend/pipeline.js"; import { loadProgram } from "../frontend/program-node.js"; import { ts7Executable } from "../frontend/ts7/rpc-api.js"; @@ -181,3 +185,24 @@ test("serialized IR travels as bounded parts through the frontend cache", () => expect(payload.llvm).toBe(first.llvm.join("")); expect(payload.ir).toEqual(first.ir); }); + +test("an x86-64 level is refused for targets that are not x86-64 before the frontend runs", () => { + const f = fixture(); + for (const target of [LINUX_ARM64_GNU_TARGET, WASM32_WASI_TARGET]) { + const result = prepareNativeExecutable( + f.entry, + { ...f.options, targetCpu: "x86-64-v3" }, + null, + { ...fixtureToolchain(), target }, + () => { + throw new Error("the frontend ran"); + }, + null, + ); + expect(result.ok).toBe(false); + if (result.ok) continue; + expect(result.diagnostics.map((d) => [d.code, d.message])).toEqual([ + ["SC3002", "--target-cpu=x86-64-v3 requires an x86-64 target"], + ]); + } +}); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 439b1ece65..12ca53da41 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -1,5 +1,5 @@ -import { targetCpuField } from "../backend/target-cpu.js"; -import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; +import { targetCpuField, targetCpuTargetRefusal } from "../backend/target-cpu.js"; +import { nativeCodegenDiag, type ScrDiagnostic } from "../diagnostics/diagnostic.js"; import type { CompilationTiming } from "../timing.js"; import { readFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; @@ -316,6 +316,17 @@ export function prepareNativeExecutable( timing: CompilationTiming = () => {}, ): NativeExecutableInput | CompileFailure { const outputKind = options.outputKind ?? "exe"; + const targetCpuRefusal = targetCpuTargetRefusal( + options.targetCpu, + toolchain.target.platform, + toolchain.target.architecture, + ); + if (targetCpuRefusal !== null) + return { + ok: false, + diagnostics: [nativeCodegenDiag("SC3002", targetCpuRefusal, entry)], + sourceTexts: new Map(), + }; const stem = basename(entry).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); const outputPaths = [ resolve(options.outPath), diff --git a/packages/compiler/test/target-cpu-emit.test.ts b/packages/compiler/test/target-cpu-emit.test.ts new file mode 100644 index 0000000000..5dee8243cb --- /dev/null +++ b/packages/compiler/test/target-cpu-emit.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const program = `const a = new Int32Array(4096); +for (let i = 0; i < a.length; i++) a[i] = (i * 2654435761) | 0; +let s = 0; +for (let r = 0; r < 64; r++) for (let i = 0; i < a.length; i++) s = (s + a[i]!) | 0; +console.log(s); +`; + +test.runIf(process.platform === "linux" && process.arch === "x64")( + "assembly, object and LLVM builds honor the target CPU", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-target-cpu-emit-")); + const entry = join(dir, "main.ts"); + const emit = async ( + outputKind: "llvm" | "asm" | "obj", + targetCpu?: "x86-64-v3", + ): Promise => { + const outPath = join(dir, `main-${outputKind}-${targetCpu ?? "baseline"}`); + const result = await compile(entry, { + outPath, + outDir: dir, + outputKind, + ...(targetCpu === undefined ? {} : { targetCpu }), + }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return readFile(outPath); + }; + try { + await writeFile(entry, program); + expect((await emit("llvm")).toString()).not.toContain('"target-cpu"'); + expect((await emit("llvm", "x86-64-v3")).toString()).toContain('"target-cpu"="x86-64-v3"'); + // The v3 build vectorizes the sums with AVX2's 256-bit registers. + expect((await emit("asm")).toString()).not.toContain("%ymm"); + expect((await emit("asm", "x86-64-v3")).toString()).toContain("%ymm"); + expect((await emit("obj", "x86-64-v3")).equals(await emit("obj"))).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 240_000, +); From 17721bae550e64982e60dd17aa1899ec993e3b8a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:40:07 -0700 Subject: [PATCH 18/44] Answer builtin prototype reflection in untyped code as Node does zod 4's z.record rejected every plain object natively ("expected record, received object"): its isPlainObject reads o.constructor.prototype, which was undefined, and asks whether that prototype has its own isPrototypeOf, which Object.prototype answered false. - The Object, Array and Error constructors held as values have their builtin prototype as a non-writable, non-enumerable, non-configurable own `prototype`; reading Function's constructor value's prototype throws SC2020 instead of answering undefined. - hasOwnProperty / Object.hasOwn on Object.prototype answer its builtin members, and on Array.prototype its constructor; Object.getOwnPropertyNames lists them in Node's order. Co-Authored-By: Claude Opus 5.5 --- ...8110-builtin-prototype-own-members.js.json | 6 ++ .../8111-plain-object-check-unknown.ts.json | 6 ++ .../npm/cases/zod4-record/main.ts.json | 6 ++ packages/runtime/src/scr_dyn.h | 4 ++ packages/runtime/src/scr_dyn_invoke.c | 3 + packages/runtime/src/scr_json.c | 41 ++++++++++++ .../8110-builtin-prototype-own-members.js | 67 +++++++++++++++++++ .../corpus/8111-plain-object-check-unknown.ts | 54 +++++++++++++++ tests/fixtures/npm/cases/zod4-record/main.ts | 37 ++++++++++ .../npm/cases/zod4-record/package.json | 1 + ...8110-builtin-prototype-own-members.js.json | 1 + .../8111-plain-object-check-unknown.ts.json | 1 + 12 files changed, 227 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8110-builtin-prototype-own-members.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8111-plain-object-check-unknown.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/zod4-record/main.ts.json create mode 100644 tests/corpus/8110-builtin-prototype-own-members.js create mode 100644 tests/corpus/8111-plain-object-check-unknown.ts create mode 100644 tests/fixtures/npm/cases/zod4-record/main.ts create mode 100644 tests/fixtures/npm/cases/zod4-record/package.json create mode 100644 tests/harness/corpus-costs/8110-builtin-prototype-own-members.js.json create mode 100644 tests/harness/corpus-costs/8111-plain-object-check-unknown.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8110-builtin-prototype-own-members.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8110-builtin-prototype-own-members.js.json new file mode 100644 index 0000000000..0338470827 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8110-builtin-prototype-own-members.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8110-builtin-prototype-own-members.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8111-plain-object-check-unknown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8111-plain-object-check-unknown.ts.json new file mode 100644 index 0000000000..2986186a2a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8111-plain-object-check-unknown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8111-plain-object-check-unknown.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/zod4-record/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/zod4-record/main.ts.json new file mode 100644 index 0000000000..8c7700ac8c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/zod4-record/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/zod4-record/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b1d0523561..a258b79e96 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -872,6 +872,10 @@ ScrDyn *scr_dyn_symbol_ref(ScrSym *value, void (*release)(ScrSym *), ScrStr *(*r ScrDyn *scr_dyn_new_str(ScrStr *s); ScrDyn *scr_dyn_new_arr(void); ScrDyn *scr_dyn_array_constructor(void); +/* Gives a builtin constructor value (Object, Array, Error) its builtin + * `prototype`: non-writable, non-enumerable, non-configurable. Takes the + * prototype. */ +void scr_dyn_fn_seed_prototype(ScrDyn *function, ScrDyn *prototype); /* The interned builtin constructor `name` (Object, Date, RegExp, Map, Set, * Promise, WeakMap, WeakSet) as a dynamic function value: +1. */ ScrDyn *scr_dyn_global_constructor(const ScrStr *name); diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index c8093f64c7..a2167fb62f 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -1984,6 +1984,9 @@ static ScrDyn *dyn_global_ctor(const char *name) { dyn_global_ctors[i] = scr_dyn_new_func(scr_closure_new((void *)dyn_global_ctor_names[i], 0), dyn_global_ctor_call, length, dyn_s6, dyn_global_ctor_names[i]); + /* `o.constructor.prototype` of a plain object is Object.prototype + * (zod's isPlainObject). The other prototypes are not modeled. */ + if (i == 0) scr_dyn_fn_seed_prototype(dyn_global_ctors[i], scr_dyn_object_prototype()); } return scr_dyn_retain(dyn_global_ctors[i]); } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 35e1d76744..9d9b6ee729 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1924,6 +1924,8 @@ ScrDyn *scr_dyn_error_constructor(const char *name) { scr_error_ctor_values[i] = scr_dyn_new_func(scr_closure_new((void *)scr_error_ctor_names[i], 0), scr_error_ctor_call, length, "native:constructor", scr_error_ctor_names[i]); + /* Error.prototype is modeled; the other error prototypes are not. */ + if (i == 0) scr_dyn_fn_seed_prototype(scr_error_ctor_values[i], scr_dyn_error_prototype()); } return scr_dyn_retain(scr_error_ctor_values[i]); } @@ -4302,6 +4304,7 @@ static ScrDyn *scr_array_ctor_call(ScrClosure *closure, ScrDyn *const *args, siz ScrDyn *scr_dyn_array_constructor(void) { if (!scr_array_ctor) { scr_array_ctor = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_array_ctor_call, 1, "native:Array", "Array"); + scr_dyn_fn_seed_prototype(scr_array_ctor, scr_dyn_array_prototype_base()); scr_atexit(scr_array_ctor_cleanup); } return scr_dyn_retain(scr_array_ctor); @@ -5509,6 +5512,16 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { return (ScrDyn *)scr_box_get_ref(owner->props); } +void scr_dyn_fn_seed_prototype(ScrDyn *function, ScrDyn *prototype) { + ScrDyn *table = scr_dyn_fn_properties(function); + scr_dyn_obj_set(table, "prototype", 9, prototype); + ScrDynEntry *entry = scr_dyn_find_entry(table, "prototype", 9); + entry->writable = false; + entry->enumerable = false; + entry->configurable = false; + scr_dyn_release(table); +} + ScrDyn *scr_dyn_class_inherit(ScrDyn *constructor, ScrDyn *base) { if (constructor->kind != SCR_DYN_FUNC || !constructor->v.fn.class_obj || (base->kind != SCR_DYN_FUNC && base->kind != SCR_DYN_UNDEF)) { static const char message[] = "Class inheritance requires native constructors"; @@ -12445,6 +12458,13 @@ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { ScrClosure *owner = scr_closure_identity(d->v.fn.clo); if (d->v.fn.class_obj && d->v.fn.class_obj->static_data && !owner->props) owner->props = scr_box_retain(d->v.fn.class_obj->static_data); + if (d->v.fn.thunk == scr_function_constructor_call && key_len == 9 && !memcmp(key, "prototype", 9)) { + /* A function's `constructor.prototype`: Function.prototype has no value + * here, and undefined would be a wrong answer. */ + static const char function_prototype[] = "Function.prototype through a constructor value is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, function_prototype, sizeof function_prototype - 1, "SC2020"); + return NULL; + } if (!d->v.fn.class_obj && key_len == 9 && !memcmp(key, "prototype", 9) && !owner->props) { if (owner->function_kind & 1) { static const char message[] = "Native generator function prototypes have no lowering"; @@ -13221,6 +13241,20 @@ ScrDyn *scr_dyn_get_own_property_names(const ScrDyn *value) { } if (value->kind == SCR_DYN_OBJ) { ScrDyn *keys = scr_dyn_obj_own_keys(value); + if (value == scr_builtin_object_prototype) { + /* Object.prototype's builtin members, in Node's order. */ + static const char *const names[] = { + "constructor", "__defineGetter__", "__defineSetter__", "hasOwnProperty", + "__lookupGetter__", "__lookupSetter__", "isPrototypeOf", "propertyIsEnumerable", + "toString", "valueOf", "__proto__", "toLocaleString" + }; + ScrDyn *all = scr_dyn_new_arr(); + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) + scr_dyn_arr_push(all, scr_dyn_objwalk_key(names[i], strlen(names[i]))); + for (size_t i = 0; i < keys->v.arr.len; i++) scr_dyn_arr_push(all, scr_dyn_retain(keys->v.arr.items[i])); + scr_dyn_release(keys); + return all; + } if (!scr_dyn_obj_get(value, "%error", 6)) return keys; /* The checked-dynamic tree's error marker is not a property. */ ScrDyn *names = scr_dyn_new_arr(); @@ -13246,6 +13280,7 @@ ScrDyn *scr_dyn_get_own_property_names(const ScrDyn *value) { } if (value->kind != SCR_DYN_BYTES) scr_dyn_arr_push(keys, scr_dyn_objwalk_key("length", 6)); + if (value == scr_builtin_array_prototype) scr_dyn_arr_push(keys, scr_dyn_objwalk_key("constructor", 11)); if (value->kind == SCR_DYN_ARR && value->v.arr.properties) { ScrDyn *properties = scr_dyn_obj_own_keys(value->v.arr.properties); for (size_t i = 0; i < properties->v.arr.len; i++) @@ -14332,10 +14367,16 @@ bool scr_dyn_has_own(const ScrDyn *v, const ScrStr *key) { if (v->kind == SCR_DYN_OBJ) { + /* Object.prototype's builtin members are its own properties (zod's + * isPlainObject asks whether the prototype has its own isPrototypeOf). */ + if (v == scr_builtin_object_prototype && scr_dyn_object_proto_name(key)) return true; return scr_dyn_obj_get(v, key->data, key->len) != NULL; } if (v->kind == SCR_DYN_ARR) { if (key->len == 6 && memcmp(key->data, "length", 6) == 0) return true; + /* Array.prototype's own constructor (its methods are table entries). */ + if (v == scr_builtin_array_prototype && key->len == 11 && memcmp(key->data, "constructor", 11) == 0) + return true; if (v->v.arr.properties && scr_dyn_obj_get(v->v.arr.properties, key->data, key->len)) return true; if (!scr_dyn_canonical_own_index(key, v->v.arr.len)) return false; size_t index = 0; diff --git a/tests/corpus/8110-builtin-prototype-own-members.js b/tests/corpus/8110-builtin-prototype-own-members.js new file mode 100644 index 0000000000..5df7458d6c --- /dev/null +++ b/tests/corpus/8110-builtin-prototype-own-members.js @@ -0,0 +1,67 @@ +"use strict"; +// Own members of the builtin prototypes and the constructor links that +// reach them, read in untyped code: Object.prototype has its own +// isPrototypeOf, hasOwnProperty and constructor; a plain object's +// constructor is Object, whose prototype is Object.prototype; an array's +// constructor is Array, whose prototype owns its methods and constructor. + +const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + +const objectKeys = ["constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toString", "toLocaleString", "valueOf", "__proto__", "__defineGetter__", "__lookupSetter__", "missing", "length"]; +for (const key of objectKeys) console.log("Object.prototype", key, has(Object.prototype, key), Object.hasOwn(Object.prototype, key)); +console.log(Object.getOwnPropertyNames(Object.prototype).join(",")); +console.log(Object.prototype.hasOwnProperty("isPrototypeOf"), Object.prototype.hasOwnProperty("nope")); + +const arrayKeys = ["constructor", "push", "map", "length", "at", "hasOwnProperty", "isPrototypeOf", "missing"]; +for (const key of arrayKeys) console.log("Array.prototype", key, has(Array.prototype, key)); +const arrayNames = Object.getOwnPropertyNames(Array.prototype); +console.log(arrayNames.length, arrayNames.includes("constructor"), arrayNames.includes("length"), arrayNames.includes("hasOwnProperty")); + +/** @param {any} value */ +function links(label, value) { + const ctor = value.constructor; + const prototype = ctor.prototype; + console.log(label, typeof ctor, ctor.name, typeof prototype, prototype === Object.getPrototypeOf(value), has(prototype, "constructor"), has(prototype, "isPrototypeOf")); +} +links("object", {}); +links("object-with-keys", { a: 1, b: [2] }); +links("parsed", JSON.parse('{"x":{"y":1}}')); +links("nested", JSON.parse('{"x":{"y":1}}').x); +links("array", []); +links("array-of", [1, 2, 3]); +const error = new Error("boom"); +console.log("error", typeof error.constructor, error.constructor.name, error.constructor.prototype === Error.prototype, has(error.constructor.prototype, "isPrototypeOf")); + +/** @param {any} value */ +const ctorOf = (value) => value.constructor; +console.log(ctorOf({}) === Object, ctorOf({}).prototype === Object.prototype, ctorOf([]) === Array, ctorOf([]).prototype === Array.prototype); +console.log(Object.prototype.constructor === Object, Object.prototype.constructor.prototype === Object.prototype); +console.log(Object.getPrototypeOf({}) === Object.prototype, has(Object.getPrototypeOf({ k: 1 }), "isPrototypeOf")); + +const O = ctorOf({}); +const descriptor = Object.getOwnPropertyDescriptor(O, "prototype"); +console.log(typeof O.prototype, O.prototype === Object.prototype, descriptor.writable, descriptor.enumerable, descriptor.configurable); +const A = ctorOf([]); +console.log(A.prototype === Array.prototype, Object.keys(A).length, A.name, A.length); +const E = ctorOf(error); +console.log(E.prototype === Error.prototype, E.prototype.name, E.name); + +// A check shaped like zod's isPlainObject: the constructor's prototype must +// be an object that has its own isPrototypeOf. +/** @param {unknown} o */ +function isPlainObject(o) { + if (typeof o !== "object" || o === null || Array.isArray(o)) return false; + const ctor = /** @type {any} */ (o).constructor; + if (ctor === undefined) return true; + if (typeof ctor !== "function") return true; + const prot = ctor.prototype; + if (typeof prot !== "object" || prot === null || Array.isArray(prot)) return false; + return Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf"); +} +class Point { + constructor() { + this.x = 1; + } +} +const samples = [{}, { a: 1 }, JSON.parse("{}"), Object.create(null), [], new Point(), new Error("e"), { constructor: 5 }, { constructor: undefined }, "s", 1, null]; +console.log(samples.map((sample) => isPlainObject(sample)).join(",")); diff --git a/tests/corpus/8111-plain-object-check-unknown.ts b/tests/corpus/8111-plain-object-check-unknown.ts new file mode 100644 index 0000000000..267f3d65ea --- /dev/null +++ b/tests/corpus/8111-plain-object-check-unknown.ts @@ -0,0 +1,54 @@ +// A plain-object check over unknown values, the way schema libraries +// accept records: the value's constructor must be absent, not a function, +// or one whose prototype has its own isPrototypeOf. Values that crossed +// JSON.parse and object literals pass; arrays, class instances and errors +// fail. + +function isObject(data: unknown): data is Record { + return typeof data === "object" && data !== null && !Array.isArray(data); +} + +function isPlainObject(o: unknown): o is Record { + if (!isObject(o)) return false; + const ctor: unknown = o.constructor; + if (ctor === undefined) return true; + if (typeof ctor !== "function") return true; + const prot: unknown = (ctor as { prototype?: unknown }).prototype; + if (!isObject(prot)) return false; + if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) return false; + return true; +} + +class Point { + x = 1; +} + +const inputs: [string, unknown][] = [ + ["literal", { a: 1 }], + ["empty", {}], + ["parsed", JSON.parse('{"k":[1,2],"m":{"n":true}}')], + ["parsed-nested", (JSON.parse('{"m":{"n":true}}') as { m: unknown }).m], + ["null-proto", Object.create(null)], + ["array", [1, 2]], + ["instance", new Point()], + ["error", new Error("x")], + ["ctor-number", { constructor: 3 }], + ["string", "s"], + ["null", null], +]; + +for (const [label, value] of inputs) { + console.log(label, isPlainObject(value)); +} + +const records: Record = {}; +for (const [label, value] of inputs) { + if (!isPlainObject(value)) continue; + records[label] = Object.keys(value).length; +} +console.log(JSON.stringify(records)); + +const proto: unknown = Object.getPrototypeOf({}); +console.log(proto === Object.prototype, Object.prototype.hasOwnProperty.call(proto, "isPrototypeOf")); +console.log(Object.hasOwn(Object.prototype, "hasOwnProperty"), Object.hasOwn(Object.prototype, "nope")); +console.log(Object.getOwnPropertyNames(Object.prototype).length); diff --git a/tests/fixtures/npm/cases/zod4-record/main.ts b/tests/fixtures/npm/cases/zod4-record/main.ts new file mode 100644 index 0000000000..b43eaebab3 --- /dev/null +++ b/tests/fixtures/npm/cases/zod4-record/main.ts @@ -0,0 +1,37 @@ +// zod 4's z.record and z.partialRecord accept only plain objects. Its check +// reads the value's constructor, that constructor's prototype, and whether +// the prototype has its own isPrototypeOf, so it needs Object.prototype's +// reflection to match Node. +import { z } from "zod"; + +const Scores = z.record(z.string(), z.number().int()); +const Flags = z.record(z.enum(["a", "b"]), z.boolean()); +const Partial = z.partialRecord(z.enum(["x", "y", "z"]), z.string()); +const Nested = z.object({ name: z.string(), meta: z.record(z.string(), z.record(z.string(), z.number())) }); + +const show = (label: string, schema: z.ZodType, value: unknown): void => { + const r = schema.safeParse(value); + if (r.success) { + console.log(label, "ok", JSON.stringify(r.data)); + } else { + console.log(label, "fail", r.error.issues.map((i) => `${i.path.join(".")}:${i.code}:${i.message}`).join(" | ")); + } +}; + +show("scores", Scores, { alice: 3, bob: 7 }); +show("scores-empty", Scores, {}); +show("scores-bad-value", Scores, { alice: 1.5 }); +show("scores-array", Scores, [1, 2]); +show("scores-null-proto", Scores, Object.assign(Object.create(null), { k: 1 })); +show("scores-parsed", Scores, JSON.parse('{"x":1,"y":2}')); +show("flags", Flags, { a: true, b: false }); +show("flags-missing", Flags, { a: true }); +show("flags-extra", Flags, { a: true, b: true, c: false }); +show("partial", Partial, { y: "why" }); +show("nested", Nested, { name: "n", meta: { one: { two: 2 } } }); +show("nested-bad", Nested, { name: "n", meta: { one: [] } }); + +class Point { + x = 1; +} +show("class-instance", Scores, new Point()); diff --git a/tests/fixtures/npm/cases/zod4-record/package.json b/tests/fixtures/npm/cases/zod4-record/package.json new file mode 100644 index 0000000000..82c3650847 --- /dev/null +++ b/tests/fixtures/npm/cases/zod4-record/package.json @@ -0,0 +1 @@ +{ "name": "zod4-record-case", "private": true, "type": "module" } diff --git a/tests/harness/corpus-costs/8110-builtin-prototype-own-members.js.json b/tests/harness/corpus-costs/8110-builtin-prototype-own-members.js.json new file mode 100644 index 0000000000..5c4dabee92 --- /dev/null +++ b/tests/harness/corpus-costs/8110-builtin-prototype-own-members.js.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 41100 } diff --git a/tests/harness/corpus-costs/8111-plain-object-check-unknown.ts.json b/tests/harness/corpus-costs/8111-plain-object-check-unknown.ts.json new file mode 100644 index 0000000000..c40ea383db --- /dev/null +++ b/tests/harness/corpus-costs/8111-plain-object-check-unknown.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 7700 } From 09f034264104d527d30d0e396da78c8105d38a85 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:40:35 -0700 Subject: [PATCH 19/44] Keep an error's name and message where a JavaScript factory created them zValidator's default failure hook serializes zod 4's ZodError with `name` after `message` natively, and with the first `name` zod's factory assigns ("$ZodError") when the second assignment took the typed slot. ZodError instances are views of a JavaScript class extending Error: each view rebuilds `message` and `name` from the error slots at fixed positions (after `stack`, and after every other property), and the property bag that holds the dynamically added properties copied its committed, possibly stale, values over them. A JavaScript Error subclass with a property bag now marks its class (errorBagOrder), and its view calls scr_error_dyn_follow_bag after the bag is copied: `name` and `message` move to follow the property they followed when the bag was committed, and data values come from the slots. TypeScript classes keep their views. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/llvm/expr-stream-bridges.ts | 19 +++++ .../lowering/class-dynamic-dispatch.ts | 9 +++ packages/compiler/src/ir/ir.ts | 4 + .../8112-error-factory-own-key-order.js.json | 6 ++ .../cases/hono-zod-default-hook/main.ts.json | 61 ++++++++++++++++ packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_json.c | 57 +++++++++++++++ .../8112-error-factory-own-key-order.js | 73 +++++++++++++++++++ .../npm/cases/hono-zod-default-hook/main.ts | 38 ++++++++++ .../cases/hono-zod-default-hook/package.json | 1 + .../8112-error-factory-own-key-order.js.json | 1 + 11 files changed, 273 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json create mode 100644 tests/corpus/8112-error-factory-own-key-order.js create mode 100644 tests/fixtures/npm/cases/hono-zod-default-hook/main.ts create mode 100644 tests/fixtures/npm/cases/hono-zod-default-hook/package.json create mode 100644 tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 1acc75cec6..d6d5a5b7c5 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -993,6 +993,25 @@ export function streamTypedRefMaterializeAdapter( : `call void @scr_error_dyn_place_name(ptr ${out}, ptr ${host.cstr(nameAfter)}, ${host.sizeType} ${Buffer.byteLength(nameAfter, "utf8")})`, ); } + // A JavaScript Error subclass's property bag records, once a dynamic + // write has committed the view, where `name` and `message` were created + // (zod's error factory assigns `name` before it defines `message`); the + // slots hold their values. + if ( + meta.def.errorBagOrder && + meta.root.def.name === "%Error" && + meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) + ) { + host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr)`); + const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); + const slot = B.tmp(), + bag = B.tmp(); + B.line( + `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`, + ); + B.line(`${bag} = load ptr, ptr ${slot}`); + B.line(`call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p)`); + } // A frozen, sealed or non-extensible instance's view says so. if (withAttributes) { host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index abf03811e2..4716324086 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1634,6 +1634,15 @@ export class ClassDynamicDispatch { if (this.deletedNames === undefined) this.deletedNames = deletedPropertyNames(lowerer.moduleOrder); if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root); + // An Error family's bag records where `name` and `message` were + // created, which the error slots do not. + if (root.base?.def.name === "%Error") { + const order = (current: ClassInfo): void => { + current.def.errorBagOrder = true; + current.subclasses.forEach(order); + }; + order(info); + } } const loc = this.propertyBag.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 550333edd0..5349110425 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1499,6 +1499,10 @@ export interface IrClassDef { * class of the chain sets `name` during construction, and then an own * `name` follows every other own property. */ errorNameAfter?: string | null; + /** JavaScript Error subclasses with a property bag: a view committed by a + * dynamic write keeps `name` and `message` where the bag recorded them + * (scr_error_dyn_follow_bag). */ + errorBagOrder?: boolean; /** Stable module symbol identities for public instance layout fields. */ symbolFields?: { field: string; globalId: string }[]; /** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json new file mode 100644 index 0000000000..4b64d1d755 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8112-error-factory-own-key-order.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json new file mode 100644 index 0000000000..2310d6e0f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json @@ -0,0 +1,61 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1398, + "end": 1407 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1413, + "end": 1422 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 3716, + "end": 3725 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 4798, + "end": 4806 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 549, + "end": 557 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 5572, + "end": 5580 + } + } + ] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b1d0523561..b743aaf0f9 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1330,6 +1330,10 @@ ScrDyn *scr_dyn_from_error(const ScrError *e); void scr_error_register_dyn(const ScrVt *vt, ScrDyn *(*box)(void *)); ScrDyn *scr_error_dyn_fields(const ScrError *e); void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len); +/* An Error subclass view of an instance whose class keeps a property bag: + * `name` and `message` take their committed positions, and data values + * from the slots (scr_json.c). */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e); /* An accessor property of a fresh object: enumerable and configurable, * with the given getter and setter (either may be NULL). Moves both. */ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *getter, ScrDyn *setter); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 35e1d76744..2086ab5eea 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8310,6 +8310,63 @@ void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len) view->v.obj.index = NULL; } +static size_t scr_dyn_entry_index(const ScrDyn *object, const char *key, size_t key_len) { + for (size_t i = 0; i < object->v.obj.len; i++) { + const ScrDynEntry *entry = &object->v.obj.entries[i]; + if (entry->key_len == key_len && memcmp(entry->key, key, key_len) == 0) return i; + } + return SIZE_MAX; +} + +/* The bag holds the view as it was last committed, in the order its + * properties were created: zod's error factory assigns `name` and then + * defines `message`, and Node lists them in that order. The slots rebuild + * both at fixed positions (scr_error_dyn_fields, scr_error_dyn_place_name), + * so each one present in the bag moves to follow the property it follows + * there. A typed write to `name` or `message` reaches only the slot, so a + * data property takes the slot's value. */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e) { + if (!bag || bag->kind != SCR_DYN_OBJ || view->kind != SCR_DYN_OBJ) return; + static const struct { const char *key; size_t len; } slots[] = {{"name", 4}, {"message", 7}}; + for (size_t s = 0; s < 2; s++) { + bool name = s == 0; + if (!(name ? e->name_present : e->message_present)) continue; + size_t at = scr_dyn_entry_index(view, slots[s].key, slots[s].len); + if (at == SIZE_MAX || view->v.obj.entries[at].accessor) continue; + ScrDyn *value = scr_dyn_new_str(name ? e->name : e->message); + scr_dyn_region_store(view, value); + ScrDyn *old = view->v.obj.entries[at].value; + view->v.obj.entries[at].value = value; + scr_dyn_release(old); + } + bool moved = false; + for (size_t b = 0; b < bag->v.obj.len; b++) { + const ScrDynEntry *bag_entry = &bag->v.obj.entries[b]; + bool slot = (bag_entry->key_len == 4 && memcmp(bag_entry->key, "name", 4) == 0) || + (bag_entry->key_len == 7 && memcmp(bag_entry->key, "message", 7) == 0); + if (!slot) continue; + size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); + if (from == SIZE_MAX) continue; + /* The nearest earlier bag property the view has; none puts it first. */ + size_t after = SIZE_MAX; + for (size_t p = b; p-- > 0 && after == SIZE_MAX;) + after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + size_t to = after == SIZE_MAX ? 0 : after < from ? after + 1 : after; + if (to == from) continue; + ScrDynEntry *entries = view->v.obj.entries; + ScrDynEntry entry = entries[from]; + if (to < from) memmove(&entries[to + 1], &entries[to], (from - to) * sizeof *entries); + else memmove(&entries[from], &entries[from + 1], (to - from) * sizeof *entries); + entries[to] = entry; + moved = true; + } + if (moved) { + SCR_JS_SHAPE_RESET(view); + free(view->v.obj.index); + view->v.obj.index = NULL; + } +} + ScrDyn *scr_dyn_from_error(const ScrError *e) { size_t hit = scr_errdyn_find(e, true); if (hit != SIZE_MAX) return scr_dyn_retain(scr_errdyn_cache[hit].dyn); diff --git a/tests/corpus/8112-error-factory-own-key-order.js b/tests/corpus/8112-error-factory-own-key-order.js new file mode 100644 index 0000000000..3e7cf277ae --- /dev/null +++ b/tests/corpus/8112-error-factory-own-key-order.js @@ -0,0 +1,73 @@ +"use strict"; +// Errors built by a constructor factory, the way zod 4 builds ZodError: a +// class extending the parent passed in, then an initializer that assigns +// `name`, defines non-enumerable fields and an enumerable `message` +// accessor, and a second initializer that reassigns `name`. JSON and +// Object.keys list `name` before `message`, as they were created, and +// `name` holds the last assignment. + +const messageDescriptor = { + get() { + return this.state.message ?? JSON.stringify(this.state.issues); + }, + set(value) { + this.state.message = value; + }, + enumerable: true, + configurable: true, +}; + +function factory(name, initializer, parent) { + const Parent = parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function construct(def) { + const instance = parent ? new Definition() : this; + Object.defineProperty(instance, "state", { value: { issues: def }, enumerable: false }); + initializer(instance, def); + return instance; + } + Object.defineProperty(construct, "init", { value: initializer }); + Object.defineProperty(construct, "name", { value: name }); + return construct; +} + +const baseInit = (instance, def) => { + instance.name = "$BaseError"; + Object.defineProperty(instance, "issues", { value: def, enumerable: false }); + Object.defineProperty(instance, "message", messageDescriptor); +}; +const BaseError = factory("$BaseError", baseInit, Error); +const ReportError = factory( + "ReportError", + (instance, def) => { + BaseError.init(instance, def); + instance.name = "ReportError"; + }, + Error, +); + +const report = new ReportError([{ code: "too_small", path: ["title"] }]); +console.log(report.name, report instanceof Error, Object.keys(report).join(",")); +console.log(JSON.stringify(report)); +console.log(JSON.stringify({ success: false, error: report })); +report.message = "replaced"; +console.log(JSON.stringify(report), report.message); +report.name = "Renamed"; +console.log(JSON.stringify(report), report.name); + +const base = new BaseError([1, 2]); +console.log(base.name, Object.keys(base).join(","), JSON.stringify(base)); + +// The same steps on a class extending Error directly. +function build(flag) { + class Direct extends Error {} + const instance = flag ? new Direct() : {}; + instance.name = "First"; + Object.defineProperty(instance, "message", messageDescriptor); + Object.defineProperty(instance, "state", { value: { issues: ["i"] }, enumerable: false }); + instance.name = "Second"; + return instance; +} +const direct = build(true); +console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts new file mode 100644 index 0000000000..9981e7154b --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts @@ -0,0 +1,38 @@ +// @hono/zod-validator without a hook: a failed check answers 400 with the +// whole safeParse result serialized, so the ZodError's own enumerable +// properties (name, then message) and their order reach the client. +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +const Item = z.object({ title: z.string().min(2), qty: z.number().int().positive() }); + +const app = new Hono(); +app.post("/items", zValidator("json", Item), (c) => { + const item = c.req.valid("json"); + return c.json({ created: item.title, qty: item.qty }, 201); +}); +app.get("/items", zValidator("query", z.object({ limit: z.coerce.number().int().max(5) })), (c) => { + return c.json({ limit: c.req.valid("query").limit }); +}); + +const post = (body: unknown) => + app.request("/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + +const show = async (label: string, res: Response | Promise) => { + const r = await res; + console.log(label, r.status, await r.text()); +}; + +await show("ok", post({ title: "pen", qty: 2 })); +await show("bad-title", post({ title: "p", qty: 2 })); +await show("bad-both", post({ title: 3, qty: -1 })); +await show("query-ok", app.request("/items?limit=4")); +await show("query-bad", app.request("/items?limit=9")); + +const result = Item.safeParse({ title: "x", qty: 0 }); +if (!result.success) { + const e = result.error; + console.log(e.name, e instanceof Error, Object.keys(e).join(",")); + console.log(JSON.stringify(e)); +} diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/package.json b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json new file mode 100644 index 0000000000..a734aaddb6 --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json @@ -0,0 +1 @@ +{ "name": "hono-zod-default-hook-case", "private": true, "type": "module" } diff --git a/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json new file mode 100644 index 0000000000..9013dc548d --- /dev/null +++ b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 10300 } From e81c9ddaa4f61409c88d1cdb58ef53e473bd3da5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:42:19 -0700 Subject: [PATCH 20/44] Give the hono-zod-default-hook npm case the zod 4 cases' timeout Co-Authored-By: Claude Opus 5.5 --- tests/harness/npm.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index ff9e09d614..2333f13dac 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -114,10 +114,12 @@ const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); /** Per-case timeouts past the default 120 s: the undici cases compile the * built-in undici implementation's module graph, the zod 3 cases (zod3, * zod3-adoption, zod3-boundary, zod3-infer) compile zod 3's 3,700-line - * types module, and the zod 4 cases (zod4-boundary, hono-zod-validator) + * types module, and the zod 4 cases (zod4-boundary, zod4-record, + * hono-zod-validator, hono-zod-default-hook) * compile zod 4's core, classic API and locales. A sanitized build of any * of them takes minutes on a CI runner. */ const TIMEOUTS: Record = { + "hono-zod-default-hook": 900_000, "hono-zod-validator": 900_000, undici5: 600_000, undici7: 600_000, From f896786ef4feb425aecd5755d13e7e1f7b9e6e2b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 16:42:23 -0700 Subject: [PATCH 21/44] Give the zod4-record npm case the zod 4 cases' timeout Co-Authored-By: Claude Opus 5.5 --- tests/harness/npm.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index ff9e09d614..d34c32fc7a 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -114,7 +114,8 @@ const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); /** Per-case timeouts past the default 120 s: the undici cases compile the * built-in undici implementation's module graph, the zod 3 cases (zod3, * zod3-adoption, zod3-boundary, zod3-infer) compile zod 3's 3,700-line - * types module, and the zod 4 cases (zod4-boundary, hono-zod-validator) + * types module, and the zod 4 cases (zod4-boundary, zod4-record, + * hono-zod-validator) * compile zod 4's core, classic API and locales. A sanitized build of any * of them takes minutes on a CI runner. */ const TIMEOUTS: Record = { @@ -122,6 +123,7 @@ const TIMEOUTS: Record = { undici5: 600_000, undici7: 600_000, "zod4-boundary": 900_000, + "zod4-record": 900_000, zod3: 600_000, "zod3-adoption": 600_000, "zod3-boundary": 600_000, From fbaf51c0325c175f66ae2a516b1226efad9fed4d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:53:36 -0500 Subject: [PATCH 22/44] Decide string or number concatenation at run time for untyped partners --- .../src/frontend/lowering/lower-exprs.ts | 19 +++++++++++++++- tests/corpus/7392-non-null-operands.ts | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d62d2cfc3f..c790581d82 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1008,12 +1008,15 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" // With a string on either side, `+` concatenates and a comparison // compares strings. `undefined + 1` and `undefined + undefined` are NaN, // which a string-typed result cannot hold, so `+` takes the union only - // with a string partner that is not itself asserted. + // with a string partner that is not itself asserted. A partner that may + // hold a string or a number at run time (`any`, `unknown`) also gets the + // union, and `+` decides between concatenation and addition when it runs. if (op === ts.SyntaxKind.PlusToken || op === ts.SyntaxKind.PlusEqualsToken) { if (op === ts.SyntaxKind.PlusEqualsToken && parent.right !== outer) return null; let partner = other; while (ts.isParenthesizedExpression(partner)) partner = partner.expression; if (isString(other)) return ts.isNonNullExpression(partner) ? null : "value"; + if (!numericPartner(lowerer.typeOf(other))) return "value"; return isString(expr) ? null : "number"; } if (RELATIONAL_OPERATORS.has(op)) return isString(other) || isString(expr) ? "value" : "number"; @@ -1021,6 +1024,20 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" return NUMERIC_COMPOUNDS.has(op) && parent.right === outer ? "number" : null; } +/** A `+` partner whose every value is a number, a boolean, null or + * undefined: `+` adds with it. */ +function numericPartner(type: ts.Type): boolean { + const numeric = + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void; + return (type.flags & ts.TypeFlags.Union) !== 0 + ? constituentTypes(type).every((part) => (part.flags & numeric) !== 0) + : (type.flags & numeric) !== 0; +} + const EQUALITY_OPERATORS = new Set([ ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, diff --git a/tests/corpus/7392-non-null-operands.ts b/tests/corpus/7392-non-null-operands.ts index 40e3367afa..60928ddb6b 100644 --- a/tests/corpus/7392-non-null-operands.ts +++ b/tests/corpus/7392-non-null-operands.ts @@ -113,3 +113,25 @@ for (const key of ["one", "none", "missing"]) { line(`loose ${key}`, [optional.get(key)! == null, optional.get(key)! != undefined, optional.get(key)! == 1]); } line("loose call", [lookup("a") == null, lookup("b") == null, lookup("c") != null, reads]); + +// `+` with an untyped partner concatenates or adds depending on what the +// partner holds when it runs. +const untypedText: any = "x"; +const untypedNumber: any = 5; +const untypedObject: any = { toString: () => "obj" }; +for (const s of [empty, full]) { + let joined: any = "t"; + let summed: any = 1; + joined += s.limit!; + summed += s.limit!; + line(`untyped ${s === empty ? "empty" : "full"}`, [ + s.limit! + untypedText, + untypedText + s.limit!, + s.limit! + untypedNumber, + untypedNumber + s.limit!, + s.label! + untypedText, + s.limit! + untypedObject, + joined, + summed, + ]); +} From bf68b3860cdf7fb7b2e5d9442fb4576d3a05bf4d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 19:05:58 -0500 Subject: [PATCH 23/44] Skip split require calls by identity and climb parentheses in early-read checks --- packages/compiler/src/frontend/program.ts | 21 ++++++++++++------- .../split-cjs-require-early/main.js.json | 10 +++++++++ tests/corpus/split-cjs-require-cycle/main.cjs | 3 ++- tests/corpus/split-cjs-require-cycle/odd.cjs | 2 +- .../split-cjs-require-early/main.js | 5 +++++ .../split-cjs-require-early/main.js.txt | 9 +++++++- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 42fa6116e4..149584d00f 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1563,6 +1563,14 @@ function splitRequireOfStatement7( return null; } +/** The top-level statement of a split require binding's assignment, from + * its require call (`m = require("./x")` or `m = (require("./x"))`). */ +function splitRequireStatementOf7(call: ts.CallExpression): ts.Node | undefined { + let node: ts.Node = call; + while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent; + return node.parent?.parent; +} + /** The require call of a split require binding's declaration, or null. */ function splitRequireCallOf7( program: ts.Program, @@ -1877,7 +1885,7 @@ function requireEarlyReference7( const sf = ts.getSourceFileOfNode(decl); // A split require binding is bound at its assignment statement. const splitCall = splitRequireCallOf7(program, decl); - const stmt = splitCall !== null ? splitCall.parent?.parent : decl.parent?.parent; + const stmt = splitCall !== null ? splitRequireStatementOf7(splitCall) : decl.parent?.parent; if ( stmt !== undefined && (ts.isVariableStatement(stmt) || splitCall !== null) && @@ -6184,15 +6192,12 @@ function preflight7(load: LoadResult): { // counts, and targets outside the compiled graph add nothing here: // their use sites keep their existing lowering fences. if (jsRequireFile && !isNodeEsmFile7(sf, program)) { + // A split require binding's assignment is a statement-level require + // edge (requiresOf7 above), parenthesized or not. + const splitCalls = new Set(splitRequireBindings7(program, sf).values()); for (const call of inlineRequiresOf7(sf)) { if (requireCallBindingKind7(program, call) !== "ambient") continue; - // A split require binding's assignment is a statement-level - // require edge (requiresOf7 above). - if ( - call.parent?.parent !== undefined && - splitRequireOfStatement7(program, call.parent.parent as ts.Statement) !== null - ) - continue; + if (splitCalls.has(call)) continue; const spec = requireSpecOf7(call)!; if (load.externalTypes.has(spec) || canonicalBuiltinModule(spec) !== null) continue; const lazy = inDeferredPosition7(call); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json index a0a094bf54..acb338494f 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json @@ -12,6 +12,16 @@ "end": 415 }, "milestone": "later" + }, + { + "code": "SC1013", + "message": "require() binding 'wrapped' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet", + "loc": { + "file": "/tests/diagnostics/split-cjs-require-early/main.js", + "start": 530, + "end": 549 + }, + "milestone": "later" } ] } diff --git a/tests/corpus/split-cjs-require-cycle/main.cjs b/tests/corpus/split-cjs-require-cycle/main.cjs index c4788ddbe5..9ec7997db2 100644 --- a/tests/corpus/split-cjs-require-cycle/main.cjs +++ b/tests/corpus/split-cjs-require-cycle/main.cjs @@ -1,7 +1,8 @@ // Split require bindings (`var m; m = require("./x.cjs")`) that close a // CommonJS cycle: each module publishes its exports before its require, and // reads the other module's binding only in functions that run after both -// modules finish, the cycle shape the const form compiles too. +// modules finish, the cycle shape the const form compiles too. One +// assignment parenthesizes its require. console.log("main: start"); var even, odd; even = require("./even.cjs"); diff --git a/tests/corpus/split-cjs-require-cycle/odd.cjs b/tests/corpus/split-cjs-require-cycle/odd.cjs index c066a1e33f..d449c28e87 100644 --- a/tests/corpus/split-cjs-require-cycle/odd.cjs +++ b/tests/corpus/split-cjs-require-cycle/odd.cjs @@ -8,5 +8,5 @@ exports.isOdd = function (n) { exports.calls = function () { return calls; }; -even = require("./even.cjs"); +even = (require("./even.cjs")); console.log("odd: loaded"); diff --git a/tests/diagnostics/split-cjs-require-early/main.js b/tests/diagnostics/split-cjs-require-early/main.js index 3e0e43aa34..ae1d8d7703 100644 --- a/tests/diagnostics/split-cjs-require-early/main.js +++ b/tests/diagnostics/split-cjs-require-early/main.js @@ -9,6 +9,11 @@ let lib; console.log('boot', helper()); lib = require('./lib.js'); +// The same with the require parenthesized. +let wrapped; +console.log('peek', wrapped === undefined); +wrapped = (require('./lib.js')); + function helper() { return lib === undefined ? 'not loaded' : lib.greet('late'); } diff --git a/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt b/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt index 2269f906e6..0496cd1259 100644 --- a/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt +++ b/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt @@ -3,4 +3,11 @@ split-cjs-require-early/main.js:10:7 - error SC1013: require() binding 'lib' ass 9 | console.log('boot', helper()); 10 | lib = require('./lib.js'); | ^~~~~~~~~~~~~~~~~~~ - 11 | \ No newline at end of file + 11 | + +split-cjs-require-early/main.js:15:12 - error SC1013: require() binding 'wrapped' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet + + 14 | console.log('peek', wrapped === undefined); + 15 | wrapped = (require('./lib.js')); + | ^~~~~~~~~~~~~~~~~~~ + 16 | \ No newline at end of file From a4e7a03761ce4a842fb424159d4ad133f618a922 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 19:37:04 -0500 Subject: [PATCH 24/44] Address review comments --- ...8113-error-class-field-before-name.js.json | 6 +++ packages/runtime/src/scr_json.c | 42 +++++++++++++-- .../8113-error-class-field-before-name.js | 53 +++++++++++++++++++ ...8113-error-class-field-before-name.js.json | 1 + 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json create mode 100644 tests/corpus/8113-error-class-field-before-name.js create mode 100644 tests/harness/corpus-costs/8113-error-class-field-before-name.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json new file mode 100644 index 0000000000..ddf5981f0b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8113-error-class-field-before-name.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8113-error-class-field-before-name.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2086ab5eea..49db3cf3e5 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8318,6 +8318,42 @@ static size_t scr_dyn_entry_index(const ScrDyn *object, const char *key, size_t return SIZE_MAX; } +/* Whether bag entry b is one of the entries Error's constructor creates + * before the instance's class fields: `stack`, a hidden entry (a key + * starting with '%'), and a `message` that directly follows them. */ +static bool scr_error_bag_constructed(const ScrDyn *bag, size_t b) { + for (size_t i = 0; i <= b; i++) { + const ScrDynEntry *entry = &bag->v.obj.entries[i]; + bool constructed = (entry->key_len == 5 && memcmp(entry->key, "stack", 5) == 0) || + (entry->key_len > 0 && entry->key[0] == '%') || + (entry->key_len == 7 && memcmp(entry->key, "message", 7) == 0); + if (!constructed) return false; + } + return true; +} + +/* The view index at which bag entry b (`name` when `name`, else `message`), + * now at `from`, belongs: after the nearest earlier bag property the view + * has. `message` without one goes first. `name` passes over the entries + * Error's constructor creates: the view's class fields, which live in slots + * and not in the bag, follow those and come before an own `name`. Without + * another earlier property, `name` goes before the nearest later one the + * view has, and without either it stays where the view put it. */ +static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t b, bool name, + size_t from) { + for (size_t p = b; p-- > 0;) { + if (name && scr_error_bag_constructed(bag, p)) break; + size_t after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + if (after != SIZE_MAX) return after < from ? after + 1 : after; + } + if (!name) return 0; + for (size_t p = b + 1; p < bag->v.obj.len; p++) { + size_t before = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + if (before != SIZE_MAX) return before > from ? before - 1 : before; + } + return from; +} + /* The bag holds the view as it was last committed, in the order its * properties were created: zod's error factory assigns `name` and then * defines `message`, and Node lists them in that order. The slots rebuild @@ -8347,11 +8383,7 @@ void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e if (!slot) continue; size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); if (from == SIZE_MAX) continue; - /* The nearest earlier bag property the view has; none puts it first. */ - size_t after = SIZE_MAX; - for (size_t p = b; p-- > 0 && after == SIZE_MAX;) - after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); - size_t to = after == SIZE_MAX ? 0 : after < from ? after + 1 : after; + size_t to = scr_error_bag_place(view, bag, b, bag_entry->key_len == 4, from); if (to == from) continue; ScrDynEntry *entries = view->v.obj.entries; ScrDynEntry entry = entries[from]; diff --git a/tests/corpus/8113-error-class-field-before-name.js b/tests/corpus/8113-error-class-field-before-name.js new file mode 100644 index 0000000000..8b40d06da7 --- /dev/null +++ b/tests/corpus/8113-error-class-field-before-name.js @@ -0,0 +1,53 @@ +"use strict"; +// Error subclasses with a class field, which their instances create before +// an own `name`, and a property bag that a later dynamic definition or +// write commits. Object.keys and JSON list the field before `name`, and a +// `message` that Error's constructor created before both. + +const messageDescriptor = { + get() { + return "described"; + }, + enumerable: true, + configurable: true, +}; + +// A factory-built class: the field, then an initializer that assigns `name` +// and defines `message`. +function factory(initializer) { + class Definition extends Error { + code = "E_FIELD"; + } + return function construct() { + const instance = new Definition(); + initializer(instance); + return instance; + }; +} +const Fielded = factory((instance) => { + instance.name = "Fielded"; + Object.defineProperty(instance, "message", messageDescriptor); +}); +const fielded = new Fielded(); +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded)); +fielded.extra = 1; +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded)); +fielded.name = "Renamed"; +console.log(Object.keys(fielded).join(","), JSON.stringify(fielded), fielded.name); + +// A class whose constructor passes a message to Error and then assigns +// `name`; the message is redefined after construction. +class Direct extends Error { + code = "E_DIRECT"; + constructor() { + super("constructed"); + this.name = "Direct"; + } +} +function redefine(instance) { + Object.defineProperty(instance, "message", messageDescriptor); + instance.extra = 2; + return instance; +} +const direct = redefine(new Direct()); +console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); diff --git a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json new file mode 100644 index 0000000000..66dce63415 --- /dev/null +++ b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 8600 } From c9f1f95d5b466913b6985f813b351227cbb554e1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 15:18:50 -0700 Subject: [PATCH 25/44] Box JavaScript class instances at their exact class A class instance converted to an untyped value was boxed in a capsule of its static class. Before any member table read it, %dyn.class.normalize tested it against each subclass and boxed it again at the instance's own class: two capsules per crossing for every value of a class with subclasses, and the subclass tests on every member read. After the dispatch tables settle, each conversion of a value whose static JavaScript class has a normalization branch now calls %dyn.class.boxExact:, which runs the same subclass tests in the same order once, at the conversion. The capsule is created at the exact class, and normalization falls through for it. TypeScript hierarchies, classes that are never boxed, and hierarchies without subclasses are untouched. zod 3, z.array(Order).parse over 500 orders: 178.5M -> 174.4M instructions per round (-2.3%). Co-Authored-By: Claude Opus 5.5 --- .../lowering/class-dynamic-dispatch.ts | 99 ++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 7 +- .../compiler/test/js-class-layouts.test.ts | 18 +++ .../7796-boxed-subclass-identity.js.json | 6 + tests/corpus/7796-boxed-subclass-identity.js | 106 ++++++++++++++++++ .../7796-boxed-subclass-identity.js.json | 1 + 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json create mode 100644 tests/corpus/7796-boxed-subclass-identity.js create mode 100644 tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index abf03811e2..701d08e4a6 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -717,6 +717,105 @@ export function classForInHasKey( return { kind: "call", callee: name, args: [value, key], type: BOOL, loc }; } +/** Box class instances at their exact class. `%dyn.class.normalize` turns a + * capsule of a class with subclasses into a capsule of the instance's own + * class before any member table reads it, so every such box was followed + * by a second capsule. Each conversion of a value whose static class has a + * normalization branch now selects the exact class at the conversion, with + * the same tests in the same order (%dyn.class.boxExact:C), and the + * normalization of the resulting capsule falls through. Only JavaScript + * class hierarchies, whose instances live in untyped code, take the exact + * box. */ +export function boxClassInstancesExactly(lowerer: Lowerer, functions: IrFunction[]): void { + const normalize = lowerer.liftedFnNamed("%dyn.class.normalize"); + if (!normalize) return; + // Static class → its constructible descendants in normalization order. + const branches = new Map(); + for (const stmt of normalize.body) { + if (stmt.kind !== "if" || stmt.cond.kind !== "libCall" || stmt.cond.fn !== "dyn.typedRefIs") + continue; + const key = stmt.cond.args[1]; + if (key?.kind !== "strLit" || !key.value.startsWith("object:")) continue; + const children: string[] = []; + for (const inner of stmt.then) { + if (inner.kind !== "if" || inner.cond.kind !== "instanceOf") continue; + children.push(inner.cond.className); + } + // TypeScript hierarchies keep their static-class capsules: their boxes + // are rarer, and an exact box would emit each subclass's checked view. + const className = key.value.slice("object:".length); + const decl = lowerer.classes.get(className)?.decl; + if (!decl || !isJsSourceFile(ts.getSourceFileOfNode(decl))) continue; + if (children.length) branches.set(className, children); + } + if (branches.size === 0) return; + const loc = normalize.loc; + const helpers = new Map(); + const helperFor = (className: string): string => { + const known = helpers.get(className); + if (known !== undefined) return known; + const name = `%dyn.class.boxExact:${className}`; + helpers.set(className, name); + const type: IrType = { kind: "object", className }; + const value = varRef("value", type, loc); + functions.push({ + name, + params: [{ localId: "value", name: "value", type }], + returnType: DYN, + locals: [{ id: "value", name: "value", type, mutable: false }], + body: [ + ...branches.get(className)!.map((child): IrStmt => ({ + kind: "if", + cond: { kind: "instanceOf", value, className: child, type: BOOL, loc }, + then: [ + { + kind: "return", + value: { + kind: "dynFrom", + value: { + kind: "downcast", + value, + type: { kind: "object", className: child }, + loc, + }, + type: DYN, + loc, + }, + loc, + }, + ], + else_: null, + loc, + })), + { kind: "return", value: { kind: "dynFrom", value, type: DYN, loc }, loc }, + ], + loc, + }); + return name; + }; + const rewrite = (fn: IrFunction): void => { + fn.body = transformStmtList(fn.body, { + stmt: (statement) => statement, + expr: (expression) => + expression.kind === "dynFrom" && + expression.value.type.kind === "object" && + branches.has(expression.value.type.className) + ? { + kind: "call", + callee: helperFor(expression.value.type.className), + args: [expression.value], + type: DYN, + loc: expression.loc, + } + : expression, + }); + }; + for (const fn of [...functions]) { + if (fn === normalize || fn.name.startsWith("%dyn.class.boxExact:")) continue; + rewrite(fn); + } +} + /** Record field creation after the dispatch worklist closes, including writes * in generated member tables. Native values stay in their slots; the bag * owns only their property presence, attributes, and insertion position. */ diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 672a995fcf..13ab793fb8 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -149,7 +149,11 @@ import type { FrontendServices } from "../services.js"; import { InternalCompilerError } from "../../errors.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js"; import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; -import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; +import { + ClassDynamicDispatch, + boxClassInstancesExactly, + trackClassFieldCreation, +} from "./class-dynamic-dispatch.js"; import { guardClassAttributeWrites } from "./class-attributes.js"; import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js"; import { applyHandleProperties } from "./handle-properties.js"; @@ -7731,6 +7735,7 @@ export class Lowerer { finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); trackClassFieldCreation(this, functions); + boxClassInstancesExactly(this, functions); guardClassAttributeWrites(this, functions); guardFrozenWrites(this, functions); applyHandleProperties(this, functions); diff --git a/packages/compiler/test/js-class-layouts.test.ts b/packages/compiler/test/js-class-layouts.test.ts index 110d007ee9..987703110e 100644 --- a/packages/compiler/test/js-class-layouts.test.ts +++ b/packages/compiler/test/js-class-layouts.test.ts @@ -221,3 +221,21 @@ test("a JavaScript file TypeScript checks uses its JSDoc types for class layouts expect(layout(unchecked, "Account").fields).toBe("owner:dyn balance:dyn"); expect(params(unchecked, "%Account.constructor")).toBe("(object, dyn, dyn): void"); }); + +test("instances of a class with subclasses box at their exact class", async () => { + const module = await lower(`${SHAPES} +/** @param {any} value */ +function keep(value) { + return value; +} +/** @param {Shape} shape */ +function cross(shape) { + return keep(shape); +} +console.log(cross(new Square()).size, cross(new Shape()).label); +`); + const helper = module.functions.find((fn) => fn.name === "%dyn.class.boxExact:Shape"); + expect(helper).toBeDefined(); + expect(calls(module, "cross")).toContain("%dyn.class.boxExact:Shape"); + expect(JSON.stringify(helper!.body)).toContain('"className":"Square"'); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json new file mode 100644 index 0000000000..37f785cc2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7796-boxed-subclass-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7796-boxed-subclass-identity.js" + ], + "diags": [] +} diff --git a/tests/corpus/7796-boxed-subclass-identity.js b/tests/corpus/7796-boxed-subclass-identity.js new file mode 100644 index 0000000000..12ed8b3c51 --- /dev/null +++ b/tests/corpus/7796-boxed-subclass-identity.js @@ -0,0 +1,106 @@ +"use strict"; +// Class instances of a hierarchy cross into untyped code through their +// base type many times. Each crossing names the same object: identity, +// Map, Set and WeakMap keys, properties added through untyped code, and +// field writes from either side stay one object, and every subclass keeps +// its own members, as in Node. + +class Shape { + constructor(name) { + this.name = name; + this.hits = 0; + } + area() { + return 0; + } + describe() { + return `${this.name}:${this.area()}`; + } +} + +class Square extends Shape { + constructor(side) { + super("square"); + this.side = side; + } + area() { + return this.side * this.side; + } +} + +class Cube extends Square { + constructor(side) { + super(side); + this.name = "cube"; + } + area() { + return 6 * super.area(); + } + get volume() { + return this.side ** 3; + } +} + +class Circle extends Shape { + constructor(r) { + super("circle"); + this.r = r; + } + area() { + return Math.round(Math.PI * this.r * this.r); + } +} + +/** @param {any} value */ +function untyped(value) { + return value; +} +/** @param {any} value @param {string} key @param {unknown} extra */ +function tag(value, key, extra) { + value[key] = extra; + value.hits++; + return value; +} + +/** @param {Shape} shape */ +function crossAsBase(shape) { + return untyped(shape); +} + +const shapes = [new Shape("plain"), new Square(2), new Cube(3), new Circle(1)]; +const seen = new Map(); +const weak = new WeakMap(); +const set = new Set(); +for (let round = 0; round < 50; round++) { + for (const shape of shapes) { + const boxed = crossAsBase(shape); + if (round === 0) { + seen.set(boxed, shape.name); + weak.set(boxed, round); + } + set.add(boxed); + if (boxed !== shape || untyped(boxed) !== crossAsBase(shape)) console.log("identity lost", shape.name); + } +} +console.log(seen.size, set.size, shapes.map((s) => seen.get(crossAsBase(s))).join(","), shapes.every((s) => weak.has(untyped(s)))); + +for (const shape of shapes) { + const boxed = crossAsBase(shape); + tag(boxed, "color", shape.name.length); + shape.hits += 10; + console.log(boxed.describe(), boxed.area(), boxed.hits, shape.hits, boxed.color, Object.keys(boxed).join(",")); + console.log(boxed instanceof Shape, boxed instanceof Square, boxed instanceof Cube, boxed instanceof Circle, boxed.constructor.name); +} + +/** @type {Shape} */ +const asBase = shapes[2]; +const cube = crossAsBase(asBase); +console.log(cube.volume, JSON.stringify(cube), "side" in cube, "volume" in cube, Object.prototype.hasOwnProperty.call(cube, "volume")); +/** @type {any} */ (shapes[1]).side = 5; +console.log(crossAsBase(shapes[1]).area(), shapes[1].area(), untyped(shapes[1]).side); + +const many = []; +for (let i = 0; i < 1000; i++) many.push(crossAsBase(i % 2 ? new Square(i) : new Circle(i))); +let squares = 0; +for (const item of many) if (item instanceof Square) squares += item.side; +console.log(many.length, squares, many[999].describe(), many[998].describe()); diff --git a/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json b/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json new file mode 100644 index 0000000000..3dfdb2b6d9 --- /dev/null +++ b/tests/harness/corpus-costs/7796-boxed-subclass-identity.js.json @@ -0,0 +1 @@ +{ "plain": 4600, "san": 5500 } From 9ab731bf668005f196fe5d0020cedd08629e686d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 19:42:52 -0500 Subject: [PATCH 26/44] Version while loops only for accesses at their counters A while loop over typed arrays also versioned when its only checks were thread-owned receivers or static indices. A worker's job loop such as `while ((job = Atomics.load(next, 0)) < count) { ...; run(job) }` then got a second copy whose guard fails whenever the array is shared, and the doubled call kept the job function from being inlined. A while loop now versions only when it has an access at one of its counters. Other while loops compile as they did before they could version. --- .../src/backend/llvm/bytes-loop-versions.test.ts | 12 ++++++++++-- .../compiler/src/backend/llvm/bytes-loop-versions.ts | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index de71a6ef0f..aef39ef75e 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -385,7 +385,10 @@ test("while loop counters below a bound version their accesses", () => { ]); const declared = (loop: IrStmt): IrFunction => fn([{ kind: "varDecl", localId: "i", init: ref("k"), loc }, loop]); - const versionOf = (loop: IrStmt & { kind: "while" }): BytesLoopVersion | null => { + const versionOf = ( + loop: IrStmt & { kind: "while" }, + workers = false, + ): BytesLoopVersion | null => { const f = declared(loop); const ranges = analyzeIntegerRanges(f); return matchBytesLoopVersion(loop, null, { @@ -396,7 +399,7 @@ test("while loop counters below a bound version their accesses", () => { initialized: findInitializedByteLoopBindings(f).get(loop) ?? new Set(), proven: new Set(), scalarStrings: new Set(), - workers: false, + workers, owned: new Set(), }); }; @@ -413,6 +416,11 @@ test("while loop counters below a bound version their accesses", () => { // An access after the counter advances in the same pass stays checked. const advanced = whileLoop([step, set(ref("out", f64s), ref("i"), num(0))]); expect(versionOf(advanced)).toBeNull(); + // In a worker program, a while loop versions for its counter's accesses, + // and not for thread-owned receivers alone. + expect(versionOf(copy, true)!.ownedReceivers).toHaveLength(2); + const job = whileLoop([set(ref("out", f64s), ref("k"), get(ref("src", f64s), ref("k"))), step]); + expect(versionOf(job, true)).toBeNull(); const mod: IrModule = { irVersion: 17, sourceFile: loc.file, diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index 2013d03e4a..1b51deee17 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -581,6 +581,11 @@ export function matchBytesLoopVersion( pairedKept.length === 0 ) return null; + // A while loop versions only for accesses at its counters. Its other + // checks (thread-owned receivers, static indices) save a test or two per + // pass, while a second copy of its body doubles the calls it makes, which + // can keep a worker's job loop from inlining the function that runs a job. + if (loop.kind === "while" && boundedKept.length === 0) return null; const bounds = new Set(); const ownedNodes = new Set(); const used = new Set(); From 1b5c90549a8cf3f38de568cfb43bdd220544fdbb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 17:44:00 -0700 Subject: [PATCH 27/44] Answer Error.prototype for Object.getPrototypeOf of an Error An Error's view had no prototype or a holder of its default name and message, so Object.getPrototypeOf(new Error()) was Object.prototype or a fresh object. A view of an error built as an Error, whose prototype is still the one its view starts with, now answers Error.prototype. Co-Authored-By: Claude Opus 5.5 --- .../8115-error-prototype-identity.js.json | 6 +++ packages/runtime/src/scr_json.c | 23 ++++++++++ tests/corpus/8115-error-prototype-identity.js | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8115-error-prototype-identity.js.json create mode 100644 tests/corpus/8115-error-prototype-identity.js diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8115-error-prototype-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8115-error-prototype-identity.js.json new file mode 100644 index 0000000000..f4a6f13f0b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8115-error-prototype-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8115-error-prototype-identity.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 40fd735081..b318114a33 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2189,8 +2189,11 @@ ScrDyn *scr_dyn_obj_create(ScrDyn *prototype) { return object; } +static bool scr_dyn_error_view_on_error_prototype(const ScrDyn *object); + ScrDyn *scr_dyn_get_prototype(ScrDyn *object) { if (object == scr_builtin_object_prototype) return scr_dyn_new_null(); + if (object->kind == SCR_DYN_OBJ && scr_dyn_error_view_on_error_prototype(object)) return scr_dyn_error_prototype(); if (scr_dyn_class_reflection_fence(object)) return NULL; if (object->kind == SCR_DYN_TYPED_REF && strncmp(object->v.typed_ref.type_key, "object:", 7)) { ScrDyn *view = scr_dyn_typed_ref_materialize(object); @@ -8841,6 +8844,26 @@ static void scr_error_dyn_add_stack(ScrDyn *d) { entry->enumerable = false; } +/* Whether `object` is the view of an error built as an Error (not a + * subclass or another error kind) whose prototype is still the one its view + * starts with: none, or the holder of its default `name` and `message` + * (scr_error_dyn_fields). Such an error's prototype is Error.prototype. */ +static bool scr_dyn_error_view_on_error_prototype(const ScrDyn *object) { + const ScrDyn *holder = object->prototype; + if (object->null_proto) return false; + if (holder) { + if (holder->kind != SCR_DYN_OBJ || holder->prototype || holder->null_proto || holder->v.obj.len > 2) return false; + for (size_t i = 0; i < holder->v.obj.len; i++) { + const ScrDynEntry *entry = &holder->v.obj.entries[i]; + bool known = (entry->key_len == 4 && memcmp(entry->key, "name", 4) == 0) || + (entry->key_len == 7 && memcmp(entry->key, "message", 7) == 0); + if (!known || entry->enumerable || entry->accessor) return false; + } + } + size_t hit = scr_errdyn_find(object, false); + return hit != SIZE_MAX && scr_errdyn_cache[hit].err->vt == &scr_error_vts[SCR_ERR_ERROR]; +} + ScrDyn *scr_error_dyn_fields(const ScrError *e) { ScrDyn *d = scr_dyn_new_obj(); scr_dyn_obj_set(d, "%error", 6, scr_dyn_new_bool(true)); /* the checked-dynamic tree's error marker */ diff --git a/tests/corpus/8115-error-prototype-identity.js b/tests/corpus/8115-error-prototype-identity.js new file mode 100644 index 0000000000..1eda2c2c0d --- /dev/null +++ b/tests/corpus/8115-error-prototype-identity.js @@ -0,0 +1,46 @@ +"use strict"; +// Object.getPrototypeOf of an Error is Error.prototype, whose own prototype +// is Object.prototype: with or without a message, after its name is +// reassigned, and when it was thrown and caught. + +const describe = (label, value) => { + const proto = Object.getPrototypeOf(value); + console.log(label, proto === Error.prototype, proto === Object.prototype, Object.getPrototypeOf(proto) === Object.prototype); +}; + +describe("message", new Error("boom")); +describe("empty", new Error()); +const renamed = new Error("x"); +renamed.name = "Custom"; +describe("renamed", renamed); +const coded = new Error("y"); +coded.code = "E_CODE"; +describe("coded", coded); +try { + throw new Error("thrown"); +} catch (error) { + describe("caught", error); +} + +// Walks up the chain, as error serializers and type sniffers do. +/** @param {unknown} value */ +function chainLength(value) { + let length = 0; + for (let current = Object.getPrototypeOf(value); current !== null; current = Object.getPrototypeOf(current)) length++; + return length; +} +console.log(chainLength(new Error("a")), chainLength({}), chainLength([])); + +/** @param {unknown} value */ +function kind(value) { + if (typeof value !== "object" || value === null) return typeof value; + const proto = Object.getPrototypeOf(value); + if (proto === Object.prototype || proto === null) return "plain"; + if (proto === Error.prototype) return "error"; + if (proto === Array.prototype) return "array"; + return "other"; +} +const values = [new Error("e"), {}, Object.create(null), [], JSON.parse('{"a":1}'), "s"]; +console.log(values.map(kind).join(",")); +const proto = Object.getPrototypeOf(new Error("m")); +console.log(proto.name, JSON.stringify(proto.message), Object.prototype.hasOwnProperty.call(proto, "name")); From 17f1351b6fb0b2dff23ef8d9fc5d5a07b4700477 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:12:40 -0500 Subject: [PATCH 28/44] Fix the CI failures --- packages/compiler/src/native/executable-cache.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/compiler/src/native/executable-cache.test.ts b/packages/compiler/src/native/executable-cache.test.ts index 3199ef97fc..91762f046d 100644 --- a/packages/compiler/src/native/executable-cache.test.ts +++ b/packages/compiler/src/native/executable-cache.test.ts @@ -40,6 +40,11 @@ function fixture(debug = false) { const library = join(sdk, "libSystem.tbd"); writeFileSync(runtime, "runtime"); writeFileSync(library, "system library"); + // A directory's identity is its stat. Where the file system stamps times + // at the kernel tick, a file added within the tick of the last write + // leaves the directory's times unchanged; an old time makes every later + // change to the SDK directory visible. + utimesSync(sdk, 0, 0); const cache = new NativeCache(join(root, "cache")); const key = contentDigest("llvm and options"); const open = () => new NativeExecutableCache(cache, key, "clang", debug, [runtime]); From e813250813691b14036442c7ac42c04fffdc0f8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:22:14 -0500 Subject: [PATCH 29/44] Keep a constructor's name ahead of later properties in the runtime fallback --- .../src/backend/llvm/expr-stream-bridges.ts | 9 +++-- packages/runtime/src/scr_dyn.h | 5 +-- packages/runtime/src/scr_json.c | 35 +++++++++++-------- .../8113-error-class-field-before-name.js | 33 ++++++++++++++--- ...8113-error-class-field-before-name.js.json | 2 +- 5 files changed, 60 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index d6d5a5b7c5..46c2d884ff 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -996,13 +996,14 @@ export function streamTypedRefMaterializeAdapter( // A JavaScript Error subclass's property bag records, once a dynamic // write has committed the view, where `name` and `message` were created // (zod's error factory assigns `name` before it defines `message`); the - // slots hold their values. + // slots hold their values. A `name` the class's construction sets is + // already where errorNameAfter places it among the fields. if ( meta.def.errorBagOrder && meta.root.def.name === "%Error" && meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) ) { - host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr)`); + host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr, i1)`); const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); const slot = B.tmp(), bag = B.tmp(); @@ -1010,7 +1011,9 @@ export function streamTypedRefMaterializeAdapter( `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`, ); B.line(`${bag} = load ptr, ptr ${slot}`); - B.line(`call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p)`); + B.line( + `call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p, i1 ${nameAfter === undefined ? "false" : "true"})`, + ); } // A frozen, sealed or non-extensible instance's view says so. if (withAttributes) { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b743aaf0f9..d84bb214d6 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1332,8 +1332,9 @@ ScrDyn *scr_error_dyn_fields(const ScrError *e); void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len); /* An Error subclass view of an instance whose class keeps a property bag: * `name` and `message` take their committed positions, and data values - * from the slots (scr_json.c). */ -void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e); + * from the slots (scr_json.c). `name_placed`: the class's construction sets + * `name`, and the view already has it in place. */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e, bool name_placed); /* An accessor property of a fresh object: enumerable and configurable, * with the given getter and setter (either may be NULL). Moves both. */ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *getter, ScrDyn *setter); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 49db3cf3e5..d583512cac 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8332,21 +8332,26 @@ static bool scr_error_bag_constructed(const ScrDyn *bag, size_t b) { return true; } -/* The view index at which bag entry b (`name` when `name`, else `message`), - * now at `from`, belongs: after the nearest earlier bag property the view - * has. `message` without one goes first. `name` passes over the entries - * Error's constructor creates: the view's class fields, which live in slots - * and not in the bag, follow those and come before an own `name`. Without - * another earlier property, `name` goes before the nearest later one the - * view has, and without either it stays where the view put it. */ +/* The view index at which bag entry b, now at `from`, belongs: after the + * nearest earlier bag property the view has. An entry created after + * construction (any `name` placed here, since no constructor of the class + * sets it, and a `message` that is not one of Error's constructor entries) + * also follows the class fields, which live in slots and not in the bag: it + * passes over the constructor's entries and over a `name` the construction + * set, and without another earlier property goes before the nearest later + * one the view has, or stays where the view put it. A `message` Error's + * constructor created follows the nearest earlier entry, or goes first. */ static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t b, bool name, - size_t from) { + bool name_placed, size_t from) { + bool late = name || !scr_error_bag_constructed(bag, b); for (size_t p = b; p-- > 0;) { - if (name && scr_error_bag_constructed(bag, p)) break; - size_t after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + const ScrDynEntry *anchor = &bag->v.obj.entries[p]; + if (late && scr_error_bag_constructed(bag, p)) break; + if (late && name_placed && anchor->key_len == 4 && memcmp(anchor->key, "name", 4) == 0) continue; + size_t after = scr_dyn_entry_index(view, anchor->key, anchor->key_len); if (after != SIZE_MAX) return after < from ? after + 1 : after; } - if (!name) return 0; + if (!late) return 0; for (size_t p = b + 1; p < bag->v.obj.len; p++) { size_t before = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); if (before != SIZE_MAX) return before > from ? before - 1 : before; @@ -8361,7 +8366,7 @@ static size_t scr_error_bag_place(const ScrDyn *view, const ScrDyn *bag, size_t * so each one present in the bag moves to follow the property it follows * there. A typed write to `name` or `message` reaches only the slot, so a * data property takes the slot's value. */ -void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e) { +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e, bool name_placed) { if (!bag || bag->kind != SCR_DYN_OBJ || view->kind != SCR_DYN_OBJ) return; static const struct { const char *key; size_t len; } slots[] = {{"name", 4}, {"message", 7}}; for (size_t s = 0; s < 2; s++) { @@ -8380,10 +8385,12 @@ void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e const ScrDynEntry *bag_entry = &bag->v.obj.entries[b]; bool slot = (bag_entry->key_len == 4 && memcmp(bag_entry->key, "name", 4) == 0) || (bag_entry->key_len == 7 && memcmp(bag_entry->key, "message", 7) == 0); - if (!slot) continue; + bool name = bag_entry->key_len == 4; + /* A `name` the class's construction sets is in place already. */ + if (!slot || (name && name_placed)) continue; size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); if (from == SIZE_MAX) continue; - size_t to = scr_error_bag_place(view, bag, b, bag_entry->key_len == 4, from); + size_t to = scr_error_bag_place(view, bag, b, name, name_placed, from); if (to == from) continue; ScrDynEntry *entries = view->v.obj.entries; ScrDynEntry entry = entries[from]; diff --git a/tests/corpus/8113-error-class-field-before-name.js b/tests/corpus/8113-error-class-field-before-name.js index 8b40d06da7..e10a9456bc 100644 --- a/tests/corpus/8113-error-class-field-before-name.js +++ b/tests/corpus/8113-error-class-field-before-name.js @@ -1,8 +1,10 @@ "use strict"; -// Error subclasses with a class field, which their instances create before -// an own `name`, and a property bag that a later dynamic definition or -// write commits. Object.keys and JSON list the field before `name`, and a -// `message` that Error's constructor created before both. +// Error subclasses with a property bag that a later dynamic definition or +// write commits. Object.keys and JSON keep the order in which an instance +// created its properties: a class field before an own `name` that is +// assigned after construction, a `name` a constructor assigns before a +// later property, and a `message` that Error's constructor created before +// all of them. const messageDescriptor = { get() { @@ -51,3 +53,26 @@ function redefine(instance) { } const direct = redefine(new Direct()); console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); + +// Constructors that assign `name` and then another property, which their +// instances create in that order, with and without a message passed to +// Error, before the message is redefined. +class Named extends Error { + constructor() { + super(); + this.name = "Named"; + this.detail = "named"; + } +} +const named = redefine(new Named()); +console.log(Object.keys(named).join(","), JSON.stringify(named), named.name); + +class NamedMessage extends Error { + constructor() { + super("constructed"); + this.name = "NamedMessage"; + this.detail = "named"; + } +} +const namedMessage = redefine(new NamedMessage()); +console.log(Object.keys(namedMessage).join(","), JSON.stringify(namedMessage), namedMessage.name); diff --git a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json index 66dce63415..29164fca30 100644 --- a/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json +++ b/tests/harness/corpus-costs/8113-error-class-field-before-name.js.json @@ -1 +1 @@ -{ "plain": 1700, "san": 8600 } +{ "plain": 1900, "san": 9700 } From 0f75f5b95248b8c8fa1f501d597db279d5ead566 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:31:12 -0500 Subject: [PATCH 30/44] Keep the run-time result of overloaded calls with untyped arguments The checker resolves an overloaded call whose argument is `any` to the first overload that argument satisfies, but in JavaScript the value can select another one: generateKeyPairSync("ec", options) returns KeyObjects or PEM strings by the options, and digest(encoding) returns a Buffer when the encoding is undefined. The result was converted to the first overload's return type, so such calls threw a TypeError where Node returns the other overload's value. When a checked-dynamic argument could take another overload whose return type differs (trailing untyped or undefined arguments may also be absent), the call's result in a JavaScript source is checked-dynamic. So are the unannotated bindings it initializes, destructured or not, and the unannotated functions that return it. fs.globSync keeps choosing its result form from the resolved overload, since the runtime checks that withFileTypes agrees with it. --- docs/content/limitations/untyped-values.mdx | 3 + .../src/frontend/lowering/builtins/fs-glob.ts | 11 +- .../src/frontend/lowering/function-returns.ts | 17 ++ .../src/frontend/lowering/lower-calls.ts | 4 + .../compiler/src/frontend/lowering/lowerer.ts | 17 ++ .../src/frontend/lowering/overload-choice.ts | 197 ++++++++++++++++++ .../7453-untyped-overload-results.cjs.json | 6 + .../corpus/7453-untyped-overload-results.cjs | 85 ++++++++ .../7453-untyped-overload-results.cjs.json | 1 + 9 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/overload-choice.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json create mode 100644 tests/corpus/7453-untyped-overload-results.cjs create mode 100644 tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json diff --git a/docs/content/limitations/untyped-values.mdx b/docs/content/limitations/untyped-values.mdx index 6a881aec3d..05245a3968 100644 --- a/docs/content/limitations/untyped-values.mdx +++ b/docs/content/limitations/untyped-values.mdx @@ -15,3 +15,6 @@ JavaScript argument and return conversions preserve native array references; oth Supported operations on `unknown` include truthiness, `typeof` narrowing, property access, `+`, `switch`, and `throw`. Cast before using other operations. + +In JavaScript, a call whose untyped argument could select an overload with another result type returns a checked value, whichever overload the type checker picks, and so do the unannotated bindings and functions that hold or return it. +In TypeScript, an `any` argument keeps the overload the type checker picks, and a value that selects another overload at runtime throws a `TypeError` where the result is used. diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts index f988c204de..4d88cd96e8 100644 --- a/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts +++ b/packages/compiler/src/frontend/lowering/builtins/fs-glob.ts @@ -22,7 +22,10 @@ import { builtinImportOf } from "./module-bindings.js"; * lib/internal/fs/glob.js). The pattern and options cross as dyn values so * the runtime validates them in Node's order. The result form (strings or * Dirent rows) comes from the call's mapped result type, so withFileTypes - * must be statically known; the runtime checks that the option agrees. */ + * must be statically known; the runtime checks that the option agrees. + * Because of that check, the form follows the checker's resolved overload + * even where an untyped options argument leaves the overload undecided + * (dynamicOverloadChoice). */ /** The interned Dirent record array (type-mapper.ts), or null. */ function direntArray(lowerer: Lowerer, type: IrType | null | undefined): boolean { @@ -67,7 +70,7 @@ export function lowerFsGlobSyncCall( loc: SrcLoc, ): IrExpr { const args = globArgs(lowerer, call, "fs.globSync"); - const callType = lowerer.mapTypeOf(lowerer.typeOf(call)); + const callType = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(call)); const types = direntArray(lowerer, callType); if (types) fenceDirentExclude(lowerer, call, "fs.globSync"); if (!types && !(callType?.kind === "array" && callType.elem.kind === "string")) { @@ -132,7 +135,9 @@ export function lowerFspGlobCall(lowerer: Lowerer, call: ts.CallExpression, loc: } const args = globArgs(lowerer, call, "fs/promises glob"); // The async iterator's yielded type (its first type argument). - const yielded = lowerer.checker.getTypeArguments(lowerer.typeOf(call) as ts.TypeReference)[0]; + const yielded = lowerer.checker.getTypeArguments( + lowerer.checker.getTypeAtLocation(call) as ts.TypeReference, + )[0]; const element = yielded ? lowerer.mapTypeOf(yielded) : null; const rows: IrType | null = element ? { kind: "array", elem: element } : null; const types = direntArray(lowerer, rows); diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index dde109ea78..6d0989e2be 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -17,6 +17,7 @@ import { PoisonError, dynFallbackType } from "./lowerer.js"; import { functionCanReturnUndefined } from "../function-completion.js"; import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js"; import type { FnCtx } from "./function-context.js"; +import { returnsGuessedOverloadValue } from "./overload-choice.js"; /** Keep the conservative completion rule shared by return construction * and inference. Nested control flow may still make completion unreachable. */ @@ -256,6 +257,22 @@ export function declaredReturnType( return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ? { kind: "promise", inner: DYN } : DYN; + // A JS function returning a value whose type rests on an undecided + // overload (guessedOverloadValue): the inferred result is the first + // overload's, which the returned value need not have. + if ( + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !decl.type && + !decl.asteriskToken && + !hasExplicitJsDocReturn(decl) && + mappedReturn !== null && + mappedReturn.kind !== "dyn" && + !(mappedReturn.kind === "promise" && mappedReturn.inner.kind === "dyn") && + returnsGuessedOverloadValue(lowerer, decl) + ) + return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ? { kind: "promise", inner: DYN } + : DYN; // JS inference assigns Number to arithmetic over any, although the // runtime operands can both be BigInts. Preserve that native result. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 8ac97fd325..073ebed912 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -260,6 +260,7 @@ import { completeArgs, } from "./call-arguments.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; +import { dynamicOverloadChoice } from "./overload-choice.js"; import { lowerStringIteratorValue } from "./iterator-adapters.js"; import { checkedArmExtraction } from "./coercions/unions.js"; import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; @@ -5506,6 +5507,9 @@ function reconcileOverloadReturn( expr: ts.CallExpression | ts.TaggedTemplateExpression, call: IrExpr, ): IrExpr { + // The checker's overload is a guess for a checked-dynamic argument + // another overload accepts: the implementation's value stands. + if (ts.isCallExpression(expr) && dynamicOverloadChoice(lowerer, expr)) return call; const rsig = lowerer.checker.getResolvedSignature(expr); const rdecl = rsig ? rsig.declaration : undefined; // Nominal covariant overrides share the base's return ABI, but retain diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0d7b89d49c..2fcfc063b7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -434,6 +434,7 @@ import { collectSignatureInner, lambdaSignature, } from "./call-signatures.js"; +import { dynamicOverloadChoice, guessedOverloadIdentifier } from "./overload-choice.js"; import { implicitAnyParamSymbolsOf, implicitLocalFnNodeOf, @@ -2845,6 +2846,8 @@ export class Lowerer { /** Parameters read as `any` in the error-path copy of an error-first * callback. */ readonly anySymbols = new Set(); + /** overload-choice's answers per call, binding and function. */ + readonly dynamicOverloadAnswers = new Map(); /** Error parameters of an error-first callback with a known value in * the copy being lowered: true while it holds an Error, false while it * holds null (staticCondition folds conditions on them). */ @@ -9037,6 +9040,20 @@ export class Lowerer { typeOf(node: ts.Node): ts.Type { if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType(); + // A checked-dynamic argument that another overload could take at run + // time leaves the resolved overload's return a guess, and JS bindings + // initialized with that result hold whatever it was. + if ( + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + dynamicOverloadChoice(this, node) + ) + return this.checker.getAnyType(); + if ( + ts.isIdentifier(node) && + isJsSourceFile(ts.getSourceFileOfNode(node)) && + guessedOverloadIdentifier(this, node) + ) + return this.checker.getAnyType(); if (ts.isPropertyAccessExpression(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { const receiver = node.expression.kind === ts.SyntaxKind.ThisKeyword && this.fnStack.length > 0 diff --git a/packages/compiler/src/frontend/lowering/overload-choice.ts b/packages/compiler/src/frontend/lowering/overload-choice.ts new file mode 100644 index 0000000000..f973ccf2e4 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/overload-choice.ts @@ -0,0 +1,197 @@ +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; +import { typeEquals, type IrType } from "../../ir/ir.js"; +import { isJsSourceFile } from "../program.js"; +import { isThisParameter } from "./call-signatures.js"; +import { hasExplicitJsDocReturn } from "./function-returns.js"; +import { hasJsTypeAnnotation } from "./lower-stmts.js"; + +/** Whether a checked-dynamic argument leaves a JS call's overload + * undecided. + * + * The checker resolves an overloaded call whose argument is `any` (an + * untyped parameter, a value read from one) to the FIRST overload that + * argument satisfies, but the value can match any overload at run time: + * `generateKeyPairSync("ec", options)` answers KeyObjects or encoded + * strings by the options' contents, `digest(encoding)` a Buffer when the + * encoding is undefined. When another overload the call could take at + * run time returns a different type, the resolved overload's return is a + * guess, and the result is checked-dynamic instead. + * + * An overload is reachable when the arguments fit its arity (trailing + * checked-dynamic or `undefined` arguments may be absent) and every + * statically typed argument is assignable to its parameter. Generic + * overloads whose return cannot be mapped without instantiation do not + * count, so the resolved instantiation stands for them. */ +export function dynamicOverloadChoice( + lowerer: Lowerer, + expr: ts.CallExpression | ts.NewExpression, +): boolean { + return cachedAnswer(lowerer, expr, () => computeDynamicOverloadChoice(lowerer, expr)); +} + +/** Whether a JavaScript value's checker type rests on an undecided + * overload (dynamicOverloadChoice): the call itself, an awaited one, a + * call to an unannotated JS function returning such a value, or a read of + * an unannotated JS binding initialized with one (destructured or not). + * Such a value is checked-dynamic wherever the checker's inference would + * otherwise carry the guess into a binding or a return. */ +export function guessedOverloadValue(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)) expr = expr.expression; + if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) { + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + if (dynamicOverloadChoice(lowerer, expr)) return true; + const decl = lowerer.checker.getResolvedSignature(expr)?.declaration; + return ( + decl !== undefined && + (ts.isFunctionDeclaration(decl) || + ts.isFunctionExpression(decl) || + ts.isArrowFunction(decl)) && + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + returnsGuessedOverloadValue(lowerer, decl) + ); + } + return ts.isIdentifier(expr) && guessedOverloadIdentifier(lowerer, expr); +} + +/** Whether a JS identifier, as a binding's declared name or as a read, + * names a guessedOverloadBinding. */ +export function guessedOverloadIdentifier(lowerer: Lowerer, node: ts.Identifier): boolean { + return cachedAnswer(lowerer, node, () => { + const parent = node.parent; + if ( + parent !== undefined && + (ts.isVariableDeclaration(parent) || ts.isBindingElement(parent)) && + parent.name === node + ) + return guessedOverloadBinding(lowerer, parent); + const decl = lowerer.checker.getSymbolAtLocation(node)?.valueDeclaration; + return decl !== undefined && guessedOverloadBinding(lowerer, decl); + }); +} + +/** Whether `decl` is an unannotated JS variable or destructured binding + * whose declaration's initializer is a guessedOverloadValue. */ +function guessedOverloadBinding(lowerer: Lowerer, decl: ts.Node): boolean { + let root: ts.Node | undefined = decl; + while (root && ts.isBindingElement(root) && root.parent && ts.isBindingPattern(root.parent)) { + root = root.parent.parent; + } + if (!root) return false; + if (!ts.isVariableDeclaration(root) || !root.initializer || root.type) return false; + if (!isJsSourceFile(ts.getSourceFileOfNode(root)) || hasJsTypeAnnotation(root)) return false; + const init = root.initializer; + return cachedAnswer(lowerer, root, () => guessedOverloadValue(lowerer, init)); +} + +/** Whether a return of the unannotated JS function `decl` itself is a + * guessedOverloadValue. */ +export function returnsGuessedOverloadValue( + lowerer: Lowerer, + decl: ts.SignatureDeclaration, +): boolean { + if (decl.type || decl.asteriskToken || hasExplicitJsDocReturn(decl)) return false; + return cachedAnswer(lowerer, decl, () => { + if (ts.isArrowFunction(decl) && decl.body && !ts.isBlock(decl.body)) { + return guessedOverloadValue(lowerer, decl.body); + } + let found = false; + const scan = (node: ts.Node): void => { + if (found || (node !== decl && ts.isFunctionLike(node))) return; + if (ts.isReturnStatement(node) && node.expression) { + found = guessedOverloadValue(lowerer, node.expression); + } + ts.forEachChild(node, scan); + }; + scan(decl); + return found; + }); +} + +/** One answer per node; a node asked again while its answer is being + * computed (recursive functions, self-referencing bindings) answers + * false. */ +function cachedAnswer(lowerer: Lowerer, node: ts.Node, compute: () => boolean): boolean { + const cached = lowerer.dynamicOverloadAnswers.get(node); + if (cached !== undefined) return cached; + lowerer.dynamicOverloadAnswers.set(node, false); + const answer = compute(); + lowerer.dynamicOverloadAnswers.set(node, answer); + return answer; +} + +function computeDynamicOverloadChoice( + lowerer: Lowerer, + expr: ts.CallExpression | ts.NewExpression, +): boolean { + // TypeScript sources keep the resolved overload: their reads validate + // the value against it where it is used. + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + const args = expr.arguments?.nodes ?? []; + if (args.length === 0 || args.some(ts.isSpreadElement)) return false; + const dynamic = args.map((arg) => checkedDynamicArgument(lowerer, arg)); + if (!dynamic.includes(true)) return false; + const checker = lowerer.checker; + const calleeType = checker.getTypeAtLocation(expr.expression); + const signatures = ts.isNewExpression(expr) + ? checker.getConstructSignatures(calleeType) + : checker.getCallSignatures(calleeType); + if (signatures.length < 2) return false; + const resolved = checker.getResolvedSignature(expr); + if (!resolved) return false; + const resolvedReturn = lowerer.mapTypeOf(checker.getReturnTypeOfSignature(resolved)); + if (resolvedReturn === null) return false; + // Arguments that may be absent at run time: the trailing run of + // checked-dynamic and `undefined` arguments. + let present = args.length; + while (present > 0 && (dynamic[present - 1] || isUndefinedArgument(args[present - 1]!))) { + present--; + } + for (const signature of signatures) { + const decl = signature.declaration; + if (!decl || decl === resolved.declaration || !ts.isFunctionLike(decl)) continue; + const generic = signature.getTypeParameters().length > 0; + const params = decl.parameters.nodes.filter((param) => !isThisParameter(param)); + const required = params.filter( + (param) => !param.questionToken && !param.initializer && !param.dotDotDotToken, + ).length; + const maximum = params.some((param) => param.dotDotDotToken) ? Infinity : params.length; + if (required > args.length || present > maximum) continue; + const symbols = signature.getParameters(); + let fits = true; + for (let i = 0; i < args.length && i < params.length && fits; i++) { + if (dynamic[i] || generic || params[i]!.dotDotDotToken) continue; + const symbol = symbols[i]; + if (!symbol) continue; + fits = checker.isTypeAssignableTo( + checker.getTypeAtLocation(args[i]!), + checker.getTypeOfSymbol(symbol), + ); + } + if (!fits) continue; + const candidate = lowerer.mapTypeOf(checker.getReturnTypeOfSignature(signature)); + if (candidate !== null && !holdsResult(resolvedReturn, candidate)) return true; + } + return false; +} + +/** Whether a value of the resolved overload's mapped return also holds + * every value of `candidate`: the same type, or a checked-dynamic one (a + * promise of one fulfills with anything). */ +function holdsResult(resolved: IrType, candidate: IrType): boolean { + if (resolved.kind === "dyn" || typeEquals(resolved, candidate)) return true; + return ( + resolved.kind === "promise" && + candidate.kind === "promise" && + holdsResult(resolved.inner, candidate.inner) + ); +} + +function checkedDynamicArgument(lowerer: Lowerer, arg: ts.Expression): boolean { + if (lowerer.checker.getTypeAtLocation(arg).flags & ts.TypeFlags.Any) return true; + return (lowerer.typeOf(arg).flags & ts.TypeFlags.Any) !== 0; +} + +function isUndefinedArgument(arg: ts.Expression): boolean { + return (ts.isIdentifier(arg) && arg.text === "undefined") || ts.isVoidExpression(arg); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json new file mode 100644 index 0000000000..9ba935c1ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7453-untyped-overload-results.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7453-untyped-overload-results.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/7453-untyped-overload-results.cjs b/tests/corpus/7453-untyped-overload-results.cjs new file mode 100644 index 0000000000..21923827d0 --- /dev/null +++ b/tests/corpus/7453-untyped-overload-results.cjs @@ -0,0 +1,85 @@ +// An untyped argument can match any overload of a call at run time. The +// result follows the overload the value selects, through bindings, +// destructuring and function returns. +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ecPair = (options) => crypto.generateKeyPairSync("ec", options); +const objects = ecPair({ namedCurve: "P-256" }); +console.log(typeof objects.privateKey, objects.privateKey.type, objects.publicKey.asymmetricKeyType); +const pem = ecPair({ + namedCurve: "P-256", + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); +console.log(typeof pem.privateKey, pem.publicKey.split("\n")[0]); + +function privateKeyKind(options) { + const pair = crypto.generateKeyPairSync("ec", options); + return typeof pair.privateKey; +} +console.log(privateKeyKind({ namedCurve: "P-384" })); + +function spread(options) { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + ...options, + }); + return [typeof privateKey, Buffer.isBuffer(publicKey)]; +} +console.log(spread({}), spread({ publicKeyEncoding: { type: "spki", format: "der" } })); + +const digest = (encoding) => crypto.createHash("sha256").update("x").digest(encoding); +console.log(typeof digest("hex"), Buffer.isBuffer(digest(undefined)), digest(undefined).length); +const hmac = (encoding) => crypto.createHmac("sha256", "k").update("v").digest(encoding); +console.log(hmac("base64"), Buffer.isBuffer(hmac())); +function digestLength(encoding) { + let value = crypto.createHash("sha1").update("y").digest(encoding); + return Buffer.isBuffer(value) ? `buffer ${value.length}` : `string ${value.length}`; +} +console.log(digestLength(undefined), digestLength("hex")); +async function digestLater(encoding) { + return crypto.createHash("md5").update("z").digest(encoding); +} + +const key = crypto.generateKeyPairSync("ed25519").publicKey; +const exported = (options) => key.export(options); +console.log( + Buffer.isBuffer(exported({ format: "der", type: "spki" })), + typeof exported({ format: "pem", type: "spki" }), + exported({ format: "jwk" }).kty, +); + +const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "overloads-")), "text.txt"); +fs.writeFileSync(file, "hello"); +const read = (options) => fs.readFileSync(file, options); +const text = read("utf8"); +const bytes = read(undefined); +const latin1 = read({ encoding: "latin1" }); +console.log(text, Buffer.isBuffer(bytes), bytes.length, latin1); +console.log(read("utf8"), read({ encoding: "hex" })); + +/** + * @overload + * @param {string} value + * @returns {string} + */ +/** + * @overload + * @param {number} value + * @returns {number} + */ +/** @param {string | number} value */ +function same(value) { + return value; +} +const viaSame = (value) => same(value); +console.log(typeof viaSame("s"), typeof viaSame(3), viaSame(3) + 1); + +(async () => { + const later = await digestLater(undefined); + console.log(Buffer.isBuffer(later), typeof (await digestLater("hex"))); + fs.rmSync(path.dirname(file), { recursive: true }); +})(); diff --git a/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json b/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json new file mode 100644 index 0000000000..46a1181bd0 --- /dev/null +++ b/tests/harness/corpus-costs/7453-untyped-overload-results.cjs.json @@ -0,0 +1 @@ +{ "plain": 2991, "san": 4866 } From 6dfde9919c4cc2f9ef29adf4a77bd1fc2eb3bfca Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:45:37 -0500 Subject: [PATCH 31/44] Keep checked dictionary protocols and invalidate direct calls after method writes --- .../lowering/class-method-mutations.ts | 13 +++++-- .../src/frontend/lowering/class-protocols.ts | 8 ++-- .../test/class-method-mutations.test.ts | 17 +++++++++ .../compiler/test/dictionary-views.test.ts | 12 ++++++ .../7012-dictionary-views-keep-objects.ts | 37 +++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index a07a4d3e12..789ee7a8cb 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -170,14 +170,19 @@ export function collectClassMethodMutations( // A string index whose values cannot be functions excludes callable // method slots. Class instances have no implicit index signature, so such // a dictionary is only a class view when the class declares that index - // signature, and then every method must be assignable to its value type. - const functionFreeDictionary = (value: ts.Expression): boolean => - lowerer.checker - .getIndexInfosOfType(lowerer.typeOf(value)) + // signature, and then every method must be assignable to its value type, + // or when the dictionary keeps checked identity: it then holds the + // existing objects it receives, class instances included. + const functionFreeDictionary = (value: ts.Expression): boolean => { + const type = lowerer.typeOf(value); + if (lowerer.classImplementedProtocol(type)) return false; + return lowerer.checker + .getIndexInfosOfType(type) .some( (index) => (index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && functionFree(index.type), ); + }; const literalLoopKeys = (key: ts.Expression): (string | number)[] | null => { if (!ts.isIdentifier(key)) return null; const symbol = lowerer.checker.getSymbolAtLocation(key); diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 4edf1ab7e8..00eedb6c03 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -983,11 +983,13 @@ export class ClassProtocols { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const previous = this.answers.get(type); if (previous !== undefined) return previous; + // A dictionary keeps checked identity when the protocols already mark + // it (a literal whose methods read `this`) or when it receives existing + // objects (markDictionaryViews). const dictionaryValue = this.dictionaryValueType(type); const answer = - dictionaryValue !== undefined - ? (this.index(), this.dictionaryViews.has(dictionaryValue)) - : this.protocol(type); + this.protocol(type) || + (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)); this.answers.set(type, answer); return answer; } diff --git a/packages/compiler/test/class-method-mutations.test.ts b/packages/compiler/test/class-method-mutations.test.ts index b63e47619a..3e29a1da2c 100644 --- a/packages/compiler/test/class-method-mutations.test.ts +++ b/packages/compiler/test/class-method-mutations.test.ts @@ -178,3 +178,20 @@ test("a store into a generic dictionary of dictionaries keeps checked dispatch", .replace("const settings: Settings =", "const settings: Settings ="); expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]); }); + +test("a runtime-key store into a dictionary view of a class instance keeps checked dispatch", async () => { + const source = ` +class Counter { + n = 0; + tick(): number { return ++this.n; } +} +const counter = new Counter(); +const view: {} = counter; +const slots: Record = view; +const key: string = String(process.argv.length); +slots[key] = "replaced"; +console.log(counter.tick()); +`; + // The method has a slot the write can replace, which calls consult. + expect(await callersOf(source, "%Counter.tick")).toContain("%method.value:%Counter.tick"); +}); diff --git a/packages/compiler/test/dictionary-views.test.ts b/packages/compiler/test/dictionary-views.test.ts index b841447cd4..a9cfd1056e 100644 --- a/packages/compiler/test/dictionary-views.test.ts +++ b/packages/compiler/test/dictionary-views.test.ts @@ -75,3 +75,15 @@ console.log(table["a"]);`), ); expect(kinds["table"]).toBe("record"); }); + +test("a dictionary literal whose methods read this keeps checked identity", async () => { + const kinds = await globalKinds( + program(`interface Table { [key: string]: () => number } +const methods: Table = { + base() { return 2; }, + twice() { return this.base() * 2; }, +}; +console.log(methods.twice());`), + ); + expect(kinds["methods"]).toBe("dyn"); +}); diff --git a/tests/corpus/7012-dictionary-views-keep-objects.ts b/tests/corpus/7012-dictionary-views-keep-objects.ts index 8ede809267..0df47cbb8c 100644 --- a/tests/corpus/7012-dictionary-views-keep-objects.ts +++ b/tests/corpus/7012-dictionary-views-keep-objects.ts @@ -53,9 +53,46 @@ const slots: Record = greeterView; slots[process.argv.length > 99 ? "never" : "greet"] = "replaced"; slots["name"] = "renamed"; console.log(typeof greeter.greet, greeter.name, JSON.stringify(greeter)); +// A write under a runtime string key replaces a method too, and a direct +// call reaches the replacement, which is not a function. +class Counter { + n = 0; + tick(): number { + return ++this.n; + } +} +const counter = new Counter(); +const counterView: {} = counter; +const counterSlots: Record = counterView; +const methodKey: string = process.argv.length > 99 ? "never" : "tick"; +counterSlots[methodKey] = "replaced"; +try { + console.log(counter.tick()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} +try { + console.log(greeter.greet()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} // Deleting through the dictionary removes the key from the object. const pruned: { a: string; b?: string } = { a: "x", b: "y" }; const prunedTable: Record = pruned; delete prunedTable[process.argv.length > 99 ? "never" : "b"]; console.log(JSON.stringify(pruned), "b" in pruned); + +// A dictionary literal whose methods read `this` keeps its live object. +interface Table { + [key: string]: () => number; +} +const table: Table = { + base() { + return 2; + }, + twice() { + return this.base() * 2; + }, +}; +console.log(table.twice(), Object.keys(table).join(",")); From 7784800c8e6d83b962e6164b8e5ba48bc69ea54f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 20:51:00 -0500 Subject: [PATCH 32/44] Prepare v0.4.4 release --- CHANGELOG.md | 27 +++++++++++++++++-- packages/cli-darwin-arm64/package.json | 2 +- packages/cli-darwin-x64/package.json | 2 +- packages/cli-linux-arm64-gnu/package.json | 2 +- packages/cli-linux-arm64-musl/package.json | 2 +- packages/cli-linux-x64-gnu/package.json | 2 +- packages/cli-linux-x64-musl/package.json | 2 +- packages/cli-win32-x64-msvc/package.json | 2 +- packages/cli/package.json | 2 +- packages/compiler/package.json | 2 +- packages/compiler/surface-manifest.json | 2 +- packages/experimental/package.json | 2 +- packages/llvm-darwin-arm64/package.json | 2 +- packages/llvm-darwin-x64/package.json | 2 +- packages/llvm-linux-arm64-gnu/package.json | 2 +- packages/llvm-linux-arm64-musl/package.json | 2 +- packages/llvm-linux-x64-gnu/package.json | 2 +- packages/llvm-linux-x64-musl/package.json | 2 +- packages/llvm-win32-x64-msvc/package.json | 2 +- packages/runtime-android-arm64/package.json | 2 +- packages/runtime-darwin-arm64/package.json | 2 +- packages/runtime-darwin-x64/package.json | 2 +- packages/runtime-ios-arm64/package.json | 2 +- .../runtime-ios-simulator-arm64/package.json | 2 +- packages/runtime-linux-arm64-gnu/package.json | 2 +- .../runtime-linux-arm64-musl/package.json | 2 +- packages/runtime-linux-x64-gnu/package.json | 2 +- packages/runtime-linux-x64-musl/package.json | 2 +- packages/runtime-wasm32-wasi/package.json | 2 +- packages/runtime-win32-x64-msvc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/threads/package.json | 2 +- packages/types/package.json | 2 +- 33 files changed, 57 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f13fa26f..612df47b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ All notable changes to scriptc will be documented in this file. +## 0.4.4 + +### Highlights + +- **Fixes for 0.4.3:** + - Unchanged rebuilds hit the frontend cache again. An unchanged rebuild of a 23-module program takes 93 ms instead of 1.26 s, and of tsc-ts (dev) 1.6 s instead of 58.2 s. + - The native compiler builds programs with several typed-array accesses at one masked index in a loop again, including tsc-ts's release build, which failed with SC3004. + - `x!` returned from a function, used as an arrow function's result, or passed as an argument keeps null or undefined when the destination type holds it, as JavaScript does. `x!` operands of `+`, `+=` and other conversions that hold undefined or null convert as Node does. + - Effect 4 programs whose generators yield clients built by other generators run again. + - The cache for untyped property access in programs that declare classes is reverted, fixing a crash in Effect 4 observability programs. +- **Faster:** + - Number compares and typed-array indexes use the exact integer of sums like `i + 1`: a heap sift-down over an `Int32Array` runs 17.5% faster. + - glibc's per-thread heaps use huge pages: the VS Code type-check runs 3.4% faster. + - Mid-size executables (over 1 MiB of LLVM text) build in at least eight partitions: the LLVM backend phase takes 34 to 74% less time for three.js programs of 1.4 to 7.8 MB. + - JavaScript class instances are boxed at their exact class, and stores into nested string-keyed dictionaries keep class method calls direct. + - `--target-cpu` selects an x86-64 microarchitecture level. +- **Node.js parity:** + - X25519 keys and `crypto.diffieHellman`; `existsSync` and `fs.promises` accept `file:` URLs; `Buffer.from` ranges over ArrayBuffers, the deprecated `Buffer()` constructor and `Buffer` as a value. + - Dates are objects with identity; `Promise.all` over different entry types settles on Node's turns; completion callbacks run in their call's AsyncLocalStorage context. + - TypedArray methods run on typed arrays held as untyped values, and unions of typed-array classes compile. Borrowed `TypedArray.prototype.set` calls compile and throw Node's TypeError for other targets. + - Split CommonJS `require` bindings, `__`-prefixed exports, string-key destructuring of `any` values, and record key order for values JavaScript built. +- **npm packages:** zod 4 and `@hono/zod-validator` values are projected at the declaration boundary. + + + ## 0.4.3 ### Highlights @@ -26,8 +51,6 @@ All notable changes to scriptc will be documented in this file. - **npm packages:** zod 3 compiles natively and zod 4's core runs, minified packages such as marked compile, chalk 5 and hot-shots 8 build from built-in shims, and `@hono/node-server` 2.x is built in. User code types npm package declarations at a checked, identity-preserving boundary. - **Builds:** the cached build is reused after whitespace and comment edits, function bodies are emitted on forked workers, and the LLVM emitter reads its analyses from one recorded walk of each module. - - ## 0.4.2 ### Highlights diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index eee0ec3c80..bac01cc442 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index b527a3cd72..6c2fb3123a 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index 512a7cd137..289d8d6bd5 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 8d0fdfb0fa..3a601ca337 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index b712056142..8f6b1aa5ee 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index eaeb6f15a9..ff0d1d6863 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index 3e3b8259ff..e94ba31df3 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.4.3", + "version": "0.4.4", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 264a7f5f14..26cf10f076 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.4.3", + "version": "0.4.4", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 60f6bd0f0e..570c0a1a5b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.4.3", + "version": "0.4.4", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 75541b8e5c..ea084affea 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "compilerVersion": "0.4.3", + "compilerVersion": "0.4.4", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", diff --git a/packages/experimental/package.json b/packages/experimental/package.json index b3e8a6fb19..583a2bc58a 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/experimental", - "version": "0.4.3", + "version": "0.4.4", "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, and per-thread slots on shared objects", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index ac99f1661e..76d5fdb72d 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index ca632dcd44..fbb755f4ae 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.4.3", + "version": "0.4.4", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 566b38e407..81fdc7d5be 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index a9288c2ca7..8b3b2f1853 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index c55f518472..79b7eba3f8 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index b257085bf3..5bffa5bef2 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index 0f3f993316..14c747a4a6 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.4.3", + "version": "0.4.4", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index 392ef17174..1dfa1b177a 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index 354526024d..32463357f5 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index 58aa358b2b..c0577b04c7 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index 6193c52581..9c21342803 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 7d01c45d35..6b7563a6af 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 419080c3ac..f0ae56929a 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 2bae09884a..79fa8e7dd6 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 6749ddeeae..45af8d2b74 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index 9b48034069..fe9d11bcdd 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index 63a95601d1..ac2f4a5e99 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 01bec7c313..9a5731da28 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.4.3", + "version": "0.4.4", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d367ad6bf1..1d58b00e59 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.4.3", + "version": "0.4.4", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/threads/package.json b/packages/threads/package.json index a37a3ba825..c29967cc67 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.4.3", + "version": "0.4.4", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/types/package.json b/packages/types/package.json index 906c68dec5..db88b4b2fc 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/types", - "version": "0.4.3", + "version": "0.4.4", "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", "license": "Apache-2.0", "homepage": "https://scriptc.dev", From 10e3476a55016309f78a294146f8b7cb0eebb75e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:12:15 -0500 Subject: [PATCH 33/44] Convert checked-dynamic expected errors in assert.throws and assert.match An expected regex or string read from a JavaScript object literal's field is a checked-dynamic value while its checker type is RegExp or string. assert.throws chose its regex comparison from the checker type and passed the value unconverted, an internal compiler error, and assert.match and the expected-object form refused it. Such a value now converts to the regex or string its type names. --- .../src/frontend/lowering/lower-assert.ts | 18 +++++- .../8160-assert-expected-from-fields.cjs.json | 6 ++ .../8160-assert-expected-from-fields.cjs | 58 +++++++++++++++++++ .../8160-assert-expected-from-fields.cjs.json | 1 + 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json create mode 100644 tests/corpus/8160-assert-expected-from-fields.cjs create mode 100644 tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index bc51a93826..b996abed7f 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -1355,7 +1355,7 @@ function lowerAssertMatch( lowerer.noLowering(`${surface} with ${expr.arguments.nodes.length} arguments`, expr); } const s = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); - const re = lowerer.lowerExpr(expr.arguments.nodes[1]!); + const re = lowerExpectedValue(lowerer, expr.arguments.nodes[1]!); if (re.type.kind !== "regex") { lowerer.noLowering( `${surface} with a non-regex pattern`, @@ -1420,6 +1420,18 @@ function inErrorHierarchy(lowerer: Lowerer, className: string): boolean { return false; } +/** An expected string or regex: a value JavaScript stores checked-dynamic + * (an object literal's field, an untyped binding) converts to the string + * or regex its checker type names, so the comparison takes that arm. */ +function lowerExpectedValue(lowerer: Lowerer, node: ts.Expression): IrExpr { + const value = lowerer.lowerExpr(node); + if (value.type.kind !== "dyn") return value; + const expected = lowerer.mapTypeOf(lowerer.typeOf(node)); + return expected?.kind === "string" || expected?.kind === "regex" + ? lowerer.coerceToExpected(value, expected) + : value; +} + /** Classify the expected argument: string → message, regex value → the * regex test over String(error), an error-class identifier → instanceof, * an object LITERAL over name/message/code (string or regex values) → @@ -1436,7 +1448,7 @@ function classifyThrowsExpected( return { form: "message", msg: m.msg, hasMsg: m.hasMsg }; } if (expectedT?.kind === "regex") { - return { form: "regex", value: lowerer.lowerExpr(node) }; + return { form: "regex", value: lowerExpectedValue(lowerer, node) }; } const sym = ts.isIdentifier(node) ? lowerer.resolveValueSymbol(node) : null; const info = @@ -1509,7 +1521,7 @@ function classifyThrowsExpected( ); } const valueNode = ts.isPropertyAssignment(named) ? named.initializer : named.name; - const value = lowerer.lowerExpr(valueNode); + const value = lowerExpectedValue(lowerer, valueNode); if (value.type.kind === "string") { byId.set(id, { id, kind: "str", value, enameBake: null }); } else if (value.type.kind === "regex") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json new file mode 100644 index 0000000000..fcaacbbee0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8160-assert-expected-from-fields.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8160-assert-expected-from-fields.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/8160-assert-expected-from-fields.cjs b/tests/corpus/8160-assert-expected-from-fields.cjs new file mode 100644 index 0000000000..4c16bd78f5 --- /dev/null +++ b/tests/corpus/8160-assert-expected-from-fields.cjs @@ -0,0 +1,58 @@ +// Expected errors stored in an object literal's fields: a JavaScript +// object keeps its fields checked-dynamic, and assert.throws, assert.match +// and the expected-object form convert them to the regex or string the +// fields hold. +"use strict"; +const assert = require("assert"); + +const errMessages = { + auth: / auth/, + state: / state/, + length: /Invalid initialization vector/, + code: "ERR_CRYPTO_INVALID_IV", +}; + +const error = (message, code) => { + const e = new Error(message); + if (code) e.code = code; + return e; +}; + +assert.throws(() => { + throw error("Unsupported state or unable to authenticate data"); +}, errMessages.auth); +assert.throws(() => { + throw error("Invalid state for operation getAuthTag"); +}, errMessages.state); +assert.throws( + () => { + throw error("Invalid initialization vector", "ERR_CRYPTO_INVALID_IV"); + }, + { code: errMessages.code, message: errMessages.length }, +); +assert.match("unable to auth", errMessages.auth); +assert.doesNotMatch("no match here", errMessages.state); + +const report = (label, run) => { + try { + run(); + console.log(label, "passed"); + } catch (error) { + console.log(label, error.name, error.code, error.message.split("\n")[0]); + } +}; +report("wrong message", () => + assert.throws(() => { + throw error("something else"); + }, errMessages.length), +); +report("wrong code", () => + assert.throws( + () => { + throw error("Invalid initialization vector", "ERR_OTHER"); + }, + { code: errMessages.code }, + ), +); +report("match", () => assert.match("plain text", errMessages.auth)); +console.log("done"); diff --git a/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json b/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json new file mode 100644 index 0000000000..cdfeabea92 --- /dev/null +++ b/tests/harness/corpus-costs/8160-assert-expected-from-fields.cjs.json @@ -0,0 +1 @@ +{ "plain": 2656, "san": 5234 } From 995f3458279e9f08a6efe78fd99a8c19e697e612 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 19:15:31 -0700 Subject: [PATCH 34/44] Record corpus 8115's CI cost Co-Authored-By: Claude Opus 5.5 --- tests/harness/corpus-costs/8115-error-prototype-identity.js.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/harness/corpus-costs/8115-error-prototype-identity.js.json diff --git a/tests/harness/corpus-costs/8115-error-prototype-identity.js.json b/tests/harness/corpus-costs/8115-error-prototype-identity.js.json new file mode 100644 index 0000000000..57bb2b2801 --- /dev/null +++ b/tests/harness/corpus-costs/8115-error-prototype-identity.js.json @@ -0,0 +1 @@ +{ "plain": 5600, "san": 5100 } From 61647a8e40d2455cd859146d88745b7cad8a3fce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:20:57 -0500 Subject: [PATCH 35/44] Read typed-array elements through non-null assertions as undefined where Node does `t[i]!` on a typed array always took the numeric element read, which is NaN for an index outside the array. Node reads undefined there, so `t[n]! === t[n]!` was false instead of true, `${t[n]!}` was "NaN", and `typeof t[n]!` was "number". `t[i]!` now takes the numeric read only where its use converts the value to a number. Where the use tells undefined from NaN (an equality, a template span, `+` with a string, a comparison with a string, `!`, `typeof`, an array literal element, or an argument whose parameter admits undefined, such as `Object.is` and `String`), it reads undefined for an invalid index, like `t[i]`. Returns and plain bindings keep the numeric read. Corpus 7394. --- .../lowering/expressions/element-access.ts | 14 +++-- .../src/frontend/lowering/lower-exprs.ts | 25 +++++++- .../7394-non-null-typed-array-reads.ts.json | 6 ++ .../corpus/7394-non-null-typed-array-reads.ts | 59 +++++++++++++++++++ .../7394-non-null-typed-array-reads.ts.json | 1 + 5 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json create mode 100644 tests/corpus/7394-non-null-typed-array-reads.ts create mode 100644 tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 0f22a96220..9ac6a04b68 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -45,6 +45,7 @@ import { lowerDynMemberAssignment, lowerOptionalArrayIndexRead, lowerOptionalNumber, + assertedReadObservesUndefined, recordKeyResultOk, representedClassFieldTarget, runtimeOptionalElementKey, @@ -381,13 +382,18 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre if (index.type.kind !== "f64") { lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); } - // `t[i]!` asserts presence: the numeric element read (NaN when - // invalid). Every other read answers undefined for an invalid index, - // like Node. + // `t[i]!` performs no runtime check, but its static type is a number: + // it takes the numeric element read (NaN when invalid), except where + // the value reaches a use that tells undefined from NaN (an equality, a + // string conversion, `typeof`, an array element), which reads undefined + // for an invalid index like `t[i]`. Every other read answers undefined + // for an invalid index, like Node. // A read whose every consumer treats undefined like NaN (see // nan-coded-reads.ts) is the same plain numeric read. if ( - (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + (ts.isNonNullExpression(expr.parent) && + expr.parent.expression === expr && + !assertedReadObservesUndefined(lowerer, expr.parent)) || lowerer.nanCodedReads?.isNumericRead(expr) === true ) { return { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index f2b6016192..033f482d13 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1024,13 +1024,14 @@ function assertionOperand( ) ) return null; - // Array and typed-array element reads keep their own handling of a - // missing element. + // Array element reads keep their own handling of a missing element. A + // typed-array read is optional here only where its use tells undefined + // from NaN (assertedReadObservesUndefined). let operand: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(operand)) operand = operand.expression; if (ts.isElementAccessExpression(operand)) { const kind = lowerer.mapTypeOf(lowerer.typeOf(operand.expression))?.kind; - if (kind === "array" || kind === "bytes") return null; + if (kind === "array") return null; } const use = convertingUseOf(lowerer, expr); if (use === null) return null; @@ -1047,6 +1048,7 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; const parent = outer.parent; if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "value" : null; + if (ts.isTypeOfExpression(parent)) return "value"; if (ts.isPrefixUnaryExpression(parent)) { if (parent.operator === ts.SyntaxKind.ExclamationToken) return "value"; return parent.operator === ts.SyntaxKind.MinusToken || @@ -1143,6 +1145,23 @@ const NUMERIC_COMPOUNDS = new Set([ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ]); +/** Whether the use of `t[i]!` tells undefined from NaN: a converting use + * that takes the value itself (see convertingUseOf), or a destination that + * holds a missing element (see assertedElementKeepsUndefined) other than a + * return, whose slot has the function's number type. */ +export function assertedReadObservesUndefined( + lowerer: Lowerer, + node: ts.NonNullExpression, +): boolean { + if (convertingUseOf(lowerer, node) === "value") return true; + let outer: ts.Expression = node; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + if (ts.isReturnStatement(parent) || (ts.isArrowFunction(parent) && parent.body === outer)) + return false; + return assertedElementKeepsUndefined(lowerer, node); +} + export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json new file mode 100644 index 0000000000..5fbae0c10b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7394-non-null-typed-array-reads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7394-non-null-typed-array-reads.ts b/tests/corpus/7394-non-null-typed-array-reads.ts new file mode 100644 index 0000000000..6f1ec98080 --- /dev/null +++ b/tests/corpus/7394-non-null-typed-array-reads.ts @@ -0,0 +1,59 @@ +// Non-null assertions on typed-array element reads. `!` performs no +// runtime check: an index outside the array reads undefined, as in Node, +// wherever the use tells undefined from NaN (equality, string conversion, +// typeof, Object.is, an array element), and NaN where it converts the value +// to a number. + +const int32 = new Int32Array([1, -2, 3]); +const float64 = new Float64Array([1.5, NaN, -0]); +const uint8 = new Uint8Array([7, 255]); + +function show(v: unknown): string { + return Object.is(v, -0) ? "-0" : String(v); +} + +function line(label: string, parts: unknown[]): void { + console.log(label.padEnd(12), parts.map(show).join(" ")); +} + +for (const k of [0, 1, 2, 3, -1, 1.5]) { + line(`int32 ${k}`, [ + int32[k]! === int32[k]!, + int32[k]! !== int32[0]!, + int32[k]! === undefined, + int32[k]! == null, + Object.is(int32[k]!, undefined), + typeof int32[k]!, + `${int32[k]!}`, + "v=" + int32[k]!, + String(int32[k]!), + int32[k]! + 1, + int32[k]! < 2, + [int32[k]!].length, + ]); + line(`float64 ${k}`, [ + float64[k]! === float64[k]!, + float64[k]! === undefined, + Object.is(float64[k]!, NaN), + typeof float64[k]!, + `${float64[k]!}`, + float64[k]! * 2, + -float64[k]!, + ]); + line(`uint8 ${k}`, [ + uint8[k]! === uint8[k]!, + uint8[k]! == undefined, + typeof uint8[k]!, + `${uint8[k]!}`, + uint8[k]! | 0, + uint8[k]! > 100, + ]); +} + +const optional: number | undefined = uint8[5]!; +const listed = [int32[9]!, float64[0]!]; +line("slots", [optional, listed[0], listed[1], listed.length]); + +let total = 0; +for (let i = 0; i <= int32.length; i++) total += int32[i]!; +line("sum", [total]); diff --git a/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json b/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json new file mode 100644 index 0000000000..ad10beaaab --- /dev/null +++ b/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 3900 } From 43b0f290119f12b92ed0a021efc1a5adc04aa91f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:34:14 -0500 Subject: [PATCH 36/44] Guard direct calls after dot-property method writes through dictionary views --- .../lowering/class-method-mutations.ts | 18 +++++++++++++++- .../test/class-method-mutations.test.ts | 16 ++++++++++++++ .../corpus/7013-dictionary-view-parameter.ts | 21 ++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index 789ee7a8cb..2c792c4d2c 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -173,6 +173,19 @@ export function collectClassMethodMutations( // signature, and then every method must be assignable to its value type, // or when the dictionary keeps checked identity: it then holds the // existing objects it receives, class instances included. + // A string-keyed dictionary that keeps checked identity holds the + // existing objects it receives, class instances included, so any write + // through it can replace a method. + const dictionaryView = (value: ts.Expression): boolean => { + const type = lowerer.typeOf(value); + return ( + lowerer.checker.getPropertiesOfType(type).length === 0 && + lowerer.checker + .getIndexInfosOfType(type) + .some((index) => (index.keyType.flags & ts.TypeFlags.StringLike) !== 0) && + lowerer.classImplementedProtocol(type) + ); + }; const functionFreeDictionary = (value: ts.Expression): boolean => { const type = lowerer.typeOf(value); if (lowerer.classImplementedProtocol(type)) return false; @@ -582,7 +595,10 @@ export function collectClassMethodMutations( ); for (const { target, site } of propertyWrites) { const name = target.name.text; - if (functionFree(lowerer.typeOf(target)) || !methodOwnerCanInhabit(target.expression, name)) + if ( + !dictionaryView(target.expression) && + (functionFree(lowerer.typeOf(target)) || !methodOwnerCanInhabit(target.expression, name)) + ) continue; if (lowerer.prototypeMethodAccesses.has(name)) lowerer.noteMethodSite(name, site); else lowerer.noteMethodAccess(name, site); diff --git a/packages/compiler/test/class-method-mutations.test.ts b/packages/compiler/test/class-method-mutations.test.ts index 3e29a1da2c..166b9d5560 100644 --- a/packages/compiler/test/class-method-mutations.test.ts +++ b/packages/compiler/test/class-method-mutations.test.ts @@ -195,3 +195,19 @@ console.log(counter.tick()); // The method has a slot the write can replace, which calls consult. expect(await callersOf(source, "%Counter.tick")).toContain("%method.value:%Counter.tick"); }); + +test("a dot store into a dictionary view of a class instance keeps checked dispatch", async () => { + const source = ` +class Lamp { + on = false; + toggle(): boolean { this.on = !this.on; return this.on; } +} +const lamp = new Lamp(); +const view: {} = lamp; +const slots: Record = view; +slots.toggle = "replaced"; +console.log(lamp.toggle()); +`; + // The method has a slot the write can replace, which calls consult. + expect(await callersOf(source, "%Lamp.toggle")).toContain("%method.value:%Lamp.toggle"); +}); diff --git a/tests/corpus/7013-dictionary-view-parameter.ts b/tests/corpus/7013-dictionary-view-parameter.ts index 859f22c235..2bfc34a760 100644 --- a/tests/corpus/7013-dictionary-view-parameter.ts +++ b/tests/corpus/7013-dictionary-view-parameter.ts @@ -1,6 +1,7 @@ // A record passed to a `{}` parameter and converted to a dictionary inside // the function is the same object: the write through the dictionary -// reaches the caller's record. +// reaches the caller's record. A dot write through a dictionary view of a +// class instance replaces its method. const key = process.argv.length > 99 ? "never" : "added"; function stamp(value: {}): void { const table: Record = value; @@ -9,3 +10,21 @@ function stamp(value: {}): void { const record: { a: string } = { a: "x" }; stamp(record); console.log(JSON.stringify(record), key in record); + +// A dot write through the dictionary view replaces a method too. +class Lamp { + on = false; + toggle(): boolean { + this.on = !this.on; + return this.on; + } +} +const lamp = new Lamp(); +const lampView: {} = lamp; +const lampSlots: Record = lampView; +lampSlots.toggle = "replaced"; +try { + console.log(lamp.toggle()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} From e4079abd4110ddbb72fcc66e49016e9abc87f732 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:39:03 -0500 Subject: [PATCH 37/44] Keep swap under the test memory cap and continue past OOM kills --- .github/workflows/ci.yml | 11 +++++++---- tests/harness/ci-test-plan.test.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab1d93312..26a38482ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,14 +216,17 @@ jobs: # Start the corpus immediately beside a serial side queue. One shared # worker budget covers both processes; file and case shard axes stay # separate so every fixture still runs exactly once in each lane. - # The tests run in a transient scope capped below the runner's 16 GB: - # past 13 GB the kernel kills the scope's largest process, so the job - # log names the test instead of the runner itself running out of memory. + # The tests run in a transient scope capped below the runner's 16 GB, so + # the runner itself keeps memory: past 14 GB of RAM the scope swaps, as + # the whole runner did before (a sanitized shard peaks near 16 GB), and + # when RAM and swap run out the kernel kills the scope's largest process. + # OOMPolicy=continue leaves the rest of the scope running, so vitest + # reports the killed worker and the job log names the test. - name: Tests (corpus and integration suites) run: | ulimit -c 0 if [ -d /run/systemd/system ]; then - exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=13G -p MemorySwapMax=0 \ + exec sudo --preserve-env systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue \ --uid="$(id -u)" --gid="$(id -g)" -- env PATH="$PATH" HOME="$HOME" node scripts/ci-test.mjs fi node scripts/ci-test.mjs diff --git a/tests/harness/ci-test-plan.test.ts b/tests/harness/ci-test-plan.test.ts index 9ff730ddff..dd77039ca9 100644 --- a/tests/harness/ci-test-plan.test.ts +++ b/tests/harness/ci-test-plan.test.ts @@ -298,7 +298,7 @@ test("pull requests run the full suite on Linux and the darwin suite on macOS", // runner, and directly elsewhere. const linuxRun = linux.steps.at(-1)!.run as string; expect(linuxRun.startsWith("ulimit -c 0\n")).toBe(true); - expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=13G -p MemorySwapMax=0"); + expect(linuxRun).toContain("systemd-run --scope --quiet -p MemoryMax=14G -p OOMPolicy=continue"); expect(linuxRun).toMatch(/-- env PATH="\$PATH" HOME="\$HOME" node scripts\/ci-test\.mjs\n/); expect(linuxRun.endsWith("fi\nnode scripts/ci-test.mjs\n")).toBe(true); expect(macos.steps.at(-1)!.run).toBe("node scripts/ci-test.mjs"); From 96dbc6204f26ac380dfa253742812c88d2af2096 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:48:25 -0500 Subject: [PATCH 38/44] Keep int32 class fields beside Buffer.from over an ArrayBuffer range Buffer.from(arrayBuffer, byteOffset[, length]) lowers to buffer.poolFromDynRange with its operands as checked values. The int32 slot analysis treats any runtime helper with a checked-value argument as a possible store into a live class instance unless the helper is listed as read-only, so one such call dropped every int32 class field fact in the program (tsc-ts's scanner makes one). buffer.poolFromDynRange and buffer.poolFromDynOffset read their operands (the ArrayBuffer's view after Node's offset and length conversions, or a copy of a string's, array-like's or typed array's values) and store into no class instance, so they join DYN_READ_ONLY. --- packages/compiler/src/ir/int32-slots.test.ts | 46 ++++++++++++++++++++ packages/compiler/src/ir/int32-slots.ts | 5 +++ 2 files changed, 51 insertions(+) diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index be933ab385..9b838bb55c 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -357,6 +357,52 @@ test("an Error subclass constructor forwarding dynamic arguments stores no class expect(slots.isField("C", "a")).toBe(true); }); +test("Buffer.from over an ArrayBuffer range and Buffer as a value keep class fields int32", () => { + // `Buffer.from(view.buffer, offset, length)` passes its operands as + // checked values, and a program that reads the Buffer function's value + // installs it in %main; neither stores into a class instance. + const classes = [cls("C", ["a"])]; + const view: IrExpr = { loc, kind: "dynFrom", value: ref("o", obj("C")), type: DYN }; + const libCall = (fnName: string, args: IrExpr[], type: IrType = VOID): IrStmt => + stmt({ loc, kind: "libCall", fn: fnName, args, type } as IrExpr); + const slots = analyzeInt32Slots( + mod(classes, [ + fn( + "load", + [ + { id: "o", type: obj("C") }, + { id: "storage", type: DYN }, + ], + [ + set(ref("o", obj("C")), "C", "a", num(1)), + libCall("dyn.typedRefIs", [ + view, + { loc, kind: "strLit", value: "object:C", type: STRING }, + ]), + libCall("buffer.installValue", []), + libCall("buffer.constructor", [], DYN), + libCall( + "buffer.poolFromDynRange", + [ref("storage", DYN), num(2), num(4)].map((arg): IrExpr => + arg.type.kind === "dyn" ? arg : { loc, kind: "dynFrom", value: arg, type: DYN }, + ), + ), + libCall("buffer.poolFromDynOffset", [ + ref("storage", DYN), + { + loc, + kind: "dynFrom", + value: num(2), + type: DYN, + }, + ]), + ], + ), + ]), + ); + expect(slots.isField("C", "a")).toBe(true); +}); + test("captured bindings share one proof across the declaring function and its closures", () => { const closure: IrExpr = { loc, kind: "closure", fnName: "inner", captures: ["s"], type: DYN }; const outer = (): IrFunction => ({ diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index a359a18c53..2c26fcf0fd 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -451,6 +451,11 @@ const DYN_READ_ONLY = new Set([ "fs.utimesSync", // Buffer.prototype members read as values: a lookup by key. "buffer.prototypeMember", + // Buffer.from(value, offset[, length]) over checked values: an + // ArrayBuffer's view after Node's offset and length conversions, or a copy + // of a string, array-like or typed array's values. + "buffer.poolFromDynOffset", + "buffer.poolFromDynRange", "insp.dyn", "insp.dynS", // The dynamic-tier operators run their dyn twins (scr_js_value.c); the From 52e84ad3b44e81e8d526d8f9291cf6d442a30f7b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:57:53 -0500 Subject: [PATCH 39/44] Parse URL hosts and paths as the URL Standard does, and inspect URLs as Node does --- docs/content/limitations/builtin-apis/url.mdx | 3 +- .../src/generated/node-v24-compatibility.json | 6 +- .../generated/node-v24-internal.json | 8 +- .../ambient/scriptc-node-fallback.d.ts | 18 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-inspect.ts | 15 + .../src/frontend/lowering/surfaces.ts | 1 + packages/compiler/src/frontend/type-mapper.ts | 10 +- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/surface-manifest.json | 6 + .../8101-url-host-and-path-parsing.js.json | 6 + ...102-url-path-to-file-url-unc-hosts.js.json | 6 + .../tests/corpus/8103-url-inspect.ts.json | 6 + .../corpus/8104-url-to-http-options.ts.json | 6 + packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_inspect.c | 60 ++++ packages/runtime/src/scr_url.c | 299 ++++++++++++++---- packages/runtime/src/scr_url.h | 6 + .../corpus/8101-url-host-and-path-parsing.js | 103 ++++++ .../8102-url-path-to-file-url-unc-hosts.js | 36 +++ tests/corpus/8103-url-inspect.ts | 19 ++ tests/corpus/8104-url-to-http-options.ts | 21 ++ 23 files changed, 575 insertions(+), 71 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8101-url-host-and-path-parsing.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8102-url-path-to-file-url-unc-hosts.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8103-url-inspect.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8104-url-to-http-options.ts.json create mode 100644 tests/corpus/8101-url-host-and-path-parsing.js create mode 100644 tests/corpus/8102-url-path-to-file-url-unc-hosts.js create mode 100644 tests/corpus/8103-url-inspect.ts create mode 100644 tests/corpus/8104-url-to-http-options.ts diff --git a/docs/content/limitations/builtin-apis/url.mdx b/docs/content/limitations/builtin-apis/url.mdx index 6345e26169..c9eee85b3e 100644 --- a/docs/content/limitations/builtin-apis/url.mdx +++ b/docs/content/limitations/builtin-apis/url.mdx @@ -1,4 +1,4 @@ -- **URL:** Non-ASCII or percent-escaped hosts are unsupported. +- **URL:** Hosts of special URLs that hold non-ASCII text, or percent-escapes that decode to it, throw `ERR_INVALID_URL`, and `xn--` labels are kept without Punycode validation. Absolute inputs and relative inputs with runtime string or URL bases, including optional bases, compile. `URL.canParse`, `URL.parse`, `toString()`, and `toJSON()` compile; the factories use the same parser as construction, returning `false` or `null` for rejected inputs while preserving conversion errors. `href`, `protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash` support assignment in native code. @@ -6,4 +6,3 @@ Native URL assignments require statically known property keys; `+=` accepts primitive operands, while other assignment operators and object operands for `+=` are refused. `origin` is read-only. `searchParams` is a live view whose identity survives query and URL replacement. - Opaque paths retain their input bytes without WHATWG C0 encoding. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 1039bc3d8f..2e30804fb2 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -71509,9 +71509,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index b3d91e4534..72b79d8014 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -99483,6 +99483,7 @@ "evidence": "derived:descendants", "tests": [ "packages/runtime/test/url-legacy.test.ts", + "tests/corpus/1794-searchparams-url-live.ts", "tests/corpus/5220-url-parse-warnings.ts", "tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", @@ -100741,8 +100742,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:url" + "status": "partial", + "evidence": "surface-manifest:node-builtin.url.urlToHttpOptions", + "tests": [ + "tests/corpus/1794-searchparams-url-live.ts" + ] }, "anchorSource": "exact" }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b97548e3b2..0f2466798d 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2796,8 +2796,9 @@ declare var URLSearchParams: { * slashes, non-empty host); pathToFileURL resolves the path and percent- * encodes it into a file: URL. The legacy API: parse() answers a Url * object, format() serializes a Url-shaped object, a string, or a URL, - * resolve() resolves one URL string against another, and the domain pair - * converts a hostname to and from its Punycode form. */ + * resolve() resolves one URL string against another, the domain pair + * converts a hostname to and from its Punycode form, and urlToHttpOptions + * builds http.request options from a URL. */ declare module "url" { export type URL = globalThis.URL; export type URLSearchParams = globalThis.URLSearchParams; @@ -2865,6 +2866,19 @@ declare module "url" { export function resolve(from: string, to: string): string; export function domainToASCII(domain: string): string; export function domainToUnicode(domain: string): string; + /** urlToHttpOptions's null-prototype request options. */ + export interface UrlHttpOptions { + protocol: string; + hostname: string; + hash: string; + search: string; + pathname: string; + path: string; + href: string; + port?: number; + auth?: string; + } + export function urlToHttpOptions(url: URL): UrlHttpOptions; } declare module "node:url" { export * from "url"; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 21f32431c1..58f7f61281 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -732,6 +732,7 @@ export const LIB_FN_SYMS: Record = { "punycode.ucs2Encode": "scr_str_from_code_point", "punycode.warn": "scr_punycode_warn", "url.domainToUnicode": "scr_url_domain_to_unicode", + "url.toHttpOptions": "scr_url_to_http_options_dyn", "url.pathToFileURL": "scr_url_from_path", "url.pathToFileURLPlatform": "scr_url_from_path_platform", "url.pathToFileURLWin32": "scr_url_from_path", diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 82cdeb3cef..b4e9e1442c 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -857,7 +857,10 @@ function inspectSupport( } // Generator objects print through their checked capsule: Node's // `Object [Generator] {}`, or the tag of the iterator they implement. + // URLs and URLSearchParams print through their handles. case "generator": + case "url": + case "searchParams": return null; default: return `'${lowerer.fmt(t)}' values have no inspect lowering`; @@ -957,6 +960,18 @@ export function inspectExpr( }; case "dyn": return { kind: "libCall", fn: "insp.dyn", args: [value, recurse, depth], type: STRING, loc }; + // URLs and URLSearchParams render through their checked-dynamic + // handles: Node's `URL { href: ..., hash: ... }` and + // `URLSearchParams { 'a' => '1' }`. + case "url": + case "searchParams": + return { + kind: "libCall", + fn: "insp.dyn", + args: [lowerer.coerceToExpected(value, DYN), recurse, depth], + type: STRING, + loc, + }; case "generator": return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index d133eeb43d..c0ffd142e8 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1681,6 +1681,7 @@ export const BUILTIN_MODULE_FNS: Record< resolve: { fn: "url.legacyResolve", params: [DYN, DYN], result: STRING }, domainToASCII: { fn: "url.domainToASCII", params: [DYN], result: STRING }, domainToUnicode: { fn: "url.domainToUnicode", params: [DYN], result: STRING }, + urlToHttpOptions: { fn: "url.toHttpOptions", params: [DYN], result: DYN }, }, child_process: { // spawnSync's and spawn's call completions are entirely special-cased diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index fc159116f8..161f73d27b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1431,16 +1431,18 @@ let mapTypeDepth = 0; let contextResolutions = 0; /** node:url's legacy record interfaces: url.parse()'s Url instance (and - * its two query-typed refinements), url.format()'s UrlObject input, and the - * URL form's URLFormatOptions. The values are checked-dynamic objects — - * Node's Url instance carries its class name and null fields, and format() - * reads any object's fields with JS truthiness. */ + * its two query-typed refinements), url.format()'s UrlObject input, the + * URL form's URLFormatOptions, and urlToHttpOptions's result. The values + * are checked-dynamic objects — Node's Url instance carries its class name + * and null fields, format() reads any object's fields with JS truthiness, + * and urlToHttpOptions answers a null-prototype object. */ const LEGACY_URL_TYPES = new Set([ "Url", "UrlObject", "UrlWithParsedQuery", "UrlWithStringQuery", "URLFormatOptions", + "UrlHttpOptions", ]); export function isLegacyUrlType( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index ce822df1a0..4bc198df27 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -753,6 +753,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "url.legacyResolve", "url.domainToASCII", "url.domainToUnicode", + // ERR_INVALID_ARG_TYPE and the credentials' URIError. + "url.toHttpOptions", // The win32-target flavor of pathToFileURL (same runtime entry point — // the bridge dispatches by the binary's platform): Node's win32 arm // raises ERR_INVALID_ARG_VALUE TypeErrors for malformed UNC inputs, so diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 37d9769e04..3f59f71970 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1028,6 +1028,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "url.legacyResolve": { argTypes: [DYN, DYN, BOOL], result: STRING }, "url.domainToASCII": { argTypes: [DYN, BOOL], result: STRING }, "url.domainToUnicode": { argTypes: [DYN, BOOL], result: STRING }, + /** url.urlToHttpOptions over a checked-dynamic argument: a URL answers + * Node's null-prototype options object; a primitive or null throws + * ERR_INVALID_ARG_TYPE. */ + "url.toHttpOptions": { argTypes: [DYN], result: DYN }, /** node:punycode (scr_url.c): punycode.js 2.1.0's string conversions; * decode/toUnicode (and, past 32-bit deltas, encode/toASCII) throw the * module's RangeErrors, ucs2Encode String.fromCodePoint's. warn marks the diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 75541b8e5c..5876c42898 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -2469,6 +2469,12 @@ "name": "url.resolve", "status": "static" }, + { + "id": "node-builtin.url.urlToHttpOptions", + "kind": "node-builtin", + "name": "url.urlToHttpOptions", + "status": "static" + }, { "id": "node-builtin.util", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8101-url-host-and-path-parsing.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8101-url-host-and-path-parsing.js.json new file mode 100644 index 0000000000..d38f437bc7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8101-url-host-and-path-parsing.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8101-url-host-and-path-parsing.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8102-url-path-to-file-url-unc-hosts.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8102-url-path-to-file-url-unc-hosts.js.json new file mode 100644 index 0000000000..987c725782 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8102-url-path-to-file-url-unc-hosts.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8102-url-path-to-file-url-unc-hosts.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8103-url-inspect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8103-url-inspect.ts.json new file mode 100644 index 0000000000..06632e9e75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8103-url-inspect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8103-url-inspect.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8104-url-to-http-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8104-url-to-http-options.ts.json new file mode 100644 index 0000000000..aedf0a20c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8104-url-to-http-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8104-url-to-http-options.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index bd76c12e26..469c550ab8 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1615,6 +1615,10 @@ typedef struct ScrDynHandleOps { /* Past the depth limit util.inspect prints `[cls]` (the class's * inspector returns `this`) instead of the bare class name. */ bool inspect_bracketed_past_depth; + /* The class's inspector formats its view with a new util.inspect call + * (URL's does): the view renders from the top level with the caller's + * depth, and its lines take the caller's indentation. */ + bool inspect_view_nested; /* The @@toStringTag when it differs from cls: String() answers * "[object ]" and util.inspect prints ` [] `, or * ` ` when the tag ends with "." and cls, as Node does. */ diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 011a1f5826..f5e4293741 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1495,6 +1495,54 @@ ScrStr *scr_insp_bytes(ScrBytes *b, double recurse, double depth) { return scr_insp_typed_array(b, recurse, depth); } +/* Node's URLSearchParams inspector: [Object] past the depth limit, and + * otherwise each pair as `name => value` with both strings inspected, on + * one line, or one pair per line when the pairs' text is longer than + * breakLength (the closing brace stays on the last pair's line). The lines + * take the caller's indentation, as a custom inspector's text does. */ +static ScrStr *scr_insp_search_params(ScrSearchParams *sp, double recurse, double depth) { + if (recurse > depth) return scr_str_new("[Object]", 8); + size_t n = (size_t)scr_sp_size(sp); + if (n == 0) return scr_str_new("URLSearchParams {}", 18); + ScrStr **items = malloc(n * sizeof *items); + if (!items) insp_oom(); + double length = -2; + for (size_t i = 0; i < n; i++) { + ScrStr *name = scr_sp_key_at(sp, (double)i); + ScrStr *value = scr_sp_val_at(sp, (double)i); + ScrStr *name_text = scr_insp_str(name); + ScrStr *value_text = scr_insp_str(value); + InspBuf item = {0}; + ib_str(&item, name_text); + ib_cstr(&item, " => "); + ib_str(&item, value_text); + items[i] = ib_take(&item); + length += (double)insp_utf16_len(items[i]->data, items[i]->len) + 2; + scr_str_release(name); + scr_str_release(value); + scr_str_release(name_text); + scr_str_release(value_text); + } + bool multiline = length > g_break_length; + InspBuf out = {0}; + ib_cstr(&out, multiline ? "URLSearchParams {\n " : "URLSearchParams { "); + for (size_t i = 0; i < n; i++) { + if (i) ib_cstr(&out, multiline ? ",\n " : ", "); + ib_str(&out, items[i]); + scr_str_release(items[i]); + } + free(items); + ib_cstr(&out, " }"); + ScrStr *text = ib_take(&out); + InspBuf indented = {0}; + for (size_t k = 0; k < text->len; k++) { + ib_char(&indented, text->data[k]); + if (text->data[k] == '\n') ib_spaces(&indented, g_indent); + } + scr_str_release(text); + return ib_take(&indented); +} + ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->kind == SCR_DYN_PROXY) return scr_insp_dyn(d->v.proxy.target, recurse, depth); switch (d->kind) { @@ -1657,6 +1705,8 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { return scr_insp_weak_collection(d->v.handle.tag == SCR_DYNH_WEAK_MAP ? "WeakMap" : "WeakSet", recurse, depth); if (d->v.handle.tag == SCR_DYNH_DATE) return scr_insp_date(scr_dyn_native_date_value(d)); + if (d->v.handle.tag == SCR_DYNH_SEARCH_PARAMS) + return scr_insp_search_params(d->v.handle.ptr, recurse, depth); if (d->v.handle.tag == SCR_DYNH_ITERATOR) { ScrStr *iterator = scr_insp_native_iterator(d, recurse, depth); if (iterator) return iterator; @@ -1675,6 +1725,16 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { return ib_take(&out); } ScrDyn *view = ops->inspect_view(d->v.handle.ptr); + if (ops->inspect_view_nested) { + scr_insp_nested_begin(); + ScrStr *inner = scr_insp_dyn(view, 0, depth); + scr_dyn_release(view); + ScrStr *prefix = scr_str_new(ops->cls, strlen(ops->cls)); + ScrStr *rendered = scr_insp_nested_end(prefix, inner); + scr_str_release(prefix); + scr_str_release(inner); + return rendered; + } ScrStr *body = scr_insp_dyn(view, recurse, depth); scr_dyn_release(view); InspBuf out = {0}; diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 025bd740fa..0ea0e5824c 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -6,7 +6,7 @@ * against Node by the differential corpus: * - scheme lowercasing; "Invalid URL" TypeError on schemeless input * - special schemes (http/https/ws/wss/ftp/file): authority parsing with - * host lowercasing, default-port removal (leading zeros stripped), + * percent-decoded, lowercased hosts, default-port removal (leading zeros stripped), * canonical bracketed IPv6 literals, * backslash-as-slash, and any run of leading slashes tolerated * (http:foo.com works); file: takes an authority only after exactly @@ -17,13 +17,14 @@ * (space, quotes, angle brackets, braces, non-ASCII UTF-8 bytes, ...); * existing %-sequences pass through verbatim (no decode, no case * normalization — like Node) - * - non-special schemes: "//" parses an authority (host case preserved), - * a single "/" roots a normalized path, anything else is an OPAQUE - * path kept verbatim (data:text/plain,hi there) + * - non-special schemes: "//" parses an authority (an opaque host, + * case preserved, C0-encoded), a single "/" roots a normalized path, + * anything else is an OPAQUE path with the C0 control set encoded + * (data:text/plain,hi there) * - * DOCUMENTED DIVERGENCES (SEMANTICS.md): non-ASCII and %-escaped hosts are - * rejected with "Invalid URL" (no IDNA/punycode), and opaque paths skip - * the spec's C0-encode pass (kept verbatim). + * DOCUMENTED DIVERGENCE: a special host that holds non-ASCII text after + * percent-decoding is rejected with "Invalid URL" (no UTS #46 processing), + * and "xn--" labels are kept without Punycode validation. * * Failures THROW catchable TypeErrors through the exception cell with * Node's messages; callers are compiler-emitted pending checks. */ @@ -176,7 +177,7 @@ static bool enc_query(unsigned char c, bool special) { } static bool enc_path(unsigned char c) { - return enc_query(c, false) || c == '?' || c == '`' || c == '{' || c == '}'; + return enc_query(c, false) || c == '?' || c == '^' || c == '`' || c == '{' || c == '}'; } static bool enc_userinfo(unsigned char c) { @@ -392,17 +393,58 @@ static bool url_ipv4_parse(const char *s, size_t n, UrlBuf *out) { return true; } -/* Shared constructor/setter host parsing keeps the existing ASCII-host - * boundary and canonical IPv6 serialization in both execution tiers. A - * special host that ends in a number is an IPv4 address: it serializes - * as dotted decimal, or the URL is invalid. */ +/* Byte classes for the host parser: URL_FORBIDDEN_HOST marks the URL + * Standard's forbidden host code points (opaque hosts), URL_FORBIDDEN_DOMAIN + * its forbidden domain code points (those plus the C0 controls, '%' and + * U+007F) and every non-ASCII byte, which a special host cannot hold + * without UTS #46 processing. */ +enum { URL_FORBIDDEN_HOST = 1, URL_FORBIDDEN_DOMAIN = 2 }; + +static const unsigned char url_host_byte_class[256] = { + 3, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 2, 2, 3, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 3, 0, 0, 3, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 3, 0, 3, 3, + 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, +}; + +static bool enc_c0_control(unsigned char c) { + return c < 0x20 || c > 0x7E; +} + +static int hex_val(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* The URL Standard's host parser for the constructor and the setters, + * with canonical IPv6 serialization: + * - a non-special (opaque) host refuses forbidden host code points and + * percent-encodes the C0 control set; + * - a special host is percent-decoded, lowercased, refused when it holds a + * forbidden domain code point, and serialized as dotted decimal when it + * ends in a number (or refused when that number is invalid). + * Divergence: a special host that decodes to non-ASCII needs UTS #46 + * processing, which this unit does not link, so it is refused; "xn--" + * labels are kept without Punycode validation. */ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_file, ScrStr **host) { bool ipv6 = host_len > 0 && hp[0] == '['; size_t ipv6_end = host_len ? host_len - 1 : 0; if (ipv6 && (host_len < 3 || hp[ipv6_end] != ']')) return false; - /* Validate + lowercase (special) the host. Divergence: non-ASCII and - * %-escapes (IDNA territory) are rejected outright. */ UrlBuf hb; ub_init(&hb); if (ipv6) { @@ -410,22 +452,37 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi free(hb.data); return false; } - } else { + *host = ub_take(&hb); + return true; + } + if (!special) { for (size_t i = 0; i < host_len; i++) { unsigned char c = (unsigned char)hp[i]; - if (c >= 0x80 || c == '%' || c == ':' || c == '[' || c == ']' || - c <= 0x20 || c == '#' || c == '/' || c == '<' || c == '>' || - c == '?' || c == '@' || c == '\\' || c == '^' || c == '|') { + if (url_host_byte_class[c] & URL_FORBIDDEN_HOST) { free(hb.data); return false; } - if (special && c >= 'A' && c <= 'Z') { - c = (unsigned char)(c - 'A' + 'a'); - } - ub_push(&hb, (char)c); + ub_push_encoded(&hb, c, enc_c0_control); } + *host = ub_take(&hb); + return true; } - if (!ipv6 && special && url_ends_in_number(hb.data, hb.len)) { + for (size_t i = 0; i < host_len; i++) { + unsigned char c = (unsigned char)hp[i]; + int hi, lo; + if (c == '%' && i + 2 < host_len && (hi = hex_val(hp[i + 1])) >= 0 && + (lo = hex_val(hp[i + 2])) >= 0) { + c = (unsigned char)(hi * 16 + lo); + i += 2; + } + if (url_host_byte_class[c] & URL_FORBIDDEN_DOMAIN) { + free(hb.data); + return false; + } + if (c >= 'A' && c <= 'Z') c = (unsigned char)(c - 'A' + 'a'); + ub_push(&hb, (char)c); + } + if (url_ends_in_number(hb.data, hb.len)) { UrlBuf ip; ub_init(&ip); bool ok = url_ipv4_parse(hb.data, hb.len, &ip); @@ -438,7 +495,7 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi } /* file: "localhost" normalizes to "" at parse time (Node). */ if (is_file && hb.len == 9 && memcmp(hb.data, "localhost", 9) == 0) hb.len = 0; - if (hb.len == 0 && special && !is_file) { + if (hb.len == 0 && !is_file) { free(hb.data); return false; /* http:// — special non-file needs a host */ } @@ -481,6 +538,13 @@ static bool parse_authority(const char *raw, size_t len, bool special, bool is_f } const char *hp = at >= 0 ? raw + at + 1 : raw; size_t hp_len = at >= 0 ? len - (size_t)at - 1 : len; + /* Credentials need a host. A file: authority is a host alone, so its + * '@' and ':' reach the host parser, which refuses them. */ + if (at >= 0 && hp_len == 0) return false; + if (is_file) { + *port = scr_str_new("", 0); + return at < 0 && parse_host(hp, hp_len, special, is_file, host); + } /* host[:port], with IPv6 literals bracketed per WHATWG. */ long colon = -1; size_t host_len = hp_len; @@ -500,6 +564,7 @@ static bool parse_authority(const char *raw, size_t len, bool special, bool is_f } host_len = colon >= 0 ? (size_t)colon : hp_len; } + if (colon >= 0 && host_len == 0) return false; if (!parse_host(hp, host_len, special, is_file, host)) return false; /* Port: digits only, leading zeros stripped, defaults dropped. */ UrlBuf pb; @@ -700,11 +765,18 @@ ScrUrl *scr_url_new(ScrStr *input) { port = scr_str_new("", 0); path = parse_rooted_path(body, body_len, false); } else { - /* Opaque path — kept verbatim (divergence: the spec C0-encodes). */ + /* Opaque path: the C0 control set is percent-encoded, and a space + * right before a query or fragment becomes %20. */ userinfo = scr_str_new("", 0); host = scr_str_new("", 0); port = scr_str_new("", 0); - path = scr_str_new(body, body_len); + UrlBuf ob; + ub_init(&ob); + bool trailing_space = qmark < rest_len && body_len > 0 && body[body_len - 1] == ' '; + size_t kept = trailing_space ? body_len - 1 : body_len; + for (size_t i = 0; i < kept; i++) ub_push_encoded(&ob, (unsigned char)body[i], enc_c0_control); + if (trailing_space) ub_append(&ob, "%20", 3); + path = ub_take(&ob); } /* Query and fragment, encoded and stored WITH their sigils. */ @@ -823,18 +895,22 @@ ScrUrl *scr_url_new_base(ScrStr *input, ScrStr *base_input) { bool drive = file && path_len >= 2 && url_drive_letter(raw, 2) && (path_len == 2 || raw[2] == '/' || raw[2] == '\\'); if (base->has_authority) { + /* A drive letter relative to a file: base keeps the base's host. */ ub_append(&absolute, "//", 2); - if (!drive) { - if (base->userinfo->len) { - ub_append(&absolute, base->userinfo->data, base->userinfo->len); - ub_push(&absolute, '@'); - } - ub_append(&absolute, base->host->data, base->host->len); - if (base->port->len) { - ub_push(&absolute, ':'); - ub_append(&absolute, base->port->data, base->port->len); - } + if (base->userinfo->len) { + ub_append(&absolute, base->userinfo->data, base->userinfo->len); + ub_push(&absolute, '@'); } + ub_append(&absolute, base->host->data, base->host->len); + if (base->port->len) { + ub_push(&absolute, ':'); + ub_append(&absolute, base->port->data, base->port->len); + } + } else if (!opaque) { + /* A host-less base path can start with "//"; the "/." prefix keeps + * the resolved path from reparsing as an authority (dot-segment + * removal drops it). */ + ub_append(&absolute, "/.", 2); } if (!path_len) { ub_append(&absolute, base->path->data, base->path->len); @@ -1279,13 +1355,6 @@ void scr_url_set_checked(ScrUrl *u, ScrStr *field, const ScrDyn *value) { /* ── the file-URL bridge ─────────────────────────────────────────────── */ -static int hex_val(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; -} - static bool url_file_scheme(ScrUrl *u) { if (u->scheme->len == 4 && memcmp(u->scheme->data, "file", 4) == 0) return true; scr_throw_error_msg_code(SCR_ERR_TYPE, "The URL must be of scheme file", 30, "ERR_INVALID_URL_SCHEME"); @@ -1467,7 +1536,7 @@ static void scr_url_throw_arg_value(const char *reason, ScrStr *value) { * bindingUrl.pathToFileURL does, reusing the parser's rooted-path * machinery (WHATWG dot-segment removal with empty segments preserved, * backslash-as-slash when `backslash_sep`, the enc_path set): the bytes - * Node encodes BEYOND that set ('%', '[', ']', '^', '|' — and '\\' when + * Node encodes BEYOND that set ('%', '[', ']', '^', '|', '~' — and '\\' when * it is a path byte rather than a separator) pre-encode here so the * parser passes them through. */ static ScrStr *scr_url_encode_file_path(const char *raw, size_t len, bool backslash_sep) { @@ -1475,7 +1544,7 @@ static ScrStr *scr_url_encode_file_path(const char *raw, size_t len, bool backsl ub_init(&pre); for (size_t i = 0; i < len; i++) { unsigned char c = (unsigned char)raw[i]; - if (c == '%' || c == '[' || c == ']' || c == '^' || c == '|' || + if (c == '%' || c == '[' || c == ']' || c == '^' || c == '|' || c == '~' || (c == '\\' && !backslash_sep)) { char hex[4]; snprintf(hex, sizeof hex, "%%%02X", c); @@ -1537,24 +1606,30 @@ static ScrUrl *scr_url_from_path_impl(ScrStr *path, bool win32) { scr_str_release(resolved); return NULL; } - /* The URL host parser lowercases ASCII and TERMINATES at the URL - * delimiters '/', '?', '#' (Node feeds the servername through - * set_hostname: "\\\\?\\C:\\x" gets an EMPTY host and stays a - * drive path). No IDNA (documented divergence — Node punycodes - * non-ASCII names), and names with ':' or '%' stay verbatim where - * Node aborts on a native set_hostname assertion. */ - UrlBuf host; - ub_init(&host); + /* The servername goes through the host parser, as Node's hostname + * setter does: it drops tabs and newlines, stops at the URL + * delimiters '/', '?' and '#' + * ("\\\\?\\C:\\x" gets an empty host and stays a drive path), + * and a host it refuses throws ERR_INVALID_URL. */ + UrlBuf name; + ub_init(&name); for (size_t i = prefix; i < (size_t)host_end; i++) { char c = resolved->data[i]; if (c == '/' || c == '?' || c == '#') break; - if (c >= 'A' && c <= 'Z') c = (char)(c + 32); - ub_push(&host, c); + if (c != '\t' && c != '\n' && c != '\r') ub_push(&name, c); + } + ScrStr *host = NULL; + bool valid = parse_host(name.data, name.len, true, true, &host); + free(name.data); + if (!valid) { + scr_str_release(resolved); + scr_url_throw_invalid(); + return NULL; } ScrStr *encoded = scr_url_encode_file_path(resolved->data + host_end, resolved->len - (size_t)host_end, true); scr_str_release(resolved); - return scr_url_new_file(ub_take(&host), encoded); + return scr_url_new_file(host, encoded); } } else { ScrArr *pack = scr_arr_new(SCR_ELEM_STR, 1); @@ -1665,10 +1740,118 @@ static bool scr_native_url_set(void *ptr, const char *key, size_t len, const Scr return true; } +/* util.inspect's view of a URL: the properties Node's URL inspector lists, + * in its order, with the live searchParams object. Past the depth limit + * the inspector returns the URL itself, which util.inspect prints as + * [URL]. */ +static ScrDyn *scr_native_url_inspect_view(void *ptr) { + static const char *const keys[] = {"href", "origin", "protocol", "username", "password", + "host", "hostname", "port", "pathname", "search", + "searchParams", "hash"}; + ScrDyn *view = scr_dyn_new_obj(); + for (size_t i = 0; i < sizeof keys / sizeof keys[0]; i++) { + size_t len = strlen(keys[i]); + scr_dyn_obj_set(view, keys[i], len, scr_native_url_get(ptr, keys[i], len)); + } + return view; +} + +/* Node's urlToHttpOptions over a URL: protocol, hostname (an IPv6 literal + * without its brackets), hash, search, pathname, path (pathname and + * search), href, then port as a number when the URL has one and auth + * (the decoded username and password) when it has credentials. */ +ScrDyn *scr_url_to_http_options(ScrUrl *u) { + ScrDyn *options = scr_dyn_new_obj_null_proto(); + ScrStr *pathname = scr_url_pathname(u); + ScrStr *search = scr_url_search(u); + ScrStr *hostname = scr_url_hostname(u); + ScrStr *port = scr_url_port(u); + ScrStr *username = scr_url_username(u); + ScrStr *password = scr_url_password(u); + ScrStr *auth = NULL; + if (username->len || password->len) { + ScrStr *user = scr_str_decode_uri_component(username); + ScrStr *pass = user ? scr_str_decode_uri_component(password) : NULL; + if (pass) { + UrlBuf b; + ub_init(&b); + ub_append(&b, user->data, user->len); + ub_push(&b, ':'); + ub_append(&b, pass->data, pass->len); + auth = ub_take(&b); + } + scr_str_release(user); + scr_str_release(pass); + if (!auth) { + scr_str_release(pathname); + scr_str_release(search); + scr_str_release(hostname); + scr_str_release(port); + scr_str_release(username); + scr_str_release(password); + scr_dyn_release(options); + return NULL; + } + } +#define PUT_STR(key, value) \ + do { \ + ScrStr *text_ = (value); \ + scr_dyn_obj_set(options, key, sizeof key - 1, scr_dyn_new_str(text_)); \ + scr_str_release(text_); \ + } while (0) + PUT_STR("protocol", scr_url_protocol(u)); + if (hostname->len >= 2 && hostname->data[0] == '[') + PUT_STR("hostname", scr_str_new(hostname->data + 1, hostname->len - 2)); + else + PUT_STR("hostname", scr_str_retain(hostname)); + PUT_STR("hash", scr_url_hash(u)); + PUT_STR("search", scr_str_retain(search)); + PUT_STR("pathname", scr_str_retain(pathname)); + UrlBuf path; + ub_init(&path); + ub_append(&path, pathname->data, pathname->len); + ub_append(&path, search->data, search->len); + PUT_STR("path", ub_take(&path)); + PUT_STR("href", scr_url_href(u)); + if (port->len) { + double number = 0; + for (size_t i = 0; i < port->len; i++) number = number * 10 + (port->data[i] - '0'); + scr_dyn_obj_set(options, "port", 4, scr_dyn_new_num(number)); + } + if (auth) PUT_STR("auth", auth); +#undef PUT_STR + scr_str_release(pathname); + scr_str_release(search); + scr_str_release(hostname); + scr_str_release(port); + scr_str_release(username); + scr_str_release(password); + return options; +} + +ScrDyn *scr_url_to_http_options_dyn(const ScrDyn *value) { + if (scr_dyn_native_url_is(value)) return scr_url_to_http_options(value->v.handle.ptr); + if (!value || value->kind <= SCR_DYN_STR || value->kind == SCR_DYN_UNDEF || + value->kind == SCR_DYN_BIGINT || value->kind == SCR_DYN_SYMBOL) { + scr_dyn_arg_type_fail("url", "of type object", value); + return NULL; + } + static const char message[] = "url.urlToHttpOptions of an object that is not a URL is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; +} + ScrDyn *scr_dyn_native_url(ScrUrl *value) { static const ScrDynHandleOps ops = { - "URL", &scr_url_retain_v, &scr_url_release_v, &scr_native_url_invoke, - &scr_native_url_get, &scr_native_url_set, NULL, NULL, + .cls = "URL", + .retain = &scr_url_retain_v, + .release = &scr_url_release_v, + .invoke = &scr_native_url_invoke, + .get = &scr_native_url_get, + .set = &scr_native_url_set, + .inspect_view = &scr_native_url_inspect_view, + .inspect_bracketed_past_depth = true, + .inspect_view_nested = true, }; scr_dyn_handle_install(SCR_DYNH_URL, &ops); return scr_dyn_new_handle(value, SCR_DYNH_URL); diff --git a/packages/runtime/src/scr_url.h b/packages/runtime/src/scr_url.h index 435644419e..c7c9c63c8d 100644 --- a/packages/runtime/src/scr_url.h +++ b/packages/runtime/src/scr_url.h @@ -41,6 +41,12 @@ ScrStr *scr_url_href(ScrUrl *u); /* +1; also toString() */ void scr_url_set(ScrUrl *u, ScrStr *field, ScrStr *value); /* borrowed; href may throw */ bool scr_url_set_component(ScrUrl *u, ScrStr *field, ScrStr *value); /* borrowed; false for ignored writes */ void scr_url_set_checked(ScrUrl *u, ScrStr *field, const ScrDyn *value); /* borrowed; conversion may throw */ +/* url.urlToHttpOptions: Node's null-prototype options object for a URL. + * The checked-dynamic form throws ERR_INVALID_ARG_TYPE for a primitive or + * null and has no lowering for an object that is not a URL. Borrowed; +1, + * or NULL with the error pending. */ +ScrDyn *scr_url_to_http_options(ScrUrl *u); +ScrDyn *scr_url_to_http_options_dyn(const ScrDyn *value); /* node:url's legacy surface (scr_url_legacy.c — linked with the regex, * querystring and dyn-async units). url.parse() answers a checked-dynamic * Url instance; format() accepts a Url-shaped object, a string, or a diff --git a/tests/corpus/8101-url-host-and-path-parsing.js b/tests/corpus/8101-url-host-and-path-parsing.js new file mode 100644 index 0000000000..6b04dd9ef9 --- /dev/null +++ b/tests/corpus/8101-url-host-and-path-parsing.js @@ -0,0 +1,103 @@ +// WHATWG URL host and path parsing: special hosts are percent-decoded and +// checked for forbidden domain code points (and read as IPv4 when they end +// in a number), opaque hosts and paths percent-encode the C0 control set, +// file: URLs take no port or credentials, empty hosts refuse credentials and +// ports, and host-less paths that start with "//" round-trip. Cases from the +// WPT url test data. +const parses = [ + ["file://example:1/", null], + ["non-special:opaque ?hi", null], + ["non-special:opaque #hi", null], + ["non-special:opaque \t\t \t#hi", null], + ["non-special:opaque \t\t #hi", null], + ["non-special:opaque\t\t \r #hi", null], + ["sc://faß.ExAmPlE/", null], + ["http://%30%78%63%30%2e%30%32%35%30.01", "http://other.com/"], + ["http://%30%78%63%30%2e%30%32%35%30.01%2e", "http://other.com/"], + ["sc://ñ.test/", null], + ["sc://%/", null], + ["sc://@/", null], + ["sc://te@s:t@/", null], + ["sc://:/", null], + ["sc://:12/", null], + ["x", "sc://ñ"], + ["wow:\uFFFF", null], + ["http://a\u007Fb/", null], + ["sc://\u0001\u0002\u0003\u0004\u0005\u0006\u0007\b\u000b\f\u000e\u000f\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f\u007F!\"$%&'()*+,-.;=_`{}~/", null], + ["asdf://%43%7C/", null], + ["C|", "file://host/dir/file"], + ["C|", "file://host/D:/dir1/dir2/file"], + ["C|#", "file://host/dir/file"], + ["C|?", "file://host/dir/file"], + ["C|/", "file://host/dir/file"], + ["C|\n/", "file://host/dir/file"], + ["C|\\", "file://host/dir/file"], + ["C|/", "file://host/"], + ["file:C:/", "file://host/"], + ["sc://ñ", null], + ["sc://ñ?x", null], + ["sc://ñ#x", null], + ["#x", "sc://ñ"], + ["?x", "sc://ñ"], + ["", "non-spec:/..//p"], + ["path", "non-spec:/..//p"], + ["../path", "non-spec:/.//p"], + ["non-special://%E2%80%A0/", null], + ["non-special://H%4fSt/path", null], + ["non-special:cannot-be-a-base-url-\u0000\u0001\u001f\u001e~\u007F\u0080", null], + ["foo://!\"$%&'()*+,-.;=_`{}~/", null], + ["foo://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~", null], + ["wss://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~", null], + ["non-special:\u0000y", null], + ["non-special:x/\u0000y", null], + ["non-special:\uFFFFy", null], + ["non-special:x/\uFFFFy", null], + ["data://:443", null], + ["javascript://:443", null], + ["mailto://:443", null], + ["intent://:443", null], + ["urn://:443", null], + ["turn://:443", null], + ["stun://:443", null], +]; +function describe(url) { + return [url.href, url.protocol, url.username, url.password, url.host, url.hostname, url.port, url.pathname, url.search, url.hash].join(" | "); +} +for (const [input, base] of parses) { + let line; + try { + line = describe(base === null ? new URL(input) : new URL(input, base)); + } catch (e) { + line = `throws ${e.code}`; + } + console.log(JSON.stringify(input), JSON.stringify(base), "=>", line); +} + +const writes = [ + ["host", "sc://x/", "ß"], + ["host", "file://y/", "loc%41lhost"], + ["hostname", "file://y/", "loc%41lhost"], + ["pathname", "a:/", "\u0000\u0001\t\n\r\u001f !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007F\u0080\u0081Éé"], + ["search", "data:space ?query", ""], + ["search", "sc:space ?query", ""], + ["search", "data:space ?query#fragment", ""], + ["search", "sc:space ?query#fragment", ""], + ["hash", "data:space #fragment", ""], + ["hash", "sc:space #fragment", ""], + ["hash", "data:space ?query#fragment", ""], + ["hash", "sc:space ?query#fragment", ""], +]; +for (const [name, href, value] of writes) { + const url = new URL(href); + if (name === "host") url.host = value; + else if (name === "hostname") url.hostname = value; + else if (name === "port") url.port = value; + else if (name === "pathname") url.pathname = value; + else if (name === "protocol") url.protocol = value; + else if (name === "username") url.username = value; + else if (name === "password") url.password = value; + else if (name === "search") url.search = value; + else if (name === "hash") url.hash = value; + else if (name === "href") url.href = value; + console.log(name, JSON.stringify(href), JSON.stringify(value), "=>", describe(url)); +} diff --git a/tests/corpus/8102-url-path-to-file-url-unc-hosts.js b/tests/corpus/8102-url-path-to-file-url-unc-hosts.js new file mode 100644 index 0000000000..a96d1252e4 --- /dev/null +++ b/tests/corpus/8102-url-path-to-file-url-unc-hosts.js @@ -0,0 +1,36 @@ +// pathToFileURL(path, { windows: true }) parses a UNC servername with the URL +// host parser after dropping tabs and newlines and stopping at '#' and '?': +// forbidden host code points throw ERR_INVALID_URL, and the +// name is lowercased, percent-decoded and read as IPv4 when it ends in a +// number. "~" in a path is percent-encoded. +import url from "node:url"; +const paths = [ + "\\\\exa mple\\share\\file.txt", + "\\\\host name\\share\\file.txt", + "\\\\host@name\\share\\file.txt", + "\\\\host:name\\share\\file.txt", + "\\\\host[name\\share\\file.txt", + "\\\\host]name\\share\\file.txt", + "\\\\h Date: Fri, 23 Oct 2026 22:50:14 -0500 Subject: [PATCH 40/44] Detect class cycles by class identity when inspecting static fields --- packages/runtime/src/scr_inspect.c | 10 ++++--- .../6344-inspect-function-properties.cjs | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 5f888e4c42..b3763f7dc5 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1609,9 +1609,11 @@ static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { return tag; } /* A function already on the traversal stack renders as [Circular *N], - * keyed by the closure every box of the function shares, even past the - * depth limit. */ - double circular = scr_insp_circ_check(owner); + * even past the depth limit. Its identity is the class object for a class + * (each dynamic box of a class may carry its own closure) and the closure + * every box of a function shares otherwise. */ + const void *identity = cls ? (const void *)cls : (const void *)owner; + double circular = scr_insp_circ_check(identity); if (circular > 0) { scr_dyn_release(table); scr_str_release(tag); @@ -1628,7 +1630,7 @@ static ScrStr *scr_insp_dyn_func(ScrDyn *d, double recurse, double depth) { prefix[tag->len] = '\0'; scr_str_release(tag); size_t nseen = g_nseen; - g_pending_seen = owner; + g_pending_seen = identity; ScrStr *rendered = scr_insp_obj(table, recurse, depth, prefix); g_pending_seen = NULL; if (g_nseen > nseen) g_nseen = nseen; diff --git a/tests/corpus/6344-inspect-function-properties.cjs b/tests/corpus/6344-inspect-function-properties.cjs index 2b1c1aae7a..5cab041721 100644 --- a/tests/corpus/6344-inspect-function-properties.cjs +++ b/tests/corpus/6344-inspect-function-properties.cjs @@ -34,3 +34,29 @@ shallow(S); shallow(A); console.log(util.inspect(g, { depth: 0 })); shallow(g); + +// A class that reaches itself through its static properties is one cycle, +// whichever dynamic box of it the inspection meets. +class Loop { + static get hidden() { + return 1; + } +} +Loop.self = Loop; +Loop.label = 'loop'; +class Left { + static get hidden() { + return 2; + } +} +class Right { + static get hidden() { + return 3; + } +} +Left.other = Right; +Right.other = Left; +show(Loop); +show(Left); +show([Loop, Loop]); +show({ a: Left, b: Right }); From d6a698084980df208d41b1d1f78be0381624e380 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 22:59:39 -0500 Subject: [PATCH 41/44] Divide by Math.pow(2, n) as a multiply by its reciprocal A quotient x / Math.pow(2, n) divided on every evaluation, and a division's latency bounds loops that carry the quotient, such as a key that keeps its low bits with k - Math.floor(k / Math.pow(2, n)) * Math.pow(2, n). A divisor that is a positive power of two from 2^-1022 to 2^1022 has a reciprocal that is a power of two too, whose bits are 0x7FE0000000000000 minus the divisor's, and the product with it is the quotient for every dividend. The quotient now multiplies by that reciprocal when the divisor's bits are such a power, and divides otherwise. Corpus 8180 compares the quotient with an ordinary division for every integer exponent from -1080 to 1030, including divisors that are subnormal, zero or infinite, and for exponents that are not integers. --- .../src/backend/llvm/expr-primitives.ts | 3 + .../src/backend/llvm/lib-filesystem.ts | 53 ++++++++++++++++- .../power-of-two-quotient-emission.test.ts | 46 +++++++++++++++ .../tests/corpus/8180-pow2-quotients.ts.json | 6 ++ tests/corpus/8180-pow2-quotients.ts | 57 +++++++++++++++++++ .../corpus-costs/8180-pow2-quotients.ts.json | 1 + 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/power-of-two-quotient-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8180-pow2-quotients.ts.json create mode 100644 tests/corpus/8180-pow2-quotients.ts create mode 100644 tests/harness/corpus-costs/8180-pow2-quotients.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 04af0fcefb..ccbe3edcc5 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -46,6 +46,7 @@ import { type ArrayAccess, } from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; +import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js"; /** The signed integer predicates of the number compares. */ const INTEGER_COMPARES: Record = { @@ -348,6 +349,8 @@ export function emitOperatorExpr( : emitCheckedRemainder(host, l, r, li, ri); if (checked) return checked; } + if (e.op === "/" && isPowerOfTwoCall(e.right)) + return { name: emitPowerOfTwoQuotient(host, l.name, r.name), type: e.type }; if (arith[e.op] !== undefined) { B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`); const uint32 = emitWrappingSum(host, e, l, r, li, ri); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 7d9be632df..f0b7669360 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -1,7 +1,7 @@ /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { undefinedArmTag } from "../../ir/analysis.js"; -import { DYN, F64, STATS_T, STRING } from "../../ir/ir.js"; +import { DYN, F64, STATS_T, STRING, type IrExpr } from "../../ir/ir.js"; import { mangleRecordNew } from "../mangle.js"; import { arrNewCall, traceArg, vAdapters } from "./shapes.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; @@ -1027,6 +1027,57 @@ function emitPowerOfTwo(host: LlvmEmitterContext, n: string): string { return out; } +/** `Math.pow(2, n)`, the divisor emitPowerOfTwoQuotient takes. */ +export function isPowerOfTwoCall(e: IrExpr): boolean { + return ( + e.kind === "libCall" && + e.fn === "math.pow" && + e.args.length === 2 && + e.args[0]!.kind === "numLit" && + e.args[0]!.value === 2 + ); +} + +/** `left / divisor` for a divisor that is usually a power of two + * (`x / Math.pow(2, n)`). A positive power of two from 2^-1022 to 2^1022 + * has a reciprocal that is a power of two too, whose bits are + * 0x7FE0000000000000 minus the divisor's, and the product with it is the + * quotient for every `left`: both are `left` scaled by the same power of + * two, rounded once. Any other divisor divides. */ +export function emitPowerOfTwoQuotient( + host: LlvmEmitterContext, + left: string, + divisor: string, +): string { + const B = host.B; + const bits = B.tmp(), + mantissa = B.tmp(), + plain = B.tmp(), + biased = B.tmp(), + offset = B.tmp(), + normal = B.tmp(), + power = B.tmp(), + inverseBits = B.tmp(), + inverse = B.tmp(), + scaled = B.tmp(), + divided = B.tmp(), + out = B.tmp(); + B.line(`${bits} = bitcast double ${divisor} to i64`); + B.line(`${mantissa} = and i64 ${bits}, ${2n ** 52n - 1n}`); + B.line(`${plain} = icmp eq i64 ${mantissa}, 0`); + // Biased exponents 1 to 2045 with a clear sign bit. + B.line(`${biased} = lshr i64 ${bits}, 52`); + B.line(`${offset} = sub i64 ${biased}, 1`); + B.line(`${normal} = icmp ult i64 ${offset}, 2045`); + B.line(`${power} = and i1 ${plain}, ${normal}`); + B.line(`${inverseBits} = sub i64 ${0x7fe0000000000000n}, ${bits}`); + B.line(`${inverse} = bitcast i64 ${inverseBits} to double`); + B.line(`${scaled} = fmul double ${left}, ${inverse}`); + B.line(`${divided} = fdiv double ${left}, ${divisor}`); + B.line(`${out} = select i1 ${power}, double ${scaled}, double ${divided}`); + return out; +} + export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; if (e.fn === "math.min" || e.fn === "math.max") { diff --git a/packages/compiler/test/power-of-two-quotient-emission.test.ts b/packages/compiler/test/power-of-two-quotient-emission.test.ts new file mode 100644 index 0000000000..4b748a2a1a --- /dev/null +++ b/packages/compiler/test/power-of-two-quotient-emission.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +test("a quotient by Math.pow(2, n) multiplies by the divisor's reciprocal power of two", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-pow2-quotient-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +function scaled(x: number, n: number): number { return x / Math.pow(2, n); } +function plain(x: number, d: number): number { return x / d; } +function other(x: number, n: number): number { return x / Math.pow(3, n); } +console.log(scaled(3, 4), plain(3, 16), other(3, 4)); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const ll = emitLlvmModule(mod); + const fn = (name: string): string => + new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec( + ll, + )![0]; + // The reciprocal's bits are 0x7FE0000000000000 minus the divisor's; a + // divisor that is not a power of two from 2^-1022 to 2^1022 divides. + const quotient = fn("scaled"); + expect(quotient).toContain(`sub i64 ${0x7fe0000000000000n}, `); + expect(quotient).toMatch(/fmul double %\w+, %\w+/); + expect(quotient).toMatch(/icmp ult i64 %\w+, 2045/); + expect(quotient).toMatch(/select i1 %\w+, double %\w+, double %\w+/); + expect(quotient.match(/ = fdiv double /g)).toHaveLength(1); + for (const name of ["plain", "other"]) { + expect(fn(name)).not.toContain(`${0x7fe0000000000000n}`); + expect(fn(name)).toMatch(/fdiv double/); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8180-pow2-quotients.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8180-pow2-quotients.ts.json new file mode 100644 index 0000000000..58c36cf5df --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8180-pow2-quotients.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8180-pow2-quotients.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8180-pow2-quotients.ts b/tests/corpus/8180-pow2-quotients.ts new file mode 100644 index 0000000000..f98d4e46ea --- /dev/null +++ b/tests/corpus/8180-pow2-quotients.ts @@ -0,0 +1,57 @@ +// x / Math.pow(2, n) over every integer exponent from -1080 to 1030, against +// the same divisor read back from an array (an ordinary division): zeros, +// subnormals, values next to the limits, infinities and NaN, and a divisor of +// 2 ** n that is subnormal, zero or Infinity. Non-integer, signed-zero, +// infinite and NaN exponents give divisors that are not powers of two. +function bits(x: number): string { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, x); + return view.getBigUint64(0).toString(16).padStart(16, "0"); +} + +const values = [ + 1, -1, 3, 0.1, -7.25, 0, -0, 5e-324, -5e-324, 2.2250738585072014e-308, 1.7976931348623157e308, + -1.7976931348623157e308, 4503599627370497, 123456789.123, Infinity, -Infinity, NaN, +]; +const divisors: number[] = []; +for (let n = -1080; n <= 1030; n++) divisors.push(Math.pow(2, n)); + +const scratch = new DataView(new ArrayBuffer(8)); +let digest = 0; +let mismatches = 0; +for (let n = -1080; n <= 1030; n++) { + const divisor = divisors[n + 1080]!; + for (const x of values) { + const quotient = x / Math.pow(2, n); + const plain = x / divisor; + scratch.setFloat64(0, quotient); + digest = (Math.imul(digest, 31) + scratch.getUint32(0)) | 0; + digest = (Math.imul(digest, 31) + scratch.getUint32(4)) | 0; + if (!Object.is(quotient, plain)) { + mismatches++; + if (mismatches <= 5) console.log("mismatch", x, n, bits(quotient), bits(plain)); + } + } +} +console.log("digest", (digest >>> 0).toString(16), "mismatches", mismatches); + +for (const n of [-1075, -1074, -1023, -1022, -1, 0, 1, 52, 1022, 1023, 1024]) { + console.log(n, bits(3 / Math.pow(2, n)), bits(-5e-324 / Math.pow(2, n)), bits(1.7976931348623157e308 / Math.pow(2, n))); +} +for (const e of [0.5, -0.5, -0, 1e-300, 1023.0000001, -1074.5, Infinity, -Infinity, NaN, 2 ** 31, -(2 ** 31)]) { + console.log("exponent", e, bits(10 / Math.pow(2, e)), bits(-0 / Math.pow(2, e))); +} + +// A divisor that changes every pass, and one computed once. +let total = 0; +for (let i = 0; i < 100000; i++) { + const shift = (i % 40) - 20; + total += (i * 7 + 3) / Math.pow(2, shift); +} +console.log("total", total); +let key = 12345; +const width = 34; +for (let i = 0; i < 100000; i++) { + key = (key - Math.floor(key / Math.pow(2, width)) * Math.pow(2, width)) * 4 + (i % 4); +} +console.log("key", key); diff --git a/tests/harness/corpus-costs/8180-pow2-quotients.ts.json b/tests/harness/corpus-costs/8180-pow2-quotients.ts.json new file mode 100644 index 0000000000..63f95b9853 --- /dev/null +++ b/tests/harness/corpus-costs/8180-pow2-quotients.ts.json @@ -0,0 +1 @@ +{ "plain": 2301, "san": 3300 } From 0b4deda1d0598375ef9910fea491f55f1b3dc099 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 23:05:32 -0500 Subject: [PATCH 42/44] Print URL {} past the inspect depth limit --- packages/runtime/src/scr_dyn.h | 4 +++- packages/runtime/src/scr_inspect.c | 8 ++++++++ packages/runtime/src/scr_url.c | 3 +-- tests/corpus/8103-url-inspect.ts | 8 +++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 469c550ab8..6e3eac7b57 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1617,7 +1617,9 @@ typedef struct ScrDynHandleOps { bool inspect_bracketed_past_depth; /* The class's inspector formats its view with a new util.inspect call * (URL's does): the view renders from the top level with the caller's - * depth, and its lines take the caller's indentation. */ + * depth, and its lines take the caller's indentation. Past the depth + * limit the inspector returns the instance, which has no own + * properties, so util.inspect prints `cls {}`. */ bool inspect_view_nested; /* The @@toStringTag when it differs from cls: String() answers * "[object ]" and util.inspect prints ` [] `, or diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index f5e4293741..78f2fa71ef 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1717,6 +1717,14 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { * depth limit (`depth < 0`); the ones that return `this` there * print util.inspect's `[cls]`. */ if (recurse > depth && !ops->inspect_view_empty) { + /* A nested-view inspector returns the instance itself there, + * which has no own properties: `URL {}`. */ + if (ops->inspect_view_nested) { + InspBuf out = {0}; + ib_cstr(&out, ops->cls); + ib_cstr(&out, " {}"); + return ib_take(&out); + } if (!ops->inspect_bracketed_past_depth) return scr_str_new(ops->cls, strlen(ops->cls)); InspBuf out = {0}; ib_char(&out, '['); diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 0ea0e5824c..09ccb87fe3 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -1743,7 +1743,7 @@ static bool scr_native_url_set(void *ptr, const char *key, size_t len, const Scr /* util.inspect's view of a URL: the properties Node's URL inspector lists, * in its order, with the live searchParams object. Past the depth limit * the inspector returns the URL itself, which util.inspect prints as - * [URL]. */ + * `URL {}` (it has no own properties). */ static ScrDyn *scr_native_url_inspect_view(void *ptr) { static const char *const keys[] = {"href", "origin", "protocol", "username", "password", "host", "hostname", "port", "pathname", "search", @@ -1850,7 +1850,6 @@ ScrDyn *scr_dyn_native_url(ScrUrl *value) { .get = &scr_native_url_get, .set = &scr_native_url_set, .inspect_view = &scr_native_url_inspect_view, - .inspect_bracketed_past_depth = true, .inspect_view_nested = true, }; scr_dyn_handle_install(SCR_DYNH_URL, &ops); diff --git a/tests/corpus/8103-url-inspect.ts b/tests/corpus/8103-url-inspect.ts index ef42709f88..005bb58314 100644 --- a/tests/corpus/8103-url-inspect.ts +++ b/tests/corpus/8103-url-inspect.ts @@ -1,6 +1,7 @@ // util.inspect and console.log of URL values print Node's URL view: the // URL's properties in its inspector's order, the live searchParams, and -// [URL] past the depth limit. URLSearchParams print their pairs. +// `URL {}` past the depth limit. URLSearchParams print their pairs, and +// [Object] past the depth limit. import { inspect } from "node:util"; const url = new URL("https://user:pass@example.com:8080/a/b?x=1&y=2#h"); @@ -17,3 +18,8 @@ console.log(inspect({ params }, { depth: 0 })); const long = new URLSearchParams(); for (let i = 0; i < 12; i++) long.append(`key${i}`, `value number ${i}`); console.log({ long }); +// Past the depth limit a URL prints as `URL {}` and URLSearchParams as +// [Object], nested or at a negative depth. +console.log(inspect({ a: { b: { c: url } } }), inspect(url, { depth: -1 })); +console.log(inspect({ a: { b: { c: params } } }), inspect(params, { depth: -1 })); +console.log(inspect({ a: url }, { depth: 0 }), inspect({ a: { b: url } }, { depth: 1 })); From 6722a01898b39cf0b472caa7d6994f6dd3003633 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 23:28:09 -0500 Subject: [PATCH 43/44] Track asserted typed-array reads that can be undefined in element state --- .../compiler/src/frontend/lowering/lowerer.ts | 18 +++++++++++++++- .../corpus/7394-non-null-typed-array-reads.ts | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2fcfc063b7..af89c61a4e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6488,6 +6488,22 @@ export class Lowerer { }); return keys; }; + // `t[i]!` on a typed array, stored into an array, keeps an invalid + // index's undefined (assertedReadObservesUndefined): `!` performs no + // runtime check. A read whose consumers treat undefined like NaN stores + // NaN instead. + const assertedReadMayBeUndefined = (node: ts.Expression): boolean => { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isNonNullExpression(e)) return false; + let read = e.expression; + while (ts.isParenthesizedExpression(read)) read = read.expression; + return ( + ts.isElementAccessExpression(read) && + this.mapTypeOf(this.typeOf(read.expression))?.kind === "bytes" && + !nanCoded.isNumericRead(read) + ); + }; const elementStates = new ArrayElementStates({ elementAdmitsUndefined: (key) => { siteDependencies?.read(key); @@ -6497,7 +6513,7 @@ export class Lowerer { allocationOwner: (node) => ownership.allocationOwner(node), arrayKeys: arrayKeysOf, mayBeUndefined: (node) => { - if (explicitlyNonNull(node)) return false; + if (explicitlyNonNull(node)) return assertedReadMayBeUndefined(node); if (mayBeOptional(node)) return true; // `undefined as unknown as T` keeps the undefined value. for ( diff --git a/tests/corpus/7394-non-null-typed-array-reads.ts b/tests/corpus/7394-non-null-typed-array-reads.ts index 6f1ec98080..bab1bcf9cd 100644 --- a/tests/corpus/7394-non-null-typed-array-reads.ts +++ b/tests/corpus/7394-non-null-typed-array-reads.ts @@ -57,3 +57,24 @@ line("slots", [optional, listed[0], listed[1], listed.length]); let total = 0; for (let i = 0; i <= int32.length; i++) total += int32[i]!; line("sum", [total]); + +// Arrays that store an asserted read past the end hold undefined, which +// their callbacks see. +const pushed: number[] = []; +pushed.push(int32[7]!); +pushed.push(uint8[1]!); +const literal = [float64[5]!, float64[0]!]; +const assigned: number[] = [1, 2]; +assigned[0] = uint8[9]!; +let visited = 0; +pushed.forEach((v) => { + visited += v; +}); +line("callbacks", [ + pushed.map((v) => v * 2).join(","), + visited, + literal.map((v) => typeof v).join(","), + literal.filter((v) => v === undefined).length, + assigned.map((v) => v + 1).join(","), + assigned.some((v) => v === undefined), +]); From cdef55778a0d96e2d50e0cf965f2f858c4162e99 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 23:31:30 -0500 Subject: [PATCH 44/44] Answer Error.prototype only while an error keeps its built prototype Object.getPrototypeOf of an Error answers Error.prototype when the error's view was built from its fields and its prototype is still the object the view was built with. After Object.setPrototypeOf(error, {}), it answers the object that was set. --- packages/runtime/src/scr_json.c | 39 ++++++++++++------- tests/corpus/8115-error-prototype-identity.js | 9 +++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b318114a33..1115c3a8af 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8560,6 +8560,12 @@ typedef struct { * or NULL. The code slot holds only strings, and a subclass's typed view * rebuilds its properties from the slots on every read. */ ScrDyn *code; + /* The prototype a view built from the error's fields starts with + * (scr_error_dyn_fields: the holder of its default name and message, or + * none), retained so that no later object takes its address. Only such a + * view sets `fields_view`. */ + ScrDyn *view_prototype; + bool fields_view; } ScrErrDynEnt; static SCR_TL ScrErrDynEnt *scr_errdyn_cache = NULL; @@ -8664,6 +8670,7 @@ static void scr_errdyn_sweep(void) { scr_error_release(dead[i].err); scr_errdyn_release_node(dead[i].dyn); scr_dyn_release(dead[i].code); + scr_dyn_release(dead[i].view_prototype); } free(dead); } @@ -8685,6 +8692,8 @@ static void scr_errdyn_append(ScrError *e, ScrDyn *d) { scr_errdyn_cache[scr_errdyn_n].err = scr_error_retain(e); scr_errdyn_cache[scr_errdyn_n].dyn = scr_dyn_retain(d); scr_errdyn_cache[scr_errdyn_n].code = NULL; + scr_errdyn_cache[scr_errdyn_n].view_prototype = NULL; + scr_errdyn_cache[scr_errdyn_n].fields_view = false; scr_errdyn_n++; if (scr_errdyn_n * 2 > scr_errdyn_slots) { free(scr_errdyn_by_err); @@ -8741,6 +8750,7 @@ static void scr_errdyn_teardown(void) { scr_error_release(cache[i].err); scr_errdyn_release_node(cache[i].dyn); scr_dyn_release(cache[i].code); + scr_dyn_release(cache[i].view_prototype); } free(cache); } @@ -8844,24 +8854,17 @@ static void scr_error_dyn_add_stack(ScrDyn *d) { entry->enumerable = false; } -/* Whether `object` is the view of an error built as an Error (not a - * subclass or another error kind) whose prototype is still the one its view - * starts with: none, or the holder of its default `name` and `message` - * (scr_error_dyn_fields). Such an error's prototype is Error.prototype. */ +/* Whether `object` is the view scr_error_dyn_fields built for an error + * created as an Error (not a subclass or another error kind) and its + * prototype is still the one the view started with. Such an error's + * prototype is Error.prototype. */ static bool scr_dyn_error_view_on_error_prototype(const ScrDyn *object) { - const ScrDyn *holder = object->prototype; if (object->null_proto) return false; - if (holder) { - if (holder->kind != SCR_DYN_OBJ || holder->prototype || holder->null_proto || holder->v.obj.len > 2) return false; - for (size_t i = 0; i < holder->v.obj.len; i++) { - const ScrDynEntry *entry = &holder->v.obj.entries[i]; - bool known = (entry->key_len == 4 && memcmp(entry->key, "name", 4) == 0) || - (entry->key_len == 7 && memcmp(entry->key, "message", 7) == 0); - if (!known || entry->enumerable || entry->accessor) return false; - } - } size_t hit = scr_errdyn_find(object, false); - return hit != SIZE_MAX && scr_errdyn_cache[hit].err->vt == &scr_error_vts[SCR_ERR_ERROR]; + if (hit == SIZE_MAX) return false; + const ScrErrDynEnt *ent = &scr_errdyn_cache[hit]; + return ent->fields_view && object->prototype == ent->view_prototype && + ent->err->vt == &scr_error_vts[SCR_ERR_ERROR]; } ScrDyn *scr_error_dyn_fields(const ScrError *e) { @@ -8993,8 +8996,14 @@ ScrDyn *scr_dyn_from_error(const ScrError *e) { break; } } + bool fields_view = d == NULL; if (!d) d = scr_error_dyn_fields(e); scr_errdyn_append((ScrError *)e, d); + if (fields_view) { + ScrErrDynEnt *ent = &scr_errdyn_cache[scr_errdyn_n - 1]; + ent->fields_view = true; + ent->view_prototype = d->prototype ? scr_dyn_retain(d->prototype) : NULL; + } return d; } diff --git a/tests/corpus/8115-error-prototype-identity.js b/tests/corpus/8115-error-prototype-identity.js index 1eda2c2c0d..5c98e1209a 100644 --- a/tests/corpus/8115-error-prototype-identity.js +++ b/tests/corpus/8115-error-prototype-identity.js @@ -44,3 +44,12 @@ const values = [new Error("e"), {}, Object.create(null), [], JSON.parse('{"a":1} console.log(values.map(kind).join(",")); const proto = Object.getPrototypeOf(new Error("m")); console.log(proto.name, JSON.stringify(proto.message), Object.prototype.hasOwnProperty.call(proto, "name")); + +// After Object.setPrototypeOf, the prototype is the object that was set. +const custom = {}; +const moved = Object.setPrototypeOf(new Error("moved"), custom); +console.log(Object.getPrototypeOf(moved) === custom, Object.getPrototypeOf(moved) === Error.prototype); +const named = { kind: "named" }; +const movedNamed = new Error(); +Object.setPrototypeOf(movedNamed, named); +console.log(Object.getPrototypeOf(movedNamed) === named, Object.getPrototypeOf(movedNamed).kind);