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 }