From ef8cc7f74f13470c21d3c47b92df75ad9055808a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 09:48:55 -0500 Subject: [PATCH 01/20] Settle Promise.all over different entry types on Node's turns `Promise.all([a, b])` whose entries have different types (the checker's tuple overload) ran through an async helper that awaited the combinator and then each fulfilled entry, so its awaits resumed three microtask turns after Node's. The combinator now stores the entry promises, and an adapter run inside its settlement reads them into the tuple, so the result settles on the combinator's turn: awaiting it, storing it first, chaining it with then and returning it from an async function all land on Node's turns. Plain entries join the combinator through Promise.resolve, as in Node, and unit-typed entries compile. --- .../compiler/src/backend/llvm/lib-dispatch.ts | 88 ++++++ .../frontend/lowering/builtins/promises.ts | 273 +++++++++--------- .../compiler/src/ir/builtin-signatures.ts | 12 + packages/compiler/src/ir/validate.ts | 26 ++ .../7035-promise-all-tuple-turns.ts.json | 6 + packages/runtime/src/scr_async.c | 6 + packages/runtime/src/scr_async.h | 2 + tests/corpus/7035-promise-all-tuple-turns.ts | 64 ++++ .../7035-promise-all-tuple-turns.ts.json | 1 + 9 files changed, 338 insertions(+), 140 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7035-promise-all-tuple-turns.ts.json create mode 100644 tests/corpus/7035-promise-all-tuple-turns.ts create mode 100644 tests/harness/corpus-costs/7035-promise-all-tuple-turns.ts.json diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 24f2bf019..207132d05 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -5,6 +5,7 @@ import { emitTlsLibCall } from "./lib-tls.js"; import { emitHttp2LibCall } from "./lib-http2.js"; import { emitDatagramLibCall } from "./lib-datagram.js"; import { emitTestLibCall } from "./lib-test.js"; +import { FN_ATTRS, arrNewCall, traceArg, vAdapters } from "./shapes.js"; /* Focused LLVM library-call emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; @@ -236,6 +237,32 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.emitPendingCheck(); return output; } + if (e.fn === "promise.allTuple") return emitPromiseAllTuple(host, e); + if (e.fn === "promise.fulfilledValue") { + // The payload of a fulfilled promise (borrowed operand): numbers and + // booleans by value, strings and references +1. + const promise = host.emitExpr(e.args[0]!); + const out = B.tmp(); + switch (e.type.kind) { + case "f64": + case "procStream": + host.declare(`declare double @scr_promise_payload_f64(ptr)`); + B.line(`${out} = call double @scr_promise_payload_f64(ptr ${promise.name})`); + return { name: out, type: e.type }; + case "bool": + host.declare(`declare zeroext i1 @scr_promise_payload_bool(ptr)`); + B.line(`${out} = call zeroext i1 @scr_promise_payload_bool(ptr ${promise.name})`); + return { name: out, type: e.type }; + case "string": + host.declare(`declare ptr @scr_promise_payload_str(ptr)`); + B.line(`${out} = call ptr @scr_promise_payload_str(ptr ${promise.name}) ; +1`); + return host.own({ name: out, type: e.type }); + default: + host.declare(`declare ptr @scr_promise_payload_ref(ptr)`); + B.line(`${out} = call ptr @scr_promise_payload_ref(ptr ${promise.name}) ; +1`); + return host.own({ name: out, type: e.type }); + } + } if (e.fn === "dyn.typedRefIs" || e.fn === "dyn.typedRefKeyIs") { const literal = e.args[1]!.kind === "strLit" ? e.args[1]!.value : null; if (e.fn === "dyn.typedRefKeyIs" && literal === null) @@ -877,6 +904,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "disposable": case "punycode": case "constants": + case "promise": return host.emitGenericLibCall(e); case "test": return emitTestLibCall(host, e); @@ -891,3 +919,63 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { } } } + +/** Promise.all's tuple form (promise.allTuple): the combinator over the + * entries stores each fulfilled entry promise, and a settlement adapter on + * the combinator calls the builder with them and fulfills the result with + * the tuple inside the combinator's settlement; a rejection settles the + * result the same way. The combinator itself counts as handled. */ +function emitPromiseAllTuple(host: LlvmEmitterContext, e: LibCallExpr): LlValue { + const B = host.B; + const build = e.args[1]!; + if (build.kind !== "closure" || e.type.kind !== "promise") + throw new InternalCompilerError("llvm emitter bug: promise.allTuple shape"); + const entriesT = e.args[0]!.type; + const entries = host.emitExpr(e.args[0]!); + const tuple = e.type.inner; + const key = `allTuple:${build.fnName}`; + let adapter = host.resolveThunks.get(key); + if (adapter === undefined) { + adapter = `sc_all_tuple_${host.resolveThunks.size}`; + host.resolveThunks.set(key, adapter); + const rc = vAdapters(host.shapeHost, tuple); + host.declare(`declare ptr @scr_promise_payload_ref(ptr)`); + host.declare(`declare void @scr_promise_fulfill_ref(ptr, ptr, ptr, ptr, ptr)`); + host.resolveThunkDefs.push( + `define internal void @${adapter}(ptr %dst, ptr %src) ${FN_ATTRS} { ; Promise.all tuple`, + `entry:`, + ` %entries = call ptr @scr_promise_payload_ref(ptr %src) ; +1, moves into the builder`, + ` %tuple = call ptr @${host.callTarget(build.fnName)}(ptr %entries)`, + ` call void @scr_promise_fulfill_ref(ptr %dst, ptr %tuple, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, tuple)})`, + ` ret void`, + `}`, + ``, + ); + } + host.declare(`declare ptr @scr_promise_all(ptr, ptr, ptr)`); + host.declare(`declare void @scr_promise_all_store_promise(ptr, double, ptr)`); + host.declare(`declare ptr @scr_promise_new()`); + host.declare(`declare void @scr_promise_race_add(ptr, ptr, ptr)`); + host.declare(`declare void @scr_promise_mark_handled(ptr)`); + host.declare(`declare void @scr_promise_release(ptr)`); + host.declare(`declare double @scr_arr_len(ptr)`); + if (entriesT.kind !== "array") + throw new InternalCompilerError("llvm emitter bug: promise.allTuple entries"); + const len = B.tmp(); + const cap = B.tmp(); + B.line(`${len} = call double @scr_arr_len(ptr ${entries.name})`); + B.line(`${cap} = fptoui double ${len} to ${host.sizeType}`); + const values = B.tmp(); + B.line(`${values} = ${arrNewCall(host.shapeHost, entriesT.elem, cap)}`); + host.own({ name: values, type: entriesT }); + const all = B.tmp(); + B.line( + `${all} = call ptr @scr_promise_all(ptr ${entries.name}, ptr ${values}, ptr @scr_promise_all_store_promise)`, + ); + B.line(`call void @scr_promise_mark_handled(ptr ${all})`); + const result = B.tmp(); + B.line(`${result} = call ptr @scr_promise_new()`); + B.line(`call void @scr_promise_race_add(ptr ${result}, ptr ${all}, ptr @${adapter})`); + B.line(`call void @scr_promise_release(ptr ${all})`); + return host.own({ name: result, type: e.type }); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 75e527aa5..c30590619 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -2,7 +2,7 @@ import { keyedHelperName } from "../stable-names.js"; import { checkedPromiseAll, thenReader } from "../checked-promise-all.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; -import { newFnCtx, type Lowerer } from "../lowerer.js"; +import type { Lowerer } from "../lowerer.js"; import { varRef } from "../../../ir/build.js"; import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; @@ -15,32 +15,25 @@ import { type IrType, UNDEFINED_T, VOID, + F64, arrayOf, + funcOf, isUnitType, typeEquals, typeKey, } from "../../../ir/ir.js"; /** `Promise.all([p, q, r])` over DIFFERENT promise types — the checker's - * tuple overload, `Promise<[A, B, C]>`. One interned async helper per - * entry/result signature takes the entries as arguments, awaits the - * runtime combinator over the same promises (each widened to - * Promise, a type-only view), so settlement is Node's exactly: the - * first rejection in settlement order wins and every later one counts as - * handled; then it reads each (now fulfilled) entry and returns the - * tuple. Entries keep their identity — no value converts through the - * checked-dynamic tree. Non-promise entries (numbers, strings, booleans, - * unit values — never thenable) pass straight into their slot; a - * checked-dynamic entry (a JavaScript function's result) joins through - * PromiseResolve: the promise it holds, a promise that adopts a thenable, - * or a promise fulfilled with its plain value. The tuple - * fulfills one microtask turn after the combinator does. Null when the - * result type or an entry has no typed home here. */ -function lowerTuplePromiseAll( + * tuple overload, `Promise<[A, B, C]>`: the result type and tuple shape, + * or null when the result type or an entry has no typed home here. + * Entries are promises, checked-dynamic values (a JavaScript function's + * result) and plain numbers, strings, booleans and unit values, which are + * never thenable. */ +function tuplePromiseAllShape( lowerer: Lowerer, call: ts.CallExpression, - elems: IrExpr[], -): IrExpr | null { + elems: readonly IrExpr[], +): { resultT: IrType & { kind: "promise" }; tuple: IrType & { kind: "record" } } | null { const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); if (resultT?.kind !== "promise" || resultT.inner.kind !== "record") return null; const shape = lowerer.shapes.get(resultT.inner.shapeId); @@ -52,135 +45,135 @@ function lowerTuplePromiseAll( const settles = (t: IrType): boolean => t.kind === "promise" || t.kind === "dyn"; if (!elems.some((e) => settles(e.type))) return null; if (!elems.every((e) => settles(e.type) || plainValue(e.type))) return null; + return { resultT, tuple: resultT.inner }; +} + +/** `Promise.all([p, q, r])` over different promise types. Each entry + * becomes a promise in input order (a plain value through Promise.resolve, + * a checked-dynamic one through its PromiseResolve view: the promise it + * holds, a promise that adopts a thenable, or a promise fulfilled with the + * value), widened to Promise, a type-only view. The runtime + * combinator over them gives Node's settlement: the first rejection in + * settlement order wins and every later one counts as handled. The + * result settles inside the combinator's own settlement, so it and its + * awaits land on Node's microtask turns; a fulfillment runs one interned + * function per entry/result signature that reads the fulfilled entries + * into the tuple. Entries keep their identity: no value converts through + * the checked-dynamic tree. */ +function lowerTuplePromiseAll( + lowerer: Lowerer, + call: ts.CallExpression, + elems: IrExpr[], +): IrExpr | null { + const plan = tuplePromiseAllShape(lowerer, call, elems); + if (!plan) return null; + const { resultT, tuple } = plan; const loc = locOf(call); - const key = `${elems.map((e) => typeKey(e.type)).join(",")}->${typeKey(resultT.inner)}`; + const voidPromise: IrType & { kind: "promise" } = { kind: "promise", inner: VOID }; + const entriesT = arrayOf(voidPromise); + const dynPromise: IrType = { kind: "promise", inner: DYN }; + const entries = elems.map((e): IrExpr => { + let promise: IrExpr; + if (e.type.kind === "promise") promise = e; + else if (e.type.kind === "dyn") + promise = { + kind: "libCall", + fn: "dyn.promiseResolve", + args: [e, thenReader(lowerer, loc)], + type: dynPromise, + loc, + }; + else if (isUnitType(e.type)) { + // A unit entry's value is its type's one value; the promise only + // takes its place in the combinator. + const resolved: IrExpr = { + kind: "intrinsic", + name: "promise.resolve", + args: [], + type: voidPromise, + loc, + }; + promise = + e.kind === "unitLit" + ? resolved + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: e, loc }], + result: resolved, + type: voidPromise, + loc, + }; + } else + promise = { + kind: "intrinsic", + name: "promise.resolve", + args: [e], + type: { kind: "promise", inner: e.type }, + loc, + }; + return promise.type.kind === "promise" && promise.type.inner.kind === "void" + ? promise + : { kind: "promiseVoidWiden", value: promise, type: voidPromise, loc }; + }); + const key = `${elems.map((e) => typeKey(e.type)).join(",")}->${typeKey(tuple)}`; let name = lowerer.arrHofHelpers.get(`promise.allTuple:${key}`); if (name === undefined) { name = keyedHelperName(lowerer, "%promise.allTuple", `promise.allTuple:${key}`); lowerer.arrHofHelpers.set(`promise.allTuple:${key}`, name); - const ctx = newFnCtx(false, null, null, resultT.inner); - ctx.isAsync = true; - lowerer.fnStack.push(ctx); - try { - const params = elems.map((e, i) => ({ - localId: `entry.${i}`, - name: `entry${i}`, - type: e.type, - })); - const ref = (i: number): IrExpr => varRef(`entry.${i}`, elems[i]!.type, loc); - const dynPromise: IrType = { kind: "promise", inner: DYN }; - const body: IrStmt[] = []; - // Each dyn entry's promise view, in entry order: the promise it - // holds, a promise that adopts a thenable's settlement, or a promise - // fulfilled with the plain value (PromiseResolve's rule). - const views = new Map(); - elems.forEach((e, i) => { - if (e.type.kind !== "dyn") return; - const view = lowerer.declareHiddenLocal("%allEntry", dynPromise); - body.push({ - kind: "varDecl", - localId: view.id, - init: { - kind: "libCall", - fn: "dyn.promiseResolve", - args: [ref(i), thenReader(lowerer, loc)], - type: dynPromise, - loc, - }, - loc, - }); - views.set(i, varRef(view.id, dynPromise, loc)); - }); - const widened: IrExpr[] = []; - elems.forEach((e, i) => { - const view = views.get(i); - if (view !== undefined) { - widened.push({ - kind: "promiseVoidWiden", - value: view, - type: { kind: "promise", inner: VOID }, - loc, - }); - return; - } - if (e.type.kind !== "promise") return; - widened.push( - e.type.inner.kind === "void" - ? ref(i) - : { - kind: "promiseVoidWiden", - value: ref(i), - type: { kind: "promise", inner: VOID }, + const shape = lowerer.shapes.get(tuple.shapeId)!; + const entriesRef = varRef("entries.0", entriesT, loc); + // Shape fields are sorted by name ("10" before "2"); a tuple field's + // name is its position. + const fields = shape.fields.map((field) => { + const i = Number(field.name); + const type = elems[i]!.type; + const inner = type.kind === "promise" ? type.inner : type; + const value: IrExpr = + inner.kind === "void" + ? { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc } + : isUnitType(inner) + ? { + kind: "unitLit", + unit: inner.kind === "nullT" ? "null" : "undefined", + type: inner, loc, - }, - ); - }); - const settled: IrExpr = { - kind: "awaitExpr", - value: { - kind: "intrinsic", - name: "promise.all", - args: [ - { - kind: "arrayLit", - elems: widened, - type: arrayOf({ kind: "promise", inner: VOID }), - loc, - }, - ], - type: { kind: "promise", inner: VOID }, - loc, - }, - type: VOID, - loc, - }; - // Shape fields are sorted by name ("10" before "2"); a tuple field's - // name is its position. - const fields = shape.fields.map((field) => { - const i = Number(field.name); - const entry = elems[i]!; - let value: IrExpr; - const view = views.get(i); - if (view !== undefined) { - value = { kind: "awaitExpr", value: view, type: DYN, loc }; - } else if (entry.type.kind === "promise") { - value = - entry.type.inner.kind === "void" - ? { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc } - : { kind: "awaitExpr", value: ref(i), type: entry.type.inner, loc }; - } else { - value = ref(i); - } - return { name: field.name, value: lowerer.coerceToExpected(value, field.type) }; - }); - body.push( - { kind: "exprStmt", expr: settled, loc }, - { - kind: "return", - value: { kind: "recordLit", fields, type: resultT.inner, loc }, - loc, - }, - ); - lowerer.liftedFns.push({ - name, - params, - returnType: resultT.inner, - locals: [ - ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), - ...lowerer.ctx.locals, - ], - body, - async: true, - loc, - }); - } catch (e) { - lowerer.arrHofHelpers.delete(`promise.allTuple:${key}`); - throw e; - } finally { - lowerer.fnStack.pop(); - } + } + : { + kind: "libCall", + fn: "promise.fulfilledValue", + args: [ + { + kind: "arrayGet", + arr: entriesRef, + index: { kind: "numLit", value: i, type: F64, loc }, + type: voidPromise, + loc, + }, + ], + type: inner, + loc, + }; + return { name: field.name, value: lowerer.coerceToExpected(value, field.type) }; + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "entries.0", name: "entries", type: entriesT }], + returnType: tuple, + locals: [{ id: "entries.0", name: "entries", type: entriesT, mutable: false }], + body: [{ kind: "return", value: { kind: "recordLit", fields, type: tuple, loc }, loc }], + loc, + }); } - return { kind: "call", callee: name, args: elems, type: resultT, loc }; + return { + kind: "libCall", + fn: "promise.allTuple", + args: [ + { kind: "arrayLit", elems: entries, type: entriesT, loc }, + { kind: "closure", fnName: name, captures: [], type: funcOf([entriesT], tuple), loc }, + ], + type: resultT, + loc, + }; } /** `Promise.race([...])` on THE Promise global: the entries lower diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index fcdbe8694..d44f3ebbf 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3139,6 +3139,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // or an optional Error | null slot (same success shape as fs.rename). "process.stdoutWriteBytesCb": { argTypes: [BYTES_U8, STRING, null], result: BOOL }, "process.stderrWriteBytesCb": { argTypes: [BYTES_U8, STRING, null], result: BOOL }, + /** The value of a FULFILLED promise, read without a microtask turn (+1 + * for references): the tuple form of Promise.all reads its entries this + * way once the combinator fulfills, each entry widened to Promise + * and read at the type it was built with (program-dependent). */ + "promise.fulfilledValue": { argTypes: [null], result: VOID }, + /** Promise.all's tuple form as a value: the entries (each widened to + * Promise) join the runtime combinator, and when it fulfills the + * function reads the fulfilled entries into the tuple inside that + * settlement, so the result settles on the combinator's turn; a + * rejection settles it the same way. The result is the tuple's promise + * (program-dependent). */ + "promise.allTuple": { argTypes: [null, null], result: VOID }, "fsp.readFile": { argTypes: [STRING, STRING], result: { kind: "promise", inner: STRING } }, "fsp.writeFile": { argTypes: [STRING, STRING], result: { kind: "promise", inner: VOID } }, /** fs.promises.writeFile(path, data, { mode }): the settled-promise diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index bb36c7007..08c237fe2 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5310,6 +5310,32 @@ function validateFunction( } function checkLibSpecialCall(e: IrExpr & { kind: "libCall" }): boolean { + if (e.fn === "promise.fulfilledValue") { + const promise = e.args[0]?.type; + if ( + promise?.kind !== "promise" || + e.type.kind === "void" || + isUnitType(e.type) || + promise.inner.kind !== "void" + ) + err(`libCall ${e.fn} must read a Promise entry at a non-void type`, e.loc); + return true; + } + if (e.fn === "promise.allTuple") { + const [entries, build] = e.args; + if ( + entries?.type.kind !== "array" || + entries.type.elem.kind !== "promise" || + entries.type.elem.inner.kind !== "void" || + build?.kind !== "closure" || + build.captures.length > 0 || + build.type.kind !== "func" || + e.type.kind !== "promise" || + !typeEquals(build.type.ret, e.type.inner) + ) + err(`libCall ${e.fn} must join Promise entries into its builder's tuple`, e.loc); + return true; + } if ( e.fn === "crypto.randomFillView" || e.fn === "crypto.randomFillViewRest" || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7035-promise-all-tuple-turns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7035-promise-all-tuple-turns.ts.json new file mode 100644 index 000000000..29b935e93 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7035-promise-all-tuple-turns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7035-promise-all-tuple-turns.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 9e3365fbe..5bdfe87a5 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1839,6 +1839,12 @@ void scr_promise_all_store_ref(ScrArr *a, double i, ScrPromise *src) { scr_arr_set_ref(a, i, scr_promise_payload_ref(src)); } +/* The entry promise itself: Promise.all's tuple form reads each fulfilled + * entry at its own type once the combinator fulfills. */ +void scr_promise_all_store_promise(ScrArr *a, double i, ScrPromise *src) { + scr_arr_set_ref(a, i, scr_promise_retain(src)); +} + /* Fulfillment payload accessors for the emitted race adapters — all * retained/by-value (the source keeps its settlement). */ double scr_promise_payload_f64(ScrPromise *p) { return p->f64; } diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index fc0128294..cc79ee1a1 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -369,6 +369,8 @@ void scr_promise_all_store_f64(ScrArr *a, double i, ScrPromise *src); void scr_promise_all_store_bool(ScrArr *a, double i, ScrPromise *src); void scr_promise_all_store_str(ScrArr *a, double i, ScrPromise *src); void scr_promise_all_store_ref(ScrArr *a, double i, ScrPromise *src); +/* Stores the fulfilled entry promise itself (+1), for Promise.all's tuple form. */ +void scr_promise_all_store_promise(ScrArr *a, double i, ScrPromise *src); void scr_promise_adapt_copy(ScrPromise *dst, ScrPromise *src); double scr_promise_payload_f64(ScrPromise *p); bool scr_promise_payload_bool(ScrPromise *p); diff --git a/tests/corpus/7035-promise-all-tuple-turns.ts b/tests/corpus/7035-promise-all-tuple-turns.ts new file mode 100644 index 000000000..6989df9b9 --- /dev/null +++ b/tests/corpus/7035-promise-all-tuple-turns.ts @@ -0,0 +1,64 @@ +// Promise.all over entries of different types settles, and resumes its +// awaits and then callbacks, on the same microtask turns as Node, whether +// the result is awaited at once, stored first, chained with then, or +// returned from an async function. The first rejection in settlement +// order wins, later ones count as handled, and record entries keep their +// identity. +const log: string[] = []; +function later(label: string, turns: number, value: T, fail = false): Promise { + return new Promise((resolve, reject) => { + let left = turns; + const step = (): void => { + if (left-- > 0) return queueMicrotask(step); + log.push(`settle ${label}`); + if (fail) reject(new Error(label)); + else resolve(value); + }; + step(); + }); +} +async function num(): Promise { + return 1; +} +async function text(): Promise { + return "s"; +} +async function both(): Promise<[number, string]> { + return Promise.all([num(), text()]); +} +interface Item { + id: number; +} +const item: Item = { id: 7 }; + +async function main(): Promise { + let ticking = true; + void (async () => { + for (let i = 0; i < 40 && ticking; i++) { + log.push(`t${i}`); + await null; + } + })(); + const [a, b] = await Promise.all([num(), text()]); + log.push(`awaited ${a}${b}`); + const stored = Promise.all([text(), num(), Promise.resolve(item)]); + const [c, d, e] = await stored; + log.push(`stored ${c}${d} ${e === item}`); + Promise.all([text(), num()]).then(([f, g]) => log.push(`then ${f}${g}`)); + const [h, i] = await both(); + log.push(`returned ${h}${i}`); + const mixed = await Promise.all([later("x", 3, "x"), 5, later("y", 1, true), undefined]); + log.push(`mixed ${mixed[0]} ${mixed[1]} ${mixed[2]} ${mixed[3]}`); + const raw: any = JSON.parse('{"value":41}'); + const [dynamic, counted] = await Promise.all([raw.value, later("z", 2, 2)]); + log.push(`dynamic ${dynamic + counted}`); + try { + await Promise.all([later("late", 4, 1, true), later("early", 2, "e", true), num()]); + } catch (err) { + log.push(`rejected ${(err as Error).message}`); + } + await later("tail", 6, 0); + ticking = false; + console.log(log.join("\n")); +} +void main(); diff --git a/tests/harness/corpus-costs/7035-promise-all-tuple-turns.ts.json b/tests/harness/corpus-costs/7035-promise-all-tuple-turns.ts.json new file mode 100644 index 000000000..9e7986c22 --- /dev/null +++ b/tests/harness/corpus-costs/7035-promise-all-tuple-turns.ts.json @@ -0,0 +1 @@ +{ "plain": 2910, "san": 4860 } From 74fcda0ad93deb463249c9a61c244f5e9d57ded2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 11:17:42 -0500 Subject: [PATCH 02/20] Evaluate every Promise.all entry before a dynamic entry's then lookup A checked-dynamic entry's PromiseResolve reads its `then` property, which can be a getter. The tuple form resolved each entry as the array was built, so that getter ran before later entries evaluated; Node evaluates the whole array first. With a checked-dynamic entry, every entry now evaluates into a hidden local before the entries resolve. --- .../frontend/lowering/builtins/promises.ts | 28 +++++++++++++++++-- tests/corpus/7035-promise-all-tuple-turns.ts | 13 +++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index c30590619..3aecf87e3 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -72,7 +72,28 @@ function lowerTuplePromiseAll( const voidPromise: IrType & { kind: "promise" } = { kind: "promise", inner: VOID }; const entriesT = arrayOf(voidPromise); const dynPromise: IrType = { kind: "promise", inner: DYN }; - const entries = elems.map((e): IrExpr => { + // A checked-dynamic entry's PromiseResolve can run a `then` getter, which + // Node runs after the whole array literal evaluated. With such an entry, + // every entry evaluates into a hidden local first. + const staged: IrStmt[] = []; + const values = !elems.some((e) => e.type.kind === "dyn") + ? elems + : elems.map((e): IrExpr => { + if (e.kind === "unitLit") return e; + if (isUnitType(e.type)) { + staged.push({ kind: "exprStmt", expr: e, loc }); + return { + kind: "unitLit", + unit: e.type.kind === "nullT" ? "null" : "undefined", + type: e.type, + loc, + }; + } + const local = lowerer.declareHiddenLocal("%allValue", e.type); + staged.push({ kind: "varDecl", localId: local.id, init: e, loc }); + return varRef(local.id, e.type, loc); + }); + const entries = values.map((e): IrExpr => { let promise: IrExpr; if (e.type.kind === "promise") promise = e; else if (e.type.kind === "dyn") @@ -164,7 +185,7 @@ function lowerTuplePromiseAll( loc, }); } - return { + const all: IrExpr = { kind: "libCall", fn: "promise.allTuple", args: [ @@ -174,6 +195,9 @@ function lowerTuplePromiseAll( type: resultT, loc, }; + return staged.length === 0 + ? all + : { kind: "seqExpr", stmts: staged, result: all, type: resultT, loc }; } /** `Promise.race([...])` on THE Promise global: the entries lower diff --git a/tests/corpus/7035-promise-all-tuple-turns.ts b/tests/corpus/7035-promise-all-tuple-turns.ts index 6989df9b9..1f8a8803d 100644 --- a/tests/corpus/7035-promise-all-tuple-turns.ts +++ b/tests/corpus/7035-promise-all-tuple-turns.ts @@ -26,6 +26,16 @@ async function text(): Promise { async function both(): Promise<[number, string]> { return Promise.all([num(), text()]); } +const thenable: any = { + get then() { + log.push("then getter"); + return (resolve: (v: number) => void) => resolve(1); + }, +}; +function evaluated(): Promise { + log.push("second entry evaluated"); + return Promise.resolve("s"); +} interface Item { id: number; } @@ -49,6 +59,9 @@ async function main(): Promise { log.push(`returned ${h}${i}`); const mixed = await Promise.all([later("x", 3, "x"), 5, later("y", 1, true), undefined]); log.push(`mixed ${mixed[0]} ${mixed[1]} ${mixed[2]} ${mixed[3]}`); + // A dynamic entry's `then` getter runs after the whole array evaluated. + const [fromThenable, second] = await Promise.all([thenable, evaluated()]); + log.push(`thenable ${fromThenable} ${second}`); const raw: any = JSON.parse('{"value":41}'); const [dynamic, counted] = await Promise.all([raw.value, later("z", 2, 2)]); log.push(`dynamic ${dynamic + counted}`); From c009b582e2da398c6e229fab779b9354325fae96 Mon Sep 17 00:00:00 2001 From: Luis Alvarez D Date: Thu, 22 Oct 2026 23:05:28 -0500 Subject: [PATCH 03/20] 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 2b28c6533..eb05550c0 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 b5f99d682..d032cb263 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 6ca21ddaa..a86decf4c 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 b04ba0b09..d10d3b9d2 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 c4d24cef1..f1e7606f2 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 000000000..c01617f80 --- /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 000000000..435dcaeab --- /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 000000000..a279f456e --- /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 000000000..b318eb85c --- /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 000000000..974421bd3 --- /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 000000000..a3c15a7a6 --- /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 000000000..584de98fe --- /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 000000000..9a931bdef --- /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 000000000..31b476c4e --- /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 000000000..9d570f700 --- /dev/null +++ b/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 12000 } From b9275c2693597c3e79287fcbedc80f5d49814d5f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 03:05:19 -0500 Subject: [PATCH 04/20] buffer: fromArrayBuffer ranges, the deprecated constructor, Buffer as a value --- .../limitations/buffer-construction.mdx | 5 + .../ambient/scriptc-node-fallback.d.ts | 8 + .../compiler/src/backend/llvm/lib-shared.ts | 9 + .../src/frontend/lowering/containers/bytes.ts | 259 ++++++++++++++---- .../lowering/expressions/element-access.ts | 3 + .../lowering/expressions/identifiers.ts | 3 + .../lowering/expressions/property-access.ts | 45 +++ .../src/frontend/lowering/lower-calls.ts | 9 +- .../src/frontend/lowering/lower-classes.ts | 45 +-- .../src/frontend/lowering/lower-inspect.ts | 20 +- .../compiler/src/frontend/lowering/lowerer.ts | 5 + .../src/frontend/lowering/surfaces.ts | 3 + packages/compiler/src/frontend/program.ts | 5 + packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 9 + packages/compiler/src/ir/int32-slots.ts | 2 + packages/compiler/src/ir/runtime-features.ts | 21 +- .../7527-buffer-from-array-buffer.ts.json | 6 + .../7528-buffer-from-array-buffer.cjs.json | 6 + .../corpus/7529-buffer-constructor.cjs.json | 6 + .../corpus/7530-buffer-constructor.ts.json | 6 + .../main.cjs.json | 18 ++ .../main.cjs.json | 18 ++ .../tests/corpus/7533-buffer-value.cjs.json | 6 + .../7534-buffer-prototype-members.cjs.json | 6 + .../7535-inspect-function-properties.cjs.json | 6 + packages/runtime/src/scr_async.h | 4 + packages/runtime/src/scr_async_dyn.c | 16 ++ packages/runtime/src/scr_builtin_values.c | 214 +++++++++++++++ packages/runtime/src/scr_bytes.c | 20 +- packages/runtime/src/scr_bytes.h | 27 ++ packages/runtime/src/scr_bytes_dyn.c | 203 ++++++++++++++ packages/runtime/src/scr_bytes_io.c | 66 ++++- packages/runtime/src/scr_dyn.h | 4 + packages/runtime/src/scr_inspect.c | 40 ++- packages/runtime/src/scr_json.c | 22 +- tests/corpus/2570-buffer-arg-validation.cjs | 3 +- tests/corpus/7527-buffer-from-array-buffer.ts | 33 +++ .../corpus/7528-buffer-from-array-buffer.cjs | 31 +++ tests/corpus/7529-buffer-constructor.cjs | 80 ++++++ tests/corpus/7530-buffer-constructor.ts | 42 +++ .../main.cjs | 36 +++ .../node_modules/legacy-buf/index.d.ts | 3 + .../node_modules/legacy-buf/index.js | 15 + .../node_modules/legacy-buf/package.json | 5 + .../main.cjs | 10 + .../node_modules/legacy-buf/index.d.ts | 3 + .../node_modules/legacy-buf/index.js | 15 + .../node_modules/legacy-buf/package.json | 5 + tests/corpus/7533-buffer-value.cjs | 101 +++++++ .../corpus/7534-buffer-prototype-members.cjs | 29 ++ .../7535-inspect-function-properties.cjs | 29 ++ .../7527-buffer-from-array-buffer.ts.json | 1 + .../7528-buffer-from-array-buffer.cjs.json | 1 + .../7529-buffer-constructor.cjs.json | 1 + .../7530-buffer-constructor.ts.json | 1 + .../main.cjs.json | 1 + .../main.cjs.json | 1 + .../corpus-costs/7533-buffer-value.cjs.json | 1 + .../7534-buffer-prototype-members.cjs.json | 1 + .../7535-inspect-function-properties.cjs.json | 1 + tests/harness/differential.test.ts | 6 +- tests/harness/errors.test.ts | 49 ++++ 63 files changed, 1535 insertions(+), 117 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7527-buffer-from-array-buffer.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7528-buffer-from-array-buffer.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7529-buffer-constructor.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7530-buffer-constructor.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7531-buffer-constructor-dependency/main.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7532-buffer-constructor-check-limit/main.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7533-buffer-value.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7534-buffer-prototype-members.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7535-inspect-function-properties.cjs.json create mode 100644 tests/corpus/7527-buffer-from-array-buffer.ts create mode 100644 tests/corpus/7528-buffer-from-array-buffer.cjs create mode 100644 tests/corpus/7529-buffer-constructor.cjs create mode 100644 tests/corpus/7530-buffer-constructor.ts create mode 100644 tests/corpus/7531-buffer-constructor-dependency/main.cjs create mode 100644 tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.d.ts create mode 100644 tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.js create mode 100644 tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/package.json create mode 100644 tests/corpus/7532-buffer-constructor-check-limit/main.cjs create mode 100644 tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.d.ts create mode 100644 tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.js create mode 100644 tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/package.json create mode 100644 tests/corpus/7533-buffer-value.cjs create mode 100644 tests/corpus/7534-buffer-prototype-members.cjs create mode 100644 tests/corpus/7535-inspect-function-properties.cjs create mode 100644 tests/harness/corpus-costs/7527-buffer-from-array-buffer.ts.json create mode 100644 tests/harness/corpus-costs/7528-buffer-from-array-buffer.cjs.json create mode 100644 tests/harness/corpus-costs/7529-buffer-constructor.cjs.json create mode 100644 tests/harness/corpus-costs/7530-buffer-constructor.ts.json create mode 100644 tests/harness/corpus-costs/7531-buffer-constructor-dependency/main.cjs.json create mode 100644 tests/harness/corpus-costs/7532-buffer-constructor-check-limit/main.cjs.json create mode 100644 tests/harness/corpus-costs/7533-buffer-value.cjs.json create mode 100644 tests/harness/corpus-costs/7534-buffer-prototype-members.cjs.json create mode 100644 tests/harness/corpus-costs/7535-inspect-function-properties.cjs.json diff --git a/docs/content/limitations/buffer-construction.mdx b/docs/content/limitations/buffer-construction.mdx index aca0c74ca..c88e01336 100644 --- a/docs/content/limitations/buffer-construction.mdx +++ b/docs/content/limitations/buffer-construction.mdx @@ -1,3 +1,8 @@ `Buffer.from` accepts untyped strings, byte arrays, arrays, and plain array-like or Buffer JSON objects. Encoding arguments must be supported literals. +`Buffer.from(arrayBuffer, byteOffset, length)` is a view of the ArrayBuffer or SharedArrayBuffer, with Node's offset and length checks. Custom input `valueOf` hooks and opaque class or native-handle inputs throw an unsupported-operation error. +The deprecated `Buffer()` and `new Buffer()` take up to three arguments, without spreads, and follow Node: the first call made outside `node_modules` reports DEP0005 on the next tick, a number allocates zero-filled bytes, and other arguments convert as `Buffer.from` does. +In JavaScript, `Buffer` read as a value (the global or the buffer module's `Buffer`) is one function with Node's statics and `poolSize`. +Calling or constructing it through a stored value, reading its `prototype`, and its `copyBytesFrom` and `of` statics throw an unsupported-operation error. +`Buffer.prototype` members read as values are functions that run on Buffer receivers; inherited and symbol-keyed members throw an unsupported-operation error. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b195c3fbb..e26faf956 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -917,6 +917,14 @@ interface Buffer< readBigUint64LE(offset?: number): bigint; } interface BufferConstructor { + /** @deprecated since v10.0.0 - Use `Buffer.from(string[, encoding])` instead. */ + new (str: string, encoding?: BufferEncoding): Buffer; + /** @deprecated since v10.0.0 - Use `Buffer.alloc()` instead. */ + new (size: number): Buffer; + /** @deprecated since v10.0.0 - Use `Buffer.from(array)` instead. */ + new (array: ArrayLike): Buffer; + /** @deprecated since v10.0.0 - Use `Buffer.from(arrayBuffer[, byteOffset[, length]])` instead. */ + new (arrayBuffer: ArrayBuffer | SharedArrayBuffer): Buffer; from(data: string, encoding?: BufferEncoding): Buffer; /* The ArrayBuffer form is the VIEW construction: x.buffer with an * optional byte offset/length shares x's storage (the subarray rule). */ diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 7dd4370c5..b92047e43 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -454,6 +454,7 @@ export const LIB_FN_SYMS: Record = { "buffer.poolFromDyn": "scr_buffer_from_dyn_pooled", "buffer.poolFromDynRef": "scr_buffer_from_dyn_ref_pooled", "buffer.poolFromDynOffset": "scr_buffer_from_dyn_offset_pooled", + "buffer.poolFromDynRange": "scr_buffer_from_dyn_range_pooled", "buffer.poolSize": "scr_buffer_pool_size", "buffer.setPoolSize": "scr_buffer_set_pool_size", "buffer.byteLenDyn": "scr_buffer_byte_length_dyn", @@ -479,6 +480,12 @@ export const LIB_FN_SYMS: Record = { "bytes.equalsChk": "scr_bytes_equals_chk", "bytes.compareChk": "scr_bytes_compare_chk", "buffer.newStringFail": "scr_buffer_new_string_fail", + "buffer.deprecation": "scr_buffer_deprecation", + "buffer.deprecationCount": "scr_buffer_deprecation_count", + "buffer.constructor": "scr_buffer_constructor_value", + "buffer.installValue": "scr_buffer_install_value", + "buffer.staticValue": "scr_buffer_static_value", + "buffer.prototypeMember": "scr_buffer_prototype_member", "fs.toUnixTimestamp": "scr_fs_to_unix_timestamp", "fs.existsChk": "scr_fs_exists_async", "fs.mkdtempSyncChk": "scr_fs_mkdtemp_sync_chk", @@ -1661,6 +1668,8 @@ export const USES_TIMERS_LIB_FNS = new Set([ "url.legacyParse", "url.legacyFormat", "url.legacyResolve", + // The deprecated Buffer constructor reports DEP0005 on the next tick. + "buffer.deprecation", // The wrappers schedule warnings and callbacks on the tick queue. "util.deprecate", "util.callbackify", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 995cb177a..82c6f3052 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -15,6 +15,7 @@ import { type IrLibFn, type IrLocal, type IrParam, + type IrFunction, type IrStmt, type IrType, STRING, @@ -30,7 +31,8 @@ import { import { isJsSourceFile, locOf } from "../../program.js"; import { stdlibGlobalNameOf } from "../surfaces.js"; import type { Lowerer } from "../lowerer.js"; -import { newFnCtx, own } from "../lowerer.js"; +import { ENTRY_NAME, newFnCtx, own } from "../lowerer.js"; +import { everyStmt } from "../../../ir/traverse.js"; import { buildBytesSortFn, closureKeyOrder } from "../lower-array-sort.js"; import { isSafeToDiscard } from "../expressions/evaluation-safety.js"; import { lowerDynObjectLiteral } from "../expressions/object-literals.js"; @@ -1807,7 +1809,12 @@ function knownBufferProducer( * or empty encoding and throws ERR_UNKNOWN_ENCODING for an unknown name; an * ArrayBuffer takes the second argument as its byte offset; other sources * ignore it. */ -function dynBufferFromParts(source: IrExpr, encoding: IrExpr, loc: SrcLoc): IrExpr { +function dynBufferFromParts( + source: IrExpr, + encoding: IrExpr, + loc: SrcLoc, + length?: IrExpr, +): IrExpr { const utf8: IrExpr = { kind: "strLit", value: "utf8", type: STRING, loc }; const isString = (x: IrExpr): IrExpr => ({ kind: "dynTest", @@ -1848,20 +1855,48 @@ function dynBufferFromParts(source: IrExpr, encoding: IrExpr, loc: SrcLoc): IrEx type: BYTES_U8, loc, }, - // An ArrayBuffer takes the second argument as its byte offset - // (scr_buffer_from_dyn_offset_pooled); other values convert with utf8. - else_: { - kind: "libCall", - fn: "buffer.poolFromDynOffset", - args: [source, encoding], - type: BYTES_U8, - loc, - }, + // An ArrayBuffer takes the second argument as its byte offset and the + // third as its length (scr_buffer_from_dyn_range_pooled); other values + // convert with utf8. + else_: length + ? { + kind: "libCall", + fn: "buffer.poolFromDynRange", + args: [source, encoding, length], + type: BYTES_U8, + loc, + } + : { + kind: "libCall", + fn: "buffer.poolFromDynOffset", + args: [source, encoding], + type: BYTES_U8, + loc, + }, type: BYTES_U8, loc, }; } +/** Buffer.from(arrayBuffer, byteOffset[, length]): each argument in order + * as a checked-dynamic value, read by the runtime as Node's + * fromArrayBuffer reads them. */ +function bufferFromArrayBufferRange( + lowerer: Lowerer, + args: readonly ts.Expression[], + loc: SrcLoc, + first?: IrExpr, +): IrExpr { + const operands = [0, 1, 2].map((i) => + i === 0 && first + ? first + : args[i] + ? lowerer.lowerExprExpecting(args[i]!, DYN) + : dynUndefinedExpr(loc), + ); + return { kind: "libCall", fn: "buffer.poolFromDynRange", args: operands, type: BYTES_U8, loc }; +} + /** Lowers `source` and `encNode` (or undefined) into hidden dyn locals, in * source order, and builds the result from their references. */ function withDynBufferArguments( @@ -1909,52 +1944,81 @@ function dynBufferFromRuntimeEncoding( ); } -/** The deprecated `new Buffer(arg[, encoding])` in a dependency's - * JavaScript, through the global or an alias of it (buffer-crc32 and yazl - * keep it as the fallback for runtimes without Buffer.alloc/Buffer.from). - * Node never emits DEP0005 for calls from node_modules, so the construction - * is exactly Node's: a number allocates zero-filled bytes (and throws - * ERR_INVALID_ARG_TYPE beside a string second argument), anything else - * converts as Buffer.from does. Null elsewhere: other callers owe the - * deprecation warning. */ -export function lowerNodeModulesBufferConstruct( +/** The deprecated `Buffer(arg[, encodingOrOffset[, length]])`, called or + * constructed through the global or an alias of it, as Node's Buffer + * function runs it: the arguments evaluate in order, DEP0005 is reported + * on the next tick (once, and never for a call made from node_modules, + * which only counts toward Node's 10000-call check), then a number + * allocates zero-filled bytes (and throws ERR_INVALID_ARG_TYPE beside a + * string second argument) and anything else converts as Buffer.from does. + * Null for spread arguments and more than three. */ +export function lowerBufferConstruct( lowerer: Lowerer, - expr: ts.NewExpression, + expr: ts.NewExpression | ts.CallExpression, ): IrExpr | null { - const sf = expr.getSourceFile(); - if (!isJsSourceFile(sf) || !/[\\/]node_modules[\\/]/.test(sf.fileName)) return null; + if (ts.isCallExpression(expr) && expr.questionDotToken) return null; const args = expr.arguments?.nodes ?? []; - if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) return null; + if (args.length > 3 || args.some(ts.isSpreadElement)) return null; if (stdlibGlobalNameOf(lowerer, expr.expression) !== "Buffer") return null; const loc = locOf(expr); - const value = withDynBufferArguments( - lowerer, - lowerer.lowerExpr(args[0]!), - args[1], - loc, - (source, encoding) => ({ + const locals = [0, 1, 2].slice(0, Math.max(args.length, 2)).map((i) => { + const local = lowerer.declareHiddenLocal("%bufferArgument", DYN); + const node = args[i]; + const init = node === undefined ? dynUndefinedExpr(loc) : lowerer.lowerExprExpecting(node, DYN); + return { local, init }; + }); + const [source, second, third] = locals.map(({ local }) => varRef(local.id, DYN, loc)) as [ + IrExpr, + IrExpr, + IrExpr | undefined, + ]; + const inNodeModules = /[\\/]node_modules[\\/]/.test(expr.getSourceFile().fileName); + const value: IrExpr = { + kind: "ternary", + cond: { kind: "dynTest", test: "number", value: source, type: BOOL, loc }, + then: { kind: "ternary", - cond: { kind: "dynTest", test: "number", value: source, type: BOOL, loc }, - then: { - kind: "ternary", - cond: { kind: "dynTest", test: "string", value: encoding, type: BOOL, loc }, - then: { kind: "libCall", fn: "buffer.newStringFail", args: [source], type: BYTES_U8, loc }, - else_: { - kind: "libCall", - fn: "buffer.alloc", - args: [{ kind: "dynCheck", value: source, type: F64, loc }], - type: BYTES_U8, - loc, - }, + cond: { kind: "dynTest", test: "string", value: second, type: BOOL, loc }, + then: { kind: "libCall", fn: "buffer.newStringFail", args: [source], type: BYTES_U8, loc }, + else_: { + kind: "libCall", + fn: "buffer.alloc", + args: [{ kind: "dynCheck", value: source, type: F64, loc }], type: BYTES_U8, loc, }, - else_: dynBufferFromParts(source, encoding, loc), type: BYTES_U8, loc, - }), - ); - return { kind: "libCall", fn: "buffer.brand", args: [value], type: BYTES_U8, loc }; + }, + else_: dynBufferFromParts(source, second, loc, third), + type: BYTES_U8, + loc, + }; + return { + kind: "seqExpr", + stmts: [ + ...locals.map(({ local, init }): IrStmt => ({ + kind: "varDecl", + localId: local.id, + init, + loc, + })), + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: inNodeModules ? "buffer.deprecationCount" : "buffer.deprecation", + args: [], + type: VOID, + loc, + }, + loc, + }, + ], + result: { kind: "libCall", fn: "buffer.brand", args: [value], type: BYTES_U8, loc }, + type: BYTES_U8, + loc, + }; } /** Buffer.from(array or bytes, extra): Node copies an array-like source and @@ -2018,7 +2082,16 @@ function lowerBufferStaticValue( lowerer.typeOf(args[0]!).getSymbol(), ) ) { - return lowerArrayBufferView(lowerer, args, "u8", loc); + // A byte offset or length follows Node's fromArrayBuffer (its + // conversions and ERR_BUFFER_OUT_OF_BOUNDS), not the typed-array view. + if (args.length === 1) return lowerArrayBufferView(lowerer, args, "u8", loc); + return bufferFromArrayBufferRange(lowerer, args, loc); + } + // Buffer.from(value, byteOffset, length) over an untyped value: an + // ArrayBuffer's range, or the two-argument conversion of other values. + if (args.length === 3 && !args.some(ts.isSpreadElement)) { + const first = lowerer.lowerExpr(args[0]!); + if (first.type.kind === "dyn") return bufferFromArrayBufferRange(lowerer, args, loc, first); } // Buffer.from(x.buffer[, byteOffset[, length]]): a u8 VIEW sharing // x's storage (Node shares the ArrayBuffer; length is in BYTES). @@ -2445,11 +2518,101 @@ function lowerBufferStaticValue( const s = lowerer.lowerExprExpecting(args[0]!, STRING); return { kind: "libCall", fn: "buffer.isEncoding", args: [s], type: BOOL, loc }; } + // Any other single argument: Node answers false for non-strings. + if (args.length === 1 && !ts.isSpreadElement(args[0]!)) { + const value = lowerer.declareHiddenLocal("%bufferEncoding", DYN); + const ref = varRef(value.id, DYN, loc); + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: value.id, + init: lowerer.lowerExprExpecting(args[0]!, DYN), + loc, + }, + ], + result: { + kind: "logical", + op: "&&", + left: { kind: "dynTest", test: "string", value: ref, type: BOOL, loc }, + right: { + kind: "libCall", + fn: "buffer.isEncoding", + args: [{ kind: "dynCheck", value: ref, type: STRING, loc }], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } lowerer.noLowering( "Buffer.isEncoding with this argument shape", call, - "one string-typed argument is the lowered form", + "one argument is the lowered form", ); } return null; // of, copyBytesFrom, ... → the SC2020 member fence } + +/** `Buffer.prototype.` or `Buffer.prototype[key]` read in JavaScript + * (smart-buffer's `typeof Buffer.prototype[method]` probe): the member as + * scr_buffer_prototype_member answers it. Null for other expressions. */ +export function lowerBufferPrototypeMember( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): IrExpr | null { + if (expr.questionDotToken || !isJsSourceFile(expr.getSourceFile())) return null; + const receiver = expr.expression; + if ( + !ts.isPropertyAccessExpression(receiver) || + receiver.questionDotToken || + receiver.name.text !== "prototype" || + stdlibGlobalNameOf(lowerer, receiver.expression) !== "Buffer" + ) + return null; + const loc = locOf(expr); + const key: IrExpr = ts.isPropertyAccessExpression(expr) + ? { + kind: "dynFrom", + value: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + } + : lowerer.lowerExprExpecting(expr.argumentExpression, DYN); + return { kind: "libCall", fn: "buffer.prototypeMember", args: [key], type: DYN, loc }; +} + +const BUFFER_VALUE_FNS: ReadonlySet = new Set([ + "buffer.constructor", + "buffer.staticValue", + "buffer.prototypeMember", +]); + +/** A program that reads the Buffer function's value installs it in %main, + * so a Buffer's `.constructor` answers the same function. */ +export function installBufferValue(functions: IrFunction[]): void { + let found = false; + const visitor = { + expr: (e: IrExpr): boolean => { + if (e.kind === "libCall" && BUFFER_VALUE_FNS.has(e.fn)) found = true; + return !found; + }, + stmt: (): boolean => !found, + }; + for (const fn of functions) { + for (const s of fn.body) everyStmt(s, visitor); + if (found) break; + } + const main = functions.find((fn) => fn.name === ENTRY_NAME); + if (!found || !main) return; + main.body.unshift({ + kind: "exprStmt", + expr: { kind: "libCall", fn: "buffer.installValue", args: [], type: VOID, loc: main.loc }, + loc: main.loc, + }); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 0ea94ec7a..24de38630 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -71,6 +71,7 @@ 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 { lowerBufferPrototypeMember } from "../containers/bytes.js"; /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` @@ -104,6 +105,8 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre const en = lowerEnumAccess(lowerer, expr); if (en) return en; } + const bufferMember = lowerBufferPrototypeMember(lowerer, expr); + if (bufferMember) return bufferMember; const cachedModule = lowerRequireCacheElement(lowerer, expr); if (cachedModule) return cachedModule; const namespaceElement = lowerModuleNamespaceElement(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index ef0d8a2d1..1570ea709 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -744,6 +744,9 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: return { kind: "libCall", fn: "webcrypto.object", args: [], type: DYN, loc }; const nativeAlias = stdlibGlobalNameOf(lowerer, expr); if (nativeAlias === "Buffer" && expr.text !== "Buffer") { + // JavaScript reads the Buffer function's value (builtinGlobalValue). + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) + return { kind: "libCall", fn: "buffer.constructor", args: [], type: DYN, loc }; return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc }; } if (lowerer.isStdlibGlobal(expr, "fetch")) { diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 7b12297a8..607ad221b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -153,6 +153,7 @@ import { lowerStringCodesValue, } from "../lower-builtin-values.js"; import { lowerArrayFromValue } from "../containers/array-construction.js"; +import { lowerBufferPrototypeMember } from "../containers/bytes.js"; import { lowerNetAddressStaticRead } from "../builtins/net-address.js"; import { STATS_TYPE_TESTS } from "../builtins/filesystem.js"; import { arrayValueRead, arrayValueType } from "../array-values.js"; @@ -310,6 +311,10 @@ export function lowerPropertyAccessExpr( // Cloudflare probe) folds like `navigator.userAgent`. const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION); if (navigator) return navigator; + { + const member = lowerBufferPrototypeMember(lowerer, expr); + if (member) return member; + } // A dgram socket's method read as a value in JavaScript (`emit: // socket.emit.bind(socket)`): the socket crosses as its checked-dynamic // handle, whose methods are function values that dispatch on their @@ -1769,6 +1774,28 @@ export function lowerIntrinsicProperty( return lowerNumberParserValue(lowerer, numberMember, locOf(expr)); if (lowerer.stdlibGlobalMember(expr, "Buffer") === "poolSize") return { kind: "libCall", fn: "buffer.poolSize", args: [], type: F64, loc: locOf(expr) }; + { + const member = lowerer.stdlibGlobalMember(expr, "Buffer"); + if (member === "name") + return { kind: "strLit", value: "Buffer", type: STRING, loc: locOf(expr) }; + if (member === "length") return numLit(3, locOf(expr)); + // JavaScript reads the statics from the Buffer function's value, so + // each has one identity and Node's name and length. + if ( + member !== null && + BUFFER_VALUE_STATICS.has(member) && + isJsSourceFile(ts.getSourceFileOfNode(expr)) + ) { + const loc = locOf(expr); + return { + kind: "libCall", + fn: "buffer.staticValue", + args: [{ kind: "strLit", value: member, type: STRING, loc }], + type: DYN, + loc, + }; + } + } const netAddressStatic = lowerNetAddressStaticRead(lowerer, expr); if (netAddressStatic) return netAddressStatic; if (lowerer.stdlibGlobalMember(expr, "Buffer") === "isBuffer") @@ -4173,6 +4200,8 @@ export function builtinFunctionValue(name: string, loc: SrcLoc): IrExpr { * null when it stays an identity token (JavaScript) or fenced. */ export function builtinGlobalValue(canonical: string, loc: SrcLoc, js = false): IrExpr | null { if (js && GLOBAL_FUNCTION_VALUES.has(canonical)) return builtinFunctionValue(canonical, loc); + if (js && canonical === "Buffer") + return { kind: "libCall", fn: "buffer.constructor", args: [], type: DYN, loc }; if (GLOBAL_CONSTRUCTOR_VALUES.has(canonical)) { return { kind: "libCall", @@ -4300,6 +4329,22 @@ function fenceBuiltinConstructorStaticRead( return null; } +/** The Buffer statics the Buffer function's value holds + * (scr_buffer_constructor_value). */ +const BUFFER_VALUE_STATICS: ReadonlySet = new Set([ + "from", + "copyBytesFrom", + "of", + "alloc", + "allocUnsafe", + "allocUnsafeSlow", + "isBuffer", + "compare", + "isEncoding", + "concat", + "byteLength", +]); + /** Object.prototype members a plain record inherits, read as values. */ const OBJECT_PROTOTYPE_VALUE_READS: ReadonlySet = new Set([ "constructor", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 82f5a27c1..03d19f0de 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -119,7 +119,7 @@ import { } from "./containers/indexed-objects.js"; import { lowerTupleReadMethodCall } from "./containers/array-methods.js"; import { tryFuseIndexRead } from "./array-values.js"; -import { lowerBytesStaticCall } from "./containers/bytes.js"; +import { lowerBufferConstruct, lowerBytesStaticCall } from "./containers/bytes.js"; import { lowerRegexMethodCall, lowerStringIndexCall, @@ -335,6 +335,13 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const codeGeneration = codeGenerationCalleeOf(lowerer, expr.expression); if (codeGeneration !== null) return lowerCodeGenerationCall(lowerer, expr, codeGeneration); } + // The deprecated Buffer(...) call (containers/bytes.ts). The checker + // types it `any`, so the bytes cross as a checked-dynamic value, which + // keeps the Buffer brand for inspection. + { + const buffer = lowerBufferConstruct(lowerer, expr); + if (buffer) return { kind: "dynFrom", value: buffer, type: DYN, loc }; + } // A package shim's runtime intrinsic (a bodiless `declare function` // named scriptcUnixDgram* in a shipped shim): a direct runtime call. if (ts.isIdentifier(expr.expression) && isShimIntrinsicName(expr.expression.text)) { diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 29afbb4fe..64fd1968d 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -46,7 +46,6 @@ import { DATE_T, DYN, F64, - bytesOf, type IrClassDef, type IrExpr, type IrFunction, @@ -115,8 +114,7 @@ import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; -import { knownBufEncoding, lowerNodeModulesBufferConstruct } from "./containers/bytes.js"; -import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; +import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js"; import { lowerSearchParamsNew } from "./builtins/url.js"; import { lowerTextCodecNew } from "./builtins/text-codecs.js"; import { @@ -9776,9 +9774,9 @@ function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean { export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { const loc = locOf(expr); - // A dependency's deprecated `new Buffer(...)` fallback (containers/bytes.ts). + // The deprecated `new Buffer(...)` (containers/bytes.ts). { - const buffer = lowerNodeModulesBufferConstruct(lowerer, expr); + const buffer = lowerBufferConstruct(lowerer, expr); if (buffer) return buffer; } // new Function(...): Node's EvalError under @@ -10855,43 +10853,6 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { // families carry pointed hints: each states WHY no honest static // lowering exists (or what to use instead). if (lowerer.isStdlibSymbol(symbol ?? undefined)) { - // The deprecated `new Buffer(string, encoding?)` ctor's string arm - // with a NON-STRING first argument and a string second: Node - // throws ERR_INVALID_ARG_TYPE synchronously (and DEP0005 never - // fires on this throwing path, so the compiled silence matches). - // Every other new Buffer form keeps the fence below — the - // constructing forms would owe the deprecation warning. - if ( - expr.expression.text === "Buffer" && - expr.arguments?.nodes.length === 2 && - !expr.arguments.nodes.some(ts.isSpreadElement) && - lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind !== "string" && - lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[1]!))?.kind === "string" - ) { - const first = lowerer.lowerExpr(expr.arguments.nodes[0]!); - if ( - first.kind === "unitLit" || - first.type.kind === "dyn" || - lowerer.dynConvertible(first.type) - ) { - const got: IrExpr = - first.type.kind === "dyn" ? first : { kind: "dynFrom", value: first, type: DYN, loc }; - // The encoding argument still evaluates in Node before the - // throw only via the ctor body's later reads — it does NOT - // observe it before throwing, so dropping it is exact for - // effect-free operands; effectful ones keep the fence. - const enc = lowerer.lowerExpr(expr.arguments.nodes[1]!); - if (enc.kind === "strLit" || isSafeToRepeat(enc)) { - return { - kind: "libCall", - fn: "buffer.newStringFail", - args: [got], - type: bytesOf("u8"), - loc, - }; - } - } - } const ctorHints: Record = { RegExp: "use a regex literal (/pattern/flags) — constructed regexes have no lowering", String: diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 5266d5c4e..7f0f3684a 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -14,7 +14,7 @@ import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; import { unsignedHex } from "../../format-integer.js"; -import type { Lowerer } from "./lowerer.js"; +import { jsFuncNameOf, type Lowerer } from "./lowerer.js"; import { isJsSourceFile } from "../program.js"; import { BOOL, @@ -2416,6 +2416,12 @@ function directCallableInspect(lowerer: Lowerer, node: ts.Expression, loc: SrcLo } const decl = sym.declarations[0]; if (!decl) return null; + // A JavaScript function can hold own properties (`f.x = 1`, computed + // keys), which the runtime walk prints after its name. + if (isJsSourceFile(node.getSourceFile())) { + const type = lowerer.mapTypeOf(lowerer.typeOf(node)); + if (type?.kind === "func" && lowerer.dynConvertible(type)) return null; + } if (ts.isFunctionDeclaration(decl) && decl.name) { return strLit(`[Function: ${decl.name.text}]`, loc); } @@ -2476,7 +2482,17 @@ export function lowerInspectValue( loc, ); } - const value = lowerer.lowerExpr(valueNode); + let value = lowerer.lowerExpr(valueNode); + // A JavaScript function renders through the runtime walk, which prints + // its own properties after its name. + if ( + value.type.kind === "func" && + isJsSourceFile(valueNode.getSourceFile()) && + lowerer.dynConvertible(value.type) + ) { + const name = jsFuncNameOf(valueNode); + value = { kind: "dynFrom", value, type: DYN, ...(name !== null ? { fnName: name } : {}), loc }; + } if (value.type.kind === "bytes") { // One runtime representation serves Buffer AND Uint8Array; their // renderings differ ( vs Uint8Array(1) [ 170 ]), so the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e36ffd09f..c4b13c9ab 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -151,6 +151,7 @@ import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; import { guardClassAttributeWrites } from "./class-attributes.js"; import { applyHandleProperties } from "./handle-properties.js"; +import { installBufferValue } from "./containers/bytes.js"; import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; @@ -7698,6 +7699,7 @@ export class Lowerer { trackClassFieldCreation(this, functions); guardClassAttributeWrites(this, functions); applyHandleProperties(this, functions); + installBufferValue(functions); // Globals typed by a class that never REGISTERED (a JS class whose // collection fenced — Symbol-keyed fields, an unsupported base): the @@ -13870,6 +13872,9 @@ export class Lowerer { loc, }; } + // The buffer module's Buffer is the global constructor's value. + if (bi.module === "buffer" && bi.member === "Buffer") + return { kind: "libCall", fn: "buffer.constructor", args: [], type: DYN, loc }; if (bi.module === "fs" && (bi.member === "realpathSync" || bi.member === "realpathSync.native")) return fsRealpathValue(this, bi.member === "realpathSync.native", loc); const fn = builtinModuleFnOf(this, bi.module, bi.member); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 8b826304b..84d41e869 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -3445,6 +3445,9 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin return ts.symbolName(resolved) === "global" ? "globalThis" : ts.symbolName(resolved); } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { + // The buffer module's Buffer (`require("buffer").Buffer`, `buffer_1.Buffer`) + // is the global constructor. + if (bufferNamespaceMember(lowerer, expr)) return "Buffer"; if (stdlibGlobalNameOf(lowerer, expr.expression) !== "globalThis") return null; // Through a detection chain the checker types the receiver as the // union of every arm; the selected arm (the global object) owns the diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 68b649b07..c9c41fceb 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1378,6 +1378,11 @@ function suppressedJsStrictness7(d: ts.Diagnostic): boolean { if (JS_ANY_OPERATOR_CODES.has(d.code)) { return ts.flattenDiagnosticMessageText(d, "\n").includes("'any'"); } + // The deprecated Buffer(...) call without `new`, which Node's Buffer + // function accepts (containers/bytes.ts lowers it). + if (d.code === 2348) { + return ts.flattenDiagnosticMessageText(d, "\n").includes("'BufferConstructor'"); + } return false; } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 0239c1624..aead4b085 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -829,12 +829,15 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "buffer.poolFromDyn", "buffer.poolFromDynRef", "buffer.poolFromDynOffset", + "buffer.poolFromDynRange", // The checked-dynamic compare/equals validators: Node's argument // ladders throw ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE catchably. "buffer.compareChk", "bytes.equalsChk", "bytes.compareChk", "buffer.newStringFail", + // Inherited and symbol-keyed Buffer.prototype members throw SC2020. + "buffer.prototypeMember", "fs.toUnixTimestamp", "fs.existsChk", "fs.mkdtempChk", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 6a645785f..87ddda65d 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2988,6 +2988,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** poolFromDyn that also copies an array crossing by reference. */ "buffer.poolFromDynRef": { argTypes: [DYN, STRING], result: BYTES_U8 }, "buffer.poolFromDynOffset": { argTypes: [DYN, DYN], result: BYTES_U8 }, + /** Buffer.from(value, byteOffset, length): an ArrayBuffer's range with + * Node's fromArrayBuffer checks; other values as poolFromDynOffset. */ + "buffer.poolFromDynRange": { argTypes: [DYN, DYN, DYN], result: BYTES_U8 }, "buffer.poolSize": { argTypes: [], result: F64 }, "buffer.setPoolSize": { argTypes: [F64], result: VOID }, /** Buffer.byteLength(string, enc) — enc a NORMALIZED literal like @@ -3022,6 +3025,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * always throws Node's ERR_INVALID_ARG_TYPE ("The \"string\" argument * must be of type string. Received ..."). Borrowed dyn; may-throw. */ "buffer.newStringFail": { argTypes: [DYN], result: BYTES_U8 }, + "buffer.deprecation": { argTypes: [], result: VOID }, + "buffer.deprecationCount": { argTypes: [], result: VOID }, + "buffer.constructor": { argTypes: [], result: DYN }, + "buffer.installValue": { argTypes: [], result: VOID }, + "buffer.staticValue": { argTypes: [STRING], result: DYN }, + "buffer.prototypeMember": { argTypes: [DYN], result: DYN }, /** fs._toUnixTimestamp(time) over a dyn value: numeric strings and * finite numbers coerce (negatives answer now/1000, Node's shape); * everything else throws Node's ERR_INVALID_ARG_TYPE. May-throw. */ diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index df9e3c0a5..be76cee7c 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -432,6 +432,8 @@ const DYN_READ_ONLY = new Set([ "error.new", "error.newOptions", "fs.utimesSync", + // Buffer.prototype members read as values: a lookup by key. + "buffer.prototypeMember", "insp.dyn", "insp.dynS", // The dynamic-tier operators run their dyn twins (scr_js_value.c); the diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 819cfbee0..89ed37e21 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -98,6 +98,8 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.setDeprecationFlag", "process.setEmitWarning", "punycode.warn", + // DEP0005 for the deprecated Buffer constructor. + "buffer.deprecation", "timers.immediatePromise", // The performance timeline (scr_perf.c, linked with this unit for its // MaxPerformanceEntryBufferExceededWarning). @@ -358,6 +360,10 @@ const SYMBOL_LIB_FNS: ReadonlySet = new Set([ "dyn.usingDispose", "dyn.usingMethod", "util.isDeepStrictEqual", + // The Buffer function value's Symbol(kIsEncodingSymbol) member + "buffer.constructor", + "buffer.staticValue", + "buffer.prototypeMember", ]); /** Entry points that link the querystring unit. */ @@ -839,6 +845,7 @@ const RUNTIME_FEATURE_RULES = { ...UTIL_CALLABLE_LIB_FNS, ...BUILTIN_CLASS_LIB_FNS, "util.promisifyDyn", + "buffer.prototypeMember", ], // Constant-key reads of Object.prototype member names answer the // inherited member through scr_dyn_invoke.c. @@ -867,10 +874,22 @@ const RUNTIME_FEATURE_RULES = { /** Buffer.prototype methods called on checked-dynamic values * (scr_bytes_dyn.c). */ dynBuffer: rule({ + // Buffer.prototype members read as values dispatch to the same entries. + fns: ["buffer.prototypeMember"], expr: (node, _tables) => node.kind === "dynInvoke" && DYN_BUFFER_METHODS.has(node.method), }), /** Builtin functions read as values (scr_builtin_values.c). */ - builtinValues: rule({ fns: ["buffer.byteLenValue", "buffer.allocValue", "fs.realpathValue"] }), + builtinValues: rule({ + fns: [ + "buffer.byteLenValue", + "buffer.allocValue", + "fs.realpathValue", + "buffer.constructor", + "buffer.installValue", + "buffer.staticValue", + "buffer.prototypeMember", + ], + }), /** The stream unit emits through the emitter unit, also when the * program constructs no stream (a pipeline of functions). */ /** scr_ecdh.c over the mbedTLS archive; DEP0031 reports through the diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7527-buffer-from-array-buffer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7527-buffer-from-array-buffer.ts.json new file mode 100644 index 000000000..577923b2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7527-buffer-from-array-buffer.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7527-buffer-from-array-buffer.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7528-buffer-from-array-buffer.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7528-buffer-from-array-buffer.cjs.json new file mode 100644 index 000000000..6ef4e3917 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7528-buffer-from-array-buffer.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7528-buffer-from-array-buffer.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7529-buffer-constructor.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7529-buffer-constructor.cjs.json new file mode 100644 index 000000000..5b199cd4c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7529-buffer-constructor.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7529-buffer-constructor.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7530-buffer-constructor.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7530-buffer-constructor.ts.json new file mode 100644 index 000000000..1927980cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7530-buffer-constructor.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7530-buffer-constructor.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7531-buffer-constructor-dependency/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7531-buffer-constructor-dependency/main.cjs.json new file mode 100644 index 000000000..b760b018a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7531-buffer-constructor-dependency/main.cjs.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/corpus/7531-buffer-constructor-dependency/main.cjs" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'legacy-buf' module is not supported yet", + "loc": { + "file": "/tests/corpus/7531-buffer-constructor-dependency/main.cjs", + "start": 362, + "end": 392 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7532-buffer-constructor-check-limit/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7532-buffer-constructor-check-limit/main.cjs.json new file mode 100644 index 000000000..e2f78ad2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7532-buffer-constructor-check-limit/main.cjs.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/corpus/7532-buffer-constructor-check-limit/main.cjs" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'legacy-buf' module is not supported yet", + "loc": { + "file": "/tests/corpus/7532-buffer-constructor-check-limit/main.cjs", + "start": 279, + "end": 309 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7533-buffer-value.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7533-buffer-value.cjs.json new file mode 100644 index 000000000..de7123599 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7533-buffer-value.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7533-buffer-value.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7534-buffer-prototype-members.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7534-buffer-prototype-members.cjs.json new file mode 100644 index 000000000..03858cfce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7534-buffer-prototype-members.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7534-buffer-prototype-members.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7535-inspect-function-properties.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7535-inspect-function-properties.cjs.json new file mode 100644 index 000000000..9831d7def --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7535-inspect-function-properties.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7535-inspect-function-properties.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index fc0128294..8ea4877c6 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -252,6 +252,10 @@ void scr_process_set_emit_warning(ScrDyn *fn); * made from node_modules) — at once for a static ES import, which Node * links before any module evaluates, otherwise on the next tick. */ void scr_punycode_warn(bool immediate, bool report); +/* The deprecated Buffer() and new Buffer() called outside node_modules + * (scr_async_dyn.c): DEP0005 on the next tick, once, within Node's + * 10000-call check (scr_buffer_deprecation_count). */ +void scr_buffer_deprecation(void); /* scr_emit_warning once a 'warning' listener exists (scr_lib.c; set by the * listener registration): units that cannot link the dyn-async unit route * their warnings through it, and print the default report otherwise. */ diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index a6cd813de..909d9eb8a 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -948,6 +948,22 @@ void scr_punycode_warn(bool immediate, bool report) { scr_str_release(message); } +void scr_buffer_deprecation(void) { + /* Node's showFlaggedDeprecation: after 10000 calls the check stops. */ + if (scr_buffer_ctor_warned || ++scr_buffer_ctor_checks > 10000) return; + static const char text[] = + "Buffer() is deprecated due to security and usability issues. Please use the " + "Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead."; + ScrStr *message = scr_str_new(text, sizeof text - 1); + ScrStr *code = scr_str_new("DEP0005", 7); + ScrDyn *w = scr_warning_new(message, "DeprecationWarning", code); + scr_warning_emit_deferred(w); + scr_dyn_release(w); + scr_str_release(code); + scr_str_release(message); + scr_buffer_ctor_warned = true; +} + /* The console members' warnings. Node's emitWarning emits on * process.nextTick, so these queue in order and each rides one raw tick * on the shared tick queue; any left at exit are dropped with it. */ diff --git a/packages/runtime/src/scr_builtin_values.c b/packages/runtime/src/scr_builtin_values.c index ea240359a..f8dd2b79b 100644 --- a/packages/runtime/src/scr_builtin_values.c +++ b/packages/runtime/src/scr_builtin_values.c @@ -259,3 +259,217 @@ ScrDyn *scr_fs_realpath_value(const ScrDyn *path, const ScrDyn *options, bool na scr_str_release(resolved); return out; } + +/* ── Buffer as a value ─────────────────────────────────────────────────── + * The Buffer function read as a value in JavaScript (bn.js assigns + * `require('buffer').Buffer` to a variable and passes it to + * `allocate(ArrayType, size)`): one function per thread, named Buffer + * with length 3, whose own enumerable properties are Node's, in Node's + * order: poolSize, the statics and Symbol(kIsEncodingSymbol). The statics + * apply Node's argument handling. Calling or constructing Buffer through + * the value, reading its prototype, and the copyBytesFrom and of statics + * throw SC2020. poolSize is a data property the pool reads before each + * decision (scr_buffer_pool_refresh_hook). */ +static SCR_TL ScrDyn *scr_bv_buffer; + +#define SCR_BV_ARG(i) ((i) < argc ? args[i] : scr_dyn_undefined()) + +static ScrDyn *scr_bv_unsupported(const char *what) { + char message[160]; + int n = snprintf(message, sizeof message, "%s through a stored Buffer value is not supported yet", what); + scr_throw_error_msg_code(SCR_ERR_ERROR, message, (size_t)n, "SC2020"); + return NULL; +} + +/* A Buffer result as a value: +1, or NULL with the exception pending. */ +static ScrDyn *scr_bv_bytes(ScrBytes *bytes) { + if (!bytes) return NULL; + ScrBytes *buffer = scr_bytes_as_buffer(bytes); + scr_bytes_release(bytes); + ScrDyn *value = scr_dyn_new_bytes(buffer); + scr_bytes_release(buffer); + return value; +} + +static ScrDyn *scr_bv_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; (void)args; (void)argc; + return scr_bv_unsupported("Calling or constructing Buffer"); +} + +static ScrDyn *scr_bv_from(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *value = SCR_BV_ARG(0), *second = SCR_BV_ARG(1), *third = SCR_BV_ARG(2); + if (value->kind != SCR_DYN_STR) return scr_bv_bytes(scr_buffer_from_dyn_range_pooled(value, second, third)); + /* fromString: an encoding that is not a non-empty string means utf8. */ + if (second->kind == SCR_DYN_STR && second->v.str->len > 0) + return scr_bv_bytes(scr_buffer_from_str_checked(value->v.str, second->v.str)); + ScrStr *utf8 = scr_str_new("utf8", 4); + ScrBytes *bytes = scr_buffer_from_str_checked(value->v.str, utf8); + scr_str_release(utf8); + return scr_bv_bytes(bytes); +} + +static ScrDyn *scr_bv_copy_bytes_from(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; (void)args; (void)argc; + return scr_bv_unsupported("Buffer.copyBytesFrom"); +} + +static ScrDyn *scr_bv_of(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; (void)args; (void)argc; + return scr_bv_unsupported("Buffer.of"); +} + +static ScrDyn *scr_bv_alloc(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return scr_bv_bytes(scr_buffer_alloc_value(SCR_BV_ARG(0), SCR_BV_ARG(1), SCR_BV_ARG(2))); +} + +static ScrDyn *scr_bv_alloc_unsafe(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *size = SCR_BV_ARG(0); + if (size->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("size", "of type number", size); + return NULL; + } + return scr_bv_bytes(scr_buffer_alloc_unsafe(size->v.num)); +} + +static ScrDyn *scr_bv_alloc_unsafe_slow(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *size = SCR_BV_ARG(0); + if (size->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("size", "of type number", size); + return NULL; + } + return scr_bv_bytes(scr_buffer_alloc_unsafe_slow(size->v.num)); +} + +static ScrDyn *scr_bv_is_buffer(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *value = SCR_BV_ARG(0); + return scr_dyn_new_bool(value->kind == SCR_DYN_BYTES && value->buffer); +} + +static ScrDyn *scr_bv_compare(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + double order = scr_buffer_compare_chk(SCR_BV_ARG(0), SCR_BV_ARG(1)); + return scr_exc_pending() ? NULL : scr_dyn_new_num(order); +} + +static ScrDyn *scr_bv_is_encoding(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *encoding = SCR_BV_ARG(0); + return scr_dyn_new_bool(encoding->kind == SCR_DYN_STR && encoding->v.str->len > 0 && + scr_bytes_is_encoding(encoding->v.str)); +} + +/* Buffer.concat(list, length): Node validates the list, answers an empty + * Buffer for an empty list before it reads the length, and otherwise + * validates the length as an offset. */ +static ScrDyn *scr_bv_concat(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + const ScrDyn *list = SCR_BV_ARG(0), *length = SCR_BV_ARG(1); + bool empty = list->kind == SCR_DYN_ARR && list->v.arr.len == 0; + if (length->kind == SCR_DYN_UNDEF || empty || list->kind != SCR_DYN_ARR) + return scr_bv_bytes(scr_buffer_concat_dyn(list)); + if (length->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("length", "of type number", length); + return NULL; + } + return scr_bv_bytes(scr_buffer_concat_len_dyn(list, length->v.num)); +} + +static ScrDyn *scr_bv_byte_length(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + double length = scr_buffer_byte_length_value((ScrDyn *)SCR_BV_ARG(0), (ScrDyn *)SCR_BV_ARG(1)); + return scr_exc_pending() ? NULL : scr_dyn_new_num(length); +} + +static ScrDyn *scr_bv_prototype(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; (void)args; (void)argc; + return scr_bv_unsupported("Buffer.prototype"); +} + +/* The pool's setting is the value's poolSize: a number as it is, another + * primitive through ToNumber, anything else NaN (no pooling). */ +static void scr_bv_pool_refresh(void) { + ScrDyn *table = scr_dyn_function_properties(scr_bv_buffer); + const ScrDyn *size = scr_dyn_obj_get(table, "poolSize", 8); + double setting = NAN; + if (size && size->kind == SCR_DYN_NUM) { + setting = size->v.num; + } else if (size && (size->kind == SCR_DYN_STR || size->kind == SCR_DYN_BOOL || size->kind == SCR_DYN_NULL || + size->kind == SCR_DYN_UNDEF)) { + if (!scr_dyn_number_coerce_js(size, &setting)) setting = NAN; + } + scr_dyn_release(table); + scr_buffer_pool_apply(setting); +} + +/* A static `Buffer.poolSize = n` write updates the value's property. */ +static void scr_bv_pool_publish(double size) { + ScrDyn *table = scr_dyn_function_properties(scr_bv_buffer); + scr_dyn_obj_set(table, "poolSize", 8, scr_dyn_new_num(size)); + scr_dyn_release(table); +} + +static void scr_bv_cleanup(void) { + scr_buffer_pool_refresh_hook = NULL; + scr_buffer_pool_publish_hook = NULL; + scr_dyn_release(scr_bv_buffer); + scr_bv_buffer = NULL; +} + +ScrDyn *scr_buffer_constructor_value(void) { + if (scr_bv_buffer) return scr_dyn_retain(scr_bv_buffer); + static const struct { const char *name; uint32_t length; ScrDynThunk thunk; } statics[] = { + {"from", 3, scr_bv_from}, + {"copyBytesFrom", 3, scr_bv_copy_bytes_from}, + {"of", 0, scr_bv_of}, + {"alloc", 3, scr_bv_alloc}, + {"allocUnsafe", 1, scr_bv_alloc_unsafe}, + {"allocUnsafeSlow", 1, scr_bv_alloc_unsafe_slow}, + {"isBuffer", 1, scr_bv_is_buffer}, + {"compare", 2, scr_bv_compare}, + {"isEncoding", 1, scr_bv_is_encoding}, + {"concat", 2, scr_bv_concat}, + {"byteLength", 2, scr_bv_byte_length}, + }; + double pool_size = scr_buffer_pool_size(); + scr_bv_buffer = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_bv_call, 3, "native:Buffer", "Buffer"); + scr_atexit(scr_bv_cleanup); + ScrDyn *table = scr_dyn_function_properties(scr_bv_buffer); + ScrDyn *getter = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_bv_prototype, 0, "native:function", "get prototype"); + scr_dyn_obj_set_accessor(table, "prototype", 9, getter, NULL); + ScrDynEntry *entry = &table->v.obj.entries[table->v.obj.len - 1]; + entry->enumerable = entry->configurable = false; + scr_dyn_obj_set(table, "poolSize", 8, scr_dyn_new_num(pool_size)); + ScrDyn *is_encoding = NULL; + for (size_t i = 0; i < sizeof statics / sizeof statics[0]; i++) { + ScrDyn *fn = scr_dyn_new_func(scr_closure_new(NULL, 0), statics[i].thunk, statics[i].length, "native:function", + statics[i].name); + if (statics[i].thunk == scr_bv_is_encoding) is_encoding = scr_dyn_retain(fn); + scr_dyn_obj_set(table, statics[i].name, strlen(statics[i].name), fn); + } + scr_dyn_release(table); + ScrStr *description = scr_str_new("kIsEncodingSymbol", 17); + ScrSym *symbol = scr_sym_new(description); + scr_str_release(description); + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_sym_release(symbol); + scr_dyn_symbol_key_set(scr_bv_buffer, key, is_encoding); + scr_dyn_release(key); + scr_dyn_release(is_encoding); + scr_buffer_pool_refresh_hook = scr_bv_pool_refresh; + scr_buffer_pool_publish_hook = scr_bv_pool_publish; + return scr_dyn_retain(scr_bv_buffer); +} + +void scr_buffer_install_value(void) { scr_bytes_buffer_constructor_fn = scr_buffer_constructor_value; } + +ScrDyn *scr_buffer_static_value(const ScrStr *name) { + ScrDyn *buffer = scr_buffer_constructor_value(); + ScrDyn *member = scr_dyn_fn_get(buffer, name->data, name->len); + scr_dyn_release(buffer); + return member; +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7bcfd0cfb..907c177dc 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -2787,16 +2787,30 @@ static SCR_TL bool scr_pool_registered; /* scr_buffer_pool_reset runs at context /* Buffer.poolSize >>> 1, kept with the setting. */ static SCR_TL size_t scr_pool_half = 32768; -double scr_buffer_pool_size(void) { return scr_pool_setting; } +SCR_TL void (*scr_buffer_pool_refresh_hook)(void) = NULL; +SCR_TL void (*scr_buffer_pool_publish_hook)(double size) = NULL; -void scr_buffer_set_pool_size(double size) { +double scr_buffer_pool_size(void) { + if (scr_buffer_pool_refresh_hook) scr_buffer_pool_refresh_hook(); + return scr_pool_setting; +} + +void scr_buffer_pool_apply(double size) { scr_pool_setting = size; double t = isfinite(size) ? fmod(trunc(size), 4294967296.0) : 0; if (t < 0) t += 4294967296.0; scr_pool_half = (size_t)((uint32_t)t >> 1); } -static size_t scr_pool_threshold(void) { return scr_pool_half; } +void scr_buffer_set_pool_size(double size) { + scr_buffer_pool_apply(size); + if (scr_buffer_pool_publish_hook) scr_buffer_pool_publish_hook(size); +} + +static size_t scr_pool_threshold(void) { + if (scr_buffer_pool_refresh_hook) scr_buffer_pool_refresh_hook(); + return scr_pool_half; +} /* Bytes left in the slab. Aligning can move the offset past the end of a * slab whose size is not a multiple of 8; nothing is left then. */ diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 9b382dda5..cca8364b1 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -388,6 +388,13 @@ ScrBytes *scr_bytes_from_str(const ScrStr *s, const ScrStr *enc); * slab. A slab lives while any of its views does. All return +1 Buffers. */ double scr_buffer_pool_size(void); void scr_buffer_set_pool_size(double size); +/* The setting without publishing it (scr_buffer_pool_refresh_hook). */ +void scr_buffer_pool_apply(double size); +/* Set while a Buffer function value exists (scr_builtin_values.c): its + * poolSize property is the setting, which the refresh hook reads before + * each pool decision and the publish hook receives from static writes. */ +extern SCR_TL void (*scr_buffer_pool_refresh_hook)(void); +extern SCR_TL void (*scr_buffer_pool_publish_hook)(double size); ScrBytes *scr_buffer_alloc(double size); /* zeroed, never pooled */ ScrBytes *scr_buffer_alloc_unsafe(double size); /* pooled */ ScrBytes *scr_buffer_alloc_unsafe_slow(double size); /* never pooled */ @@ -398,6 +405,15 @@ ScrBytes *scr_buffer_from_arr(const ScrArr *arr); /* number[] */ ScrBytes *scr_buffer_from_dyn_pooled(const ScrDyn *value, const ScrStr *encoding); ScrBytes *scr_buffer_from_dyn_ref_pooled(const ScrDyn *value, const ScrStr *encoding); ScrBytes *scr_buffer_from_dyn_offset_pooled(const ScrDyn *value, const ScrDyn *second); +/* Buffer.from(value, byteOffset, length): an ArrayBuffer's view with + * Node's fromArrayBuffer checks; other values as the two-argument form. */ +ScrBytes *scr_buffer_from_dyn_range_pooled(const ScrDyn *value, const ScrDyn *second, + const ScrDyn *third); +/* The deprecated Buffer constructor's DEP0005 state (scr_bytes_io.c): a + * call from node_modules counts toward Node's 10000-call check. */ +extern SCR_TL bool scr_buffer_ctor_warned; +extern SCR_TL uint64_t scr_buffer_ctor_checks; +void scr_buffer_deprecation_count(void); ScrBytes *scr_buffer_concat(const ScrArr *list); ScrBytes *scr_buffer_concat_len(const ScrArr *list, double total); /* The untyped-list forms: Node's ERR_INVALID_ARG_TYPE checks on the list @@ -406,6 +422,17 @@ ScrBytes *scr_buffer_concat_dyn(const struct ScrDyn *list); ScrBytes *scr_buffer_alloc_value(const struct ScrDyn *size, const struct ScrDyn *fill, const struct ScrDyn *encoding); struct ScrDyn *scr_fs_realpath_value(const struct ScrDyn *path, const struct ScrDyn *options, bool native); +/* The Buffer function as a value (scr_builtin_values.c), one per thread. +1. */ +struct ScrDyn *scr_buffer_constructor_value(void); +/* Installs that function as a Buffer's `.constructor` (the program's + * %main calls it when it reads the value): scr_bytes_buffer_constructor_fn. */ +void scr_buffer_install_value(void); +extern struct ScrDyn *(*scr_bytes_buffer_constructor_fn)(void); +/* A static of that value by name (`Buffer.alloc` read as a value). +1. */ +struct ScrDyn *scr_buffer_static_value(const ScrStr *name); +/* `Buffer.prototype[key]` (scr_bytes_dyn.c). +1, or NULL with the + * exception pending. */ +struct ScrDyn *scr_buffer_prototype_member(const struct ScrDyn *key); ScrBytes *scr_buffer_concat_len_dyn(const struct ScrDyn *list, double total); /* Drops the pool's slab reference and restores Buffer.poolSize (process * exit audits and library session resets). */ diff --git a/packages/runtime/src/scr_bytes_dyn.c b/packages/runtime/src/scr_bytes_dyn.c index c492d43b1..a65cdf239 100644 --- a/packages/runtime/src/scr_bytes_dyn.c +++ b/packages/runtime/src/scr_bytes_dyn.c @@ -592,3 +592,206 @@ ScrDyn *scr_dyn_invoke_buffer(ScrDyn *recv, ScrDyn *callee, const char *method, } return scr_dyn_invoke_prepared_id(recv, callee, method, mid_slot, args, argc, what); } + +/* A fixed-width read or write name (readUInt8, writeDoubleLE, ...). */ +static bool scr_bytes_dyn_num_method_name(const char *method) { + bool read = !strncmp(method, "read", 4); + if (!read && strncmp(method, "write", 5)) return false; + const char *rest = method + (read ? 4 : 5); + for (size_t at = 0; at < sizeof scr_bytes_dyn_names / sizeof scr_bytes_dyn_names[0]; at++) + if (!strcmp(rest, scr_bytes_dyn_names[at].name)) return true; + return false; +} + +/* ── Buffer.prototype members read as values ──────────────────────────── + * `Buffer.prototype[name]` (smart-buffer's typeof probe for the BigInt + * methods): an own member answers one function per name, with the name and + * length Node's function has; the parent and offset getters, read on the + * prototype itself, answer undefined; constructor is the Buffer value. + * Members inherited from Uint8Array.prototype and Object.prototype, symbol + * keys and object keys throw SC2020, and other keys answer undefined. The + * functions run the method on a Buffer receiver (`.call(buf, ...)`); other + * receivers throw SC2020. */ +static const struct { const char *key; const char *name; uint32_t length; } scr_buffer_proto_own[] = { + {"readBigUInt64LE", "readBigUInt64LE", 0}, + {"readBigUInt64BE", "readBigUInt64BE", 0}, + {"readBigUint64LE", "readBigUInt64LE", 0}, + {"readBigUint64BE", "readBigUInt64BE", 0}, + {"readBigInt64LE", "readBigInt64LE", 0}, + {"readBigInt64BE", "readBigInt64BE", 0}, + {"writeBigUInt64LE", "writeBigUInt64LE", 1}, + {"writeBigUInt64BE", "writeBigUInt64BE", 1}, + {"writeBigUint64LE", "writeBigUInt64LE", 1}, + {"writeBigUint64BE", "writeBigUInt64BE", 1}, + {"writeBigInt64LE", "writeBigInt64LE", 1}, + {"writeBigInt64BE", "writeBigInt64BE", 1}, + {"readUIntLE", "readUIntLE", 2}, + {"readUInt32LE", "", 1}, + {"readUInt16LE", "", 1}, + {"readUInt8", "", 1}, + {"readUIntBE", "readUIntBE", 2}, + {"readUInt32BE", "", 1}, + {"readUInt16BE", "", 1}, + {"readUintLE", "readUIntLE", 2}, + {"readUint32LE", "", 1}, + {"readUint16LE", "", 1}, + {"readUint8", "", 1}, + {"readUintBE", "readUIntBE", 2}, + {"readUint32BE", "", 1}, + {"readUint16BE", "", 1}, + {"readIntLE", "readIntLE", 2}, + {"readInt32LE", "", 1}, + {"readInt16LE", "", 1}, + {"readInt8", "", 1}, + {"readIntBE", "readIntBE", 2}, + {"readInt32BE", "", 1}, + {"readInt16BE", "", 1}, + {"writeUIntLE", "writeUIntLE", 3}, + {"writeUInt32LE", "writeUInt32LE", 1}, + {"writeUInt16LE", "writeUInt16LE", 1}, + {"writeUInt8", "writeUInt8", 1}, + {"writeUIntBE", "writeUIntBE", 3}, + {"writeUInt32BE", "writeUInt32BE", 1}, + {"writeUInt16BE", "writeUInt16BE", 1}, + {"writeUintLE", "writeUIntLE", 3}, + {"writeUint32LE", "writeUInt32LE", 1}, + {"writeUint16LE", "writeUInt16LE", 1}, + {"writeUint8", "writeUInt8", 1}, + {"writeUintBE", "writeUIntBE", 3}, + {"writeUint32BE", "writeUInt32BE", 1}, + {"writeUint16BE", "writeUInt16BE", 1}, + {"writeIntLE", "writeIntLE", 3}, + {"writeInt32LE", "writeInt32LE", 1}, + {"writeInt16LE", "writeInt16LE", 1}, + {"writeInt8", "writeInt8", 1}, + {"writeIntBE", "writeIntBE", 3}, + {"writeInt32BE", "writeInt32BE", 1}, + {"writeInt16BE", "writeInt16BE", 1}, + {"readFloatLE", "readFloatForwards", 0}, + {"readFloatBE", "readFloatBackwards", 0}, + {"readDoubleLE", "readDoubleForwards", 0}, + {"readDoubleBE", "readDoubleBackwards", 0}, + {"writeFloatLE", "writeFloatForwards", 1}, + {"writeFloatBE", "writeFloatBackwards", 1}, + {"writeDoubleLE", "writeDoubleForwards", 1}, + {"writeDoubleBE", "writeDoubleBackwards", 1}, + {"asciiSlice", "", 2}, + {"base64Slice", "", 2}, + {"base64urlSlice", "", 2}, + {"latin1Slice", "", 2}, + {"hexSlice", "", 2}, + {"ucs2Slice", "", 2}, + {"utf8Slice", "", 2}, + {"asciiWrite", "", 3}, + {"base64Write", "", 3}, + {"base64urlWrite", "", 3}, + {"latin1Write", "", 3}, + {"hexWrite", "", 3}, + {"ucs2Write", "", 3}, + {"utf8Write", "", 3}, + {"parent", NULL, 0}, + {"offset", NULL, 0}, + {"copy", "copy", 4}, + {"toString", "toString", 3}, + {"equals", "equals", 1}, + {"inspect", "inspect", 2}, + {"compare", "compare", 5}, + {"indexOf", "indexOf", 4}, + {"lastIndexOf", "lastIndexOf", 4}, + {"includes", "includes", 4}, + {"fill", "fill", 4}, + {"write", "write", 4}, + {"toJSON", "toJSON", 0}, + {"subarray", "subarray", 2}, + {"slice", "slice", 2}, + {"swap16", "swap16", 0}, + {"swap32", "swap32", 0}, + {"swap64", "swap64", 0}, + {"toLocaleString", "toString", 3}, +}; + +static const char *const scr_buffer_proto_inherited[] = { + "BYTES_PER_ELEMENT", "buffer", "byteLength", "byteOffset", "length", "entries", "keys", "values", + "at", "copyWithin", "every", "filter", "find", "findIndex", "findLast", "findLastIndex", + "forEach", "join", "map", "reverse", "reduce", "reduceRight", "set", "some", "sort", "toReversed", + "toSorted", "with", "__defineGetter__", "__defineSetter__", "hasOwnProperty", "__lookupGetter__", + "__lookupSetter__", "isPrototypeOf", "propertyIsEnumerable", "valueOf", "__proto__", +}; + +static SCR_TL ScrDyn *scr_buffer_proto_fns; + +static void scr_buffer_proto_cleanup(void) { + scr_dyn_release(scr_buffer_proto_fns); + scr_buffer_proto_fns = NULL; +} + +static ScrDyn *scr_buffer_proto_unsupported(const char *format, const char *name) { + char message[200]; + int n = snprintf(message, sizeof message, format, name); + scr_throw_error_msg_code(SCR_ERR_ERROR, message, (size_t)n, "SC2020"); + return NULL; +} + +static ScrDyn *scr_buffer_proto_run(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc) { + if (!scr_bytes_dyn_buffer(recv)) + return scr_buffer_proto_unsupported("Buffer.prototype.%s on a receiver that is not a Buffer is not supported yet", + method); + const char *what = "Buffer.prototype method"; + /* The method-id cache slot of a call site, here a fresh one per call. */ + int32_t slot = -1; + int32_t *mid = &slot; + if (scr_bytes_dyn_num_method_name(method)) return scr_dyn_invoke_buffer_num(recv, NULL, method, mid, args, argc, what); + if (!strcmp(method, "write")) return scr_dyn_invoke_buffer_write(recv, NULL, method, mid, args, argc, what); + if (!strcmp(method, "fill")) return scr_dyn_invoke_buffer_fill(recv, NULL, method, mid, args, argc, what); + if (!strcmp(method, "toString") || !strcmp(method, "toLocaleString")) + return scr_dyn_invoke_buffer_to_string(recv, NULL, "toString", mid, args, argc, what); + if (!strcmp(method, "indexOf") || !strcmp(method, "lastIndexOf") || !strcmp(method, "includes")) + return scr_dyn_invoke_buffer_search(recv, NULL, method, mid, args, argc, what); + if (!strcmp(method, "subarray") || !strcmp(method, "slice")) + return scr_dyn_invoke(recv, method, args, argc, what); + if (!strcmp(method, "inspect")) + return scr_buffer_proto_unsupported("Buffer.prototype.%s through a stored value is not supported yet", method); + return scr_dyn_invoke_buffer(recv, NULL, method, mid, args, argc, what); +} + +static ScrDyn *scr_buffer_proto_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrDyn *recv = scr_dyn_this_get(); + ScrDyn *result = scr_buffer_proto_run(recv, closure->fn, args, argc); + scr_dyn_release(recv); + return result; +} + +ScrDyn *scr_buffer_prototype_member(const ScrDyn *key) { + if (key->kind == SCR_DYN_SYMBOL) + return scr_buffer_proto_unsupported("Buffer.prototype members keyed by %s are not supported yet", "symbols"); + if (key->kind != SCR_DYN_STR) { + if (key->kind == SCR_DYN_NUM || key->kind == SCR_DYN_BOOL || key->kind == SCR_DYN_UNDEF || + key->kind == SCR_DYN_NULL || key->kind == SCR_DYN_BIGINT) + return scr_dyn_retain(scr_dyn_undefined()); + return scr_buffer_proto_unsupported("Buffer.prototype members keyed by %s are not supported yet", "objects"); + } + const ScrStr *name = key->v.str; + if (name->len == 11 && memcmp(name->data, "constructor", 11) == 0) return scr_buffer_constructor_value(); + for (size_t i = 0; i < sizeof scr_buffer_proto_inherited / sizeof scr_buffer_proto_inherited[0]; i++) { + const char *inherited = scr_buffer_proto_inherited[i]; + if (strlen(inherited) == name->len && memcmp(inherited, name->data, name->len) == 0) + return scr_buffer_proto_unsupported("Buffer.prototype.%s is not supported yet", inherited); + } + for (size_t i = 0; i < sizeof scr_buffer_proto_own / sizeof scr_buffer_proto_own[0]; i++) { + const char *own = scr_buffer_proto_own[i].key; + if (strlen(own) != name->len || memcmp(own, name->data, name->len) != 0) continue; + if (!scr_buffer_proto_own[i].name) return scr_dyn_retain(scr_dyn_undefined()); + if (!scr_buffer_proto_fns) { + scr_buffer_proto_fns = scr_dyn_new_obj_null_proto(); + scr_atexit(scr_buffer_proto_cleanup); + } + ScrDyn *fn = scr_dyn_obj_get(scr_buffer_proto_fns, own, name->len); + if (!fn) { + fn = scr_dyn_new_func(scr_closure_new((void *)own, 0), scr_buffer_proto_call, scr_buffer_proto_own[i].length, + "native:function", scr_buffer_proto_own[i].name); + scr_dyn_obj_set(scr_buffer_proto_fns, own, name->len, fn); + } + return scr_dyn_retain(fn); + } + return scr_dyn_retain(scr_dyn_undefined()); +} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 137208938..c93c67cdc 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -1299,13 +1299,16 @@ ScrBytes *scr_buffer_concat_len_dyn(const ScrDyn *list, double total) { return scr_buffer_concat_dyn_impl(list, total, true); } -/* Buffer.from(arrayBuffer, byteOffset) over a checked ArrayBuffer or - * SharedArrayBuffer, as Node's fromArrayBuffer: a Buffer from the offset to - * the end of the buffer that shares its memory. The offset converts with - * ToNumber, where a missing offset (NULL) or undefined and NaN are 0; an - * offset past the end throws ERR_BUFFER_OUT_OF_BOUNDS, and one at or below - * -1 V8's start-offset RangeError. NULL with the exception pending. */ -static ScrBytes *scr_buffer_from_storage(const ScrDyn *storage, const ScrDyn *offset_value) { +/* Buffer.from(arrayBuffer, byteOffset, length) over a checked ArrayBuffer + * or SharedArrayBuffer, as Node's fromArrayBuffer: a Buffer that shares its + * memory. The offset converts with ToNumber, where a missing offset (NULL) + * or undefined and NaN are 0; an offset past the end throws + * ERR_BUFFER_OUT_OF_BOUNDS, and one at or below -1 V8's start-offset + * RangeError. A missing length runs to the end; a given one converts with + * ToNumber, past the end throws ERR_BUFFER_OUT_OF_BOUNDS for "length", and + * zero, negative and NaN lengths are 0. NULL with the exception pending. */ +static ScrBytes *scr_buffer_from_storage(const ScrDyn *storage, const ScrDyn *offset_value, + const ScrDyn *length_value) { double offset = 0; if (offset_value && offset_value->kind != SCR_DYN_UNDEF) { if (!scr_dyn_number_coerce_js(offset_value, &offset)) return NULL; @@ -1313,11 +1316,26 @@ static ScrBytes *scr_buffer_from_storage(const ScrDyn *storage, const ScrDyn *of } ScrBytes *bytes = storage->v.handle.ptr; ScrBytes *owner = bytes->backing ? bytes->backing : bytes; - if ((double)scr_bytes_byte_len(owner) - offset < 0) { + double max_length = (double)scr_bytes_byte_len(owner) - offset; + if (max_length < 0) { static const char msg[] = "\"offset\" is outside of buffer bounds"; scr_throw_error_msg_code(SCR_ERR_RANGE, msg, sizeof msg - 1, "ERR_BUFFER_OUT_OF_BOUNDS"); return NULL; } + bool has_length = length_value && length_value->kind != SCR_DYN_UNDEF; + double length = max_length; + if (has_length) { + if (!scr_dyn_number_coerce_js(length_value, &length)) return NULL; + if (length > 0) { + if (length > max_length) { + static const char msg[] = "\"length\" is outside of buffer bounds"; + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, sizeof msg - 1, "ERR_BUFFER_OUT_OF_BOUNDS"); + return NULL; + } + } else { + length = 0; + } + } double start = trunc(offset); if (start < 0) { /* The offset as JavaScript prints it (-1.5, -1e+21, -Infinity). */ @@ -1331,7 +1349,7 @@ static ScrBytes *scr_buffer_from_storage(const ScrDyn *storage, const ScrDyn *of scr_throw_error(SCR_ERR_RANGE, message); return NULL; } - ScrBytes *out = scr_bytes_buffer_view(bytes, SCR_BYTES_U8, start, false, 0); + ScrBytes *out = scr_bytes_buffer_view(bytes, SCR_BYTES_U8, start, has_length, trunc(length)); if (out) out->is_buffer = true; return out; } @@ -1340,7 +1358,7 @@ ScrBytes *scr_buffer_from_dyn(const ScrDyn *value, const ScrStr *encoding) { if (value->kind == SCR_DYN_STR) return scr_bytes_from_str(value->v.str, encoding); /* The second argument here is an encoding name, which as a byte offset * converts to NaN: offset 0. */ - if (scr_buffer_storage_is(value)) return scr_buffer_from_storage(value, NULL); + if (scr_buffer_storage_is(value)) return scr_buffer_from_storage(value, NULL, NULL); if (value->kind == SCR_DYN_BYTES) return scr_bytes_convert(SCR_BYTES_U8, value->v.bytes); if (value->kind == SCR_DYN_ARR) { return scr_buffer_from_array_like(value, (double)value->v.arr.len); @@ -1386,7 +1404,7 @@ ScrBytes *scr_buffer_from_dyn_pooled(const ScrDyn *value, const ScrStr *encoding return scr_buffer_from_bytes(value->v.bytes); /* A view of an ArrayBuffer shares its memory, so it never comes from the * pool. The second argument here is an encoding name: offset 0. */ - if (scr_buffer_storage_is(value)) return scr_buffer_from_storage(value, NULL); + if (scr_buffer_storage_is(value)) return scr_buffer_from_storage(value, NULL, NULL); ScrBytes *out = scr_buffer_from_dyn(value, encoding); if (!out) return NULL; out->is_buffer = true; @@ -1422,12 +1440,32 @@ ScrBytes *scr_buffer_from_dyn_offset_pooled(const ScrDyn *value, const ScrDyn *s scr_str_release(utf8); return out; } - return scr_buffer_from_storage(value, second); + return scr_buffer_from_storage(value, second, NULL); +} + +/* Buffer.from(value, second, third): an ArrayBuffer or SharedArrayBuffer + * takes them as its byte offset and length; other values convert as + * scr_buffer_from_dyn_offset_pooled does, without the third argument. */ +ScrBytes *scr_buffer_from_dyn_range_pooled(const ScrDyn *value, const ScrDyn *second, + const ScrDyn *third) { + if (!scr_buffer_storage_is(value)) return scr_buffer_from_dyn_offset_pooled(value, second); + return scr_buffer_from_storage(value, second, third); +} + +/* The deprecated Buffer constructor's DEP0005 state, Node's + * bufferWarningAlreadyEmitted and nodeModulesCheckCounter: a call from + * node_modules only counts toward the 10000-call check, and + * scr_buffer_deprecation (scr_async_dyn.c) reports for the others. */ +SCR_TL bool scr_buffer_ctor_warned = false; +SCR_TL uint64_t scr_buffer_ctor_checks = 0; + +void scr_buffer_deprecation_count(void) { + if (!scr_buffer_ctor_warned) scr_buffer_ctor_checks++; } /* new Buffer(number, encoding) — the deprecated ctor's string arm with a - * non-string first argument: Node's exact ERR_INVALID_ARG_TYPE (the - * throwing path never fires DEP0005, so compiled silence matches). */ + * non-string first argument: Node's exact ERR_INVALID_ARG_TYPE, thrown + * after scr_buffer_deprecation has reported DEP0005. */ ScrBytes *scr_buffer_new_string_fail(const ScrDyn *got) { scr_dyn_arg_type_fail("string", "of type string", got); return NULL; diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 553b897d2..e93a9a0f0 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1328,6 +1328,10 @@ ScrDyn *scr_dyn_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, si * for anonymous) and "length" (the boxed arity). Returns +1, or NULL when * the key answers nothing (the caller's undefined). Never throws. */ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len); +/* A function value's own property table, created on first use. +1. */ +ScrDyn *scr_dyn_function_properties(const ScrDyn *function); +/* The table a function value already has. +1, or NULL. */ +ScrDyn *scr_dyn_function_properties_existing(const ScrDyn *function); /* Object.defineProperties over dyn values (targets: OBJ and FUNC): each * descriptor's `value` becomes an own data property. OBJ targets preserve * writable/enumerable/configurable; FUNC targets still use plain properties. diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 1dce8d3c2..79b2564ff 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1600,7 +1600,45 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } else { ib_cstr(&out, "[Function (anonymous)]"); } - return ib_take(&out); + /* A function's own enumerable properties follow its base, as for an + * object (`[Function: f] { x: 1 }`), and past the depth limit it is + * `[Function]` (formatRaw). Classes keep the base. */ + ScrDyn *table = d->v.fn.class_obj ? NULL : scr_dyn_function_properties_existing(d); + bool shown = false; + for (size_t i = 0; table && i < table->v.obj.len && !shown; i++) shown = table->v.obj.entries[i].enumerable; + for (size_t i = 0; table && !shown && table->symbol_keys && i < table->symbol_keys->v.arr.len; i++) + shown = scr_insp_symbol_entry(table, i) != NULL; + if (!shown) { + scr_dyn_release(table); + return ib_take(&out); + } + /* Every value of one function shares its table: the table is the + * function's identity for the circular checks. */ + double circular = scr_insp_circ_check(table); + if (circular != 0) { + scr_dyn_release(table); + free(out.data); + return scr_insp_circular(circular); + } + if (recurse > depth) { + scr_dyn_release(table); + free(out.data); + return scr_str_new("[Function]", 10); + } + ScrStr *base = ib_take(&out); + char *cls = malloc(base->len + 1); + if (!cls) scr_trap("scriptc: out of memory\n"); + memcpy(cls, base->data, base->len); + cls[base->len] = '\0'; + scr_str_release(base); + size_t nseen = g_nseen; + g_pending_seen = table; + ScrStr *rendered = scr_insp_obj(table, recurse, depth, cls); + g_pending_seen = NULL; + if (g_nseen > nseen) g_nseen = nseen; + free(cls); + scr_dyn_release(table); + return rendered; } case SCR_DYN_HANDLE: { if (d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 615785687..c0154ab57 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2366,6 +2366,10 @@ static ScrDyn *scr_dyn_fn_existing_table(const ScrDyn *function) { return owner->props ? (ScrDyn *)scr_box_get_ref(owner->props) : NULL; } +ScrDyn *scr_dyn_function_properties_existing(const ScrDyn *function) { + return scr_dyn_fn_existing_table(function); +} + ScrDyn *scr_dyn_prevent_extensions(ScrDyn *object) { if (scr_dyn_class_reflection_fence(object) || scr_dyn_error_prototype_fence(object)) return NULL; if (object->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("Object.preventExtensions"); return NULL; } @@ -4200,6 +4204,9 @@ bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const Sc } if (scr_array_buffer_ctor && callee->v.fn.clo == scr_array_buffer_ctor->v.fn.clo) return scr_array_buffer_is(value); if (scr_array_ctor && callee->v.fn.clo == scr_array_ctor->v.fn.clo) return value->kind == SCR_DYN_ARR || scr_dyn_ref_is_array(value); + /* The Buffer function value: Buffer instances, not other Uint8Arrays. */ + if (callee->v.fn.sig && strcmp(callee->v.fn.sig, "native:Buffer") == 0) + return value->kind == SCR_DYN_BYTES && value->buffer; for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { if (scr_bytes_ctors[i] && callee->v.fn.clo == scr_bytes_ctors[i]->v.fn.clo) return scr_dyn_typed_array_is(value, (int)i); @@ -4262,6 +4269,7 @@ bool scr_bytes_instanceof(const ScrDyn *value, const ScrDyn *callee) { return scr_bytes_instanceof_at(value, callee, NULL); } SCR_TL bool (*scr_dyn_global_construct_fn)(const ScrDyn *callee, const ScrDyn *args, ScrDyn **result) = NULL; +ScrDyn *(*scr_bytes_buffer_constructor_fn)(void) = NULL; ScrDyn *scr_bytes_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) { if (callee->kind == SCR_DYN_FUNC && scr_array_ctor && callee->v.fn.clo == scr_array_ctor->v.fn.clo) @@ -4282,6 +4290,14 @@ ScrDyn *scr_bytes_construct(const ScrDyn *callee, const ScrDyn *args, const ScrS ScrDyn *constructed = NULL; if (scr_dyn_global_construct_fn && scr_dyn_global_construct_fn(callee, args, &constructed)) return constructed; + /* The Buffer function value (scr_builtin_values.c): Node's construction + * reports DEP0005 by the caller's location, which a stored value does + * not know. */ + if (callee->kind == SCR_DYN_FUNC && callee->v.fn.sig && strcmp(callee->v.fn.sig, "native:Buffer") == 0) { + static const char message[] = "Calling or constructing Buffer through a stored Buffer value is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; + } ScrJsonBuf message; scr_jb_init(&message); scr_jb_write(&message, what->data, what->len); @@ -4311,7 +4327,9 @@ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { return scr_dyn_new_num((double)scr_bytes_elem_size(bytes->elem)); if (key->len == 11 && memcmp(key->data, "constructor", 11) == 0) { if (!value->buffer) return scr_bytes_constructor_for(bytes->elem); - /* Buffer retains its separate builtin identity. */ + /* A program that reads the Buffer function's value answers it; + * otherwise Buffer keeps its separate builtin identity token. */ + if (scr_bytes_buffer_constructor_fn) return scr_bytes_buffer_constructor_fn(); char token[64]; int length = snprintf(token, sizeof token, "[builtin %s]", value->buffer ? "Buffer" : scr_bytes_elem_name(bytes->elem)); ScrStr *name = scr_str_new(token, (size_t)length); @@ -5219,6 +5237,8 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) { return (ScrDyn *)scr_box_get_ref(owner->props); } +ScrDyn *scr_dyn_function_properties(const ScrDyn *function) { return scr_dyn_fn_properties(function); } + 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"; diff --git a/tests/corpus/2570-buffer-arg-validation.cjs b/tests/corpus/2570-buffer-arg-validation.cjs index d49593a30..c0980e495 100644 --- a/tests/corpus/2570-buffer-arg-validation.cjs +++ b/tests/corpus/2570-buffer-arg-validation.cjs @@ -1,4 +1,3 @@ -// @no-deprecation // Buffer's invalid-argument ladders, Node-exact: Buffer.compare / // buf.compare / buf.equals over non-buffer values and bad offsets throw // ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE with Node's argument names and @@ -7,6 +6,8 @@ // real answers. Plus the deprecated `new Buffer(number, enc)` string-arm // rejection and the ERR_* codes on the write/swap/fill range ladders. 'use strict'; +// The deprecated constructor's DEP0005 report carries a process id. +process.noDeprecation = true; const show = (fn) => { try { console.log('ret', fn()); diff --git a/tests/corpus/7527-buffer-from-array-buffer.ts b/tests/corpus/7527-buffer-from-array-buffer.ts new file mode 100644 index 000000000..fb650e9f2 --- /dev/null +++ b/tests/corpus/7527-buffer-from-array-buffer.ts @@ -0,0 +1,33 @@ +// Buffer.from(arrayBuffer, byteOffset, length): Node's fromArrayBuffer over +// typed and untyped ArrayBuffers and SharedArrayBuffers: the offset and +// length conversions, the shared memory, zero and negative lengths, and +// ERR_BUFFER_OUT_OF_BOUNDS for an offset or length past the end. +const ab = new ArrayBuffer(6); +new Uint8Array(ab).set([1, 2, 3, 4, 5, 6]); +const offsets: unknown[] = [undefined, NaN, "1", 1.5, -1, 0, 6, 7, null, true]; +const lengths: unknown[] = [undefined, 0, -1, 2.5, "2", 6, 9, NaN, null]; +for (const o of offsets) { + for (const l of lengths) { + try { + const b = Buffer.from(ab, o as number, l as number); + console.log(String(o), String(l), b, b.byteOffset, b.length); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(String(o), String(l), err.name, err.code, err.message); + } + } +} +const typed = Buffer.from(ab, 2, 2); +typed[0] = 99; +console.log(new Uint8Array(ab)[2], Buffer.from(ab, 4).toString("hex"), Buffer.isBuffer(Buffer.from(ab, 1, 1))); +const shared = new SharedArrayBuffer(4); +console.log(Buffer.from(shared, 1, 2).length); +const loose: any = new ArrayBuffer(4); +const pairs: [unknown, unknown][] = [[1, 2], [4, undefined], [5, 1], [0, 5], ["2", "1"]]; +for (const [o, l] of pairs) { + try { + console.log(Buffer.from(loose, o as number, l as number).toString("hex")); + } catch (e) { + console.log((e as NodeJS.ErrnoException).code, (e as Error).message); + } +} diff --git a/tests/corpus/7528-buffer-from-array-buffer.cjs b/tests/corpus/7528-buffer-from-array-buffer.cjs new file mode 100644 index 000000000..e77b5d55b --- /dev/null +++ b/tests/corpus/7528-buffer-from-array-buffer.cjs @@ -0,0 +1,31 @@ +// Buffer.from(arrayBuffer, byteOffset, length) in JavaScript, with offsets +// and lengths of every kind and an ArrayBuffer held in an untyped slot. +const ab = new ArrayBuffer(6); +new Uint8Array(ab).set([1, 2, 3, 4, 5, 6]); +const offsets = [undefined, NaN, "1", 1.5, -1, 0, 6, 7, null, true]; +const lengths = [undefined, 0, -1, 2.5, "2", 6, 9, NaN, null]; +for (const o of offsets) { + for (const l of lengths) { + try { + const b = Buffer.from(ab, o, l); + console.log(String(o), String(l), b, b.byteOffset, b.length); + } catch (e) { + const err = e; + console.log(String(o), String(l), err.name, err.code, err.message); + } + } +} +const typed = Buffer.from(ab, 2, 2); +typed[0] = 99; +console.log(new Uint8Array(ab)[2], Buffer.from(ab, 4).toString("hex"), Buffer.isBuffer(Buffer.from(ab, 1, 1))); +const shared = new SharedArrayBuffer(4); +console.log(Buffer.from(shared, 1, 2).length); +const slots = { buffer: new ArrayBuffer(3) }; +new Uint8Array(slots.buffer).set([9, 8, 7]); +for (const args of [[1], [1, 1], [0, 0], [3, 0], [2, 5], [-0.5, 2]]) { + try { + console.log(args.join(","), Buffer.from(slots.buffer, args[0], args[1])); + } catch (e) { + console.log(args.join(","), e.code, e.message); + } +} diff --git a/tests/corpus/7529-buffer-constructor.cjs b/tests/corpus/7529-buffer-constructor.cjs new file mode 100644 index 000000000..3181cebec --- /dev/null +++ b/tests/corpus/7529-buffer-constructor.cjs @@ -0,0 +1,80 @@ +// The deprecated Buffer constructor, called and constructed, as Node's +// Buffer function runs it: the arguments evaluate in order, the first call +// reports DEP0005 on the next tick (once), a number allocates zero-filled +// bytes, and other values convert as Buffer.from does. +// The program re-runs itself and prints the child's output with process +// ids normalized. +"use strict"; +const { spawnSync } = require("node:child_process"); +const { Buffer: Imported } = require("buffer"); + +function child() { + process.on("warning", (w) => console.log("listener", w.name, w.code, w.message.length)); + const B = Buffer; + + const show = (label, fn) => { + try { + const b = fn(); + console.log(label, b, b.length, Buffer.isBuffer(b)); + } catch (e) { + console.log(label, e.name, e.code, e.message); + } + }; + + // The throwing form still reports the warning, after the catch. + show("number+encoding", () => new Buffer(3, "utf8")); + show("size", () => new Buffer(4)); + show("fraction", () => new Buffer(2.7)); + show("zero", () => Buffer(0)); + show("negative", () => new Buffer(-1)); + show("nan", () => new Buffer(NaN)); + show("string", () => new Buffer("héllo")); + show("hex", () => new Buffer("6869", "hex")); + show("base64", () => Buffer("aGk=", "base64")); + show("empty encoding", () => new Buffer("x", "")); + show("bad encoding", () => new Buffer("x", "nope")); + show("array", () => new Buffer([1, 2, 300, -1])); + show("array-like", () => new Buffer({ length: 2, 0: 7, 1: 8 })); + show("buffer", () => new B(Buffer.from("copy"))); + show("typed", () => Buffer(new Uint16Array([1, 258]))); + const storage = new Uint8Array([10, 20, 30, 40, 50]).buffer; + show("arraybuffer", () => new Buffer(storage)); + show("offset", () => new Buffer(storage, 1)); + show("range", () => Buffer(storage, 1, 3)); + show("range past end", () => new Buffer(storage, 6)); + show("string third", () => new Buffer("abc", "latin1", 1)); + show("undefined", () => new Buffer(undefined)); + show("null", () => Buffer(null)); + show("none", () => new Buffer()); + show("imported", () => new Imported("imp")); + + console.log(Buffer(2), Buffer("ab").length, Buffer("hi").toString("hex"), new Buffer([5])); + console.log(Buffer.isBuffer(Buffer(1)), Buffer(1) instanceof Uint8Array, new Buffer(1) instanceof Uint8Array); + + // The view shares the ArrayBuffer's memory. + const view = new Buffer(storage, 2, 2); + new Uint8Array(storage)[2] = 99; + console.log(view); + + const order = []; + const arg = (v) => (order.push(v), v); + new Buffer(arg("a"), arg("utf8")); + Buffer(arg(2)); + console.log(order); + + console.log("sync end"); + process.nextTick(() => console.log("tick")); + setTimeout(() => { + new Buffer(1); + console.log("timer"); + }, 0); +} + +if (process.argv[2] === "child") { + child(); +} else { + const run = spawnSync(process.execPath, [process.argv[1], "child"], { encoding: "utf8" }); + console.log(`status: ${run.status}`); + console.log(run.stdout); + console.log(run.stderr.replace(/\(node:\d+\)/g, "(node:)")); +} diff --git a/tests/corpus/7530-buffer-constructor.ts b/tests/corpus/7530-buffer-constructor.ts new file mode 100644 index 000000000..14eac61d5 --- /dev/null +++ b/tests/corpus/7530-buffer-constructor.ts @@ -0,0 +1,42 @@ +// The deprecated `new Buffer(...)` in TypeScript, over the forms +// @types/node declares: strings with an encoding, sizes, arrays and +// ArrayBuffers. The first call reports DEP0005 on the next tick. +// The program re-runs itself and prints the child's output with process +// ids normalized. +import { spawnSync } from "node:child_process"; + +function child(): void { + process.on("warning", (w) => console.log("listener", w.name, (w as { code?: string }).code)); + + function sized(n: number): Buffer { + return new Buffer(n); + } + + const encodings: BufferEncoding[] = ["utf8", "hex", "base64", "latin1"]; + for (const encoding of encodings) { + console.log(encoding, new Buffer("6869", encoding)); + } + console.log(sized(3), sized(0).length); + console.log(new Buffer([104, 105]).toString(), new Buffer(new Uint8Array([1, 2]))); + const storage = new ArrayBuffer(4); + const view = new Buffer(storage); + view[1] = 5; + console.log(new Uint8Array(storage), view.buffer === storage); + const text: string = "typed"; + console.log(new Buffer(text).toString("hex")); + try { + sized(-2); + } catch (e) { + console.log((e as Error).name, (e as { code?: string }).code); + } + console.log("sync end"); +} + +if (process.argv[2] === "child") { + child(); +} else { + const run = spawnSync(process.execPath, [process.argv[1]!, "child"], { encoding: "utf8" }); + console.log(`status: ${run.status}`); + console.log(run.stdout); + console.log(run.stderr.replace(/\(node:\d+\)/g, "(node:)")); +} diff --git a/tests/corpus/7531-buffer-constructor-dependency/main.cjs b/tests/corpus/7531-buffer-constructor-dependency/main.cjs new file mode 100644 index 000000000..75b130bc6 --- /dev/null +++ b/tests/corpus/7531-buffer-constructor-dependency/main.cjs @@ -0,0 +1,36 @@ +// The deprecated Buffer constructor called from a dependency under +// node_modules: Node reports no DEP0005 for those calls, and the program's +// own first call reports it once, on the next tick. +// The program re-runs itself and prints the child's output with process +// ids normalized. +"use strict"; +const { spawnSync } = require("node:child_process"); +const legacy = require("legacy-buf"); + +function child() { + process.on("warning", (w) => console.log("listener", w.name, w.code)); + + console.log(legacy.construct("hi", "utf8"), legacy.construct(3), legacy.call([1, 2])); + const storage = new Uint8Array([1, 2, 3, 4]).buffer; + console.log(legacy.construct(storage, 1, 2), legacy.many(5)); + try { + legacy.construct(4, "hex"); + } catch (e) { + console.log(e.code); + } + process.nextTick(() => { + console.log("tick"); + console.log(new Buffer("own")); + console.log(Buffer(2), new Buffer([9])); + setTimeout(() => console.log("timer"), 0); + }); +} + +if (process.argv[2] === "child") { + child(); +} else { + const run = spawnSync(process.execPath, [process.argv[1], "child"], { encoding: "utf8" }); + console.log(`status: ${run.status}`); + console.log(run.stdout); + console.log(run.stderr.replace(/\(node:\d+\)/g, "(node:)")); +} diff --git a/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.d.ts b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.d.ts new file mode 100644 index 000000000..ef314d625 --- /dev/null +++ b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.d.ts @@ -0,0 +1,3 @@ +export declare function construct(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer; +export declare function call(value: unknown): Buffer; +export declare function many(count: number): number; diff --git a/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.js b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.js new file mode 100644 index 000000000..062e4e41d --- /dev/null +++ b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/index.js @@ -0,0 +1,15 @@ +"use strict"; +// A dependency that keeps the deprecated Buffer constructor. +exports.construct = function (value, encodingOrOffset, length) { + if (length !== undefined) return new Buffer(value, encodingOrOffset, length); + if (encodingOrOffset !== undefined) return new Buffer(value, encodingOrOffset); + return new Buffer(value); +}; +exports.call = function (value) { + return Buffer(value); +}; +exports.many = function (count) { + let total = 0; + for (let i = 0; i < count; i++) total += new Buffer(1).length; + return total; +}; diff --git a/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/package.json b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/package.json new file mode 100644 index 000000000..db2277b60 --- /dev/null +++ b/tests/corpus/7531-buffer-constructor-dependency/node_modules/legacy-buf/package.json @@ -0,0 +1,5 @@ +{ + "name": "legacy-buf", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/corpus/7532-buffer-constructor-check-limit/main.cjs b/tests/corpus/7532-buffer-constructor-check-limit/main.cjs new file mode 100644 index 000000000..c08690a83 --- /dev/null +++ b/tests/corpus/7532-buffer-constructor-check-limit/main.cjs @@ -0,0 +1,10 @@ +// Node checks the caller of the deprecated Buffer constructor for its first +// 10000 calls only: once a dependency under node_modules has made them, the +// program's own calls report no DEP0005. +"use strict"; +process.on("warning", (w) => console.log("listener", w.code)); +const legacy = require("legacy-buf"); + +console.log(legacy.many(10000)); +console.log(new Buffer("after"), Buffer(1)); +setTimeout(() => console.log("timer"), 0); diff --git a/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.d.ts b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.d.ts new file mode 100644 index 000000000..ef314d625 --- /dev/null +++ b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.d.ts @@ -0,0 +1,3 @@ +export declare function construct(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer; +export declare function call(value: unknown): Buffer; +export declare function many(count: number): number; diff --git a/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.js b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.js new file mode 100644 index 000000000..062e4e41d --- /dev/null +++ b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/index.js @@ -0,0 +1,15 @@ +"use strict"; +// A dependency that keeps the deprecated Buffer constructor. +exports.construct = function (value, encodingOrOffset, length) { + if (length !== undefined) return new Buffer(value, encodingOrOffset, length); + if (encodingOrOffset !== undefined) return new Buffer(value, encodingOrOffset); + return new Buffer(value); +}; +exports.call = function (value) { + return Buffer(value); +}; +exports.many = function (count) { + let total = 0; + for (let i = 0; i < count; i++) total += new Buffer(1).length; + return total; +}; diff --git a/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/package.json b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/package.json new file mode 100644 index 000000000..db2277b60 --- /dev/null +++ b/tests/corpus/7532-buffer-constructor-check-limit/node_modules/legacy-buf/package.json @@ -0,0 +1,5 @@ +{ + "name": "legacy-buf", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tests/corpus/7533-buffer-value.cjs b/tests/corpus/7533-buffer-value.cjs new file mode 100644 index 000000000..3fd500ec2 --- /dev/null +++ b/tests/corpus/7533-buffer-value.cjs @@ -0,0 +1,101 @@ +// The Buffer function read as a value: one identity across the global, the +// buffer module and its aliases, Node's own enumerable members in Node's +// order, statics that apply Node's argument handling, instanceof, and a +// poolSize property the pool reads. +"use strict"; +const buffer = require("buffer"); +const nodeBuffer = require("node:buffer"); +const util = require("util"); + +// bn.js keeps the module's Buffer in a variable assigned under try. +var BnBuffer; +try { + BnBuffer = require("buffer").Buffer; +} catch (e) {} +const pass = (value) => value; +const Stored = pass(require("buffer").Buffer); +const Alias = Buffer; +const table = { Buffer: buffer.Buffer }; + +console.log(typeof Stored, Stored.name, Stored.length, Alias.name, Alias.length); +console.log(Stored === Buffer, Stored === globalThis.Buffer, Stored === buffer.Buffer, Stored === nodeBuffer.Buffer); +console.log(Stored === Alias, table.Buffer === Stored, Stored.constructor === Function); +console.log(Stored); +console.log(Object.keys(Stored)); +console.log(util.inspect({ nested: { Buffer: Stored } }, { depth: 1 })); + +// Statics through the value. +const show = (label, run) => { + try { + console.log(label, run()); + } catch (e) { + console.log(label, e.name, e.code, e.message); + } +}; +show("from string", () => Stored.from("héllo")); +show("from hex", () => Stored.from("6869", "hex")); +show("from empty encoding", () => Stored.from("x", "")); +show("from bad encoding", () => Stored.from("x", "nope")); +show("from array", () => Stored.from([1, 2, 300])); +const storage = new Uint8Array([1, 2, 3, 4, 5]).buffer; +show("from range", () => Stored.from(storage, 1, 3)); +show("from number", () => Stored.from(5)); +show("alloc", () => Stored.alloc(3, "ab")); +show("alloc fill", () => Stored.alloc(2, 7)); +show("alloc string size", () => Stored.alloc("3")); +show("allocUnsafe", () => Stored.allocUnsafe(4).fill(9)); +show("allocUnsafe negative", () => Stored.allocUnsafe(-1)); +show("allocUnsafe string", () => Stored.allocUnsafe("4")); +show("allocUnsafeSlow", () => Stored.allocUnsafeSlow(2).fill(1)); +show("isBuffer", () => [Stored.isBuffer(Buffer.alloc(1)), Stored.isBuffer(new Uint8Array(1)), Stored.isBuffer("x")]); +show("isEncoding", () => [Stored.isEncoding("UTF-8"), Stored.isEncoding(""), Stored.isEncoding("nope"), Stored.isEncoding(1)]); +show("compare", () => Stored.compare(Buffer.from("a"), Buffer.from("b"))); +show("compare bad", () => Stored.compare("a", Buffer.from("b"))); +show("concat", () => Stored.concat([Buffer.from("ab"), Buffer.from("cd")])); +show("concat length", () => Stored.concat([Buffer.from("ab"), Buffer.from("cd")], 3)); +show("concat empty", () => Stored.concat([], "x")); +show("concat bad length", () => Stored.concat([Buffer.from("a")], "x")); +show("concat bad list", () => Stored.concat("x")); +show("byteLength", () => [Stored.byteLength("héllo"), Stored.byteLength("aGk=", "base64"), Stored.byteLength(new Uint16Array(3))]); + +// Member values keep one identity, with Node's names and lengths. +const members = [ + [Buffer.from, Stored.from], + [Buffer.alloc, Stored.alloc], + [Buffer.allocUnsafe, Stored.allocUnsafe], + [Buffer.isBuffer, Stored.isBuffer], + [Buffer.compare, Stored.compare], + [Buffer.concat, Stored.concat], + [Buffer.byteLength, Stored.byteLength], + [Buffer.isEncoding, Stored.isEncoding], +]; +console.log(members.map(([a, b]) => `${a.name}/${a.length}/${a === b}`).join(" ")); +const isBuffer = require("buffer").Buffer.isBuffer; +console.log(isBuffer(Buffer.from("x")), isBuffer(null), typeof Buffer.allocUnsafe); + +// A bn.js-style allocator over Buffer or Array. +function allocate(ArrayType, size) { + if (ArrayType.allocUnsafe) return ArrayType.allocUnsafe(size); + return new ArrayType(size); +} +function toArrayLike(ArrayType, size) { + const made = allocate(ArrayType, size); + made.fill(4); + return made; +} +function toBuffer(size) { + return BnBuffer ? toArrayLike(BnBuffer, size) : null; +} +const made = toBuffer(3); +console.log(made, toArrayLike(Array, 2), made instanceof Stored, made instanceof Buffer); +console.log(new Uint8Array(2) instanceof Stored, Buffer.from("x") instanceof Alias, pass("x") instanceof Stored); + +// poolSize: one setting through the value and the static spelling. +console.log(Stored.poolSize, Buffer.poolSize); +Buffer.poolSize = 1024; +console.log(Stored.poolSize); +const small = Buffer.allocUnsafe(600); +console.log(small.buffer.byteLength, Buffer.allocUnsafe(400).buffer.byteLength); +Stored.poolSize = 2048; +console.log(Buffer.poolSize, Buffer.allocUnsafe(600).buffer.byteLength); +Stored.poolSize = 65536; diff --git a/tests/corpus/7534-buffer-prototype-members.cjs b/tests/corpus/7534-buffer-prototype-members.cjs new file mode 100644 index 000000000..2fd347373 --- /dev/null +++ b/tests/corpus/7534-buffer-prototype-members.cjs @@ -0,0 +1,29 @@ +// Buffer.prototype members read as values, as smart-buffer probes and +// calls them: own methods are functions with Node's names and lengths that +// run on a Buffer receiver, the parent and offset getters read on the +// prototype answer undefined, constructor is Buffer, and absent names are +// undefined. +"use strict"; +const buffer_1 = require("buffer"); + +function check(method) { + if (typeof buffer_1.Buffer.prototype[method] === "undefined") return `${method}: missing`; + return `${method}: ${typeof buffer_1.Buffer.prototype[method]}`; +} +console.log(["writeBigInt64BE", "readBigUInt64LE", "readUInt8", "nope", "parent", "offset"].map(check)); +console.log(Buffer.prototype.constructor === Buffer, Buffer.prototype["constructor"] === buffer_1.Buffer); +console.log(Buffer.prototype[0], Buffer.prototype[undefined], Buffer.prototype.toJSON === Buffer.prototype.toJSON); + +const names = ["readUInt8", "readUint8", "readFloatLE", "writeUInt16LE", "readUIntLE", "toString", "toLocaleString", "slice", "hexSlice"]; +console.log(names.map((n) => `${n}=${JSON.stringify(Buffer.prototype[n].name)}/${Buffer.prototype[n].length}`).join(" ")); + +const buf = Buffer.alloc(8); +function write(func, value, offset) { + return func.call(buf, value, offset); +} +console.log(write(Buffer.prototype.writeUInt16LE, 513, 0), write(Buffer.prototype.writeInt8, -2, 2), buf); +console.log(Buffer.prototype.readUInt16LE.call(buf, 0), Buffer.prototype.readInt8.call(buf, 2)); +console.log(Buffer.prototype.toString.call(buf, "hex", 0, 3), Buffer.prototype.toLocaleString.call(Buffer.from("hi"))); +console.log(Buffer.prototype.slice.call(buf, 0, 2), Buffer.prototype.equals.call(buf, Buffer.alloc(8))); +console.log(Buffer.prototype.fill.call(Buffer.alloc(3), 1), Buffer.prototype.indexOf.call(Buffer.from("abc"), "c")); +console.log(Buffer.prototype.write.call(buf, "zz", 4), buf, Buffer.prototype.toJSON.call(Buffer.from([1]))); diff --git a/tests/corpus/7535-inspect-function-properties.cjs b/tests/corpus/7535-inspect-function-properties.cjs new file mode 100644 index 000000000..5e3f8c6a6 --- /dev/null +++ b/tests/corpus/7535-inspect-function-properties.cjs @@ -0,0 +1,29 @@ +// util.inspect of functions with own enumerable properties, as Node's +// formatRaw prints them: the function's base then its keys (symbols +// included), `[Function]` past the depth limit, and the plain base when +// every own property is hidden. +"use strict"; +const util = require("util"); + +function named() {} +named.x = 1; +const arrow = () => {}; +arrow.a = "s"; +arrow[Symbol("k")] = 2; +Object.defineProperty(arrow, "hidden", { value: 3, enumerable: false }); +function hiddenOnly() {} +Object.defineProperty(hiddenOnly, "secret", { value: 1, enumerable: false }); +function big() {} +for (let i = 0; i < 8; i++) big["key" + i] = i; +function self() {} +self.me = self; + +const pass = (value) => value; + +console.log(named, arrow, hiddenOnly); +console.log(util.inspect(named, { depth: 0 })); +console.log(util.inspect(pass({ wrap: named }), { depth: 0 })); +console.log(util.inspect(pass([named, arrow]))); +console.log(util.inspect(big)); +console.log(util.inspect(self)); +console.log(util.inspect(pass({ a: { b: { f: big } } }))); diff --git a/tests/harness/corpus-costs/7527-buffer-from-array-buffer.ts.json b/tests/harness/corpus-costs/7527-buffer-from-array-buffer.ts.json new file mode 100644 index 000000000..593c181c3 --- /dev/null +++ b/tests/harness/corpus-costs/7527-buffer-from-array-buffer.ts.json @@ -0,0 +1 @@ +{ "plain": 4043, "san": 6242 } diff --git a/tests/harness/corpus-costs/7528-buffer-from-array-buffer.cjs.json b/tests/harness/corpus-costs/7528-buffer-from-array-buffer.cjs.json new file mode 100644 index 000000000..759dce51e --- /dev/null +++ b/tests/harness/corpus-costs/7528-buffer-from-array-buffer.cjs.json @@ -0,0 +1 @@ +{ "plain": 3823, "san": 5864 } diff --git a/tests/harness/corpus-costs/7529-buffer-constructor.cjs.json b/tests/harness/corpus-costs/7529-buffer-constructor.cjs.json new file mode 100644 index 000000000..408808da4 --- /dev/null +++ b/tests/harness/corpus-costs/7529-buffer-constructor.cjs.json @@ -0,0 +1 @@ +{ "plain": 5309, "san": 56270 } diff --git a/tests/harness/corpus-costs/7530-buffer-constructor.ts.json b/tests/harness/corpus-costs/7530-buffer-constructor.ts.json new file mode 100644 index 000000000..287d50fdc --- /dev/null +++ b/tests/harness/corpus-costs/7530-buffer-constructor.ts.json @@ -0,0 +1 @@ +{ "plain": 4282, "san": 5688 } diff --git a/tests/harness/corpus-costs/7531-buffer-constructor-dependency/main.cjs.json b/tests/harness/corpus-costs/7531-buffer-constructor-dependency/main.cjs.json new file mode 100644 index 000000000..568483d98 --- /dev/null +++ b/tests/harness/corpus-costs/7531-buffer-constructor-dependency/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 5222, "san": 6614 } diff --git a/tests/harness/corpus-costs/7532-buffer-constructor-check-limit/main.cjs.json b/tests/harness/corpus-costs/7532-buffer-constructor-check-limit/main.cjs.json new file mode 100644 index 000000000..3d262fe41 --- /dev/null +++ b/tests/harness/corpus-costs/7532-buffer-constructor-check-limit/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 4707, "san": 5678 } diff --git a/tests/harness/corpus-costs/7533-buffer-value.cjs.json b/tests/harness/corpus-costs/7533-buffer-value.cjs.json new file mode 100644 index 000000000..126c82265 --- /dev/null +++ b/tests/harness/corpus-costs/7533-buffer-value.cjs.json @@ -0,0 +1 @@ +{ "plain": 4874, "san": 10466 } diff --git a/tests/harness/corpus-costs/7534-buffer-prototype-members.cjs.json b/tests/harness/corpus-costs/7534-buffer-prototype-members.cjs.json new file mode 100644 index 000000000..624b7f12f --- /dev/null +++ b/tests/harness/corpus-costs/7534-buffer-prototype-members.cjs.json @@ -0,0 +1 @@ +{ "plain": 3944, "san": 5992 } diff --git a/tests/harness/corpus-costs/7535-inspect-function-properties.cjs.json b/tests/harness/corpus-costs/7535-inspect-function-properties.cjs.json new file mode 100644 index 000000000..58c48c344 --- /dev/null +++ b/tests/harness/corpus-costs/7535-inspect-function-properties.cjs.json @@ -0,0 +1 @@ +{ "plain": 3458, "san": 4785 } diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 905f648dd..7f86c0dc5 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -148,9 +148,9 @@ function nodeOracleFile(file: string): string { } /** `// @no-deprecation` in the directive head: the Node oracle runs with - * --no-deprecation — for programs exercising deprecated-API ERROR paths - * (new Buffer's ctor ladder) whose DEP warnings carry a pid and can never - * byte-compare; the compiled runtime emits no process warnings at all. */ + * --no-deprecation, for programs whose DEP warnings carry a pid and cannot + * compare. The compiled program does not read the directive; a program + * whose compiled form reports the warning sets process.noDeprecation. */ function wantsNoDeprecation(file: string): boolean { return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l)); } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 319e7329f..39ecd9e79 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -2050,3 +2050,52 @@ console.log(words.length); expect(r.stdout).toBe(`${fence}${fence}1\n`); }); }); + +// The Buffer function read as a value models Node's statics; the forms it +// cannot answer as Node does throw SC2020: calling or constructing it +// through the value (Node reports DEP0005 by the caller's location), its +// prototype as a whole, copyBytesFrom and of, and Buffer.prototype members +// that are inherited, symbol-keyed or called on other receivers. +describe("Buffer value refusals", () => { + test("unmodeled Buffer value forms throw SC2020", async () => { + const r = await compileAndRun( + "buffer-value-refusals", + `const pass = (value) => value; +const Stored = pass(require("buffer").Buffer); +const runs = [ + () => Stored("x"), + () => new Stored(2), + () => Stored.prototype, + () => Stored.copyBytesFrom(new Uint8Array(2)), + () => Stored.of(1), + () => Buffer.prototype.map, + () => Buffer.prototype[Symbol.iterator], + () => Buffer.prototype.readUInt8.call(new Uint8Array(2), 0), +]; +for (const run of runs) { + try { + run(); + console.log("ran"); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} +`, + "cjs", + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + [ + "Error SC2020 Calling or constructing Buffer through a stored Buffer value is not supported yet", + "Error SC2020 Calling or constructing Buffer through a stored Buffer value is not supported yet", + "Error SC2020 Buffer.prototype through a stored Buffer value is not supported yet", + "Error SC2020 Buffer.copyBytesFrom through a stored Buffer value is not supported yet", + "Error SC2020 Buffer.of through a stored Buffer value is not supported yet", + "Error SC2020 Buffer.prototype.map is not supported yet", + "Error SC2020 Buffer.prototype members keyed by symbols are not supported yet", + "Error SC2020 Buffer.prototype.readUInt8 on a receiver that is not a Buffer is not supported yet", + "", + ].join("\n"), + ); + }); +}); From 5a829bd225082bae509c41aede5be68e5a2c9d83 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 11:16:10 -0500 Subject: [PATCH 05/20] 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 4aa9b65ad..5f51a19ca 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 959f9e51e..8c6fb4c85 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 1f10640c0..1ca51f526 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 995cb177a..11f56986b 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 6c7ce814e..c1b54a417 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 0ea94ec7a..e61e7f434 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 7b12297a8..1c544b5c5 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 04ca23a96..394dcafea 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 000000000..aa93871c9 --- /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 66ecea602..046c03aa2 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 d6942718a..2f7d2bea6 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 000000000..c85ce647a --- /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 000000000..63b6307ea --- /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 000000000..b2274d160 --- /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 000000000..743788af0 --- /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 000000000..57c220dcc --- /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 000000000..523896c96 --- /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 000000000..706ed37b5 --- /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 000000000..ee546c4fd --- /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 000000000..89e0d92e7 --- /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 000000000..89fb8ddd2 --- /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 06/20] 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 41ddf2b9d..4d27fa647 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 af32c6936..072e65bba 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 6ebe1222b..43a9176d5 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 11f56986b..c804d62e1 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 5266d5c4e..d914faa5d 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 1be0f95cd..071e9c59e 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 878088d65..c6842f9e4 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 a404c918a..c319cef15 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 c85ce647a..9c770f8dd 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 63b6307ea..a18e9e7df 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 b2274d160..783fb1435 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 000000000..8a1372cea --- /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 000000000..26cc31982 --- /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 7bcfd0cfb..dd6c60783 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 9b382dda5..1ba7a7c8b 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 1dce8d3c2..522a56977 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 5ac3aa9dd..af146c7d3 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 000000000..7cae267ec --- /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 000000000..aebb0dbdf --- /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 000000000..7d76a0583 --- /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 000000000..c0aef3bb7 --- /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 07/20] 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 eb05550c0..3d28dfae3 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 43303b4c6..2b76080fd 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 319e7329f..f379973b3 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 08/20] 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 c804d62e1..1c5513506 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 aa93871c9..774f3aef3 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 d914faa5d..fc52ed555 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 3c74cfbad..090c957ad 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 743788af0..5aaf846ed 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 6c53b141c..1f3a01ba5 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 bbbed7fe2..ea36a9c76 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 09/20] 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 8b0353620..9fa5e9447 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 242a7c6f7..f14866a9c 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 000000000..c5852fb40 --- /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 16a1b1601..e5258e583 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 e6334ab4c..7dffc9183 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 4aa9b65ad..2e46f60b9 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 000000000..428caee32 --- /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 000000000..228652ab3 --- /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 3285aa7fa..57b15349e 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 c4d07656a..01afbdd41 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 9ecd75974..c50931c6a 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 5cf8c6588..923730e17 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 7b7febc06..343b03430 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 7610edcfa07f8478b5a5d7c3b02461f23d3f2df1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 16:16:43 -0500 Subject: [PATCH 10/20] Compare and index with the integer beside a sum's double A sum or product of exact integers whose result range is not proven, such as `child + 1` of a speculated loop counter, computes its double and, beside it, an i64 wide view (`wideArithmetic`). Array indexes already used that view, but number compares and typed-array indexes did not: `child + 1 < end` compared doubles, and `a[child + 1]` converted the double back to an integer and tested the round trip before its bounds compare. `alwaysExactInteger` gives a value's exact integer, or its wide view when the view's magnitude bound is within the magnitude below which the view equals the double. A number compare whose two operands both have one compares the i64s, and a typed-array index uses it directly. Views that may differ from their double, such as `2 * root + 1` of a counter that may reach 2^53, keep the double compare. --- .../compiler/src/backend/llvm/expr-bytes.ts | 17 ++--- .../src/backend/llvm/expr-primitives.ts | 33 ++++++--- .../src/backend/llvm/integer-values.ts | 14 ++++ .../test/integer-view-compares.test.ts | 52 ++++++++++++++ .../corpus/7982-integer-view-compares.ts.json | 6 ++ tests/corpus/7982-integer-view-compares.ts | 69 +++++++++++++++++++ .../7982-integer-view-compares.ts.json | 1 + 7 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/integer-view-compares.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7982-integer-view-compares.ts.json create mode 100644 tests/corpus/7982-integer-view-compares.ts create mode 100644 tests/harness/corpus-costs/7982-integer-view-compares.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 41ddf2b9d..1bc1d5707 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -4,7 +4,7 @@ import { emitBorrowedInput } from "./borrowed-inputs.js"; import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js"; import type { LlvmEmitterContext, LlValue } from "./expr-context.js"; import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js"; -import { exactInteger, widenInteger } from "./integer-values.js"; +import { alwaysExactInteger, exactInteger } from "./integer-values.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; import { emitByteNumber } from "./byte-numbers.js"; import { integerOrNaNIndex, integerOrNaNRange } from "./wrapping-sums.js"; @@ -86,17 +86,18 @@ export function emitBytesIndex( ): string { const B = host.B; const index = value.name; - const integer = exactInteger(host, value, expr); - if (inBounds && integer) { - const wide = widenInteger(host, integer); - if (host.sizeType === "i64") return wide; + // The exact integer, or a wide view that always equals the double (such + // as `i + 1` of a speculated counter). + const exact = alwaysExactInteger(host, value, exactInteger(host, value, expr)); + if (inBounds && exact !== null) { + if (host.sizeType === "i64") return exact; const narrow = B.tmp(); - B.line(`${narrow} = trunc i64 ${wide} to ${host.sizeType}`); + B.line(`${narrow} = trunc i64 ${exact} to ${host.sizeType}`); return narrow; } const len = emitBytesHeaderLength(host, receiver); - if (integer) { - const wideIndex = widenInteger(host, integer); + if (exact !== null) { + const wideIndex = exact; const wideLength = host.sizeType === "i64" ? len : B.tmp(); if (host.sizeType !== "i64") B.line(`${wideLength} = zext ${host.sizeType} ${len} to i64`); const inRange = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2c6ce35db..04af0fcef 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -13,6 +13,7 @@ import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { + alwaysExactInteger, exactInteger, int32Number, integerNumber, @@ -46,6 +47,16 @@ import { } from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; +/** The signed integer predicates of the number compares. */ +const INTEGER_COMPARES: Record = { + "<": "slt", + "<=": "sle", + ">": "sgt", + ">=": "sge", + "===": "eq", + "!==": "ne", +}; + export function emitLiteralExpr( host: LlvmEmitterContext, e: ExprOf<"numLit" | "boolLit" | "strLit" | "moduleNsRef" | "unitLit" | "varRef">, @@ -318,15 +329,7 @@ export function emitOperatorExpr( if (integer) return integer; } if (cmp[e.op] !== undefined) { - const integerCmp: Record = { - "<": "slt", - "<=": "sle", - ">": "sgt", - ">=": "sge", - "===": "eq", - "!==": "ne", - }; - B.line(`${t} = icmp ${integerCmp[e.op]} i64 ${left}, ${right}`); + B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`); return { name: t, type: e.type }; } } @@ -356,7 +359,17 @@ export function emitOperatorExpr( if (wideInteger) value.wideInteger = wideInteger; return value; } - } else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); + } else { + // An operand with only a wide view, such as `i + 1` of a speculated + // counter: compare the integers when both always equal the doubles. + const left = alwaysExactInteger(host, l, li), + right = left === null ? null : alwaysExactInteger(host, r, ri); + if (left !== null && right !== null) { + B.line(`${t} = icmp ${INTEGER_COMPARES[e.op]} i64 ${left}, ${right}`); + return { name: t, type: e.type }; + } + B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); + } } else if (bit[e.op] !== undefined) { if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") { throw new LlvmUnsupportedError( diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index ddbcdd8da..7d5e241dc 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -134,6 +134,20 @@ export function indexInteger( return wide && wide.below >= INDEX_LIMIT ? wide.name : null; } +/** An i64 equal to a value's double wherever the value can be: its exact + * integer, or a wide view whose magnitude bound is within the magnitude below + * which the view is exact. Every integer of magnitude at most 2^53 is a + * double, so a bound equal to that limit is exact too. Null otherwise. */ +export function alwaysExactInteger( + host: LlvmEmitterContext, + value: LlValue, + integer: LlInteger | null, +): string | null { + if (integer) return widenInteger(host, integer); + const wide = value.wideInteger; + return wide && wide.bound <= wide.below ? wide.name : null; +} + export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string { if (value.type === "i64") return value.name; const wide = host.B.tmp(); diff --git a/packages/compiler/test/integer-view-compares.test.ts b/packages/compiler/test/integer-view-compares.test.ts new file mode 100644 index 000000000..e988d48bd --- /dev/null +++ b/packages/compiler/test/integer-view-compares.test.ts @@ -0,0 +1,52 @@ +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"; + +const source = `function siftDown(a: Int32Array, start: number, end: number): void { + let root = start; + while (2 * root + 1 < end) { + let child = 2 * root + 1; + if (child + 1 < end && a[child]! < a[child + 1]!) child++; + if (a[root]! >= a[child]!) return; + const t = a[root]!; + a[root] = a[child]!; + a[child] = t; + root = child; + } +} +const a = new Int32Array([3, 9, 1, 7, 5]); +siftDown(a, 0, Number(process.argv[2] ?? "5")); +console.log(Array.from(a).join()); +`; + +test("integer sums of speculated locals compare and index as integers", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-views-")); + try { + const entry = join(dir, "sift.ts"); + await writeFile(entry, source); + const outPath = join(dir, "sift.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8"))); + const body = ll.match( + /define internal [^\n]+ @sc_bf_siftDown(?:\.i32)?\([^\n]*\) #0 \{([\s\S]*?)\n\}/, + )![1]!; + // `child + 1`: the integer the speculated copy already adds. + const sums = [...body.matchAll(/(%t\d+) = add nsw i64 %t\d+, 1\n/g)].map((m) => m[1]!); + expect(sums.length).toBeGreaterThan(0); + const uses = (op: string) => + sums.filter((sum) => new RegExp(`= ${op} i64 \\${sum}, %t\\d+\\n`).test(body)); + // `child + 1 < end` compares the integers, + expect(uses("icmp slt").length).toBeGreaterThan(0); + // and `a[child + 1]` bounds the integer index without a double round trip. + expect(uses("icmp ult").length).toBeGreaterThan(0); + // `2 * root + 1 < end` may pass 2^53, so it keeps the double compare. + expect(body).toMatch(/fmul double 0x4000000000000000, %t\d+\n\s+%t\d+ = mul nsw i64 2/); + expect(body).toContain("fcmp olt double"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7982-integer-view-compares.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7982-integer-view-compares.ts.json new file mode 100644 index 000000000..94919aab0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7982-integer-view-compares.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7982-integer-view-compares.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7982-integer-view-compares.ts b/tests/corpus/7982-integer-view-compares.ts new file mode 100644 index 000000000..db224aff1 --- /dev/null +++ b/tests/corpus/7982-integer-view-compares.ts @@ -0,0 +1,69 @@ +// Loops whose integer locals take part in compares and typed-array indexes as +// `i + 1`, `i - 1` and `2 * i + 1`: heap sift-downs, neighbor reads at both +// ends of an array, a binary search, and counters near 2^53, where the sum +// of a counter and a constant is still an exact double. + +function siftDown(a: Int32Array, start: number, end: number): void { + let root = start; + while (2 * root + 1 < end) { + let child = 2 * root + 1; + if (child + 1 < end && a[child]! < a[child + 1]!) child++; + if (a[root]! >= a[child]!) return; + const t = a[root]!; + a[root] = a[child]!; + a[child] = t; + root = child; + } +} + +function heapSort(a: Int32Array): string { + for (let start = (a.length >> 1) - 1; start >= 0; start--) siftDown(a, start, a.length); + for (let end = a.length - 1; end > 0; end--) { + const t = a[0]!; + a[0] = a[end]!; + a[end] = t; + siftDown(a, 0, end); + } + return Array.from(a).join(); +} + +function neighbors(a: Float64Array): string { + const out: number[] = []; + let i = 0; + while (i < a.length) { + out.push(a[i - 1]! + a[i + 1]!); + i = i + 1; + } + return out.join(); +} + +function lowerBound(a: Int32Array, value: number): number { + let lo = 0; + let hi = a.length; + while (lo < hi) { + const mid = lo + ((hi - lo) >> 1); + if (a[mid]! < value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function nearLimit(start: number): string { + const seen: string[] = []; + let i = start; + let n = 0; + while (n < 4) { + seen.push(`${i + 1 > 9007199254740991}:${i + 1 === i + 2}:${i - 1 < i}`); + i = i + 1; + n = n + 1; + } + return seen.join(" "); +} + +console.log(heapSort(new Int32Array([5, -3, 9, 0, 9, 2147483647, -2147483648, 7, 1]))); +console.log(heapSort(new Int32Array([])), heapSort(new Int32Array([4]))); +console.log(neighbors(new Float64Array([1, 2, 3, 4]))); +const sorted = new Int32Array([-5, -1, 0, 0, 3, 8, 8, 13]); +console.log([-6, -1, 0, 1, 8, 13, 14].map((v) => lowerBound(sorted, v)).join()); +console.log(nearLimit(9007199254740988)); +console.log(nearLimit(-9007199254740991)); diff --git a/tests/harness/corpus-costs/7982-integer-view-compares.ts.json b/tests/harness/corpus-costs/7982-integer-view-compares.ts.json new file mode 100644 index 000000000..39d9e2b8a --- /dev/null +++ b/tests/harness/corpus-costs/7982-integer-view-compares.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 4600 } From 0b83599af4ab8d0944ffbafdc6479d247ce4973b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 16:27:40 -0500 Subject: [PATCH 11/20] Accept file URL paths in existsSync, fs.promises and order-checked fs calls existsSync and the fs.promises functions accept a file: URL wherever they accept a path. They convert it after their arguments are evaluated, and a URL that does not convert gives false from existsSync and a promise rejected with fileURLToPath's error from fs.promises, as in Node. copyFileSync and renameSync check a string source for null bytes before they convert a URL destination, and readFileSync checks a runtime encoding before it converts a URL path, so the error Node reports first is the one thrown. Refs #1616 Co-authored-by: jlucaso1 <55464917+jlucaso1@users.noreply.github.com> --- .../builtin-apis/filesystem-url-paths.mdx | 6 +- .../ambient/scriptc-node-fallback.d.ts | 58 +-- .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../lowering/builtins/filesystem-calls.ts | 6 +- .../frontend/lowering/builtins/fs-paths.ts | 377 +++++++++++++----- .../lowering/builtins/module-calls.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 10 +- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 8 + .../corpus/6935-fs-url-paths-ordered.ts.json | 6 + .../node-types/fs-url-fallible.ts.json | 6 + .../node-types/fs-url-path-fences.ts.json | 6 - packages/runtime/src/scr_bytes_io.c | 4 + packages/runtime/src/scr_inspect.h | 3 + packages/runtime/src/scr_lib.c | 4 + packages/runtime/src/scr_lib.h | 3 + packages/runtime/src/scr_url.c | 16 + packages/runtime/src/scr_url.h | 3 + tests/corpus/6935-fs-url-paths-ordered.ts | 116 ++++++ tests/diagnostics/fs-url-path-fences.ts | 9 +- tests/fixtures/node-types/fs-url-fallible.ts | 19 + .../fixtures/node-types/fs-url-path-fences.ts | 9 - .../__snapshots__/fs-url-path-fences.ts.txt | 52 +-- .../6935-fs-url-paths-ordered.ts.json | 1 + tests/harness/project-config.test.ts | 19 +- 25 files changed, 568 insertions(+), 188 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-fs-url-paths-ordered.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-fallible.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json create mode 100644 tests/corpus/6935-fs-url-paths-ordered.ts create mode 100644 tests/fixtures/node-types/fs-url-fallible.ts delete mode 100644 tests/fixtures/node-types/fs-url-path-fences.ts create mode 100644 tests/harness/corpus-costs/6935-fs-url-paths-ordered.ts.json diff --git a/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx index d5079ed5a..9b3580647 100644 --- a/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx +++ b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx @@ -1,3 +1,3 @@ -- **Filesystem URL paths:** Synchronous `fs` functions accept `file:` URL paths and convert them as `fileURLToPath` does. - `existsSync` and the `fs.promises` functions refuse URL paths; pass `fileURLToPath(url)` instead. - A URL path after a string path, or with options that are not literals, is refused, because Node checks those arguments before it converts the URL. +- **Filesystem URL paths:** `fs` and `fs.promises` functions accept `file:` URL paths and convert them as `fileURLToPath` does; `existsSync` answers `false` and the `fs.promises` functions reject for a URL that does not convert. + A URL path with options that are not literals is refused where Node checks those options before it converts the URL, except a `readFileSync` encoding. + `fs.promises` functions also refuse a string path before a URL path; pass `fileURLToPath(url)` instead. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 10ed35e68..cceac4ab6 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1582,7 +1582,7 @@ declare module "node:fs" { export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; export function writeFileSync(path: string | URL, data: string | Uint8Array): void; export function appendFileSync(path: string | URL, data: string | Uint8Array): void; - export function existsSync(path: string): boolean; + export function existsSync(path: string | URL): boolean; export function mkdirSync(path: string | URL): void; export function mkdirSync( path: string | URL, @@ -2223,7 +2223,7 @@ declare module "fs/promises" { stat(): Promise; [Symbol.asyncDispose](): Promise; } - export function open(path: string, flags?: string, mode?: number): Promise; + export function open(path: string | URL, flags?: string, mode?: number): Promise; export function link( existingPath: string | Uint8Array | URL, newPath: string | Uint8Array | URL, @@ -2260,14 +2260,14 @@ declare module "fs/promises" { atime: number | string | Date, mtime: number | string | Date, ): Promise; - export function readFile(path: string, encoding: "utf8" | "utf-8"): Promise; + export function readFile(path: string | URL, encoding: "utf8" | "utf-8"): Promise; export function readFile( - path: string, + path: string | URL, options: BufferEncoding | { encoding: BufferEncoding }, ): Promise; - export function readFile(path: string): Promise; + export function readFile(path: string | URL): Promise; export function writeFile( - path: string, + path: string | URL, data: string, options?: { mode?: number; @@ -2276,14 +2276,18 @@ declare module "fs/promises" { [option: string]: unknown; }, ): Promise; - export function writeFile(path: string, data: string, encoding: "utf8" | "utf-8"): Promise; + export function writeFile( + path: string | URL, + data: string, + encoding: "utf8" | "utf-8", + ): Promise; export function mkdir( - path: string, + path: string | URL, options?: { recursive?: boolean; mode?: number }, ): Promise; - export function readdir(path: string): Promise; + export function readdir(path: string | URL): Promise; export function readdir( - path: string, + path: string | URL, options: { encoding?: "utf8" | "utf-8"; withFileTypes: true; @@ -2300,12 +2304,12 @@ declare module "fs/promises" { pattern: string | readonly string[], options: import("node:fs").GlobOptionsWithFileTypes, ): AsyncIterableIterator; - export function rm(path: string): Promise; + export function rm(path: string | URL): Promise; export function rm( - path: string, + path: string | URL, options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number }, ): Promise; - export function stat(path: string): Promise; + export function stat(path: string | URL): Promise; export function statfs( path: string | Uint8Array | URL, options?: import("node:fs").StatFsOptions & { bigint?: false }, @@ -2318,21 +2322,21 @@ declare module "fs/promises" { path: string | Uint8Array | URL, options?: import("node:fs").StatFsOptions, ): Promise; - export function realpath(path: string): Promise; - export function lstat(path: string): Promise; - export function unlink(path: string): Promise; - export function chmod(path: string, mode: number): Promise; - export function chown(path: string, uid: number, gid: number): Promise; - export function lchown(path: string, uid: number, gid: number): Promise; - export function access(path: string, mode?: number): Promise; - export function truncate(path: string, len?: number): Promise; - export function appendFile(path: string, data: string | Uint8Array): Promise; - export function copyFile(src: string, dest: string): Promise; - export function mkdtemp(prefix: string): Promise; - export function rmdir(path: string): Promise; - export function rename(oldPath: string, newPath: string): Promise; + export function realpath(path: string | URL): Promise; + export function lstat(path: string | URL): Promise; + export function unlink(path: string | URL): Promise; + export function chmod(path: string | URL, mode: number): Promise; + export function chown(path: string | URL, uid: number, gid: number): Promise; + export function lchown(path: string | URL, uid: number, gid: number): Promise; + export function access(path: string | URL, mode?: number): Promise; + export function truncate(path: string | URL, len?: number): Promise; + export function appendFile(path: string | URL, data: string | Uint8Array): Promise; + export function copyFile(src: string | URL, dest: string | URL): Promise; + export function mkdtemp(prefix: string | URL): Promise; + export function rmdir(path: string | URL): Promise; + export function rename(oldPath: string | URL, newPath: string | URL): Promise; export function opendir( - path: string, + path: string | URL, options?: import("node:fs").OpenDirOptions, ): Promise; } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9cdc36cf5..11d621ce2 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -703,6 +703,9 @@ export const LIB_FN_SYMS: Record = { "url.hash": "scr_url_hash", "url.searchParams": "scr_url_search_params", "url.fileURLToPathUrl": "scr_url_to_path", + "url.fileURLToPathValue": "scr_url_to_path_value", + "fs.checkPath": "scr_fs_check_path", + "fs.checkReadEncoding": "scr_fs_check_read_encoding", "url.fileURLToPathChecked": "scr_url_checked_to_path", "url.fileURLToPathOptions": "scr_url_checked_to_path_options", "url.fileURLToPathBuffer": "scr_url_checked_to_path_buffer", diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index d147a6208..0bbc3b3fa 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -265,7 +265,7 @@ export function lowerFsPromisesOpenCall( "use open(path[, stringFlags[, numericMode]])", ); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const defaultFlags: IrExpr = { kind: "strLit", value: "r", type: STRING, loc }; const defaultMode: IrExpr = { kind: "numLit", value: 0o666, type: F64, loc }; const flags = expr.arguments.nodes[1] @@ -705,7 +705,7 @@ export function lowerFsPromisesEncodedRead( ) return null; } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const encoding: IrExpr = optionsType.kind === "string" ? lowerer.lowerExprExpecting(options, STRING) @@ -853,7 +853,7 @@ export function lowerFsPromisesMkdirCall( ); } const { recursive, modeNode } = options; - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const mode: IrExpr | null = modeNode ? lowerer.lowerExprExpecting(modeNode, F64) : null; const type: IrType = { kind: "promise", inner: VOID }; if (!recursive) { diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts index 38583025d..5481f93ba 100644 --- a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts +++ b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts @@ -5,30 +5,40 @@ * conversion (url.fileURLToPathUrl). The later arguments through the last * one with side effects are evaluated into hidden locals before the * conversion, in source order, so they run even when it throws and each - * reads what it reads in Node. The conversion keeps Node's error order when every - * earlier path is a URL too and each argument Node validates before the - * path is absent or a literal that passes that validation; other URL - * forms fence with SC2020, as do the fs.promises functions, whose - * conversion errors are rejections rather than throws, and existsSync, - * which answers false for a URL it cannot convert. */ + * reads what it reads in Node. Before the conversion, an earlier string + * path gets its null-byte check (copyFileSync, renameSync) and a runtime + * readFileSync encoding its name check, which Node runs first. existsSync + * and the fs.promises functions convert to a value instead + * (url.fileURLToPathValue) and answer false or a rejected promise for a + * conversion error. An argument Node validates before the path must + * otherwise be absent or a literal that passes that validation; other + * forms fence with SC2020. */ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { sideEffectFreeOptionValue } from "../surfaces.js"; import { stripTypeWrappers } from "../typed-arguments.js"; -import { type IrExpr, type IrStmt, STRING, URL_T } from "../../../ir/ir.js"; +import { boolLit, varRef } from "../../../ir/build.js"; +import { BOOL, DYN, type IrExpr, type IrStmt, STRING, URL_T, VOID } from "../../../ir/ir.js"; +import { InternalCompilerError } from "../../../errors.js"; -/** The fs functions whose path parameters accept a URL: the path - * parameter indexes, and whether Node validates the paths before every - * other argument (otherwise the options come first). */ -const FS_URL_PATHS: Readonly< - Record -> = { +interface FsPathRow { + /** The path parameter indexes. */ + readonly paths: readonly number[]; + /** Whether Node validates the paths before every other argument + * (otherwise the options come first). */ + readonly pathsFirst: boolean; + /** The path parameters' names in Node's errors, when there are two. */ + readonly names?: readonly string[]; +} + +/** The fs functions whose path parameters accept a URL. */ +const FS_URL_PATHS: Readonly> = { accessSync: { paths: [0], pathsFirst: true }, appendFileSync: { paths: [0], pathsFirst: false }, chmodSync: { paths: [0], pathsFirst: true }, chownSync: { paths: [0], pathsFirst: true }, - copyFileSync: { paths: [0, 1], pathsFirst: true }, + copyFileSync: { paths: [0, 1], pathsFirst: true, names: ["src", "dest"] }, lchownSync: { paths: [0], pathsFirst: true }, lstatSync: { paths: [0], pathsFirst: true }, mkdirSync: { paths: [0], pathsFirst: false }, @@ -39,7 +49,7 @@ const FS_URL_PATHS: Readonly< readdirSync: { paths: [0], pathsFirst: false }, realpathSync: { paths: [0], pathsFirst: false }, "realpathSync.native": { paths: [0], pathsFirst: false }, - renameSync: { paths: [0, 1], pathsFirst: true }, + renameSync: { paths: [0, 1], pathsFirst: true, names: ["oldPath", "newPath"] }, rmSync: { paths: [0], pathsFirst: false }, rmdirSync: { paths: [0], pathsFirst: true }, statSync: { paths: [0], pathsFirst: true }, @@ -48,43 +58,66 @@ const FS_URL_PATHS: Readonly< writeFileSync: { paths: [0], pathsFirst: false }, }; -/** The fs.promises functions with path parameters, by path indexes. */ -const FSP_PATHS: Readonly> = { - access: [0], - appendFile: [0], - chmod: [0], - chown: [0], - copyFile: [0, 1], - lchown: [0], - lstat: [0], - mkdir: [0], - mkdtemp: [0], - open: [0], - opendir: [0], - readFile: [0], - readdir: [0], - realpath: [0], - rename: [0, 1], - rm: [0], - rmdir: [0], - stat: [0], - truncate: [0], - unlink: [0], - writeFile: [0], +/** The fs.promises functions with path parameters. */ +const FSP_PATHS: Readonly> = { + access: { paths: [0], pathsFirst: true }, + appendFile: { paths: [0], pathsFirst: false }, + chmod: { paths: [0], pathsFirst: true }, + chown: { paths: [0], pathsFirst: true }, + copyFile: { paths: [0, 1], pathsFirst: true }, + lchown: { paths: [0], pathsFirst: true }, + lstat: { paths: [0], pathsFirst: true }, + mkdir: { paths: [0], pathsFirst: false }, + mkdtemp: { paths: [0], pathsFirst: false }, + open: { paths: [0], pathsFirst: true }, + opendir: { paths: [0], pathsFirst: true }, + readFile: { paths: [0], pathsFirst: false }, + readdir: { paths: [0], pathsFirst: false }, + realpath: { paths: [0], pathsFirst: false }, + rename: { paths: [0, 1], pathsFirst: true }, + rm: { paths: [0], pathsFirst: true }, + rmdir: { paths: [0], pathsFirst: true }, + stat: { paths: [0], pathsFirst: true }, + truncate: { paths: [0], pathsFirst: true }, + unlink: { paths: [0], pathsFirst: true }, + writeFile: { paths: [0], pathsFirst: false }, }; +/** What a path argument of an fs call lowers to (Lowerer.fsPathPlans). */ +export type FsPathPlan = + /** The first URL path: the later arguments with effects, the earlier + * string paths to check, and a runtime encoding to check, all before the + * conversion. */ + | { + kind: "convert"; + staged: ts.Expression[]; + checks: { path: ts.Expression; name: string }[]; + encoding: ts.Expression | null; + } + /** A string path that a later URL path checks: the local that holds it, + * once lowered. */ + | { kind: "save"; local: string | null } + /** A URL path whose conversion error becomes false or a rejection: the + * local that holds the conversion's value. */ + | { kind: "value"; local: string }; + function isUrlArgument(lowerer: Lowerer, node: ts.Expression | undefined): boolean { return node !== undefined && lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "url"; } +function stripParens(node: ts.Expression): ts.Expression { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + /** A literal that Node's option validation accepts before it reads the * path: a string (the checker admits only the declared encodings, flags * and type names), a boolean, an integer in [0, 2^31 - 1] (every numeric * option of these functions accepts that range), undefined or null, or an * object literal of those. */ function preValidatedLiteral(node: ts.Expression): boolean { - let e = node; - while (ts.isParenthesizedExpression(e)) e = e.expression; + const e = stripParens(node); if (ts.isStringLiteralLike(e)) return true; if ( e.kind === ts.SyntaxKind.TrueKeyword || @@ -108,39 +141,59 @@ function preValidatedLiteral(node: ts.Expression): boolean { return false; } -/** SC2020 when the call passes a URL path in a form whose conversion - * order would differ from Node's (see the file comment). */ -function checkUrlPaths( +const HINT = "convert the URL first: fileURLToPath(url) from node:url"; + +/** SC2020 for an argument Node validates before the paths that is neither + * absent nor a literal passing that validation, other than a string + * readFileSync encoding (returned: it is checked before the conversion). + * writeFileSync/appendFileSync's data is checked after the path. */ +function optionsBeforePaths( lowerer: Lowerer, - call: ts.CallExpression, - module: string, + args: readonly ts.Expression[], member: string, -): void { - const args = call.arguments.nodes; - const name = module === "fs/promises" ? `fs.promises.${member}` : member; - const hint = "convert the URL first: fileURLToPath(url) from node:url"; - if (module === "fs/promises") { - if ((FSP_PATHS[member] ?? []).some((i) => isUrlArgument(lowerer, args[i]))) - lowerer.noLowering(`${name} with a URL path`, call, hint); - return; + row: FsPathRow, + name: string, +): ts.Expression | null { + if (row.pathsFirst) return null; + const data = /^(writeFile|appendFile)(Sync)?$/.test(member) ? 1 : -1; + let encoding: ts.Expression | null = null; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (row.paths.includes(i) || i === data || preValidatedLiteral(arg)) continue; + if ( + member === "readFileSync" && + i === 1 && + lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "string" + ) { + encoding = stripTypeWrappers(arg); + continue; + } + lowerer.noLowering(`${name} with a URL path and options that are not literals`, arg, HINT); } - if (member === "existsSync" && isUrlArgument(lowerer, args[0])) - lowerer.noLowering(`${name} with a URL path`, call, hint); - const row = FS_URL_PATHS[member]; - if (!row) return; - const first = row.paths.find((i) => isUrlArgument(lowerer, args[i])); - if (first === undefined) return; - if (args.some(ts.isSpreadElement)) - lowerer.noLowering(`${name} with spread arguments and a URL path`, call); - // The runtime checks a string path after the conversion, so a string - // path Node checks first cannot precede the URL. - if (row.paths.some((i) => i < first && args[i] !== undefined)) - lowerer.noLowering(`${name} with a string path before a URL path`, call, hint); - // Every argument from the URL through the last one with effects is - // evaluated ahead of the conversion, in source order: a later argument's - // effect (an assignment, say) must not change what an earlier one read. + return encoding; +} + +/** The arguments after the URL path that are evaluated ahead of the + * conversion: every one through the last with effects or that `force` + * names, in source order. An object or array literal among them is + * SC2020: its members are lowered one by one, so it cannot be evaluated + * ahead of the conversion as one value. */ +function stagedAfter( + lowerer: Lowerer, + args: readonly ts.Expression[], + first: number, + name: string, + force: (e: ts.Expression, i: number) => boolean, +): ts.Expression[] { + // Every argument from the URL through the last one with effects (or that + // the caller forces) is evaluated ahead of the conversion, in source + // order: a later argument's effect (an assignment, say) must not change + // what an earlier one read. let last = first; - for (let i = first + 1; i < args.length; i++) if (!sideEffectFreeOptionValue(args[i]!)) last = i; + for (let i = first + 1; i < args.length; i++) { + const e = stripTypeWrappers(args[i]!); + if (!sideEffectFreeOptionValue(e) || force(e, i)) last = i; + } const staged: ts.Expression[] = []; for (let i = first + 1; i <= last; i++) { // Keyed without type-only wrappers (`satisfies`, `as`, `!`), so a @@ -154,56 +207,187 @@ function checkUrlPaths( ? `${name} with a URL path and a literal before a later argument that has effects` : `${name} with a URL path and a later literal that has effects`, e, - hint, + HINT, ); } staged.push(e); } - if (staged.length > 0) { - lowerer.stagedAfterPath.set(args[first]!, staged); - for (const e of staged) lowerer.stagedArguments.set(e, null); - } - if (row.pathsFirst) return; - // writeFileSync/appendFileSync's data is checked after the path. - const data = member === "writeFileSync" || member === "appendFileSync" ? 1 : -1; - for (let i = 0; i < args.length; i++) { - if (row.paths.includes(i) || i === data || preValidatedLiteral(args[i]!)) continue; - lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[i]!, hint); - } + return staged; } -/** Fences URL paths that cannot convert in Node's order: the check every - * fs and fs.promises call runs before lowering its arguments. */ +/** Plans the URL paths of a synchronous fs call (see the file comment): + * the check every fs call runs before lowering its arguments. */ export function checkFsUrlPaths( lowerer: Lowerer, call: ts.CallExpression, bi: { module: string; member: string }, ): void { - if (bi.module === "fs" || bi.module === "fs/promises") - checkUrlPaths(lowerer, call, bi.module, bi.member); + if (bi.module !== "fs") return; + const row = FS_URL_PATHS[bi.member]; + if (!row) return; + const args = call.arguments.nodes; + const first = row.paths.find((i) => isUrlArgument(lowerer, args[i])); + if (first === undefined || lowerer.fsPathPlans.has(stripParens(args[first]!))) return; + const name = bi.member; + if (args.some(ts.isSpreadElement)) + lowerer.noLowering(`${name} with spread arguments and a URL path`, call); + const checks: { path: ts.Expression; name: string }[] = []; + for (const i of row.paths) { + if (i >= first || args[i] === undefined) continue; + const path = stripParens(args[i]!); + checks.push({ path, name: row.names?.[i] ?? "path" }); + lowerer.fsPathPlans.set(path, { kind: "save", local: null }); + } + const encoding = optionsBeforePaths(lowerer, args, bi.member, row, name); + const staged = stagedAfter(lowerer, args, first, name, (e) => e === encoding); + for (const e of staged) lowerer.stagedArguments.set(e, null); + lowerer.fsPathPlans.set(stripParens(args[first]!), { kind: "convert", staged, checks, encoding }); +} + +/** existsSync and the fs.promises functions with URL paths: each URL + * converts to a value (url.fileURLToPathValue) after the arguments are + * evaluated, and the call runs on the converted paths only when every + * conversion produced a string; otherwise existsSync answers false and an + * fs.promises function a promise rejected with the first conversion's + * error. `lowerCall` lowers the call itself, whose URL paths then read the + * converted values. Null for every other call. */ +export function lowerFallibleFsUrlCall( + lowerer: Lowerer, + call: ts.CallExpression, + bi: { module: string; member: string }, + lowerCall: () => IrExpr, +): IrExpr | null { + const row = + bi.module === "fs/promises" + ? FSP_PATHS[bi.member] + : bi.module === "fs" && bi.member === "existsSync" + ? ({ paths: [0], pathsFirst: true } satisfies FsPathRow) + : undefined; + if (!row) return null; + const args = call.arguments.nodes; + const urls = row.paths.filter((i) => isUrlArgument(lowerer, args[i])); + if (urls.length === 0 || lowerer.fallibleFsCalls.has(call)) return null; + const name = bi.module === "fs/promises" ? `fs.promises.${bi.member}` : bi.member; + if (args.some(ts.isSpreadElement)) + lowerer.noLowering(`${name} with spread arguments and a URL path`, call); + const first = urls[0]!; + // A string path before the URL would be checked in the call itself, + // after the conversion. + if (row.paths.some((i) => i < first && args[i] !== undefined)) + lowerer.noLowering(`${name} with a string path before a URL path`, call, HINT); + if (optionsBeforePaths(lowerer, args, bi.member, row, name) !== null) + lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[1]!, HINT); + const loc = locOf(call); + const stmts: IrStmt[] = []; + const values = new Map(); + const save = (value: IrExpr, label: string, at: ts.Node): string => { + const slot = lowerer.declareHiddenLocal(label, value.type); + stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(at) }); + return slot.id; + }; + values.set(first, save(lowerer.lowerExprExpecting(args[first]!, URL_T), "%fsUrl", args[first]!)); + const staged = stagedAfter(lowerer, args, first, name, (_e, i) => urls.includes(i)); + for (const e of staged) { + const value = lowerer.lowerExpr(e); + const slot = save(value, "%fsArg", e); + lowerer.stagedArguments.set(e, { localId: slot, type: value.type }); + const index = args.findIndex((arg) => stripTypeWrappers(arg) === e); + if (urls.includes(index)) values.set(index, slot); + } + const results: string[] = []; + for (const i of urls) { + const url = varRef(values.get(i)!, URL_T, loc); + const converted: IrExpr = { + kind: "libCall", + fn: "url.fileURLToPathValue", + args: [url], + type: DYN, + loc, + }; + const local = save(converted, "%fsPathValue", args[i]!); + lowerer.fsPathPlans.set(stripParens(args[i]!), { kind: "value", local }); + results.push(local); + } + lowerer.fallibleFsCalls.add(call); + let inner: IrExpr; + try { + inner = lowerCall(); + } finally { + lowerer.fallibleFsCalls.delete(call); + } + let result = inner; + for (let k = results.length - 1; k >= 0; k--) { + const value = varRef(results[k]!, DYN, loc); + const failed: IrExpr = + bi.module === "fs/promises" + ? { kind: "intrinsic", name: "promise.reject", args: [value], type: inner.type, loc } + : boolLit(false, loc); + result = { + kind: "ternary", + cond: { kind: "dynTest", test: "string", value, type: BOOL, loc }, + then: result, + else_: failed, + type: inner.type, + loc, + }; + } + return { kind: "seqExpr", stmts, result, type: inner.type, loc }; } /** An fs path argument as a string: a URL converts with fileURLToPath's * conversion (url.fileURLToPathUrl), which throws Node's errors for other - * schemes, hosts and encoded separators. */ + * schemes, hosts and encoded separators, after the checks its plan names. */ export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr { - if (!isUrlArgument(lowerer, node)) return lowerer.lowerExprExpecting(node, STRING); const loc = locOf(node); + const plan = lowerer.fsPathPlans.get(stripParens(node)); + if (plan?.kind === "value") + return { kind: "dynCheck", value: varRef(plan.local, DYN, loc), type: STRING, loc }; + if (!isUrlArgument(lowerer, node)) { + const value = lowerer.lowerExprExpecting(node, STRING); + if (plan?.kind !== "save") return value; + const slot = lowerer.declareHiddenLocal("%fsPath", STRING); + plan.local = slot.id; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: slot.id, init: value, loc }], + result: varRef(slot.id, STRING, loc), + type: STRING, + loc, + }; + } const url = lowerer.lowerExprExpecting(node, URL_T); - const staged = lowerer.stagedAfterPath.get(node); - if (staged === undefined) + if (plan?.kind !== "convert") return { kind: "libCall", fn: "url.fileURLToPathUrl", args: [url], type: STRING, loc }; // The URL and the later arguments with effects, in source order, then - // the conversion. + // the checks Node runs first, then the conversion. const saved = lowerer.declareHiddenLocal("%fsUrl", URL_T); const stmts: IrStmt[] = [{ kind: "varDecl", localId: saved.id, init: url, loc }]; - for (const arg of staged) { + for (const arg of plan.staged) { lowerer.stagedArguments.delete(arg); const value = lowerer.lowerExpr(arg); const slot = lowerer.declareHiddenLocal("%fsArg", value.type); stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(arg) }); lowerer.stagedArguments.set(arg, { localId: slot.id, type: value.type }); } + const check = (fn: "fs.checkPath" | "fs.checkReadEncoding", args: IrExpr[]): void => { + stmts.push({ kind: "exprStmt", expr: { kind: "libCall", fn, args, type: VOID, loc }, loc }); + }; + for (const { path, name } of plan.checks) { + const saved = lowerer.fsPathPlans.get(path); + if (saved?.kind !== "save" || saved.local === null) + throw new InternalCompilerError("an fs path checked before it was lowered"); + check("fs.checkPath", [ + varRef(saved.local, STRING, loc), + { kind: "strLit", value: name, type: STRING, loc }, + ]); + } + if (plan.encoding !== null) { + const slot = lowerer.stagedArguments.get(plan.encoding); + if (!slot) throw new InternalCompilerError("an fs encoding checked before it was saved"); + check("fs.checkReadEncoding", [ + lowerer.coerceToExpected(varRef(slot.localId, slot.type, loc), STRING), + ]); + } return { kind: "seqExpr", stmts, @@ -219,7 +403,12 @@ export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr { }; } -/** Whether `index` is a path parameter of the fs function `member`. */ -export function isFsPathParam(member: string, index: number): boolean { - return FS_URL_PATHS[member]?.paths.includes(index) === true; +/** Whether `index` is a path parameter of the fs or fs.promises function + * `member`. */ +export function isFsPathParam(module: string, member: string, index: number): boolean { + if (module === "fs/promises") return FSP_PATHS[member]?.paths.includes(index) === true; + if (module !== "fs") return false; + return ( + (member === "existsSync" && index === 0) || FS_URL_PATHS[member]?.paths.includes(index) === true + ); } diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index e5446291f..2aaa2f80f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -25,7 +25,7 @@ import { lowerFsWriteOptionsCall, lowerFsReadPathCall, } from "./filesystem-calls.js"; -import { checkFsUrlPaths, isFsPathParam, lowerFsPath } from "./fs-paths.js"; +import { checkFsUrlPaths, isFsPathParam, lowerFallibleFsUrlCall, lowerFsPath } from "./fs-paths.js"; import { lowerBufferEncodingCall } from "./text-codecs.js"; import { lowerUtilTypeCall } from "./util-types.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -59,6 +59,10 @@ export function lowerBuiltinModuleCall( loc: SrcLoc, ): IrExpr { const name = ts.getTextOfNode(expr.expression); + const fallible = lowerFallibleFsUrlCall(lowerer, expr, bi, () => + lowerBuiltinModuleCall(lowerer, expr, bi, fn, loc), + ); + if (fallible) return fallible; checkFsUrlPaths(lowerer, expr, bi); if (bi.module === "util/types") { return lowerUtilTypeCall(lowerer, expr, bi, loc, name); @@ -258,7 +262,7 @@ export function lowerBuiltinModuleCall( bi.member === "readFile" && expr.arguments.nodes.length === 1 ) { - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); return { kind: "libCall", fn: "fsp.readFileBytes", @@ -473,7 +477,7 @@ export function lowerBuiltinModuleCall( args = lowerer.completeArgs(expr.arguments.nodes, shapes, loc, expr); } else { args = expr.arguments.nodes.map((a, i) => - bi.module === "fs" && isFsPathParam(bi.member, i) + isFsPathParam(bi.module, bi.member, i) ? lowerFsPath(lowerer, a) : lowerer.lowerExprExpecting(a, fn.params[i]), ); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7aad166e9..0e1821ae5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -147,6 +147,7 @@ import { } from "./function-context.js"; import type { FrontendServices } from "../services.js"; import { InternalCompilerError } from "../../errors.js"; +import type { FsPathPlan } from "./builtins/fs-paths.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optional-arguments.js"; import { type ProtocolDispatch } from "./class-protocol-dispatch.js"; import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js"; @@ -3136,8 +3137,13 @@ export class Lowerer { * (builtins/fs-paths.ts): each lowers to the hidden local that holds its * value, which is null until the path's lowering has saved it. */ readonly stagedArguments = new Map(); - /** The staged arguments that follow each such URL path, in order. */ - readonly stagedAfterPath = new Map(); + /** What each fs path argument of a URL-path call lowers to (the plans + * of builtins/fs-paths.ts). */ + readonly fsPathPlans = new Map(); + /** The existsSync/fs.promises calls whose converted URL paths are being + * lowered into the call itself (lowerFallibleFsUrlCall). A call may be + * lowered more than once, and each lowering converts again. */ + readonly fallibleFsCalls = new Set(); readonly moduleNsParamOverrides = new Map< ts.ParameterDeclaration, IrType & { kind: "moduleNs" } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index b517d43a1..cfdf8a729 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -739,6 +739,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "url.set", "url.setChecked", "url.fileURLToPathUrl", + "fs.checkPath", + "fs.checkReadEncoding", "url.fileURLToPathChecked", "url.fileURLToPathOptions", "url.fileURLToPathBuffer", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index e174238e2..ee9c5451b 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1006,6 +1006,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "url.set": { argTypes: [URL_T, STRING, STRING], result: VOID }, "url.setChecked": { argTypes: [URL_T, STRING, DYN], result: VOID }, "url.fileURLToPathUrl": { argTypes: [URL_T], result: STRING }, + /** fileURLToPathUrl as a value: the path string, or the error it would + * throw (fs.existsSync and fs.promises URL paths). Never throws. */ + "url.fileURLToPathValue": { argTypes: [URL_T], result: DYN }, + /** getValidatedPath's null-byte check on a string path, and + * readFileSync's encoding check: what Node validates before it converts + * a later URL path. (path, argument name) and (encoding). */ + "fs.checkPath": { argTypes: [STRING, STRING], result: VOID }, + "fs.checkReadEncoding": { argTypes: [STRING], result: VOID }, "url.fileURLToPathChecked": { argTypes: [DYN], result: STRING }, "url.fileURLToPathOptions": { argTypes: [DYN, DYN], result: STRING }, /** node:url's legacy API (scr_url_legacy.c): parse(url, parseQueryString, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-fs-url-paths-ordered.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-fs-url-paths-ordered.ts.json new file mode 100644 index 000000000..ec3d3f004 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6935-fs-url-paths-ordered.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6935-fs-url-paths-ordered.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-fallible.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-fallible.ts.json new file mode 100644 index 000000000..e4967ea93 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-fallible.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fs-url-fallible.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json deleted file mode 100644 index 7b63a5564..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/fixtures/node-types/fs-url-path-fences.ts" - ], - "diags": [] -} diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index 137208938..bac72a3ed 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -59,6 +59,10 @@ static bool scr_fs_read_utf8(ScrStr *encoding) { return encoding->len == 0 || scr_bytes_encoding_is_utf8(encoding); } +void scr_fs_check_read_encoding(ScrStr *encoding) { + (void)scr_fs_read_encoding_valid(encoding); +} + ScrStr *scr_fs_read_file_encoded(ScrStr *path, ScrStr *encoding) { if (!scr_fs_read_encoding_valid(encoding)) return NULL; if (scr_fs_read_utf8(encoding)) return scr_fs_read_file(path); diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index 5ac3aa9dd..6db466fcf 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -115,6 +115,9 @@ ScrBytes *scr_fs_read_file_bytes(ScrStr *path); /* +1 */ * with the exception pending. */ ScrDyn *scr_fs_read_file_sync_dyn(ScrStr *path, const ScrDyn *enc); ScrStr *scr_fs_read_file_encoded(ScrStr *path, ScrStr *encoding); +/* readFileSync's encoding check (ERR_INVALID_ARG_VALUE), on its own: the + * check Node runs before it converts a URL path. */ +void scr_fs_check_read_encoding(ScrStr *encoding); ScrStr *scr_fs_read_fd_encoded(double fd, ScrStr *encoding); ScrDyn *scr_fs_callback_value(ScrStr *member); ScrDyn *scr_fs_callback_call(ScrStr *member, const ScrDyn *args); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index c17f44f9d..f413eb607 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1078,6 +1078,10 @@ bool scr_fs_path_ok(const ScrStr *path, const char *name) { return false; } +void scr_fs_check_path(ScrStr *path, ScrStr *name) { + (void)scr_fs_path_ok(path, name->data); +} + void scr_process_load_env_file(const ScrDyn *value) { ScrStr *path; if (value->kind == SCR_DYN_UNDEF || value->kind == SCR_DYN_NULL) path = scr_str_new("./.env", 6); diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 95f67044a..fa447ff41 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -409,6 +409,9 @@ void scr_fs_invalid_named_path(const ScrDyn *value, const ScrStr *path, const ch /* A string path argument: false after throwing Node's ERR_INVALID_ARG_VALUE * when it holds a null byte (`name` is the argument's name). */ bool scr_fs_path_ok(const ScrStr *path, const char *name); +/* scr_fs_path_ok as a statement: getValidatedPath's null-byte check on a + * path the call validates before converting a later URL path. */ +void scr_fs_check_path(ScrStr *path, ScrStr *name); void scr_fs_futimes(double fd, double atime, double mtime); void scr_fs_link(ScrStr *source, ScrStr *dest); void scr_fs_symlink(ScrStr *target, ScrStr *destination, int kind); diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 67de82471..025bd740f 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -1394,6 +1394,22 @@ ScrStr *scr_url_to_path(ScrUrl *u) { #endif } +/* fs.existsSync and the fs.promises functions convert a URL path inside a + * try and turn its error into false or a rejection: the conversion's result + * as a value, the path string or the error. */ +ScrDyn *scr_url_to_path_value(ScrUrl *u) { + ScrStr *path = scr_url_to_path(u); + if (path) { + ScrDyn *value = scr_dyn_new_str(path); + scr_str_release(path); + return value; + } + ScrCaught *caught = scr_exc_take(); + ScrDyn *error = scr_caught_to_dyn(caught); + scr_caught_release(caught); + return error; +} + /* The string-receiver form parses first (Node accepts URL strings). */ ScrStr *scr_url_str_to_path(ScrStr *input) { ScrUrl *u = scr_url_new(input); diff --git a/packages/runtime/src/scr_url.h b/packages/runtime/src/scr_url.h index 05e5702bb..435644419 100644 --- a/packages/runtime/src/scr_url.h +++ b/packages/runtime/src/scr_url.h @@ -64,6 +64,9 @@ ScrStr *scr_punycode_to_unicode(const ScrStr *input); ScrArr *scr_punycode_ucs2_decode(const ScrStr *input); ScrStr *scr_url_domain_to_unicode(const ScrDyn *domain, bool missing); ScrStr *scr_url_to_path(ScrUrl *u); /* +1, or throws */ +/* scr_url_to_path as a value: the path string, or the error it would throw + * (+1 dyn, never throws). */ +ScrDyn *scr_url_to_path_value(ScrUrl *u); ScrStr *scr_url_str_to_path(ScrStr *s); /* +1, or throws */ ScrUrl *scr_url_from_path(ScrStr *path); /* +1; throws on win32 UNC malformations only */ ScrUrl *scr_url_from_path_platform(ScrStr *path, bool windows); /* +1; explicit options.windows */ diff --git a/tests/corpus/6935-fs-url-paths-ordered.ts b/tests/corpus/6935-fs-url-paths-ordered.ts new file mode 100644 index 000000000..4e9ddb53c --- /dev/null +++ b/tests/corpus/6935-fs-url-paths-ordered.ts @@ -0,0 +1,116 @@ +// URL paths that Node converts inside a try or after checking other +// arguments: existsSync answers false for a URL it cannot convert, the +// fs.promises functions return a promise rejected with the conversion's +// error, copyFileSync and renameSync check a string source before they +// convert a URL destination, and readFileSync checks a runtime encoding +// before it converts the URL. +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; + +const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "fs-url-ordered-"))); +const at = (name: string): URL => pathToFileURL(path.join(dir, name)); +const scheme = new URL("http://example.com/a.txt"); +const host = new URL("file://example.com/a.txt"); + +function report(label: string, error: unknown): void { + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + console.log(label, error.name, code, error.message.split(dir).join("")); + } +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "returned"); + } catch (error) { + report(label, error); + } +} + +async function settle(label: string, run: () => Promise): Promise { + try { + await run(); + console.log(label, "fulfilled"); + } catch (error) { + report(label, error); + } +} + +async function main(): Promise { + fs.writeFileSync(at("a b.txt"), "hello"); + console.log(fs.existsSync(at("a b.txt")), fs.existsSync(at("missing"))); + console.log(fs.existsSync(scheme), fs.existsSync(host), fs.existsSync(new URL("file:///tmp/a%2Fb"))); + console.log(fs.existsSync(new URL("file:///tmp/a%00b"))); + + console.log(await fsp.readFile(at("a b.txt"), "utf8")); + console.log((await fsp.readFile(at("a b.txt"))).length); + await fsp.writeFile(at("w.txt"), "written"); + await fsp.appendFile(at("w.txt"), "!"); + console.log((await fsp.stat(at("w.txt"))).size, (await fsp.lstat(at("w.txt"))).isFile()); + await fsp.access(at("w.txt")); + await fsp.chmod(at("w.txt"), 0o644); + await fsp.truncate(at("w.txt"), 3); + await fsp.copyFile(at("w.txt"), at("copy.txt")); + await fsp.rename(at("copy.txt"), at("moved.txt")); + console.log(await fsp.readFile(at("moved.txt"), "utf8")); + await fsp.mkdir(at("sub/deep"), { recursive: true }); + console.log(await fsp.readdir(at("sub"))); + console.log(path.dirname(await fsp.mkdtemp(at("tmp-"))) === dir); + console.log((await fsp.realpath(at("sub/../sub"))).split(dir).join("")); + // An array literal lowers its elements more than once. + console.log([await fsp.realpath(at("sub"))].length, [fs.existsSync(scheme)][0]); + await fsp.rmdir(at("sub/deep")); + await fsp.unlink(at("moved.txt")); + await fsp.rm(at("w.txt")); + await fsp.rm(at("sub"), { recursive: true, force: true }); + console.log(fs.existsSync(at("sub"))); + + // The promise is returned; the error arrives as its rejection. + const pending = fsp.stat(scheme); + console.log("returned a promise"); + await pending.catch((error: unknown) => report("stat scheme", error)); + await settle("readFile host", () => fsp.readFile(host, "utf8")); + await settle("copyFile both", () => fsp.copyFile(scheme, host)); + await settle("rename dest", () => fsp.rename(at("a b.txt"), host)); + await settle("writeFile separator", () => fsp.writeFile(new URL("file:///tmp/a%2fb"), "x")); + await settle("unlink missing", () => fsp.unlink(at("missing"))); + await settle("rm scheme options", () => fsp.rm(scheme, { recursive: true, force: true })); + + // Arguments inside type-only wrappers are evaluated once. + let modeCalls = 0; + const countedMode = (): number => { + modeCalls++; + return 0o644; + }; + await settle("chmod satisfies", () => fsp.chmod(scheme, countedMode() satisfies number)); + await settle("chmod as", () => fsp.chmod(at("a b.txt"), countedMode() as number)); + await settle("chmod non-null", () => fsp.chmod(host, countedMode()!)); + console.log("mode calls", modeCalls); + await settle("copyFile as", () => fsp.copyFile(scheme, at("c.txt") as URL)); + await settle("copyFile dest as", () => fsp.copyFile(at("a b.txt"), host as URL)); + + attempt("copyFileSync source first", () => fs.copyFileSync("a\0b", scheme)); + attempt("copyFileSync dest", () => fs.copyFileSync(path.join(dir, "a b.txt"), host)); + attempt("renameSync source first", () => fs.renameSync("a\0b", host)); + fs.copyFileSync(path.join(dir, "a b.txt"), at("copied.txt")); + console.log(fs.readFileSync(at("copied.txt"), "utf8")); + + const encodings = ["utf8", "latin1", "base64", "bogus"]; + for (const encoding of encodings) { + attempt(`readFileSync ${encoding}`, () => { + console.log(fs.readFileSync(at("a b.txt"), encoding as BufferEncoding)); + }); + attempt(`readFileSync ${encoding} scheme`, () => { + console.log(fs.readFileSync(scheme, encoding as BufferEncoding)); + }); + } + + fs.rmSync(dir, { recursive: true, force: true }); + console.log("done", fs.existsSync(dir)); +} + +void main(); diff --git a/tests/diagnostics/fs-url-path-fences.ts b/tests/diagnostics/fs-url-path-fences.ts index 080c561e1..6b65bfac0 100644 --- a/tests/diagnostics/fs-url-path-fences.ts +++ b/tests/diagnostics/fs-url-path-fences.ts @@ -1,15 +1,16 @@ // URL paths whose conversion would run in a different order than Node's. import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; const url = new URL("file:///tmp/fs-url-path-fences"); -const encoding: BufferEncoding = process.argv.length > 9 ? "latin1" : "utf8"; const recursive = process.argv.length > 9; -// Node validates the string source before converting the URL destination. -fs.copyFileSync("/tmp/source", url); // Node validates the options before the path. -console.log(fs.readFileSync(url, encoding)); fs.mkdirSync(url, { recursive }); +void fsp.mkdir(url, { recursive }); // A literal's members are lowered one by one, so they cannot be evaluated // ahead of the conversion as one value. console.log(fs.statSync(url, { throwIfNoEntry: process.argv.length > 9 })); +// fs.promises checks the string source inside the call, after the +// conversion would have run. +void fsp.copyFile("/tmp/source", url); diff --git a/tests/fixtures/node-types/fs-url-fallible.ts b/tests/fixtures/node-types/fs-url-fallible.ts new file mode 100644 index 000000000..da86879d5 --- /dev/null +++ b/tests/fixtures/node-types/fs-url-fallible.ts @@ -0,0 +1,19 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const dir = mkdtempSync(join(tmpdir(), "node-types-url-")); +const file = pathToFileURL(join(dir, "a.txt")); +writeFileSync(file, "hi"); +// existsSync answers false for a URL it cannot convert. +console.log(existsSync(file), existsSync(new URL("http://example.com/"))); +// fs.promises functions reject for a URL they cannot convert. +readFile(file, "utf8") + .then((text) => console.log(text)) + .then(() => readFile(new URL("file://example.com/a.txt"), "utf8")) + .catch((error: unknown) => { + if (error instanceof Error) console.log((error as NodeJS.ErrnoException).code); + rmSync(dir, { recursive: true }); + }); diff --git a/tests/fixtures/node-types/fs-url-path-fences.ts b/tests/fixtures/node-types/fs-url-path-fences.ts deleted file mode 100644 index 4ae142189..000000000 --- a/tests/fixtures/node-types/fs-url-path-fences.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; - -const url = new URL("file:///tmp/node-types-url-fences"); -// existsSync answers false for a URL it cannot convert. -console.log(existsSync(url)); -// The fs.promises functions reject for a URL they cannot convert. -void readFile(url, "utf8"); -void stat(url); diff --git a/tests/harness/__snapshots__/fs-url-path-fences.ts.txt b/tests/harness/__snapshots__/fs-url-path-fences.ts.txt index 1a53f8df5..1b4642d32 100644 --- a/tests/harness/__snapshots__/fs-url-path-fences.ts.txt +++ b/tests/harness/__snapshots__/fs-url-path-fences.ts.txt @@ -1,35 +1,35 @@ -fs-url-path-fences.ts:9:1 - error SC2020: 'copyFileSync with a string path before a URL path' is part of the standard library types but has no scriptc lowering yet +fs-url-path-fences.ts:9:19 - error SC2020: 'mkdirSync with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet - 8 | // Node validates the string source before converting the URL destination. - 9 | fs.copyFileSync("/tmp/source", url); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 10 | // Node validates the options before the path. - - hint: convert the URL first: fileURLToPath(url) from node:url - -fs-url-path-fences.ts:11:34 - error SC2020: 'readFileSync with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet - - 10 | // Node validates the options before the path. - 11 | console.log(fs.readFileSync(url, encoding)); - | ^~~~~~~~ - 12 | fs.mkdirSync(url, { recursive }); - - hint: convert the URL first: fileURLToPath(url) from node:url - -fs-url-path-fences.ts:12:19 - error SC2020: 'mkdirSync with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet - - 11 | console.log(fs.readFileSync(url, encoding)); - 12 | fs.mkdirSync(url, { recursive }); + 8 | // Node validates the options before the path. + 9 | fs.mkdirSync(url, { recursive }); | ^~~~~~~~~~~~~ - 13 | // A literal's members are lowered one by one, so they cannot be evaluated + 10 | void fsp.mkdir(url, { recursive }); hint: convert the URL first: fileURLToPath(url) from node:url -fs-url-path-fences.ts:15:30 - error SC2020: 'statSync with a URL path and a later literal that has effects' is part of the standard library types but has no scriptc lowering yet +fs-url-path-fences.ts:10:21 - error SC2020: 'fs.promises.mkdir with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet - 14 | // ahead of the conversion as one value. - 15 | console.log(fs.statSync(url, { throwIfNoEntry: process.argv.length > 9 })); + 9 | fs.mkdirSync(url, { recursive }); + 10 | void fsp.mkdir(url, { recursive }); + | ^~~~~~~~~~~~~ + 11 | // A literal's members are lowered one by one, so they cannot be evaluated + + hint: convert the URL first: fileURLToPath(url) from node:url + +fs-url-path-fences.ts:13:30 - error SC2020: 'statSync with a URL path and a later literal that has effects' is part of the standard library types but has no scriptc lowering yet + + 12 | // ahead of the conversion as one value. + 13 | console.log(fs.statSync(url, { throwIfNoEntry: process.argv.length > 9 })); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 16 | + 14 | // fs.promises checks the string source inside the call, after the + + hint: convert the URL first: fileURLToPath(url) from node:url + +fs-url-path-fences.ts:16:6 - error SC2020: 'fs.promises.copyFile with a string path before a URL path' is part of the standard library types but has no scriptc lowering yet + + 15 | // conversion would have run. + 16 | void fsp.copyFile("/tmp/source", url); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 17 | hint: convert the URL first: fileURLToPath(url) from node:url \ No newline at end of file diff --git a/tests/harness/corpus-costs/6935-fs-url-paths-ordered.ts.json b/tests/harness/corpus-costs/6935-fs-url-paths-ordered.ts.json new file mode 100644 index 000000000..f2dfcb1a1 --- /dev/null +++ b/tests/harness/corpus-costs/6935-fs-url-paths-ordered.ts.json @@ -0,0 +1 @@ +{ "plain": 3746, "san": 5148 } diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 4676499eb..c59ee8693 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -795,20 +795,17 @@ test("node-types: fs functions take URL paths under @types/node's PathLike", asy expect(stdout).toBe("hello 5 5\n"); }); -test("node-types: existsSync and fs.promises URL paths remain named fences", async () => { - const dir = outDirFor("node-fs-url-path-fences"); - const result = await compile(join(nodeTypesDir, "fs-url-path-fences.ts"), { - outPath: join(dir, "fs-url-path-fences"), +test("node-types: existsSync and fs.promises answer false or reject for unconvertible URLs", async () => { + const dir = outDirFor("node-fs-url-fallible"); + const result = await compile(join(nodeTypesDir, "fs-url-fallible.ts"), { + outPath: join(dir, "fs-url-fallible"), outDir: dir, sanitize, }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message])).toEqual([ - ["SC2020", expect.stringContaining("'existsSync with a URL path'")], - ["SC2020", expect.stringContaining("'fs.promises.readFile with a URL path'")], - ["SC2020", expect.stringContaining("'fs.promises.stat with a URL path'")], - ]); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath); + expect(stdout).toBe("true false\nhi\nERR_INVALID_FILE_URL_HOST\n"); }); test("node-types: path and os lower statically under @types/node's shapes", async () => { From 6b3297e3a65c46c7b649f2dfba6cbadd2d536598 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 16:33:09 -0500 Subject: [PATCH 12/20] Run TypedArray methods on typed arrays held as untyped values --- .../7752-typed-array-dynamic-methods.js.json | 6 + packages/runtime/src/scr_bytes.c | 13 + packages/runtime/src/scr_dyn.h | 7 + packages/runtime/src/scr_dyn_invoke.c | 70 +-- packages/runtime/src/scr_json.c | 569 +++++++++++++++++- .../7752-typed-array-dynamic-methods.js | 90 +++ .../7752-typed-array-dynamic-methods.js.json | 1 + 7 files changed, 690 insertions(+), 66 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7752-typed-array-dynamic-methods.js.json create mode 100644 tests/corpus/7752-typed-array-dynamic-methods.js create mode 100644 tests/harness/corpus-costs/7752-typed-array-dynamic-methods.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7752-typed-array-dynamic-methods.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7752-typed-array-dynamic-methods.js.json new file mode 100644 index 000000000..78c9633d6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7752-typed-array-dynamic-methods.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7752-typed-array-dynamic-methods.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 7bcfd0cfb..dc7c77382 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -874,6 +874,19 @@ ScrBytes *scr_bytes_from_dyn(ScrBytesElem elem, const ScrDyn *value, bool from) // Constructors drain array iterators before element coercion. Node's .from // fast path reads ordinary array elements during conversion instead. Strings // supplied to .from iterate by code point (set reads UTF-16 code units). + // An object with a Symbol.iterator method iterates; others are array-like. + if (scr_dyn_has_iterator_method(value)) { + ScrDyn *items = scr_dyn_destructure_rest(value, NULL); + if (!items) return NULL; + ScrBytes *out = scr_bytes_new(elem, (double)items->v.arr.len); + if (out && !scr_bytes_copy_array_like(out, items, 0, out->len)) { + scr_bytes_release(out); + out = NULL; + } + scr_dyn_release(items); + return out; + } + if (scr_exc_pending()) return NULL; if ((!from && value->kind == SCR_DYN_ARR) || (from && value->kind == SCR_DYN_STR)) { ScrDyn *items = scr_dyn_iter_pack(value, NULL); if (!items) return NULL; diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 73dad7bd3..49fb60978 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1382,6 +1382,13 @@ ScrDyn *scr_dyn_invoke_buffer_to_string(ScrDyn *recv, ScrDyn *callee, const char ScrDyn *scr_dyn_invoke_buffer_search(ScrDyn *recv, ScrDyn *callee, const char *method, int32_t *mid_slot, ScrDyn *const *args, size_t argc, const char *what); ScrDyn *scr_dyn_invoke_buffer(ScrDyn *recv, ScrDyn *callee, const char *method, int32_t *mid_slot, ScrDyn *const *args, size_t argc, const char *what); ScrDyn *scr_dyn_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc, const char *what); +/* A %TypedArray%.prototype method called on a checked typed array (not a + * DataView): its answer (+1, or NULL with the exception pending), with + * *handled false for a name it does not implement. */ +ScrDyn *scr_dyn_bytes_method(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc, bool *handled); +/* Whether a plain object has a Symbol.iterator method (its getter may + * throw: check for a pending exception). */ +bool scr_dyn_has_iterator_method(const ScrDyn *value); /* Keyed read on a FUNC node: the own-property table first (defineProperties * writes land there), then "name" (the box's best-effort static name; "" * for anonymous) and "length" (the boxed arity). Returns +1, or NULL when diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index c8093f64c..b76fe38d2 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -191,13 +191,6 @@ static double dyn_index_arg(ScrDyn *const *args, size_t argc, size_t i, double d return isnan(number) ? 0 : trunc(number); } -static double dyn_bytes_index_arg(ScrDyn *const *args, size_t argc, size_t index, double fallback) { - if (index >= argc || args[index]->kind == SCR_DYN_UNDEF) return fallback; - double value; - if (!scr_dyn_number_coerce_js(args[index], &value)) return 0; - return isnan(value) ? 0 : trunc(value); -} - /* JS relative-index normalization (slice's rule). */ static size_t dyn_rel_index(double rel, size_t len) { if (rel < 0) { @@ -716,7 +709,8 @@ static bool dyn_bytes_proto_real(int m) { case DYN_M_every: case DYN_M_find: case DYN_M_findIndex: case DYN_M_reverse: case DYN_M_fill: case DYN_M_set: case DYN_M_subarray: case DYN_M_sort: case DYN_M_keys: case DYN_M_values: case DYN_M_entries: case DYN_M_reduce: case DYN_M_reduceRight: case DYN_M_copyWithin: - case DYN_M_toString: case DYN_M_toLocaleString: + case DYN_M_toString: case DYN_M_toLocaleString: case DYN_M_findLast: case DYN_M_findLastIndex: + case DYN_M_toReversed: case DYN_M_toSorted: case DYN_M_with: return true; default: return false; @@ -1660,63 +1654,9 @@ static ScrDyn *scr_dyn_invoke_impl( dyn_throw_not_fn(what); return NULL; } - size_t blen = bytes->len; - if ((mid == DYN_M_at)) { - double iD = dyn_bytes_index_arg(args, argc, 0, 0); - if (scr_exc_pending()) return NULL; - double idx = iD < 0 ? (double)blen + iD : iD; - if (idx < 0 || idx >= (double)blen) return scr_dyn_retain(scr_dyn_undefined()); - return scr_dyn_new_num(scr_bytes_get(bytes, idx)); - } - if ((mid == DYN_M_slice) || (mid == DYN_M_subarray)) { - /* subarray and Buffer.slice alias their source. TypedArray.slice - * owns an independent copy; all keep the receiver's Buffer flavor. */ - double startD = dyn_bytes_index_arg(args, argc, 0, 0); - if (scr_exc_pending()) return NULL; - double endD = dyn_bytes_index_arg(args, argc, 1, (double)blen); - if (scr_exc_pending()) return NULL; - ScrBytes *out = recv->buffer || (mid == DYN_M_subarray) - ? scr_bytes_subarray(bytes, startD, endD) : scr_bytes_slice(bytes, startD, endD); - ScrDyn *d = recv->buffer ? scr_dyn_new_buffer(out) : scr_dyn_new_bytes(out); - scr_bytes_release(out); - return d; - } - if ((mid == DYN_M_set)) { - double offset = dyn_bytes_index_arg(args, argc, 1, 0); - if (scr_exc_pending()) return NULL; - scr_bytes_set_from_dyn(bytes, argc ? args[0] : scr_dyn_undefined(), offset); - return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); - } - if ((mid == DYN_M_copyWithin)) { - double target = dyn_bytes_index_arg(args, argc, 0, 0); - if (scr_exc_pending()) return NULL; - double start = dyn_bytes_index_arg(args, argc, 1, 0); - if (scr_exc_pending()) return NULL; - double end = dyn_bytes_index_arg(args, argc, 2, (double)blen); - if (scr_exc_pending()) return NULL; - scr_bytes_release(scr_bytes_copy_within(bytes, target, start, end)); - return scr_dyn_retain(recv); - } - if ((mid == DYN_M_fill) && !recv->buffer) { - double value; - if (!scr_dyn_number_coerce_js(argc ? args[0] : scr_dyn_undefined(), &value)) return NULL; - double start = dyn_bytes_index_arg(args, argc, 1, 0); - if (scr_exc_pending()) return NULL; - double end = dyn_bytes_index_arg(args, argc, 2, (double)blen); - if (scr_exc_pending()) return NULL; - scr_bytes_release(scr_bytes_fill_elem(bytes, value, start, end)); - return scr_dyn_retain(recv); - } - if ((mid == DYN_M_join) || ((mid == DYN_M_toString) && !recv->buffer)) { - ScrStr *separator = (mid == DYN_M_join) && argc && args[0]->kind != SCR_DYN_UNDEF - ? scr_dyn_string_coerce_js(args[0]) : scr_str_new(",", 1); - if (scr_exc_pending()) { scr_str_release(separator); return NULL; } - ScrStr *joined = scr_bytes_join(bytes, separator); - scr_str_release(separator); - ScrDyn *out = scr_dyn_new_str(joined); - scr_str_release(joined); - return out; - } + bool handled; + ScrDyn *answer = scr_dyn_bytes_method(recv, method, args, argc, &handled); + if (handled) return answer; 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_json.c b/packages/runtime/src/scr_json.c index 35e1d7674..c415de1bc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4249,6 +4249,155 @@ static ScrDyn *scr_bytes_ctor_call(ScrClosure *closure, ScrDyn *const *args, siz scr_trap("scriptc: invalid typed array constructor\n"); } static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function); + +/* The element kind a typed array constructor value makes, or -1. */ +static int scr_bytes_ctor_elem(const ScrDyn *callee) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) + if (callee->kind == SCR_DYN_FUNC && scr_bytes_ctors[i] && callee->v.fn.clo == scr_bytes_ctors[i]->v.fn.clo) return (int)i; + return -1; +} + +bool scr_dyn_has_iterator_method(const ScrDyn *value) { + if (!scr_iterator_symbol || value->kind != SCR_DYN_OBJ) return false; + ScrDyn *method = scr_dyn_symbol_key_get(value, scr_iterator_symbol, true); + bool iterable = method && method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL; + scr_dyn_release(method); + return iterable; +} + +static bool scr_dyn_from_iterable(const ScrDyn *source) { + return scr_builtin_iterable(source) || scr_dyn_has_iterator_method(source); +} + +/* TypedArrayCreate(C, [n]) for TypedArray.from and TypedArray.of: a typed + * array constructor makes one directly; any other constructor is called + * and must answer a typed array of at least n elements. */ +static ScrDyn *scr_bytes_static_create(ScrDyn *constructor, size_t n) { + int elem = scr_bytes_ctor_elem(constructor); + if (elem >= 0) { + ScrBytes *out = scr_bytes_new((ScrBytesElem)elem, (double)n); + ScrDyn *result = scr_dyn_new_bytes(out); + scr_bytes_release(out); + return result; + } + if (!scr_dyn_is_callable(constructor)) { + static const char message[] = "this is not a constructor"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + ScrDyn *args = scr_dyn_new_arr(); + scr_dyn_arr_push(args, scr_dyn_new_num((double)n)); + ScrStr *what = scr_str_new("TypedArray species", 18); + ScrDyn *result = scr_dyn_construct(constructor, args, what); + scr_str_release(what); + scr_dyn_release(args); + if (!result) return NULL; + const char *problem = result->kind != SCR_DYN_BYTES || result->v.bytes->is_data_view + ? "this is not a typed array." + : result->v.bytes->len < n ? "Derived TypedArray constructor created an array which was too small" : NULL; + if (problem) { + scr_dyn_release(result); + scr_throw_error_msg(SCR_ERR_TYPE, problem, strlen(problem)); + return NULL; + } + return result; +} + +/* target[k] = value, converting with ToNumber as a typed array [[Set]] does. */ +static bool scr_bytes_static_set(ScrDyn *target, size_t k, const ScrDyn *value) { + double v; + if (!scr_dyn_number_coerce_js(value, &v)) return false; + if (k < target->v.bytes->len) scr_bytes_set(target->v.bytes, (double)k, v); + return true; +} + +/* mapfn(value, k) with thisArg, or the value itself (+1). */ +static ScrDyn *scr_bytes_static_map(ScrDyn *mapfn, ScrDyn *this_arg, ScrDyn *value, size_t k) { + if (!mapfn) return scr_dyn_retain(value); + ScrDyn *index = scr_dyn_new_num((double)k); + ScrDyn *cbargs[2] = { value, index }; + scr_dyn_this_push_dyn(this_arg); + ScrDyn *mapped = scr_dyn_call(mapfn, cbargs, 2, "mapfn"); + scr_dyn_this_pop(); + scr_dyn_release(index); + return mapped; +} + +/* TypedArray.from(source, mapfn, thisArg): an iterable source is drained + * first, then the target is created; an array-like source is read element + * by element after the target exists. */ +static ScrDyn *scr_bytes_from_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *constructor = scr_dyn_this_get(); + ScrDyn *result = NULL, *values = NULL; + ScrDyn *source = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *mapfn = argc > 1 && args[1]->kind != SCR_DYN_UNDEF ? args[1] : NULL; + ScrDyn *this_arg = argc > 2 ? args[2] : scr_dyn_undefined(); + if (scr_bytes_ctor_elem(constructor) < 0 && !scr_dyn_is_callable(constructor)) { + static const char message[] = "this is not a constructor"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + goto done; + } + if (mapfn && !scr_dyn_is_callable(mapfn)) { + static const char message[] = "TypedArray.from: the mapping function is not callable"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + goto done; + } + if (source->kind == SCR_DYN_UNDEF || source->kind == SCR_DYN_NULL) { + static const char message[] = "Cannot convert undefined or null to object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + goto done; + } + if (scr_dyn_from_iterable(source)) { + values = scr_dyn_destructure_rest(source, NULL); + if (!values) goto done; + size_t n = values->v.arr.len; + result = scr_bytes_static_create(constructor, n); + for (size_t k = 0; result && k < n; k++) { + ScrDyn *mapped = scr_bytes_static_map(mapfn, this_arg, values->v.arr.items[k], k); + bool ok = mapped && scr_bytes_static_set(result, k, mapped); + scr_dyn_release(mapped); + if (!ok) { scr_dyn_release(result); result = NULL; } + } + goto done; + } + if (scr_exc_pending()) goto done; + double n = 0; + if (source->kind == SCR_DYN_OBJ) { + ScrDyn *length = scr_dyn_obj_read(source, "length", 6); + bool ok = !scr_exc_pending() && (!length || scr_dyn_number_coerce_js(length, &n)); + scr_dyn_release(length); + if (!ok) goto done; + n = isnan(n) || n < 0 ? 0 : n > 9007199254740991.0 ? 9007199254740991.0 : trunc(n); + } + result = scr_bytes_static_create(constructor, (size_t)n); + for (size_t k = 0; result && k < (size_t)n; k++) { + char key[32]; + int size = snprintf(key, sizeof key, "%zu", k); + ScrDyn *item = scr_dyn_obj_read(source, key, (size_t)size); + ScrDyn *mapped = scr_exc_pending() ? NULL : scr_bytes_static_map(mapfn, this_arg, item ? item : scr_dyn_undefined(), k); + bool ok = mapped && scr_bytes_static_set(result, k, mapped); + scr_dyn_release(item); + scr_dyn_release(mapped); + if (!ok) { scr_dyn_release(result); result = NULL; } + } +done: + scr_dyn_release(values); + scr_dyn_release(constructor); + return result; +} + +/* TypedArray.of(...items). */ +static ScrDyn *scr_bytes_of_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *constructor = scr_dyn_this_get(); + ScrDyn *result = scr_bytes_static_create(constructor, argc); + for (size_t k = 0; result && k < argc; k++) + if (!scr_bytes_static_set(result, k, args[k])) { scr_dyn_release(result); result = NULL; } + scr_dyn_release(constructor); + return result; +} + static ScrDyn *scr_bytes_constructor_for(ScrBytesElem elem) { if (!scr_bytes_ctors[elem]) { bool initialized = false; @@ -4260,6 +4409,14 @@ static ScrDyn *scr_bytes_constructor_for(ScrBytesElem elem) { scr_dyn_obj_set(properties, "BYTES_PER_ELEMENT", 17, scr_dyn_new_num((double)scr_bytes_elem_size(elem))); ScrDynEntry *entry = &properties->v.obj.entries[properties->v.obj.len - 1]; entry->writable = entry->enumerable = entry->configurable = false; + /* from and of live on %TypedArray%, which is not modeled: each + * constructor carries them as non-enumerable properties. */ + scr_dyn_obj_set(properties, "from", 4, + scr_dyn_new_func(scr_closure_new(NULL, 0), scr_bytes_from_call, 1, "native:TypedArray", "from")); + properties->v.obj.entries[properties->v.obj.len - 1].enumerable = false; + scr_dyn_obj_set(properties, "of", 2, + scr_dyn_new_func(scr_closure_new(NULL, 0), scr_bytes_of_call, 0, "native:TypedArray", "of")); + properties->v.obj.entries[properties->v.obj.len - 1].enumerable = false; scr_dyn_release(properties); } return scr_dyn_retain(scr_bytes_ctors[elem]); @@ -4494,6 +4651,393 @@ ScrDyn *scr_bytes_construct(const ScrDyn *callee, const ScrDyn *args, const ScrS return NULL; } +static bool scr_dyn_bytes_named(const char *method, const char *name) { + return strcmp(method, name) == 0; +} + +/* An optional index argument: ToIntegerOrInfinity, or `fallback` when + * absent or undefined. */ +static double scr_dyn_bytes_index_arg(ScrDyn *const *args, size_t argc, size_t index, double fallback) { + if (index >= argc || args[index]->kind == SCR_DYN_UNDEF) return fallback; + double value; + if (!scr_dyn_number_coerce_js(args[index], &value)) return 0; + return isnan(value) ? 0 : trunc(value); +} + +/* The callable-callback gate: " is not a function". */ +static bool scr_dyn_bytes_cb_check(ScrDyn *const *args, size_t argc) { + ScrDyn *cb = argc > 0 ? args[0] : scr_dyn_undefined(); + if (scr_dyn_is_callable(cb)) return true; + bool primitive = cb->kind == SCR_DYN_NUM || cb->kind == SCR_DYN_STR || cb->kind == SCR_DYN_BOOL || + cb->kind == SCR_DYN_UNDEF || cb->kind == SCR_DYN_NULL || cb->kind == SCR_DYN_BIGINT; + ScrStr *text = primitive ? scr_dyn_string_coerce_js(cb) : scr_str_new("#", 9); + if (!text) return false; + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, text->data, text->len); + scr_jb_puts(&b, " is not a function"); + scr_str_release(text); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return false; +} + +/* ── %TypedArray%.prototype methods over a checked typed array ────────── + * One dispatch for scr_dyn_invoke's typed array receivers and for the + * method values their reads give, in this always-linked unit. + * Elements are read live at each step: a callback can write the array it + * walks. Results of map, filter and the copying methods take the + * receiver's element kind (and a Buffer's flavor). */ + +static ScrDyn *dyn_bytes_wrap(const ScrDyn *recv, ScrBytes *out) { + ScrDyn *d = recv->buffer ? scr_dyn_new_buffer(out) : scr_dyn_new_bytes(out); + scr_bytes_release(out); + return d; +} + +/* The element at k, or undefined past the view's current end. */ +static ScrDyn *dyn_bytes_item(const ScrBytes *b, size_t k) { + return k < b->len ? scr_dyn_new_num(scr_bytes_get(b, (double)k)) : scr_dyn_retain(scr_dyn_undefined()); +} + +/* callback(value, index, array) with thisArg as `this`. */ +static ScrDyn *dyn_bytes_call(ScrDyn *cb, ScrDyn *this_arg, ScrDyn *const *cbargs, size_t n) { + scr_dyn_this_push_dyn(this_arg); + ScrDyn *r = scr_dyn_call(cb, (ScrDyn **)cbargs, n, "callback"); + scr_dyn_this_pop(); + return r; +} + +/* ToIntegerOrInfinity of a present argument; `fallback` when absent. */ +static bool dyn_bytes_integer_arg(ScrDyn *const *args, size_t argc, size_t i, double fallback, double *out) { + if (i >= argc) { *out = fallback; return true; } + double v; + if (!scr_dyn_number_coerce_js(args[i], &v)) return false; + *out = isnan(v) ? 0 : trunc(v); + return true; +} + +/* SortCompare: the comparator's ToNumber answer (NaN as 0), or the + * numeric order with -0 before +0 and NaN last. */ +static bool dyn_bytes_less_eq(ScrDyn *cmp, double x, double y, bool *ok) { + if (cmp->kind != SCR_DYN_UNDEF) { + ScrDyn *a = scr_dyn_new_num(x), *b = scr_dyn_new_num(y); + ScrDyn *cbargs[2] = { a, b }; + ScrDyn *r = scr_dyn_call(cmp, cbargs, 2, "comparefn"); + scr_dyn_release(a); + scr_dyn_release(b); + double v = 0; + if (!r || !scr_dyn_number_coerce_js(r, &v)) { scr_dyn_release(r); *ok = false; return true; } + scr_dyn_release(r); + return isnan(v) || v <= 0; + } + if (isnan(x)) return isnan(y); + if (isnan(y)) return true; + if (x < y) return true; + if (x > y) return false; + return signbit(x) || !signbit(y); +} + +/* A stable merge sort of values[0..n). False with the exception pending. */ +static bool dyn_bytes_sort_values(double *values, size_t n, ScrDyn *cmp) { + if (n < 2) return true; + double *tmp = malloc(n * sizeof *tmp); + if (!tmp) scr_trap("scriptc: out of memory\n"); + bool ok = true; + for (size_t width = 1; width < n && ok; width *= 2) { + for (size_t lo = 0; lo < n && ok; lo += 2 * width) { + size_t mid = lo + width < n ? lo + width : n, hi = lo + 2 * width < n ? lo + 2 * width : n; + size_t i = lo, j = mid, k = lo; + while (i < mid && j < hi && ok) tmp[k++] = dyn_bytes_less_eq(cmp, values[i], values[j], &ok) ? values[i++] : values[j++]; + while (i < mid) tmp[k++] = values[i++]; + while (j < hi) tmp[k++] = values[j++]; + } + if (ok) memcpy(values, tmp, n * sizeof *values); + } + free(tmp); + return ok; +} + +static bool dyn_bytes_comparefn_check(ScrDyn *const *args, size_t argc) { + if (argc == 0 || args[0]->kind == SCR_DYN_UNDEF || scr_dyn_is_callable(args[0])) return true; + static const char message[] = "The comparison function must be either a function or undefined"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return false; +} + +static double *dyn_bytes_values(const ScrBytes *b, size_t n) { + double *values = malloc((n ? n : 1) * sizeof *values); + if (!values) scr_trap("scriptc: out of memory\n"); + for (size_t i = 0; i < n; i++) values[i] = scr_bytes_get(b, (double)i); + return values; +} + +ScrDyn *scr_dyn_bytes_method(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc, bool *handled) { + ScrBytes *bytes = recv->v.bytes; + size_t len = bytes->len; + *handled = true; + size_t blen = bytes->len; + if (scr_dyn_bytes_named(method, "at")) { + double iD = scr_dyn_bytes_index_arg(args, argc, 0, 0); + if (scr_exc_pending()) return NULL; + double idx = iD < 0 ? (double)blen + iD : iD; + if (idx < 0 || idx >= (double)blen) return scr_dyn_retain(scr_dyn_undefined()); + return scr_dyn_new_num(scr_bytes_get(bytes, idx)); + } + if (scr_dyn_bytes_named(method, "slice") || scr_dyn_bytes_named(method, "subarray")) { + /* subarray and Buffer.slice alias their source. TypedArray.slice + * owns an independent copy; all keep the receiver's Buffer flavor. */ + double startD = scr_dyn_bytes_index_arg(args, argc, 0, 0); + if (scr_exc_pending()) return NULL; + double endD = scr_dyn_bytes_index_arg(args, argc, 1, (double)blen); + if (scr_exc_pending()) return NULL; + ScrBytes *out = recv->buffer || scr_dyn_bytes_named(method, "subarray") + ? scr_bytes_subarray(bytes, startD, endD) : scr_bytes_slice(bytes, startD, endD); + ScrDyn *d = recv->buffer ? scr_dyn_new_buffer(out) : scr_dyn_new_bytes(out); + scr_bytes_release(out); + return d; + } + if (scr_dyn_bytes_named(method, "set")) { + double offset = scr_dyn_bytes_index_arg(args, argc, 1, 0); + if (scr_exc_pending()) return NULL; + scr_bytes_set_from_dyn(bytes, argc ? args[0] : scr_dyn_undefined(), offset); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); + } + if (scr_dyn_bytes_named(method, "copyWithin")) { + double target = scr_dyn_bytes_index_arg(args, argc, 0, 0); + if (scr_exc_pending()) return NULL; + double start = scr_dyn_bytes_index_arg(args, argc, 1, 0); + if (scr_exc_pending()) return NULL; + double end = scr_dyn_bytes_index_arg(args, argc, 2, (double)blen); + if (scr_exc_pending()) return NULL; + scr_bytes_release(scr_bytes_copy_within(bytes, target, start, end)); + return scr_dyn_retain(recv); + } + if (scr_dyn_bytes_named(method, "fill") && !recv->buffer) { + double value; + if (!scr_dyn_number_coerce_js(argc ? args[0] : scr_dyn_undefined(), &value)) return NULL; + double start = scr_dyn_bytes_index_arg(args, argc, 1, 0); + if (scr_exc_pending()) return NULL; + double end = scr_dyn_bytes_index_arg(args, argc, 2, (double)blen); + if (scr_exc_pending()) return NULL; + scr_bytes_release(scr_bytes_fill_elem(bytes, value, start, end)); + return scr_dyn_retain(recv); + } + if (scr_dyn_bytes_named(method, "join") || (scr_dyn_bytes_named(method, "toString") && !recv->buffer)) { + ScrStr *separator = scr_dyn_bytes_named(method, "join") && argc && args[0]->kind != SCR_DYN_UNDEF + ? scr_dyn_string_coerce_js(args[0]) : scr_str_new(",", 1); + if (scr_exc_pending()) { scr_str_release(separator); return NULL; } + ScrStr *joined = scr_bytes_join(bytes, separator); + scr_str_release(separator); + ScrDyn *out = scr_dyn_new_str(joined); + scr_str_release(joined); + return out; + } + if (scr_dyn_bytes_named(method, "keys") || scr_dyn_bytes_named(method, "values") || scr_dyn_bytes_named(method, "entries")) + return scr_dyn_native_handle_iterator(recv, scr_dyn_bytes_named(method, "keys") ? 1 : scr_dyn_bytes_named(method, "entries") ? 2 : 0); + if (scr_dyn_bytes_named(method, "indexOf") || scr_dyn_bytes_named(method, "lastIndexOf") || scr_dyn_bytes_named(method, "includes")) { + const bool includes = scr_dyn_bytes_named(method, "includes"), last = scr_dyn_bytes_named(method, "lastIndexOf"); + if (len == 0) return includes ? scr_dyn_new_bool(false) : scr_dyn_new_num(-1); + double n; + if (!dyn_bytes_integer_arg(args, argc, 1, last ? (double)len - 1 : 0, &n)) return NULL; + ScrDyn *needle = argc ? args[0] : scr_dyn_undefined(); + const bool number = needle->kind == SCR_DYN_NUM; + const double target = number ? needle->v.num : 0; + if (last) { + if (n == -INFINITY) return scr_dyn_new_num(-1); + double k = n >= 0 ? (n < (double)len - 1 ? n + 0.0 : (double)len - 1) : (double)len + n; + for (; number && k >= 0; k--) + if ((size_t)k < bytes->len && scr_bytes_get(bytes, k) == target) return scr_dyn_new_num(k); + return scr_dyn_new_num(-1); + } + if (n == INFINITY) return includes ? scr_dyn_new_bool(false) : scr_dyn_new_num(-1); + double k = n >= 0 ? n + 0.0 : ((double)len + n < 0 ? 0 : (double)len + n); + for (; k < (double)len; k++) { + /* An element past a shrunk view reads undefined, which includes finds. */ + if ((size_t)k >= bytes->len) { + if (includes && needle->kind == SCR_DYN_UNDEF) return scr_dyn_new_bool(true); + continue; + } + if (!number) continue; + double v = scr_bytes_get(bytes, k); + if (v == target || (includes && isnan(v) && isnan(target))) + return includes ? scr_dyn_new_bool(true) : scr_dyn_new_num(k); + } + return includes ? scr_dyn_new_bool(false) : scr_dyn_new_num(-1); + } + if (scr_dyn_bytes_named(method, "forEach") || scr_dyn_bytes_named(method, "map") || scr_dyn_bytes_named(method, "filter") || scr_dyn_bytes_named(method, "some") || + scr_dyn_bytes_named(method, "every") || scr_dyn_bytes_named(method, "find") || scr_dyn_bytes_named(method, "findIndex") || + scr_dyn_bytes_named(method, "findLast") || scr_dyn_bytes_named(method, "findLastIndex")) { + if (!scr_dyn_bytes_cb_check(args, argc)) return NULL; + ScrDyn *cb = args[0], *this_arg = argc > 1 ? args[1] : scr_dyn_undefined(); + const bool backward = scr_dyn_bytes_named(method, "findLast") || scr_dyn_bytes_named(method, "findLastIndex"); + ScrBytes *mapped = scr_dyn_bytes_named(method, "map") ? scr_bytes_new(bytes->elem, (double)len) : NULL; + double *kept = scr_dyn_bytes_named(method, "filter") ? malloc((len ? len : 1) * sizeof *kept) : NULL; + size_t nkept = 0; + for (size_t step = 0; step < len; step++) { + size_t k = backward ? len - 1 - step : step; + ScrDyn *item = dyn_bytes_item(bytes, k), *index = scr_dyn_new_num((double)k); + ScrDyn *cbargs[3] = { item, index, recv }; + ScrDyn *r = dyn_bytes_call(cb, this_arg, cbargs, 3); + scr_dyn_release(index); + if (!r) { + scr_dyn_release(item); + if (mapped) scr_bytes_release(mapped); + free(kept); + return NULL; + } + if (mapped) { + double v; + bool converted = scr_dyn_number_coerce_js(r, &v); + scr_dyn_release(r); + scr_dyn_release(item); + if (!converted) { scr_bytes_release(mapped); return NULL; } + if (k < mapped->len) scr_bytes_set(mapped, (double)k, v); + continue; + } + bool truthy = scr_dyn_truthy(r); + scr_dyn_release(r); + if (kept) { + if (truthy) kept[nkept++] = item->kind == SCR_DYN_NUM ? item->v.num : NAN; + } else if (truthy && (scr_dyn_bytes_named(method, "find") || scr_dyn_bytes_named(method, "findLast"))) { + return item; + } else if (truthy && (scr_dyn_bytes_named(method, "findIndex") || scr_dyn_bytes_named(method, "findLastIndex"))) { + scr_dyn_release(item); + return scr_dyn_new_num((double)k); + } else if (truthy && scr_dyn_bytes_named(method, "some")) { + scr_dyn_release(item); + return scr_dyn_new_bool(true); + } else if (!truthy && scr_dyn_bytes_named(method, "every")) { + scr_dyn_release(item); + return scr_dyn_new_bool(false); + } + scr_dyn_release(item); + } + if (mapped) return dyn_bytes_wrap(recv, mapped); + if (kept) { + ScrBytes *out = scr_bytes_new(bytes->elem, (double)nkept); + for (size_t i = 0; i < nkept; i++) scr_bytes_set(out, (double)i, kept[i]); + free(kept); + return dyn_bytes_wrap(recv, out); + } + if (scr_dyn_bytes_named(method, "some")) return scr_dyn_new_bool(false); + if (scr_dyn_bytes_named(method, "every")) return scr_dyn_new_bool(true); + if (scr_dyn_bytes_named(method, "findIndex") || scr_dyn_bytes_named(method, "findLastIndex")) return scr_dyn_new_num(-1); + return scr_dyn_retain(scr_dyn_undefined()); /* forEach, find, findLast */ + } + if (scr_dyn_bytes_named(method, "reduce") || scr_dyn_bytes_named(method, "reduceRight")) { + if (!scr_dyn_bytes_cb_check(args, argc)) return NULL; + const bool backward = scr_dyn_bytes_named(method, "reduceRight"); + size_t step = 0; + ScrDyn *acc; + if (argc > 1) acc = scr_dyn_retain(args[1]); + else if (len == 0) { + static const char message[] = "Reduce of empty array with no initial value"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } else acc = dyn_bytes_item(bytes, backward ? len - 1 : 0), step = 1; + for (; step < len; step++) { + size_t k = backward ? len - 1 - step : step; + ScrDyn *item = dyn_bytes_item(bytes, k), *index = scr_dyn_new_num((double)k); + ScrDyn *cbargs[4] = { acc, item, index, recv }; + ScrDyn *next = scr_dyn_call(args[0], cbargs, 4, "callback"); + scr_dyn_release(index); + scr_dyn_release(item); + scr_dyn_release(acc); + if (!next) return NULL; + acc = next; + } + return acc; + } + if (scr_dyn_bytes_named(method, "reverse")) { + for (size_t lo = 0, hi = len; lo + 1 < hi; lo++, hi--) { + double a = scr_bytes_get(bytes, (double)lo), b = scr_bytes_get(bytes, (double)(hi - 1)); + scr_bytes_set(bytes, (double)lo, b); + scr_bytes_set(bytes, (double)(hi - 1), a); + } + return scr_dyn_retain(recv); + } + if (scr_dyn_bytes_named(method, "sort") || scr_dyn_bytes_named(method, "toSorted")) { + if (!dyn_bytes_comparefn_check(args, argc)) return NULL; + double *values = dyn_bytes_values(bytes, len); + if (!dyn_bytes_sort_values(values, len, argc ? args[0] : scr_dyn_undefined())) { free(values); return NULL; } + ScrBytes *target = scr_dyn_bytes_named(method, "sort") ? bytes : scr_bytes_new(bytes->elem, (double)len); + for (size_t i = 0; i < len && i < target->len; i++) scr_bytes_set(target, (double)i, values[i]); + free(values); + return scr_dyn_bytes_named(method, "sort") ? scr_dyn_retain(recv) : dyn_bytes_wrap(recv, target); + } + if (scr_dyn_bytes_named(method, "toReversed")) { + ScrBytes *out = scr_bytes_new(bytes->elem, (double)len); + for (size_t i = 0; i < len; i++) scr_bytes_set(out, (double)i, scr_bytes_get(bytes, (double)(len - 1 - i))); + return dyn_bytes_wrap(recv, out); + } + if (scr_dyn_bytes_named(method, "with")) { + double rel, value; + if (!dyn_bytes_integer_arg(args, argc, 0, 0, &rel)) return NULL; + double actual = rel >= 0 ? rel : (double)len + rel; + if (!scr_dyn_number_coerce_js(argc > 1 ? args[1] : scr_dyn_undefined(), &value)) return NULL; + if (!(actual >= 0 && actual < (double)bytes->len)) { + static const char message[] = "Invalid typed array index"; + scr_throw_error_msg(SCR_ERR_RANGE, message, sizeof message - 1); + return NULL; + } + ScrBytes *out = scr_bytes_new(bytes->elem, (double)len); + for (size_t i = 0; i < len; i++) scr_bytes_set(out, (double)i, scr_bytes_get(bytes, (double)i)); + scr_bytes_set(out, actual, value); + return dyn_bytes_wrap(recv, out); + } + *handled = false; + return NULL; +} + +/* A %TypedArray%.prototype method read as a value: it calls the method on + * its `this`, which must be a typed array. */ +static ScrDyn *dyn_bytes_method_value_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrStr *method = scr_box_get_ref(closure->caps[0]); + ScrDyn *receiver = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (receiver->kind == SCR_DYN_BYTES && !receiver->v.bytes->is_data_view) { + bool handled; + result = scr_dyn_bytes_method(receiver, method->data, args, argc, &handled); + if (!handled) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "'TypedArray.prototype."); + scr_jb_puts(&b, method->data); + scr_jb_puts(&b, "' through a method value is not supported yet"); + scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); + } + } else { + static const char message[] = "this is not a typed array."; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(receiver); + scr_str_release(method); + return result; +} + +static SCR_TL ScrDyn *dyn_bytes_method_values; + +static void dyn_bytes_method_values_cleanup(void) { + scr_dyn_release(dyn_bytes_method_values); + dyn_bytes_method_values = NULL; +} + +static ScrDyn *scr_dyn_bytes_method_value(const ScrStr *name, size_t arity) { + if (!dyn_bytes_method_values) { + dyn_bytes_method_values = scr_dyn_new_obj_null_proto(); + scr_atexit(dyn_bytes_method_values_cleanup); + } + ScrDyn *value = scr_dyn_obj_get(dyn_bytes_method_values, name->data, name->len); + if (!value) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_STR); + scr_box_set_ref(closure->caps[0], scr_str_retain((ScrStr *)name)); + value = scr_dyn_new_func(closure, dyn_bytes_method_value_call, arity, "native:TypedArray.prototype", name->data); + scr_dyn_obj_set(dyn_bytes_method_values, name->data, name->len, value); /* the table owns it */ + } + return scr_dyn_retain(value); +} + /* Shared view metadata and indexed reads for both compiler backends. */ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { ScrBytes *bytes = value->v.bytes; @@ -4532,6 +5076,18 @@ ScrDyn *scr_dyn_bytes_key_get(const ScrDyn *value, const ScrStr *key) { } if (digits && index < bytes->len) return scr_dyn_new_num(scr_bytes_get(bytes, (double)index)); } + /* %TypedArray%.prototype methods, with their lengths. */ + static const struct { const char *name; size_t arity; } methods[] = { + {"at", 1}, {"copyWithin", 2}, {"entries", 0}, {"every", 1}, {"fill", 1}, {"filter", 1}, + {"find", 1}, {"findIndex", 1}, {"findLast", 1}, {"findLastIndex", 1}, {"forEach", 1}, + {"includes", 1}, {"indexOf", 1}, {"join", 1}, {"keys", 0}, {"lastIndexOf", 1}, {"map", 1}, + {"reduce", 1}, {"reduceRight", 1}, {"reverse", 0}, {"set", 1}, {"slice", 2}, {"some", 1}, + {"sort", 1}, {"subarray", 2}, {"toLocaleString", 0}, {"toReversed", 0}, {"toSorted", 1}, + {"toString", 0}, {"values", 0}, {"with", 2}, + }; + for (size_t i = 0; i < sizeof methods / sizeof methods[0]; i++) + if (key->len == strlen(methods[i].name) && memcmp(key->data, methods[i].name, key->len) == 0) + return scr_dyn_bytes_method_value(key, methods[i].arity); return scr_dyn_retain(scr_dyn_undefined()); } @@ -5946,7 +6502,18 @@ static ScrDyn *scr_native_iterator_value(ScrNativeIterator *iterator, bool *done } } } else if (items->kind == SCR_DYN_BYTES) { - value = scr_dyn_new_num(scr_bytes_get(items->v.bytes, iterator->index++)); + /* keys(), values() and entries() of a typed array. */ + double index = iterator->index++; + if (iterator->selection == 1) value = scr_dyn_new_num(index); + else { + value = scr_dyn_new_num(scr_bytes_get(items->v.bytes, index)); + if (iterator->selection == 2) { + ScrDyn *entry = scr_dyn_new_arr(); + scr_dyn_arr_push(entry, scr_dyn_new_num(index)); + scr_dyn_arr_push(entry, value); + value = entry; + } + } } else { ScrStr *point = scr_str_cp_at(items->v.str, iterator->index); iterator->index += scr_str_utf16_len(point); diff --git a/tests/corpus/7752-typed-array-dynamic-methods.js b/tests/corpus/7752-typed-array-dynamic-methods.js new file mode 100644 index 000000000..5482beb5b --- /dev/null +++ b/tests/corpus/7752-typed-array-dynamic-methods.js @@ -0,0 +1,90 @@ +// %TypedArray%.prototype methods called on typed arrays held as untyped +// values, as JavaScript sources and Test262's testTypedArray.js use them. +const kinds = [Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + +function show(label, value) { + console.log(label, value); +} + +// TA arrives untyped, as Test262's testWithTypedArrayConstructors passes it. +function check(TA) { + const a = new TA([3, 1, 2, 1, 0]); + show(TA.name + " search", [a.indexOf(1), a.lastIndexOf(1), a.includes(2), a.indexOf(1, 2), a.lastIndexOf(1, -3), a.includes(3, 1), a.indexOf(9), a.indexOf("1")]); + show(" map", a.map((v, i) => v * 2 + i)); + show(" filter", a.filter((v) => v > 0)); + show(" some/every", [a.some((v) => v > 2), a.every((v) => v >= 0), a.every((v) => v > 0)]); + show(" find", [a.find((v) => v < 2), a.findIndex((v) => v < 2), a.findLast((v) => v < 2), a.findLastIndex((v) => v < 2), a.find((v) => v > 5), a.findLastIndex((v) => v > 5)]); + show(" reduce", [a.reduce((s, v) => s + v), a.reduceRight((s, v, i) => s + "" + v + i, ""), a.reduce((s, v) => s * 10 + v, 0)]); + const seen = []; + a.forEach(function (v, i, arr) { seen.push([v, i, arr === a, this.tag]); }, { tag: "t" }); + show(" forEach", seen.map((x) => x.join(":")).join(" ")); + show(" sorted", [a.toSorted().join(), a.toSorted((x, y) => y - x).join(), a.toReversed().join(), a.with(1, 7).join(), a.with(-1, 9).join(), a.join()]); + show(" iterators", [[...a.keys()].join(), [...a.values()].join(), [...a.entries()].map((e) => e.join("=")).join(" ")]); + const b = a.slice(); + b.sort(); + show(" sort", [b.join(), b.reverse().join(), b.sort((x, y) => x - y) === b]); +} +kinds.forEach(check); + +// Edge cases over untyped constructors. +function edges(F, C, I, U, S) { + const f = new F([NaN, -0, 0, 1, -Infinity, NaN]); + show("float search", [f.indexOf(NaN), f.includes(NaN), f.indexOf(-0), f.lastIndexOf(0), f.includes(-0)]); + show("float sort", Array.from(f.toSorted(), (v) => Object.is(v, -0) ? "-0" : String(v)).join()); + show("clamped map", new C([1, 2]).map((v) => v * 200)); + show("wrapping map", new I([1, 2]).map((v) => v * 100)); + const live = new U([1, 2, 3]); + show("live", live.map((v, i, arr) => { if (i === 0) arr[2] = 9; return v; })); + for (const bad of [() => new I(2).forEach(1), () => new I(0).reduce((a, b) => a), () => new I(2).with(5, 1), () => new I(2).sort("x")]) { + try { + bad(); + show("no throw", ""); + } catch (e) { + show(e.constructor.name, e.message.length > 0); + } + } + let order = []; + new S([5, 1, 4]).sort((x, y) => { order.push(x + "" + y); return x - y; }); + show("comparator calls", order.length > 0); +} +edges(Float64Array, Uint8ClampedArray, Int8Array, Uint8Array, Int16Array); + +// Method values, and the constructors' from and of. +function run(TA) { + const s = new TA([1, 2, 3]); + console.log(typeof s.findLast, typeof s.with, typeof s.toSorted, typeof s.map, typeof s.at); + const m = s.findLast; + console.log(m.call(s, (v) => v < 3)); + console.log(typeof TA.from, typeof TA.of); +} +run(Int16Array); +function statics(TA) { + console.log(TA.from([1, 2, 300]), TA.from({ length: 2, 0: "7", 1: 8 }), TA.from(new Set([4, 5]), (v, k) => v * 10 + k)); + console.log(TA.of(1, 2, 3), TA.of(), TA.from({ [Symbol.iterator]: function* () { yield 9; yield 8; } })); + try { TA.from([1], 5); } catch (e) { console.log(e.constructor.name); } + try { TA.from(null); } catch (e) { console.log(e.constructor.name); } +} +statics(Uint8Array); +statics(Float32Array); + +// TypedArray.from and TypedArray.of with other constructors as this. +function customs(TA) { + let calls = []; + function Big(n) { calls.push(n); return new TA(n + 2); } + const fromBig = TA.from.call(Big, [1, 2, 3], (v) => v * 2); + console.log(fromBig, calls); + console.log(TA.of.call(Big, 7, 8)); + function attempt(label, C) { + try { TA.from.call(C, [1, 2]); console.log(label, "no throw"); } catch (e) { console.log(label, e.constructor.name); } + try { TA.of.call(C, 1, 2); console.log(label, "no throw"); } catch (e) { console.log(label, e.constructor.name); } + } + attempt("small", function () { return new TA(1); }); + attempt("plain", function () { return {}; }); + attempt("arrow", () => new TA(5)); + attempt("number", 5); + const order = []; + const arrayLike = { length: 2, get 0() { order.push("get0"); return 1; }, get 1() { order.push("get1"); return 2; } }; + TA.from.call(function (n) { order.push("create" + n); return new TA(n); }, arrayLike, (v) => { order.push("map" + v); return v; }); + console.log(order.join(" ")); +} +customs(Int32Array); diff --git a/tests/harness/corpus-costs/7752-typed-array-dynamic-methods.js.json b/tests/harness/corpus-costs/7752-typed-array-dynamic-methods.js.json new file mode 100644 index 000000000..a3c2e4739 --- /dev/null +++ b/tests/harness/corpus-costs/7752-typed-array-dynamic-methods.js.json @@ -0,0 +1 @@ +{ "plain": 4800, "san": 5400 } From 4b6a6e6bc484f0cad83712064dd552a426f178a5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:34:59 -0500 Subject: [PATCH 13/20] 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 b26a3dd9f..00a23caf5 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 c1c5ef8c8..d62d2cfc3 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 000000000..be7ffaa53 --- /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 000000000..40e3367af --- /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 ccf62f40e..ddacb8015 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 bba468c40..8e801b0fe 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 000000000..a591cc420 --- /dev/null +++ b/tests/harness/corpus-costs/7392-non-null-operands.ts.json @@ -0,0 +1 @@ +{ "plain": 4200, "san": 4800 } From 47d573d216ca385ccf42b08365191e6e985f5f67 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:41:37 -0500 Subject: [PATCH 14/20] Pass undefined as this to strict callbacks and read Symbol.iterator once --- packages/runtime/src/scr_bytes.c | 6 +- packages/runtime/src/scr_dyn.h | 9 ++- packages/runtime/src/scr_json.c | 81 ++++++++++++++++--- .../7752-typed-array-dynamic-methods.js | 35 ++++++++ 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index dc7c77382..f2d95c905 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -875,8 +875,10 @@ ScrBytes *scr_bytes_from_dyn(ScrBytesElem elem, const ScrDyn *value, bool from) // fast path reads ordinary array elements during conversion instead. Strings // supplied to .from iterate by code point (set reads UTF-16 code units). // An object with a Symbol.iterator method iterates; others are array-like. - if (scr_dyn_has_iterator_method(value)) { - ScrDyn *items = scr_dyn_destructure_rest(value, NULL); + ScrDyn *method = scr_dyn_iterator_method(value); + if (method) { + ScrDyn *items = scr_dyn_iterate_with(value, method); + scr_dyn_release(method); if (!items) return NULL; ScrBytes *out = scr_bytes_new(elem, (double)items->v.arr.len); if (out && !scr_bytes_copy_array_like(out, items, 0, out->len)) { diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 49fb60978..97fd82244 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1386,9 +1386,12 @@ ScrDyn *scr_dyn_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, si * DataView): its answer (+1, or NULL with the exception pending), with * *handled false for a name it does not implement. */ ScrDyn *scr_dyn_bytes_method(ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc, bool *handled); -/* Whether a plain object has a Symbol.iterator method (its getter may - * throw: check for a pending exception). */ -bool scr_dyn_has_iterator_method(const ScrDyn *value); +/* A plain object's Symbol.iterator method (+1), read once; NULL when it + * has none, or with the exception pending from its getter. */ +ScrDyn *scr_dyn_iterator_method(const ScrDyn *value); +/* The values the iterator `method` gives for `source` (+1), or NULL with + * the exception pending. */ +ScrDyn *scr_dyn_iterate_with(const ScrDyn *source, ScrDyn *method); /* Keyed read on a FUNC node: the own-property table first (defineProperties * writes land there), then "name" (the box's best-effort static name; "" * for anonymous) and "length" (the boxed arity). Returns +1, or NULL when diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index c415de1bc..30955583c 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4257,16 +4257,72 @@ static int scr_bytes_ctor_elem(const ScrDyn *callee) { return -1; } -bool scr_dyn_has_iterator_method(const ScrDyn *value) { - if (!scr_iterator_symbol || value->kind != SCR_DYN_OBJ) return false; +ScrDyn *scr_dyn_iterator_method(const ScrDyn *value) { + if (!scr_iterator_symbol || value->kind != SCR_DYN_OBJ) return NULL; ScrDyn *method = scr_dyn_symbol_key_get(value, scr_iterator_symbol, true); - bool iterable = method && method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL; - scr_dyn_release(method); - return iterable; + if (method && (method->kind == SCR_DYN_UNDEF || method->kind == SCR_DYN_NULL)) { + scr_dyn_release(method); + return NULL; + } + return method; } -static bool scr_dyn_from_iterable(const ScrDyn *source) { - return scr_builtin_iterable(source) || scr_dyn_has_iterator_method(source); +ScrDyn *scr_dyn_iterate_with(const ScrDyn *source, ScrDyn *method) { + if (!scr_dyn_is_callable(method)) { + static const char message[] = "Symbol.iterator is not a function"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + scr_dyn_this_push_dyn(source); + ScrDyn *iterator = scr_dyn_call(method, NULL, 0, "[Symbol.iterator]"); + scr_dyn_this_pop(); + if (!iterator) return NULL; + if (!scr_iterator_object(iterator)) { + scr_dyn_release(iterator); + static const char message[] = "Result of the Symbol.iterator method is not an object"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return NULL; + } + /* A generator or native iterator drains through its own iteration. */ + if (scr_builtin_iterable(iterator)) { + ScrDyn *drained = scr_dyn_destructure_rest(iterator, NULL); + scr_dyn_release(iterator); + return drained; + } + ScrDyn *next = scr_dyn_iterator_result_get(iterator, "next", 4); + ScrDyn *values = next ? scr_dyn_new_arr() : NULL; + while (values) { + scr_dyn_this_push_dyn(iterator); + ScrDyn *result = scr_dyn_call(next, NULL, 0, "next"); + scr_dyn_this_pop(); + ScrDyn *checked = result ? scr_dyn_iterator_result(result) : NULL; + scr_dyn_release(result); + if (!checked) { scr_dyn_release(values); values = NULL; break; } + ScrDyn *done = scr_dyn_iterator_result_get(checked, "done", 4); + bool finished = done && scr_dyn_truthy(done); + scr_dyn_release(done); + ScrDyn *value = !scr_exc_pending() && !finished ? scr_dyn_iterator_result_get(checked, "value", 5) : NULL; + scr_dyn_release(checked); + if (scr_exc_pending()) { scr_dyn_release(value); scr_dyn_release(values); values = NULL; break; } + if (finished) break; + scr_dyn_arr_push(values, value); + } + scr_dyn_release(next); + scr_dyn_release(iterator); + return values; +} + +/* The values of `source` when it is iterable (+1): a builtin iterable's + * own iteration, or an object's Symbol.iterator method, read once. NULL + * for an array-like source, or with the exception pending. */ +static ScrDyn *scr_dyn_from_iterated(const ScrDyn *source, bool *iterable) { + *iterable = true; + if (scr_builtin_iterable(source)) return scr_dyn_destructure_rest(source, NULL); + ScrDyn *method = scr_dyn_iterator_method(source); + if (!method) { *iterable = false; return NULL; } + ScrDyn *values = scr_dyn_iterate_with(source, method); + scr_dyn_release(method); + return values; } /* TypedArrayCreate(C, [n]) for TypedArray.from and TypedArray.of: a typed @@ -4348,8 +4404,9 @@ static ScrDyn *scr_bytes_from_call(ScrClosure *closure, ScrDyn *const *args, siz scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); goto done; } - if (scr_dyn_from_iterable(source)) { - values = scr_dyn_destructure_rest(source, NULL); + bool iterable; + values = scr_dyn_from_iterated(source, &iterable); + if (iterable) { if (!values) goto done; size_t n = values->v.arr.len; result = scr_bytes_static_create(constructor, n); @@ -4722,7 +4779,10 @@ static bool dyn_bytes_less_eq(ScrDyn *cmp, double x, double y, bool *ok) { if (cmp->kind != SCR_DYN_UNDEF) { ScrDyn *a = scr_dyn_new_num(x), *b = scr_dyn_new_num(y); ScrDyn *cbargs[2] = { a, b }; + /* The comparator is called with an undefined `this`. */ + scr_dyn_this_push_dyn(scr_dyn_undefined()); ScrDyn *r = scr_dyn_call(cmp, cbargs, 2, "comparefn"); + scr_dyn_this_pop(); scr_dyn_release(a); scr_dyn_release(b); double v = 0; @@ -4939,7 +4999,10 @@ ScrDyn *scr_dyn_bytes_method(ScrDyn *recv, const char *method, ScrDyn *const *ar size_t k = backward ? len - 1 - step : step; ScrDyn *item = dyn_bytes_item(bytes, k), *index = scr_dyn_new_num((double)k); ScrDyn *cbargs[4] = { acc, item, index, recv }; + /* The reducer is called with an undefined `this`. */ + scr_dyn_this_push_dyn(scr_dyn_undefined()); ScrDyn *next = scr_dyn_call(args[0], cbargs, 4, "callback"); + scr_dyn_this_pop(); scr_dyn_release(index); scr_dyn_release(item); scr_dyn_release(acc); diff --git a/tests/corpus/7752-typed-array-dynamic-methods.js b/tests/corpus/7752-typed-array-dynamic-methods.js index 5482beb5b..a728e3ce9 100644 --- a/tests/corpus/7752-typed-array-dynamic-methods.js +++ b/tests/corpus/7752-typed-array-dynamic-methods.js @@ -88,3 +88,38 @@ function customs(TA) { console.log(order.join(" ")); } customs(Int32Array); + +// Reducers and comparators run with an undefined this, also through method +// values; a Symbol.iterator getter is read once. +function review(TA) { + "use strict"; + const ta = new TA([3, 1, 2]); + const seen = []; + const reduce = ta.reduce, reduceRight = ta.reduceRight, sort = ta.sort, toSorted = ta.toSorted; + reduce.call(ta, function (a, b) { seen.push(this === undefined); return a + b; }); + reduceRight.call(ta, function (a, b) { seen.push(this === undefined); return a + b; }, 0); + sort.call(ta.slice(), function (x, y) { seen.push(this === undefined); return x - y; }); + toSorted.call(ta, function (x, y) { seen.push(this === undefined); return y - x; }); + console.log(seen.every((v) => v), seen.length > 0); + let gets = 0; + const source = {}; + Object.defineProperty(source, Symbol.iterator, { get() { gets++; return function* () { yield 4; yield 5; }; } }); + console.log(new TA(source), gets); + console.log(TA.from(source), gets); +} +review(Uint8Array); +function plainIterator(TA) { + let gets = 0, nexts = 0; + const source = {}; + Object.defineProperty(source, Symbol.iterator, { + get() { + gets++; + return function () { + let i = 0; + return { next() { nexts++; return i < 2 ? { value: 10 + i++, done: false } : { value: undefined, done: true }; } }; + }; + }, + }); + console.log(new TA(source), TA.from(source, (v) => v * 2), gets, nexts); +} +plainIterator(Int16Array); From 398930e7de45a94bb1fab957404e1053312da825 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:48:22 -0500 Subject: [PATCH 15/20] Store the frontend cache's LLVM placeholder as an empty array A native frontend cache entry stores the input's LLVM text after its metadata, with an empty placeholder in the input itself. The placeholder was an empty string where NativeExecutableInput declares an array of parts. Under Node the reader's cast of the parsed entry is not checked, but the native compiler checks it, so every read threw "expected array at $.input.llvm, got string". The reader treated that as a miss: no native build ever reused its frontend output, and an unchanged rebuild ran the frontend, lowering and LLVM emission again. The placeholder is now an empty array, and the cache test checks the stored member types. --- packages/compiler/src/native/prepare.test.ts | 4 +++- packages/compiler/src/native/prepare.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 112484f4a..c03bfdd26 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -176,7 +176,9 @@ test("serialized IR travels as bounded parts through the frontend cache", () => const key = readdirSync(family).find((name) => /^[0-9a-f]{64}$/.test(name))!; const payload = JSON.parse(readFileSync(join(family, key), "utf8")); expect(payload.schema).toBe("scriptc.native-frontend.v3"); - expect(payload.input.llvm).toBe(""); + // The placeholders keep NativeExecutableInput's member types: the native + // compiler checks the parsed entry against them when it reads it back. + expect(payload.input.llvm).toEqual([]); expect(payload.input.ir).toBeNull(); expect(payload.llvm).toBe(first.llvm.join("")); expect(payload.ir).toEqual(first.ir); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index a238029dc..ffd5bfd28 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -59,7 +59,7 @@ export interface NativeExecutableInput { /** The two large artifacts follow the input metadata as separate members so * an entry can be written as parts instead of one JSON string; the stored - * input carries empty placeholders (`""`, `null`) for them. */ + * input carries empty placeholders (`[]`, `null`) for them. */ interface FrontendCacheEntry { schema: "scriptc.native-frontend.v3"; probes: FrontendInputSnapshot; @@ -78,7 +78,7 @@ function* frontendCacheParts( const metadata = JSON.stringify({ schema: "scriptc.native-frontend.v3", probes, - input: { ...input, llvm: "", ir: null }, + input: { ...input, llvm: [], ir: null }, }); yield metadata.slice(0, metadata.length - 1) + ',"llvm":"'; for (const part of joinedParts(input.llvm)) From 39bb8a5b7e3afdda5d30dca8c449ce2991d85b34 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:05:10 -0500 Subject: [PATCH 16/20] Require integer views to stay below 2^53 exactly, and fix corpus 1420 --- .../src/backend/llvm/integer-values.ts | 22 ++++++++++++++++--- tests/corpus/7982-integer-view-compares.ts | 16 ++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 7d5e241dc..90954f4c6 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -65,6 +65,22 @@ function magnitude(range: IntegerRange): number { return Math.max(Math.abs(range.min), Math.abs(range.max)); } +/** The bounds of a sum and a product of two magnitude bounds, as doubles. + * Below 2^53 the double result is the integer; above it the result is past + * every exactness limit either way, and rounding moves it far less than the + * margin from WIDE_BOUND to the i64 range. A result of exactly 2^53 may + * stand for 2^53 + 1 (2^53 - 1 + 2 rounds to 2^53), so it moves to the next + * double up unless the sum is exactly 2^53. */ +function sumBound(a: number, b: number): number { + const sum = a + b; + return sum === EXACT_DOUBLES && EXACT_DOUBLES - a !== b ? sum + 2 : sum; +} + +function productBound(a: number, b: number): number { + const product = a * b; + return product === EXACT_DOUBLES ? product + 2 : product; +} + /** The wide view of `l op r` for `+`, `-` and `*` when the exact operands * have no proven result range (their product or sum may pass 2^53). * @@ -98,14 +114,14 @@ export function wideArithmetic( if (op === "*") { if (lv.below !== Infinity || rv.below !== Infinity) return null; below = EXACT_DOUBLES; - bound = lv.bound * rv.bound; + bound = productBound(lv.bound, rv.bound); } else if (lv.below === Infinity && rv.below === Infinity) { below = EXACT_DOUBLES; - bound = lv.bound + rv.bound; + bound = sumBound(lv.bound, rv.bound); } else if (lv.below === Infinity || rv.below === Infinity) { const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv]; below = Math.min(wide.below - exact.bound, EXACT_DOUBLES); - bound = exact.bound + wide.bound; + bound = sumBound(exact.bound, wide.bound); } else return null; if (!(bound <= WIDE_BOUND) || below < INDEX_LIMIT) return null; const operand = (value: LlValue, integer: LlInteger | null): string => diff --git a/tests/corpus/7982-integer-view-compares.ts b/tests/corpus/7982-integer-view-compares.ts index db224aff1..5978eaf86 100644 --- a/tests/corpus/7982-integer-view-compares.ts +++ b/tests/corpus/7982-integer-view-compares.ts @@ -60,6 +60,20 @@ function nearLimit(start: number): string { return seen.join(" "); } +function atLimit(start: number, end: number): string { + // A counter of integer locals only, up to 2^53 - 1: `i + 2` may be + // 2^53 + 1, whose double is 2^53, so `i + 1 === i + 2` holds at the end. + let same = 0; + let above = 0; + let mixed = 0; + for (let i = start; i <= end; i++) { + if (i + 1 === i + 2) same++; + if (i + 2 > 9007199254740992) above++; + mixed += (i * 2) % 3; + } + return `${same} ${above} ${mixed}`; +} + console.log(heapSort(new Int32Array([5, -3, 9, 0, 9, 2147483647, -2147483648, 7, 1]))); console.log(heapSort(new Int32Array([])), heapSort(new Int32Array([4]))); console.log(neighbors(new Float64Array([1, 2, 3, 4]))); @@ -67,3 +81,5 @@ const sorted = new Int32Array([-5, -1, 0, 0, 3, 8, 8, 13]); console.log([-6, -1, 0, 1, 8, 13, 14].map((v) => lowerBound(sorted, v)).join()); console.log(nearLimit(9007199254740988)); console.log(nearLimit(-9007199254740991)); +console.log(atLimit(9007199254740988, 9007199254740991)); +console.log(atLimit(-9007199254740991, -9007199254740988)); From 3e73eee0c4ab46d09ab8b9411d788a298b3f3fbc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:12:45 -0500 Subject: [PATCH 17/20] Evaluate fs.promises URL arguments once and fence the new fs entries --- .../src/frontend/lowering/builtins/fs-paths.ts | 3 +++ .../compiler/src/frontend/lowering/surfaces.ts | 9 +++++++++ tests/corpus/6935-fs-url-paths-ordered.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts index 5481f93ba..46a575388 100644 --- a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts +++ b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts @@ -288,6 +288,9 @@ export function lowerFallibleFsUrlCall( values.set(first, save(lowerer.lowerExprExpecting(args[first]!, URL_T), "%fsUrl", args[first]!)); const staged = stagedAfter(lowerer, args, first, name, (_e, i) => urls.includes(i)); for (const e of staged) { + // A call lowered again (an array-literal element) evaluates the + // argument anew; the slot an earlier lowering saved it in is gone. + lowerer.stagedArguments.delete(e); const value = lowerer.lowerExpr(e); const slot = save(value, "%fsArg", e); lowerer.stagedArguments.set(e, { localId: slot, type: value.type }); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 44564f2f1..580b118fc 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2070,7 +2070,16 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< "fs.readFdEncoded", "fs.readFdSync", "fs.readFdSyncBytes", + // The runtime encoding's check before a URL path converts + // (fs-paths.ts). + "fs.checkReadEncoding", ], + // The string source's check before a URL destination converts + // (fs-paths.ts). + copyFileSync: ["fs.checkPath"], + renameSync: ["fs.checkPath"], + copyFile: ["fs.checkPath"], + rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"], diff --git a/tests/corpus/6935-fs-url-paths-ordered.ts b/tests/corpus/6935-fs-url-paths-ordered.ts index 4e9ddb53c..8d39c40ff 100644 --- a/tests/corpus/6935-fs-url-paths-ordered.ts +++ b/tests/corpus/6935-fs-url-paths-ordered.ts @@ -63,6 +63,22 @@ async function main(): Promise { console.log((await fsp.realpath(at("sub/../sub"))).split(dir).join("")); // An array literal lowers its elements more than once. console.log([await fsp.realpath(at("sub"))].length, [fs.existsSync(scheme)][0]); + // A later argument with effects runs once per call there, and the call + // receives its value. + let dataCalls = 0; + const data = (): string => { + dataCalls++; + return `data ${dataCalls}`; + }; + console.log([await fsp.writeFile(at("w.txt"), data())].length, dataCalls); + console.log(await fsp.readFile(at("w.txt"), "utf8")); + const code = (error: unknown): string => String((error as { code?: string }).code); + const outcomes = [ + await fsp.writeFile(scheme, data()).then(() => "fulfilled", code), + await fsp.appendFile(at("w.txt"), data()).then(() => "fulfilled", code), + ]; + console.log(outcomes, dataCalls); + console.log(await fsp.readFile(at("w.txt"), "utf8")); await fsp.rmdir(at("sub/deep")); await fsp.unlink(at("moved.txt")); await fsp.rm(at("w.txt")); From 6b1a56a49ae607a87f3111bd3a1e327924459d33 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:26:12 -0500 Subject: [PATCH 18/20] 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 c5852fb40..ca5243564 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 228652ab3..51db87bbb 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 2aa173b2c..5024efbec 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 112484f4a..c5d3c162d 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 439b1ece6..12ca53da4 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 000000000..5dee8243c --- /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 fbaf51c0325c175f66ae2a516b1226efad9fed4d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 18:53:36 -0500 Subject: [PATCH 19/20] 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 d62d2cfc3..c790581d8 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 40e3367af..60928ddb6 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 20/20] 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 42fa6116e..149584d00 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 a0a094bf5..acb338494 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 c4788ddbe..9ec7997db 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 c066a1e33..d449c28e8 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 3e0e43aa3..ae1d8d770 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 2269f906e..0496cd125 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