From efc22a2fd38787181a20a8df0cf042e3bc2cd082 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 17:00:03 -0700 Subject: [PATCH 1/8] Box user module namespaces as dynamic values A `file:` module namespace handed to typed code now boxes as Node's namespace object instead of the "converting typed values to 'unknown'" fence: `const u: unknown = ns`, `take(ns)`, a `Promise` widened to `Promise` (or `Promise | undefined`), and `await import("./m")` in an `unknown` slot all see one non-extensible null-prototype object per module whose own properties are the module's exports in Node's sorted order. They are enumerable read-only accessors whose reads call the compiled namespace getter, so they stay live, and the object carries Symbol.toStringTag "Module", Node's strict-mode write TypeErrors, the not-extensible add error and the ToPrimitive throw. `node:` builtins have no compiled export table and keep their SC1101 fence. The object is built by a new gated runtime unit, packages/runtime/src/scr_module_namespace_dyn.c, selected by the `moduleNamespaceDyn` runtime feature (`module.namespaceDyn` in the IR). It adds no field to ScrDynHandleOps and no branch to a shared unit: the core reaches it through the ordinary SCR_DYN_OBJ paths, so key listing, for-in, `in`, JSON, identity, property reads and assignment all reuse the existing dispatch. The compiler reuses the getter lifted for module.namespaceValue (existing programs' IR is unchanged) and adds moduleNamespacePromiseDynValue, one lifted async mapper per module for the promise slots. Zero cost: a program that never boxes a namespace links exactly what it did. The zero-cost gate reports every program identical, with byte-identical IR and LLVM; the runtime pack's only difference is the new unit (0 changed runtime functions), and among the namespace corpus programs only 8412 links it. Evidence: tests/corpus/8412-module-namespace-untyped is a differential program against Node in both the plain and sanitized lanes, and the diagnostics snapshots and TypeScript 7 baselines stay unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/native-link-info-core.ts | 3 + .../compiler/src/backend/native/contracts.ts | 3 + .../compiler/src/backend/native/executable.ts | 2 + .../compiler/src/backend/runtime-pack-core.ts | 2 + .../src/executable/executable-cache.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 105 ++++++++++++- .../lowering/module-namespace-elements.ts | 108 +++++++++++-- packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 + packages/compiler/src/ir/runtime-features.ts | 3 + .../compiler/src/native/sanitizer.test.ts | 1 + .../main.ts.json | 7 + .../runtime-pack-matrix.mjs | 2 + packages/runtime/src/scr_blob.h | 7 + .../runtime/src/scr_module_namespace_dyn.c | 143 ++++++++++++++++++ .../8412-module-namespace-untyped/main.ts | 70 +++++++++ .../8412-module-namespace-untyped/mod.ts | 12 ++ .../main.ts.json | 1 + 20 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json create mode 100644 packages/runtime/src/scr_module_namespace_dyn.c create mode 100644 tests/corpus/8412-module-namespace-untyped/main.ts create mode 100644 tests/corpus/8412-module-namespace-untyped/mod.ts create mode 100644 tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 73bce21b6..7df8d7d3c 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -22,6 +22,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 185c85638..dc3a4f108 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -305,6 +305,7 @@ export const LIB_FN_SYMS: Record = { "process.builtinModule": "scr_process_builtin_module", "process.builtinUnsupported": "scr_process_builtin_unsupported", "module.namespaceValue": "scr_module_namespace_value", + "module.namespaceDyn": "scr_module_namespace_dyn_value", "fs.callbackValue": "scr_fs_callback_value", "fs.callbackCall": "scr_fs_callback_call", "process.hrtimeValue": "scr_process_hrtime_value", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 49681c986..6c282b74d 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -40,6 +40,9 @@ export interface NativeLinkFeatures { /** Module namespace objects as checked-dynamic values link * scr_module_namespace.c; absent (false) otherwise. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code links + * scr_module_namespace_dyn.c; absent (false) otherwise. */ + moduleNamespaceDyn?: boolean; zlib: boolean; /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */ brotli?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index ba475be61..370bde77e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -164,6 +164,9 @@ export interface CcOptions { /** Module namespace objects as checked-dynamic values: compiles * scr_module_namespace.c. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code: compiles + * scr_module_namespace_dyn.c. */ + moduleNamespaceDyn?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 1d9f24f03..98140888b 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -202,6 +202,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.wasm, "scr_wasm.c"), // Module namespace objects as checked-dynamic values. unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"), + // A module namespace as the frozen null-prototype object typed code sees. + unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"), unit( (u) => u.opts.dynAsync || diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index af4c9f874..a4884300c 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -103,6 +103,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; + moduleNamespaceDyn: boolean; nativeFetch: boolean; abortSignalOnly: boolean; netEffective: boolean; @@ -312,6 +313,7 @@ export function effectiveRuntimeFeatures( nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, + moduleNamespaceDyn: features.moduleNamespaceDyn === true, nativeFetch, // The fetch unit has AbortController and AbortSignal; scr_abort.c is // the same implementation without the fetch transport. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index a7f123860..bc43e2d39 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -67,6 +67,7 @@ export interface EarlyExecutableNativeFeatures { stdioExtended?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; + moduleNamespaceDyn?: boolean; zlib: boolean; brotli?: boolean; assert: boolean; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 4c521386f..92a84c5f1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -41,7 +41,11 @@ import { type UnitEvents, type WaveHost, } from "./unit-journal.js"; -import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js"; +import { + moduleNamespaceDynValue, + moduleNamespaceObject, + moduleNamespaceValue, +} from "./module-namespace-elements.js"; import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; @@ -189,6 +193,7 @@ import { captureContextBinding, declareContextLocal, declareContextThis, + newFnCtx, type FnCtx, } from "./function-context.js"; import type { FrontendServices } from "../services.js"; @@ -5120,6 +5125,79 @@ export class Lowerer { return this.moduleOrder.find((sf) => tsgoPath(resolve(sf.fileName)) === path) ?? null; } + /** A module namespace taken as a checked-dynamic value (`const u: unknown + * = ns`, `take(ns)`): the runtime's namespace object — one per module, + * Node's sorted export keys, live reads through a compiled getter, and + * Node's read-only write errors — built by `moduleNamespaceDynValue`. + * `node:` builtins have no compiled source and no complete export table, + * so they keep their fence. */ + moduleNamespaceDyn(ns: IrExpr & { type: IrType & { kind: "moduleNs" } }): IrExpr | null { + if (!ns.type.moduleId.startsWith("file:")) return null; + return moduleNamespaceDynValue(this, ns); + } + + /** A `Promise` taken as `Promise` (or as + * `unknown`): one lifted async mapper per module awaits the promise and + * answers the same namespace object a bare namespace value boxes to, so + * the settlement is Node's namespace while the value stays a promise. + * The import() promise reaches these slots alone, and the runtime's + * per-module namespace cache keeps `await mapped === await original`; a + * rejection propagates unchanged. */ + moduleNamespacePromiseDynValue( + value: IrExpr & { type: IrType & { kind: "promise" } }, + ): IrExpr | null { + const inner = value.type.inner; + if (inner.kind !== "moduleNs" || !inner.moduleId.startsWith("file:")) return null; + const loc = value.loc; + const name = `%module.promise:${inner.moduleId}`; + const promiseT: IrType & { kind: "promise" } = { kind: "promise", inner }; + if (!this.liftedFns.some((fn) => fn.name === name)) { + const context = newFnCtx(false, null, null, DYN); + context.isAsync = true; + const previousClass = this.currentClass; + this.currentClass = null; + this.fnStack.push(context); + let namespace: IrExpr | null = null; + try { + const awaited: IrExpr & { type: IrType & { kind: "moduleNs" } } = { + kind: "awaitExpr", + value: varRef("promise", promiseT, loc), + type: inner, + loc, + }; + namespace = moduleNamespaceDynValue(this, awaited); + if (namespace) { + const fn: IrFunction = { + name, + params: [{ localId: "promise", name: "promise", type: promiseT }], + returnType: DYN, + locals: [ + { id: "promise", name: "promise", type: promiseT, mutable: false }, + ...context.locals, + ], + body: [{ kind: "return", value: namespace, loc }], + async: true, + loc, + }; + this.liftedFns.push(fn); + } + } finally { + this.fnStack.pop(); + this.currentClass = previousClass; + } + // A namespace with no compiled source has no dynamic value: keep the + // promise unconverted so the destination's own fence reports it. + if (!namespace) return null; + } + return { + kind: "call", + callee: name, + args: [value], + type: { kind: "promise", inner: DYN }, + loc, + }; + } + builtinErrorInfoOf(symbol: ts.Symbol | null | undefined): ClassInfo | null { return builtinErrorInfoOf(this, symbol); } @@ -11869,6 +11947,21 @@ export class Lowerer { const result = this.coerceToExpected(expr.result, expected); return { ...expr, result, type: result.type }; } + // A `Promise` widening into `Promise`, or + // into an `unknown` slot: map the payload through the namespace's + // dynamic value (moduleNamespacePromiseDynValue). A destination that + // wants the namespace type itself keeps the promise unchanged; the + // union path reuses this through its single-promise-arm conversion. + if ( + expr.type.kind === "promise" && + expr.type.inner.kind === "moduleNs" && + (expected.kind === "dyn" || (expected.kind === "promise" && expected.inner.kind === "dyn")) + ) { + const mapped = this.moduleNamespacePromiseDynValue( + expr as IrExpr & { type: IrType & { kind: "promise" } }, + ); + if (mapped) expr = mapped; + } // An instance of a class declared `extends Array` is its array (the // same object); an array slot of another element type converts it as // an array. @@ -12016,6 +12109,16 @@ export class Lowerer { const object = moduleNamespaceObject(this, ns) ?? moduleNamespaceValue(this, ns); if (object) return object; } + // A TypeScript source's module namespace as an untyped value: the + // runtime's namespace object with live reads, Node's sorted keys and + // read-only writes. `node:` builtins have no compiled source or + // complete export table here and keep their fence below. + if (expr.type.kind === "moduleNs") { + const boxed = this.moduleNamespaceDyn( + expr as IrExpr & { type: IrType & { kind: "moduleNs" } }, + ); + if (boxed) return boxed; + } if ( expr.type.kind === "moduleNs" && expr.type.moduleId === "builtin:events" && diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index 2c8e6a475..be94aa34a 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -4,6 +4,7 @@ import * as ts from "../ts7/adapter.js"; import { DYN, STRING, + SYMBOL_T, type IrExpr, type IrFunction, type IrStmt, @@ -173,18 +174,14 @@ export function moduleNamespaceObject( }; } -/** A module namespace taken as an untyped value whose exports a copy - * cannot represent: a binding that can change after initialization (`let`, - * `var`), or an export the copy cannot name. The value is the runtime's - * namespace object (scr_module_namespace_value): one per module, whose - * property reads call a compiled getter for the export's current value and - * whose writes throw Node's TypeError. An export whose value has no - * checked-dynamic form throws its fence when it is read, and listing the - * object's keys or inspecting it throws the "not supported yet" error. */ -export function moduleNamespaceValue( +/** The compiled getter behind a namespace object: one lifted function per + * module that answers an export's current value by name, plus the module's + * sorted export names. An export whose value has no checked-dynamic form + * throws its fence when it is read. */ +function moduleNamespaceGetter( lowerer: Lowerer, ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, -): IrExpr | null { +): { name: string; keys: string[] } | null { const source = lowerer.sourceFileOfModuleNamespace(ns.type); if (!source) return null; const symbol = lowerer.checker.getSymbolAtLocation(source); @@ -263,6 +260,24 @@ export function moduleNamespaceValue( lowerer.currentClass = previousClass; } } + return { name, keys: exports.map(({ key }) => key) }; +} + +/** A module namespace taken as an untyped value whose exports a copy + * cannot represent: a binding that can change after initialization (`let`, + * `var`), or an export the copy cannot name. The value is the runtime's + * namespace object (scr_module_namespace_value): one per module, whose + * property reads call a compiled getter for the export's current value and + * whose writes throw Node's TypeError. An export whose value has no + * checked-dynamic form throws its fence when it is read, and listing the + * object's keys or inspecting it throws the "not supported yet" error. */ +export function moduleNamespaceValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; const value: IrExpr = { kind: "libCall", fn: "module.namespaceValue", @@ -270,13 +285,82 @@ export function moduleNamespaceValue( strLit(ns.type.moduleId, loc), { kind: "arrayLit", - elems: exports.map(({ key }) => strLit(key, loc)), + elems: getter.keys.map((key) => strLit(key, loc)), type: arrayOf(STRING), loc, }, { kind: "dynFrom", - value: { kind: "closure", fnName: name, captures: [], type: funcOf([STRING], DYN), loc }, + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + if (ns.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: ns, loc }], + result: value, + type: DYN, + loc, + }; +} + +/** A module namespace boxed for typed code through a dynamic slot + * (`const u: unknown = ns`, `take(ns)`): Node's namespace object as + * scr_module_namespace_dyn.c builds it — one non-extensible null-prototype + * object per module whose own sorted export names are enumerable read-only + * accessors, whose `Symbol.toStringTag` is "Module", whose reads call the same + * compiled getter, and whose writes and coerces throw Node's TypeErrors. A + * `node:` builtin has no compiled export table, so it keeps its fence. */ +export function moduleNamespaceDynValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; + const value: IrExpr = { + kind: "libCall", + fn: "module.namespaceDyn", + args: [ + strLit(ns.type.moduleId, loc), + { + kind: "arrayLit", + elems: getter.keys.map((key) => strLit(key, loc)), + type: arrayOf(STRING), + loc, + }, + { + kind: "dynFrom", + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + { + kind: "dynFrom", + value: { + kind: "libCall", + fn: "sym.wellKnown", + args: [strLit("toStringTag", loc)], + type: SYMBOL_T, + loc, + }, type: DYN, loc, }, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 90b062580..d9d78bf77 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -695,6 +695,7 @@ async function compileExecutableNative( ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 06ddb5007..a3cf4252c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3577,6 +3577,11 @@ function buildLibFnSignatures() { * (scr_module_namespace_value): the module id, its sorted export names * and the compiled getter. One object per module. Never throws. */ "module.namespaceValue": { argTypes: [STRING, arrayOf(STRING), DYN], result: DYN }, + /** A module namespace as typed code sees it through a dynamic slot + * (scr_module_namespace_dyn_value): the module id, its sorted export + * names, the compiled getter, and the Symbol.toStringTag key. Draws + * Node's frozen null-prototype object, one per module. Never throws. */ + "module.namespaceDyn": { argTypes: [STRING, arrayOf(STRING), DYN, DYN], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, "fs.callbackCall": { argTypes: [STRING, DYN], result: DYN }, "process.hrtimeValue": { argTypes: [], result: DYN }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 04392fe48..6a73d1e0a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1193,6 +1193,8 @@ const RUNTIME_FEATURE_RULES = { /** Module namespace objects as checked-dynamic values * (scr_module_namespace.c). */ moduleNamespace: rule({ fns: ["module.namespaceValue"] }), + /** A module namespace boxed for typed code (scr_module_namespace_dyn.c). */ + moduleNamespaceDyn: rule({ fns: ["module.namespaceDyn"] }), /** The WebAssembly API (scr_wasm.c). */ wasm: rule({ prefixes: ["wasm."] }), /** crypto.subtle importKey/sign/verify/digest and CryptoKey: @@ -1292,6 +1294,7 @@ function noRuntimeFeatures(): RuntimeFeatures { urlIdna: false, stdioExtended: false, moduleNamespace: false, + moduleNamespaceDyn: false, stream: false, streamDyn: false, handleProps: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index f66381659..1a1262490 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -34,6 +34,7 @@ const features = { nodeIsland: false, brotli: false, moduleNamespace: false, + moduleNamespaceDyn: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..9384505a5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8412-module-namespace-untyped/mod.ts", + "/tests/corpus/8412-module-namespace-untyped/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index e26fdb8d8..d14f5ae88 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -90,6 +90,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_perf_loop.c", "perfLoop"], // Module namespace objects as checked-dynamic values. ["scr_module_namespace.c", "moduleNamespace"], + // The same namespace as the frozen null-prototype object typed code sees. + ["scr_module_namespace_dyn.c", "moduleNamespaceDyn"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 0882e41b6..7c7893322 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -34,6 +34,13 @@ ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member); * one handle per module `id`, whose reads call `getter` with the export * name when `keys` lists it. Borrows all three; +1 handle. */ ScrDyn *scr_module_namespace_value(ScrStr *id, ScrArr *keys, ScrDyn *getter); +/* A module namespace as checked-dynamic code sees it + * (scr_module_namespace_dyn.c): one non-extensible null-prototype object per + * module `id`, Node's sorted export names as enumerable read-only accessors + * whose getters call `getter` with the name, `Symbol.toStringTag` "Module" + * (the well-known symbol is `tag_key`), and Node's strict-mode write + * TypeErrors. Borrows all four; +1 object. */ +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key); ScrDyn *scr_global_native(ScrArr *known); ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)); ScrDyn *scr_console_native(void); diff --git a/packages/runtime/src/scr_module_namespace_dyn.c b/packages/runtime/src/scr_module_namespace_dyn.c new file mode 100644 index 000000000..632a09905 --- /dev/null +++ b/packages/runtime/src/scr_module_namespace_dyn.c @@ -0,0 +1,143 @@ +/* Module namespace objects handed to typed code as checked-dynamic values. + * + * In Node a module namespace is a non-extensible null-prototype object whose + * own properties are the module's exports in sorted order, which are + * enumerable and read-only, whose Symbol.toStringTag is "Module", and whose + * ToPrimitive finds no method. Every generic dynamic operation — + * Object.keys/values/entries/getOwnPropertyNames, for-in, `in`, + * JSON.stringify, identity, member reads and calls — already handles that + * shape through scr_json.c's object + * dispatch, so this unit only builds the object and links only into programs + * that box a namespace. + * + * The exports stay live: each property is an accessor whose getter calls the + * compiled namespace getter with the export name (the same getter the + * JavaScript-source representation uses), and whose setter throws Node's + * strict-mode read-only TypeError. An unknown name is not a member, so the + * object's own not-extensible state answers Node's "Cannot add property". + * One object per module keeps its identity. + * + * Node models the exports as writable data properties on a sealed object, so + * Object.isFrozen answers false and Object.isSealed true. Live reads need + * accessors here, and the accessor shape cannot be sealed without also + * freezing: Object.isFrozen and Object.isExtensible answer as Node's do, + * Object.isSealed does not. */ +#include "scr_runtime.h" + +#include +#include + +typedef struct ScrModuleNamespaceDyn { + ScrStr *id; + ScrDyn *object; /* retained for the program's lifetime */ + struct ScrModuleNamespaceDyn *next; +} ScrModuleNamespaceDyn; + +static SCR_TL ScrModuleNamespaceDyn *scr_module_namespace_dyns; +static SCR_TL bool scr_module_namespace_dyn_cleanup_registered; + +static void scr_module_namespace_dyn_cleanup(void) { + while (scr_module_namespace_dyns) { + ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; + scr_module_namespace_dyns = ns->next; + scr_str_release(ns->id); + scr_dyn_release(ns->object); + free(ns); + } +} + +/* An export's current value: the compiled getter takes the export name as a + * dyn string and answers it +1. */ +static ScrDyn *scr_module_namespace_dyn_read(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *getter = scr_box_get_ref(clo->caps[0]); + ScrDyn *name = scr_box_get_ref(clo->caps[1]); + ScrDyn *argument = scr_dyn_retain(name); + ScrDyn *value = scr_dyn_call(getter, &argument, 1, "module namespace export"); + scr_dyn_release(argument); + scr_dyn_release(name); + scr_dyn_release(getter); + return value; +} + +/* A namespace export is read-only: Node's strict-mode TypeError names the + * export and the namespace's [object Module] tag. */ +static ScrDyn *scr_module_namespace_dyn_write(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *name = scr_box_get_ref(clo->caps[0]); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Cannot assign to read only property '"); + scr_jb_write(&text, name->v.str->data, name->v.str->len); + scr_jb_puts(&text, "' of object '[object Module]'"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + scr_dyn_release(name); + return NULL; +} + +/* `thunk` as a dynamic function over one or two captured dynamic values. */ +static ScrDyn *scr_module_namespace_dyn_fn(ScrDynThunk thunk, ScrDyn *first, ScrDyn *second) { + ScrClosure *closure = scr_closure_new(NULL, second ? 2 : 1); + closure->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[0], scr_dyn_retain(first)); + if (second) { + closure->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[1], scr_dyn_retain(second)); + } + return scr_dyn_new_func(closure, thunk, 0, "native:module namespace", "module namespace export"); +} + +/* The namespace object: null prototype, one enumerable read-only accessor per + * export in `keys` order, a non-writable Symbol.toStringTag of "Module", and + * not extensible. */ +static ScrDyn *scr_module_namespace_dyn_build(ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + ScrDyn *object = scr_dyn_new_obj_null_proto(); + for (size_t i = 0; i < keys->len; i++) { + ScrStr *key = scr_arr_get_ref(keys, (double)i); /* +1 */ + ScrDyn *name = scr_dyn_new_str(key); + ScrDyn *read = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_read, getter, name); + ScrDyn *write = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_write, name, NULL); + scr_dyn_obj_set_accessor(object, key->data, key->len, read, write); + scr_dyn_release(name); + scr_str_release(key); + } + ScrStr *tag = scr_str_new("Module", 6); + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "value", 5, scr_dyn_new_str(tag)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(false)); + scr_dyn_release(scr_dyn_define_property(object, tag_key, descriptor)); + scr_str_release(tag); + scr_dyn_release(descriptor); + scr_dyn_release(scr_dyn_prevent_extensions(object)); + if (scr_exc_pending()) { + /* The tag key is a well-known symbol, so this cannot fail in practice; a + * pending exception still must not be cached. */ + scr_dyn_release(object); + return NULL; + } + return object; +} + +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + for (ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; ns; ns = ns->next) { + if (ns->id->len == id->len && memcmp(ns->id->data, id->data, id->len) == 0) + return scr_dyn_retain(ns->object); + } + if (!scr_module_namespace_dyn_cleanup_registered) { + scr_atexit(scr_module_namespace_dyn_cleanup); + scr_module_namespace_dyn_cleanup_registered = true; + } + ScrDyn *object = scr_module_namespace_dyn_build(keys, getter, tag_key); + if (!object) return NULL; + ScrModuleNamespaceDyn *ns = malloc(sizeof(*ns)); + if (!ns) scr_trap("scriptc: out of memory\n"); + *ns = (ScrModuleNamespaceDyn){ scr_str_retain(id), scr_dyn_retain(object), scr_module_namespace_dyns }; + scr_module_namespace_dyns = ns; + return object; +} diff --git a/tests/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts new file mode 100644 index 000000000..d569800e6 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -0,0 +1,70 @@ +// A module namespace taken as a checked-dynamic value: Node's export keys, +// live reads, function typeof, member calls, `in`, for-in, JSON, the +// [object Module] tag, the read-only write TypeErrors and the "Cannot +// convert object to primitive value" coercion, plus `Promise` +// widening into `Promise` and `unknown`. +import * as declared from "./mod.ts"; + +function keys(value: unknown): string { + return Object.keys(value as any).join(","); +} + +function describe(value: unknown): void { + const dyn = value as any; + console.log("keys", keys(value)); + console.log("kinds", valueKinds(value)); + console.log("names", Object.getOwnPropertyNames(dyn).join(",")); + console.log("alpha", dyn.alpha, typeof dyn.beta, typeof dyn.default); + console.log("call", dyn.beta()); + console.log("tag", Object.prototype.toString.call(dyn)); + console.log("in", "alpha" in dyn, "toString" in dyn); + const forIn: string[] = []; + for (const key in dyn) forIn.push(key); + console.log("for-in", forIn.join(",")); + console.log("json", JSON.stringify(dyn)); + try { + console.log("string", String(dyn)); + } catch (error) { + console.log("string threw", (error as Error).message); + } + try { + dyn.alpha = 2; + console.log("write", dyn.alpha); + } catch (error) { + console.log("write threw", (error as Error).message); + } + try { + dyn.gamma = 2; + console.log("add", dyn.gamma); + } catch (error) { + console.log("add threw", (error as Error).message); + } + console.log("live", dyn.alpha); +} + +function valueKinds(value: unknown): string { + const dyn = value as any; + const kinds: string[] = []; + for (const key of Object.keys(dyn)) kinds.push(typeof dyn[key]); + return kinds.join(","); +} + +function bump(value: unknown): void { + const dyn = value as any; + dyn.bump(); + console.log("after bump", dyn.count); +} + +async function main(): Promise { + describe(declared); + bump(declared); + const loaded = await import("./mod.ts"); + console.log("same", loaded === declared); + describe(loaded); + const widened: Promise | undefined = import("./mod.ts"); + if (widened) console.log("widened", keys(await widened)); + const boxed: unknown = import("./mod.ts"); + console.log("unknown", keys(await boxed)); +} + +main(); diff --git a/tests/corpus/8412-module-namespace-untyped/mod.ts b/tests/corpus/8412-module-namespace-untyped/mod.ts new file mode 100644 index 000000000..be7be7d4a --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod.ts @@ -0,0 +1,12 @@ +export const alpha = 1; +export let count = 0; +export function beta(): string { + return "b" + String(count); +} +export function bump(): void { + count += 1; +} +export const obj = { k: alpha }; +export default class Gamma { + readonly tag = "g"; +} diff --git a/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..6a0493eb5 --- /dev/null +++ b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4451, "san": 57918 } From 1cf59d42e461b87ae86663dc9f8c292a74b83363 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 09:33:31 -0500 Subject: [PATCH 2/8] Lower Response.clone and Request.clone on typed values Response.clone() and Request.clone() on a value typed Response or Request were refused: in TypeScript the fallback declarations had no clone, and the fetch profile listed both as unsupported, so the call compiled to an SC2020 refusal, also in JavaScript where the checker types the value. The checked-dynamic handles already implement them (the body tees, and the clone gets a copy of the headers with the same guard), so typed calls now dispatch to those implementations, as text() and json() do. The declarations gain clone(), and the fetch profile lists both as static methods with the new static-clone fetch fixture as their differential evidence. The surface manifest is regenerated, and the surface-manifest probes that kept clone fenced now fence blob() and formData(). Adds the static-clone fixture and corpus programs fetch-clone.ts and fetch-clone-untyped.mjs, with their corpus costs and preflight/order baselines. --- .../ambient/scriptc-node-fallback.d.ts | 4 + packages/compiler/src/compat/fetch-profile.ts | 21 ++++- packages/compiler/surface-manifest.json | 10 +-- .../tests/corpus/fetch-clone-untyped.mjs.json | 6 ++ .../tests/corpus/fetch-clone.ts.json | 6 ++ tests/corpus/fetch-clone-untyped.mjs | 28 +++++++ tests/corpus/fetch-clone.ts | 80 +++++++++++++++++++ tests/fixtures/fetch/static-clone/main.mts | 44 ++++++++++ .../corpus-costs/fetch-clone-untyped.mjs.json | 1 + .../harness/corpus-costs/fetch-clone.ts.json | 1 + tests/harness/fetch.test.ts | 1 + tests/harness/surface-manifest.test.ts | 6 +- 12 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json create mode 100644 tests/corpus/fetch-clone-untyped.mjs create mode 100644 tests/corpus/fetch-clone.ts create mode 100644 tests/fixtures/fetch/static-clone/main.mts create mode 100644 tests/harness/corpus-costs/fetch-clone-untyped.mjs.json create mode 100644 tests/harness/corpus-costs/fetch-clone.ts.json diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 3f2124e28..bf107323b 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1258,6 +1258,9 @@ interface Response { * direct use and points at bytes(). */ arrayBuffer(): Promise; bytes(): Promise; + /* A copy whose body is one branch of the body's tee; the original keeps + * the other. */ + clone(): Response; } interface ResponseInit { headers?: Headers | Record | readonly (readonly [string, string])[]; @@ -1335,6 +1338,7 @@ interface Request { text(): Promise; arrayBuffer(): Promise; bytes(): Promise; + clone(): Request; } interface RequestInit { method?: string; diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts index 83d809ee1..07f5f09c8 100644 --- a/packages/compiler/src/compat/fetch-profile.ts +++ b/packages/compiler/src/compat/fetch-profile.ts @@ -231,7 +231,7 @@ function buildFetchCompatProfile() { "body", "bodyUsed", ], - responseCalls: ["json", "text", "bytes"], + responseCalls: ["json", "text", "bytes", "clone"], readableStreamReads: ["locked"], readableStreamCalls: ["cancel", "getReader"], }, @@ -287,6 +287,13 @@ function buildFetchCompatProfile() { facets: ["body-consumption", "promise-settlement", "state-machine", "error-shape"], evidence: [fixture("static-request")], })), + { + id: "stdlib.request.clone", + name: "Request.clone", + kind: "method", + facets: ["body-consumption", "identity", "state-machine", "error-shape"], + evidence: [fixture("static-clone")], + }, { id: "stdlib.response.constructor", name: "Response constructor", @@ -538,6 +545,13 @@ function buildFetchCompatProfile() { ? [fixture("static")] : [fixture("static"), fixture("static-stream")], })), + { + id: "stdlib.response.clone", + name: "Response.clone", + kind: "method", + facets: ["body-consumption", "identity", "state-machine", "error-shape"], + evidence: [fixture("static-clone")], + }, ], inventory: { interfaces: [ @@ -672,7 +686,7 @@ function buildFetchCompatProfile() { "bytes", "attribute", ].map((member) => - ["clone", "blob", "formData", "attribute"].includes(member) + ["blob", "formData", "attribute"].includes(member) ? unsupportedEntry( `stdlib.request.${member}`, "Request", @@ -706,7 +720,8 @@ function buildFetchCompatProfile() { (member) => staticEntry(`stdlib.response.${member}`, "Response", member, "prototype"), ), staticEntry("stdlib.response.arrayBuffer", "Response", "arrayBuffer", "prototype"), - ...["clone", "blob", "formData"].map((member) => + staticEntry("stdlib.response.clone", "Response", "clone", "prototype"), + ...["blob", "formData"].map((member) => unsupportedEntry( `stdlib.response.${member}`, "Response", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 2237619ef..ea8295406 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -5130,9 +5130,8 @@ "id": "stdlib.request.clone", "kind": "stdlib", "name": "Request.clone", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; typed source has no compiler bridge for this interface" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: body-consumption, identity, state-machine, error-shape; differential evidence: fixture:static-clone" }, { "id": "stdlib.request.constructor", @@ -5329,9 +5328,8 @@ "id": "stdlib.response.clone", "kind": "stdlib", "name": "Response.clone", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; the native fetch runtime does not implement this Response operation" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: body-consumption, identity, state-machine, error-shape; differential evidence: fixture:static-clone" }, { "id": "stdlib.response.constructor", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json new file mode 100644 index 000000000..e0bf41cc0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-clone-untyped.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json new file mode 100644 index 000000000..fc2cd158e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-clone.ts" + ], + "diags": [] +} diff --git a/tests/corpus/fetch-clone-untyped.mjs b/tests/corpus/fetch-clone-untyped.mjs new file mode 100644 index 000000000..61748d8a0 --- /dev/null +++ b/tests/corpus/fetch-clone-untyped.mjs @@ -0,0 +1,28 @@ +// Response.clone() and Request.clone() in JavaScript on values the checker +// types as Response and Request: the clone's metadata, headers and body +// branch, and Node's TypeErrors for a used body. +const describe = (e) => `${e.name}: ${e.message}`; + +const original = new Response("from js", { status: 202, headers: { "x-c": "3" } }); +const copy = original.clone(); +console.log("meta", copy.status, copy.headers.get("x-c"), copy instanceof Response); +console.log("bodies", await original.text(), await copy.text()); +try { + copy.clone(); +} catch (e) { + console.log("used", describe(e)); +} + +const request = new Request("http://example.com/js", { method: "POST", body: "js body" }); +const requestCopy = request.clone(); +console.log("request", requestCopy.method, requestCopy.url, await requestCopy.text(), await request.text()); +try { + request.clone(); +} catch (e) { + console.log("request used", describe(e)); +} + +const viewed = new Response("viewed"); +const [left] = viewed.clone().body.tee(); +const reader = left.getReader(); +console.log("tee of clone", new TextDecoder().decode((await reader.read()).value), await viewed.text()); diff --git a/tests/corpus/fetch-clone.ts b/tests/corpus/fetch-clone.ts new file mode 100644 index 000000000..d602c9179 --- /dev/null +++ b/tests/corpus/fetch-clone.ts @@ -0,0 +1,80 @@ +// Response.clone() and Request.clone() on typed values: the clone keeps the +// status, status text, URL and a copy of the headers, its body is one +// branch of the original's tee, a used or locked body refuses with Node's +// TypeError, and a Request clone keeps the method, URL, headers and body. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +const describe = (e: unknown): string => `${(e as Error).name}: ${(e as Error).message}`; + +async function responses(): Promise { + const original = new Response("hello", { status: 201, statusText: "Made", headers: { "x-a": "1" } }); + const copy = original.clone(); + console.log("meta", copy.status, copy.statusText, copy.ok, copy.headers.get("x-a"), copy.bodyUsed, copy.body === original.body); + copy.headers.set("x-a", "changed"); + console.log("headers", original.headers.get("x-a"), copy.headers.get("x-a")); + console.log("bodies", await copy.text(), await original.text(), original.bodyUsed, copy.bodyUsed); + try { + original.clone(); + } catch (e) { + console.log("used", describe(e)); + } + + const chained = new Response("chain").clone().clone(); + console.log("chained", await chained.text()); + + const twice = new Response("twice"); + const first = twice.clone(); + const second = twice.clone(); + console.log("twice", await second.text(), await first.text(), await twice.text()); + + const locked = new Response("locked"); + locked.body!.getReader(); + try { + locked.clone(); + } catch (e) { + console.log("locked", describe(e)); + } + + const empty = new Response(null, { status: 204 }); + const emptyCopy = empty.clone(); + console.log("null body", emptyCopy.status, emptyCopy.body === null, await emptyCopy.text() === ""); + + const json = Response.json({ n: 1 }); + const jsonCopy = json.clone(); + console.log("json", JSON.stringify(await jsonCopy.json()), jsonCopy.headers.get("content-type")); +} + +async function requests(): Promise { + const request = new Request("http://example.com/path?q=1", { method: "PUT", body: "payload", headers: { "x-b": "2" } }); + const copy = request.clone(); + console.log("request", copy.method, copy.url, copy.headers.get("x-b"), copy.bodyUsed); + console.log("request bodies", await copy.text(), await request.text()); + try { + request.clone(); + } catch (e) { + console.log("request used", describe(e)); + } + const get = new Request("http://example.com/"); + const getCopy = get.clone(); + console.log("get", getCopy.method, getCopy.body === null, getCopy.redirect); +} + +async function fetched(): Promise { + const server = createServer((_req: IncomingMessage, res: ServerResponse) => { + res.setHeader("x-served", "yes"); + res.write("part one;"); + setTimeout(() => res.end("part two"), 5); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + const copy = response.clone(); + console.log("fetched", copy.status, copy.headers.get("x-served"), copy.url === response.url); + console.log("fetched bodies", await copy.text(), await response.text()); + server.close(); +} + +await responses(); +await requests(); +await fetched(); diff --git a/tests/fixtures/fetch/static-clone/main.mts b/tests/fixtures/fetch/static-clone/main.mts new file mode 100644 index 000000000..48f3f699a --- /dev/null +++ b/tests/fixtures/fetch/static-clone/main.mts @@ -0,0 +1,44 @@ +// Response.clone() and Request.clone() in a static build: fetched +// responses (a whole body, and one that arrives in chunks across a +// multibyte character), constructed responses and requests, the clone's +// metadata and header copy, a clone of a clone, and Node's TypeErrors for a +// used or locked body. +const base = process.argv[2]!; +const describe = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)); + +const fetched = await fetch(`${base}/text`); +const fetchedCopy = fetched.clone(); +console.log(fetchedCopy.status, fetchedCopy.headers.get("x-kind"), fetchedCopy.url === fetched.url, fetchedCopy.redirected); +console.log(await fetchedCopy.text(), await fetched.text(), fetched.bodyUsed, fetchedCopy.bodyUsed); +try { + fetched.clone(); +} catch (e) { + console.log("used", describe(e)); +} + +const chunked = await fetch(`${base}/chunked`); +const chunkedCopy = chunked.clone().clone(); +console.log(await chunked.text(), await chunkedCopy.text()); + +const made = new Response("made", { status: 203, statusText: "Copied", headers: { "x-a": "1" } }); +const madeCopy = made.clone(); +madeCopy.headers.set("x-a", "2"); +console.log(madeCopy.status, madeCopy.statusText, made.headers.get("x-a"), madeCopy.headers.get("x-a")); +console.log(await made.text(), await madeCopy.text()); + +const locked = new Response("locked"); +locked.body!.getReader(); +try { + locked.clone(); +} catch (e) { + console.log("locked", describe(e)); +} + +const request = new Request(`${base}/post-echo`, { method: "POST", body: "request body", headers: { "x-b": "3" } }); +const requestCopy = request.clone(); +console.log(requestCopy.method, requestCopy.headers.get("x-b"), await requestCopy.text(), await request.text()); +try { + request.clone(); +} catch (e) { + console.log("request used", describe(e)); +} diff --git a/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json b/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json new file mode 100644 index 000000000..b65433b72 --- /dev/null +++ b/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 48000 } diff --git a/tests/harness/corpus-costs/fetch-clone.ts.json b/tests/harness/corpus-costs/fetch-clone.ts.json new file mode 100644 index 000000000..b65433b72 --- /dev/null +++ b/tests/harness/corpus-costs/fetch-clone.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 48000 } diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 337eafec6..1df7965f6 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -164,6 +164,7 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => { "static-abort-throw", "static-dispatch-throw", "static-response-json", + "static-clone", ] as const; test.for( staticCases.flatMap((name) => (["llvm"] as const).map((backend) => [name, backend] as const)), diff --git a/tests/harness/surface-manifest.test.ts b/tests/harness/surface-manifest.test.ts index d854db3e0..710cc529b 100644 --- a/tests/harness/surface-manifest.test.ts +++ b/tests/harness/surface-manifest.test.ts @@ -388,7 +388,7 @@ test("fetch interface object bindings retain the inventory fence code", () => { source: '/// \n' + "function headers(value: Headers): void { const { entries } = value; }\n" + - "function response(value: Response): void { const { clone } = value; }\n" + + "function response(value: Response): void { const { blob } = value; }\n" + "function stream(value: ReadableStream): void { const { tee } = value; }\n" + "function assign(value: Headers, entries: unknown): void { ({ entries } = value); }\n" + "void headers; void response; void stream; void assign;\n", @@ -447,7 +447,7 @@ test("unimplemented Response and ReadableStream calls stay fenced", () => { source: '/// \n' + "function response(value: Response): void {\n" + - " void value.clone(); void value.blob(); void value.formData();\n" + + " void value.blob(); void value.formData();\n" + "}\n" + "function stream(value: ReadableStream): void {\n" + " void value.tee(); void value.pipeTo(null as never);\n" + @@ -458,7 +458,7 @@ test("unimplemented Response and ReadableStream calls stay fenced", () => { const coverage = analyze(file, {}).coverage; expect(coverage.preflightFailed).toBe(false); expect([...new Set(coverage.diagnostics.map((d) => d.code))]).toEqual(["SC2020"]); - expect(coverage.diagnostics.filter((d) => d.code === "SC2020")).toHaveLength(5); + expect(coverage.diagnostics.filter((d) => d.code === "SC2020")).toHaveLength(4); } }); From dea433dacd1828f17c1bc76b3e15219b3b1b4df9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 10:21:05 -0500 Subject: [PATCH 3/8] Call the receiver copy for direct calls whose result is discarded A direct call to an untyped method that returns `this` on every path, used as a statement (`builder.add(part);`), boxed its receiver into a capsule that nothing read. Such a call now runs the method's copy that returns nothing, as the class dispatch helpers already do. --- .../src/frontend/lowering/receiver-returns.ts | 20 +++++++- ...ynamic-method-receiver-statements.mjs.json | 6 +++ .../dynamic-method-receiver-statements.mjs | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json create mode 100644 tests/corpus/dynamic-method-receiver-statements.mjs diff --git a/packages/compiler/src/frontend/lowering/receiver-returns.ts b/packages/compiler/src/frontend/lowering/receiver-returns.ts index 5e83f236a..d1c160d7f 100644 --- a/packages/compiler/src/frontend/lowering/receiver-returns.ts +++ b/packages/compiler/src/frontend/lowering/receiver-returns.ts @@ -7,8 +7,10 @@ * (`%dyn.class.call.N`) that calls such a method already holds a capsule * of the same instance at its exact class: its receiver after * normalization. The helper now calls a copy of the method that returns - * nothing and answers its own receiver, so the call boxes nothing. Other - * callers (vtables, typed code, method values) keep the method itself. */ + * nothing and answers its own receiver, so the call boxes nothing. A + * direct call whose result nothing reads calls the copy too. Other callers + * (vtables, method values, calls that use the result) keep the method + * itself. */ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { VOID } from "../../ir/ir.js"; import { varRef } from "../../ir/build.js"; @@ -57,6 +59,20 @@ export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[ ]; } } + // A direct call whose result nothing reads (`builder.set(1);` in typed + // code) needs no capsule at all. + for (const fn of [...functions]) { + everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "exprStmt" && s.expr.kind === "call") { + const copy = copyOf(s.expr.callee); + if (copy) s.expr = { ...s.expr, callee: copy.name, type: VOID }; + } + return true; + }, + expr: () => true, + }); + } } /** The helper's receiver is assigned only by its normalization, so it is diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json new file mode 100644 index 000000000..97d131a90 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-method-receiver-statements.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/dynamic-method-receiver-statements.mjs b/tests/corpus/dynamic-method-receiver-statements.mjs new file mode 100644 index 000000000..80c125475 --- /dev/null +++ b/tests/corpus/dynamic-method-receiver-statements.mjs @@ -0,0 +1,46 @@ +// Untyped methods that return their receiver, called as statements on +// receivers whose class is known: side effects, throws, overrides, and +// results that are used. +class Builder { + constructor() { + this.parts = []; + this.count = 0; + } + add(part) { + if (part === "") throw new TypeError("empty part"); + this.parts.push(part); + this.count++; + return this; + } + reset() { + this.parts.length = 0; + return this; + } +} +class LoudBuilder extends Builder { + add(part) { + super.add(part.toUpperCase()); + return this; + } +} +const b = new Builder(); +for (let i = 0; i < 1000; i++) { + b.add("p" + (i % 3)); + if (i % 100 === 99) b.reset(); +} +console.log(b.count, b.parts.join(",")); +b.add("x").add("y"); +const same = b.add("z"); +console.log(same === b, b.parts.join(",")); +try { + b.add(""); +} catch (e) { + console.log(e instanceof TypeError, e.message, b.count); +} +const loud = new LoudBuilder(); +loud.add("a"); +loud.add("b").add("c"); +console.log(loud.parts.join(","), loud.count, loud instanceof Builder); +const builders = [new Builder(), new LoudBuilder()]; +for (const each of builders) each.add("q"); +console.log(builders.map((each) => each.parts.join("")).join("|")); From a0849484ab8650101974cde4857fdd030b2f817d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 09:09:45 -0700 Subject: [PATCH 4/8] Read an expression default through a module namespace getter `export default ` and the anonymous default function/class have no named binding for the dynamic namespace getter to read, so a namespace used as a dynamic value fenced its `default` property with SC1090. They do have storage: the module registers a `default` global keyed by its default export symbol, which is what a default import reads. The getter keeps that symbol per export and answers the snapshot coerced to `dyn`, so the boxed namespace's `default` matches Node; a missing registration still fences SC1090 and a value with no checked-dynamic form still fences SC1101. Corpus: 8412-module-namespace-untyped gains an `export default 42` module and reads its boxed and typed `default`. Co-Authored-By: Claude Opus 5.5 --- .../lowering/module-namespace-elements.ts | 39 +++++++++++++------ .../8412-module-namespace-untyped/main.ts | 7 ++++ .../mod-default.ts | 4 ++ 3 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 tests/corpus/8412-module-namespace-untyped/mod-default.ts diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index be94aa34a..405d2af5f 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -186,12 +186,16 @@ function moduleNamespaceGetter( if (!source) return null; const symbol = lowerer.checker.getSymbolAtLocation(source); if (!symbol) return null; - const exports: { key: string; declaration: ts.Node | undefined }[] = []; + const exports: { key: string; declaration: ts.Node | undefined; symbol: ts.Symbol }[] = []; for (const exported of lowerer.checker.getExportsOfModule(symbol)) { const target = exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; if (!(target.flags & ts.SymbolFlags.Value)) continue; - exports.push({ key: ts.symbolName(exported), declaration: target.valueDeclaration }); + exports.push({ + key: ts.symbolName(exported), + declaration: target.valueDeclaration, + symbol: target, + }); } exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); const name = `%module.namespace:${ns.type.moduleId}`; @@ -203,24 +207,37 @@ function moduleNamespaceGetter( lowerer.fnStack.push(context); try { const body: IrStmt[] = []; - for (const { key, declaration } of exports) { + for (const { key, declaration, symbol: exportSymbol } of exports) { const node = declaration ?? source; const diagsBefore = lowerer.diags.length; let read: IrStmt[]; try { const identifier = declaration?.name as ts.Node | undefined; - if (!identifier || !ts.isIdentifier(identifier)) { - lowerer.unsupported( - "SC1090", - node, - `reading the unnamed export '${key}' through a module namespace object`, - ); + let value: IrExpr; + if (identifier && ts.isIdentifier(identifier)) { + value = lowerer.lowerExprExpecting(identifier, DYN); + } else { + // `export default ` and the anonymous default + // class/function have no named binding to read. The module's + // pre-registered default global holds the value the export + // statement snapshotted — the same storage a default import + // reads — so the namespace property answers it instead of + // fencing. A default whose type has no checked-dynamic form + // still fails the coercion below and keeps its fence. + const storage = lowerer.globalsBySymbol.get(exportSymbol); + if (!storage) { + lowerer.unsupported( + "SC1090", + node, + `reading the unnamed export '${key}' through a module namespace object`, + ); + } + value = lowerer.coerceToExpected(varRef(storage.id, storage.type, loc), DYN); } - const value = lowerer.lowerExprExpecting(identifier, DYN); if (value.type.kind !== "dyn") { lowerer.unsupported( "SC1101", - identifier, + identifier ?? node, `reading the export '${key}' ('${lowerer.fmt(value.type)}') through a module namespace object`, ); } diff --git a/tests/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts index d569800e6..fcb84a729 100644 --- a/tests/corpus/8412-module-namespace-untyped/main.ts +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -4,6 +4,7 @@ // convert object to primitive value" coercion, plus `Promise` // widening into `Promise` and `unknown`. import * as declared from "./mod.ts"; +import * as expressionDefault from "./mod-default.ts"; function keys(value: unknown): string { return Object.keys(value as any).join(","); @@ -56,6 +57,12 @@ function bump(value: unknown): void { } async function main(): Promise { + // An expression default is snapshotted once by the export statement; + // the boxed namespace's `default` read answers that value, like Node. + const boxedDefault: unknown = expressionDefault; + const dynDefault = boxedDefault as any; + console.log("expression default", dynDefault.default, typeof dynDefault.default); + console.log("expression keys", keys(boxedDefault), expressionDefault.default); describe(declared); bump(declared); const loaded = await import("./mod.ts"); diff --git a/tests/corpus/8412-module-namespace-untyped/mod-default.ts b/tests/corpus/8412-module-namespace-untyped/mod-default.ts new file mode 100644 index 000000000..a0af818a4 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod-default.ts @@ -0,0 +1,4 @@ +// An EXPRESSION default (`export default `): Node evaluates the +// expression once, at the export statement, and the namespace object's +// `default` property answers that snapshotted value. +export default 42; From 626e9021a3e9d79fd7b192d766fd8d87e52b07c9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 09:33:10 -0700 Subject: [PATCH 5/8] Record the new import in the module-order baseline The corpus fixture gained `mod-default.ts`, so the recorded module evaluation order for `main.ts` must list it after `mod.ts` and before `main.ts`. Re-recorded from the native frontend; the order-parity sweep (1959 entries) passes against it. Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/8412-module-namespace-untyped/main.ts.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json index 9384505a5..464e68307 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -1,6 +1,7 @@ { "order": [ "/tests/corpus/8412-module-namespace-untyped/mod.ts", + "/tests/corpus/8412-module-namespace-untyped/mod-default.ts", "/tests/corpus/8412-module-namespace-untyped/main.ts" ], "diags": [] From 857b4ad3e08682907fe2e868f129cef1aa2c2367 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 12:38:39 -0500 Subject: [PATCH 6/8] Read typed-array constructors, NaN and Infinity in shorthand properties A shorthand property's name carries the property's symbol, so the identifier lowering's typed-array and NaN/Infinity cases, which checked that symbol's provenance, did not match `{ Uint8Array }` or `{ NaN }`. A JavaScript source stored the opaque identity token there, and TypeScript stored a runtime fence; the long form `{ Uint8Array: Uint8Array }` read the constructor. Both cases now resolve the name as the other builtin cases do (isStdlibGlobal), which reads the shorthand's value symbol. In JavaScript sources, `globalThis.NaN` and `globalThis.Infinity`, and destructuring them from the global object, read the identity token too. They now read the numbers. --- .../lowering/expressions/identifiers.ts | 5 +- .../src/frontend/lowering/global-values.ts | 3 + .../corpus/shorthand-builtin-values.cjs.json | 6 ++ .../corpus/shorthand-builtin-values.ts.json | 6 ++ tests/corpus/shorthand-builtin-values.cjs | 97 +++++++++++++++++++ tests/corpus/shorthand-builtin-values.ts | 51 ++++++++++ .../shorthand-builtin-values.cjs.json | 1 + .../shorthand-builtin-values.ts.json | 1 + 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json create mode 100644 tests/corpus/shorthand-builtin-values.cjs create mode 100644 tests/corpus/shorthand-builtin-values.ts create mode 100644 tests/harness/corpus-costs/shorthand-builtin-values.cjs.json create mode 100644 tests/harness/corpus-costs/shorthand-builtin-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 7ef9b9b0e..c73217ef8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -700,8 +700,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds // because f64 compares are IEEE compares). if (expr.text === "Infinity" || expr.text === "NaN") { - const sym = lowerer.checker.getSymbolAtLocation(expr); - if (lowerer.isStdlibSymbol(sym)) { + if (lowerer.isStdlibGlobal(expr, expr.text)) { return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc }; } } @@ -729,7 +728,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc }; if ( Object.values(BYTES_ELEMENT_NAME).includes(expr.text) && - lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr)) + lowerer.isStdlibGlobal(expr, expr.text) ) { return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 21593203d..91f705fef 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -16,6 +16,7 @@ import * as ts from "../ts7/adapter.js"; import { BYTES_ELEMENT_NAME, DYN, + F64, STRING, UNDEFINED_T, type IrExpr, @@ -133,6 +134,8 @@ export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc) /** jsGlobalValueByName for the globals other than the interned builtin * functions and constructors (builtinGlobalValue). */ export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + if (name === "NaN" || name === "Infinity") + return { kind: "numLit", value: name === "NaN" ? NaN : Infinity, type: F64, loc }; if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt") return primitiveCtorClosure(lowerer, name, loc, true); if (name === "ArrayBuffer") diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..1b3fb5b25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..1f444c73d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/shorthand-builtin-values.cjs b/tests/corpus/shorthand-builtin-values.cjs new file mode 100644 index 000000000..eeee0ff4e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.cjs @@ -0,0 +1,97 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) and a plain read hold. + +const short = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + Number, + Boolean, + BigInt, + ArrayBuffer, + Array, + Map, + JSON, + Math, + encodeURIComponent, + decodeURI, + crypto, + Buffer, + fetch, + console, + setTimeout, +}; +const long = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + Number: Number, + Boolean: Boolean, + BigInt: BigInt, + ArrayBuffer: ArrayBuffer, + Array: Array, + Map: Map, + JSON: JSON, + Math: Math, + encodeURIComponent: encodeURIComponent, + decodeURI: decodeURI, + crypto: crypto, + Buffer: Buffer, + fetch: fetch, + console: console, + setTimeout: setTimeout, +}; + +for (const key of Object.keys(long)) { + const same = Number.isNaN(long[key]) ? Number.isNaN(short[key]) : short[key] === long[key]; + console.log(key, same, typeof short[key]); +} +console.log(short.Uint8Array === Uint8Array, short.Float64Array === Float64Array); +console.log(Object.keys(short).length, Object.keys(short)[0], Object.keys(short)[9]); + +// The typed-array constructors construct and keep their names. +const bytes = new short.Uint8Array([1, 2, 300]); +console.log(bytes, bytes instanceof Uint8Array, short.Uint8Array.name, short.Int32Array.BYTES_PER_ELEMENT); +console.log(new short.Float64Array(2).length, short.Int16Array.from([1, -2])); +console.log(short.NaN !== short.NaN, short.Infinity > 1e308, -short.Infinity); + +// Shorthand destructuring from the global object reads the same values. +function fromGlobal() { + const { Uint8Array, Float32Array, NaN, Infinity, Buffer } = globalThis; + return [Uint8Array === long.Uint8Array, Float32Array === long.Float32Array, Number.isNaN(NaN), Infinity, Buffer === long.Buffer]; +} +console.log(fromGlobal()); + +// A local binding with a builtin's name is the local. +function shadowed() { + const Uint8Array = "local"; + const NaN = 5; + return { Uint8Array, NaN }; +} +console.log(shadowed()); + +// The global object's NaN and Infinity are numbers, read or destructured. +function numbersFromGlobal() { + const { NaN: notANumber, Infinity: infinite } = globalThis; + return [typeof notANumber, notANumber !== notANumber, infinite, infinite === Infinity]; +} +console.log(typeof globalThis.NaN, globalThis.NaN !== globalThis.NaN, globalThis.Infinity, -globalThis.Infinity); +console.log(numbersFromGlobal()); diff --git a/tests/corpus/shorthand-builtin-values.ts b/tests/corpus/shorthand-builtin-values.ts new file mode 100644 index 000000000..ad943b56e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.ts @@ -0,0 +1,51 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) holds. + +const short: Record = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + ArrayBuffer, + encodeURIComponent, +}; +const long: Record = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + ArrayBuffer: ArrayBuffer, + encodeURIComponent: encodeURIComponent, +}; + +for (const key of Object.keys(long)) { + const value = long[key]; + const same = + typeof value === "number" && Number.isNaN(value) + ? Number.isNaN(short[key]) + : short[key] === value; + console.log(key, same, typeof short[key]); +} + +const numbers = { NaN, Infinity }; +console.log(numbers.NaN !== numbers.NaN, numbers.Infinity > 1e308, -numbers.Infinity); + +const ctors = { Uint8Array, Float64Array }; +const bytes = new ctors.Uint8Array([1, 2, 300]); +console.log(bytes, ctors.Uint8Array.name, new ctors.Float64Array(3).length); diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..74a3101e8 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 4000 } diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.ts.json b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..f71f3eb98 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2400 } From 5a1021dcd12dcec2c5d4695e28ad414f73e268fd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 12:42:13 -0500 Subject: [PATCH 7/8] Fence Response.formData in the node-types fenced fixture Response.clone lowers now, so the fixture's unlowered Response operation is formData, which keeps the same fence and hint. --- tests/fixtures/node-types/fenced.ts | 2 +- tests/harness/__snapshots__/node-types-fenced.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index d94c7c19a..33cc56778 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -25,7 +25,7 @@ fetch("https://example.invalid/", fetchInit); async function inspectResponse(url: string): Promise { const response = await fetch(url); console.log(response.type); - response.clone(); + response.formData(); } void inspectResponse; ReadableStream.from(new Set([1, 2])); diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index a7618fa58..5808b9502 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -21,15 +21,15 @@ fenced.ts:27:15 - error SC2020: 'Response.type' is typed by @types/node but has 26 | const response = await fetch(url); 27 | console.log(response.type); | ^~~~~~~~~~~~~ - 28 | response.clone(); + 28 | response.formData(); hint: the member is outside the native static handle projection -fenced.ts:28:3 - error SC2020: 'Response.clone' is typed by @types/node but has no scriptc lowering yet +fenced.ts:28:3 - error SC2020: 'Response.formData' is typed by @types/node but has no scriptc lowering yet 27 | console.log(response.type); - 28 | response.clone(); - | ^~~~~~~~~~~~~~ + 28 | response.formData(); + | ^~~~~~~~~~~~~~~~~ 29 | } hint: the native fetch runtime does not implement this Response operation From 577f086658c8e2b8e2d47dd1c0804c5ba9ed989b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 12:49:21 -0500 Subject: [PATCH 8/8] Space the timer delays in two order-sensitive tests and raise the static-spatial timeout mock-timers' "named and namespace imports keep the loop's timers" and 6811-node-island-uncaught-listener check the order in which real timers fire, with delays 1 to 5 ms apart that are scheduled one after another. On a busy runner more time than that can pass between scheduling two of them, and the order flips in either lane: the sanitized node:test run saw Node's reference print 'named promise' first, and the sanitized llvm-differential run saw 'island timer 1' before 'native timer after'. The delays are now 100 ms apart in mock-timers (100, 200, 300) and the island's timers (150, 250) start 100 ms after the native ones (10, 50). Node prints the same output as before. static-spatial bundles three.js and builds it with the sanitizer, which passed the default 300 s test timeout on a busy runner. Its cases take three.test.ts's 1200 s timeout. --- .../corpus/6811-node-island-uncaught-listener/island.cjs | 4 ++-- tests/corpus/6811-node-island-uncaught-listener/main.mjs | 6 ++++-- tests/fixtures/node-test/cases/mock-timers.ts | 9 ++++++--- tests/harness/static-spatial.test.ts | 3 +++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/corpus/6811-node-island-uncaught-listener/island.cjs b/tests/corpus/6811-node-island-uncaught-listener/island.cjs index 650c7f3f3..ae988f8c0 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/island.cjs +++ b/tests/corpus/6811-node-island-uncaught-listener/island.cjs @@ -1,4 +1,4 @@ exports.timers = () => { - setTimeout(() => console.log("island timer 1"), 15); - setTimeout(() => console.log("island timer 2"), 40); + setTimeout(() => console.log("island timer 1"), 150); + setTimeout(() => console.log("island timer 2"), 250); }; diff --git a/tests/corpus/6811-node-island-uncaught-listener/main.mjs b/tests/corpus/6811-node-island-uncaught-listener/main.mjs index 9195e7baa..05397c2e4 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/main.mjs +++ b/tests/corpus/6811-node-island-uncaught-listener/main.mjs @@ -7,12 +7,14 @@ import { callSync } from "@scriptc/node"; process.on("uncaughtException", (error) => { console.log("caught", error.message); }); +// The island's timers (150 and 250 ms) are scheduled before these (10 and +// 50 ms), with gaps wide enough that a slow run keeps the delay order. callSync("./island.cjs", "timers"); setTimeout(() => { console.log("native timer throws"); throw new Error("native boom"); -}, 5); -setTimeout(() => console.log("native timer after"), 10); +}, 10); +setTimeout(() => console.log("native timer after"), 50); setImmediate(() => { queueMicrotask(() => { throw new Error("from a microtask"); diff --git a/tests/fixtures/node-test/cases/mock-timers.ts b/tests/fixtures/node-test/cases/mock-timers.ts index 1e5e5883f..c31b16aa1 100644 --- a/tests/fixtures/node-test/cases/mock-timers.ts +++ b/tests/fixtures/node-test/cases/mock-timers.ts @@ -271,21 +271,24 @@ describe("imports", () => { it("named and namespace imports keep the loop's timers", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const log: string[] = []; + // The three timers are real and scheduled one after another, so their + // delays are 100 ms apart: a slow run (a sanitized build on a busy + // machine) still sees them fire in delay order. await Promise.all([ - sleep(5).then(() => { + sleep(300).then(() => { log.push("named promise"); }), new Promise((resolve) => namedSetTimeout(() => { log.push("named"); resolve(); - }, 2), + }, 100), ), new Promise((resolve) => timersNamespace.setTimeout(() => { log.push("namespace"); resolve(); - }, 3), + }, 200), ), ]); t.mock.timers.tick(10); diff --git a/tests/harness/static-spatial.test.ts b/tests/harness/static-spatial.test.ts index b0f40ee4e..e9bb23b1c 100644 --- a/tests/harness/static-spatial.test.ts +++ b/tests/harness/static-spatial.test.ts @@ -29,6 +29,9 @@ const cases = [ ].flatMap((fixture) => ["native", "wasm32-wasi"].map((target) => ({ fixture, target }))); test.for(cases)( "published $fixture runs statically on $target", + // Bundling three.js and building it with the sanitizer can pass the + // default 300 s on a busy runner, as in three.test.ts. + { timeout: 1_200_000 }, async ({ fixture, target }, context) => { if (target === "wasm32-wasi" && !hasZig) context.skip(); const dir = await mkdtemp("/tmp/scriptc-spatial-");