From efc22a2fd38787181a20a8df0cf042e3bc2cd082 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 17:00:03 -0700 Subject: [PATCH] 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 }