From ba1724122057e6717466a7e56e53c145f183c0c8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:12 -0700 Subject: [PATCH 01/12] Run String and Number methods on dynamic values and add substr, locale case and toPrecision string-width's `character.codePointAt(0)`, camelcase's `string.toLocaleLowerCase(locale)`, toml's `input.substr(...)` and pretty-bytes' `number.toPrecision(3)` threw "x.y is not a function": the dynamic method dispatch had no answer for these names on strings and numbers. A checked-dynamic receiver calling a String or Number method the runtime dispatch cannot answer (codePointAt, substr, toLocaleLowerCase, toLocaleUpperCase, isWellFormed, toWellFormed, trimLeft, trimRight, toPrecision, toExponential(digits)) now evaluates the receiver and arguments once and dispatches on the runtime kind: a string or number runs the typed lowering, every other kind keeps the runtime dispatch's answer. Names other dyn kinds share (at, concat, slice) and the Annex B HTML names chalk-style builders define (bold, link, ...) keep their existing dispatch. Typed receivers gain substr, the locale case pair, the HTML methods, toPrecision and toExponential(fractionDigits), all previously compile-time refusals. toPrecision/toExponential digits come from exact big-integer arithmetic over the double (ties up on the exact binary value). Locale case mapping follows V8: the first requested locale decides, Turkic dotted/dotless I is implemented, and Lithuanian, Greek uppercasing, objects and malformed tags throw a 'not supported yet' Error. A checker-untyped JS binding holding `string | undefined` boxes into the dyn receiver path for String methods (string-width's baseVisible result). tests/diagnostics/retired-engine-1112-number-methods.ts compiles now and moves back to tests/corpus/1112-number-methods.ts. New runtime functions are reached only from the new lowerings; the existing runtime objects' sections are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 4 + .../lowering/containers/string-and-regexp.ts | 423 +++++++++++++++++- .../src/frontend/lowering/lower-calls.ts | 94 ++++ packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 12 + packages/compiler/src/ir/runtime-features.ts | 2 + .../tests/corpus/1112-number-methods.ts.json | 6 + .../6200-string-annexb-locale-methods.ts.json | 6 + .../6201-number-precision-exponential.ts.json | 6 + .../6202-dyn-string-number-methods.js.json | 6 + .../6203-any-string-number-methods.ts.json | 6 + ...retired-engine-1112-number-methods.ts.json | 6 - packages/runtime/src/scr_lib.c | 131 ++++++ packages/runtime/src/scr_number.c | 160 +++++++ packages/runtime/src/scr_number.h | 11 + packages/runtime/src/scr_regex.c | 216 +++++++++ packages/runtime/src/scr_string_methods.h | 11 + .../1112-number-methods.ts} | 6 +- .../6200-string-annexb-locale-methods.ts | 33 ++ .../6201-number-precision-exponential.ts | 34 ++ .../corpus/6202-dyn-string-number-methods.js | 92 ++++ .../corpus/6203-any-string-number-methods.ts | 15 + tests/diagnostics/dynamic-surface.ts | 9 +- .../__snapshots__/dynamic-surface.ts.txt | 12 +- .../retired-engine-1112-number-methods.ts.txt | 8 - 25 files changed, 1282 insertions(+), 30 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json rename tests/{diagnostics/retired-engine-1112-number-methods.ts => corpus/1112-number-methods.ts} (73%) create mode 100644 tests/corpus/6200-string-annexb-locale-methods.ts create mode 100644 tests/corpus/6201-number-precision-exponential.ts create mode 100644 tests/corpus/6202-dyn-string-number-methods.js create mode 100644 tests/corpus/6203-any-string-number-methods.ts delete mode 100644 tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02bf37192..170ce07db 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -94,6 +94,8 @@ export const LIB_FN_SYMS: Record = { "num.parseInt": "scr_parse_int", "num.parseFloat": "scr_parse_float", "str.localeCompare": "scr_str_locale_compare", + "str.substr": "scr_str_substr", + "str.localeCase": "scr_str_locale_case", "str.replaceLiteral": "scr_str_replace_literal", "str.replaceChars": "scr_str_replace_chars", "num.fromString": "scr_string_to_number", @@ -143,6 +145,8 @@ export const LIB_FN_SYMS: Record = { "num.toExponential": "scr_num_to_exponential", "num.toFixed0": "scr_num_to_fixed0", "num.toFixed": "scr_num_to_fixed", + "num.toPrecision": "scr_num_to_precision", + "num.toExponentialDigits": "scr_num_to_exponential_digits", "num.toStringRadix": "scr_num_to_string_radix", "num.sameValue": "scr_num_same_value", "intl.numFormatEnUs": "scr_intl_num_format_en_us", diff --git a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts index 361441636..27634886a 100644 --- a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts +++ b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts @@ -1,4 +1,11 @@ -import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js"; +import { + boolLit, + dynUndefinedExpr, + nodeThrowExpr, + numLit, + strLit, + varRef, +} from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { BOOL, @@ -315,6 +322,21 @@ export function lowerStringIndexCall( ) { index = lowerer.coerceInto(indexNode, index, DYN); } + return stringIndexHelperCall(lowerer, call, method, receiver, receiverNode, index, indexNode); +} + +/** The relative-index / code-point helper call over an already lowered + * index (any type positionNumber converts — dyn indices included). */ +function stringIndexHelperCall( + lowerer: Lowerer, + call: ts.CallExpression, + method: "at" | "codePointAt", + receiver: IrExpr, + receiverNode: ts.Node, + index: IrExpr, + indexNode: ts.Expression | undefined, +): IrExpr { + const loc = locOf(call); const valueType = method === "at" ? STRING : F64; const resultType = lowerer.withUndefinedArmOf(valueType); if (!resultType || resultType.kind !== "union") @@ -1057,7 +1079,8 @@ export function lowerStringMethodCall( const replaceMethod = STRING_REPLACE_METHODS.has(access.name.text) ? (access.name.text as "replace" | "replaceAll") : null; - if (!entry && !indexMethod && !replaceMethod) return null; + const extraMethod = EXTRA_STRING_METHODS.has(access.name.text); + if (!entry && !indexMethod && !replaceMethod && !extraMethod) return null; // A validated dyn receiver (`pkg.name.replace(...)` on a JSON.parse // value) arrives pre-extracted through `dynReceiver`; its checker type // is `any`, so the type/symbol gates don't apply — the dyn value's @@ -1085,6 +1108,12 @@ export function lowerStringMethodCall( return null; if (!lowerer.isStdlibMember(access)) return null; } + if (extraMethod && argumentNodes === call.arguments) { + const receiver = dynReceiver + ? dynReceiver() + : lowerMethodReceiver(lowerer, access.expression, STRING, access.name.text); + return lowerExtraStringMethodCall(lowerer, call, access.name.text, receiver, access.expression); + } // The lib declares optional parameters beyond some lowered forms; fence // those arities instead of passing arguments the runtime doesn't take. if ( @@ -1382,3 +1411,393 @@ function lowerLocaleCompareCall( lowerer.badType(call.arguments[0]!, lowerer.typeOf(call.arguments[0]!)); return { kind: "libCall", fn: "str.localeCompare", args: [receiver, arg], type: F64, loc }; } + +/* ── String.prototype / Number.prototype members beyond the core tables ── + * substr, the locale case pair, the Annex B HTML methods, toPrecision and + * toExponential(fractionDigits) on typed receivers, plus the kind dispatch + * for every String/Number method a checked-dynamic receiver can name + * whose runtime dispatch had no answer (`character.codePointAt(0)` in a + * JS package, `n.toPrecision(3)` on a JS-inferred number). Each path + * replaces a compile-time refusal, a runtime 'not supported yet' fence or + * Node's is-not-a-function answer for a real method, so programs that + * never spell these names compile exactly as before. */ + +/** String.prototype members (Annex B included) whose typed-receiver + * lowering lives here. */ +export const EXTRA_STRING_METHODS: ReadonlySet = new Set([ + "substr", + "toLocaleLowerCase", + "toLocaleUpperCase", + "anchor", + "big", + "blink", + "bold", + "fixed", + "fontcolor", + "fontsize", + "italics", + "link", + "small", + "strike", + "sub", + "sup", +]); + +/** String methods a dyn receiver dispatches by runtime kind here: a string + * runs the typed lowering, every other kind takes the runtime dispatch + * (an object's own member, Node's is-not-a-function otherwise). Only names + * no other dyn kind's prototype declares: the shared ones (at, concat, + * slice, ...) already dispatch at run time for arrays, and a kind test in + * front of them would change array code that works today. The Annex B + * HTML names (bold, italics, link, ...) stay out too: chalk-style builders + * call them on functions and objects, whose calls must stay as they are. */ +const DYN_STRING_KIND_METHODS: ReadonlySet = new Set([ + "substr", + "toLocaleLowerCase", + "toLocaleUpperCase", + "codePointAt", + "isWellFormed", + "toWellFormed", + "trimLeft", + "trimRight", +]); + +/** Number methods a dyn receiver dispatches by runtime kind here. */ +const DYN_NUMBER_KIND_METHODS: ReadonlySet = new Set(["toPrecision", "toExponential"]); + +/** Annex B CreateHTML: the tag and the attribute (null for none). */ +const HTML_METHODS: Readonly> = { + anchor: ["a", "name"], + big: ["big", null], + blink: ["blink", null], + bold: ["b", null], + fixed: ["tt", null], + fontcolor: ["font", "color"], + fontsize: ["font", "size"], + italics: ["i", null], + link: ["a", "href"], + small: ["small", null], + strike: ["strike", null], + sub: ["sub", null], + sup: ["sup", null], +}; + +export function isDynPrimitiveKindMethod(name: string): boolean { + return DYN_STRING_KIND_METHODS.has(name) || DYN_NUMBER_KIND_METHODS.has(name); +} + +/** Evaluate `value` once into a hidden local (effect-free reads stay). */ +function hoist(lowerer: Lowerer, value: IrExpr, stmts: IrStmt[], name: string): IrExpr { + if ( + value.kind === "varRef" || + value.kind === "numLit" || + value.kind === "strLit" || + value.kind === "boolLit" || + value.kind === "unitLit" + ) + return value; + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); + return varRef(local.id, value.type, value.loc); +} + +function seq(stmts: IrStmt[], result: IrExpr): IrExpr { + return stmts.length === 0 + ? result + : { kind: "seqExpr", stmts, result, type: result.type, loc: result.loc }; +} + +/** ToString of a method argument: the dyn coercion (symbols throw), the + * typed conversions elsewhere. */ +function stringArg(lowerer: Lowerer, value: IrExpr, node: ts.Node): IrExpr { + if (value.type.kind === "dyn") + return { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [value], + type: STRING, + loc: value.loc, + }; + return lowerer.ensureString(value, node); +} + +/** An optional numeric argument: its ToNumber value and whether it was + * supplied (undefined counts as absent, as the spec's checks do). */ +function optionalNumber( + lowerer: Lowerer, + value: IrExpr | undefined, + node: ts.Expression, + stmts: IrStmt[], + subject: string, + loc: SrcLoc, +): { value: IrExpr; has: IrExpr } { + if (!value || value.type.kind === "undefinedT" || value.type.kind === "void") + return { value: numLit(0, loc), has: boolLit(false, loc) }; + if ( + value.type.kind === "f64" || + value.type.kind === "string" || + value.type.kind === "bool" || + value.type.kind === "nullT" + ) + return { + value: positionNumber(lowerer, value, numLit(0, loc), node, subject), + has: boolLit(true, loc), + }; + const boxed = + value.type.kind === "dyn" + ? value + : hoist(lowerer, lowerer.coerceToExpected(value, DYN), stmts, "%numArg"); + const absent: IrExpr = { kind: "dynTest", test: "undefined", value: boxed, type: BOOL, loc }; + return { + value: positionNumber(lowerer, boxed, numLit(0, loc), node, subject), + has: { kind: "unary", op: "!", operand: absent, type: BOOL, loc }, + }; +} + +/** The typed result of a String.prototype member over a STRING receiver + * and already evaluated arguments, or null when the name is not one of + * this module's. */ +function buildStringMethod( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + s: IrExpr, + args: IrExpr[], + receiverNode: ts.Node, + stmts: IrStmt[], +): IrExpr | null { + const loc = locOf(call); + const node = (i: number): ts.Expression => call.arguments[i] ?? call; + if (name === "substr") { + const start = args[0] + ? positionNumber(lowerer, args[0], numLit(0, loc), node(0), "substr start") + : numLit(0, loc); + const length = optionalNumber(lowerer, args[1], node(1), stmts, "substr length", loc); + return { + kind: "libCall", + fn: "str.substr", + args: [s, start, length.value, length.has], + type: STRING, + loc, + }; + } + if (name === "toLocaleLowerCase" || name === "toLocaleUpperCase") { + const locales = args[0] ? lowerer.coerceToExpected(args[0], DYN) : dynUndefinedExpr(loc); + return { + kind: "libCall", + fn: "str.localeCase", + args: [s, locales, boolLit(name === "toLocaleUpperCase", loc)], + type: STRING, + loc, + }; + } + const html = Object.hasOwn(HTML_METHODS, name) ? HTML_METHODS[name] : undefined; + if (html) { + const [tag, attribute] = html; + const subject = hoist(lowerer, s, stmts, "%htmlSubject"); + const pieces: IrExpr[] = [strLit(`<${tag}`, loc)]; + if (attribute !== null) { + const text = args[0] ? stringArg(lowerer, args[0], node(0)) : strLit("undefined", loc); + pieces.push(strLit(` ${attribute}="`, loc)); + pieces.push({ + kind: "libCall", + fn: "str.replaceLiteral", + args: [text, strLit('"', loc), strLit(""", loc), boolLit(true, loc)], + type: STRING, + loc, + }); + pieces.push(strLit('"', loc)); + } + pieces.push(strLit(">", loc), subject, strLit(``, loc)); + return pieces.reduce((left, right) => ({ kind: "strConcat", left, right, type: STRING, loc })); + } + if (name === "codePointAt") + return stringIndexHelperCall( + lowerer, + call, + name, + s, + receiverNode, + args[0] ?? numLit(0, loc), + call.arguments[0], + ); + if (name === "isWellFormed" || name === "toWellFormed") + return { + kind: "strIntrinsic", + method: name, + receiver: s, + args: [], + type: name === "isWellFormed" ? BOOL : STRING, + loc, + }; + if (name === "trimLeft" || name === "trimRight") + return { + kind: "strIntrinsic", + method: name === "trimLeft" ? "trimStart" : "trimEnd", + receiver: s, + args: [], + type: STRING, + loc, + }; + return null; +} + +/** The typed result of toPrecision/toExponential over an F64 receiver. */ +function buildNumberMethod( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + x: IrExpr, + args: IrExpr[], + stmts: IrStmt[], +): IrExpr | null { + const loc = locOf(call); + if (name !== "toPrecision" && name !== "toExponential") return null; + const digits = optionalNumber( + lowerer, + args[0], + call.arguments[0] ?? call, + stmts, + `${name} digits`, + loc, + ); + if (name === "toPrecision") + return { + kind: "libCall", + fn: "num.toPrecision", + args: [x, digits.value, digits.has], + type: STRING, + loc, + }; + const shortest: IrExpr = { + kind: "libCall", + fn: "num.toExponential", + args: [x], + type: STRING, + loc, + }; + if (digits.has.kind === "boolLit" && !digits.has.value) return shortest; + const fixed: IrExpr = { + kind: "libCall", + fn: "num.toExponentialDigits", + args: [x, digits.value], + type: STRING, + loc, + }; + if (digits.has.kind === "boolLit") return fixed; + return { kind: "ternary", cond: digits.has, then: fixed, else_: shortest, type: STRING, loc }; +} + +/** Evaluate the call's arguments, in order, into hidden locals. */ +function evaluatedArgs( + lowerer: Lowerer, + call: ts.CallExpression, + stmts: IrStmt[], + expected: IrType | undefined, +): IrExpr[] { + return call.arguments.map((arg) => + hoist( + lowerer, + expected ? lowerer.lowerExprExpecting(arg, expected) : lowerer.lowerExpr(arg), + stmts, + "%methodArg", + ), + ); +} + +/** A typed STRING receiver calling one of EXTRA_STRING_METHODS: receiver, + * then arguments, then the method's own conversions — the spec order. */ +export function lowerExtraStringMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + receiver: IrExpr, + receiverNode: ts.Node, +): IrExpr | null { + if (call.arguments.some(ts.isSpreadElement)) return null; + const stmts: IrStmt[] = []; + const s = hoist(lowerer, receiver, stmts, "%stringReceiver"); + const args = evaluatedArgs(lowerer, call, stmts, undefined); + const result = buildStringMethod(lowerer, call, name, s, args, receiverNode, stmts); + return result ? seq(stmts, result) : null; +} + +/** A typed F64 receiver calling toPrecision(p) or toExponential(f). */ +export function lowerExtraNumberMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + receiver: IrExpr, +): IrExpr | null { + if (call.arguments.some(ts.isSpreadElement) || call.arguments.length > 1) return null; + if (name !== "toPrecision" && !(name === "toExponential" && call.arguments.length === 1)) + return null; + const stmts: IrStmt[] = []; + const x = hoist(lowerer, receiver, stmts, "%numberReceiver"); + const args = evaluatedArgs(lowerer, call, stmts, undefined); + const result = buildNumberMethod(lowerer, call, name, x, args, stmts); + return result ? seq(stmts, result) : null; +} + +/** `recv.m(args)` on a checked-dynamic receiver for a String/Number method + * the runtime dispatch cannot answer: the receiver and arguments evaluate + * once, a string (or number) receiver runs the typed lowering, and every + * other kind keeps the runtime dispatch's answer. Null for other names. */ +export function lowerDynPrimitiveMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + name: string, + calleeName: string, + recv: IrExpr, +): IrExpr | null { + const stringMethod = DYN_STRING_KIND_METHODS.has(name); + const numberMethod = DYN_NUMBER_KIND_METHODS.has(name); + if (!stringMethod && !numberMethod) return null; + if (call.arguments.some(ts.isSpreadElement)) return null; + const loc = locOf(call); + const stmts: IrStmt[] = []; + const local = lowerer.declareHiddenLocal("%primitiveReceiver", DYN); + stmts.push({ kind: "varDecl", localId: local.id, init: recv, loc }); + const r = varRef(local.id, DYN, loc); + const args = evaluatedArgs(lowerer, call, stmts, DYN); + let result: IrExpr = { + kind: "dynInvoke", + recv: r, + method: name, + calleeName, + args, + type: DYN, + loc, + }; + const branch = ( + test: "string" | "number", + build: (value: IrExpr, branchStmts: IrStmt[]) => IrExpr | null, + ): void => { + const branchStmts: IrStmt[] = []; + const value: IrExpr = { + kind: "dynCheck", + value: r, + type: test === "string" ? STRING : F64, + loc, + }; + const built = build(value, branchStmts); + if (!built) return; + result = { + kind: "ternary", + cond: { kind: "dynTest", test, value: r, type: BOOL, loc }, + then: { kind: "dynFrom", value: seq(branchStmts, built), type: DYN, loc }, + else_: result, + type: DYN, + loc, + }; + }; + if (numberMethod) + branch("number", (x, branchStmts) => + buildNumberMethod(lowerer, call, name, x, args, branchStmts), + ); + if (stringMethod) + branch("string", (s, branchStmts) => + buildStringMethod(lowerer, call, name, s, args, call.expression, branchStmts), + ); + return seq(stmts, result); +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c..ca4dd0edb 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -95,6 +95,9 @@ import { lowerStringMethodCall, lowerStringPaddingCall, lowerStringSplitCall, + isDynPrimitiveKindMethod, + lowerDynPrimitiveMethodCall, + lowerExtraNumberMethodCall, } from "./containers/string-and-regexp.js"; import { createRequireSpecOf } from "./builtins/module-bindings.js"; import { @@ -3274,6 +3277,23 @@ function lowerDynReceiverMethodCall( lowerStringMethodCall(lowerer, call, access, () => recv) ); } + // A checker-untyped JS binding holding `string | undefined` (string- + // width's `const visibleSegment = baseVisible(segment)` after an + // `=== undefined` guard the checker cannot see through): no typed + // lowering claims the union, so a String method boxes it and takes the + // dyn receiver path, whose undefined/null arms throw Node's read errors. + if ( + checkerUntyped && + recv.type.kind === "union" && + isJsSourceFile(call.getSourceFile()) && + isStringMethodName(access.name.text) && + lowerer.unions + .get(recv.type.unionId) + ?.arms.every((arm) => arm.kind === "string" || isUnitType(arm)) === true && + lowerer.dynConvertible(recv.type) + ) { + recv = lowerer.coerceToExpected(recv, DYN); + } if (recv.type.kind !== "dyn") return null; if ( access.name.text === "normalize" && @@ -3393,6 +3413,26 @@ function lowerDynReceiverMethodCall( lowerStringMethodCall(lowerer, call, access, checked) ); } + // String/Number methods the runtime dispatch has no answer for + // (codePointAt, substr, toLocaleLowerCase, toPrecision, ...): a string + // or number receiver runs the typed lowering, every other kind keeps the + // runtime dispatch. + if ( + !ownCallable && + !call.questionDotToken && + !access.questionDotToken && + isDynPrimitiveKindMethod(access.name.text) && + !lowerer.chainBlocked(access, call) + ) { + const primitive = lowerDynPrimitiveMethodCall( + lowerer, + call, + access.name.text, + access.getText(), + recv, + ); + if (primitive) return primitive; + } // The argument is a radix for bigint and an encoding for Buffer. // Preserve its runtime value so receiver dispatch can choose correctly. if (access.name.text === "toString" && call.arguments.length <= 1) { @@ -3968,6 +4008,19 @@ export function lowerDynDispatchMethodCall( }; } +/** Every String.prototype method name a typed or dyn string lowering + * answers. */ +function isStringMethodName(name: string): boolean { + return ( + Object.hasOwn(STR_METHODS, name) || + STRING_INDEX_METHODS.has(name) || + DYN_STRING_ONLY_METHODS.has(name) || + isDynPrimitiveKindMethod(name) || + name === "normalize" || + name === "lastIndexOf" + ); +} + /** STR_METHODS ∪ the regex-form names, MINUS everything Array (or any * other dyn kind's prototype) also declares. */ const DYN_STRING_ONLY_METHODS = new Set([ @@ -4410,6 +4463,21 @@ function lowerPrimitiveProtoCall( isJsSourceFile(call.getSourceFile()) ) { const operand = lowerer.lowerExpr(recv); + if ( + name === "toExponential" && + call.arguments.length === 1 && + (operand.type.kind === "dyn" || + (operand.type.kind === "union" && lowerer.dynConvertible(operand.type))) + ) { + const dispatched = lowerDynPrimitiveMethodCall( + lowerer, + call, + name, + `${recv.getText()}.${name}`, + lowerer.coerceToExpected(operand, DYN), + ); + if (dispatched) return dispatched; + } if ( operand.type.kind === "dyn" || (operand.type.kind === "union" && lowerer.dynConvertible(operand.type)) @@ -4424,6 +4492,32 @@ function lowerPrimitiveProtoCall( loc: locOf(call), }; } + if ( + name === "toPrecision" && + call.arguments.length <= 1 && + !call.arguments.some(ts.isSpreadElement) && + isJsSourceFile(call.getSourceFile()) && + recvKind === "union" + ) { + const operand = lowerer.lowerExpr(recv); + if (operand.type.kind === "union" && lowerer.dynConvertible(operand.type)) { + const dispatched = lowerDynPrimitiveMethodCall( + lowerer, + call, + name, + `${recv.getText()}.${name}`, + lowerer.coerceToExpected(operand, DYN), + ); + if (dispatched) return dispatched; + } + } + if ( + recvKind === "f64" && + (name === "toPrecision" || (name === "toExponential" && call.arguments.length === 1)) + ) { + const typed = lowerExtraNumberMethodCall(lowerer, call, name, lowerer.lowerExpr(recv)); + if (typed) return typed; + } if (recvKind !== "f64" && recvKind !== "bool" && recvKind !== "string") return null; const loc = locOf(call); if (name === "toString" && call.arguments.length === 0) { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1bb9cbd14..b537923a9 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -136,6 +136,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "fetch.streamNew", "fetch.streamFrom", "num.toFixed", + "num.toPrecision", + "num.toExponentialDigits", + "str.localeCase", "num.toStringRadix", "insp.jsonDyn", // diagnostics_channel: publish runs subscribers synchronously (a throw diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 08c493ae4..ab4d0ba7f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -686,6 +686,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** `a.localeCompare(b)`: -1/0/1 in Node's root-locale collation for * Latin-script text (scr_str_locale_compare). Never throws. */ "str.localeCompare": { argTypes: [STRING, STRING], result: F64 }, + /** Annex B substr(start, length) with start ToNumber'd and length's + * presence; never throws. */ + "str.substr": { argTypes: [STRING, F64, F64, BOOL], result: STRING }, + /** toLocaleLowerCase/toLocaleUpperCase (scr_regex.c): the locales value + * as written, upper=true for toLocaleUpperCase. Throws for null and for + * locale forms with no native rules. */ + "str.localeCase": { argTypes: [STRING, DYN, BOOL], result: STRING }, "str.replaceLiteral": { argTypes: [STRING, STRING, STRING, BOOL], result: STRING }, // (subject, characters, NUL-separated replacement texts): the fused // chain of global one-character replacements. @@ -750,6 +757,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "num.toExponential": { argTypes: [F64], result: STRING }, "num.toFixed0": { argTypes: [F64], result: STRING }, "num.toFixed": { argTypes: [F64, F64], result: STRING }, + /** toPrecision(precision) with the argument ToNumber'd and its presence + * (undefined answers ToString); toExponential with a DEFINED + * fractionDigits. Both throw V8's RangeError for out-of-range digits. */ + "num.toPrecision": { argTypes: [F64, F64, BOOL], result: STRING }, + "num.toExponentialDigits": { argTypes: [F64, F64], result: STRING }, "num.toStringRadix": { argTypes: [F64, DYN], result: STRING }, /** Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is `===`. Plain bool diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd..15e9ad999 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -422,6 +422,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run } if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; if (fn.startsWith("tlsca.")) features.tlsCa = true; + // Locale case mapping runs the default conversion of the regex unit. + if (fn === "str.localeCase") features.regex = true; // scr_cipher.c over the mbedTLS archive; DEP0182 reports through the // process-warning unit, and unknown output encodings render through // the inspect unit. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json new file mode 100644 index 000000000..c95861fe3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/1112-number-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/1112-number-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json new file mode 100644 index 000000000..17a5c1e30 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6200-string-annexb-locale-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6200-string-annexb-locale-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json new file mode 100644 index 000000000..b80384ada --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6201-number-precision-exponential.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6201-number-precision-exponential.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json new file mode 100644 index 000000000..dbf5146ed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6202-dyn-string-number-methods.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6202-dyn-string-number-methods.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json new file mode 100644 index 000000000..42b17b7bb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6203-any-string-number-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6203-any-string-number-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json deleted file mode 100644 index 27b24a99a..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-1112-number-methods.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-1112-number-methods.ts" - ], - "diags": [] -} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index fd51e9c78..45d9b7e2b 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10730,6 +10730,137 @@ ScrStr *scr_num_to_fixed(double x, double fraction_digits) { return scr_str_new(out, len); } +/* String.prototype.substr (Annex B.2.2.1) over substring's UTF-16 + * clamp: -Infinity and other starts below -size clamp to 0, an Infinity + * length to the end. Comparisons only (no floating-point literal other + * than zero, which would join the constant pool every binary links). + * Nothing on an existing path calls this. */ +ScrStr *scr_str_substr(ScrStr *s, double start, double length, bool has_length) { + double size = scr_str_utf16_len(s); + double first = isnan(start) ? 0 : trunc(start); + if (first < 0) { + first = size + first; + if (first < 0) first = 0; + } else if (first > size) { + first = size; + } + double count = has_length ? (isnan(length) ? 0 : trunc(length)) : size; + if (count < 0) count = 0; + double end = first + count; + if (end > size) end = size; + return scr_str_substring(s, first, end); +} + +/* The exponent suffix of toPrecision/toExponential: "e+5", "e-7". */ +static size_t scr_num_exp_suffix(char *o, int e) { + size_t n = 0; + o[n++] = 'e'; + o[n++] = e < 0 ? '-' : '+'; + int a = e < 0 ? -e : e; + char rev[8]; + int k = 0; + do { + rev[k++] = (char)('0' + a % 10); + a /= 10; + } while (a != 0); + while (k > 0) o[n++] = rev[--k]; + return n; +} + +/* Number.prototype.toPrecision(precision) (ECMA-262 §21.1.3.5): undefined + * precision is ToString(x); ToIntegerOrInfinity runs (in the caller's + * coercion) before the non-finite arm, which precedes the 1..100 range + * check. Digits are the exact-value, ties-up n of + * scr_f64_precision_digits; exponential form when e < -6 or e >= p. + * Nothing on an existing path calls this. */ +ScrStr *scr_num_to_precision(double x, double precision, bool has_precision) { + if (!has_precision || !isfinite(x)) return scr_f64_to_scrstr(x); + double p = isnan(precision) ? 0 : trunc(precision); + if (!(p >= 1 && p <= 100)) { + static const char msg[] = "toPrecision() argument must be between 1 and 100"; + scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); + return NULL; + } + int digits_count = (int)p; + char out[160]; + size_t o = 0; + double a = x; + if (x < 0) { + out[o++] = '-'; + a = -x; + } + char d[104]; + int e = 0; + if (a == 0) { + memset(d, '0', (size_t)digits_count); + d[digits_count] = '\0'; + } else { + scr_f64_precision_digits(a, digits_count, d, &e); + } + if (e < -6 || e >= digits_count) { + out[o++] = d[0]; + if (digits_count != 1) { + out[o++] = '.'; + memcpy(out + o, d + 1, (size_t)(digits_count - 1)); + o += (size_t)(digits_count - 1); + } + o += scr_num_exp_suffix(out + o, e); + } else if (e >= 0) { + memcpy(out + o, d, (size_t)(e + 1)); + o += (size_t)(e + 1); + if (e + 1 < digits_count) { + out[o++] = '.'; + memcpy(out + o, d + e + 1, (size_t)(digits_count - e - 1)); + o += (size_t)(digits_count - e - 1); + } + } else { + out[o++] = '0'; + out[o++] = '.'; + for (int i = 0; i < -(e + 1); i++) out[o++] = '0'; + memcpy(out + o, d, (size_t)digits_count); + o += (size_t)digits_count; + } + return scr_str_new(out, o); +} + +/* Number.prototype.toExponential(fractionDigits) with a DEFINED argument + * (the undefined form is scr_num_to_exponential's shortest mantissa): + * f + 1 exact-value, ties-up digits, "d.ddde±X". Nothing on an existing + * path calls this. */ +ScrStr *scr_num_to_exponential_digits(double x, double fraction_digits) { + if (!isfinite(x)) return scr_f64_to_scrstr(x); + double f = isnan(fraction_digits) ? 0 : trunc(fraction_digits); + if (!(f >= 0 && f <= 100)) { + static const char msg[] = "toExponential() argument must be between 0 and 100"; + scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); + return NULL; + } + int count = (int)f + 1; + char out[160]; + size_t o = 0; + double a = x; + if (x < 0) { + out[o++] = '-'; + a = -x; + } + char d[104]; + int e = 0; + if (a == 0) { + memset(d, '0', (size_t)count); + d[count] = '\0'; + } else { + scr_f64_precision_digits(a, count, d, &e); + } + out[o++] = d[0]; + if (count != 1) { + out[o++] = '.'; + memcpy(out + o, d + 1, (size_t)(count - 1)); + o += (size_t)(count - 1); + } + o += scr_num_exp_suffix(out + o, e); + return scr_str_new(out, o); +} + /* Increment a decimal digit string in place. Returns true on overflow — * the value becomes 1 followed by len zeros (the caller folds the zeros * into its scale); an EMPTY string increments to "1" the same way (the diff --git a/packages/runtime/src/scr_number.c b/packages/runtime/src/scr_number.c index 1ccc414b7..dc8743e14 100644 --- a/packages/runtime/src/scr_number.c +++ b/packages/runtime/src/scr_number.c @@ -654,3 +654,163 @@ size_t scr_f64_to_fixed(double x, int f, char *buf) { *o = '\0'; return (size_t)(o - buf); } + +/* ── toPrecision / toExponential(fractionDigits) digit engine ────────── + * The spec's n and e for p significant digits: the integer n with + * 10^(p-1) <= n < 10^p for which n × 10^(e-p+1) - x is closest to zero, + * ties picking the LARGER n (half-up on the exact binary value). Exact + * big-integer arithmetic over the double's mantissa: the value scaled by + * 10^(p-1-e) is mantissa × 2^(binary_exp + f) × 5^f, and + * round_half_up(v) = floor((floor(2v) + 1) / 2) composes from floor + * divisions alone. Values reach ~1100 bits (5e-324 at 100 digits and + * 1.8e308), so the limbs are wider than toFixed's. Nothing on an existing + * path calls this. */ +#define SCR_PREC_LIMBS 40 +typedef struct { + uint32_t limb[SCR_PREC_LIMBS]; /* little-endian */ + int len; +} ScrPrecInt; + +static void scr_prec_normalize(ScrPrecInt *v) { + while (v->len > 1 && v->limb[v->len - 1] == 0) v->len--; +} + +static void scr_prec_mul_small(ScrPrecInt *v, uint32_t m) { + uint64_t carry = 0; + for (int i = 0; i < v->len; i++) { + uint64_t p = (uint64_t)v->limb[i] * m + carry; + v->limb[i] = (uint32_t)p; + carry = p >> 32; + } + if (carry != 0) v->limb[v->len++] = (uint32_t)carry; +} + +static uint32_t scr_prec_div_small(ScrPrecInt *v, uint32_t d) { + uint64_t rem = 0; + for (int i = v->len - 1; i >= 0; i--) { + uint64_t cur = (rem << 32) | v->limb[i]; + v->limb[i] = (uint32_t)(cur / d); + rem = cur % d; + } + scr_prec_normalize(v); + return (uint32_t)rem; +} + +static void scr_prec_shl(ScrPrecInt *v, int bits) { + uint32_t out[SCR_PREC_LIMBS] = {0}; + int words = bits / 32; + int rem = bits % 32; + for (int i = 0; i < v->len; i++) { + int at = i + words; + out[at] |= v->limb[i] << rem; + if (rem != 0 && at + 1 < SCR_PREC_LIMBS) out[at + 1] |= v->limb[i] >> (32 - rem); + } + int n = v->len + words + 1; + if (n > SCR_PREC_LIMBS) n = SCR_PREC_LIMBS; + memcpy(v->limb, out, sizeof out); + v->len = n; + scr_prec_normalize(v); +} + +static void scr_prec_shr(ScrPrecInt *v, int bits) { + int words = bits / 32; + int rem = bits % 32; + if (words >= v->len) { + v->limb[0] = 0; + v->len = 1; + return; + } + int n = v->len - words; + for (int i = 0; i < n; i++) { + uint32_t lo = v->limb[i + words] >> rem; + uint32_t hi = rem != 0 && i + words + 1 < v->len ? v->limb[i + words + 1] << (32 - rem) : 0; + v->limb[i] = lo | hi; + } + v->len = n; + scr_prec_normalize(v); +} + +/* round_half_up(a × 10^f) for positive finite a, as a decimal digit + * string written right-aligned ending at `end`; returns the first digit. */ +static char *scr_prec_scaled_digits(double a, int f, char *end) { + uint64_t bits; + memcpy(&bits, &a, sizeof bits); + uint64_t mantissa = bits & ((UINT64_C(1) << 52) - 1); + int ieee_exp = (int)((bits >> 52) & 0x7ffu); + int binary_exp; + if (ieee_exp == 0) { + binary_exp = -1074; + } else { + mantissa |= UINT64_C(1) << 52; + binary_exp = ieee_exp - 1023 - 52; + } + ScrPrecInt v = {{(uint32_t)mantissa, (uint32_t)(mantissa >> 32)}, 2}; + scr_prec_normalize(&v); + for (int i = 0; i < f; i++) scr_prec_mul_small(&v, 5); + int twos = binary_exp + f + 1; /* the +1 doubles: floor(2v) */ + if (twos > 0) scr_prec_shl(&v, twos); + for (int i = 0; i < -f; i++) scr_prec_div_small(&v, 5); + if (twos < 0) scr_prec_shr(&v, -twos); + /* (floor(2v) + 1) / 2 */ + uint64_t carry = 1; + for (int i = 0; i < v.len && carry != 0; i++) { + uint64_t s = (uint64_t)v.limb[i] + carry; + v.limb[i] = (uint32_t)s; + carry = s >> 32; + } + if (carry != 0) v.limb[v.len++] = (uint32_t)carry; + scr_prec_shr(&v, 1); + char *p = end; + for (;;) { + uint32_t chunk = scr_prec_div_small(&v, 1000000000u); + bool last = v.len == 1 && v.limb[0] == 0; + for (int i = 0; i < 9 && (chunk != 0 || !last || p == end); i++) { + *--p = (char)('0' + chunk % 10); + chunk /= 10; + } + if (last) break; + } + return p; +} + +int scr_f64_precision_digits(double a, int p, char *digits, int *e_out) { + char shortest[18]; + int n; + scr_f64_digits(a, shortest, &n); + int e = n - 1; + char buf[400]; + char *end = buf + sizeof buf; + for (int attempt = 0; attempt < 3; attempt++) { + char *first = scr_prec_scaled_digits(a, p - 1 - e, end); + int len = (int)(end - first); + if (len == p) { + memcpy(digits, first, (size_t)p); + digits[p] = '\0'; + *e_out = e; + return p; + } + if (len > p) { + /* Rounding carried into a new digit (or the estimate was low). */ + if (len == p + 1 && first[0] == '1') { + bool zeros = true; + for (int i = 1; i < len; i++) zeros = zeros && first[i] == '0'; + if (zeros) { + digits[0] = '1'; + memset(digits + 1, '0', (size_t)(p - 1)); + digits[p] = '\0'; + *e_out = e + 1; + return p; + } + } + e++; + } else { + e--; + } + } + /* Unreachable for finite positive doubles: the estimate is off by at + * most one decade. */ + memset(digits, '0', (size_t)p); + digits[p] = '\0'; + *e_out = e; + return p; +} diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 84db27075..38a362ac9 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -37,6 +37,12 @@ int scr_decimal_scan(const char *p, size_t n, double *out); * the length. */ size_t scr_f64_to_fixed(double x, int f, char *buf); +/* toPrecision / toExponential(fractionDigits) digits for POSITIVE finite + * a and 1 <= p <= 101: the spec's p-digit n (ties to the larger n on the + * exact binary value) into digits (NUL-terminated, >= p + 1 bytes) and its + * decimal exponent e (value ~ d.ddd x 10^e) into *e_out. Returns p. */ +int scr_f64_precision_digits(double a, int p, char *digits, int *e_out); + /* ToString for template literals / string coercion. Returns +1. */ ScrStr *scr_f64_to_scrstr(double x); ScrStr *scr_bool_to_scrstr(bool b); /* interned "true"/"false" */ @@ -73,6 +79,11 @@ bool scr_num_is_safe_integer(double x); ScrStr *scr_num_to_exponential(double x); ScrStr *scr_num_to_fixed0(double x); ScrStr *scr_num_to_fixed(double x, double fraction_digits); +/* Number.prototype.toPrecision(precision) and toExponential(fractionDigits) + * with the argument already coerced (has_precision false = undefined). + * Invalid digit counts throw V8's RangeError (NULL). Result +1. */ +ScrStr *scr_num_to_precision(double x, double precision, bool has_precision); +ScrStr *scr_num_to_exponential_digits(double x, double fraction_digits); /* Object.is over two numbers — the spec's SameValue on doubles: NaN * equals NaN, +0 differs from -0, everything else is ==. Never throws. */ diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index b24261a4d..24a11d4b6 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -1742,6 +1742,222 @@ static ScrStr *scr_str_case_conv(const ScrStr *s, int to_lower) { ScrStr *scr_str_to_lower(const ScrStr *s) { return scr_str_case_conv(s, 1); } ScrStr *scr_str_to_upper(const ScrStr *s) { return scr_str_case_conv(s, 0); } +/* ── String.prototype.toLocaleLowerCase / toLocaleUpperCase ──────────── + * V8 decides the case mapping from the FIRST requested locale only + * (probed: ["en", 5] maps like "en", [5] throws, a non-object primitive + * requests nothing). ICU tailors case mapping for Turkic (tr, az: dotted + * and dotless I), Lithuanian (dot above retention) and Greek uppercasing + * (accent removal); every other language uses the default conversion. + * The Turkic rules are SpecialCasing's: lowercase I → ı unless a + * U+0307 follows (then I + U+0307 → i), İ → i; uppercase i → İ. Tags are + * checked against the unicode_locale_id structure; anything this unit + * cannot decide exactly (malformed tags, whose V8 RangeError texts vary, + * objects, lt, el uppercasing, I before other combining marks) throws a + * 'not supported yet' Error rather than guess. Nothing on an existing + * path calls this. */ + +static bool scr_tag_alpha(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!((p[i] >= 'a' && p[i] <= 'z') || (p[i] >= 'A' && p[i] <= 'Z'))) return false; + return true; +} + +static bool scr_tag_digit(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!(p[i] >= '0' && p[i] <= '9')) return false; + return true; +} + +static bool scr_tag_alnum(const char *p, size_t n) { + for (size_t i = 0; i < n; i++) + if (!scr_tag_alpha(p + i, 1) && !scr_tag_digit(p + i, 1)) return false; + return true; +} + +static bool scr_tag_same(const char *a, size_t an, const char *b, size_t bn) { + if (an != bn) return false; + for (size_t i = 0; i < an; i++) { + char x = a[i] >= 'A' && a[i] <= 'Z' ? (char)(a[i] + 32) : a[i]; + char y = b[i] >= 'A' && b[i] <= 'Z' ? (char)(b[i] + 32) : b[i]; + if (x != y) return false; + } + return true; +} + +/* 0 default, 1 Turkic, 2 Lithuanian, 3 Greek; -1 when the tag is outside + * the structural grammar handled here. */ +static int scr_locale_case_rules(const char *tag, size_t len) { + const char *subtags[64]; + size_t lens[64]; + size_t count = 0; + size_t start = 0; + for (size_t i = 0; i <= len; i++) { + if (i < len && tag[i] != '-') continue; + if (i == start || count == 64) return -1; + subtags[count] = tag + start; + lens[count++] = i - start; + start = i + 1; + } + size_t k = 0; + size_t lang = lens[0]; + if (!scr_tag_alpha(subtags[0], lang) || !((lang >= 2 && lang <= 3) || (lang >= 5 && lang <= 8))) + return -1; + k = 1; + if (k < count && lens[k] == 4 && scr_tag_alpha(subtags[k], 4)) k++; + if (k < count && ((lens[k] == 2 && scr_tag_alpha(subtags[k], 2)) || + (lens[k] == 3 && scr_tag_digit(subtags[k], 3)))) + k++; + size_t variants = k; + while (k < count && scr_tag_alnum(subtags[k], lens[k]) && + ((lens[k] >= 5 && lens[k] <= 8) || (lens[k] == 4 && scr_tag_digit(subtags[k], 1)))) { + for (size_t j = variants; j < k; j++) + if (scr_tag_same(subtags[j], lens[j], subtags[k], lens[k])) return -1; + k++; + } + size_t singletons = k; + while (k < count) { + if (lens[k] != 1 || !scr_tag_alnum(subtags[k], 1)) return -1; + for (size_t j = singletons; j < k; j++) + if (lens[j] == 1 && scr_tag_same(subtags[j], 1, subtags[k], 1)) return -1; + bool private_use = subtags[k][0] == 'x' || subtags[k][0] == 'X'; + size_t min = private_use ? 1 : 2; + size_t first = ++k; + while (k < count && lens[k] >= min && lens[k] <= 8 && scr_tag_alnum(subtags[k], lens[k]) && + (private_use || lens[k] > 1)) + k++; + if (k == first) return -1; + if (private_use && k != count) return -1; + } + if (lang > 3) return 0; + char l[3] = {0, 0, 0}; + for (size_t i = 0; i < lang; i++) + l[i] = subtags[0][i] >= 'A' && subtags[0][i] <= 'Z' ? (char)(subtags[0][i] + 32) : subtags[0][i]; + bool two = lang == 2; + if ((two && l[0] == 't' && l[1] == 'r') || (two && l[0] == 'a' && l[1] == 'z') || + (!two && l[0] == 't' && l[1] == 'u' && l[2] == 'r') || + (!two && l[0] == 'a' && l[1] == 'z' && l[2] == 'e')) + return 1; + if ((two && l[0] == 'l' && l[1] == 't') || (!two && l[0] == 'l' && l[1] == 'i' && l[2] == 't')) + return 2; + if ((two && l[0] == 'e' && l[1] == 'l') || (!two && l[0] == 'e' && l[1] == 'l' && l[2] == 'l') || + (!two && l[0] == 'g' && l[1] == 'r' && l[2] == 'e')) + return 3; + return 0; +} + +static ScrStr *scr_locale_case_unsupported(bool upper) { + static const char lower_msg[] = + "String.prototype.toLocaleLowerCase with this locale argument is not supported yet"; + static const char upper_msg[] = + "String.prototype.toLocaleUpperCase with this locale argument is not supported yet"; + /* The unit's existing "SC2020" literal: a separate constant array would + * absorb it (LLVM merges identical constants) and move it out of the + * string section every regex-linking binary keeps. */ + if (upper) scr_throw_error_msg_code(SCR_ERR_ERROR, upper_msg, sizeof upper_msg - 1, "SC2020"); + else scr_throw_error_msg_code(SCR_ERR_ERROR, lower_msg, sizeof lower_msg - 1, "SC2020"); + return NULL; +} + +static ScrStr *scr_turkic_case(const ScrStr *s, bool upper) { + ScrJsonBuf b; + scr_jb_init(&b); + for (size_t i = 0; i < s->len; i++) { + unsigned char c = (unsigned char)s->data[i]; + if (upper && c == 'i') { + scr_jb_putc(&b, (char)0xC4); /* İ */ + scr_jb_putc(&b, (char)0xB0); + continue; + } + if (!upper && c == 'I') { + unsigned char n0 = i + 1 < s->len ? (unsigned char)s->data[i + 1] : 0; + unsigned char n1 = i + 2 < s->len ? (unsigned char)s->data[i + 2] : 0; + if (n0 == 0xCC && n1 == 0x87) { /* I + U+0307 → i */ + scr_jb_putc(&b, 'i'); + i += 2; + continue; + } + /* Another combining mark (U+0300..U+036F) may precede a U+0307. */ + if ((n0 == 0xCC && n1 >= 0x80) || (n0 == 0xCD && n1 >= 0x80 && n1 <= 0xAF)) { + scr_str_release(scr_jb_finish(&b)); + return scr_locale_case_unsupported(upper); + } + scr_jb_putc(&b, (char)0xC4); /* ı */ + scr_jb_putc(&b, (char)0xB1); + continue; + } + if (!upper && c == 0xC4 && i + 1 < s->len && (unsigned char)s->data[i + 1] == 0xB0) { + scr_jb_putc(&b, 'i'); /* İ → i */ + i++; + continue; + } + scr_jb_putc(&b, (char)c); + } + ScrStr *mapped = scr_jb_finish(&b); + ScrStr *result = scr_str_case_conv(mapped, upper ? 0 : 1); + scr_str_release(mapped); + return result; +} + +ScrStr *scr_str_locale_case(const ScrStr *s, ScrDyn *locales, bool upper) { + ScrDyn *owned = NULL; + if (locales->kind == SCR_DYN_TYPED_REF) { + owned = scr_dyn_typed_ref_materialize(locales); + if (!owned) return NULL; + locales = owned; + } + const ScrDyn *tag = NULL; + int rules = 0; + switch (locales->kind) { + case SCR_DYN_NULL: { + static const char msg[] = "Cannot convert undefined or null to object"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return NULL; + } + case SCR_DYN_UNDEF: + case SCR_DYN_NUM: + case SCR_DYN_BOOL: + case SCR_DYN_SYMBOL: + case SCR_DYN_BIGINT: + break; + case SCR_DYN_STR: + tag = locales; + break; + case SCR_DYN_ARR: + if (locales->v.arr.len == 0) break; + if (locales->v.arr.presence && !locales->v.arr.presence[0]) { + static const char msg[] = "Language ID should be string or object."; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + scr_dyn_release(owned); + return NULL; + } + tag = locales->v.arr.items[0]; + if (tag->kind != SCR_DYN_STR) { + bool primitive = tag->kind == SCR_DYN_UNDEF || tag->kind == SCR_DYN_NULL || + tag->kind == SCR_DYN_NUM || tag->kind == SCR_DYN_BOOL || + tag->kind == SCR_DYN_BIGINT || tag->kind == SCR_DYN_SYMBOL; + scr_dyn_release(owned); + if (!primitive) return scr_locale_case_unsupported(upper); + static const char msg[] = "Language ID should be string or object."; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return NULL; + } + break; + default: + scr_dyn_release(owned); + return scr_locale_case_unsupported(upper); + } + if (tag) { + rules = scr_locale_case_rules(tag->v.str->data, tag->v.str->len); + if (rules < 0 || rules == 2 || (rules == 3 && upper)) { + scr_dyn_release(owned); + return scr_locale_case_unsupported(upper); + } + } + scr_dyn_release(owned); + if (rules == 1) return scr_turkic_case(s, upper); + return scr_str_case_conv(s, upper ? 0 : 1); +} + static void *scr_normalize_realloc(void *opaque, void *ptr, size_t size) { (void)opaque; if (!size) { free(ptr); return NULL; } diff --git a/packages/runtime/src/scr_string_methods.h b/packages/runtime/src/scr_string_methods.h index 62354142e..6ed2e09df 100644 --- a/packages/runtime/src/scr_string_methods.h +++ b/packages/runtime/src/scr_string_methods.h @@ -73,6 +73,10 @@ bool scr_str_ends_with_from(ScrStr *s, ScrStr *needle, double end_position); ScrStr *scr_str_slice(ScrStr *s, double start, double end); /* substring: clamp-and-swap (negatives clamp to 0, start > end swaps). */ ScrStr *scr_str_substring(ScrStr *s, double start, double end); +/* Annex B substr(start, length): start already ToNumber'd (NaN → 0), a + * negative start counts from the end, length absent (has_length false) + * runs to the end. Returns +1. */ +ScrStr *scr_str_substr(ScrStr *s, double start, double length, bool has_length); /* Scalar-only local slices retain their original owner without copying. * Bounds and split-surrogate replacements are snapshotted at construction. */ @@ -111,6 +115,13 @@ ScrStr *scr_str_trim(ScrStr *s); ScrStr *scr_str_to_lower(const ScrStr *s); ScrStr *scr_str_to_upper(const ScrStr *s); ScrStr *scr_str_normalize(const ScrStr *s, const ScrStr *form); +/* toLocaleLowerCase/toLocaleUpperCase(locales) (scr_regex.c): undefined, + * empty lists, and primitives other than strings use the default case + * conversion; the first requested tag selects the Turkic (tr, az) rules or + * the default ones. Lithuanian, Greek uppercasing and tags outside the + * structural grammar throw a 'not supported yet' Error; null throws Node's + * TypeError. Borrows both; +1 or NULL with an exception pending. */ +ScrStr *scr_str_locale_case(const ScrStr *s, struct ScrDyn *locales, bool upper); /* NFC over a code point buffer (scr_regex.c, beside normalize): a fresh * malloc'd buffer the caller frees, its length in *out_len. Borrows input. */ uint32_t *scr_nfc_code_points(const uint32_t *input, size_t length, size_t *out_len); diff --git a/tests/diagnostics/retired-engine-1112-number-methods.ts b/tests/corpus/1112-number-methods.ts similarity index 73% rename from tests/diagnostics/retired-engine-1112-number-methods.ts rename to tests/corpus/1112-number-methods.ts index c96f9df00..510850dca 100644 --- a/tests/diagnostics/retired-engine-1112-number-methods.ts +++ b/tests/corpus/1112-number-methods.ts @@ -1,7 +1,5 @@ -// Explicit toFixed is static; toPrecision/toString(radix) execute in the -// island. The receiver marshals by value there and the engine auto-boxes -// it, so `this` binds the number exactly as in JS. All stay byte-exact -// differential. +// Number.prototype.toFixed, toPrecision and toString(radix) on typed +// numbers all compile statically and stay byte-exact differential. const n = 1234.5678; console.log(n.toFixed(0), n.toFixed(2), n.toFixed(6)); console.log((0.1 + 0.2).toFixed(17), (-3.7).toFixed(1), (0).toFixed(2)); diff --git a/tests/corpus/6200-string-annexb-locale-methods.ts b/tests/corpus/6200-string-annexb-locale-methods.ts new file mode 100644 index 000000000..8c0c2f66b --- /dev/null +++ b/tests/corpus/6200-string-annexb-locale-methods.ts @@ -0,0 +1,33 @@ +// String.prototype.substr, the locale case pair and the Annex B HTML +// methods on typed strings. substr counts a negative start from the end +// and clamps its length; toLocaleLowerCase/toLocaleUpperCase use the +// default case conversion except for the Turkic languages, decided by the +// first requested locale only; the HTML methods escape '"' in attribute +// values. +const s = "Hello, World"; +console.log(s.substr(0), s.substr(7), s.substr(-5), s.substr(-50, 3), s.substr(3, 4)); +console.log(JSON.stringify([s.substr(20), s.substr(2, -1), s.substr(NaN, 2), s.substr(1, Infinity)])); +console.log(JSON.stringify([s.substr(-Infinity, 2), s.substr(1.9, 2.9), "😀ab".substr(1, 2)])); +function tail(from: number, length?: number): string { + return s.substr(from, length); +} +console.log(tail(2), tail(2, 3), tail(-3, undefined)); + +function lower(text: string, locale?: string | string[]): string { + return text.toLocaleLowerCase(locale); +} +function upper(text: string, locale?: string | string[]): string { + return text.toLocaleUpperCase(locale); +} +const sample = "IİiıÍ ΣΑΣ Straße"; +for (const locale of [undefined, "en", "en-US", "de-DE", "TR", "tr-TR", "az", "tur", "zh-Hant-TW", "und", "en-u-co-phonebk", "en-x-tr"]) { + console.log(String(locale), lower(sample, locale), upper(sample, locale)); +} +console.log(lower(sample, ["tr", "en"]), lower(sample, ["en", "tr"]), lower(sample, [])); +console.log(upper("istanbul", ["az"]), lower("DİYARBAKIR"), lower("İx", "tr").length); +console.log("Dž".toLocaleUpperCase(), "ß".toLocaleUpperCase("de"), "ffi".toLocaleUpperCase("en")); + + +console.log(s.anchor('a"b'), s.big(), s.blink(), s.bold(), s.fixed()); +console.log(s.fontcolor("red"), s.fontsize(7), s.italics(), s.link("https://x/?q=\"1\"")); +console.log(s.small(), s.strike(), s.sub(), s.sup(), "".anchor("")); diff --git a/tests/corpus/6201-number-precision-exponential.ts b/tests/corpus/6201-number-precision-exponential.ts new file mode 100644 index 000000000..7c3d4eff8 --- /dev/null +++ b/tests/corpus/6201-number-precision-exponential.ts @@ -0,0 +1,34 @@ +// Number.prototype.toPrecision and toExponential(fractionDigits): the +// exact binary value rounds half up at the requested digit, exponential +// form takes over when the exponent is below -6 or reaches the precision, +// and the non-finite answers come before the digit-range RangeError. +const values = [0, -0, 1, -1, 2.5, -2.5, 0.5, 1.005, 1.45, 123.456, 0.000001234, 0.0000001234, 1e21, 1e-7, 5e-324, 1.7976931348623157e308, 123456789012345680000, 0.1, 99.99, 9.995, 1e23, -1234.5678]; +for (const v of values) { + const p = [1, 2, 3, 5, 10, 21].map((d) => v.toPrecision(d)); + const e = [0, 1, 2, 6, 20].map((d) => v.toExponential(d)); + console.log(String(v), p.join(" "), e.join(" ")); +} +console.log((123.456).toPrecision(), (123.456).toPrecision(undefined), (1 / 3).toPrecision(100)); +console.log((1 / 3).toExponential(100), (2 ** 70).toPrecision(25), (2 ** -30).toExponential(30)); +console.log(NaN.toPrecision(200), Infinity.toPrecision(0), (-Infinity).toExponential(-1), NaN.toExponential(101)); +console.log((15).toPrecision(1.9), (15).toExponential(1.9), (0.00001).toPrecision(1), (1e-6).toPrecision(2)); +for (const bad of [0, 101, -1, Infinity]) { + try { + console.log((1).toPrecision(bad)); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +for (const bad of [-1, 101, Infinity]) { + try { + console.log((1).toExponential(bad)); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +let n = 0; +for (let i = 1; i < 2000; i++) { + const x = i / 7 + i * 1e-9; + if (x.toPrecision(7) !== Number(x.toPrecision(7)).toPrecision(7)) n++; +} +console.log("stable", n); diff --git a/tests/corpus/6202-dyn-string-number-methods.js b/tests/corpus/6202-dyn-string-number-methods.js new file mode 100644 index 000000000..3f50a4636 --- /dev/null +++ b/tests/corpus/6202-dyn-string-number-methods.js @@ -0,0 +1,92 @@ +// String and Number methods on JS-inferred receivers: string receivers run +// the real method (string-width's codePointAt, camelcase's +// toLocaleLowerCase, toml's substr, pretty-bytes' toPrecision); other +// receivers keep JavaScript's answer for the name, an object's own method +// or Node's TypeError; arrays keep the runtime dispatch of shared names. +function width(text) { + let total = 0; + for (const character of text) { + const code = character.codePointAt(0); + total += code >= 0x1100 ? 2 : 1; + } + return total; +} +console.log(width("abc"), width("古"), width("👍x")); + +function describe(input) { + return [ + input.codePointAt(0), + input.codePointAt(-1), + input.codePointAt("1"), + input.substr(1), + input.substr(-2, 1), + input.toLocaleLowerCase(), + input.toLocaleUpperCase("en-US"), + input.toLocaleLowerCase("tr"), + input.trimLeft(), + input.trimRight(), + input.isWellFormed(), + input.toWellFormed().length, + ]; +} +console.log(JSON.stringify(describe(" Iİ😀 "))); +console.log(JSON.stringify(describe("\ud800Ab"))); +const parsed = JSON.parse('[" I\\u0130\\ud83d\\ude00 ", 1337, 0.000123456, [1, 2, 3]]'); +console.log(JSON.stringify(describe(parsed[0]))); + +function number(value, digits) { + return [value.toPrecision(digits), value.toExponential(digits), value.toPrecision()]; +} +console.log(JSON.stringify(number(1337, 3)), JSON.stringify(number(0.000123456, 2))); +console.log(JSON.stringify(number(-1.5, "4")), JSON.stringify(number(2.5, undefined))); +console.log(JSON.stringify(number(parsed[1], 3)), JSON.stringify(number(parsed[2], parsed[1] - 1335))); + +function prettyBytes(n) { + const units = ["B", "kB", "MB", "GB"]; + const exponent = Math.min(Math.floor(Math.log10(n) / 3), units.length - 1); + return Number((n / 1000 ** exponent).toPrecision(3)) + " " + units[exponent]; +} +console.log(prettyBytes(1337), prettyBytes(100), prettyBytes(12345678)); + +const own = { codePointAt(i) { return "own " + i; }, substr() { return "own substr"; }, toPrecision(d) { return "own " + d; } }; +console.log(describeOwn(own)); +function describeOwn(value) { + return [value.codePointAt(3), value.substr(), value.toPrecision(2)].join("|"); +} +function arrays(list) { + return JSON.stringify([list.at(-1), list.concat([4], 5)]); +} +console.log(arrays([1, 2, 3]), arrays(parsed[3])); + +function attempt(f) { + try { + return String(f()); + } catch (e) { + return e.name + ": " + e.message; + } +} +function fail(value) { + return [ + attempt(() => value.codePointAt(0)), + attempt(() => value.substr(0)), + attempt(() => value.toPrecision(2)), + attempt(() => value.toLocaleLowerCase()), + ].join(" / "); +} +for (const value of [42, "str", undefined, null, {}, parsed[3]]) console.log(fail(value)); +for (const value of JSON.parse('[42, "str", null, {}, true]')) console.log(fail(value)); +console.log(attempt(() => number(1, 200))); +console.log(attempt(() => "x".toLocaleUpperCase(null))); + +// A helper whose parameter the checker leaves untyped answers `string | +// undefined` natively; the guarded receiver still runs String methods. +function afterX(segment) { + const index = segment.indexOf("x"); + return index === -1 ? undefined : segment.slice(index + 1); +} +function tailCode(text) { + const rest = afterX(text); + if (rest === undefined) return -1; + return [rest.codePointAt(0), rest.substr(1), rest.toUpperCase(), rest.slice(1)].join(","); +} +console.log(tailCode("axé😀"), tailCode("abc")); diff --git a/tests/corpus/6203-any-string-number-methods.ts b/tests/corpus/6203-any-string-number-methods.ts new file mode 100644 index 000000000..202365e5e --- /dev/null +++ b/tests/corpus/6203-any-string-number-methods.ts @@ -0,0 +1,15 @@ +// String and Number methods through 'any'-typed values: string and number +// receivers run the real methods, other values keep JavaScript's answer. +const values: any[] = JSON.parse('["Straße İx", 1234.5678, {"substr": 1}, [1, 2]]'); +const [text, num, obj, list] = values; +console.log(text.codePointAt(7), text.substr(-2), text.toLocaleUpperCase(), text.toLocaleLowerCase("az")); +console.log(text.trimRight(), text.isWellFormed(), text.toLocaleLowerCase(["TR"])); +console.log(num.toPrecision(6), num.toExponential(3), num.toPrecision(), num.toExponential()); +console.log(list.at(-1), JSON.stringify(list.concat([3]))); +for (const call of [() => obj.substr(0), () => num.codePointAt(0), () => text.toPrecision(2)]) { + try { + console.log(call()); + } catch (e) { + console.log((e as Error).name + ": " + (e as Error).message); + } +} diff --git a/tests/diagnostics/dynamic-surface.ts b/tests/diagnostics/dynamic-surface.ts index 537a992eb..3af6671f4 100644 --- a/tests/diagnostics/dynamic-surface.ts +++ b/tests/diagnostics/dynamic-surface.ts @@ -4,11 +4,12 @@ // flag — never an ICE, never a link error. Static Math methods include // the scalar functions and variadic Math.hypot. The .split(string), // trim/pad variants, parseInt, isNaN, the global parseFloat/isFinite and -// Number.parseFloat/Number.parseInt over exactly-typed arguments, and -// string-pattern replacement compile statically now and no longer appear -// here.) +// Number.parseFloat/Number.parseInt over exactly-typed arguments, +// string-pattern replacement and toPrecision compile statically now and no +// longer appear here; toLocaleString without a locale still has no +// lowering.) const up = Math.cbrt(2); const tau = Math.PI * 2; -const price = (19.99).toPrecision(4); +const price = (19.99).toLocaleString(); const n = Number.parseFloat("3.14"); void n; diff --git a/tests/harness/__snapshots__/dynamic-surface.ts.txt b/tests/harness/__snapshots__/dynamic-surface.ts.txt index 2744d29e5..d4fe2ac7e 100644 --- a/tests/harness/__snapshots__/dynamic-surface.ts.txt +++ b/tests/harness/__snapshots__/dynamic-surface.ts.txt @@ -1,8 +1,8 @@ -dynamic-surface.ts:12:15 - error SC2012: '.toPrecision()' on numbers cannot compile statically +dynamic-surface.ts:13:15 - error SC2020: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet - 11 | const tau = Math.PI * 2; - 12 | const price = (19.99).toPrecision(4); - | ^~~~~~~~~~~~~~~~~~~~~~ - 13 | const n = Number.parseFloat("3.14"); + 12 | const tau = Math.PI * 2; + 13 | const price = (19.99).toLocaleString(); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 14 | const n = Number.parseFloat("3.14"); - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt b/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt deleted file mode 100644 index dfd5f0eaa..000000000 --- a/tests/harness/__snapshots__/retired-engine-1112-number-methods.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -retired-engine-1112-number-methods.ts:9:13 - error SC2012: '.toPrecision()' on numbers cannot compile statically - - 8 | console.log((1e21).toFixed(2), (0 / 0).toFixed(2)); - 9 | console.log(n.toPrecision(2), n.toPrecision(8), (0.000123).toPrecision(2)); - | ^~~~~~~~~~~~~~~~ - 10 | console.log((255).toString(16), (255).toString(2), (511).toString(8), (12345).toString(36)); - - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file From 8edd2bce8e0379c646c7ca2f39ffcac50f1a8932 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:15 -0700 Subject: [PATCH 02/12] Add crypto.getRandomValues nanoid fills its id bytes with `crypto.getRandomValues(buffer.subarray(...))`, which had no lowering. The WebCrypto global, node:crypto's export and `webcrypto.getRandomValues` now fill typed views in place and return them at their own type. Float views, DataViews and other values throw Node's TypeMismatchError DOMException, views over 65536 bytes QuotaExceededError, with Node's messages and codes. The fallback declarations gain the node:crypto export and webcrypto's member (the crypto global stays unknown in builds without @types/node). The runtime entry points are new functions reached only from these calls; the size check is integer arithmetic so no constant joins the pool every binary links. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 17 +++++++ .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/builtins/crypto.ts | 46 +++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 21 ++++++++ .../src/frontend/lowering/surfaces.ts | 2 + packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 5 ++ packages/compiler/src/ir/validate.ts | 6 ++- .../6204-crypto-get-random-values.ts.json | 6 +++ .../6205-crypto-get-random-values.js.json | 6 +++ packages/runtime/src/scr_crypto.h | 4 ++ packages/runtime/src/scr_lib.c | 49 +++++++++++++++++++ tests/corpus/6204-crypto-get-random-values.ts | 36 ++++++++++++++ tests/corpus/6205-crypto-get-random-values.js | 38 ++++++++++++++ 14 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json create mode 100644 tests/corpus/6204-crypto-get-random-values.ts create mode 100644 tests/corpus/6205-crypto-get-random-values.js diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index d72ca2dcd..1d4e11bba 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -2557,6 +2557,23 @@ declare module "crypto" { a: ArrayBufferView | ArrayBufferLike, b: ArrayBufferView | ArrayBufferLike, ): boolean; + /* WebCrypto's getRandomValues (also the webcrypto object's): integer + * typed arrays, filled in place and returned. The crypto GLOBAL stays + * unknown in fallback builds. */ + type RandomValuesArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | BigInt64Array + | BigUint64Array; + export function getRandomValues(typedArray: T): T; + export const webcrypto: { + getRandomValues(typedArray: T): T; + }; export function randomFillSync( buffer: T, offset?: number, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 170ce07db..a7eecb7b3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -570,6 +570,8 @@ export const LIB_FN_SYMS: Record = { "crypto.randomIntMax": "scr_crypto_random_int_max", "crypto.randomFillView": "scr_crypto_random_fill", "crypto.randomFillViewRest": "scr_crypto_random_fill_rest", + "crypto.getRandomValues": "scr_crypto_get_random_values", + "crypto.getRandomValuesDyn": "scr_crypto_get_random_values_dyn", "crypto.randomFillSyncDyn": "scr_crypto_random_fill_sync_dyn", "crypto.randomUUIDOpts": "scr_crypto_random_uuid_opts", "crypto.createSecretKey": "scr_crypto_create_secret_key", diff --git a/packages/compiler/src/frontend/lowering/builtins/crypto.ts b/packages/compiler/src/frontend/lowering/builtins/crypto.ts index a492a0da7..e029d1c29 100644 --- a/packages/compiler/src/frontend/lowering/builtins/crypto.ts +++ b/packages/compiler/src/frontend/lowering/builtins/crypto.ts @@ -261,6 +261,7 @@ export function lowerCryptoModuleCall( if (args.some(ts.isSpreadElement)) { lowerer.noLowering(`crypto.${bi.member} with spread arguments`, expr); } + if (bi.member === "getRandomValues") return lowerCryptoGetRandomValues(lowerer, expr); if (bi.member === "randomBytes") { if (args.length === 1) return null; if (args.length !== 2) { @@ -1264,3 +1265,48 @@ export function lowerPromisifiedCryptoCall( "KeyObject", ); } + +/** `crypto.getRandomValues(view)` — the WebCrypto global, node:crypto's + * export and `webcrypto.getRandomValues`: a typed view fills in place and + * returns itself at its own type (float views, DataViews and views over + * 65536 bytes throw Node's DOMExceptions at run time); any other value + * takes the checked form. Null for other arities (the member fence keeps + * them). */ +export function lowerCryptoGetRandomValues( + lowerer: Lowerer, + call: ts.CallExpression, +): IrExpr | null { + if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) return null; + const loc = locOf(call); + const value = lowerer.lowerExpr(call.arguments[0]!); + if (value.type.kind === "bytes") + return { kind: "libCall", fn: "crypto.getRandomValues", args: [value], type: value.type, loc }; + return { + kind: "libCall", + fn: "crypto.getRandomValuesDyn", + args: [lowerer.coerceInto(call.arguments[0]!, value, DYN)], + type: DYN, + loc, + }; +} + +/** Whether `receiver` names a WebCrypto Crypto object: the `crypto` + * global (also through globalThis) or node:crypto's `webcrypto`. */ +export function isWebCryptoReceiver(lowerer: Lowerer, receiver: ts.Expression): boolean { + if (ts.isIdentifier(receiver)) { + if (receiver.text === "crypto" && lowerer.isStdlibGlobal(receiver, "crypto")) return true; + const imported = lowerer.builtinImportOf(receiver); + return imported?.module === "crypto" && imported.member === "webcrypto"; + } + if (ts.isPropertyAccessExpression(receiver) && !receiver.questionDotToken) { + if ( + receiver.name.text === "crypto" && + ts.isIdentifier(receiver.expression) && + lowerer.isStdlibGlobal(receiver.expression, "globalThis") + ) + return true; + const member = lowerer.builtinMemberOf(receiver); + return member?.module === "crypto" && member.member === "webcrypto"; + } + return false; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index ca4dd0edb..d19dd1d50 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -110,7 +110,9 @@ import { lowerRequireResolveCall, } from "./builtins/module-resolution.js"; import { + isWebCryptoReceiver, lowerCipherMethodCall, + lowerCryptoGetRandomValues, lowerCryptoHashMethodCall, lowerPromisifiedCryptoCall, } from "./builtins/crypto.js"; @@ -1403,6 +1405,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerer.fenceStaticReadableStreamMember(expr.expression, "call") ?? lowerDisposableStackUseCall(lowerer, expr, expr.expression) ?? lowerer.lowerStdlibMethodCall(expr, expr.expression) ?? + // crypto.getRandomValues on the WebCrypto global (untyped in + // fallback builds, so before the dyn receiver path) or webcrypto. + lowerWebCryptoGetRandomValues(lowerer, expr, expr.expression) ?? // Dyn receivers (JSON.parse-derived `unknown`/`any` values) — lowerDynReceiverMethodCall(lowerer, expr, expr.expression) ?? @@ -9728,3 +9733,19 @@ export function lowerStaticNumberGlobal( } return null; } + +/** `crypto.getRandomValues(view)` on the WebCrypto global or webcrypto. */ +function lowerWebCryptoGetRandomValues( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if ( + access.name.text !== "getRandomValues" || + call.questionDotToken || + access.questionDotToken || + !isWebCryptoReceiver(lowerer, access.expression) + ) + return null; + return lowerCryptoGetRandomValues(lowerer, call); +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index d394c1960..6bea581fe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1521,6 +1521,7 @@ export const BUILTIN_MODULE_FNS: Record< hash: { fn: "crypto.hashDigestStr", params: [STRING, STRING, STRING], result: STRING }, timingSafeEqual: { fn: "crypto.timingSafeEqual", params: [BYTES_U8, BYTES_U8], result: BOOL }, randomFillSync: { fn: "crypto.randomFill", params: [BYTES_U8, F64, F64], result: BYTES_U8 }, + getRandomValues: { fn: "crypto.getRandomValues", params: [BYTES_U8], result: BYTES_U8 }, randomInt: { fn: "crypto.randomInt", params: [F64, F64], result: F64 }, randomFill: { fn: "crypto.randomFillCb", params: [], result: VOID }, randomUUIDv7: { fn: "crypto.randomUUIDv7", params: [DYN], result: STRING }, @@ -2069,6 +2070,7 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< "crypto.randomFillPromiseDyn", ], timingSafeEqual: ["crypto.timingSafeEqualDyn"], + getRandomValues: ["crypto.getRandomValuesDyn"], randomInt: ["crypto.randomIntMax", "crypto.randomIntCb", "crypto.randomIntPromise"], randomUUID: ["crypto.randomUUIDOpts"], }, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index b537923a9..3bebd98ae 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -670,6 +670,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "crypto.randomIntMax", "crypto.randomFillView", "crypto.randomFillViewRest", + "crypto.getRandomValues", + "crypto.getRandomValuesDyn", "crypto.randomFillSyncDyn", "crypto.randomFillCb", "crypto.randomFillCbDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index ab4d0ba7f..b2b6b0350 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2579,6 +2579,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * whether min was given. */ "crypto.randomFillView": { argTypes: [null, F64, F64], result: BYTES_U8 }, "crypto.randomFillViewRest": { argTypes: [null, F64], result: BYTES_U8 }, + /** crypto.getRandomValues: a view returns itself at its own type (the + * float/size DOMExceptions are thrown at run time); any other value + * takes the checked form. */ + "crypto.getRandomValues": { argTypes: [null], result: BYTES_U8 }, + "crypto.getRandomValuesDyn": { argTypes: [DYN], result: DYN }, "crypto.randomFillSyncDyn": { argTypes: [DYN, DYN, DYN], result: DYN }, "crypto.randomFillCb": { argTypes: [null, F64, F64, F64, null], result: VOID }, "crypto.randomFillCbDyn": { argTypes: [DYN, DYN, DYN, F64, null], result: VOID }, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index d10160115..68d2ca6a1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5211,7 +5211,11 @@ function validateFunction( } function checkLibSpecialCall(e: IrExpr & { kind: "libCall" }): boolean { - if (e.fn === "crypto.randomFillView" || e.fn === "crypto.randomFillViewRest") { + if ( + e.fn === "crypto.randomFillView" || + e.fn === "crypto.randomFillViewRest" || + e.fn === "crypto.getRandomValues" + ) { // The filled view is the result, at its own element type. const view = e.args[0]?.type; if (view?.kind !== "bytes" || !typeEquals(e.type, view)) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json new file mode 100644 index 000000000..86fcbcf58 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6204-crypto-get-random-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6204-crypto-get-random-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json new file mode 100644 index 000000000..42d90fd62 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6205-crypto-get-random-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6205-crypto-get-random-values.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_crypto.h b/packages/runtime/src/scr_crypto.h index 5abfeaa48..ff920a6a1 100644 --- a/packages/runtime/src/scr_crypto.h +++ b/packages/runtime/src/scr_crypto.h @@ -46,6 +46,10 @@ ScrBytes *scr_crypto_random_fill_rest(ScrBytes *bytes, double offset); double scr_crypto_random_int(double min, double max); double scr_crypto_random_int_max(double max); ScrDyn *scr_crypto_random_fill_sync_dyn(ScrDyn *buf, ScrDyn *offset, ScrDyn *size); +/* crypto.getRandomValues over a view (returned +1) or any value (the + * value itself, +1); NULL with Node's DOMException pending. */ +ScrBytes *scr_crypto_get_random_values(ScrBytes *view); +ScrDyn *scr_crypto_get_random_values_dyn(ScrDyn *value); double scr_crypto_random_fill_args(ScrBytes *view, bool typed_array, double offset, double size, int form); void scr_crypto_random_int_refill(void); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 45d9b7e2b..3c69f84d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -11176,3 +11176,52 @@ ScrDyn *scr_dyn_native_date_from_ms(double ms) { scr_native_date_release(date); return result; } + +/* crypto.getRandomValues(typedArray) (WebCrypto, also node:crypto's + * export): integer typed arrays only (Node's TypeMismatchError DOMException + * for float arrays, DataViews and every other value), at most 65536 bytes + * (QuotaExceededError), filled in place and returned. Nothing on an + * existing path calls these. They stay at the end of the unit: clang emits + * a static helper (scr_random_write) where its first emitted caller + * appears, and an earlier caller would move it in every binary that links + * the random fills. */ +ScrBytes *scr_crypto_get_random_values(ScrBytes *view) { + static const char mismatch[] = "TypeMismatchError"; + static const char mismatch_msg[] = "The data argument must be an integer-type TypedArray"; + static const char quota[] = "QuotaExceededError"; + static const char quota_msg[] = "The requested length exceeds 65,536 bytes"; + if (view->is_data_view || view->elem == SCR_BYTES_F32 || view->elem == SCR_BYTES_F64 || + view->elem == SCR_BYTES_F16) { + scr_throw_domex(mismatch, mismatch_msg); + return NULL; + } + /* Integer arithmetic: a new floating-point literal would land in the + * merged constant pool every binary links. */ + size_t length = view->len * (size_t)scr_bytes_elem_size(view->elem); + if (length > 65536u) { + scr_throw_domex(quota, quota_msg); + return NULL; + } + scr_random_write(view, 0, length); + return scr_bytes_retain(view); +} + +ScrDyn *scr_crypto_get_random_values_dyn(ScrDyn *value) { + if (value->kind == SCR_DYN_TYPED_REF) { + ScrDyn *materialized = scr_dyn_typed_ref_materialize(value); + if (!materialized) return NULL; + ScrDyn *result = scr_crypto_get_random_values_dyn(materialized); + scr_dyn_release(materialized); + return result ? scr_dyn_retain(value) : NULL; + } + if (value->kind != SCR_DYN_BYTES) { + static const char mismatch[] = "TypeMismatchError"; + static const char mismatch_msg[] = "The data argument must be an integer-type TypedArray"; + scr_throw_domex(mismatch, mismatch_msg); + return NULL; + } + ScrBytes *filled = scr_crypto_get_random_values(value->v.bytes); + if (!filled) return NULL; + scr_bytes_release(filled); + return scr_dyn_retain(value); +} diff --git a/tests/corpus/6204-crypto-get-random-values.ts b/tests/corpus/6204-crypto-get-random-values.ts new file mode 100644 index 000000000..fae3c4049 --- /dev/null +++ b/tests/corpus/6204-crypto-get-random-values.ts @@ -0,0 +1,36 @@ +// crypto.getRandomValues through node:crypto's export and webcrypto: +// integer typed arrays fill in place and come back as the same object; +// views over 65536 bytes throw QuotaExceededError. (The global spelling and +// the float/DataView TypeMismatchError are 6205's.) +import { getRandomValues, webcrypto } from "node:crypto"; +import * as nodeCrypto from "node:crypto"; + +const bytes = new Uint8Array(4096); +const same = webcrypto.getRandomValues(bytes); +function nonzero(view: Uint8Array): boolean { + for (let i = 0; i < view.length; i++) if (view[i] !== 0) return true; + return false; +} +function nonzeroWords(view: Uint32Array): boolean { + for (let i = 0; i < view.length; i++) if (view[i] !== 0) return true; + return false; +} +console.log(same === bytes, bytes.length, nonzero(bytes)); +const words = new Uint32Array(256); +console.log(getRandomValues(words) === words, nonzeroWords(words)); +const shorts = new Int16Array(8); +console.log(webcrypto.getRandomValues(shorts) === shorts, nodeCrypto.getRandomValues(new Uint8ClampedArray(3)).length); +console.log(nodeCrypto.webcrypto.getRandomValues(new Int8Array(65536)).length, getRandomValues(new Uint8Array(0)).length); +const buf = Buffer.alloc(16); +console.log(Buffer.isBuffer(getRandomValues(buf)), buf.subarray(4, 8).length); + +function attempt(label: string, f: () => unknown): void { + try { + f(); + console.log(label, "ok"); + } catch (e) { + console.log(label, (e as DOMException).name, (e as DOMException).code, (e as Error).message, e instanceof DOMException); + } +} +attempt("big", () => getRandomValues(new Uint8Array(65537))); +attempt("big32", () => webcrypto.getRandomValues(new Uint32Array(16385))); diff --git a/tests/corpus/6205-crypto-get-random-values.js b/tests/corpus/6205-crypto-get-random-values.js new file mode 100644 index 000000000..97d3e67d5 --- /dev/null +++ b/tests/corpus/6205-crypto-get-random-values.js @@ -0,0 +1,38 @@ +// nanoid's fillRandom: crypto.getRandomValues over Buffer windows of an +// untyped helper, plus non-view values that throw TypeMismatchError. +import { getRandomValues } from "node:crypto"; +const GET_RANDOM_LIMIT = 65536; +function fillRandom(buffer) { + let from = 0; + while (from < buffer.length) { + const to = Math.min(from + GET_RANDOM_LIMIT, buffer.length); + crypto.getRandomValues(buffer.subarray(from, to)); + from = to; + } +} +const alphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; +function nanoid(size = 21) { + const bytes = Buffer.allocUnsafe(size); + fillRandom(bytes); + let id = ""; + for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63]; + return id; +} +const id = nanoid(); +console.log(id.length, /^[A-Za-z0-9_-]+$/.test(id), nanoid(10).length, nanoid(70000).length); +const filled = globalThis.crypto.getRandomValues(new Uint16Array(3)); +console.log(filled.length); +for (const value of [new Float64Array(1), new Float32Array(2), new DataView(new ArrayBuffer(2)), new Uint8Array(65537), [1, 2], "x", 5, {}]) { + try { + crypto.getRandomValues(value); + console.log("ok"); + } catch (e) { + console.log(e.name, e.message); + } +} +const untyped = JSON.parse("[1]"); +try { + getRandomValues(untyped); +} catch (e) { + console.log(e.name, e.code, e.message); +} From 9e63a937db52bafd616ce56aaa8bd0e4f201c06f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:18 -0700 Subject: [PATCH 03/12] Test the Buffer brand at run time and alias the buffer module's Buffer `Buffer.isBuffer` over a Uint8Array-typed value (safe-buffer's `Buffer.from(...)` result, etag's input) refused at compile time because the bytes IR shares storage between Buffer and Uint8Array. The view keeps its Buffer brand at run time, so the test now reads it. `require('buffer').Buffer`, `const { Buffer } = require('buffer')` and `buffer.Buffer` through a namespace binding alias the global Buffer (the module member is the same constructor), so their statics lower like the global's. A file-scope `var` of the namespace form aliases too when nothing reassigns it and the require-binding early-reference analysis finds no earlier reader. `Buffer.from` over a JS-inferred `string | Uint8Array | undefined` value takes the checked runtime form. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/containers/bytes.ts | 44 ++++++++++++++++++- .../src/frontend/lowering/lower-modules.ts | 2 + .../src/frontend/lowering/lower-stmts.ts | 25 +++++++++++ .../src/frontend/lowering/surfaces.ts | 30 +++++++++++-- .../compiler/src/ir/builtin-signatures.ts | 3 ++ .../6206-buffer-is-buffer-views.ts.json | 6 +++ .../corpus/6209-buffer-module-alias.cjs.json | 6 +++ packages/runtime/src/scr_bytes.c | 3 ++ packages/runtime/src/scr_bytes.h | 2 + tests/corpus/6206-buffer-is-buffer-views.ts | 16 +++++++ tests/corpus/6209-buffer-module-alias.cjs | 25 +++++++++++ 12 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json create mode 100644 tests/corpus/6206-buffer-is-buffer-views.ts create mode 100644 tests/corpus/6209-buffer-module-alias.cjs diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index a7eecb7b3..749a68e9f 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -406,6 +406,7 @@ export const LIB_FN_SYMS: Record = { "buffer.fromStr": "scr_bytes_from_str", "buffer.fromStrChecked": "scr_bytes_from_str_checked", "buffer.brand": "scr_bytes_as_buffer", + "buffer.isBranded": "scr_bytes_is_buffer", "buffer.fromDyn": "scr_buffer_from_dyn", "buffer.concat": "scr_buffer_concat", "buffer.concatLen": "scr_buffer_concat_len", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 7a19927de..235863efb 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -20,6 +20,7 @@ import { arrayOf, bytesOf, funcOf, + isUnitType, typeEquals, } from "../../../ir/ir.js"; import { isJsSourceFile, locOf } from "../../program.js"; @@ -1408,7 +1409,14 @@ function lowerBufferStaticValue( } return { kind: "libCall", fn, args: [s, enc], type: BYTES_U8, loc }; } - if (args.length === 1 && srcIr?.kind === "bytes" && srcIr.elem === "u8") { + // A checker type with no mapping (pako's conditional `O extends + // { toText: true } ? string : Uint8Array` result) whose lowered + // value is a u8 view copies like the typed form. + if ( + args.length === 1 && + ((srcIr?.kind === "bytes" && srcIr.elem === "u8") || + (srcIr === null && value.type.kind === "bytes" && value.type.elem === "u8")) + ) { return { kind: "libCall", fn: "buffer.poolFromBytes", @@ -1426,6 +1434,34 @@ function lowerBufferStaticValue( loc, }; } + // A union of strings, u8 views and undefined/null (a JS-inferred + // npm result such as pako's deflate): the checked form dispatches + // on the runtime kind exactly as for an untyped JS value, Node's + // TypeError for the unit arms included. + if ( + args.length === 1 && + value.type.kind === "union" && + lowerer.dynConvertible(value.type) && + lowerer.unions + .get(value.type.unionId) + ?.arms.every( + (arm) => + arm.kind === "string" || + (arm.kind === "bytes" && arm.elem === "u8") || + isUnitType(arm), + ) === true + ) { + return { + kind: "libCall", + fn: "buffer.poolFromDyn", + args: [ + lowerer.coerceToExpected(value, DYN), + { kind: "strLit", value: "utf8", type: STRING, loc }, + ], + type: BYTES_U8, + loc, + }; + } } } lowerer.noLowering( @@ -1626,6 +1662,12 @@ function lowerBufferStaticValue( }; } } + // A typed view of unknown provenance (a Uint8Array parameter, + // safe-buffer's Buffer.from result): Buffer is a brand of the view, + // which the runtime keeps, so the test reads it. + if (v.type.kind === "bytes") { + return { kind: "libCall", fn: "buffer.isBranded", args: [v], type: BOOL, loc }; + } lowerer.noLowering( `Buffer.isBuffer of '${lowerer.fmt(v.type)}' values`, args[0]!, diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3baab9dd0..0edb586bc 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -102,6 +102,7 @@ import { isVarDeclared, numericIteratorSourceOf, provenanceElidedConstDecl, + stdlibBufferVarAlias, } from "./lower-stmts.js"; import { objectFactoryGlobalType } from "./object-factory-new.js"; import { streamClassAliasDecl } from "./lower-stream.js"; @@ -1805,6 +1806,7 @@ export function collectGlobals( // same test). const stableStdlibAlias = isConst || + stdlibBufferVarAlias(lowerer, decl) || ((list.flags & ts.NodeFlags.Let) !== 0 && ts.isIdentifier(decl.name) && stdlibGlobalAliasNameOf(lowerer, decl.initializer) !== null && diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef..dba32b854 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -67,6 +67,7 @@ import { isJsSourceFile, isNodeEsmFile, locOf, + requireEarlyReference, requireSpecOf, } from "../program.js"; import { @@ -78,6 +79,7 @@ import { sideEffectFreeOptionValue, stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, + bufferNamespaceMember, stdlibGlobalNameOf, } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; @@ -4385,6 +4387,7 @@ export function lowerVarDecl( // alias plumbing (see stdlibGlobalAliasDecl), no storage, no code. const stableStdlibAlias = !isLet || + stdlibBufferVarAlias(lowerer, decl) || (ts.isVariableDeclarationList(decl.parent) && (decl.parent.flags & ts.NodeFlags.Let) !== 0 && ts.isIdentifier(decl.name) && @@ -10521,3 +10524,25 @@ export function lowerForStatement(lowerer: Lowerer, stmt: ts.ForStatement): IrSt lowerer.scopes.pop(); } } + +/** safe-buffer's `var Buffer = buffer.Buffer` at file scope: a `var` alias + * of the global Buffer is the same alias plumbing as the const spelling + * when nothing reassigns it and no code that runs before the declaration + * can read it (the require-binding early-reference analysis), so the + * hoisted undefined is unobservable. */ +export function stdlibBufferVarAlias(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if ( + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(decl.parent) || + (decl.parent.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0 || + decl.initializer === undefined || + !bufferNamespaceMember(lowerer, decl.initializer) + ) + return false; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + return ( + symbol !== undefined && + bindingNeverReassigned(lowerer, symbol, decl) && + requireEarlyReference(lowerer.program, decl) === null + ); +} diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6bea581fe..6d7da7b38 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -22,7 +22,7 @@ import { VOID, arrayOf, } from "../../ir/ir.js"; -import { isNodeTypesPath, requireSpecOf } from "../program.js"; +import { isJsSourceFile, isNodeTypesPath, requireSpecOf } from "../program.js"; import { ZLIB_CONSTANTS } from "./zlib-constants.js"; /** zlib stream instances: runtime Transform streams (scr_zlib_stream.c). */ @@ -3243,7 +3243,21 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin // `Buffer` resolves to a user-file declaration and still misses. const resolved = symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; - if (!lowerer.isStdlibSymbol(resolved)) return null; + if (!lowerer.isStdlibSymbol(resolved)) { + // A CommonJS binding of the buffer module's Buffer (`var Buffer = + // require('buffer').Buffer`, `const { Buffer } = require('buffer')` + // — safe-buffer and compression): the module member IS the global + // constructor. + if ( + decl !== undefined && + (ts.isVariableDeclaration(decl) || ts.isBindingElement(decl)) && + isJsSourceFile(decl.getSourceFile()) + ) { + const imported = lowerer.builtinImportOf(expr); + if (imported?.module === "buffer" && imported.member === "Buffer") return "Buffer"; + } + return null; + } return resolved.name === "global" ? "globalThis" : resolved.name; } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { @@ -3318,7 +3332,9 @@ export function stdlibGlobalAliasNameOf( const name = requireSpec === "process" || requireSpec === "node:process" ? "process" - : stdlibGlobalNameOf(lowerer, aliasExpr); + : bufferNamespaceMember(lowerer, aliasExpr) + ? "Buffer" + : stdlibGlobalNameOf(lowerer, aliasExpr); if (name === null) return null; // These globals have provenance-aware call or member lowering, so a // stable snapshot keeps the same dispatch as the original global. @@ -3339,6 +3355,14 @@ export function stdlibGlobalAliasNameOf( : null; } +/** `buffer.Buffer` through a buffer-module namespace binding (`var buffer + * = require('buffer')`): the module's Buffer IS the global constructor. */ +export function bufferNamespaceMember(lowerer: Lowerer, expr: ts.Expression): boolean { + if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== "Buffer") return false; + const member = lowerer.builtinMemberOf(expr); + return member?.module === "buffer" && member.member === "Buffer"; +} + export function stdlibGlobalAliasDecl( lowerer: Lowerer, nameNode: ts.Node, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index b2b6b0350..748029589 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2634,6 +2634,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "buffer.fromStr": { argTypes: [STRING, STRING], result: BYTES_U8 }, "buffer.fromStrChecked": { argTypes: [STRING, STRING], result: BYTES_U8 }, "buffer.brand": { argTypes: [BYTES_U8], result: BYTES_U8 }, + /** Buffer.isBuffer over a typed view: whether the view carries the + * Buffer brand (any element kind; only Buffers do). Never throws. */ + "buffer.isBranded": { argTypes: [null], result: BOOL }, /** Buffer.from on checked-native strings, bytes, arrays and data-only * array-like/Buffer-JSON objects. The encoding is a normalized literal; * non-string inputs ignore it. Copies the input and may throw during diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json new file mode 100644 index 000000000..7a9c25279 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6206-buffer-is-buffer-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6206-buffer-is-buffer-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json new file mode 100644 index 000000000..3d3c194b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6209-buffer-module-alias.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6209-buffer-module-alias.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index 28258a29c..01a3be609 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -293,6 +293,9 @@ ScrBytes *scr_bytes_as_buffer(ScrBytes *bytes) { return scr_bytes_retain(bytes); } +/* Nothing on an existing path calls this. */ +bool scr_bytes_is_buffer(const ScrBytes *bytes) { return bytes->is_buffer; } + void scr_bytes_release(ScrBytes *b) { if (!b || b->rc == SIZE_MAX) return; /* NULL: an uninitialized `let` local */ if (--b->rc == 0) { diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index 4309154c9..bdc5e5615 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -159,6 +159,8 @@ ScrBytes *scr_bytes_new(ScrBytesElem elem, double n); /* +1 */ /* Shared u8 view over exactly the source view's raw bytes. Borrows; +1. */ ScrBytes *scr_bytes_raw_view(ScrBytes *bytes); ScrBytes *scr_bytes_as_buffer(ScrBytes *bytes); /* borrows; +1, Buffer factory only */ +/* Buffer.isBuffer over a typed view: the view's Buffer brand. Borrows. */ +bool scr_bytes_is_buffer(const ScrBytes *bytes); /* Views retain the root allocation; offsets are relative to its complete * byte storage, including when src is itself a subarray. */ ScrBytes *scr_bytes_buffer_view(ScrBytes *src, ScrBytesElem elem, diff --git a/tests/corpus/6206-buffer-is-buffer-views.ts b/tests/corpus/6206-buffer-is-buffer-views.ts new file mode 100644 index 000000000..55e9fd49c --- /dev/null +++ b/tests/corpus/6206-buffer-is-buffer-views.ts @@ -0,0 +1,16 @@ +// Buffer.isBuffer over Uint8Array-typed values reads the view's Buffer +// brand: Buffer factories, Buffer subarrays and slices are Buffers, plain +// typed arrays and copies into them are not. +function check(view: Uint8Array): boolean { + return Buffer.isBuffer(view); +} +const plain = new Uint8Array([1, 2, 3]); +const buffer = Buffer.from(plain); +console.log(check(plain), check(buffer), check(Buffer.alloc(2)), check(Buffer.from("hey").subarray(1))); +console.log(check(plain.subarray(1)), check(new Uint8Array(buffer)), check(buffer.slice(1)), check(Buffer.concat([plain]))); +const views: Uint8Array[] = [plain, buffer, new TextEncoder().encode("x"), Buffer.allocUnsafe(1)]; +console.log(views.map((v) => Buffer.isBuffer(v)).join(",")); +function words(view: Uint32Array): boolean { + return Buffer.isBuffer(view); +} +console.log(words(new Uint32Array(2)), Buffer.from(plain).toString("hex"), Buffer.from(views[2]!).toString()); diff --git a/tests/corpus/6209-buffer-module-alias.cjs b/tests/corpus/6209-buffer-module-alias.cjs new file mode 100644 index 000000000..cec2d06d8 --- /dev/null +++ b/tests/corpus/6209-buffer-module-alias.cjs @@ -0,0 +1,25 @@ +// The buffer module's Buffer through CommonJS bindings — `var Buffer = +// require('buffer').Buffer` and a member of a stored require — is the +// global Buffer constructor, with its statics. +var Buffer = require("buffer").Buffer; +var buffer = require("buffer"); +var B2 = buffer.Buffer; +const B3 = require("node:buffer").Buffer; +console.log(Buffer.from("hi").toString("hex"), Buffer.isBuffer(Buffer.alloc(1)), B2.byteLength("héllo"), B2.isBuffer("x")); +console.log(B3.concat([B2.from("a"), Buffer.from([98])]).toString(), B3.alloc(2, 1).toString("base64")); +function toBuffer(chunk) { + return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); +} +console.log(toBuffer("abc").length, toBuffer(Buffer.from("xyz")).toString()); + +// Buffer.from over a JS-inferred `string | Uint8Array | undefined` result +// dispatches on the runtime kind, Node's TypeError for undefined included. +function make(flag) { + return flag ? "ab" : flag === false ? new Uint8Array([99, 100]) : undefined; +} +console.log(Buffer.from(make(true)).toString(), Buffer.from(make(false)).toString("hex")); +try { + Buffer.from(make(null)); +} catch (e) { + console.log(e.name, e.code); +} From ffd13d97510d6756a2907731094ac0413bc8f694 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:21 -0700 Subject: [PATCH 04/12] Read fs.constants from the target libc Only the access-mode and COPYFILE bits of fs.constants lowered; every other member and the object itself refused. The runtime now builds node_constants.cc's fs group in its order under the target libc's #ifdef guards (O_NOATIME and O_DIRECT on Linux, O_SYMLINK on macOS) as a cached null-prototype object with read-only members. `fs.constants` as a value reads it, and members beyond the baked literals read from it (undefined for a constant the target lacks, in JavaScript sources). The fallback declarations list the full set. The table keeps each name inside its entry, so the whole table is one data object section GC drops. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 41 +++++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/builtins/constants.ts | 36 +++- .../src/frontend/lowering/lower-exprs.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 20 +-- .../src/frontend/lowering/surfaces.ts | 9 + .../compiler/src/ir/builtin-signatures.ts | 2 + .../corpus/6207-fs-constants-object.ts.json | 6 + .../corpus/6208-fs-constants-require.cjs.json | 6 + packages/runtime/src/scr_os.c | 170 ++++++++++++++++++ packages/runtime/src/scr_os.h | 2 + tests/corpus/6207-fs-constants-object.ts | 16 ++ tests/corpus/6208-fs-constants-require.cjs | 8 + 13 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json create mode 100644 tests/corpus/6207-fs-constants-object.ts create mode 100644 tests/corpus/6208-fs-constants-require.cjs diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1d4e11bba..ff7921e73 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1493,6 +1493,47 @@ declare module "node:fs" { readonly COPYFILE_EXCL: number; readonly COPYFILE_FICLONE: number; readonly COPYFILE_FICLONE_FORCE: number; + readonly UV_FS_SYMLINK_DIR: number; + readonly UV_FS_SYMLINK_JUNCTION: number; + readonly UV_DIRENT_UNKNOWN: number; + readonly UV_DIRENT_FILE: number; + readonly UV_DIRENT_DIR: number; + readonly UV_DIRENT_LINK: number; + readonly UV_DIRENT_FIFO: number; + readonly UV_DIRENT_SOCKET: number; + readonly UV_DIRENT_CHAR: number; + readonly UV_DIRENT_BLOCK: number; + readonly UV_FS_O_FILEMAP: number; + readonly UV_FS_COPYFILE_EXCL: number; + readonly UV_FS_COPYFILE_FICLONE: number; + readonly UV_FS_COPYFILE_FICLONE_FORCE: number; + readonly S_IFMT: number; + readonly S_IFREG: number; + readonly S_IFDIR: number; + readonly S_IFCHR: number; + readonly S_IFBLK: number; + readonly S_IFIFO: number; + readonly S_IFLNK: number; + readonly S_IFSOCK: number; + readonly O_NOCTTY: number; + readonly O_DIRECTORY: number; + readonly O_NOATIME: number; + readonly O_SYNC: number; + readonly O_DSYNC: number; + readonly O_SYMLINK: number; + readonly O_DIRECT: number; + readonly S_IRWXU: number; + readonly S_IRUSR: number; + readonly S_IWUSR: number; + readonly S_IXUSR: number; + readonly S_IRWXG: number; + readonly S_IRGRP: number; + readonly S_IWGRP: number; + readonly S_IXGRP: number; + readonly S_IRWXO: number; + readonly S_IROTH: number; + readonly S_IWOTH: number; + readonly S_IXOTH: number; }; /* A stat(2) snapshot (statSync follows symlinks, lstatSync does not — * Node's split) — immutable; the supported surface is exactly these diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 749a68e9f..210881932 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -198,6 +198,7 @@ export const LIB_FN_SYMS: Record = { "os.cpusModel": "scr_os_cpus_model", "os.cpusField": "scr_os_cpus_field", "os.constants": "scr_os_constants", + "fs.constants": "scr_fs_constants", "os.release": "scr_os_release", "os.userName": "scr_os_user_name", "os.userShell": "scr_os_user_shell", diff --git a/packages/compiler/src/frontend/lowering/builtins/constants.ts b/packages/compiler/src/frontend/lowering/builtins/constants.ts index 0325a0ef7..b435f4634 100644 --- a/packages/compiler/src/frontend/lowering/builtins/constants.ts +++ b/packages/compiler/src/frontend/lowering/builtins/constants.ts @@ -1,11 +1,11 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer, own } from "../lowerer.js"; -import { canonicalBuiltinModule, locOf, requireSpecOf } from "../../program.js"; +import { canonicalBuiltinModule, isJsSourceFile, locOf, requireSpecOf } from "../../program.js"; import { builtinConstLit } from "../surfaces.js"; import { HTTP2_CONSTANTS } from "../http2-constants.js"; import { CRYPTO_CONSTANTS } from "../crypto-tables.js"; import { ZLIB_CONSTANTS } from "../zlib-constants.js"; -import { F64, type IrExpr } from "../../../ir/ir.js"; +import { DYN, F64, type IrExpr, STRING, type SrcLoc } from "../../../ir/ir.js"; import { builtinImportOf } from "./module-bindings.js"; /** The builtin modules whose `constants` object bakes as literals at @@ -147,12 +147,30 @@ export function lowerFsConstantsProperty( COPYFILE_FICLONE_FORCE: 4, }; const value = own(MODES, expr.name.text); - if (value === undefined) { - lowerer.noLowering( - `fs.constants.${expr.name.text}`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + if (value === undefined) return fsConstantMemberRead(expr); return { kind: "numLit", value, type: F64, loc: locOf(expr) }; } + +/** fs.constants as a value: the target's null-prototype constants object, + * built by the runtime from its own libc's macros. */ +export function fsConstantsObject(loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; +} + +/** `fs.constants.O_RDONLY` (any member the access-mode literals do not + * cover): a read of the runtime object, so a constant the target lacks + * reads as undefined in JavaScript and fails the number check in + * TypeScript, whose declarations promise every name. */ +export function fsConstantMemberRead(expr: ts.PropertyAccessExpression): IrExpr { + const loc = locOf(expr); + const read: IrExpr = { + kind: "dynKeyGet", + value: fsConstantsObject(loc), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + return isJsSourceFile(expr.getSourceFile()) + ? read + : { kind: "dynCheck", value: read, type: F64, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d4d03591d..8c8b1c09b 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1253,6 +1253,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (bi.module === "os" && bi.member === "constants") { return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc }; } + if (bi.module === "fs" && bi.member === "constants") + return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; // tls.rootCertificates: a runtime-valued module constant (the // cached bundled-CA array) — the one member read that lowers // to a libCall instead of a baked literal. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bf4ed5464..65f1efbe9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -444,6 +444,8 @@ import { lowerConsoleNew } from "./lower-console.js"; import { lowerNumberStaticCall, lowerNumberStaticProperty } from "./builtins/numbers.js"; import { dateJsonType, isDateJsonSource, lowerDateCall } from "./builtins/dates.js"; import { + fsConstantMemberRead, + fsConstantsObject, lowerFsConstantsProperty, lowerBuiltinConstantsProperty, builtinConstantBindingOf, @@ -11685,13 +11687,7 @@ export class Lowerer { COPYFILE_FICLONE_FORCE: 4, }; const value = own(MODES, expr.name.text); - if (value === undefined) { - this.noLowering( - `fs.constants.${expr.name.text}`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + if (value === undefined) return fsConstantMemberRead(expr); return { kind: "numLit", value, type: F64, loc }; } } @@ -11751,14 +11747,8 @@ export class Lowerer { const callable = this.lowerBuiltinCallableValue(bi, loc); if (callable) return callable; } - if (bi.member === "constants" && bi.module === "fs") { - // A bare `fs.constants` read (not one of the baked bits above). - this.noLowering( - `fs.constants`, - expr, - "F_OK, R_OK, W_OK, X_OK, and COPYFILE_* are the lowered constants", - ); - } + // A bare `fs.constants` read (not one of the baked bits above). + if (bi.member === "constants" && bi.module === "fs") return fsConstantsObject(loc); if (builtinModuleFnOf(this, bi.module, bi.member)) { this.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 6d7da7b38..ef03fa65e 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2622,6 +2622,15 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ fns: ["os.constants"], note: "the platform's dlopen, errno, signals and priority groups; members a platform lacks read as undefined", }, + // fs.constants as a value, and its members beyond the baked access-mode + // bits: the target libc's null-prototype constants object. + { + id: "node-builtin.fs.constants", + kind: "node-builtin", + name: "fs.constants", + fns: ["fs.constants"], + note: "node_constants.cc's fs group under the target libc's macros; members a platform lacks read as undefined", + }, ]; /** The win32-target overrides of the bare modules' constants: path's diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 748029589..634ca33af 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -563,6 +563,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** os.constants: the per-thread null-prototype constants object (dlopen, * errno, signals, priority, UV_UDP_REUSEADDR). Never throws. */ "os.constants": { argTypes: [], result: DYN }, + /** fs.constants: the target's cached null-prototype constants object. */ + "fs.constants": { argTypes: [], result: DYN }, /** umask(2): arg < 0 reads without setting (umask has no read-only form * — set 0, restore); otherwise sets and answers the PREVIOUS mask. * Never throws. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json new file mode 100644 index 000000000..6f24442b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6207-fs-constants-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6207-fs-constants-object.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json new file mode 100644 index 000000000..eac8fa544 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6208-fs-constants-require.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6208-fs-constants-require.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index 76f69588e..bebdb2dc0 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -16,6 +16,7 @@ #include #include #include +#include #ifdef _WIN32 #include /* BEFORE windows.h (which pulls winsock 1 otherwise) */ @@ -1332,3 +1333,172 @@ ScrDyn *scr_os_constants(void) { scr_atexit(scr_os_constants_clear); return scr_dyn_retain(os); } + +/* fs.constants: node_constants.cc's fs group, in its order and under its + * #ifdef guards, so each target answers its own libc's set and values + * (O_NOATIME/O_DIRECT on Linux, O_SYMLINK on macOS). The names live inside + * the table entries, so the table is one data object that section GC drops + * from binaries that never read fs.constants. Nothing on an existing path + * calls this. */ +typedef struct { + char name[32]; + double value; +} ScrFsConstant; +#define SCR_FS_C(n) {#n, (double)(n)} +static const ScrFsConstant scr_fs_constant_table[] = { + {"UV_FS_SYMLINK_DIR", 1}, + {"UV_FS_SYMLINK_JUNCTION", 2}, +#ifdef O_RDONLY + SCR_FS_C(O_RDONLY), +#endif +#ifdef O_WRONLY + SCR_FS_C(O_WRONLY), +#endif +#ifdef O_RDWR + SCR_FS_C(O_RDWR), +#endif + {"UV_DIRENT_UNKNOWN", 0}, + {"UV_DIRENT_FILE", 1}, + {"UV_DIRENT_DIR", 2}, + {"UV_DIRENT_LINK", 3}, + {"UV_DIRENT_FIFO", 4}, + {"UV_DIRENT_SOCKET", 5}, + {"UV_DIRENT_CHAR", 6}, + {"UV_DIRENT_BLOCK", 7}, +#ifdef S_IFMT + SCR_FS_C(S_IFMT), +#endif +#ifdef S_IFREG + SCR_FS_C(S_IFREG), +#endif +#ifdef S_IFDIR + SCR_FS_C(S_IFDIR), +#endif +#ifdef S_IFCHR + SCR_FS_C(S_IFCHR), +#endif +#ifdef S_IFBLK + SCR_FS_C(S_IFBLK), +#endif +#ifdef S_IFIFO + SCR_FS_C(S_IFIFO), +#endif +#ifdef S_IFLNK + SCR_FS_C(S_IFLNK), +#endif +#ifdef S_IFSOCK + SCR_FS_C(S_IFSOCK), +#endif +#ifdef O_CREAT + SCR_FS_C(O_CREAT), +#endif +#ifdef O_EXCL + SCR_FS_C(O_EXCL), +#endif + {"UV_FS_O_FILEMAP", 0}, +#ifdef O_NOCTTY + SCR_FS_C(O_NOCTTY), +#endif +#ifdef O_TRUNC + SCR_FS_C(O_TRUNC), +#endif +#ifdef O_APPEND + SCR_FS_C(O_APPEND), +#endif +#ifdef O_DIRECTORY + SCR_FS_C(O_DIRECTORY), +#endif +#ifdef O_NOATIME + SCR_FS_C(O_NOATIME), +#endif +#ifdef O_NOFOLLOW + SCR_FS_C(O_NOFOLLOW), +#endif +#ifdef O_SYNC + SCR_FS_C(O_SYNC), +#endif +#ifdef O_DSYNC + SCR_FS_C(O_DSYNC), +#endif +#ifdef O_SYMLINK + SCR_FS_C(O_SYMLINK), +#endif +#ifdef O_DIRECT + SCR_FS_C(O_DIRECT), +#endif +#ifdef O_NONBLOCK + SCR_FS_C(O_NONBLOCK), +#endif +#ifdef S_IRWXU + SCR_FS_C(S_IRWXU), +#endif +#ifdef S_IRUSR + SCR_FS_C(S_IRUSR), +#endif +#ifdef S_IWUSR + SCR_FS_C(S_IWUSR), +#endif +#ifdef S_IXUSR + SCR_FS_C(S_IXUSR), +#endif +#ifdef S_IRWXG + SCR_FS_C(S_IRWXG), +#endif +#ifdef S_IRGRP + SCR_FS_C(S_IRGRP), +#endif +#ifdef S_IWGRP + SCR_FS_C(S_IWGRP), +#endif +#ifdef S_IXGRP + SCR_FS_C(S_IXGRP), +#endif +#ifdef S_IRWXO + SCR_FS_C(S_IRWXO), +#endif +#ifdef S_IROTH + SCR_FS_C(S_IROTH), +#endif +#ifdef S_IWOTH + SCR_FS_C(S_IWOTH), +#endif +#ifdef S_IXOTH + SCR_FS_C(S_IXOTH), +#endif +#ifdef F_OK + SCR_FS_C(F_OK), +#endif +#ifdef R_OK + SCR_FS_C(R_OK), +#endif +#ifdef W_OK + SCR_FS_C(W_OK), +#endif +#ifdef X_OK + SCR_FS_C(X_OK), +#endif + {"UV_FS_COPYFILE_EXCL", 1}, + {"COPYFILE_EXCL", 1}, + {"UV_FS_COPYFILE_FICLONE", 2}, + {"COPYFILE_FICLONE", 2}, + {"UV_FS_COPYFILE_FICLONE_FORCE", 4}, + {"COPYFILE_FICLONE_FORCE", 4}, +}; +#undef SCR_FS_C + +static SCR_TL ScrDyn *scr_fs_constants_value; +static void scr_fs_constants_clear(void) { + ScrDyn *value = scr_fs_constants_value; + scr_fs_constants_value = NULL; + scr_dyn_release(value); +} + +ScrDyn *scr_fs_constants(void) { + if (scr_fs_constants_value) return scr_dyn_retain(scr_fs_constants_value); + ScrDyn *o = scr_dyn_new_obj_null_proto(); + for (size_t i = 0; i < sizeof scr_fs_constant_table / sizeof scr_fs_constant_table[0]; i++) + scr_os_constant(o, scr_fs_constant_table[i].name, scr_fs_constant_table[i].value); + scr_fs_constants_value = o; + scr_atexit(scr_fs_constants_clear); + return scr_dyn_retain(o); +} diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index 3c5009fc5..835f2b052 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -38,6 +38,8 @@ ScrStr *scr_os_cpus_model(double i); double scr_os_cpus_field(double i, double field); /* os.constants: the per-thread null-prototype constants object (+1). */ ScrDyn *scr_os_constants(void); +/* fs.constants: the target's null-prototype constants object (cached, +1). */ +ScrDyn *scr_fs_constants(void); double scr_os_available_parallelism(void); /* uv_available_parallelism: CPUs this process may use, >= 1 */ /* os.userInfo()'s field trio (pw_name / pw_shell / pw_dir — the passwd * home, not the $HOME cascade). +1 fresh; abort on lookup failure. */ diff --git a/tests/corpus/6207-fs-constants-object.ts b/tests/corpus/6207-fs-constants-object.ts new file mode 100644 index 000000000..e329009c1 --- /dev/null +++ b/tests/corpus/6207-fs-constants-object.ts @@ -0,0 +1,16 @@ +// fs.constants as a value and every member: the target platform's flags, +// file-type bits and permission bits, in Node's order, on a null-prototype +// object whose members are read-only. The access-mode bits keep their +// baked literals. +import * as fs from "node:fs"; +import { constants } from "node:fs"; + +console.log(JSON.stringify(Object.entries(fs.constants))); +console.log(fs.constants.O_RDONLY, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, constants.S_IFMT, constants.S_IFDIR); +console.log(constants.W_OK, fs.constants.F_OK, constants.COPYFILE_EXCL, fs.constants.UV_FS_O_FILEMAP); +const c: unknown = fs.constants; +console.log(typeof c, c === (fs.constants as unknown), Object.getPrototypeOf(c as object) === null, Object.keys(fs.constants).length > 40); +const mode = fs.statSync(".").mode; +console.log((mode & constants.S_IFMT) === constants.S_IFDIR, (mode & fs.constants.S_IRUSR) !== 0); +const descriptor = Object.getOwnPropertyDescriptor(c as object, "O_RDONLY"); +console.log(JSON.stringify(descriptor)); diff --git a/tests/corpus/6208-fs-constants-require.cjs b/tests/corpus/6208-fs-constants-require.cjs new file mode 100644 index 000000000..c8488a8bf --- /dev/null +++ b/tests/corpus/6208-fs-constants-require.cjs @@ -0,0 +1,8 @@ +// graceful-fs's reads of require('fs').constants: own-property probes for +// platform-specific flags and member reads through a stored binding. +var fs = require("fs"); +var constants = fs.constants; +console.log(typeof constants.O_RDONLY, constants.O_RDWR, Object.prototype.hasOwnProperty.call(constants, "O_SYMLINK") === ("O_SYMLINK" in constants), typeof constants.hasOwnProperty); +console.log(Object.prototype.hasOwnProperty.call(constants, "S_IFREG"), constants.O_EXCL > 0, typeof require("fs").constants.S_IFLNK); +var names = Object.keys(constants).filter(function (k) { return k.indexOf("UV_DIRENT_") === 0; }); +console.log(names.join(","), constants.NOT_A_CONSTANT); From 5737483a0480d27656a8e87d4c9eaa7327d87474 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:24 -0700 Subject: [PATCH 05/12] Answer typeof for Web constructor globals and their static methods ky probes `typeof globalThis.AbortSignal === 'function' && typeof globalThis.AbortSignal.any === 'function'`. In JavaScript sources these values are identity tokens, so typeof answered "string", and the static method read refused. Node 24's Web-platform constructor globals and the static methods of AbortSignal, Response and URL now answer "function", also inside typeof comparisons, where a fallback-typed receiver narrows to never after the first guard. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 96 +++++++++++++++++++ .../6210-typeof-global-static-members.js.json | 6 ++ .../6210-typeof-global-static-members.js | 11 +++ 3 files changed, 113 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json create mode 100644 tests/corpus/6210-typeof-global-static-members.js diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8c8b1c09b..3b068f1ba 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -686,6 +686,31 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { } } } + // `typeof globalThis.AbortSignal` / `typeof Headers`: Node 24's Web + // platform constructor globals are functions. JavaScript sources + // represent such a value as an identity token, and the fallback + // declarations type some (AbortSignal) without a construct signature, + // so neither the operand nor the type can answer; the read has no + // effects. + if (constructorGlobalTypeof(lowerer, tested) !== null) + return { kind: "strLit", value: "function", type: STRING, loc }; + // `typeof globalThis.AbortSignal.any` (ky's feature probe): a static + // member of a standard-library global, declared callable, whose value + // has no representation. The read has no effects and Node's answer + // for every such function is "function"; members that lower as values + // keep their own answer. + if (ts.isPropertyAccessExpression(tested) && !tested.questionDotToken) { + const owner = stdlibGlobalNameOf(lowerer, tested.expression); + if (owner !== null && lowerer.isStdlibMember(tested)) { + const t = lowerer.typeOf(tested); + if ( + (lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0) && + tryLowerExpression(lowerer, tested) === null + ) + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } if (ts.isIdentifier(tested)) { const sym = lowerer.checker.getSymbolAtLocation(tested); if (!sym) { @@ -8744,6 +8769,76 @@ export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrEx return lowerRecordPropertyKey(lowerer, key, node); } +/** `typeof` of a Node 24 Web-platform constructor global or one of its + * static methods: "function", decided by name. The read has no effects. + * Static methods answer by name because after a `typeof + * globalThis.AbortSignal === "function" &&` guard the checker narrows a + * fallback-typed receiver to never, hiding the member's declaration. */ +function constructorGlobalTypeof(lowerer: Lowerer, tested: ts.Expression): string | null { + while (ts.isParenthesizedExpression(tested)) tested = tested.expression; + const globalName = stdlibGlobalNameOf(lowerer, tested); + if (globalName !== null && NODE_CONSTRUCTOR_GLOBALS.has(globalName)) return "function"; + if (!ts.isPropertyAccessExpression(tested) || tested.questionDotToken) return null; + const owner = stdlibGlobalNameOf(lowerer, tested.expression); + return owner !== null && + Object.hasOwn(NODE_CONSTRUCTOR_STATICS, owner) && + NODE_CONSTRUCTOR_STATICS[owner]!.includes(tested.name.text) + ? "function" + : null; +} + +/** `typeof globalThis.AbortSignal.any === "function"`: the comparison + * folds with constructorGlobalTypeof's answer. */ +function lowerConstructorGlobalTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; + const answer = constructorGlobalTypeof(lowerer, a.expression); + if (answer !== null) + return { kind: "boolLit", value: (answer === b.text) !== negated, type: BOOL, loc }; + } + return null; +} + +/** Static methods of Node 24's Web-platform constructor globals. */ +const NODE_CONSTRUCTOR_STATICS: Readonly> = { + AbortSignal: ["abort", "any", "timeout"], + Response: ["error", "json", "redirect"], + URL: ["canParse", "createObjectURL", "parse", "revokeObjectURL"], +}; + +/** Node 24's Web-platform constructor globals: `typeof` answers + * "function" for each. */ +const NODE_CONSTRUCTOR_GLOBALS: ReadonlySet = new Set([ + "AbortController", + "AbortSignal", + "Blob", + "BroadcastChannel", + "CustomEvent", + "DOMException", + "Event", + "EventTarget", + "File", + "FormData", + "Headers", + "MessageChannel", + "MessageEvent", + "MessagePort", + "ReadableStream", + "Request", + "Response", + "TextDecoder", + "TextEncoder", + "TransformStream", + "URL", + "URLSearchParams", + "WritableStream", +]); + export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { if (e.type.kind === "string") return e; if (e.type.kind === "url") @@ -10262,6 +10357,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr op === ts.SyntaxKind.ExclamationEqualsEqualsToken ) { const test = + lowerConstructorGlobalTypeofTest(lowerer, expr, loc) ?? lowerErrorCodeTypeofTest(lowerer, expr, loc) ?? lowerer.lowerCaughtTypeofTest(expr, loc) ?? lowerDynTypeofTest(lowerer, expr, loc) ?? diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json new file mode 100644 index 000000000..33ccb2715 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6210-typeof-global-static-members.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6210-typeof-global-static-members.js" + ], + "diags": [] +} diff --git a/tests/corpus/6210-typeof-global-static-members.js b/tests/corpus/6210-typeof-global-static-members.js new file mode 100644 index 000000000..88c3c40a1 --- /dev/null +++ b/tests/corpus/6210-typeof-global-static-members.js @@ -0,0 +1,11 @@ +// ky's feature probes: typeof Node's Web-platform constructor globals and +// their static methods answers "function" without materializing the value. +export const supportsAbortController = typeof globalThis.AbortController === "function"; +export const supportsAbortSignal = + typeof globalThis.AbortSignal === "function" && typeof globalThis.AbortSignal.any === "function"; +console.log(supportsAbortController, supportsAbortSignal); +console.log(typeof AbortSignal.timeout, typeof AbortSignal.abort, typeof globalThis.AbortSignal.any); +console.log(typeof Response.json, typeof Response.error, typeof URL.canParse, typeof Promise.withResolvers); +console.log(typeof AbortSignal, typeof globalThis.URL, typeof Headers, typeof globalThis.TextEncoder, typeof URLSearchParams); +const signal = AbortSignal.abort("stop"); +console.log(signal.aborted, signal.reason); From ec4a6930914843e71a93a9b9e6b18999c700ed2d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:55:27 -0700 Subject: [PATCH 06/12] Map crypto.getRandomValues and fs.constants in the compatibility ledger crypto.getRandomValues (crypto and webcrypto chapters) is supported with corpus 6204/6205; fs.constants is partial (the fs group, not the other platforms' sets) with corpus 6207/6208. Regenerated with pnpm manifest and pnpm node-compat. Co-Authored-By: Claude Opus 5.5 --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 36 ++++---- .../generated/node-v24-backlog.json | 90 +++++-------------- .../generated/node-v24-internal.json | 50 ++++++++--- internal/compatibility/static-support.json | 3 + packages/compiler/surface-manifest.json | 13 +++ 6 files changed, 94 insertions(+), 100 deletions(-) diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index a64169933..4397b4b72 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "a610bdd1ae56e1c4cd38", + "artifactVersion": "02a00aa34299b18710fc", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index d4b6839e7..d922674e5 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -208,10 +208,10 @@ "entries": 152, "apiEntries": 152, "static": { - "supported": 5, + "supported": 6, "partial": 39, "refused": 21, - "not-implemented": 87, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -314,9 +314,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 198, + "partial": 200, "refused": 0, - "not-implemented": 75, + "not-implemented": 73, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -853,10 +853,10 @@ "entries": 156, "apiEntries": 156, "static": { - "supported": 1, + "supported": 2, "partial": 10, "refused": 0, - "not-implemented": 145, + "not-implemented": 144, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -12325,9 +12325,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -30288,9 +30288,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -35256,9 +35256,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -80183,9 +80183,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index f36c865cf..5d75728c1 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -3,19 +3,19 @@ "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", "summary": { - "taskCount": 3456, - "tierItemCount": 3456, + "taskCount": 3454, + "tierItemCount": 3454, "tiers": { "static": { - "audit-partial": 1155, - "verify-gap": 2174, + "audit-partial": 1157, + "verify-gap": 2170, "replace-refusal": 40, "classify": 2, "implement": 85 } }, "priorities": { - "high": 2289, + "high": 2287, "normal": 694, "low": 473 } @@ -7348,31 +7348,6 @@ } } }, - { - "id": "crypto:2ab8394e29a7", - "chapter": "crypto", - "label": "crypto.getRandomValues(typedArray)", - "signature": "`crypto.getRandomValues(typedArray)`", - "apiSymbol": "crypto.getRandomValues", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/crypto.html#cryptogetrandomvaluestypedarray", - "nodeStability": { - "index": "2", - "level": 2, - "text": "Stable", - "inherited": true - }, - "priority": "high", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:crypto", - "tests": [] - } - } - }, { "id": "crypto:21f22c71fb61", "chapter": "crypto", @@ -27036,12 +27011,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] } } }, @@ -32733,12 +32711,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:fs", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] } } }, @@ -80964,31 +80945,6 @@ } } }, - { - "id": "webcrypto:dcc24f369f43", - "chapter": "webcrypto", - "label": "crypto.getRandomValues(typedArray)", - "signature": "`crypto.getRandomValues(typedArray)`", - "apiSymbol": "crypto.getRandomValues", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/webcrypto.html#cryptogetrandomvaluestypedarray", - "nodeStability": { - "index": "2", - "level": 2, - "text": "Stable", - "inherited": true - }, - "priority": "high", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:webcrypto", - "tests": [] - } - } - }, { "id": "webcrypto:8c570ec75666", "chapter": "webcrypto", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 514282a0a..f1f3a0f30 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -190,10 +190,10 @@ "entries": 179, "apiEntries": 152, "static": { - "supported": 5, + "supported": 6, "partial": 39, "refused": 21, - "not-implemented": 87, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -326,9 +326,9 @@ "apiEntries": 280, "static": { "supported": 7, - "partial": 198, + "partial": 200, "refused": 0, - "not-implemented": 75, + "not-implemented": 73, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -910,10 +910,10 @@ "entries": 176, "apiEntries": 156, "static": { - "supported": 1, + "supported": 2, "partial": 10, "refused": 0, - "not-implemented": 145, + "not-implemented": 144, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -19861,6 +19861,8 @@ "tests/corpus/5760-crypto-hash-stream.ts", "tests/corpus/5761-crypto-hash-stream-states.ts", "tests/corpus/5762-crypto-hash-stream.cjs", + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/node24-crypto-kdf.ts", @@ -21013,8 +21015,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:crypto" + "status": "supported", + "evidence": "compiler-dedicated:crypto.getRandomValues", + "tests": [ + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js" + ] }, "anchorSource": "exact" }, @@ -44097,6 +44103,8 @@ "tests/corpus/5568-fs-cp-errors.js", "tests/corpus/5569-fs-cp-symlinks.ts", "tests/corpus/5570-fs-cp-async.ts", + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs", "tests/corpus/fs-native-realpath.ts", "tests/corpus/node24-fs-links-callbacks.cjs", "tests/corpus/node24-fs-links-errors.cjs", @@ -45884,8 +45892,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] }, "anchorSource": "ancestor" }, @@ -51756,8 +51768,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:fs" + "status": "partial", + "evidence": "surface-manifest:node-builtin.fs.constants", + "tests": [ + "tests/corpus/6207-fs-constants-object.ts", + "tests/corpus/6208-fs-constants-require.cjs" + ] }, "anchorSource": "ancestor" }, @@ -106766,6 +106782,8 @@ "tests/corpus/2890-crypto-static-utility.ts", "tests/corpus/5442-crypto-random-values.ts", "tests/corpus/5443-crypto-random-arguments.js", + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/node24-crypto-kdf.ts", @@ -110250,8 +110268,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:webcrypto" + "status": "supported", + "evidence": "compiler-dedicated:crypto.getRandomValues", + "tests": [ + "tests/corpus/6204-crypto-get-random-values.ts", + "tests/corpus/6205-crypto-get-random-values.js" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 45a946925..66590fd96 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -111,6 +111,8 @@ "node-builtin.os.uptime": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.version": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.constants": ["tests/corpus/5141-os-constants.ts"], + "node-builtin.fs.constants": ["tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs"], + "node-builtin.crypto.getRandomValues": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"], "node-builtin.url.parse": ["tests/corpus/5220-url-parse-warnings.ts", "tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], "node-builtin.url.format": ["tests/corpus/5224-url-format-objects.ts", "tests/corpus/5225-url-format-whatwg.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], "node-builtin.url.resolve": ["tests/corpus/5226-url-resolve.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"], @@ -389,6 +391,7 @@ { "symbols": ["crypto.createHash", "Hash", "hash.update", "hash.digest", "hash.copy", "crypto.createHmac", "Hmac", "hmac.update", "hmac.digest", "crypto.hash", "crypto.pbkdf2", "crypto.pbkdf2Sync"], "status": "partial", "evidence": ["tests/corpus/5760-crypto-hash-stream.ts", "tests/corpus/5761-crypto-hash-stream-states.ts", "tests/corpus/5762-crypto-hash-stream.cjs", "tests/corpus/2890-crypto-static-utility.ts", "tests/corpus/node24-crypto-sha2.ts", "tests/corpus/5435-crypto-hash-algorithms.ts", "tests/corpus/5436-crypto-hash-names.js", "tests/corpus/5437-crypto-hmac-algorithms.ts", "tests/corpus/5438-crypto-kdf-digests.ts", "tests/corpus/5440-crypto-hash-encodings.js", "tests/corpus/5441-crypto-hash-output-length.ts", "tests/harness/npm-static.test.ts"] }, { "symbols": ["crypto.randomFill", "crypto.randomFillSync", "crypto.randomUUID", "crypto.randomUUIDv7"], "status": "supported", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/5443-crypto-random-arguments.js", "tests/corpus/1358-crypto-random.ts"] }, { "symbols": ["crypto.createSecretKey", "crypto.generateKey", "crypto.generateKeySync", "KeyObject", "keyObject.export", "keyObject.equals", "keyObject.symmetricKeySize", "keyObject.type"], "status": "partial", "evidence": ["tests/corpus/5445-crypto-secret-key-objects.ts", "tests/corpus/5446-crypto-secret-key-errors.js"] }, + { "symbols": ["crypto.getRandomValues"], "status": "supported", "evidence": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"] }, { "symbols": ["crypto.timingSafeEqual"], "status": "supported", "evidence": ["tests/corpus/5444-crypto-timing-safe-equal.js", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.randomInt"], "status": "partial", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] }, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index f5313f12d..8d13d02ac 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -611,6 +611,12 @@ "code": "SC2020", "note": "asymmetric-key operations need a public-key stack (bignum, RSA/EC/EdDSA math) and public and private KeyObjects — neither exists in the static runtime, so no faithful lowering can be small; the lowered crypto surface is hashing, randomness, secret KeyObjects, and the introspection statics" }, + { + "id": "node-builtin.crypto.getRandomValues", + "kind": "node-builtin", + "name": "crypto.getRandomValues", + "status": "static" + }, { "id": "node-builtin.crypto.hash", "kind": "node-builtin", @@ -861,6 +867,13 @@ "name": "fs.closeSync", "status": "static" }, + { + "id": "node-builtin.fs.constants", + "kind": "node-builtin", + "name": "fs.constants", + "status": "static", + "note": "node_constants.cc's fs group under the target libc's macros; members a platform lacks read as undefined" + }, { "id": "node-builtin.fs.copyFileSync", "kind": "node-builtin", From 0ee1205bc469c9d01eff9024ca5d3701c118cbd7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 16:05:10 -0700 Subject: [PATCH 07/12] Keep fs.constants on its own table beside node:constants Calling the node:constants fs fill from a second site stops that static from inlining into scr_node_constants and changes every binary linking node:constants; fs.constants keeps its own table (now with the UV_FS_O_* Windows-only flags). Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/scr_os.c | 167 ++++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/scr_os.c b/packages/runtime/src/scr_os.c index da51bc25f..500283995 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -1527,12 +1527,164 @@ ScrDyn *scr_node_constants(const ScrDyn *crypto_table) { return scr_dyn_retain(o); } -/* fs.constants as a value: the fs group node:constants merges (the - * scr_fs_constants fill above, node_constants.cc's order under the target - * libc's guards) on its own cached null-prototype object with read-only - * members. Defined after scr_node_constants so the shared static fill - * keeps its place in binaries that link node:constants. Nothing on an - * existing path calls this. */ +/* fs.constants as a value: node_constants.cc's fs group (the set + * node:constants merges above), in its order and under its #ifdef guards, + * on its own cached null-prototype object with read-only members. It keeps + * its own table instead of calling the scr_fs_constants fill: a second + * caller would stop that static from inlining into scr_node_constants and + * change every binary that links node:constants. The names live inside + * the table entries, so the table is one data object section GC drops. + * Nothing on an existing path calls this. */ +typedef struct { + char name[32]; + double value; +} ScrFsConstant; +#define SCR_FS_C(n) {#n, (double)(n)} +static const ScrFsConstant scr_fs_constant_table[] = { + {"UV_FS_SYMLINK_DIR", 1}, + {"UV_FS_SYMLINK_JUNCTION", 2}, +#ifdef O_RDONLY + SCR_FS_C(O_RDONLY), +#endif +#ifdef O_WRONLY + SCR_FS_C(O_WRONLY), +#endif +#ifdef O_RDWR + SCR_FS_C(O_RDWR), +#endif + {"UV_DIRENT_UNKNOWN", 0}, + {"UV_DIRENT_FILE", 1}, + {"UV_DIRENT_DIR", 2}, + {"UV_DIRENT_LINK", 3}, + {"UV_DIRENT_FIFO", 4}, + {"UV_DIRENT_SOCKET", 5}, + {"UV_DIRENT_CHAR", 6}, + {"UV_DIRENT_BLOCK", 7}, +#ifdef S_IFMT + SCR_FS_C(S_IFMT), +#endif +#ifdef S_IFREG + SCR_FS_C(S_IFREG), +#endif +#ifdef S_IFDIR + SCR_FS_C(S_IFDIR), +#endif +#ifdef S_IFCHR + SCR_FS_C(S_IFCHR), +#endif +#ifdef S_IFBLK + SCR_FS_C(S_IFBLK), +#endif +#ifdef S_IFIFO + SCR_FS_C(S_IFIFO), +#endif +#ifdef S_IFLNK + SCR_FS_C(S_IFLNK), +#endif +#ifdef S_IFSOCK + SCR_FS_C(S_IFSOCK), +#endif +#ifdef O_CREAT + SCR_FS_C(O_CREAT), +#endif +#ifdef O_EXCL + SCR_FS_C(O_EXCL), +#endif + {"UV_FS_O_FILEMAP", 0}, + {"UV_FS_O_TEMPORARY", 0}, + {"UV_FS_O_SHORT_LIVED", 0}, + {"UV_FS_O_SEQUENTIAL", 0}, + {"UV_FS_O_RANDOM", 0}, +#ifdef O_NOCTTY + SCR_FS_C(O_NOCTTY), +#endif +#ifdef O_TRUNC + SCR_FS_C(O_TRUNC), +#endif +#ifdef O_APPEND + SCR_FS_C(O_APPEND), +#endif +#ifdef O_DIRECTORY + SCR_FS_C(O_DIRECTORY), +#endif +#ifdef O_NOATIME + SCR_FS_C(O_NOATIME), +#endif +#ifdef O_NOFOLLOW + SCR_FS_C(O_NOFOLLOW), +#endif +#ifdef O_SYNC + SCR_FS_C(O_SYNC), +#endif +#ifdef O_DSYNC + SCR_FS_C(O_DSYNC), +#endif +#ifdef O_SYMLINK + SCR_FS_C(O_SYMLINK), +#endif +#ifdef O_DIRECT + SCR_FS_C(O_DIRECT), +#endif +#ifdef O_NONBLOCK + SCR_FS_C(O_NONBLOCK), +#endif +#ifdef S_IRWXU + SCR_FS_C(S_IRWXU), +#endif +#ifdef S_IRUSR + SCR_FS_C(S_IRUSR), +#endif +#ifdef S_IWUSR + SCR_FS_C(S_IWUSR), +#endif +#ifdef S_IXUSR + SCR_FS_C(S_IXUSR), +#endif +#ifdef S_IRWXG + SCR_FS_C(S_IRWXG), +#endif +#ifdef S_IRGRP + SCR_FS_C(S_IRGRP), +#endif +#ifdef S_IWGRP + SCR_FS_C(S_IWGRP), +#endif +#ifdef S_IXGRP + SCR_FS_C(S_IXGRP), +#endif +#ifdef S_IRWXO + SCR_FS_C(S_IRWXO), +#endif +#ifdef S_IROTH + SCR_FS_C(S_IROTH), +#endif +#ifdef S_IWOTH + SCR_FS_C(S_IWOTH), +#endif +#ifdef S_IXOTH + SCR_FS_C(S_IXOTH), +#endif +#ifdef F_OK + SCR_FS_C(F_OK), +#endif +#ifdef R_OK + SCR_FS_C(R_OK), +#endif +#ifdef W_OK + SCR_FS_C(W_OK), +#endif +#ifdef X_OK + SCR_FS_C(X_OK), +#endif + {"UV_FS_COPYFILE_EXCL", 1}, + {"COPYFILE_EXCL", 1}, + {"UV_FS_COPYFILE_FICLONE", 2}, + {"COPYFILE_FICLONE", 2}, + {"UV_FS_COPYFILE_FICLONE_FORCE", 4}, + {"COPYFILE_FICLONE_FORCE", 4}, +}; +#undef SCR_FS_C + static SCR_TL ScrDyn *scr_fs_constants_value; static void scr_fs_constants_clear(void) { ScrDyn *value = scr_fs_constants_value; @@ -1543,7 +1695,8 @@ static void scr_fs_constants_clear(void) { ScrDyn *scr_fs_constants_object(void) { if (scr_fs_constants_value) return scr_dyn_retain(scr_fs_constants_value); ScrDyn *o = scr_dyn_new_obj_null_proto(); - scr_fs_constants(o); + for (size_t i = 0; i < sizeof scr_fs_constant_table / sizeof scr_fs_constant_table[0]; i++) + scr_os_constant(o, scr_fs_constant_table[i].name, scr_fs_constant_table[i].value); scr_fs_constants_value = o; scr_atexit(scr_fs_constants_clear); return scr_dyn_retain(o); From 3ffcdfb08fb126cd918c5621886575789495dbb8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 19:34:12 -0700 Subject: [PATCH 08/12] Refuse a default-locale toLocaleString in the coverage tier fixtures This branch lowers Number.prototype.toPrecision statically, so the tier-demotions and dynamic-mix coverage fixtures lost the operation they used as "no static lowering". Use n.toLocaleString() instead: the host's default locale is something a compiled binary cannot carry, so it stays refused (SC2020) and the fixtures keep the same blocker and demotion request on the same lines. Co-Authored-By: Claude Opus 5.5 --- tests/coverage-fixtures/dynamic-mix.ts | 4 ++-- tests/coverage-fixtures/tier-demotions.ts | 4 ++-- tests/harness/__snapshots__/coverage-dynamic-mix.txt | 4 ++-- tests/harness/__snapshots__/coverage-explain-dynamic.txt | 2 +- tests/harness/__snapshots__/coverage-tier-demotions.txt | 4 ++-- tests/harness/coverage.test.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/coverage-fixtures/dynamic-mix.ts b/tests/coverage-fixtures/dynamic-mix.ts index 0c9779a64..d70141b91 100644 --- a/tests/coverage-fixtures/dynamic-mix.ts +++ b/tests/coverage-fixtures/dynamic-mix.ts @@ -1,9 +1,9 @@ // Native checked values and loose equality share this program with -// island-backed APIs (SC2012) with native compilation refusals. +// native compilation refusals (a default-locale toLocaleString, SC2020). const v: any = 21; const doubled = v * 2; const root = Math.cbrt(81); -const up = (19.99).toPrecision(3); +const up = (19.99).toLocaleString(); const parsed = Number.parseFloat("1.5"); // a string argument compiles statically, like the global const unknownScore: unknown = 81; const flags = unknownScore == 81; diff --git a/tests/coverage-fixtures/tier-demotions.ts b/tests/coverage-fixtures/tier-demotions.ts index 52f2058f1..3dbd0c3fe 100644 --- a/tests/coverage-fixtures/tier-demotions.ts +++ b/tests/coverage-fixtures/tier-demotions.ts @@ -17,9 +17,9 @@ function readName(text: string): string { return String(data.name); } -// An operation without a static lowering, on a parameter. +// An operation without a static lowering (the host's default locale), on a parameter. function precise(n: number): string { - return n.toPrecision(3); + return n.toLocaleString(); } console.log(readName('{"name":"a"}'), precise(1.5), new Point(1, 2).x); diff --git a/tests/harness/__snapshots__/coverage-dynamic-mix.txt b/tests/harness/__snapshots__/coverage-dynamic-mix.txt index 2ad84f0af..0bff0f03c 100644 --- a/tests/harness/__snapshots__/coverage-dynamic-mix.txt +++ b/tests/harness/__snapshots__/coverage-dynamic-mix.txt @@ -5,6 +5,6 @@ scriptc coverage tests/coverage-fixtures/dynamic-mix.ts in dynamic tier 2 (operate on values typed only at runtime; --explain-dynamic lists them) blockers: - ×1 SC2012 '.toPrecision()' on numbers cannot compile statically + ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet at tests/coverage-fixtures/dynamic-mix.ts:6:12 - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-explain-dynamic.txt b/tests/harness/__snapshots__/coverage-explain-dynamic.txt index 1c86a808c..f168988e6 100644 --- a/tests/harness/__snapshots__/coverage-explain-dynamic.txt +++ b/tests/harness/__snapshots__/coverage-explain-dynamic.txt @@ -12,5 +12,5 @@ function tests/coverage-fixtures/tier-demotions.ts:1:1 (1 site inside) function precise tests/coverage-fixtures/tier-demotions.ts:21:1 (1 site inside) binding n in precise tests/coverage-fixtures/tier-demotions.ts:21:18 (1 request) - <- SC2012 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: '.toPrecision()' on numbers cannot compile statically + <- SC2020 operation at tests/coverage-fixtures/tier-demotions.ts:22:10: 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet -> function precise turns dynamic \ No newline at end of file diff --git a/tests/harness/__snapshots__/coverage-tier-demotions.txt b/tests/harness/__snapshots__/coverage-tier-demotions.txt index 7b52f9d63..aaf1f4e3b 100644 --- a/tests/harness/__snapshots__/coverage-tier-demotions.txt +++ b/tests/harness/__snapshots__/coverage-tier-demotions.txt @@ -8,6 +8,6 @@ scriptc coverage tests/coverage-fixtures/tier-demotions.ts ×1 SC1090 assignment to non-variables at tests/coverage-fixtures/tier-demotions.ts:10:1 hint: this form is valid TypeScript but does not compile yet — rewrite it with a supported equivalent of the construct the message names (https://scriptc.dev/limitations lists the boundary) - ×1 SC2012 '.toPrecision()' on numbers cannot compile statically + ×1 SC2020 'Number.prototype.toLocaleString without a locale' is part of the standard library types but has no scriptc lowering yet at tests/coverage-fixtures/tier-demotions.ts:22:10 - hint: use a supported API or rewrite this operation for native compilation \ No newline at end of file + hint: the default locale is the host environment's, which a compiled binary cannot carry — pass it explicitly: x.toLocaleString("en-US") \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index f1347bf23..8cf5d6719 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -295,7 +295,7 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou requests.map((r) => [r.code, r.seed, r.scope, r.unit.kind, r.unit.name, r.enclosing.name]), ).toEqual([ ["SC1090", "reflection", "reached", "class", "Point", ""], - ["SC2012", "operation", "reached", "binding", "n", "precise"], + ["SC2020", "operation", "reached", "binding", "n", "precise"], ]); expect(tiers.units.map((u) => [u.kind, u.name, u.requests, u.sites])).toEqual([ ["class", "Point", 1, 0], From f74c2eaf03798cf2fc11e24883e5fefb826ee744 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 20:01:18 -0700 Subject: [PATCH 09/12] Use a Brotli refusal for the K14 manifest-id teaching and fence tests toPrecision lowers statically on this branch, so the two K14 tests that needed a surface whose own refusal fires compiled instead. Use zlib.brotliCompressSync: it has its own manifest id, the fallback types declare it, and it refuses with SC2020 because the static runtime vendors no Brotli codec. The id-keyed teaching and the fence teaching ride that refusal as the note, as before. Co-Authored-By: Claude Opus 5.5 --- tests/harness/library-mode.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 0eb0cf2c3..ffd42bd8e 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -1053,12 +1053,13 @@ describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => { test("a manifest-id-keyed teachings entry attaches to that surface's own refusal", async () => { const diags = await refusal( - `export function f(): number { return (1.234).toPrecision(2).length; }\n`, + `import { brotliCompressSync } from "node:zlib";\nexport function f(): number { return brotliCompressSync("data").length; }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], determinism: { teachings: { - "stdlib.number.toPrecision": "formatting runs in the host; request it as an effect", + "node-builtin.zlib.brotliCompressSync": + "compression runs in the host; request it as an effect", }, }, }, @@ -1066,27 +1067,29 @@ describe.each(EMISSIONS)("K14: determinism fences, %s emission", (emission) => { ); // The surface's own code, not a fence code: the id key attaches text // to the refusal that already fires. - expect(diags[0]!.code).toBe("SC2012"); - expect(diags[0]!.message).toContain(".toPrecision()"); + expect(diags[0]!.code).toBe("SC2020"); + expect(diags[0]!.message).toContain("zlib.brotliCompressSync"); expect(diags[0]!.note).toBe( - "from the 'refusal-fixture' profile: formatting runs in the host; request it as an effect", + "from the 'refusal-fixture' profile: compression runs in the host; request it as an effect", ); }); test("fencing a surface the static tier refuses anyway changes only the message", async () => { const diags = await refusal( - `export function f(): number { return (1.234).toPrecision(2).length; }\n`, + `import { brotliCompressSync } from "node:zlib";\nexport function f(): number { return brotliCompressSync("data").length; }\n`, { exports: [{ export: "f", symbol: "kx_f", params: [], returns: "f64" }], determinism: { - fences: [{ id: "stdlib.number.toPrecision", teaching: "formatting is host work" }], + fences: [ + { id: "node-builtin.zlib.brotliCompressSync", teaching: "compression is host work" }, + ], }, }, emission, ); // The existing refusal's code survives — the fence never re-codes a // surface that already refuses; its teaching rides as the note. - expect(diags[0]!.code).toBe("SC2012"); - expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: formatting is host work"); + expect(diags[0]!.code).toBe("SC2020"); + expect(diags[0]!.note).toBe("from the 'refusal-fixture' profile: compression is host work"); }); }); From e819e2af3db645fd9f8f4c6a9533645310684a20 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 20:09:31 -0700 Subject: [PATCH 10/12] Record Number.prototype.toPrecision as static in the surface manifest This branch lowers toPrecision statically, but STATIC_NUMBER_METHODS still lacked it, so the regenerated manifest kept stdlib.number.toPrecision as unsupported/SC2012 and a determinism fence on it had no reach detector. List it with its num.toPrecision witness and 0 to 1 arguments, like toFixed, and regenerate the manifest; the compatibility outputs are unchanged. library-profile's remediation test used that id as its SC2012 fence; it now names the SC2012 code family (diagnostic.sc2012), so the explicit remediations key still competes with fence-supplied SC2012 text. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/surfaces.ts | 1 + packages/compiler/surface-manifest.json | 4 ++-- tests/harness/library-profile.test.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 977e760a1..28b66ae40 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -773,6 +773,7 @@ export const STATIC_NUMBER_METHODS: Record< { fns: readonly IrLibFn[]; minArgs: number; maxArgs: number } | undefined > = { toFixed: { fns: ["num.toFixed0", "num.toFixed"], minArgs: 0, maxArgs: 1 }, + toPrecision: { fns: ["num.toPrecision"], minArgs: 0, maxArgs: 1 }, }; /** One lowerable builtin-module FUNCTION: `fn`'s libCall with `params` diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index a1288beee..1e2004177 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -4836,8 +4836,8 @@ "id": "stdlib.number.toPrecision", "kind": "stdlib", "name": "number.prototype.toPrecision", - "status": "unsupported", - "code": "SC2012" + "status": "static", + "note": "the lowered call form takes 0 to 1 arguments" }, { "id": "stdlib.number.toString", diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index fc243ccb5..52ccb67a6 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -481,7 +481,7 @@ describe("library profile fences", () => { determinism: { remediations: { SC2012: "the explicit map key wins" }, fences: [ - { id: "stdlib.number.toPrecision", remediation: "request it as an effect" }, + { id: "diagnostic.sc2012", remediation: "request it as an effect" }, { id: "node-builtin.crypto.createSign", remediation: "signatures come from the host" }, ], }, From 9ac4551de5784c810ac3e295aa38112fea80e043 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 09:13:55 -0500 Subject: [PATCH 11/12] Expect SC1101 from fs-shims and record the branch's corpus costs fs-shims now gets past Buffer.isBuffer on a Uint8Array (fszoo/index.js:35), which this branch lowers; its first deferred refusal is the existing SC1101 for a closure passed into an unknown slot (fszoo/index.js:44), so the expectation records SC1101. The branch's new corpus programs (1112 and 6200 to 6210) get per-program cost files in tests/harness/corpus-costs, taken from the differential and llvm-differential case durations in this branch's Linux CI shards. --- tests/harness/corpus-costs/1112-number-methods.ts.json | 1 + .../corpus-costs/6200-string-annexb-locale-methods.ts.json | 1 + .../corpus-costs/6201-number-precision-exponential.ts.json | 1 + .../harness/corpus-costs/6202-dyn-string-number-methods.js.json | 1 + .../harness/corpus-costs/6203-any-string-number-methods.ts.json | 1 + .../harness/corpus-costs/6204-crypto-get-random-values.ts.json | 1 + .../harness/corpus-costs/6205-crypto-get-random-values.js.json | 1 + tests/harness/corpus-costs/6206-buffer-is-buffer-views.ts.json | 1 + tests/harness/corpus-costs/6207-fs-constants-object.ts.json | 1 + tests/harness/corpus-costs/6208-fs-constants-require.cjs.json | 1 + tests/harness/corpus-costs/6209-buffer-module-alias.cjs.json | 1 + .../corpus-costs/6210-typeof-global-static-members.js.json | 1 + tests/harness/npm.test.ts | 2 +- 13 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 tests/harness/corpus-costs/1112-number-methods.ts.json create mode 100644 tests/harness/corpus-costs/6200-string-annexb-locale-methods.ts.json create mode 100644 tests/harness/corpus-costs/6201-number-precision-exponential.ts.json create mode 100644 tests/harness/corpus-costs/6202-dyn-string-number-methods.js.json create mode 100644 tests/harness/corpus-costs/6203-any-string-number-methods.ts.json create mode 100644 tests/harness/corpus-costs/6204-crypto-get-random-values.ts.json create mode 100644 tests/harness/corpus-costs/6205-crypto-get-random-values.js.json create mode 100644 tests/harness/corpus-costs/6206-buffer-is-buffer-views.ts.json create mode 100644 tests/harness/corpus-costs/6207-fs-constants-object.ts.json create mode 100644 tests/harness/corpus-costs/6208-fs-constants-require.cjs.json create mode 100644 tests/harness/corpus-costs/6209-buffer-module-alias.cjs.json create mode 100644 tests/harness/corpus-costs/6210-typeof-global-static-members.js.json diff --git a/tests/harness/corpus-costs/1112-number-methods.ts.json b/tests/harness/corpus-costs/1112-number-methods.ts.json new file mode 100644 index 000000000..b99785c0f --- /dev/null +++ b/tests/harness/corpus-costs/1112-number-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 3859, "san": 43669 } diff --git a/tests/harness/corpus-costs/6200-string-annexb-locale-methods.ts.json b/tests/harness/corpus-costs/6200-string-annexb-locale-methods.ts.json new file mode 100644 index 000000000..645cfe9a7 --- /dev/null +++ b/tests/harness/corpus-costs/6200-string-annexb-locale-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2795, "san": 4147 } diff --git a/tests/harness/corpus-costs/6201-number-precision-exponential.ts.json b/tests/harness/corpus-costs/6201-number-precision-exponential.ts.json new file mode 100644 index 000000000..98160b1ea --- /dev/null +++ b/tests/harness/corpus-costs/6201-number-precision-exponential.ts.json @@ -0,0 +1 @@ +{ "plain": 1595, "san": 2653 } diff --git a/tests/harness/corpus-costs/6202-dyn-string-number-methods.js.json b/tests/harness/corpus-costs/6202-dyn-string-number-methods.js.json new file mode 100644 index 000000000..d4cc558f1 --- /dev/null +++ b/tests/harness/corpus-costs/6202-dyn-string-number-methods.js.json @@ -0,0 +1 @@ +{ "plain": 4232, "san": 4434 } diff --git a/tests/harness/corpus-costs/6203-any-string-number-methods.ts.json b/tests/harness/corpus-costs/6203-any-string-number-methods.ts.json new file mode 100644 index 000000000..27c124839 --- /dev/null +++ b/tests/harness/corpus-costs/6203-any-string-number-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2843, "san": 5904 } diff --git a/tests/harness/corpus-costs/6204-crypto-get-random-values.ts.json b/tests/harness/corpus-costs/6204-crypto-get-random-values.ts.json new file mode 100644 index 000000000..ecb1d73b4 --- /dev/null +++ b/tests/harness/corpus-costs/6204-crypto-get-random-values.ts.json @@ -0,0 +1 @@ +{ "plain": 3066, "san": 3476 } diff --git a/tests/harness/corpus-costs/6205-crypto-get-random-values.js.json b/tests/harness/corpus-costs/6205-crypto-get-random-values.js.json new file mode 100644 index 000000000..dff4ea74a --- /dev/null +++ b/tests/harness/corpus-costs/6205-crypto-get-random-values.js.json @@ -0,0 +1 @@ +{ "plain": 2527, "san": 3675 } diff --git a/tests/harness/corpus-costs/6206-buffer-is-buffer-views.ts.json b/tests/harness/corpus-costs/6206-buffer-is-buffer-views.ts.json new file mode 100644 index 000000000..80af3c965 --- /dev/null +++ b/tests/harness/corpus-costs/6206-buffer-is-buffer-views.ts.json @@ -0,0 +1 @@ +{ "plain": 3062, "san": 3821 } diff --git a/tests/harness/corpus-costs/6207-fs-constants-object.ts.json b/tests/harness/corpus-costs/6207-fs-constants-object.ts.json new file mode 100644 index 000000000..9a68cf16c --- /dev/null +++ b/tests/harness/corpus-costs/6207-fs-constants-object.ts.json @@ -0,0 +1 @@ +{ "plain": 2367, "san": 1937 } diff --git a/tests/harness/corpus-costs/6208-fs-constants-require.cjs.json b/tests/harness/corpus-costs/6208-fs-constants-require.cjs.json new file mode 100644 index 000000000..377f8328c --- /dev/null +++ b/tests/harness/corpus-costs/6208-fs-constants-require.cjs.json @@ -0,0 +1 @@ +{ "plain": 3082, "san": 3887 } diff --git a/tests/harness/corpus-costs/6209-buffer-module-alias.cjs.json b/tests/harness/corpus-costs/6209-buffer-module-alias.cjs.json new file mode 100644 index 000000000..db5f0ebc8 --- /dev/null +++ b/tests/harness/corpus-costs/6209-buffer-module-alias.cjs.json @@ -0,0 +1 @@ +{ "plain": 2310, "san": 4242 } diff --git a/tests/harness/corpus-costs/6210-typeof-global-static-members.js.json b/tests/harness/corpus-costs/6210-typeof-global-static-members.js.json new file mode 100644 index 000000000..ccf3a0f37 --- /dev/null +++ b/tests/harness/corpus-costs/6210-typeof-global-static-members.js.json @@ -0,0 +1 @@ +{ "plain": 3244, "san": 5476 } diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index b6af0dbb6..54207f246 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -52,7 +52,7 @@ const KNOWN: Record = { "esm-missing-default": { build: "SC1090" }, "esm-missing-export": { build: "SC1090" }, "fetch-destructure": { run: "SC1031" }, - "fs-shims": { run: "SC2020" }, + "fs-shims": { run: "SC1101" }, "hoisted-workspace": { build: "SC2013" }, "import-refusal": { build: "SC2020" }, "island-registry": { build: "SC2011" }, From 2f4edcc3587f9521e94e94184dfe1705e8b8248c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 07:47:24 -0700 Subject: [PATCH 12/12] Keep existing objects in JavaScript var and destructured bindings A JavaScript `var` binding hoists to its function, and its slot was created with the checker's type before the initializer lowered. When the initializer was an existing checked-dynamic object (an element of an array built with push, a find result, the object a function returns), the record-typed slot held a converted copy: writes through the binding missed the object that the array or the function's caller still held, and `===` against it was false. `let` and `const` already lowered the initializer first and kept such a value checked-dynamic. A `var` initialized with an existing value now lowers the initializer before creating the slot, and a checked-dynamic value gets a checked-dynamic slot. The same applies to `for (var x of ...)`, to array destructuring with `var`, `let` and `const`, and to file-scope bindings, whose module-entry `undefined` store follows the slot's final type. A literal holding such an object builds checked-dynamic for a `var` as it already did for `let`. TypeScript files and annotated bindings are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/containers/for-of.ts | 6 +- .../src/frontend/lowering/lower-modules.ts | 15 ++ .../src/frontend/lowering/lower-stmts.ts | 153 ++++++++++++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 15 +- .../corpus/6260-js-var-dyn-identity.js.json | 6 + ...261-js-destructure-for-of-identity.js.json | 6 + .../6262-js-file-scope-var-identity.js.json | 6 + .../6263-js-factory-return-identity.cjs.json | 6 + tests/corpus/6260-js-var-dyn-identity.js | 103 ++++++++++++ .../6261-js-destructure-for-of-identity.js | 46 ++++++ .../corpus/6262-js-file-scope-var-identity.js | 40 +++++ .../6263-js-factory-return-identity.cjs | 56 +++++++ 12 files changed, 441 insertions(+), 17 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6260-js-var-dyn-identity.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6261-js-destructure-for-of-identity.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-js-file-scope-var-identity.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-js-factory-return-identity.cjs.json create mode 100644 tests/corpus/6260-js-var-dyn-identity.js create mode 100644 tests/corpus/6261-js-destructure-for-of-identity.js create mode 100644 tests/corpus/6262-js-file-scope-var-identity.js create mode 100644 tests/corpus/6263-js-factory-return-identity.cjs diff --git a/packages/compiler/src/frontend/lowering/containers/for-of.ts b/packages/compiler/src/frontend/lowering/containers/for-of.ts index 79f970eb1..2331ee25a 100644 --- a/packages/compiler/src/frontend/lowering/containers/for-of.ts +++ b/packages/compiler/src/frontend/lowering/containers/for-of.ts @@ -153,7 +153,7 @@ export function lowerForOfSearchParams( } : singleRead(); if (ts.isIdentifier(decl.name)) { - const varTarget = forOfVarTarget(lowerer, decl); + const varTarget = forOfVarTarget(lowerer, decl, elemT!); if (varTarget) { // `for (var x of ...)`: one function-scoped binding, assigned // per pass — closures made in the loop share it, and the value @@ -357,7 +357,7 @@ export function lowerForOfArrayIter( } : keyRead(); if (ts.isIdentifier(decl.name)) { - const varTarget = forOfVarTarget(lowerer, decl); + const varTarget = forOfVarTarget(lowerer, decl, declT!); if (varTarget) { // `for (var x of ...)`: one function-scoped binding, assigned // per pass — closures made in the loop share it, and the value @@ -650,7 +650,7 @@ function lowerForOfMapOrSet( } : singleRead(); if (ts.isIdentifier(decl!.name)) { - const varTarget = forOfVarTarget(lowerer, decl!); + const varTarget = forOfVarTarget(lowerer, decl!, elemT!); if (varTarget) { // `for (var x of ...)`: one function-scoped binding, assigned // per pass — closures made in the loop share it, and the value diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 96e22f208..e97e8a68f 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -2877,11 +2877,16 @@ export function lowerFileInit( // a function called above the declaration reads `undefined`, exactly // Node); checked-dynamic var globals hold the dyn undefined the same // way. After the guard: a cache-hit revisit must not reset them. + // A union slot the body retypes checked-dynamic (lowerVarDecl: a + // JavaScript var initialized with an existing checked-dynamic object) + // gets the dyn undefined once the body has lowered. + const unionEntryInits: { at: number; g: IrGlobal }[] = []; for (const g of lowerer.varGlobalEntryInits.get(sf) ?? []) { if (g.type.kind !== "union" && g.type.kind !== "dyn") continue; const wrapped = g.type.kind === "dyn" ? dynUndefinedExpr(loc0) : lowerer.unassignedSlotInit(g.type, loc0); if (wrapped) { + if (g.type.kind === "union") unionEntryInits.push({ at: header.length, g }); // A let global whose early reads are checked (initFlag) only // gets its storage filled here: this is not the source write, so // it must neither run the TDZ check nor set the flag. @@ -2976,6 +2981,16 @@ export function lowerFileInit( ); at++; } + for (const { at, g } of unionEntryInits) { + if (g.type.kind !== "dyn") continue; + body[at] = { + kind: "assign", + localId: g.id, + value: dynUndefinedExpr(loc0), + ...(g.initFlag !== undefined ? { initializes: true as const } : {}), + loc: loc0, + }; + } const moduleRef = cjsModuleRef(lowerer, sf); if (moduleRef !== null) { const guardedBody = body.splice(guardId !== undefined ? 2 : 0); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 36abb9130..91e8b8740 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -191,6 +191,7 @@ import { import { globalSymbolKey } from "./expressions/global-symbols.js"; import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js"; import { + jsHoldsDynObject, lowerEnvironmentKey, lowerNativeFunctionAssignment, lowerStrictEquality, @@ -1067,13 +1068,124 @@ function hostVariableDeclarationOf(name: ts.Identifier): ts.VariableDeclaration export function forOfVarTarget( lowerer: Lowerer, decl: ts.VariableDeclaration, + valueType?: IrType, ): { id: string; type: IrType } | null { if (!ts.isIdentifier(decl.name) || !isVarDeclared(decl)) return null; const symbol = lowerer.checker.getSymbolAtLocation(decl.name); if (!symbol) return null; const g = lowerer.globalsBySymbol.get(symbol); - if (g) return g; - return hoistVarBinding(lowerer, symbol, decl.name); + if (g) { + if (valueType?.kind === "dyn" && jsBindingKeepsCheckedObject(lowerer, decl.name, g.type)) + g.type = DYN; + return g; + } + return hoistVarBinding(lowerer, symbol, decl.name, valueType); +} + +/** JavaScript: whether an unannotated variable binding whose static type + * is `type` keeps a checked-dynamic value unconverted. The checker types + * such a binding by the object it receives (an element of an evolving + * `[]`, a `find` result, a member of a checked-dynamic object), but the + * value is an existing checked-dynamic object or array: a record- or + * array-typed slot would hold a converted copy, so writes through the + * binding would miss the object other references see, and `===` against + * them would fail. Block-scoped bindings with an identifier name keep such + * values by the same rule in lowerVarDecl (preservesObjectIdentity). */ +function jsBindingKeepsCheckedObject( + lowerer: Lowerer, + nameNode: ts.Identifier, + type: IrType, +): boolean { + if (!isJsSourceFile(nameNode.getSourceFile())) return false; + const host = hostVariableDeclarationOf(nameNode); + if (!host || host.type || hasJsTypeAnnotation(host)) return false; + const arms = type.kind === "union" ? (lowerer.unions.get(type.unionId)?.arms ?? []) : [type]; + return ( + arms.some((arm) => arm.kind === "record" || arm.kind === "array") && + arms.every((arm) => arm.kind === "record" || arm.kind === "array" || isUnitType(arm)) + ); +} + +/** Whether `node` mentions the binding `symbol` anywhere, nested functions + * included. */ +function mentionsSymbol(lowerer: Lowerer, node: ts.Node, symbol: ts.Symbol): boolean { + if (ts.isIdentifier(node)) return lowerer.checker.getSymbolAtLocation(node) === symbol; + return ( + node.forEachChild((child) => (mentionsSymbol(lowerer, child, symbol) ? true : undefined)) === + true + ); +} + +/** Whether `node` evaluates to an existing value rather than constructing + * one whose layout a destination directs: not a literal, `new`, function + * or class expression, nor a conditional with such an arm (an arm that + * is `null` or `undefined` constructs nothing). */ +function existingValueForm(node: ts.Expression, armOfConditional = false): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (ts.isConditionalExpression(node)) + return existingValueForm(node.whenTrue, true) && existingValueForm(node.whenFalse, true); + if ( + armOfConditional && + (node.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(node) && node.text === "undefined")) + ) + return true; + return !( + ts.isObjectLiteralExpression(node) || + ts.isArrayLiteralExpression(node) || + ts.isNewExpression(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isClassExpression(node) || + ts.isLiteralExpression(node) || + node.kind === ts.SyntaxKind.NullKeyword + ); +} + +/** JavaScript `var x = e` whose binding could hold an existing + * checked-dynamic object (jsBindingKeepsCheckedObject): lowers `e` BEFORE + * minting the hoisted slot, as a block-scoped declaration does, so a + * checked-dynamic value gets a checked-dynamic slot instead of a converted + * copy. Other values coerce into the planned slot exactly as + * lowerExprExpecting would. Null when the declaration does not qualify: + * the slot already exists, the initializer is fresh construction whose + * layout the slot type directs (existingValueForm; a literal holding an + * existing checked-dynamic object qualifies), or it refers to the binding + * itself (hoist-before-init resolves that reference). */ +function lowerJsVarFromExisting( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + symbol: ts.Symbol, +): IrStmt | null { + if (!ts.isIdentifier(decl.name) || !decl.initializer) return null; + if (!isJsSourceFile(decl.getSourceFile()) || lowerer.ctx.hoistedVars.has(symbol)) return null; + if (lowerer.bindingIn(lowerer.ctx, symbol)) return null; + let node: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) + node = node.expression; + // A literal holding an existing checked-dynamic object builds as a + // checked-dynamic literal without a destination (lowerArrayLiteral, + // lowerObjectLiteral): a record- or array-typed slot would copy it. + const holdsChecked = + (ts.isArrayLiteralExpression(node) || ts.isObjectLiteralExpression(node)) && + jsHoldsDynObject(lowerer, node); + if (!holdsChecked && !existingValueForm(node)) return null; + const planned = varBindingType(lowerer, decl.name); + if (!planned || !jsBindingKeepsCheckedObject(lowerer, decl.name, planned)) return null; + if (mentionsSymbol(lowerer, node, symbol)) return null; + const value = lowerer.lowerExpr(node); + const local = hoistVarBinding( + lowerer, + symbol, + decl.name, + value.type.kind === "dyn" ? DYN : undefined, + ); + return { + kind: "assign", + localId: local.id, + value: lowerer.coerceExistingInto(node, value, local.type), + loc: locOf(decl), + }; } /** The `var` symbol's function-scoped binding TYPE — the declared type at @@ -1129,7 +1241,12 @@ function varBindingType(lowerer: Lowerer, nameNode: ts.Identifier): IrType | nul * a loop re-ASSIGNS the one binding per pass and never resets it — the * classic capture semantics fall out: closures made in a loop share the * single boxed binding, where `let` gets a fresh box per iteration. */ -function hoistVarBinding(lowerer: Lowerer, symbol: ts.Symbol, nameNode: ts.Identifier): IrLocal { +function hoistVarBinding( + lowerer: Lowerer, + symbol: ts.Symbol, + nameNode: ts.Identifier, + valueType?: IrType, +): IrLocal { const existing = lowerer.ctx.hoistedVars.get(symbol); if (existing) return existing; // The parameter merge: the symbol already binds a function-root local. @@ -1138,7 +1255,15 @@ function hoistVarBinding(lowerer: Lowerer, symbol: ts.Symbol, nameNode: ts.Ident lowerer.ctx.hoistedVars.set(symbol, bound); return bound; } - const type = varBindingType(lowerer, nameNode); + let type = varBindingType(lowerer, nameNode); + // The first value stored is an existing checked-dynamic object: keep it + // (jsBindingKeepsCheckedObject). A checked-dynamic slot holds undefined + // natively, so no runtime-optional widening applies. + const keepsChecked = + valueType?.kind === "dyn" && + type !== null && + jsBindingKeepsCheckedObject(lowerer, nameNode, type); + if (keepsChecked) type = DYN; if (!type) lowerer.badType(nameNode, lowerer.typeOf(nameNode)); const root = lowerer.activeStmtLists.find((e) => e.ctx === lowerer.ctx); if (!root) @@ -1155,7 +1280,7 @@ function hoistVarBinding(lowerer: Lowerer, symbol: ts.Symbol, nameNode: ts.Ident }; lowerer.ctx.locals.push(local); lowerer.ctx.scopes[0]!.set(symbol, local); - noteEarlyReadVar(lowerer, symbol, local); + if (!keepsChecked) noteEarlyReadVar(lowerer, symbol, local); // A checked-dynamic slot holds the dyn undefined (a NULL dyn is a // trap); everything else rides unassignedSlotInit (interned union arm, @@ -4197,6 +4322,8 @@ export function bindPatternTarget( const symbol = lowerer.checker.getSymbolAtLocation(name); const g = symbol ? lowerer.globalsBySymbol.get(symbol) : undefined; if (g) { + if (value.type.kind === "dyn" && jsBindingKeepsCheckedObject(lowerer, name, g.type)) + g.type = DYN; out.push({ kind: "assign", localId: g.id, @@ -4213,7 +4340,7 @@ export function bindPatternTarget( // here by declareParams) carry no block-scope flag either, but their // bindings are the parameters' own. if (symbol && hostVariableDeclarationOf(name) !== null && isVarDeclared(name)) { - const hoisted = hoistVarBinding(lowerer, symbol, name); + const hoisted = hoistVarBinding(lowerer, symbol, name, value.type); out.push({ kind: "assign", localId: hoisted.id, @@ -4228,7 +4355,7 @@ export function bindPatternTarget( value.type.kind === "dyn" && (() => { const mapped = lowerer.mapTypeOf(lowerer.typeOf(name)); - return mapped === null; + return mapped === null || jsBindingKeepsCheckedObject(lowerer, name, mapped); })(); let type = dynStays ? DYN @@ -4711,7 +4838,11 @@ export function lowerVarDecl( !decl.type && !hasJsTypeAnnotation(decl) && raw.type.kind === "dyn" && - (g.type.kind === "record" || g.type.kind === "object") + (g.type.kind === "record" || + g.type.kind === "object" || + (ts.isIdentifier(decl.name) && + jsBindingKeepsCheckedObject(lowerer, decl.name, g.type) && + (existingValueForm(decl.initializer) || jsHoldsDynObject(lowerer, decl.initializer)))) ) g.type = DYN; g.type = inferredOptionalRecordType(lowerer, decl, g.type, raw.type); @@ -4758,6 +4889,8 @@ export function lowerVarDecl( // types already hold the interned undefined arm), and a declaration // re-run in a loop must not reset the persisting value. if (isVarDeclared(decl) && declSymbol) { + const fromExisting = lowerJsVarFromExisting(lowerer, decl, declSymbol); + if (fromExisting) return fromExisting; const local = hoistVarBinding(lowerer, declSymbol, decl.name); if (!decl.initializer) return null; const init = lowerer.lowerExprExpecting(decl.initializer, local.type); @@ -9220,7 +9353,7 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { // local and the body opens by ASSIGNING it into the one hoisted // binding — closures capture the shared slot, and the value persists // after the loop (both Node-exact for var). - const varTarget = forOfVarTarget(lowerer, decl); + const varTarget = forOfVarTarget(lowerer, decl, elemValueT); if (varTarget) { const elemType = elemValueT; const loc = locOf(decl.name); @@ -9956,7 +10089,7 @@ function lowerForOfDyn( binds, ); } else if (ts.isIdentifier(decl.name)) { - const varTarget = forOfVarTarget(lowerer, decl); + const varTarget = forOfVarTarget(lowerer, decl, DYN); if (varTarget) { // `for (var x of d)`: the element mechanics stay on a hidden // per-iteration local; the body opens by assigning the ONE diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d252af250..1e1c1a9d3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9645,16 +9645,23 @@ export class Lowerer { } } } - let e = this.lowerExpr(node); + const e = this.lowerExpr(node); + return expected ? this.coerceExistingInto(node, e, expected) : e; + } + + /** The conversion lowerExprExpecting applies to `e`, the lowered value + * of `node` when it is not fresh construction the destination directs + * (a literal, conditional, or empty collection). */ + coerceExistingInto(node: ts.Expression, e: IrExpr, expected: IrType): IrExpr { if ( - (expected?.kind === "union" && this.armTag(expected.unionId, UNDEFINED_T) >= 0) || - (expected?.kind === "dyn" && isJsSourceFile(node.getSourceFile())) + (expected.kind === "union" && this.armTag(expected.unionId, UNDEFINED_T) >= 0) || + (expected.kind === "dyn" && isJsSourceFile(node.getSourceFile())) ) { // A destination that accepts undefined must retain an unchecked // read's storage value even when the checker still calls it present. e = this.runtimeOptionalSourceValue(node, e) ?? e; } - return expected ? this.coerceInto(node, e, expected) : e; + return this.coerceInto(node, e, expected); } /** An unannotated empty collection has no existing elements or aliases diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6260-js-var-dyn-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6260-js-var-dyn-identity.js.json new file mode 100644 index 000000000..bcd9bcf0f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6260-js-var-dyn-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6260-js-var-dyn-identity.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6261-js-destructure-for-of-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6261-js-destructure-for-of-identity.js.json new file mode 100644 index 000000000..3733ef399 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6261-js-destructure-for-of-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6261-js-destructure-for-of-identity.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-js-file-scope-var-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-js-file-scope-var-identity.js.json new file mode 100644 index 000000000..2db0c3083 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6262-js-file-scope-var-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6262-js-file-scope-var-identity.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-js-factory-return-identity.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-js-factory-return-identity.cjs.json new file mode 100644 index 000000000..d0e4d3fcb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6263-js-factory-return-identity.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6263-js-factory-return-identity.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/6260-js-var-dyn-identity.js b/tests/corpus/6260-js-var-dyn-identity.js new file mode 100644 index 000000000..f22ce2888 --- /dev/null +++ b/tests/corpus/6260-js-var-dyn-identity.js @@ -0,0 +1,103 @@ +// JavaScript: a function-scoped `var` initialized with an object that +// already exists (an element of an array built with push, a find result, +// a member of another object) holds that object, not a copy: writes +// through the binding are visible through the array, and `===` holds. +function make(i) { + return { id: i, x: i % 17 }; +} + +function updateInPlace() { + var all = []; + for (var i = 0; i < 3; i++) all.push(make(i)); + var same = 0; + for (var j = 0; j < all.length; j++) { + var o = all[j]; + o.x += 1; + if (o === all[j]) same++; + } + console.log(JSON.stringify(all), same); +} +updateInPlace(); + +function literalElements() { + var all = []; + for (var i = 0; i < 3; i++) all.push({ id: i, x: i }); + for (var j = 0; j < all.length; j++) { + var o = all[j]; + o.x *= 10; + } + console.log(JSON.stringify(all)); +} +literalElements(); + +function initializerForms(flag) { + var all = []; + all.push({ n: 0, inner: { v: 1 } }); + all.push({ n: 1, inner: { v: 2 } }); + var found = all.find(function (e) { + return e.n === 1; + }); + found.n = 11; + var picked = flag ? all[0] : null; + picked.n = 10; + var either = all[5] || all[0]; + either.inner.v += 100; + var inner = all[1].inner; + inner.v = 20; + var last = all.pop(); + console.log(JSON.stringify(all), last === found, picked === either); + console.log(JSON.stringify(last)); +} +initializerForms(true); + +function arraysAndClosures() { + var grid = []; + grid.push([1, 2]); + var row = grid[0]; + row[0] = 9; + row.push(3); + var list = []; + list.push({ count: 0 }); + var target = list[0]; + var bump = function () { + target.count++; + }; + bump(); + bump(); + console.log(JSON.stringify(grid), JSON.stringify(list), target === list[0]); +} +arraysAndClosures(); + +function redeclaredAndReassigned() { + var all = []; + all.push({ x: 1 }); + all.push({ x: 2 }); + var o = all[0]; + o.x = 10; + var o = all[1]; + o.x = 20; + o = { x: 30 }; + all.push(o); + o.x = 40; + console.log(JSON.stringify(all), o === all[2]); +} +redeclaredAndReassigned(); + +const arrow = () => { + var all = []; + all.push({ x: 1 }); + var o = all[0]; + o.x = 5; + console.log(JSON.stringify(all)); +}; +arrow(); + +// An annotated binding keeps its declared type. +function annotated() { + var all = []; + all.push({ x: 1 }); + /** @type {{ x: number }} */ + var o = all[0]; + console.log(o.x); +} +annotated(); diff --git a/tests/corpus/6261-js-destructure-for-of-identity.js b/tests/corpus/6261-js-destructure-for-of-identity.js new file mode 100644 index 000000000..a8821b3b4 --- /dev/null +++ b/tests/corpus/6261-js-destructure-for-of-identity.js @@ -0,0 +1,46 @@ +// JavaScript: array destructuring (var, let and const) and `for (var ... +// of ...)` bind the existing element objects of an array built with push, +// so writes through the bindings reach the array's objects. +function destructure() { + var all = []; + all.push({ x: 1 }); + all.push({ x: 2 }); + var [first] = all; + first.x = 10; + let [, second] = all; + second.x = 20; + const [again] = all; + again.x += 1; + console.log(JSON.stringify(all), first === all[0], again === first, second === all[1]); +} +destructure(); + +function forVarOf() { + var all = []; + all.push({ x: 1 }); + all.push({ x: 2 }); + var seen = []; + for (var o of all) { + o.x *= 10; + seen.push(o === all[seen.length]); + } + console.log(JSON.stringify(all), seen.join()); + for (let p of all) p.x += 1; + console.log(JSON.stringify(all)); +} +forVarOf(); + +function* tagged(list) { + for (var o of list) { + o.seen = true; + yield o; + } +} +function generators() { + var items = []; + items.push({ seen: false }); + items.push({ seen: false }); + for (var it of tagged(items)) it.extra = items.indexOf(it); + console.log(JSON.stringify(items)); +} +generators(); diff --git a/tests/corpus/6262-js-file-scope-var-identity.js b/tests/corpus/6262-js-file-scope-var-identity.js new file mode 100644 index 000000000..cb8678b5f --- /dev/null +++ b/tests/corpus/6262-js-file-scope-var-identity.js @@ -0,0 +1,40 @@ +// JavaScript: file-scope `var` and `let` bindings initialized with an +// existing object (an element of an array built with push) hold that +// object, including bindings declared in a loop body, by destructuring or +// by for-of, and functions declared above them read the same object. +function show() { + return typeof o === "undefined" ? "unset" : o.x; +} +console.log(show()); + +var all = []; +all.push({ x: 1 }); +all.push({ x: 2 }); + +var o = all[0]; +o.x = 10; +console.log(show(), JSON.stringify(all), o === all[0]); + +var same = 0; +for (var j = 0; j < all.length; j++) { + var item = all[j]; + item.x += 5; + if (item === all[j]) same++; +} +console.log(JSON.stringify(all), same); + +var [head] = all; +head.x = 100; +for (var each of all) { + each.x += 1; + if (each === all[1]) console.log("last", each.x); +} +console.log(JSON.stringify(all), head === o); + +let current = all[1]; +current.x = 7; +function touch() { + current.x++; +} +touch(); +console.log(JSON.stringify(all), current === all[1]); diff --git a/tests/corpus/6263-js-factory-return-identity.cjs b/tests/corpus/6263-js-factory-return-identity.cjs new file mode 100644 index 000000000..eb3f77557 --- /dev/null +++ b/tests/corpus/6263-js-factory-return-identity.cjs @@ -0,0 +1,56 @@ +// JavaScript: an object a function returns is the object the function +// holds elsewhere (a cache entry, a registry element). Bindings that +// receive it and array or object literals built from those bindings keep +// that object, so writes through them reach the cache and registry. +'use strict'; +const cache = { n: 0 }; +function get() { + return cache; +} + +const registry = []; +function make(name) { + const o = { name: name, hits: 0 }; + registry.push(o); + return o; +} + +function identity(v) { + return v; +} +function wrapped(i) { + return identity({ id: i, x: i % 17 }); +} + +function main() { + var a = get(); + a.n = 5; + console.log(cache.n, a === cache); + var list = [a]; + list[0].n = 6; + var holder = { item: a }; + holder.item.n += 1; + console.log(cache.n, list[0] === cache, holder.item === cache); + + var x = make("x"); + let y = make("y"); + const z = make("z"); + x.hits++; + y.hits += 2; + z.hits += 3; + var group = [x, y, z]; + for (var j = 0; j < group.length; j++) group[j].hits *= 10; + console.log(JSON.stringify(registry), group[0] === registry[0]); + + var all = []; + for (var i = 0; i < 4; i++) all.push(wrapped(i)); + var sum = 0; + for (var r = 0; r < 3; r++) + for (var k = 0; k < all.length; k++) { + var e = all[k]; + e.x += 1; + sum += e.x; + } + console.log(all.length, sum, JSON.stringify(all.slice(0, 2))); +} +main();