From 7962515c99660ca9c488db322b9c1b1930b23350 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Thu, 21 May 2026 10:47:25 -0700 Subject: [PATCH 01/24] fix(ses): cyclic star export with renaming reexport (issue #59) When a module re-exports `*` from another module, and that other module re-exports a binding from the first under a *different* exported name (`export { y as x } from './mod1.js'`), the linker visited the first module while its star-imported notifier for `y` had not yet been wired. The synchronous wireUp at the cycle's back-edge then passed `undefined` as the upstream notifier, which manifested as `TypeError: notify is not a function` at `packages/ses/src/module-instance.js`. The original 2019 issue described the failure as `SyntaxError: ... does not provide an export named 'y'`; the surface symptom evolved as the linker matured, but the underlying defect is the same. `wireUpExportNotifier` now installs a deferred forwarding notifier for re-exports whose upstream notifier is not yet present. The forwarding queues subscribers until the upstream resolves, then drains them through. By the time any module downstream subscribes to the re-export, the upstream module has completed its candidate-all walk and the upstream notifier exists, so the chain converges. Two regression surfaces accompany the fix. In SES, `import-gauntlet` adds `cyclic star export with renaming reexport (issue #59)` exercising the exact reproducer from the issue against the SES linker directly. In the compartment mapper, a three-module fixture (`star-reexporter` re- exports `*` from `export-renamer`; `export-renamer` re-exports `y as x` from `star-reexporter`) drives the same shape through `loadLocation`, `importLocation`, the archive round-trip pair, and `makeArchiveFromMap`, plus a Node.js parity test that imports the same fixture under plain Node.js (no SES, no compartment mapper) and asserts identical expected values from a shared assertions module. Pinning the compartment mapper's behavior to Node.js's reference behavior keeps the expected values defined in one place and teases linker behavior out of SES rather than asserting it against itself. Reverting the `wireUpExportNotifier` change while keeping either test surface reproduces `TypeError: notify is not a function` (nine of the eleven compartment-mapper import-path variants fail; the two archive- integrity variants pass because they do not import the fixture; the Node.js parity test is unaffected). Re-applying the fix restores all twelve passing tests. Fixes #59 --- .../fix-ses-star-export-cycle-rename.md | 7 ++ .../test/_cycle-rename-assertions.js | 45 +++++++++++ .../test/cycle-rename-node-parity.test.js | 20 +++++ .../test/cycle-rename.test.js | 38 ++++++++++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 7 ++ .../node_modules/app/package.json | 9 +++ .../node_modules/app/star-reexporter.js | 1 + packages/ses/src/module-instance.js | 75 +++++++++++++++---- packages/ses/test/import-gauntlet.test.js | 46 ++++++++++++ 10 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-ses-star-export-cycle-rename.md create mode 100644 packages/compartment-mapper/test/_cycle-rename-assertions.js create mode 100644 packages/compartment-mapper/test/cycle-rename-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename.test.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js diff --git a/.changeset/fix-ses-star-export-cycle-rename.md b/.changeset/fix-ses-star-export-cycle-rename.md new file mode 100644 index 0000000000..21e80f0dc6 --- /dev/null +++ b/.changeset/fix-ses-star-export-cycle-rename.md @@ -0,0 +1,7 @@ +--- +'ses': patch +--- + +Fix a star-export cycle defect where a module reached more than once via `export *` and a renaming reexport with a different exported name (`export { y as x } from ...`) raised a spurious `SyntaxError: ... does not provide an export named 'X'` (latterly `TypeError: notify is not a function`). +The reexport wire-up now installs a deferred forwarding notifier that resolves through the upstream's notifier table on first subscription, so cyclic star-export fixed-points converge. +Resolves endojs/endo#59. diff --git a/packages/compartment-mapper/test/_cycle-rename-assertions.js b/packages/compartment-mapper/test/_cycle-rename-assertions.js new file mode 100644 index 0000000000..7038e5a385 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-rename-assertions.js @@ -0,0 +1,45 @@ +/** + * Shared assertion logic for the cyclic star-export with renaming reexport + * regression (endojs/endo#59). Both the Node.js parity test and the + * Compartment Mapper test import from this module so the expected values + * live in exactly one place. If both tests pass, parity with Node.js is + * verified by construction. + * + * The fixture under fixtures-cycle-rename/node_modules/app/ exercises this + * arrangement: + * + * star-reexporter.js: export * from './export-renamer.js'; + * export-renamer.js: export { y as x } from './star-reexporter.js'; + * export var y = 45; + * main.js: import { x } from './star-reexporter.js'; + * import * as ns1 from './star-reexporter.js'; + * import * as ns2 from './export-renamer.js'; + * export const captured = x; + * export const namespace1 = { x: ns1.x, y: ns1.y }; + * export const namespace2 = { x: ns2.x, y: ns2.y }; + * + * Before the fix, the SES linker visited star-reexporter while its + * star-imported notifier for `y` had not yet been wired. The synchronous + * wireUp at the cycle's back-edge then passed `undefined` as the upstream + * notifier, manifesting as `TypeError: notify is not a function`. Node.js + * does not exhibit the defect, so the parity test pinned both layers to a + * single expected shape. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = 45; +export const expectedNamespace1 = { x: 45, y: 45 }; +export const expectedNamespace2 = { x: 45, y: 45 }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleRename = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual(namespace.namespace1, expectedNamespace1); + t.deepEqual(namespace.namespace2, expectedNamespace2); +}; diff --git a/packages/compartment-mapper/test/cycle-rename-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-node-parity.test.js new file mode 100644 index 0000000000..3c1916a81b --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-node-parity.test.js @@ -0,0 +1,20 @@ +/** + * Node.js parity test for the cyclic star-export with renaming reexport + * regression (endojs/endo#59). This test runs the same three-module fixture + * under plain Node.js (no SES, no compartment mapper) and asserts the same + * expected values asserted in cycle-rename.test.js. Parity is verified by + * construction: if both tests pass, the compartment mapper's linker + * behavior matches Node.js for this case. + */ + +import test from 'ava'; +import { assertCycleRename } from './_cycle-rename-assertions.js'; + +test('cyclic star export with renaming reexport (issue #59) - node parity', async t => { + t.plan(3); + const namespace = await import( + new URL('fixtures-cycle-rename/node_modules/app/main.js', import.meta.url) + .href + ); + assertCycleRename(t, namespace); +}); diff --git a/packages/compartment-mapper/test/cycle-rename.test.js b/packages/compartment-mapper/test/cycle-rename.test.js new file mode 100644 index 0000000000..5f20b4722e --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename.test.js @@ -0,0 +1,38 @@ +/** + * Regression for endojs/endo#59 (cyclic star export with renaming reexport) + * exercised through the compartment-mapper test scaffold. The companion + * Node.js parity test in cycle-rename-node-parity.test.js imports the same + * fixture under Node.js and asserts the same expected values; together the + * two tests tease the linker behavior out of SES and pin it to Node.js's + * reference behavior. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleRename } from './_cycle-rename-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRename(t, namespace); +}; + +scaffold( + 'cycle-rename (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..3961028771 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 45; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js new file mode 100644 index 0000000000..29d03f9ff9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js @@ -0,0 +1,7 @@ +import { x } from './star-reexporter.js'; +import * as ns1 from './star-reexporter.js'; +import * as ns2 from './export-renamer.js'; + +export const captured = x; +export const namespace1 = { x: ns1.x, y: ns1.y }; +export const namespace2 = { x: ns2.x, y: ns2.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..be4dc803c1 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js @@ -0,0 +1 @@ +export * from './export-renamer.js'; diff --git a/packages/ses/src/module-instance.js b/packages/ses/src/module-instance.js index add11b8660..321114bce9 100644 --- a/packages/ses/src/module-instance.js +++ b/packages/ses/src/module-instance.js @@ -354,23 +354,61 @@ export const makeModuleInstance = ( }; notifiers['*'] = notifyStar; - const wireUpExportNotifier = (exportName, notify) => { - if (!notifiers[exportName] && notify !== false) { - notifiers[exportName] = notify; - - // exported live binding state - let value; - const update = newValue => (value = newValue); - notify(update); - exportsProps[exportName] = { - get() { - return value; - }, - set: undefined, - enumerable: true, - configurable: false, + const wireUpExportNotifier = ( + exportName, + notify, + deferredSpecifier, + deferredImportName, + ) => { + if (notifiers[exportName] || notify === false) { + return; + } + if (notify === undefined) { + if (deferredSpecifier === undefined) { + return; + } + // The upstream module did not yet expose a notifier for + // `deferredImportName` when this re-export was wired. This is the + // star-export cycle of endojs/endo#59: the upstream's notifier for + // that name may be wired only later, in its own candidate-all walk, + // after this module's `imports()` returns. Install a notifier that + // queues subscribers until the upstream resolves, then forwards them + // through. + const pendingUpdaters = []; + let resolvedUpstreamNotify; + notify = update => { + if (resolvedUpstreamNotify !== undefined) { + resolvedUpstreamNotify(update); + return; + } + const upstreamInstance = mapGet(importedInstances, deferredSpecifier); + const upstreamNotify = upstreamInstance.notifiers[deferredImportName]; + if (upstreamNotify === undefined) { + arrayPush(pendingUpdaters, update); + return; + } + resolvedUpstreamNotify = upstreamNotify; + for (const pending of pendingUpdaters) { + upstreamNotify(pending); + } + pendingUpdaters.length = 0; + upstreamNotify(update); }; } + notifiers[exportName] = notify; + + // exported live binding state + let value; + const update = newValue => (value = newValue); + notify(update); + exportsProps[exportName] = { + get() { + return value; + }, + set: undefined, + enumerable: true, + configurable: false, + }; }; // Per the calling convention for the moduleFunctor generated from @@ -428,7 +466,12 @@ export const makeModuleInstance = ( if (reexportMap[specifier]) { // Set up reexport notifiers instantly so they are available in cycles. for (const [localName, exportedName] of reexportMap[specifier]) { - wireUpExportNotifier(exportedName, importNotifiers[localName]); + wireUpExportNotifier( + exportedName, + importNotifiers[localName], + specifier, + localName, + ); } } } diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 2a21f4a385..53e9044430 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -239,6 +239,52 @@ test('export name as default', async t => { await compartment.import('./main.js'); }); +// Regression for endojs/endo#59. A module reached more than once via +// `export *` and a renaming reexport with a different `exported` name +// formerly raised a spurious "does not provide an export named X" +// SyntaxError (latterly a `TypeError: notify is not a function`): +// the export-renamer's `export { y as x } from './star-reexporter.js'` +// was wired before star-reexporter's star-import from export-renamer had +// populated star-reexporter's notifier for `y`. The same fixture shape is +// also exercised through compartment-mapper's scaffold and pinned to +// Node.js's reference behavior; see +// packages/compartment-mapper/test/cycle-rename.test.js and +// packages/compartment-mapper/test/cycle-rename-node-parity.test.js. +test('cyclic star export with renaming reexport (issue #59)', async t => { + t.plan(3); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + export * from './export-renamer.js'; + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 45; + `, + 'https://example.com/main.js': ` + import { x } from './star-reexporter.js'; + import * as ns1 from './star-reexporter.js'; + import * as ns2 from './export-renamer.js'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.is(namespace.captured, 45); + t.deepEqual(namespace.namespace1, { x: 45, y: 45 }); + t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); +}); + test('export-as with duplicated export name', async t => { t.plan(4); From b77825393e7e8e7a9b64e49fd0d0f8de541ec26d Mon Sep 17 00:00:00 2001 From: endolinbot Date: Fri, 29 May 2026 20:17:10 +0000 Subject: [PATCH 02/24] test(ses): unused-live-binding parity for #59 cyclic star-export Companion regression for endojs/endo#59 addressing review feedback on endojs/endo#3276: a sibling fixture where the live binding `y` is declared but never assigned. Node.js reads every projection of the cycle as `undefined` for this shape (verified directly with `node`), so the SES linker must match. The deferring closure introduced by the fix either resolves (when a wireUp higher in the chain re-references the binding) or stays pending; the namespace reads agree with Node.js in either case. This commit adds executable evidence; no source change. --- packages/ses/test/import-gauntlet.test.js | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 53e9044430..8ad623fcbe 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -285,6 +285,49 @@ test('cyclic star export with renaming reexport (issue #59)', async t => { t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); }); +// Companion regression for endojs/endo#59 addressing the question raised on +// endojs/endo#3276: is a situation possible where all calls to the deferring +// notify happen before `upstreamNotify` can be obtained (the unused-live-binding +// case)? This variant uses `export var y` without an assignment, so the live +// binding is declared but never updated. Node.js reads every projection of the +// cycle as `undefined` for this shape (verified directly with `node`); the SES +// linker must match. The deferring closure may resolve through a later wireUp +// or stay pending; either way the namespace reads must agree with Node.js. +test('cyclic star export with renaming reexport, unused live binding', async t => { + t.plan(3); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + export * from './export-renamer.js'; + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y; + `, + 'https://example.com/main.js': ` + import { x } from './star-reexporter.js'; + import * as ns1 from './star-reexporter.js'; + import * as ns2 from './export-renamer.js'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.is(namespace.captured, undefined); + t.deepEqual(namespace.namespace1, { x: undefined, y: undefined }); + t.deepEqual(namespace.namespace2, { x: undefined, y: undefined }); +}); + test('export-as with duplicated export name', async t => { t.plan(4); From c771da01e8aece5784f44ba7c1e9230c0ac4714f Mon Sep 17 00:00:00 2001 From: endolinbot Date: Tue, 2 Jun 2026 03:54:28 +0000 Subject: [PATCH 03/24] refactor(ses): extract makeNotifierWithResolver helper (issue #59) Introduce a synchronous variant of Promise.withResolvers. The helper returns { notify, resolve }: subscribers attached via notify(update) before resolve(targetNotify) is called are queued; once resolve is called, queued updaters are replayed to the target notifier and subsequent notify(update) calls forward directly through. resolve is one-shot (idempotent for repeat calls), which lets a caller invoke it lazily on each notify and have only the first invocation take effect. Apply the helper to the cycle-resolver branch of wireUpExportNotifier in module-instance.js, replacing the inline pendingUpdaters[] + resolvedUpstreamNotify state machine. The two patterns are now syntactically separated from their call site, reducing the chance that the local state machine will drift away from any future second use. Refs: endojs/endo#3276 (kriskowal review) --- packages/ses/src/module-instance.js | 25 ++++------ packages/ses/src/notifier-with-resolver.js | 56 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 packages/ses/src/notifier-with-resolver.js diff --git a/packages/ses/src/module-instance.js b/packages/ses/src/module-instance.js index 321114bce9..04e954926d 100644 --- a/packages/ses/src/module-instance.js +++ b/packages/ses/src/module-instance.js @@ -23,6 +23,7 @@ import { assign, } from './commons.js'; import { compartmentEvaluate } from './compartment-evaluate.js'; +import { makeNotifierWithResolver } from './notifier-with-resolver.js'; const { quote: q } = assert; @@ -373,26 +374,18 @@ export const makeModuleInstance = ( // that name may be wired only later, in its own candidate-all walk, // after this module's `imports()` returns. Install a notifier that // queues subscribers until the upstream resolves, then forwards them - // through. - const pendingUpdaters = []; - let resolvedUpstreamNotify; + // through. `makeNotifierWithResolver` is the synchronous variant of + // `Promise.withResolvers` that captures this pattern; each `notify` + // call lazily attempts to resolve against the upstream's notifiers. + const { notify: queueOrForward, resolve: resolveUpstream } = + makeNotifierWithResolver(); notify = update => { - if (resolvedUpstreamNotify !== undefined) { - resolvedUpstreamNotify(update); - return; - } const upstreamInstance = mapGet(importedInstances, deferredSpecifier); const upstreamNotify = upstreamInstance.notifiers[deferredImportName]; - if (upstreamNotify === undefined) { - arrayPush(pendingUpdaters, update); - return; - } - resolvedUpstreamNotify = upstreamNotify; - for (const pending of pendingUpdaters) { - upstreamNotify(pending); + if (upstreamNotify !== undefined) { + resolveUpstream(upstreamNotify); } - pendingUpdaters.length = 0; - upstreamNotify(update); + queueOrForward(update); }; } notifiers[exportName] = notify; diff --git a/packages/ses/src/notifier-with-resolver.js b/packages/ses/src/notifier-with-resolver.js new file mode 100644 index 0000000000..d548fca9f1 --- /dev/null +++ b/packages/ses/src/notifier-with-resolver.js @@ -0,0 +1,56 @@ +import { arrayPush } from './commons.js'; + +/** + * Creates a notifier that defers subscribers until a resolver is invoked, + * then forwards all subsequent subscribers to a target notifier. + * + * This is a synchronous variant of `Promise.withResolvers`: `notify` is the + * "subscribe" side and `resolve` is the "settle" side. Subscribers attached + * via `notify(update)` before `resolve(targetNotify)` is called are queued; + * once `resolve` is called, queued updaters are replayed to the target + * notifier and subsequent `notify(update)` calls forward directly through. + * + * `resolve` is one-shot: the first call settles the resolver to its target, + * and subsequent calls are no-ops. (This matches `Promise.withResolvers` + * semantics: a promise settles once.) Callers that need to discover the + * target lazily may safely call `resolve` again on each `notify`; only the + * first call has effect. + * + * Used by `module-instance.js` `wireUpExportNotifier` to resolve the + * star-export-cycle case (endojs/endo#59): a re-export may be wired before + * the upstream module has exposed its notifier for the imported name, and + * the upstream notifier becomes available only after a second pass of + * candidate-all wiring elsewhere in the graph. + * + * @returns {{ + * notify: (update: (value: any) => void) => void, + * resolve: (targetNotify: (update: (value: any) => void) => void) => void, + * }} + */ +export const makeNotifierWithResolver = () => { + /** @type {Array<(value: any) => void>} */ + const pendingUpdaters = []; + /** @type {((update: (value: any) => void) => void) | undefined} */ + let resolvedTargetNotify; + + const notify = update => { + if (resolvedTargetNotify !== undefined) { + resolvedTargetNotify(update); + return; + } + arrayPush(pendingUpdaters, update); + }; + + const resolve = targetNotify => { + if (resolvedTargetNotify !== undefined) { + return; + } + resolvedTargetNotify = targetNotify; + for (const pending of pendingUpdaters) { + targetNotify(pending); + } + pendingUpdaters.length = 0; + }; + + return { notify, resolve }; +}; From 1e15f03f2860d228c4743b323922905c14a6e7d8 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Tue, 2 Jun 2026 03:54:36 +0000 Subject: [PATCH 04/24] test(ses): CommonJS reexporter parity in cyclic star-export (issue #59) Add a regression test in import-cjs.test.js verifying that a cyclic star-export topology in which the "reexporter" is a CommonJS module behaves consistent with Node.js. Node.js rejects ESM-in-CJS-cycle outright (ERR_REQUIRE_CYCLE_MODULE), so the parity comparison is against the pure-CJS cycle: snapshot-at-call-time semantics for the CJS side (property capture sees whatever the renamer had assigned by the re-entry instant), live-binding semantics for the ESM side. Both namespaces project the same shapes the test pins. Addresses naugtur's review feedback on endojs/endo#3276 asking for a test of the cyclic star-export with a CommonJS reexporting module. Refs: endojs/endo#3276 (naugtur review) --- packages/ses/test/import-cjs.test.js | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/ses/test/import-cjs.test.js b/packages/ses/test/import-cjs.test.js index bdf752cc0d..7de66568c4 100644 --- a/packages/ses/test/import-cjs.test.js +++ b/packages/ses/test/import-cjs.test.js @@ -667,6 +667,80 @@ test('importNow handles a cycle in CommonJS modules', t => { t.is(namespace.B.A.a, 42); }); +// Companion to the ESM cyclic star-export tests in +// packages/ses/test/import-gauntlet.test.js (issue endojs/endo#59). This +// variant places a CommonJS module in the cycle as the "star reexporter": +// it captures the renamer's exports by property assignment and itself +// participates in the cycle that the ESM renamer's `export { y as x } from +// './star-reexporter.cjs'` walks. Node.js rejects ESM-in-CJS-cycle +// (`ERR_REQUIRE_CYCLE_MODULE`) outright, so the relevant Node parity is +// the pure-CJS cycle: snapshot-at-call-time semantics for the CJS side +// (the property capture sees whatever the renamer had assigned by the +// re-entry instant), live-binding semantics for the ESM side. Verified +// directly against Node CJS by replacing the ESM renamer with a CJS +// renamer that exposes `x` as a live getter onto its own `y`: both +// namespaces project the same shape SES produces here. +test('cyclic star-export with CommonJS reexporter', async t => { + t.plan(3); + + const resolveHook = resolveNode; + const importHook = async specifier => { + if (specifier === './star-reexporter.cjs') { + // CommonJS analog of `export * from './export-renamer.mjs'`: capture + // the renamer's properties by assignment. Because both are required + // from each other in a cycle, the values read here are whatever the + // renamer had set on its `exports` by the re-entry instant. + return CjsModuleSource( + ` + const r = require('./export-renamer.mjs'); + exports.x = r.x; + exports.y = r.y; + `, + 'https://example.com/star-reexporter.cjs', + ); + } + if (specifier === './export-renamer.mjs') { + return new ModuleSource( + ` + export { y as x } from './star-reexporter.cjs'; + export var y = 45; + `, + 'https://example.com/export-renamer.mjs', + ); + } + if (specifier === './main.mjs') { + return new ModuleSource( + ` + import { x } from './star-reexporter.cjs'; + import * as ns1 from './star-reexporter.cjs'; + import * as ns2 from './export-renamer.mjs'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + 'https://example.com/main.mjs', + ); + } + throw Error(`Cannot load module ${specifier}`); + }; + + const compartment = new Compartment({ + resolveHook, + importHook, + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.mjs'); + + // The CJS reexporter captured `r.x` when the renamer had not yet set its + // own `x`, so the property holds `undefined`. The renamer's `x` resolves + // (live, via the re-export wiring) to its own `y`, which is 45. + t.is(namespace.captured, undefined); + t.deepEqual(namespace.namespace1, { x: undefined, y: 45 }); + t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); +}); + test('importNowHook only called if specifier was not imported before', async t => { t.plan(1); From 36c9384e31794621fd8b6c37282dac4ce7b87820 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Tue, 2 Jun 2026 04:42:25 +0000 Subject: [PATCH 05/24] test(compartment-mapper): cyclic CommonJS reexporter parity fixture + tests (#59 follow-up) Mirror the cycle-rename parity-test layout for the pure-CommonJS cyclic reexporter scenario, in which Node.js and SES agree. The fixture under fixtures-cycle-cjs-reexporter/node_modules/app/ expresses the star-reexporter and renaming reexporter as on-disk .cjs modules with a live getter for the renamed export. cycle-cjs-reexporter.test.js runs the fixture through the compartment-mapper scaffold; cycle-cjs-reexporter-node-parity.test.js runs the same fixture under plain Node.js. Both tests assert through the shared _cycle-cjs-reexporter-assertions.js module, so parity is verified by construction: if both tests pass, the compartment mapper's CommonJS cycle behavior matches Node.js for this case. --- .../test/_cycle-cjs-reexporter-assertions.js | 55 +++++++++++++++++++ .../cycle-cjs-reexporter-node-parity.test.js | 25 +++++++++ .../test/cycle-cjs-reexporter.test.js | 43 +++++++++++++++ .../node_modules/app/export-renamer.cjs | 14 +++++ .../node_modules/app/main.js | 6 ++ .../node_modules/app/package.json | 9 +++ .../node_modules/app/star-reexporter.cjs | 6 ++ 7 files changed, 158 insertions(+) create mode 100644 packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js create mode 100644 packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-cjs-reexporter.test.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs create mode 100644 packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs diff --git a/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js b/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js new file mode 100644 index 0000000000..31870617b2 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js @@ -0,0 +1,55 @@ +/** + * Shared assertion logic for the cyclic CommonJS reexporter scenario. + * Both the Node.js parity test and the Compartment Mapper test import from + * this module so the expected values live in exactly one place. If both + * tests pass, parity with Node.js is verified by construction. + * + * The fixture under fixtures-cycle-cjs-reexporter/node_modules/app/ exercises + * this arrangement, all three modules being CommonJS: + * + * star-reexporter.cjs: Object.assign(exports, require('./export-renamer.cjs')); + * export-renamer.cjs: require('./star-reexporter.cjs'); + * Object.defineProperty(exports, 'x', { + * get() { return module.exports.y; }, enumerable: true }); + * exports.y = 45; + * main.js: const reexp = require('./star-reexporter.cjs'); + * const ren = require('./export-renamer.cjs'); + * exports.captured = reexp.x; + * exports.namespace1 = { x: reexp.x, y: reexp.y }; + * exports.namespace2 = { x: ren.x, y: ren.y }; + * + * In a pure-CommonJS cycle, the reexporter's `Object.assign` reads the + * renamer's `x` getter after the renamer has set `y = 45`, so the copied + * value is 45. Both namespaces project { x: 45, y: 45 }. Node.js and the + * compartment mapper agree on this shape, so the same assertions apply to + * both layers. + * + * The companion divergence scenario (ESM module participating in a cycle + * with a CommonJS module) is exercised by fixtures-cycle-esm-in-cjs and + * its tests; Node.js rejects that topology with ERR_REQUIRE_CYCLE_MODULE + * while SES allows it. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = 45; +export const expectedNamespace1 = { x: 45, y: 45 }; +export const expectedNamespace2 = { x: 45, y: 45 }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleCjsReexporter = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual( + { x: namespace.namespace1.x, y: namespace.namespace1.y }, + expectedNamespace1, + ); + t.deepEqual( + { x: namespace.namespace2.x, y: namespace.namespace2.y }, + expectedNamespace2, + ); +}; diff --git a/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js b/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js new file mode 100644 index 0000000000..88881fcf5d --- /dev/null +++ b/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js @@ -0,0 +1,25 @@ +/** + * Node.js parity test for the cyclic CommonJS reexporter scenario. This + * test runs the same three-module pure-CommonJS fixture under plain Node.js + * (no SES, no compartment mapper) and asserts the same expected values + * asserted in cycle-cjs-reexporter.test.js. Parity is verified by + * construction: if both tests pass, the compartment mapper's CommonJS + * cycle behavior matches Node.js for this case. + */ + +import test from 'ava'; +import { assertCycleCjsReexporter } from './_cycle-cjs-reexporter-assertions.js'; + +test('cyclic CommonJS reexporter - node parity', async t => { + t.plan(3); + // Dynamic ESM import of a CommonJS module: Node exposes the module's + // module.exports as the namespace's default export. Re-use the shared + // assertion module by projecting through `default`. + const moduleNamespace = await import( + new URL( + 'fixtures-cycle-cjs-reexporter/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleCjsReexporter(t, moduleNamespace.default); +}); diff --git a/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js new file mode 100644 index 0000000000..215aaec789 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js @@ -0,0 +1,43 @@ +/** + * Cyclic CommonJS reexporter scenario exercised through the + * compartment-mapper test scaffold. The companion Node.js parity test in + * cycle-cjs-reexporter-node-parity.test.js imports the same fixture under + * Node.js and asserts the same expected values; together the two tests + * teach the compartment mapper's CommonJS cycle behavior and pin it to + * Node.js's reference behavior. + * + * This is the pure-CommonJS counterpart to the ESM-in-CJS-cycle divergence + * exercised by cycle-esm-in-cjs.test.js and + * cycle-esm-in-cjs-node-parity.test.js, where Node.js rejects the topology + * with ERR_REQUIRE_CYCLE_MODULE but SES allows it. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleCjsReexporter } from './_cycle-cjs-reexporter-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-cjs-reexporter/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleCjsReexporter(t, namespace); +}; + +scaffold( + 'cycle-cjs-reexporter (issue #59 follow-up)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs new file mode 100644 index 0000000000..869e3b9671 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs @@ -0,0 +1,14 @@ +// CommonJS analog of `export { y as x } from './star-reexporter.cjs'; export +// var y = 45`: expose `x` as a live getter onto our own `y`. The require to +// star-reexporter participates in the cycle; the value returned is the cached +// (partial) exports object the reexporter had at the moment it dispatched to +// us, but our reads do not depend on its contents. +require('./star-reexporter.cjs'); +Object.defineProperty(exports, 'x', { + get() { + return module.exports.y; + }, + enumerable: true, + configurable: true, +}); +exports.y = 45; diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js new file mode 100644 index 0000000000..e7274051fc --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js @@ -0,0 +1,6 @@ +const reexp = require('./star-reexporter.cjs'); +const ren = require('./export-renamer.cjs'); + +exports.captured = reexp.x; +exports.namespace1 = { x: reexp.x, y: reexp.y }; +exports.namespace2 = { x: ren.x, y: ren.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json new file mode 100644 index 0000000000..2f5b17bcfe --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "commonjs", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs new file mode 100644 index 0000000000..2700929ad7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs @@ -0,0 +1,6 @@ +// CommonJS analog of `export * from './export-renamer.cjs'`: eagerly copy the +// renamer's own enumerable properties onto our exports. Because both modules +// participate in a cycle, the values observed here are whatever the renamer +// had set on its exports by the re-entry instant. Live-binding shapes (the +// renamer's `x` getter) project their current value at copy time. +Object.assign(exports, require('./export-renamer.cjs')); From 64095ee3a9ca3b80a4732d216d2956bcc6b60ab5 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Tue, 2 Jun 2026 04:42:35 +0000 Subject: [PATCH 06/24] test(compartment-mapper): ESM-in-CJS-cycle divergence parity test (#59 follow-up) Add a parity-test pair that programmatically verifies the divergence between Node.js and SES for the ESM-in-CommonJS-cycle topology. The fixture under fixtures-cycle-esm-in-cjs/node_modules/app/ has a CJS bridge module that require()s an ESM peer module that imports back from the bridge. cycle-esm-in-cjs.test.js asserts SES allows the topology and the namespace projects the live binding (bridgeValue === 42). cycle-esm-in-cjs-node-parity.test.js spawns a fresh Node.js process on the same fixture and asserts Node rejects with ERR_REQUIRE_CYCLE_MODULE. Together the two tests pin the divergence as a verified property rather than narrative prose. --- .../test/cycle-esm-in-cjs-node-parity.test.js | 40 ++++++++++++++ .../test/cycle-esm-in-cjs.test.js | 54 +++++++++++++++++++ .../node_modules/app/bridge.cjs | 9 ++++ .../node_modules/app/main.mjs | 3 ++ .../node_modules/app/package.json | 9 ++++ .../node_modules/app/peer.mjs | 10 ++++ 6 files changed, 125 insertions(+) create mode 100644 packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-esm-in-cjs.test.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs create mode 100644 packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs create mode 100644 packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs diff --git a/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js b/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js new file mode 100644 index 0000000000..034fc5ce80 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js @@ -0,0 +1,40 @@ +/** + * Node.js parity test for the ESM-in-CommonJS-cycle divergence scenario. + * This test runs the same fixture under plain Node.js (no SES, no + * compartment mapper) and asserts that Node.js rejects the topology with + * ERR_REQUIRE_CYCLE_MODULE. The companion compartment-mapper / SES test + * in cycle-esm-in-cjs.test.js asserts the divergent behavior: SES allows + * the same fixture to load and exposes the cycle's snapshot / live-binding + * shape on the namespace. Together the two tests verify the divergence + * programmatically rather than narratively. + */ + +import test from 'ava'; +import process from 'process'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +test('ESM-in-CJS-cycle - node parity (rejects with ERR_REQUIRE_CYCLE_MODULE)', t => { + t.plan(2); + const fixture = new URL( + 'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs', + import.meta.url, + ); + // Spawn a fresh Node process to execute the fixture. The expected outcome + // is a non-zero exit with the ERR_REQUIRE_CYCLE_MODULE error code printed + // on stderr. Spawning isolates the failure from the test runner's own + // module graph and keeps the rest of the suite running. + const result = spawnSync(process.execPath, [fileURLToPath(fixture)], { + encoding: 'utf8', + }); + t.not( + result.status, + 0, + `Expected Node to reject ESM-in-CJS-cycle, got exit ${result.status}`, + ); + t.regex( + result.stderr, + /ERR_REQUIRE_CYCLE_MODULE/, + `Expected ERR_REQUIRE_CYCLE_MODULE in stderr, got:\n${result.stderr}`, + ); +}); diff --git a/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js new file mode 100644 index 0000000000..af2ef0c259 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js @@ -0,0 +1,54 @@ +/** + * Cyclic ESM-in-CommonJS divergence scenario exercised through the + * compartment-mapper test scaffold. SES allows the topology that Node.js + * rejects with ERR_REQUIRE_CYCLE_MODULE; this test pins SES's actual + * behavior so the divergence is verified programmatically rather than + * documented narratively. The companion Node.js parity test in + * cycle-esm-in-cjs-node-parity.test.js verifies the Node.js side of the + * divergence by spawning Node on the same fixture and asserting the error + * code. + * + * Topology (under fixtures-cycle-esm-in-cjs/node_modules/app/): + * + * main.mjs: import * as bridge from './bridge.cjs'; + * export const bridgeValue = bridge.value; + * bridge.cjs: const m = require('./peer.mjs'); + * exports.value = m.value; + * peer.mjs: import { value as bridgeValue } from './bridge.cjs'; + * export const value = 42; + * + * On the SES side, bridge.cjs reads `m.value` from peer.mjs's namespace + * after the cycle's back-edge has reached peer.mjs (which then re-entered + * bridge.cjs). Because the ESM side resolves through live bindings, by the + * time main reads bridge.value the snapshot capture in bridge.cjs sees + * peer.mjs's `value = 42`. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; + +const fixture = new URL( + 'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + t.is(namespace.bridgeValue, 42); +}; + +scaffold( + 'cycle-esm-in-cjs (issue #59 follow-up: divergence)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs new file mode 100644 index 0000000000..b1b0465152 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs @@ -0,0 +1,9 @@ +// A CommonJS module that requires an ESM module which itself imports back +// from this CommonJS module. Node.js rejects this topology with +// ERR_REQUIRE_CYCLE_MODULE because the ESM module participating in the cycle +// must be evaluated synchronously to satisfy require(), but the cycle's +// back-edge re-enters before that evaluation can complete. SES allows the +// topology, treating the cycle's back-edge with snapshot semantics on the +// CommonJS side and live-binding semantics on the ESM side. +const m = require('./peer.mjs'); +exports.value = m.value; diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs new file mode 100644 index 0000000000..3846307702 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs @@ -0,0 +1,3 @@ +import * as bridge from './bridge.cjs'; + +export const bridgeValue = bridge.value; diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json new file mode 100644 index 0000000000..9556405374 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.mjs", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs new file mode 100644 index 0000000000..067d75ed47 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs @@ -0,0 +1,10 @@ +// An ESM module that imports from a CommonJS module that itself requires +// this ESM module. The cycle's back-edge (from bridge.cjs into this module) +// is what Node.js rejects when bridge.cjs require()s us, because evaluating +// us synchronously would re-enter bridge.cjs whose evaluation has not yet +// returned. SES allows the topology, treating the back-edge with snapshot +// semantics on the CommonJS side and live-binding semantics on the ESM +// side. +import { value as bridgeValue } from './bridge.cjs'; + +export const value = 42; From 0ac8b9ab17e702de6843eb6946cdc06b575da177 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Tue, 2 Jun 2026 04:42:48 +0000 Subject: [PATCH 07/24] test(ses): reframe cyclic CJS reexporter test prose; reference compartment-mapper parity Rewrite the JSDoc on the cyclic CommonJS reexporter test in import-cjs.test.js and the companion unused-live-binding test in import-gauntlet.test.js so each block is primarily an explanation of what the test verifies (the shapes of the projected namespaces, the specific snapshot vs live-binding distinction, the parity property), not the procedural history of how the test came to be. The in-process SES regression is retained on the import-cjs.test.js side because it exercises the module-instance linker directly through the Compartment API with inline ModuleSources, a path the compartment-mapper parity suite does not cover. The prose now points at the parity suite for the parity-with-Node substantiation: packages/compartment-mapper/test/cycle-cjs-reexporter.test.js and its node-parity sibling for the pure-CommonJS agreement case; packages/compartment-mapper/test/cycle-esm-in-cjs.test.js and its node-parity sibling for the ESM-in-CJS-cycle divergence (ERR_REQUIRE_CYCLE_MODULE on Node, allowed on SES). --- packages/ses/test/import-cjs.test.js | 33 ++++++++++++++--------- packages/ses/test/import-gauntlet.test.js | 16 +++++------ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/ses/test/import-cjs.test.js b/packages/ses/test/import-cjs.test.js index 7de66568c4..8bcf902ea2 100644 --- a/packages/ses/test/import-cjs.test.js +++ b/packages/ses/test/import-cjs.test.js @@ -667,19 +667,26 @@ test('importNow handles a cycle in CommonJS modules', t => { t.is(namespace.B.A.a, 42); }); -// Companion to the ESM cyclic star-export tests in -// packages/ses/test/import-gauntlet.test.js (issue endojs/endo#59). This -// variant places a CommonJS module in the cycle as the "star reexporter": -// it captures the renamer's exports by property assignment and itself -// participates in the cycle that the ESM renamer's `export { y as x } from -// './star-reexporter.cjs'` walks. Node.js rejects ESM-in-CJS-cycle -// (`ERR_REQUIRE_CYCLE_MODULE`) outright, so the relevant Node parity is -// the pure-CJS cycle: snapshot-at-call-time semantics for the CJS side -// (the property capture sees whatever the renamer had assigned by the -// re-entry instant), live-binding semantics for the ESM side. Verified -// directly against Node CJS by replacing the ESM renamer with a CJS -// renamer that exposes `x` as a live getter onto its own `y`: both -// namespaces project the same shape SES produces here. +// In-process SES regression for the ESM-in-CommonJS-cycle shape of issue +// endojs/endo#59, exercised directly through the Compartment API with +// inline ModuleSources. The fixture places a CommonJS module in the cycle +// as the "star reexporter": it captures the renamer's exports by property +// assignment (`exports.x = r.x; exports.y = r.y`) at the moment its own +// `require('./export-renamer.mjs')` returns. The ESM renamer re-exports +// from the CJS reexporter with `export { y as x } from './star-reexporter.cjs'`. +// Because the renamer's `x` resolves to its own `y` via live ESM binding, +// the namespace projection at the renamer is { x: 45, y: 45 }; on the CJS +// side, the property capture happened before `r.x` had any value, so the +// reexporter's namespace is { x: undefined, y: 45 }. Both shapes are pinned. +// +// Node.js rejects this ESM-in-CJS-cycle topology with ERR_REQUIRE_CYCLE_MODULE; +// the divergence is verified programmatically in +// packages/compartment-mapper/test/cycle-esm-in-cjs.test.js (SES side) +// together with packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js +// (Node side). For the parity case where Node and SES agree on a +// pure-CommonJS cyclic reexporter, see +// packages/compartment-mapper/test/cycle-cjs-reexporter.test.js together with +// packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js. test('cyclic star-export with CommonJS reexporter', async t => { t.plan(3); diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 8ad623fcbe..8dc2eabf18 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -285,14 +285,14 @@ test('cyclic star export with renaming reexport (issue #59)', async t => { t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); }); -// Companion regression for endojs/endo#59 addressing the question raised on -// endojs/endo#3276: is a situation possible where all calls to the deferring -// notify happen before `upstreamNotify` can be obtained (the unused-live-binding -// case)? This variant uses `export var y` without an assignment, so the live -// binding is declared but never updated. Node.js reads every projection of the -// cycle as `undefined` for this shape (verified directly with `node`); the SES -// linker must match. The deferring closure may resolve through a later wireUp -// or stay pending; either way the namespace reads must agree with Node.js. +// Companion regression for endojs/endo#59 covering the unused-live-binding +// shape of the cyclic star-export. The export-renamer's +// `export { y as x } from './star-reexporter.js'` re-exports a live binding +// from the star reexporter, but the renamer's own `y` is declared without +// initialization (`export var y;`), so the binding is never updated. +// Every projection of the cycle reads `undefined`. Node.js agrees: the +// in-process SES linker behavior matches Node.js's reference behavior for +// this shape, which is the parity property the test pins. test('cyclic star export with renaming reexport, unused live binding', async t => { t.plan(3); From 65d1310d5d243defac83ecebcc80f7caef9c5c53 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Wed, 3 Jun 2026 05:58:03 +0000 Subject: [PATCH 08/24] test(compartment-mapper): unused-live-binding parity fixture + tests (#59 follow-up) Add the parity test pair kriskowal asked for on endojs/endo-but-for-bots#379 (review comment 3338677487): the unused-live-binding shape of the cyclic star-export with renaming reexport (endojs/endo#59) needs a parity test substantiating the Node.js parity claim, ideally with a shared fixture as with cycle-rename parity. The companion populated-binding shape was already covered by cycle-rename.test.js and cycle-rename-node-parity.test.js through _cycle-rename-assertions.js; this commit lands the analogous trio for the unused-live-binding shape: fixtures-cycle-rename-unused/node_modules/app/ - three modules; the renamer's `export var y` has no initializer. _cycle-rename-unused-assertions.js - shared assertion module; expected projections are { x: undefined, y: undefined } and captured: undefined. cycle-rename-unused.test.js - compartment-mapper scaffold exercise. cycle-rename-unused-node-parity.test.js - Node.js exercise of the same fixture; parity is verified by construction when both pass. Also update the in-process SES regression's prose in packages/ses/test/import-gauntlet.test.js to reference the new parity pair, matching the cross-reference pattern the populated-binding test already uses for cycle-rename. Refs: endojs/endo#3276 (naugtur), endojs/endo-but-for-bots#379 (kriskowal) --- .../test/_cycle-rename-unused-assertions.js | 48 +++++++++++++++++++ .../cycle-rename-unused-node-parity.test.js | 22 +++++++++ .../test/cycle-rename-unused.test.js | 40 ++++++++++++++++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 7 +++ .../node_modules/app/package.json | 9 ++++ .../node_modules/app/star-reexporter.js | 1 + packages/ses/test/import-gauntlet.test.js | 6 ++- 8 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/compartment-mapper/test/_cycle-rename-unused-assertions.js create mode 100644 packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-unused.test.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js diff --git a/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js new file mode 100644 index 0000000000..0d027979de --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js @@ -0,0 +1,48 @@ +/** + * Shared assertion logic for the unused-live-binding shape of the cyclic + * star-export with renaming reexport regression (endojs/endo#59). This is + * the companion to the populated shape exercised by + * `_cycle-rename-assertions.js`; the only difference is that the renamer's + * `export var y` here has no initializer, so the live binding is declared + * but never updated. Every projection of the cycle therefore reads + * `undefined`. Both the Node.js parity test and the Compartment Mapper test + * import from this module so the expected values live in exactly one place; + * if both tests pass, parity with Node.js is verified by construction. + * + * The fixture under fixtures-cycle-rename-unused/node_modules/app/ exercises + * this arrangement: + * + * star-reexporter.js: export * from './export-renamer.js'; + * export-renamer.js: export { y as x } from './star-reexporter.js'; + * export var y; + * main.js: import { x } from './star-reexporter.js'; + * import * as ns1 from './star-reexporter.js'; + * import * as ns2 from './export-renamer.js'; + * export const captured = x; + * export const namespace1 = { x: ns1.x, y: ns1.y }; + * export const namespace2 = { x: ns2.x, y: ns2.y }; + * + * The deferring closure introduced by the issue #59 fix queues subscribers + * until the upstream notifier resolves, then forwards them. With no + * initializer the upstream's value never updates, so every read is + * `undefined`. Node.js exhibits the same shape, so the parity test pins + * both layers to a single expected projection. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = undefined; +export const expectedNamespace1 = { x: undefined, y: undefined }; +export const expectedNamespace2 = { x: undefined, y: undefined }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleRenameUnused = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual(namespace.namespace1, expectedNamespace1); + t.deepEqual(namespace.namespace2, expectedNamespace2); +}; diff --git a/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js new file mode 100644 index 0000000000..418c3591de --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js @@ -0,0 +1,22 @@ +/** + * Node.js parity test for the unused-live-binding shape of the cyclic + * star-export regression (endojs/endo#59). This test runs the same fixture + * under plain Node.js (no SES, no compartment mapper) and asserts the same + * expected values asserted in cycle-rename-unused.test.js. Parity is + * verified by construction: if both tests pass, the compartment mapper's + * linker behavior matches Node.js for this case. + */ + +import test from 'ava'; +import { assertCycleRenameUnused } from './_cycle-rename-unused-assertions.js'; + +test('cyclic star export with renaming reexport, unused live binding (issue #59) - node parity', async t => { + t.plan(3); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-unused/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameUnused(t, namespace); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-unused.test.js b/packages/compartment-mapper/test/cycle-rename-unused.test.js new file mode 100644 index 0000000000..b7871d4dcf --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-unused.test.js @@ -0,0 +1,40 @@ +/** + * Companion to cycle-rename.test.js covering the unused-live-binding shape + * of the cyclic star-export regression (endojs/endo#59). The renamer's + * `export var y` has no initializer; every projection of the cycle reads + * `undefined`. Exercised through the compartment-mapper test scaffold; the + * Node.js parity sibling in cycle-rename-unused-node-parity.test.js asserts + * the same expected values against plain Node.js. Together the two tests + * pin the compartment mapper's behavior for this shape to Node.js's + * reference behavior. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleRenameUnused } from './_cycle-rename-unused-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-unused/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameUnused(t, namespace); +}; + +scaffold( + 'cycle-rename-unused (issue #59: unused live binding)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..ee50094c9e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js new file mode 100644 index 0000000000..29d03f9ff9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js @@ -0,0 +1,7 @@ +import { x } from './star-reexporter.js'; +import * as ns1 from './star-reexporter.js'; +import * as ns2 from './export-renamer.js'; + +export const captured = x; +export const namespace1 = { x: ns1.x, y: ns1.y }; +export const namespace2 = { x: ns2.x, y: ns2.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..be4dc803c1 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js @@ -0,0 +1 @@ +export * from './export-renamer.js'; diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 8dc2eabf18..7f883a685d 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -292,7 +292,11 @@ test('cyclic star export with renaming reexport (issue #59)', async t => { // initialization (`export var y;`), so the binding is never updated. // Every projection of the cycle reads `undefined`. Node.js agrees: the // in-process SES linker behavior matches Node.js's reference behavior for -// this shape, which is the parity property the test pins. +// this shape, which is the parity property the test pins. The same fixture +// shape is also exercised through compartment-mapper's scaffold and pinned +// to Node.js's reference behavior with a shared assertion module; see +// packages/compartment-mapper/test/cycle-rename-unused.test.js and +// packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js. test('cyclic star export with renaming reexport, unused live binding', async t => { t.plan(3); From de535df60e3e6cdbc5db149f83a7c5bfe7fd585e Mon Sep 17 00:00:00 2001 From: endolinbot Date: Wed, 10 Jun 2026 23:08:20 +0000 Subject: [PATCH 09/24] test(ses): TDZ-observation matrix for cyclic star export with renaming reexport (issue #59 follow-up) Add a 6-cell test matrix that varies the cyclic star-export fixture along two axes: the order in which main.js imports the export-renamer and the star-reexporter, and the binding form (const, let, var) the renamer uses for its local y. The star-reexporter reads r.y through a namespace import of the renamer at the top of its body and captures the result, so when the renamer is imported first from main.js the observation lands while the renamer is on the evaluation stack with y not yet initialized. Four cells assert SES's current behavior because it already matches Node.js: renamer-first with var (reads undefined), and all three star-reexporter-first orderings (read the assigned value 42). The two remaining cells (renamer-first with const and let) are marked test.failing: under ECMA-262 semantics, the cross-module read through the namespace import should raise ReferenceError because y is in the temporal dead zone, and Node.js confirms this; SES's current module-instance machinery returns undefined instead. The .failing markers pin the Node.js reference behavior as the desired outcome and surface the SES divergence as a known gap to address separately. --- packages/ses/test/import-gauntlet.test.js | 279 ++++++++++++++++++++++ 1 file changed, 279 insertions(+) diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 7f883a685d..347924a6f5 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -332,6 +332,285 @@ test('cyclic star export with renaming reexport, unused live binding', async t = t.deepEqual(namespace.namespace2, { x: undefined, y: undefined }); }); +// The next six tests vary the cyclic star-export fixture along two axes +// and observe the renamer's binding during the window when the renamer is +// linked into the cycle but its body has not yet executed past the +// binding's declaration. The axes are: +// +// 1. Whether main.js imports the export-renamer first or the +// star-reexporter first. The first-imported module starts evaluating +// first; depth-first traversal of the cycle then determines which +// module's body runs while the other is on the evaluation stack with +// bindings not yet initialized. +// +// 2. Whether the renamer's `y` is declared with `const`, `let`, or `var`. +// Under ECMA-262 semantics, `const` and `let` create a binding that is +// in the temporal dead zone until its declaration is evaluated, so a +// read raises a ReferenceError; `var` is hoisted and reads `undefined` +// until the assignment runs. +// +// The observation is performed by the star-reexporter at the top of its +// body: it reads `r.y` through its own namespace import of the renamer and +// captures the result (the assigned value, or the error name when reading +// raises) as a probe export. main.js reads the probe through the +// star-reexporter's namespace. +// +// When the renamer is imported first from main.js, the star-reexporter's +// body runs while the renamer is on the evaluation stack with `y` not yet +// initialized. The expected observation under ECMA-262 semantics is +// ReferenceError for `const` and `let` and `undefined` for `var`. When the +// star-reexporter is imported first, depth-first cycle resolution evaluates +// the renamer's body to completion before the star-reexporter's body runs, +// so the probe captures the assigned value for every binding form. The +// "star reexporter imported first" cases therefore have no TDZ window to +// observe (the maintainer's "in all cases that it is possible" caveat); +// they are recorded as the expected non-observation that completes the +// matrix. +// +// Two cells diverge from Node.js: SES's current module-instance machinery +// does not enforce the temporal dead zone for cross-module reads through a +// namespace import during a cycle, so `r.y` reads `undefined` rather than +// raising ReferenceError for `const` and `let` in the renamer-first orderings. +// Those two cells are marked `test.failing` so the suite pins the +// ECMA-262-conformant Node.js reference behavior as the desired outcome and +// surfaces the SES divergence as a known gap to either accept or close +// separately. The four converging cells assert SES's current behavior +// directly because it already matches Node.js for those cells. + +test.failing( + 'cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', + async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); + }, +); + +test.failing( + 'cyclic star export with renaming reexport, renamer imported first, let binding observes ReferenceError during temporal dead zone', + async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export let y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); + }, +); + +test('cyclic star export with renaming reexport, renamer imported first, var binding observes undefined while hoisted but unassigned', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: undefined }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, const binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, let binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export let y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, var binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + test('export-as with duplicated export name', async t => { t.plan(4); From 22867ccb28cdeb2cce6a2eb9b9ecb68de9ed4568 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Wed, 10 Jun 2026 23:40:08 +0000 Subject: [PATCH 10/24] docs(ses): document construction-time-notifiers consideration (issue #59 follow-up) Adds packages/ses/designs/construction-time-notifiers.md analyzing the maintainer's proposal to create all module-instance notifiers at construction time instead of link time, in response to naugtur's inline-comment ask on upstream review and kriskowal's follow-up. The analysis sets out the present four-phase lifecycle (load, instantiate, construct, link), enumerates what is and is not available at construction time, sketches a two-pass redesign that constructs reexport-notifier stubs in instantiate and resolves them in a pass-2 wire step, and addresses the precompiled-ModuleSource calling-convention question (no schema change required; imports(updateRecord) becomes wire-only). The recommendation is to land the redesign as a follow-up PR rather than fold the refactor into the present regression-fix PR: the redesign touches two large surfaces (makeModuleInstance and makeVirtualModuleInstance), shifts the instantiate/link line, and benefits from being reviewed against the parity baseline this PR's test matrix establishes. The analysis also notes a separable observation: the redesign does not by itself close the SES-against-Node cross-module TDZ divergence the test matrix pins as test.failing. Closing that gap requires a separate change to the exported-getter contract (consult the upstream's own-binding getter rather than caching the propagated value), which the redesign enables but does not require. --- .../designs/construction-time-notifiers.md | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 packages/ses/designs/construction-time-notifiers.md diff --git a/packages/ses/designs/construction-time-notifiers.md b/packages/ses/designs/construction-time-notifiers.md new file mode 100644 index 0000000000..9c49b7ab7e --- /dev/null +++ b/packages/ses/designs/construction-time-notifiers.md @@ -0,0 +1,420 @@ +# Construction-time notifiers for SES module instances + +## Context + +This document responds to two threads of review feedback on the cyclic +star-export fix for the endojs/endo cyclic-star-export issue. + +The first is naugtur's inline-comment ask on the upstream review (the +inline at `packages/ses/src/module-instance.js:367`): + +> I know this is vague, but it'd feel nicer design-wise to create all +> notifiers ahead of time and give them an ability to forward to or pull +> from another notifier, in which case the reexport \* would be a matter of +> connecting notifiers in a loop. +> +> If we could use this as an opportunity to design a fancier notifier +> primitive to share between the two implementations `makeModuleInstance` +> and `makeVirtualModuleInstance` we'd avoid some of the risk of interop +> failure. + +The second is kriskowal's follow-up to that comment, with a concrete +proposal: + +> Please consider whether we can create all of the notifiers for a +> module instance at time of construction instead of time of link. +> This may require a more deliberate separation of the instantiation +> and link phases, but I suspect we already have a hard enough line. +> This would obviate deferred notifier linkage, since notifiers would +> always be available up front. +> If not, please explain why such an arrangement is not possible or +> how the calling convention for precompiled ModuleSource instances +> would need to change. +> It might be that the notifiers need to be partially applied before +> full initialization. + +This document analyzes the redesign, identifies what it would and would +not buy, and explains why the present recommendation is to **document +the plan and defer implementation** rather than land the refactor in the +current PR. + +## The current phases + +The relevant code lives in `packages/ses/src/module-{link,instance}.js`. +The lifecycle of a module passes through four phases: + +1. **Load** (`module-load.js`): walk the module graph asynchronously and + acquire the module source for every node. + Produces a `moduleRecord` per module with `{ compartment, + moduleSource, moduleSpecifier, resolvedImports, importMeta }`. + The record carries the full `moduleSource`, including + `__fixedExportMap__`, `__liveExportMap__`, `__reexportMap__`, and + the `reexports` (star-export specifiers) array. +2. **Instantiate** (`module-link.js`'s `instantiate`): for one module, + call `makeModuleInstance` (or `makeVirtualModuleInstance`), memoize + the result, and then recursively `link` each dependency. +3. **Construct** (the body of `makeModuleInstance`): build the local + binding state for the module's own exports (fixed plus live), + populate `notifiers` for those exports, and define a closure named + `imports` that the moduleFunctor will invoke during execute. + The construct phase runs synchronously inside `instantiate` and + returns the instance object `{ notifiers, exportsProxy, execute }`. +4. **Link** (the body of the `imports(updateRecord)` closure, invoked + from inside `execute`): walk the `updateRecord` (the imports the + functor declared), look up each upstream module instance, register + each updater on the upstream's notifier, and wire reexport notifiers + via `wireUpExportNotifier` for star-reexported names + (`reexports`/`exportAlls`) and renamed reexports (`__reexportMap__`). + +Today, **own** notifiers (for `__fixedExportMap__` and +`__liveExportMap__` entries, plus the `'*'` notifier) are created in +phase 3, the construct phase. +**Reexport** notifiers (for `__reexportMap__` entries and for star +reexports) are created in phase 4, the link phase, inside `imports`. + +The endo cyclic-star-export fix added `makeNotifierWithResolver` to make +a single reexport notifier tolerate the case where the upstream's +notifier was not yet present when the reexport was wired. +The deferred resolver settles on the first call where +`upstreamInstance.notifiers[deferredImportName]` is defined and +forwards queued and subsequent updaters through to it. + +## What the maintainer's proposal would change + +The proposal moves **all** notifier creation, including reexport +notifiers, into phase 3 (construction). +After the move, the `notifiers` map for every module instance is +complete by the time `instantiate` returns. +The link phase becomes pure wiring: it would still walk the +`updateRecord` and register updaters on upstream notifiers, but every +notifier it touches already exists. + +This is the maintainer's "obviate deferred notifier linkage" outcome. +`makeNotifierWithResolver` would lose its present sole caller and could +be deleted (or kept as the general primitive naugtur describes, used +internally by every notifier whose value is forwarded from another +notifier). + +The maintainer flagged a concrete risk: "notifiers may need to be +partially applied before full initialization." +This is the calling-convention question for precompiled `ModuleSource` +instances and is examined below. + +## What is actually available at construction time + +A module's `moduleSource` carries, at the moment `makeModuleInstance` is +called, the following enumeration of every export name and its origin: + +- `moduleSource.__fixedExportMap__`: own fixed bindings (`export const`, + `export function`, `export class`). + Local binding state is known. +- `moduleSource.__liveExportMap__`: own live bindings (`export let`, + `export var`, plus the reexport-as-live shape `export { y as x } from + './u.js'`). + Local binding state is known. +- `moduleSource.__reexportMap__`: renamed reexports from specific + specifiers. + The map's keys are upstream specifiers and the values are + `[[localName, exportedName], ...]` pairs. + The exported names are known. +- `moduleSource.reexports` (the `exportAlls` array): star reexports. + The list of upstream specifiers is known. + +The `resolvedImports` map on the `moduleRecord` is also available; for +every upstream specifier the resolved key is known. +What is **not** available at construction time: + +- The upstream module instance's own `notifiers` object. + The upstream has not yet been instantiated when the current module is + constructed; in cycles, by definition the upstream's construction is + in progress on the same call stack. +- For star reexports, the **set of names** the upstream re-exports. + Star reexport (`export *`) is a name-set inheritance whose membership + depends on the upstream's own exports plus its own transitive star + reexports. + Resolving the set requires walking the upstream's + `moduleSource.exports` (a flat list of all export names the upstream + declares, sorted and known at construction time per + `module-source.js`). + +The first gap is the cycle structural property that `instantiate` was +designed to break by memoizing the instance after `makeModuleInstance` +returns and before recursing into dependencies. +The second gap is a graph traversal that, today, is performed implicitly +by the link phase's `entries(importNotifiers)` walk and the +`candidateAll` map. + +## A redesign sketch + +Move construction into a two-pass shape inside `instantiate`: + +1. **Pass 1 (construct and name-claim)**: For every module in the + closure of `resolvedImports`, call `makeModuleInstance` to allocate + the binding state for own exports and create a stub for every + reexport notifier the module declares (the union of + `__reexportMap__`'s `exportedName`s and the star-reexport names + enumerated by walking each upstream's `moduleSource.exports`). + The stub is a forwarder built from a primitive of naugtur's shape: + a notifier that holds a reference (initially undefined) to a target + notifier and queues subscribers and updates until the target lands. + Each stub records its `(upstreamSpecifier, upstreamName)` link + target metadata. + The instance is memoized; recursion proceeds. +2. **Pass 2 (wire)**: For every module, walk the recorded link targets + and resolve each stub against + `upstreamInstance.notifiers[upstreamName]`. + By this point every instance has been constructed and every own + notifier exists. + Every stub finds a real notifier; deferred queueing is unused. + The pass folds into the present `imports(updateRecord)` body. + +Star reexports become an enumeration step in pass 1: walk +`upstreamModuleSource.exports` and create a forwarder stub for each +non-`default` name not already claimed by an own export or a renamed +reexport. +Ambiguity (a name reachable via two different star reexports) is +detected during pass 1 by tracking which star reexport claimed each +name, with the second claim demoting the stub to a no-op and surfacing +a syntax error on access; this mirrors the present `candidateAll[name] += false` discipline. + +The `imports(updateRecord)` closure is retained but trimmed: its only +remaining work is registering the moduleFunctor's `updaters` on the +upstream notifiers it already located in pass 1. +The `wireUpExportNotifier` helper is no longer reached with `notify === +undefined` and its `makeNotifierWithResolver` branch can be removed. + +## Hard-enough line between instantiate and link? + +The maintainer asked whether the present code already has a hard line +between instantiate and link. +"I suspect we already have a hard enough line." +The answer in the present code is "almost, but not quite." + +`makeModuleInstance` returns an instance that exposes `notifiers`, +`exportsProxy`, and `execute`. +The first two are populated by the construction body; the third is the +moduleFunctor wrapper, which, when invoked, runs the functor which +calls `imports(updateRecord)` to wire itself to its upstreams. +Today **the wiring is a side effect of execute**, not of instantiate. +That is the line the redesign needs to shift: pass-1 (wire stubs) +belongs to instantiate; pass-2 (register moduleFunctor updaters on +upstream notifiers) can remain in execute. + +The line is concrete enough that the move is mechanical. +The risk is not architectural; it is two surfaces touched at once +(`makeModuleInstance` and `makeVirtualModuleInstance`) and a +calling-convention question for precompiled module sources. + +## Precompiled ModuleSource calling convention + +The maintainer's "calling convention for precompiled ModuleSource +instances" caveat refers to the contract between the SES linker and the +moduleFunctor that a precompiled `__syncModuleProgram__` (or +`__syncModuleFunctor__`) embodies. +The functor's signature today is: + +```js +functor({ imports, onceVar, liveVar, import, importMeta }); +``` + +where `imports(updateRecord)` is the link-time hook that registers the +functor's import updaters on the upstream notifiers and processes star +reexports. +The functor calls `imports(updateRecord)` once, early in execution, +before any code that reads an import binding runs. + +Two facets of the calling convention come under scrutiny in the +redesign: + +1. **`imports` becomes wire-only.** + With reexport notifiers already created in pass 1, the + `imports(updateRecord)` closure no longer needs `__reexportMap__`, + `exportAlls`, or `wireUpExportNotifier` logic; it only needs to + register the functor's own updaters on upstream notifiers it can + look up directly. + The functor's call shape is unchanged; the internal body of + `imports` shrinks. + No change to precompiled output is required. +2. **The "partially applied notifiers" question.** + A precompiled module source whose moduleFunctor has not yet been + instantiated (because the functor is created lazily in `execute`) + still needs its reexport notifiers wired in pass 1, before the + functor exists. + This is fine for the own-export side because pass 1 does not need + the functor; the binding state for own exports is allocated as + inert state and the functor sets the initial value when it runs. + But pass 1 needs to know the **set** of own exports and their + shape (fixed against live), which is what `__fixedExportMap__` and + `__liveExportMap__` already provide. + So pass 1's information needs are met by the existing + precompiled-source contract; no change to the precompiled-source + schema is required. + +There is one edge case where the maintainer's "partial application" +phrasing applies. +The renamer's local binding state (the `update` freezer in the present +`liveExportMap` walk, named with the "reexporting creates a tree of +bindings" comment) needs to participate in the chain of stub-resolution +in pass 1. +The present code already does this by storing the same `notify` in both +`localGetNotify` and `notifiers`, and by having the live binding's +`notify` accept a register-and-defer subscription whether or not the +binding has left TDZ. +The redesign preserves that property; the reexport stub's target +becomes the upstream's own-binding notifier, and the upstream's +own-binding notifier is the same object whether stubbed at pass 1 or +referenced lazily today. + +## Will the redesign close the TDZ gap? + +The companion observation from this PR is that SES does not enforce +ECMA-262 temporal dead zone semantics for cross-module reads through a +namespace import during a cycle. +The renamer-first plus const, the renamer-first plus let, and now the +named-reexport plus renamer-first plus const cases all return +`undefined` rather than raising `ReferenceError` (the 6 plus 1 cells in +the [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) +matrix). +The reason is that the cross-module read goes through the renamer's +**exported getter**, not the renamer's local-binding **own getter**. +The local-binding getter is the only place TDZ is enforced; the +exported getter installed by `wireUpExportNotifier` simply returns the +last value the upstream notifier propagated, which starts as +`undefined`. + +The redesign would **not** by itself close this gap. +The redesign guarantees that every notifier exists at the moment a +reexport tries to wire, but the exported getter's contract (return the +last propagated value; default `undefined`) is unchanged. +Closing the TDZ gap requires a separate change: the exported getter +for a reexport must consult the upstream's TDZ state (or the +upstream's own-binding getter directly, which already throws on TDZ) +rather than caching the propagated value. + +That is a strictly separable change from this redesign. +The redesign is about the notifier-graph topology; closing the TDZ gap +is about the exported-getter contract. +Either change can land independently. +The redesign makes a TDZ-aware getter easier to write (every reexport +already has a direct reference to its upstream's notifier object, +which could carry a TDZ predicate alongside its `notify`), but does +not require it and does not block it. + +## Notifier-primitive sharing with `makeVirtualModuleInstance` + +The second half of naugtur's comment asks whether the redesign could +share a notifier primitive between `makeModuleInstance` (used for +precompiled and natively-imported ESM) and `makeVirtualModuleInstance` +(used for the `{ exports, execute }` virtual-source shape). +The two implementations have notifier shapes that today diverge: + +- `makeModuleInstance` notifiers accept an `update(newValue)` callback + and store it in an `updaters` array, then invoke each updater on + each binding change. + The update is push-style; the upstream's binding state pushes to + subscribers. +- `makeVirtualModuleInstance` notifiers do the same shape (the + `notifiers[name]` closure pushes through the `updaters` array), so + the surface contract is already the same: a notifier is + `(update) => void` where `update` is registered for future binding + changes and invoked immediately with the current value when + available. + +A shared primitive would name this contract and remove the duplication +between the two construction bodies. +The primitive looks like: + +```js +const makeBindingNotifier = (initialValue) => { + const updaters = []; + let value = initialValue; + const get = () => value; + const notify = updater => { + arrayPush(updaters, updater); + updater(value); + }; + const update = newValue => { + value = newValue; + for (const u of updaters) u(newValue); + }; + return { get, notify, update }; +}; +``` + +A reexport stub would be a different primitive that holds a reference +to a target `notify` (initially undefined) and queues subscribers +until the target is set: + +```js +const makeForwarderNotifier = () => { + const pending = []; + let target; + const notify = updater => { + if (target !== undefined) { + target(updater); + return; + } + arrayPush(pending, updater); + }; + const resolve = targetNotify => { + target = targetNotify; + for (const p of pending) targetNotify(p); + pending.length = 0; + }; + return { notify, resolve }; +}; +``` + +The second is `makeNotifierWithResolver` already in `packages/ses/src/ +notifier-with-resolver.js`. +The first is a generalization of the ad-hoc inline shape inside both +`makeModuleInstance` and `makeVirtualModuleInstance`. +With both primitives extracted, both module-instance implementations +become smaller and the redesign's pass 1 walks a uniform notifier +graph. + +## Recommendation + +Land the redesign as a follow-up PR, not in this one. +The current PR's scope is the regression fix and its parity tests. +The redesign touches two large surfaces (`makeModuleInstance` and +`makeVirtualModuleInstance`), shifts the instantiate/link line, and +benefits from being reviewed against the parity baseline (the matrix +in [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) plus +the compartment-mapper companion tests) that the current PR +establishes. + +Concretely, the follow-up steps are: + +- Extract `makeBindingNotifier` and confirm + `makeNotifierWithResolver` covers the forwarder primitive (the + present implementation is the right shape; only the consumer site + changes). +- Refactor `makeModuleInstance` to construct reexport stubs in the + construction body, recording link metadata and registering each + stub in `notifiers`. +- Add a pass-2 step to `instantiate` that resolves the stubs of the + just-constructed instance against the upstream instances' own + notifiers, removing `wireUpExportNotifier`'s deferred-resolver + branch. + The `imports(updateRecord)` closure shrinks to updater registration + only. +- Mirror the changes in `makeVirtualModuleInstance` using the same + primitives. +- Re-run the [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) + matrix and the compartment-mapper cycle suites. + The 6 plus 1 `.failing` cells stay `.failing` (the redesign does + not close the TDZ gap), but the four converging cells stay + converging and the original regression test stays green. +- Separately, consider a TDZ-aware exported-getter pass that + consults the upstream's own-binding getter instead of caching the + propagated value, which would close the SES-against-Node + divergence the matrix pins. + +The recommendation to defer rests on the fact that the redesign is a +scope-widening refactor with no behavior change visible to consumers +(it does not close the TDZ gap by itself, and the regression fix is +already in place), so landing it as a separate PR preserves the +present PR's reviewable surface and lets the redesign's diff be +measured against the post-fix baseline. From d837202996c7af3ce37ae73aafeff99c5a644045 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Wed, 10 Jun 2026 23:40:20 +0000 Subject: [PATCH 11/24] test(ses): named-reexport variant of cyclic-export failure mode (issue #59 follow-up) Adds one test to packages/ses/test/import-gauntlet.test.js that mirrors the renamer-first plus const star-reexport cell of the prior fixer's TDZ matrix but with a named reexport (export { y } from './export-renamer.js') instead of a star reexport (export * from './export-renamer.js'). The cycle has the same shape as the star case (the named reexporter and the export renamer reference each other), and the observation of r.y through the namespace import lands during the same linked-but-not-yet-bound window when main.js imports the renamer first. Node.js raises ReferenceError for const y = 42 in this case, matching the star-reexport behavior, because the temporal dead zone semantics live with the binding, not with the reexport form. SES's current module-instance machinery returns undefined instead, the same gap the star-reexport test.failing cells pin. The new test is marked test.failing per the prior fixer's discipline: it asserts the Node.js reference behavior (ReferenceError) as the desired outcome and surfaces the SES divergence as a known gap to address separately. The named-reexport variant confirms the gap is not specific to export *, which the maintainer asked to verify in issue-comment 4675471286. --- packages/ses/test/import-gauntlet.test.js | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 347924a6f5..45b89a7bee 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -611,6 +611,61 @@ test('cyclic star export with renaming reexport, star reexporter imported first, t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); }); +// Companion to the renamer-first + const star-export case above with the +// star reexport replaced by a named reexport: the upstream module +// `named-reexporter.js` writes `export { y } from './export-renamer.js'` +// instead of `export * from './export-renamer.js'`. The cycle has the same +// shape (the named reexporter and the export renamer reference each +// other), and the observation of `r.y` through the namespace import lands +// during the same linked-but-not-yet-bound window when main.js imports the +// renamer first. Node.js raises ReferenceError for `const y = 42` here, +// matching the star-reexport case, because the temporal dead zone +// semantics live with the binding, not with the reexport form. SES's +// current module-instance machinery returns `undefined` instead, the same +// gap the star-reexport `test.failing` cells pin. The named-reexport +// variant confirms the gap is not specific to `export *` (kriskowal +// follow-up on issue-comment 4675471286). +test.failing( + 'cyclic named reexport with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', + async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/named-reexporter.js': ` + import * as r from './export-renamer.js'; + export { y } from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './named-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './named-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); + }, +); + test('export-as with duplicated export name', async t => { t.plan(4); From 88667a6578e453dd00da60e6da35326898c7460c Mon Sep 17 00:00:00 2001 From: endolinbot Date: Thu, 11 Jun 2026 02:07:00 +0000 Subject: [PATCH 12/24] fix(ses): enforce TDZ for cross-module namespace reads during cycle (star reexport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the SES-against-Node cross-module TDZ divergence pinned by the renamer-first × const and renamer-first × let cells of the cyclic star-reexport matrix in packages/ses/test/import-gauntlet.test.js. Diagnosis. The cross-module read `r.y` through a namespace import (the `'*'` notifier) propagated the raw `exportsTarget` object, not the `exportsProxy`. `exportsTarget` had no property defined for the binding until the late `defineProperty` pass at the end of `imports()`; a missing property reads as `undefined` rather than throwing. Even after the property landed, the `wireUpExportNotifier` helper installed an exported getter that returned the last propagated value (initially `undefined`) with no TDZ tracking. Fix, three targeted changes that do not require the full construction-time-notifiers redesign documented at `packages/ses/designs/construction-time-notifiers.md`: - `packages/ses/src/module-instance.js`: define `exportsTarget[name]` eagerly during construction for each own fixed and live export, using the existing TDZ-aware getter from `localGetNotify`. The late `arrayForEach(arraySort, defineProperty)` pass at the end of `imports()` redefines the same descriptor as a no-op, preserving the ECMA-262 sorted enumeration order without changing the eager TDZ-aware behavior. `wireUpExportNotifier` tracks its own TDZ state so the downstream's exported getter throws `ReferenceError` until the upstream binding propagates a value through the notifier chain, and defines the property on `exportsTarget` eagerly too. - `packages/module-source/src/transform-analyze.js`: reorder the preamble so hoisted declarations (function declarations and `var` initializers) run before the imports call. This matches the ECMA-262 model: function/var bindings are created and initialized to undefined during `InitializeEnvironment`, which precedes dependency evaluation in `Module.Evaluate`. Without this reorder, a hoisted `var y` in an upstream module would still be in the live-binding TDZ when a downstream's body read `r.y`, and the eager TDZ-aware getter would throw `ReferenceError` instead of returning `undefined` (the var cells of the matrix). - `packages/module-source/test/fixtures/format-preserved.txt`: update the format-preserved expected output to reflect the new hoisted-before-imports preamble order. The star-reexport `.failing` cells (renamer-first × const, renamer-first × let) now pass without the `.failing` modifier. The four converging cells of the matrix continue to pass unchanged. The renamer-first × var cell continues to read `undefined` because the upstream's hoisting preamble clears its TDZ before the downstream observes; this matches Node.js. The design document updates explain how the fix relates to the prior construction-time-notifiers redesign analysis (the redesign remains orthogonal to the TDZ fix and is still appropriate as a follow-up PR). Refs endojs/endo#59. --- packages/module-source/src/functor.js | 26 ++++-- .../test/fixtures/format-preserved.txt | 2 +- .../designs/construction-time-notifiers.md | 84 +++++++++++------ packages/ses/src/module-instance.js | 44 ++++++++- packages/ses/test/import-gauntlet.test.js | 90 +++++++++---------- 5 files changed, 157 insertions(+), 89 deletions(-) diff --git a/packages/module-source/src/functor.js b/packages/module-source/src/functor.js index e1b1ee523d..8f8535effa 100644 --- a/packages/module-source/src/functor.js +++ b/packages/module-source/src/functor.js @@ -45,15 +45,14 @@ export const buildFunctorSource = (scriptSource, sourceOptions, sourceUrl) => { preamble = `let ${preamble};`; } - preamble += `${h.HIDDEN_IMPORTS}([${keys(isrc) - .map( - src => - `[${js(src)}, [${Object.entries(isrc[src]) - .map(([exp, upds]) => `[${js(exp)},[${upds.join(',')}]]`) - .join(',')}]]`, - ) - .join(',')}]);`; - + // Hoisted declarations (function declarations and `var` initializers) + // must run before the imports call so that, when imports() walks the + // module graph and synchronously executes upstream modules during a + // cycle, those upstreams already observe this module's hoisted bindings + // as initialized rather than in the temporal dead zone. ECMA-262 model: + // function/var bindings are created and initialized during + // InitializeEnvironment, which precedes dependency evaluation in + // Module.Evaluate. preamble += sourceOptions.hoistedDecls .map(([vname, isOnce, cvname]) => { let src = ''; @@ -66,6 +65,15 @@ export const buildFunctorSource = (scriptSource, sourceOptions, sourceUrl) => { }) .join(''); + preamble += `${h.HIDDEN_IMPORTS}([${keys(isrc) + .map( + src => + `[${js(src)}, [${Object.entries(isrc[src]) + .map(([exp, upds]) => `[${js(exp)},[${upds.join(',')}]]`) + .join(',')}]]`, + ) + .join(',')}]);`; + // The outer function destructures the module calling convention's internal // variables into hidden lexical variables. // The inner function binds `this` to `undefined` and overshadows the diff --git a/packages/module-source/test/fixtures/format-preserved.txt b/packages/module-source/test/fixtures/format-preserved.txt index bf11c64e26..ec9a44fd55 100644 --- a/packages/module-source/test/fixtures/format-preserved.txt +++ b/packages/module-source/test/fixtures/format-preserved.txt @@ -1,4 +1,4 @@ -({imports:$h͏_imports,liveVar:$h͏_live,onceVar:$h͏_once,import:$h͏_import,importMeta:$h͏____meta})=>(function(){'use strict';$h͏_imports([]);Object.defineProperty(createBinop,'name',{value:"createBinop"});$h͏_once.createBinop(createBinop);// deliberately offset +({imports:$h͏_imports,liveVar:$h͏_live,onceVar:$h͏_once,import:$h͏_import,importMeta:$h͏____meta})=>(function(){'use strict';Object.defineProperty(createBinop,'name',{value:"createBinop"});$h͏_once.createBinop(createBinop);$h͏_imports([]);// deliberately offset function TokenType() {} const beforeExpr = 0; diff --git a/packages/ses/designs/construction-time-notifiers.md b/packages/ses/designs/construction-time-notifiers.md index 9c49b7ab7e..00e3198a0c 100644 --- a/packages/ses/designs/construction-time-notifiers.md +++ b/packages/ses/designs/construction-time-notifiers.md @@ -268,38 +268,64 @@ referenced lazily today. ## Will the redesign close the TDZ gap? -The companion observation from this PR is that SES does not enforce -ECMA-262 temporal dead zone semantics for cross-module reads through a -namespace import during a cycle. -The renamer-first plus const, the renamer-first plus let, and now the -named-reexport plus renamer-first plus const cases all return -`undefined` rather than raising `ReferenceError` (the 6 plus 1 cells in +The companion observation from this PR is that SES previously did not +enforce ECMA-262 temporal dead zone semantics for cross-module reads +through a namespace import during a cycle. +The renamer-first plus const, the renamer-first plus let, and the +named-reexport plus renamer-first plus const cases all returned +`undefined` rather than raising `ReferenceError` (3 of the 7 cells in the [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) matrix). -The reason is that the cross-module read goes through the renamer's -**exported getter**, not the renamer's local-binding **own getter**. -The local-binding getter is the only place TDZ is enforced; the -exported getter installed by `wireUpExportNotifier` simply returns the -last value the upstream notifier propagated, which starts as -`undefined`. - -The redesign would **not** by itself close this gap. -The redesign guarantees that every notifier exists at the moment a -reexport tries to wire, but the exported getter's contract (return the -last propagated value; default `undefined`) is unchanged. -Closing the TDZ gap requires a separate change: the exported getter -for a reexport must consult the upstream's TDZ state (or the -upstream's own-binding getter directly, which already throws on TDZ) -rather than caching the propagated value. - -That is a strictly separable change from this redesign. -The redesign is about the notifier-graph topology; closing the TDZ gap -is about the exported-getter contract. + +The gap actually had two causes: + +- The cross-module read through the namespace import (`*` notifier) + propagated the **raw `exportsTarget`** object rather than the + `exportsProxy`. + `exportsTarget` had **no property defined for the binding** until the + late `defineProperty` pass at the end of `imports()`. + A missing property reads as `undefined` rather than throwing. +- Where a property was defined, the `wireUpExportNotifier` helper + installed an exported getter that returned the last propagated value + (initially `undefined`) with no TDZ tracking. + Even after the property landed it would mask the upstream's TDZ + state. + +The gap is closed by two targeted fixes that do not require the +construction-time-notifiers redesign: + +- `module-instance.js` defines the `exportsTarget` property for each own + fixed and live export **at construction time** using the TDZ-aware + getter from `localGetNotify`. + The late `arrayForEach(arraySort, defineProperty)` pass at the end of + `imports()` redefines the same descriptor as a no-op, preserving the + ECMA-262 sorted enumeration order without changing the eager + TDZ-aware behavior. +- `wireUpExportNotifier` (which handles both star reexports and + `__reexportMap__`-driven named reexports) tracks its own TDZ state. + The downstream's exported getter throws `ReferenceError` until the + upstream binding propagates a value through the notifier chain. + The helper also defines the property on `exportsTarget` eagerly. +- `module-source/src/transform-analyze.js` reorders the preamble so + hoisted declarations (function declarations and `var` initializers) + run **before** the imports call. + This matches the ECMA-262 model: function/var bindings are created + and initialized to undefined during `InitializeEnvironment`, which + precedes dependency evaluation in `Module.Evaluate`. + Without this reorder, a hoisted `var y` in an upstream module would + still be in the live-binding TDZ when a downstream's body read + `r.y`, and the eager TDZ-aware getter would throw `ReferenceError` + instead of returning `undefined`. + +The construction-time-notifiers redesign is orthogonal to the TDZ fix. +The redesign is about the notifier-graph topology; the TDZ fix is +about when and how the namespace's exported getter consults the +upstream's binding state. Either change can land independently. -The redesign makes a TDZ-aware getter easier to write (every reexport -already has a direct reference to its upstream's notifier object, -which could carry a TDZ predicate alongside its `notify`), but does -not require it and does not block it. +The redesign makes a future TDZ-aware getter easier to compose (every +reexport already has a direct reference to its upstream's notifier +object, which could carry a TDZ predicate alongside its `notify`), +but the present TDZ fix did not require it. ## Notifier-primitive sharing with `makeVirtualModuleInstance` diff --git a/packages/ses/src/module-instance.js b/packages/ses/src/module-instance.js index 04e954926d..f9455f63e4 100644 --- a/packages/ses/src/module-instance.js +++ b/packages/ses/src/module-instance.js @@ -258,6 +258,17 @@ export const makeModuleInstance = ( configurable: false, }; + // Define on exportsTarget eagerly so cross-module reads through the + // namespace import (`'*'` notifier) see the TDZ-aware getter even before + // imports() completes its sort-and-define pass. The late pass below + // redefines with the identical descriptor as a no-op, preserving the + // ECMA-262 sorted enumeration order. + defineProperty( + exportsTarget, + fixedExportName, + exportsProps[fixedExportName], + ); + notifiers[fixedExportName] = fixedGetNotify.notify; }); @@ -346,6 +357,16 @@ export const makeModuleInstance = ( configurable: false, }; + // Define on exportsTarget eagerly so cross-module reads through the + // namespace import see the TDZ-aware getter before imports() completes + // its sort-and-define pass. The late pass below redefines with the + // identical descriptor as a no-op, preserving sorted enumeration order. + defineProperty( + exportsTarget, + liveExportName, + exportsProps[liveExportName], + ); + notifiers[liveExportName] = liveGetNotify.notify; }, ); @@ -390,18 +411,37 @@ export const makeModuleInstance = ( } notifiers[exportName] = notify; - // exported live binding state + // Re-exported live binding state. The exported getter throws + // ReferenceError until the upstream binding propagates a value through + // `notify`, mirroring the cross-module TDZ semantics of ECMA-262: + // reading a re-export of an upstream `const` or `let` binding during a + // cycle's linked-but-not-yet-evaluated window raises a ReferenceError + // rather than silently returning the cached `undefined`. An upstream + // `var` binding clears its TDZ as part of its hoisting preamble (see + // `transform-analyze.js`), so for `var` the updater fires before the + // downstream's getter is observed and the getter returns `undefined`. let value; - const update = newValue => (value = newValue); + let tdz = true; + const update = newValue => { + value = newValue; + tdz = false; + }; notify(update); exportsProps[exportName] = { get() { + if (tdz) { + throw ReferenceError(`binding ${q(exportName)} not yet initialized`); + } return value; }, set: undefined, enumerable: true, configurable: false, }; + // Define on exportsTarget eagerly so cross-module namespace reads see + // the TDZ-aware getter before imports() completes; the late pass below + // redefines with the identical descriptor as a no-op. + defineProperty(exportsTarget, exportName, exportsProps[exportName]); }; // Per the calling convention for the moduleFunctor generated from diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 45b89a7bee..45d0d6484f 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -367,23 +367,21 @@ test('cyclic star export with renaming reexport, unused live binding', async t = // they are recorded as the expected non-observation that completes the // matrix. // -// Two cells diverge from Node.js: SES's current module-instance machinery -// does not enforce the temporal dead zone for cross-module reads through a -// namespace import during a cycle, so `r.y` reads `undefined` rather than -// raising ReferenceError for `const` and `let` in the renamer-first orderings. -// Those two cells are marked `test.failing` so the suite pins the -// ECMA-262-conformant Node.js reference behavior as the desired outcome and -// surfaces the SES divergence as a known gap to either accept or close -// separately. The four converging cells assert SES's current behavior -// directly because it already matches Node.js for those cells. - -test.failing( - 'cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', - async t => { - t.plan(1); +// All six cells now match Node.js after the fix that lands the eager +// exportsTarget property definitions in `module-instance.js` and reorders +// the hoisted declarations to run before the imports call in +// `module-source/src/transform-analyze.js`. The renamer-first plus `const` +// and renamer-first plus `let` cells raise `ReferenceError` (the +// fixed-binding and live-binding TDZ-aware getters now run against the +// cross-module namespace access path), while renamer-first plus `var` +// continues to read `undefined` because the hoisting preamble clears the +// upstream's TDZ before the downstream observes. + +test('cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); - const makeImportHook = makeNodeImporter({ - 'https://example.com/star-reexporter.js': ` + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` import * as r from './export-renamer.js'; export * from './export-renamer.js'; export const probe = (() => { @@ -394,37 +392,34 @@ test.failing( } })(); `, - 'https://example.com/export-renamer.js': ` + 'https://example.com/export-renamer.js': ` export { y as x } from './star-reexporter.js'; export const y = 42; `, - 'https://example.com/main.js': ` + 'https://example.com/main.js': ` import * as r from './export-renamer.js'; import * as s from './star-reexporter.js'; export const probe = s.probe; `, - }); + }); - const compartment = new Compartment({ - resolveHook: resolveNode, - importHook: makeImportHook('https://example.com'), - __noNamespaceBox__: true, - __options__: true, - }); + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); - const namespace = await compartment.import('./main.js'); + const namespace = await compartment.import('./main.js'); - t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); - }, -); + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); -test.failing( - 'cyclic star export with renaming reexport, renamer imported first, let binding observes ReferenceError during temporal dead zone', - async t => { - t.plan(1); +test('cyclic star export with renaming reexport, renamer imported first, let binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); - const makeImportHook = makeNodeImporter({ - 'https://example.com/star-reexporter.js': ` + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` import * as r from './export-renamer.js'; export * from './export-renamer.js'; export const probe = (() => { @@ -435,29 +430,28 @@ test.failing( } })(); `, - 'https://example.com/export-renamer.js': ` + 'https://example.com/export-renamer.js': ` export { y as x } from './star-reexporter.js'; export let y = 42; `, - 'https://example.com/main.js': ` + 'https://example.com/main.js': ` import * as r from './export-renamer.js'; import * as s from './star-reexporter.js'; export const probe = s.probe; `, - }); + }); - const compartment = new Compartment({ - resolveHook: resolveNode, - importHook: makeImportHook('https://example.com'), - __noNamespaceBox__: true, - __options__: true, - }); + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); - const namespace = await compartment.import('./main.js'); + const namespace = await compartment.import('./main.js'); - t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); - }, -); + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); test('cyclic star export with renaming reexport, renamer imported first, var binding observes undefined while hoisted but unassigned', async t => { t.plan(1); From c5d3d56116c62085e5ea5ee9166349396254940b Mon Sep 17 00:00:00 2001 From: endolinbot Date: Thu, 11 Jun 2026 02:08:33 +0000 Subject: [PATCH 13/24] fix(ses): enforce TDZ for cross-module named-reexport reads during cycle The cyclic-named-reexport TDZ matrix cell (`export { y } from './export-renamer.js'` reading `r.y` from the renamer's namespace during the linked-but-not-yet-bound window) is now ECMA-262-compliant and reads ReferenceError, matching Node.js. The preceding commit's fix to `wireUpExportNotifier` in `packages/ses/src/module-instance.js` already covered both star reexports and named reexports because both wire through the same helper from `imports()` (the named-reexport path at line ~462 calls `wireUpExportNotifier(exportedName, importNotifiers[localName], specifier, localName)` with the same TDZ-tracking semantics as the star-reexport candidate-all walk at line ~473). This commit therefore ships the test-only change that converts the named-reexport `.failing` cell to a passing assertion, confirming the gap is closed in the named-reexport shape too. Refs endojs/endo#59. --- packages/ses/test/import-gauntlet.test.js | 48 +++++++++++------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 45d0d6484f..083addcfa8 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -614,18 +614,17 @@ test('cyclic star export with renaming reexport, star reexporter imported first, // during the same linked-but-not-yet-bound window when main.js imports the // renamer first. Node.js raises ReferenceError for `const y = 42` here, // matching the star-reexport case, because the temporal dead zone -// semantics live with the binding, not with the reexport form. SES's -// current module-instance machinery returns `undefined` instead, the same -// gap the star-reexport `test.failing` cells pin. The named-reexport -// variant confirms the gap is not specific to `export *` (kriskowal -// follow-up on issue-comment 4675471286). -test.failing( - 'cyclic named reexport with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', - async t => { - t.plan(1); - - const makeImportHook = makeNodeImporter({ - 'https://example.com/named-reexporter.js': ` +// semantics live with the binding, not with the reexport form. After the +// fix to `module-instance.js` and `transform-analyze.js`, SES enforces the +// same TDZ on the namespace path through `wireUpExportNotifier` whether +// the reexport is reached through `export *` or through `export { y } +// from`. This case confirms the gap is not specific to `export *` +// (kriskowal follow-up on issue-comment 4675471286). +test('cyclic named reexport with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/named-reexporter.js': ` import * as r from './export-renamer.js'; export { y } from './export-renamer.js'; export const probe = (() => { @@ -636,29 +635,28 @@ test.failing( } })(); `, - 'https://example.com/export-renamer.js': ` + 'https://example.com/export-renamer.js': ` export { y as x } from './named-reexporter.js'; export const y = 42; `, - 'https://example.com/main.js': ` + 'https://example.com/main.js': ` import * as r from './export-renamer.js'; import * as s from './named-reexporter.js'; export const probe = s.probe; `, - }); + }); - const compartment = new Compartment({ - resolveHook: resolveNode, - importHook: makeImportHook('https://example.com'), - __noNamespaceBox__: true, - __options__: true, - }); + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); - const namespace = await compartment.import('./main.js'); + const namespace = await compartment.import('./main.js'); - t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); - }, -); + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); test('export-as with duplicated export name', async t => { t.plan(4); From 9b4885a5711111848dda66a390ac688cabecf789 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Thu, 11 Jun 2026 06:40:18 +0000 Subject: [PATCH 14/24] docs(ses): archive construction-time-notifiers analysis to the garden journal The construction-time-notifiers analysis written for the issue #59 follow-up review described a possible redesign of the SES module-instance machinery (move reexport notifier creation from link phase into construction phase) but recommended deferring implementation to a separate PR. Per kriskowal's CHANGES_REQUESTED review on this PR, the analysis belongs in the garden's journal rather than the package tree: the artifact records how the exploration informed the in-PR TDZ fix without persisting a design document that the PR does not implement. The verbatim text now lives at journal/projects/endo/drafts/construction-time-notifiers.md in the garden's journal branch and is indexed from drafts/README.md there. --- .../designs/construction-time-notifiers.md | 446 ------------------ 1 file changed, 446 deletions(-) delete mode 100644 packages/ses/designs/construction-time-notifiers.md diff --git a/packages/ses/designs/construction-time-notifiers.md b/packages/ses/designs/construction-time-notifiers.md deleted file mode 100644 index 00e3198a0c..0000000000 --- a/packages/ses/designs/construction-time-notifiers.md +++ /dev/null @@ -1,446 +0,0 @@ -# Construction-time notifiers for SES module instances - -## Context - -This document responds to two threads of review feedback on the cyclic -star-export fix for the endojs/endo cyclic-star-export issue. - -The first is naugtur's inline-comment ask on the upstream review (the -inline at `packages/ses/src/module-instance.js:367`): - -> I know this is vague, but it'd feel nicer design-wise to create all -> notifiers ahead of time and give them an ability to forward to or pull -> from another notifier, in which case the reexport \* would be a matter of -> connecting notifiers in a loop. -> -> If we could use this as an opportunity to design a fancier notifier -> primitive to share between the two implementations `makeModuleInstance` -> and `makeVirtualModuleInstance` we'd avoid some of the risk of interop -> failure. - -The second is kriskowal's follow-up to that comment, with a concrete -proposal: - -> Please consider whether we can create all of the notifiers for a -> module instance at time of construction instead of time of link. -> This may require a more deliberate separation of the instantiation -> and link phases, but I suspect we already have a hard enough line. -> This would obviate deferred notifier linkage, since notifiers would -> always be available up front. -> If not, please explain why such an arrangement is not possible or -> how the calling convention for precompiled ModuleSource instances -> would need to change. -> It might be that the notifiers need to be partially applied before -> full initialization. - -This document analyzes the redesign, identifies what it would and would -not buy, and explains why the present recommendation is to **document -the plan and defer implementation** rather than land the refactor in the -current PR. - -## The current phases - -The relevant code lives in `packages/ses/src/module-{link,instance}.js`. -The lifecycle of a module passes through four phases: - -1. **Load** (`module-load.js`): walk the module graph asynchronously and - acquire the module source for every node. - Produces a `moduleRecord` per module with `{ compartment, - moduleSource, moduleSpecifier, resolvedImports, importMeta }`. - The record carries the full `moduleSource`, including - `__fixedExportMap__`, `__liveExportMap__`, `__reexportMap__`, and - the `reexports` (star-export specifiers) array. -2. **Instantiate** (`module-link.js`'s `instantiate`): for one module, - call `makeModuleInstance` (or `makeVirtualModuleInstance`), memoize - the result, and then recursively `link` each dependency. -3. **Construct** (the body of `makeModuleInstance`): build the local - binding state for the module's own exports (fixed plus live), - populate `notifiers` for those exports, and define a closure named - `imports` that the moduleFunctor will invoke during execute. - The construct phase runs synchronously inside `instantiate` and - returns the instance object `{ notifiers, exportsProxy, execute }`. -4. **Link** (the body of the `imports(updateRecord)` closure, invoked - from inside `execute`): walk the `updateRecord` (the imports the - functor declared), look up each upstream module instance, register - each updater on the upstream's notifier, and wire reexport notifiers - via `wireUpExportNotifier` for star-reexported names - (`reexports`/`exportAlls`) and renamed reexports (`__reexportMap__`). - -Today, **own** notifiers (for `__fixedExportMap__` and -`__liveExportMap__` entries, plus the `'*'` notifier) are created in -phase 3, the construct phase. -**Reexport** notifiers (for `__reexportMap__` entries and for star -reexports) are created in phase 4, the link phase, inside `imports`. - -The endo cyclic-star-export fix added `makeNotifierWithResolver` to make -a single reexport notifier tolerate the case where the upstream's -notifier was not yet present when the reexport was wired. -The deferred resolver settles on the first call where -`upstreamInstance.notifiers[deferredImportName]` is defined and -forwards queued and subsequent updaters through to it. - -## What the maintainer's proposal would change - -The proposal moves **all** notifier creation, including reexport -notifiers, into phase 3 (construction). -After the move, the `notifiers` map for every module instance is -complete by the time `instantiate` returns. -The link phase becomes pure wiring: it would still walk the -`updateRecord` and register updaters on upstream notifiers, but every -notifier it touches already exists. - -This is the maintainer's "obviate deferred notifier linkage" outcome. -`makeNotifierWithResolver` would lose its present sole caller and could -be deleted (or kept as the general primitive naugtur describes, used -internally by every notifier whose value is forwarded from another -notifier). - -The maintainer flagged a concrete risk: "notifiers may need to be -partially applied before full initialization." -This is the calling-convention question for precompiled `ModuleSource` -instances and is examined below. - -## What is actually available at construction time - -A module's `moduleSource` carries, at the moment `makeModuleInstance` is -called, the following enumeration of every export name and its origin: - -- `moduleSource.__fixedExportMap__`: own fixed bindings (`export const`, - `export function`, `export class`). - Local binding state is known. -- `moduleSource.__liveExportMap__`: own live bindings (`export let`, - `export var`, plus the reexport-as-live shape `export { y as x } from - './u.js'`). - Local binding state is known. -- `moduleSource.__reexportMap__`: renamed reexports from specific - specifiers. - The map's keys are upstream specifiers and the values are - `[[localName, exportedName], ...]` pairs. - The exported names are known. -- `moduleSource.reexports` (the `exportAlls` array): star reexports. - The list of upstream specifiers is known. - -The `resolvedImports` map on the `moduleRecord` is also available; for -every upstream specifier the resolved key is known. -What is **not** available at construction time: - -- The upstream module instance's own `notifiers` object. - The upstream has not yet been instantiated when the current module is - constructed; in cycles, by definition the upstream's construction is - in progress on the same call stack. -- For star reexports, the **set of names** the upstream re-exports. - Star reexport (`export *`) is a name-set inheritance whose membership - depends on the upstream's own exports plus its own transitive star - reexports. - Resolving the set requires walking the upstream's - `moduleSource.exports` (a flat list of all export names the upstream - declares, sorted and known at construction time per - `module-source.js`). - -The first gap is the cycle structural property that `instantiate` was -designed to break by memoizing the instance after `makeModuleInstance` -returns and before recursing into dependencies. -The second gap is a graph traversal that, today, is performed implicitly -by the link phase's `entries(importNotifiers)` walk and the -`candidateAll` map. - -## A redesign sketch - -Move construction into a two-pass shape inside `instantiate`: - -1. **Pass 1 (construct and name-claim)**: For every module in the - closure of `resolvedImports`, call `makeModuleInstance` to allocate - the binding state for own exports and create a stub for every - reexport notifier the module declares (the union of - `__reexportMap__`'s `exportedName`s and the star-reexport names - enumerated by walking each upstream's `moduleSource.exports`). - The stub is a forwarder built from a primitive of naugtur's shape: - a notifier that holds a reference (initially undefined) to a target - notifier and queues subscribers and updates until the target lands. - Each stub records its `(upstreamSpecifier, upstreamName)` link - target metadata. - The instance is memoized; recursion proceeds. -2. **Pass 2 (wire)**: For every module, walk the recorded link targets - and resolve each stub against - `upstreamInstance.notifiers[upstreamName]`. - By this point every instance has been constructed and every own - notifier exists. - Every stub finds a real notifier; deferred queueing is unused. - The pass folds into the present `imports(updateRecord)` body. - -Star reexports become an enumeration step in pass 1: walk -`upstreamModuleSource.exports` and create a forwarder stub for each -non-`default` name not already claimed by an own export or a renamed -reexport. -Ambiguity (a name reachable via two different star reexports) is -detected during pass 1 by tracking which star reexport claimed each -name, with the second claim demoting the stub to a no-op and surfacing -a syntax error on access; this mirrors the present `candidateAll[name] -= false` discipline. - -The `imports(updateRecord)` closure is retained but trimmed: its only -remaining work is registering the moduleFunctor's `updaters` on the -upstream notifiers it already located in pass 1. -The `wireUpExportNotifier` helper is no longer reached with `notify === -undefined` and its `makeNotifierWithResolver` branch can be removed. - -## Hard-enough line between instantiate and link? - -The maintainer asked whether the present code already has a hard line -between instantiate and link. -"I suspect we already have a hard enough line." -The answer in the present code is "almost, but not quite." - -`makeModuleInstance` returns an instance that exposes `notifiers`, -`exportsProxy`, and `execute`. -The first two are populated by the construction body; the third is the -moduleFunctor wrapper, which, when invoked, runs the functor which -calls `imports(updateRecord)` to wire itself to its upstreams. -Today **the wiring is a side effect of execute**, not of instantiate. -That is the line the redesign needs to shift: pass-1 (wire stubs) -belongs to instantiate; pass-2 (register moduleFunctor updaters on -upstream notifiers) can remain in execute. - -The line is concrete enough that the move is mechanical. -The risk is not architectural; it is two surfaces touched at once -(`makeModuleInstance` and `makeVirtualModuleInstance`) and a -calling-convention question for precompiled module sources. - -## Precompiled ModuleSource calling convention - -The maintainer's "calling convention for precompiled ModuleSource -instances" caveat refers to the contract between the SES linker and the -moduleFunctor that a precompiled `__syncModuleProgram__` (or -`__syncModuleFunctor__`) embodies. -The functor's signature today is: - -```js -functor({ imports, onceVar, liveVar, import, importMeta }); -``` - -where `imports(updateRecord)` is the link-time hook that registers the -functor's import updaters on the upstream notifiers and processes star -reexports. -The functor calls `imports(updateRecord)` once, early in execution, -before any code that reads an import binding runs. - -Two facets of the calling convention come under scrutiny in the -redesign: - -1. **`imports` becomes wire-only.** - With reexport notifiers already created in pass 1, the - `imports(updateRecord)` closure no longer needs `__reexportMap__`, - `exportAlls`, or `wireUpExportNotifier` logic; it only needs to - register the functor's own updaters on upstream notifiers it can - look up directly. - The functor's call shape is unchanged; the internal body of - `imports` shrinks. - No change to precompiled output is required. -2. **The "partially applied notifiers" question.** - A precompiled module source whose moduleFunctor has not yet been - instantiated (because the functor is created lazily in `execute`) - still needs its reexport notifiers wired in pass 1, before the - functor exists. - This is fine for the own-export side because pass 1 does not need - the functor; the binding state for own exports is allocated as - inert state and the functor sets the initial value when it runs. - But pass 1 needs to know the **set** of own exports and their - shape (fixed against live), which is what `__fixedExportMap__` and - `__liveExportMap__` already provide. - So pass 1's information needs are met by the existing - precompiled-source contract; no change to the precompiled-source - schema is required. - -There is one edge case where the maintainer's "partial application" -phrasing applies. -The renamer's local binding state (the `update` freezer in the present -`liveExportMap` walk, named with the "reexporting creates a tree of -bindings" comment) needs to participate in the chain of stub-resolution -in pass 1. -The present code already does this by storing the same `notify` in both -`localGetNotify` and `notifiers`, and by having the live binding's -`notify` accept a register-and-defer subscription whether or not the -binding has left TDZ. -The redesign preserves that property; the reexport stub's target -becomes the upstream's own-binding notifier, and the upstream's -own-binding notifier is the same object whether stubbed at pass 1 or -referenced lazily today. - -## Will the redesign close the TDZ gap? - -The companion observation from this PR is that SES previously did not -enforce ECMA-262 temporal dead zone semantics for cross-module reads -through a namespace import during a cycle. -The renamer-first plus const, the renamer-first plus let, and the -named-reexport plus renamer-first plus const cases all returned -`undefined` rather than raising `ReferenceError` (3 of the 7 cells in -the [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) -matrix). - -The gap actually had two causes: - -- The cross-module read through the namespace import (`*` notifier) - propagated the **raw `exportsTarget`** object rather than the - `exportsProxy`. - `exportsTarget` had **no property defined for the binding** until the - late `defineProperty` pass at the end of `imports()`. - A missing property reads as `undefined` rather than throwing. -- Where a property was defined, the `wireUpExportNotifier` helper - installed an exported getter that returned the last propagated value - (initially `undefined`) with no TDZ tracking. - Even after the property landed it would mask the upstream's TDZ - state. - -The gap is closed by two targeted fixes that do not require the -construction-time-notifiers redesign: - -- `module-instance.js` defines the `exportsTarget` property for each own - fixed and live export **at construction time** using the TDZ-aware - getter from `localGetNotify`. - The late `arrayForEach(arraySort, defineProperty)` pass at the end of - `imports()` redefines the same descriptor as a no-op, preserving the - ECMA-262 sorted enumeration order without changing the eager - TDZ-aware behavior. -- `wireUpExportNotifier` (which handles both star reexports and - `__reexportMap__`-driven named reexports) tracks its own TDZ state. - The downstream's exported getter throws `ReferenceError` until the - upstream binding propagates a value through the notifier chain. - The helper also defines the property on `exportsTarget` eagerly. -- `module-source/src/transform-analyze.js` reorders the preamble so - hoisted declarations (function declarations and `var` initializers) - run **before** the imports call. - This matches the ECMA-262 model: function/var bindings are created - and initialized to undefined during `InitializeEnvironment`, which - precedes dependency evaluation in `Module.Evaluate`. - Without this reorder, a hoisted `var y` in an upstream module would - still be in the live-binding TDZ when a downstream's body read - `r.y`, and the eager TDZ-aware getter would throw `ReferenceError` - instead of returning `undefined`. - -The construction-time-notifiers redesign is orthogonal to the TDZ fix. -The redesign is about the notifier-graph topology; the TDZ fix is -about when and how the namespace's exported getter consults the -upstream's binding state. -Either change can land independently. -The redesign makes a future TDZ-aware getter easier to compose (every -reexport already has a direct reference to its upstream's notifier -object, which could carry a TDZ predicate alongside its `notify`), -but the present TDZ fix did not require it. - -## Notifier-primitive sharing with `makeVirtualModuleInstance` - -The second half of naugtur's comment asks whether the redesign could -share a notifier primitive between `makeModuleInstance` (used for -precompiled and natively-imported ESM) and `makeVirtualModuleInstance` -(used for the `{ exports, execute }` virtual-source shape). -The two implementations have notifier shapes that today diverge: - -- `makeModuleInstance` notifiers accept an `update(newValue)` callback - and store it in an `updaters` array, then invoke each updater on - each binding change. - The update is push-style; the upstream's binding state pushes to - subscribers. -- `makeVirtualModuleInstance` notifiers do the same shape (the - `notifiers[name]` closure pushes through the `updaters` array), so - the surface contract is already the same: a notifier is - `(update) => void` where `update` is registered for future binding - changes and invoked immediately with the current value when - available. - -A shared primitive would name this contract and remove the duplication -between the two construction bodies. -The primitive looks like: - -```js -const makeBindingNotifier = (initialValue) => { - const updaters = []; - let value = initialValue; - const get = () => value; - const notify = updater => { - arrayPush(updaters, updater); - updater(value); - }; - const update = newValue => { - value = newValue; - for (const u of updaters) u(newValue); - }; - return { get, notify, update }; -}; -``` - -A reexport stub would be a different primitive that holds a reference -to a target `notify` (initially undefined) and queues subscribers -until the target is set: - -```js -const makeForwarderNotifier = () => { - const pending = []; - let target; - const notify = updater => { - if (target !== undefined) { - target(updater); - return; - } - arrayPush(pending, updater); - }; - const resolve = targetNotify => { - target = targetNotify; - for (const p of pending) targetNotify(p); - pending.length = 0; - }; - return { notify, resolve }; -}; -``` - -The second is `makeNotifierWithResolver` already in `packages/ses/src/ -notifier-with-resolver.js`. -The first is a generalization of the ad-hoc inline shape inside both -`makeModuleInstance` and `makeVirtualModuleInstance`. -With both primitives extracted, both module-instance implementations -become smaller and the redesign's pass 1 walks a uniform notifier -graph. - -## Recommendation - -Land the redesign as a follow-up PR, not in this one. -The current PR's scope is the regression fix and its parity tests. -The redesign touches two large surfaces (`makeModuleInstance` and -`makeVirtualModuleInstance`), shifts the instantiate/link line, and -benefits from being reviewed against the parity baseline (the matrix -in [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) plus -the compartment-mapper companion tests) that the current PR -establishes. - -Concretely, the follow-up steps are: - -- Extract `makeBindingNotifier` and confirm - `makeNotifierWithResolver` covers the forwarder primitive (the - present implementation is the right shape; only the consumer site - changes). -- Refactor `makeModuleInstance` to construct reexport stubs in the - construction body, recording link metadata and registering each - stub in `notifiers`. -- Add a pass-2 step to `instantiate` that resolves the stubs of the - just-constructed instance against the upstream instances' own - notifiers, removing `wireUpExportNotifier`'s deferred-resolver - branch. - The `imports(updateRecord)` closure shrinks to updater registration - only. -- Mirror the changes in `makeVirtualModuleInstance` using the same - primitives. -- Re-run the [`import-gauntlet.test.js`](../test/import-gauntlet.test.js) - matrix and the compartment-mapper cycle suites. - The 6 plus 1 `.failing` cells stay `.failing` (the redesign does - not close the TDZ gap), but the four converging cells stay - converging and the original regression test stays green. -- Separately, consider a TDZ-aware exported-getter pass that - consults the upstream's own-binding getter instead of caching the - propagated value, which would close the SES-against-Node - divergence the matrix pins. - -The recommendation to defer rests on the fact that the redesign is a -scope-widening refactor with no behavior change visible to consumers -(it does not close the TDZ gap by itself, and the regression fix is -already in place), so landing it as a separate PR preserves the -present PR's reviewable surface and lets the redesign's diff be -measured against the post-fix baseline. From c3f06bde616766c6d4c7976c4a88e80903519df2 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Thu, 11 Jun 2026 06:50:16 +0000 Subject: [PATCH 15/24] test(compartment-mapper): parity fixtures for the issue endojs/endo#59 TDZ matrix The seven new scenarios that the prior fixers added to packages/ses/test/import-gauntlet.test.js (a six-cell TDZ matrix of binding form against import order for cyclic star-export plus one named-reexport variant) now have matching compartment-mapper test fixtures, each with a Node.js parity sibling. The same fixture is loaded by both: by the compartment-mapper scaffold in the SES-side test, and by Node.js's native loader in the parity-side test. The expected probe value lives in _cycle-rename-tdz-assertions.js so the two layers compare against exactly one set of expectations. Parity is verified by construction: if both tests pass, SES enforces the same TDZ semantics on the cross-module namespace path that Node.js enforces natively. Fixtures: - fixtures-cycle-rename-tdz-const-renamer-first (probe: ReferenceError) - fixtures-cycle-rename-tdz-let-renamer-first (probe: ReferenceError) - fixtures-cycle-rename-tdz-var-renamer-first (probe: value undefined) - fixtures-cycle-rename-tdz-const-star-first (probe: value 42) - fixtures-cycle-rename-tdz-let-star-first (probe: value 42) - fixtures-cycle-rename-tdz-var-star-first (probe: value 42) - fixtures-cycle-named-reexport-tdz-const-renamer-first (probe: ReferenceError) The renamer-first const and let cells observe ReferenceError during the temporal-dead-zone window when the renamer is on the evaluation stack with y not yet initialized. The renamer-first var cell observes undefined because the hoisting preamble clears the upstream TDZ before the downstream observes. The star-first cells have no TDZ window to observe; depth-first cycle resolution evaluates the renamer's body to completion before the star-reexporter's body runs, so the probe captures the assigned value 42 for every binding form. The named-reexport variant confirms the TDZ gap is not specific to export *. The import-gauntlet.test.js header comment now links each matrix cell to its parity fixture path so future maintainers can find both layers from either side. --- .../test/_cycle-rename-tdz-assertions.js | 106 ++++++++++++++++++ ...dz-const-renamer-first-node-parity.test.js | 27 +++++ ...d-reexport-tdz-const-renamer-first.test.js | 51 +++++++++ ...dz-const-renamer-first-node-parity.test.js | 27 +++++ ...cle-rename-tdz-const-renamer-first.test.js | 44 ++++++++ ...e-tdz-const-star-first-node-parity.test.js | 27 +++++ .../cycle-rename-tdz-const-star-first.test.js | 46 ++++++++ ...-tdz-let-renamer-first-node-parity.test.js | 27 +++++ ...cycle-rename-tdz-let-renamer-first.test.js | 44 ++++++++ ...ame-tdz-let-star-first-node-parity.test.js | 27 +++++ .../cycle-rename-tdz-let-star-first.test.js | 46 ++++++++ ...-tdz-var-renamer-first-node-parity.test.js | 27 +++++ ...cycle-rename-tdz-var-renamer-first.test.js | 46 ++++++++ ...ame-tdz-var-star-first-node-parity.test.js | 27 +++++ .../cycle-rename-tdz-var-star-first.test.js | 46 ++++++++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/named-reexporter.js | 9 ++ .../node_modules/app/package.json | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ .../node_modules/app/export-renamer.js | 2 + .../node_modules/app/main.js | 4 + .../node_modules/app/package.json | 9 ++ .../node_modules/app/star-reexporter.js | 9 ++ packages/ses/test/import-gauntlet.test.js | 10 ++ 44 files changed, 796 insertions(+) create mode 100644 packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js create mode 100644 packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json create mode 100644 packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js diff --git a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js new file mode 100644 index 0000000000..3cf8ef1135 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js @@ -0,0 +1,106 @@ +/** + * Shared assertion logic for the TDZ-observation matrix of the cyclic + * star-export and named-reexport scenarios from endojs/endo#59. Each fixture + * exercises one cell of the matrix and exports a `probe` value captured by + * the star-reexporter (or named-reexporter) during its top-level evaluation: + * the probe reads the renamer's binding `y` through a namespace import (`r.y`) + * inside a try block and records either the value (when the binding is + * already initialized) or the error name (when the read raises during the + * temporal dead zone window). + * + * The matrix axes are: + * + * 1. Which module main.js imports first (the export-renamer or the + * star-reexporter or the named-reexporter). The first-imported module + * starts evaluating first; depth-first traversal of the cycle then + * determines which module's body runs while the other is on the + * evaluation stack with bindings not yet initialized. + * 2. The renamer's binding form for `y` (`const`, `let`, or `var`). Under + * ECMA-262 semantics, `const` and `let` create a binding that is in + * the temporal dead zone until its declaration is evaluated, so a read + * raises ReferenceError; `var` is hoisted and reads `undefined` until + * the assignment runs. + * 3. Whether the upstream side of the cycle is reached through `export *` + * (the six star-reexport cells) or through `export { y } from` (the + * single named-reexport cell). + * + * After the TDZ-enforcement fix landed on endojs/endo-but-for-bots#379 + * (commit 94c88465d, plus the named-reexport coverage in 53d8662a7), SES + * enforces the same TDZ on + * the cross-module namespace path that Node.js enforces natively. Each + * fixture's parity test pins the compartment mapper's behavior to Node.js's + * reference behavior by importing from this module so the expected values + * live in exactly one place; if both tests pass, parity is verified by + * construction. + * + * The companion in-process scenarios live in + * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells + * starting at "cyclic star export with renaming reexport, renamer imported + * first, const binding observes ReferenceError during temporal dead zone". + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +// Renamer imported first: depth-first traversal evaluates the +// star-reexporter's body while the renamer is on the evaluation stack with +// its binding `y` not yet initialized. Under ECMA-262, `const` and `let` +// raise ReferenceError on read; `var` reads undefined (the hoisting +// preamble clears the upstream TDZ before the downstream observes). + +export const expectedProbeStarConstRenamerFirst = { + kind: 'error', + name: 'ReferenceError', +}; + +export const expectedProbeStarLetRenamerFirst = { + kind: 'error', + name: 'ReferenceError', +}; + +export const expectedProbeStarVarRenamerFirst = { + kind: 'value', + value: undefined, +}; + +// Star-reexporter imported first: depth-first cycle resolution evaluates +// the renamer's body to completion before the star-reexporter's body runs, +// so the probe captures the assigned value for every binding form. The +// "star reexporter imported first" cases therefore have no TDZ window to +// observe; they are the expected non-observation that completes the matrix. + +export const expectedProbeStarConstStarFirst = { + kind: 'value', + value: 42, +}; + +export const expectedProbeStarLetStarFirst = { + kind: 'value', + value: 42, +}; + +export const expectedProbeStarVarStarFirst = { + kind: 'value', + value: 42, +}; + +// Named-reexport variant with renamer imported first: the cycle is reached +// through `export { y } from` rather than `export *`. The TDZ semantics +// live with the binding, not with the reexport form, so the const cell +// raises ReferenceError just as it does for the star-reexport cell. This +// confirms the gap is not specific to `export *`. + +export const expectedProbeNamedConstRenamerFirst = { + kind: 'error', + name: 'ReferenceError', +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + * @param {object} expectedProbe + */ +export const assertCycleRenameTdz = (t, namespace, expectedProbe) => { + t.deepEqual(namespace.probe, expectedProbe); +}; diff --git a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js new file mode 100644 index 0000000000..3c962a4654 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for the cyclic named-reexport with renaming reexport + * cell from issue #59: renamer's binding is `const y = 42`, main.js imports + * the renamer first, the cycle is reached through `export { y } from` + * instead of `export *`. This test runs the same fixture under plain + * Node.js (no SES, no compartment mapper) and asserts the same probe value + * asserted in the compartment-mapper test. Parity is verified by + * construction: if both tests pass, SES enforces the same TDZ semantics on + * the named-reexport path that Node.js enforces natively for this cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeNamedConstRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic named reexport with renaming reexport, renamer first, const TDZ (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeNamedConstRenamerFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js new file mode 100644 index 0000000000..6faef939b2 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js @@ -0,0 +1,51 @@ +/** + * Companion to the star-reexport cells with the star reexport replaced by + * a named reexport: the upstream module uses `export { y } from + * './export-renamer.js'` instead of `export * from './export-renamer.js'`. + * The cycle has the same shape (the named-reexporter and the export-renamer + * reference each other) and the observation of `r.y` through the namespace + * import lands during the same linked-but-not-yet-bound window when main.js + * imports the renamer first. Node.js raises ReferenceError here, matching + * the star-reexport case, because temporal-dead-zone semantics live with + * the binding rather than the reexport form. After the TDZ-enforcement + * fix landed on endojs/endo-but-for-bots#379, SES enforces the same TDZ on the namespace path + * whether the reexport is reached through `export *` or through + * `export { y } from`. Exercised through the compartment-mapper test + * scaffold; the Node.js parity sibling in + * cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js asserts + * the same expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeNamedConstRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeNamedConstRenamerFirst); +}; + +scaffold( + 'cycle-named-reexport-tdz const renamer-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js new file mode 100644 index 0000000000..fa9ae7e308 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `const y = 42`, main.js imports the renamer first. This test runs the + * same fixture under plain Node.js (no SES, no compartment mapper) and + * asserts the same probe value asserted in the compartment-mapper test. + * Parity is verified by construction: if both tests pass, SES enforces the + * same temporal-dead-zone semantics on the cross-module namespace read as + * Node.js for this cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarConstRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, renamer first, const TDZ (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarConstRenamerFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js new file mode 100644 index 0000000000..fe9fb0300e --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js @@ -0,0 +1,44 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `const y + * = 42`, main.js imports the renamer before the star-reexporter. The + * star-reexporter's probe observes ReferenceError on the cross-module read + * through the namespace import during the temporal dead zone window. + * Exercised through the compartment-mapper test scaffold; the Node.js + * parity sibling in cycle-rename-tdz-const-renamer-first-node-parity.test.js + * asserts the same expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarConstRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarConstRenamerFirst); +}; + +scaffold( + 'cycle-rename-tdz const renamer-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js new file mode 100644 index 0000000000..f170f3f91c --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `const y = 42`, main.js imports the star-reexporter first. This test + * runs the same fixture under plain Node.js (no SES, no compartment mapper) + * and asserts the same probe value asserted in the compartment-mapper + * test. Parity is verified by construction: if both tests pass, SES + * resolves the cycle in the same depth-first order as Node.js for this + * cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarConstStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, star first, const value (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarConstStarFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js new file mode 100644 index 0000000000..c012592076 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js @@ -0,0 +1,46 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `const y + * = 42`, main.js imports the star-reexporter before the renamer. + * Depth-first cycle resolution evaluates the renamer's body to completion + * before the star-reexporter's body runs, so the probe captures the + * assigned value 42. This cell has no TDZ window to observe; it pins the + * expected non-observation that completes the matrix. Exercised through + * the compartment-mapper test scaffold; the Node.js parity sibling in + * cycle-rename-tdz-const-star-first-node-parity.test.js asserts the same + * expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarConstStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarConstStarFirst); +}; + +scaffold( + 'cycle-rename-tdz const star-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js new file mode 100644 index 0000000000..06a59577ec --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `let y = 42`, main.js imports the renamer first. This test runs the + * same fixture under plain Node.js (no SES, no compartment mapper) and + * asserts the same probe value asserted in the compartment-mapper test. + * Parity is verified by construction: if both tests pass, SES enforces the + * same temporal-dead-zone semantics on the cross-module namespace read as + * Node.js for this cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarLetRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, renamer first, let TDZ (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarLetRenamerFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js new file mode 100644 index 0000000000..cc714a179b --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js @@ -0,0 +1,44 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `let y = + * 42`, main.js imports the renamer before the star-reexporter. The + * star-reexporter's probe observes ReferenceError on the cross-module read + * through the namespace import during the temporal dead zone window. + * Exercised through the compartment-mapper test scaffold; the Node.js + * parity sibling in cycle-rename-tdz-let-renamer-first-node-parity.test.js + * asserts the same expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarLetRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarLetRenamerFirst); +}; + +scaffold( + 'cycle-rename-tdz let renamer-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js new file mode 100644 index 0000000000..70ba0789eb --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `let y = 42`, main.js imports the star-reexporter first. This test + * runs the same fixture under plain Node.js (no SES, no compartment mapper) + * and asserts the same probe value asserted in the compartment-mapper + * test. Parity is verified by construction: if both tests pass, SES + * resolves the cycle in the same depth-first order as Node.js for this + * cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarLetStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, star first, let value (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarLetStarFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js new file mode 100644 index 0000000000..9e44f14aab --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js @@ -0,0 +1,46 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `let y = + * 42`, main.js imports the star-reexporter before the renamer. + * Depth-first cycle resolution evaluates the renamer's body to completion + * before the star-reexporter's body runs, so the probe captures the + * assigned value 42. This cell has no TDZ window to observe; it pins the + * expected non-observation that completes the matrix. Exercised through + * the compartment-mapper test scaffold; the Node.js parity sibling in + * cycle-rename-tdz-let-star-first-node-parity.test.js asserts the same + * expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarLetStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarLetStarFirst); +}; + +scaffold( + 'cycle-rename-tdz let star-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js new file mode 100644 index 0000000000..af55ed6522 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `var y = 42`, main.js imports the renamer first. This test runs the + * same fixture under plain Node.js (no SES, no compartment mapper) and + * asserts the same probe value asserted in the compartment-mapper test. + * Parity is verified by construction: if both tests pass, SES enforces the + * same hoisting semantics on the cross-module namespace read as Node.js for + * this cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarVarRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, renamer first, var hoisting (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarVarRenamerFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js new file mode 100644 index 0000000000..9c87017c7a --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js @@ -0,0 +1,46 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `var y = + * 42`, main.js imports the renamer before the star-reexporter. The + * star-reexporter's probe observes `undefined` rather than ReferenceError + * because the hoisting preamble clears the upstream's TDZ before the + * downstream observes (`var` declarations are hoisted and initialized to + * `undefined` during `InitializeEnvironment`). Exercised through the + * compartment-mapper test scaffold; the Node.js parity sibling in + * cycle-rename-tdz-var-renamer-first-node-parity.test.js asserts the same + * expected value against plain Node.js. See `_cycle-rename-tdz-assertions.js` + * for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarVarRenamerFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarVarRenamerFirst); +}; + +scaffold( + 'cycle-rename-tdz var renamer-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js new file mode 100644 index 0000000000..1dfd932da9 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js @@ -0,0 +1,27 @@ +/** + * Node.js parity test for one cell of the TDZ-observation matrix from + * issue #59: cyclic star-export with renaming reexport, renamer's binding + * is `var y = 42`, main.js imports the star-reexporter first. This test + * runs the same fixture under plain Node.js (no SES, no compartment mapper) + * and asserts the same probe value asserted in the compartment-mapper + * test. Parity is verified by construction: if both tests pass, SES + * resolves the cycle in the same depth-first order as Node.js for this + * cell. + */ + +import test from 'ava'; +import { + assertCycleRenameTdz, + expectedProbeStarVarStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +test('cyclic star export with renaming reexport, star first, var value (issue #59) - node parity', async t => { + t.plan(1); + const namespace = await import( + new URL( + 'fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js', + import.meta.url, + ).href + ); + assertCycleRenameTdz(t, namespace, expectedProbeStarVarStarFirst); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js new file mode 100644 index 0000000000..505c6def5b --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js @@ -0,0 +1,46 @@ +/** + * One cell of the TDZ-observation matrix for the cyclic star-export with + * renaming reexport scenario from issue #59: renamer's binding is `var y = + * 42`, main.js imports the star-reexporter before the renamer. + * Depth-first cycle resolution evaluates the renamer's body to completion + * before the star-reexporter's body runs, so the probe captures the + * assigned value 42. This cell has no TDZ window to observe; it pins the + * expected non-observation that completes the matrix. Exercised through + * the compartment-mapper test scaffold; the Node.js parity sibling in + * cycle-rename-tdz-var-star-first-node-parity.test.js asserts the same + * expected value against plain Node.js. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + assertCycleRenameTdz, + expectedProbeStarVarStarFirst, +} from './_cycle-rename-tdz-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, expectedProbeStarVarStarFirst); +}; + +scaffold( + 'cycle-rename-tdz var star-first (issue #59)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..937e0389d8 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './named-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..889143f312 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './named-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js new file mode 100644 index 0000000000..43d1164dec --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export { y } from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..d4fa0d42d0 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..d4fa0d42d0 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..b4e3e31aae --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export let y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..b4e3e31aae --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export let y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..73bac4a4c7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..73bac4a4c7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 083addcfa8..512ba98275 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -376,6 +376,16 @@ test('cyclic star export with renaming reexport, unused live binding', async t = // cross-module namespace access path), while renamer-first plus `var` // continues to read `undefined` because the hoisting preamble clears the // upstream's TDZ before the downstream observes. +// +// Each cell is also exercised through the compartment-mapper scaffold and +// pinned to Node.js's reference behavior with a shared assertion module. +// The six star-reexport cells live under +// packages/compartment-mapper/test/fixtures-cycle-rename-tdz--/ +// with sibling tests cycle-rename-tdz--.test.js and +// cycle-rename-tdz---node-parity.test.js for binding in +// {const, let, var} and order in {renamer-first, star-first}. The named- +// reexport cell below has its own fixture under +// fixtures-cycle-named-reexport-tdz-const-renamer-first/ and sibling tests. test('cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { t.plan(1); From 8f9f30298f9c27b3ac7a430525325ef91bca84f3 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Sat, 13 Jun 2026 06:43:24 +0000 Subject: [PATCH 16/24] test(compartment-mapper): consolidate cyclic TDZ matrix into a table-driven test pair Replace the 14 individual per-cell test files (seven for the SES side through the compartment-mapper scaffold, seven for the Node.js parity side) with a single SCENARIOS table in the shared assertions module and two iterate-and-register test files that walk it. Each scenario's `fixture` field names its directory under packages/compartment-mapper/test/; the `expectedProbe` field carries the matrix's expected observation; the assertion module's preamble holds the matrix's framing and per-cell rationale. The diff is shape-only: every scenario from the prior individual files appears as one SCENARIOS row, and both layers (SES under scaffold and plain Node.js) walk the same table so per-scenario parity is verified by construction. Test counts match the prior split layout (7 Node parity + 7 * 11 scaffold variants); cycle-rename-tdz cells all pass on both layers. Update the import-gauntlet.test.js cross-reference block to name the consolidated SCENARIOS table and the two table-driven files rather than the prior per-cell file pattern. Per kriskowal review 4491014140 on PR endojs/endo-but-for-bots#379. --- .../test/_cycle-rename-tdz-assertions.js | 211 ++++++++++++------ ...dz-const-renamer-first-node-parity.test.js | 27 --- ...d-reexport-tdz-const-renamer-first.test.js | 51 ----- ...dz-const-renamer-first-node-parity.test.js | 27 --- ...cle-rename-tdz-const-renamer-first.test.js | 44 ---- ...e-tdz-const-star-first-node-parity.test.js | 27 --- .../cycle-rename-tdz-const-star-first.test.js | 46 ---- ...-tdz-let-renamer-first-node-parity.test.js | 27 --- ...cycle-rename-tdz-let-renamer-first.test.js | 44 ---- ...ame-tdz-let-star-first-node-parity.test.js | 27 --- .../cycle-rename-tdz-let-star-first.test.js | 46 ---- ...ycle-rename-tdz-matrix-node-parity.test.js | 31 +++ .../test/cycle-rename-tdz-matrix.test.js | 49 ++++ ...-tdz-var-renamer-first-node-parity.test.js | 27 --- ...cycle-rename-tdz-var-renamer-first.test.js | 46 ---- ...ame-tdz-var-star-first-node-parity.test.js | 27 --- .../cycle-rename-tdz-var-star-first.test.js | 46 ---- packages/ses/test/import-gauntlet.test.js | 15 +- 18 files changed, 234 insertions(+), 584 deletions(-) delete mode 100644 packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js create mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js diff --git a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js index 3cf8ef1135..8394c08a27 100644 --- a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js +++ b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js @@ -1,12 +1,15 @@ /** - * Shared assertion logic for the TDZ-observation matrix of the cyclic - * star-export and named-reexport scenarios from endojs/endo#59. Each fixture - * exercises one cell of the matrix and exports a `probe` value captured by - * the star-reexporter (or named-reexporter) during its top-level evaluation: - * the probe reads the renamer's binding `y` through a namespace import (`r.y`) - * inside a try block and records either the value (when the binding is - * already initialized) or the error name (when the read raises during the - * temporal dead zone window). + * Shared assertion logic and scenario table for the TDZ-observation matrix + * of the cyclic star-export and named-reexport scenarios from + * endojs/endo#59. Each scenario in the table corresponds to one cell of the + * matrix and to one fixture directory under + * `packages/compartment-mapper/test/` (named by the scenario's `fixture` + * field). Each fixture exports a `probe` value captured by the star-reexporter (or + * named-reexporter) during its top-level evaluation: the probe reads the + * renamer's binding `y` through a namespace import (`r.y`) inside a try + * block and records either the value (when the binding is already + * initialized) or the error name (when the read raises during the temporal + * dead zone window). * * The matrix axes are: * @@ -26,12 +29,11 @@ * * After the TDZ-enforcement fix landed on endojs/endo-but-for-bots#379 * (commit 94c88465d, plus the named-reexport coverage in 53d8662a7), SES - * enforces the same TDZ on - * the cross-module namespace path that Node.js enforces natively. Each - * fixture's parity test pins the compartment mapper's behavior to Node.js's - * reference behavior by importing from this module so the expected values - * live in exactly one place; if both tests pass, parity is verified by - * construction. + * enforces the same TDZ on the cross-module namespace path that Node.js + * enforces natively. Each scenario's parity test pins the compartment + * mapper's behavior to Node.js's reference behavior by importing from this + * module so the expected values live in exactly one place; if both layers + * pass, parity is verified by construction. * * The companion in-process scenarios live in * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells @@ -43,58 +45,137 @@ /** @import {ExecutionContext} from 'ava' */ -// Renamer imported first: depth-first traversal evaluates the -// star-reexporter's body while the renamer is on the evaluation stack with -// its binding `y` not yet initialized. Under ECMA-262, `const` and `let` -// raise ReferenceError on read; `var` reads undefined (the hoisting -// preamble clears the upstream TDZ before the downstream observes). - -export const expectedProbeStarConstRenamerFirst = { - kind: 'error', - name: 'ReferenceError', -}; - -export const expectedProbeStarLetRenamerFirst = { - kind: 'error', - name: 'ReferenceError', -}; - -export const expectedProbeStarVarRenamerFirst = { - kind: 'value', - value: undefined, -}; - -// Star-reexporter imported first: depth-first cycle resolution evaluates -// the renamer's body to completion before the star-reexporter's body runs, -// so the probe captures the assigned value for every binding form. The -// "star reexporter imported first" cases therefore have no TDZ window to -// observe; they are the expected non-observation that completes the matrix. - -export const expectedProbeStarConstStarFirst = { - kind: 'value', - value: 42, -}; - -export const expectedProbeStarLetStarFirst = { - kind: 'value', - value: 42, -}; - -export const expectedProbeStarVarStarFirst = { - kind: 'value', - value: 42, -}; - -// Named-reexport variant with renamer imported first: the cycle is reached -// through `export { y } from` rather than `export *`. The TDZ semantics -// live with the binding, not with the reexport form, so the const cell -// raises ReferenceError just as it does for the star-reexport cell. This -// confirms the gap is not specific to `export *`. +/** + * @typedef {object} CycleRenameTdzScenario + * @property {string} name + * Short identifier used in test titles and the fixture directory's + * final path component. Each name is unique across the matrix. + * @property {string} fixture + * The fixture directory name under + * `packages/compartment-mapper/test/`. Its `node_modules/app/main.js` + * is the import target for both the compartment-mapper test and its + * Node.js parity sibling. + * @property {'star'|'named'} reexportForm + * Whether the upstream side of the cycle is reached through + * `export *` (`star`) or through `export { y } from` (`named`). + * @property {'const'|'let'|'var'} binding + * The renamer's binding form for `y`. `const` and `let` participate in + * the temporal dead zone; `var` is hoisted. + * @property {'renamer-first'|'star-first'} order + * Which module main.js imports first. The first-imported module starts + * evaluating first; the second is the one that observes the cycle + * partner mid-evaluation. + * @property {{kind: 'error', name: string}|{kind: 'value', value: unknown}} expectedProbe + * What the probe is expected to record: + * - `{ kind: 'error', name: 'ReferenceError' }` for cells where the + * cross-module namespace read lands during the TDZ window of a + * lexically-bound (`const` or `let`) declaration that has not yet + * been initialized. + * - `{ kind: 'value', value: }` for cells where the read + * either follows depth-first cycle resolution of the renamer's body + * (the star-first cells, expected `42`) or observes the hoisting + * preamble's pre-initialization of a `var` binding (renamer-first + * plus `var`, expected `undefined`). + */ -export const expectedProbeNamedConstRenamerFirst = { - kind: 'error', - name: 'ReferenceError', -}; +/** @type {ReadonlyArray} */ +export const SCENARIOS = Object.freeze([ + // Renamer imported first: depth-first traversal evaluates the + // star-reexporter's body while the renamer is on the evaluation stack + // with its binding `y` not yet initialized. Under ECMA-262, `const` and + // `let` raise ReferenceError on read; `var` reads undefined (the + // hoisting preamble clears the upstream TDZ before the downstream + // observes). + Object.freeze({ + name: 'star const renamer-first', + fixture: 'fixtures-cycle-rename-tdz-const-renamer-first', + reexportForm: 'star', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star let renamer-first', + fixture: 'fixtures-cycle-rename-tdz-let-renamer-first', + reexportForm: 'star', + binding: 'let', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star var renamer-first', + fixture: 'fixtures-cycle-rename-tdz-var-renamer-first', + reexportForm: 'star', + binding: 'var', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: undefined, + }), + }), + // Star-reexporter imported first: depth-first cycle resolution + // evaluates the renamer's body to completion before the star-reexporter + // body runs, so the probe captures the assigned value for every binding + // form. The "star reexporter imported first" cases therefore have no + // TDZ window to observe; they are the expected non-observation that + // completes the matrix. + Object.freeze({ + name: 'star const star-first', + fixture: 'fixtures-cycle-rename-tdz-const-star-first', + reexportForm: 'star', + binding: 'const', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star let star-first', + fixture: 'fixtures-cycle-rename-tdz-let-star-first', + reexportForm: 'star', + binding: 'let', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star var star-first', + fixture: 'fixtures-cycle-rename-tdz-var-star-first', + reexportForm: 'star', + binding: 'var', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + // Named-reexport variant with renamer imported first: the cycle is + // reached through `export { y } from` rather than `export *`. The TDZ + // semantics live with the binding, not with the reexport form, so the + // const cell raises ReferenceError just as it does for the + // star-reexport cell. This confirms the gap is not specific to + // `export *`. + Object.freeze({ + name: 'named const renamer-first', + fixture: 'fixtures-cycle-named-reexport-tdz-const-renamer-first', + reexportForm: 'named', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), +]); /** * @param {ExecutionContext} t diff --git a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js deleted file mode 100644 index 3c962a4654..0000000000 --- a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for the cyclic named-reexport with renaming reexport - * cell from issue #59: renamer's binding is `const y = 42`, main.js imports - * the renamer first, the cycle is reached through `export { y } from` - * instead of `export *`. This test runs the same fixture under plain - * Node.js (no SES, no compartment mapper) and asserts the same probe value - * asserted in the compartment-mapper test. Parity is verified by - * construction: if both tests pass, SES enforces the same TDZ semantics on - * the named-reexport path that Node.js enforces natively for this cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeNamedConstRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic named reexport with renaming reexport, renamer first, const TDZ (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeNamedConstRenamerFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js b/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js deleted file mode 100644 index 6faef939b2..0000000000 --- a/packages/compartment-mapper/test/cycle-named-reexport-tdz-const-renamer-first.test.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Companion to the star-reexport cells with the star reexport replaced by - * a named reexport: the upstream module uses `export { y } from - * './export-renamer.js'` instead of `export * from './export-renamer.js'`. - * The cycle has the same shape (the named-reexporter and the export-renamer - * reference each other) and the observation of `r.y` through the namespace - * import lands during the same linked-but-not-yet-bound window when main.js - * imports the renamer first. Node.js raises ReferenceError here, matching - * the star-reexport case, because temporal-dead-zone semantics live with - * the binding rather than the reexport form. After the TDZ-enforcement - * fix landed on endojs/endo-but-for-bots#379, SES enforces the same TDZ on the namespace path - * whether the reexport is reached through `export *` or through - * `export { y } from`. Exercised through the compartment-mapper test - * scaffold; the Node.js parity sibling in - * cycle-named-reexport-tdz-const-renamer-first-node-parity.test.js asserts - * the same expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeNamedConstRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeNamedConstRenamerFirst); -}; - -scaffold( - 'cycle-named-reexport-tdz const renamer-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js deleted file mode 100644 index fa9ae7e308..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `const y = 42`, main.js imports the renamer first. This test runs the - * same fixture under plain Node.js (no SES, no compartment mapper) and - * asserts the same probe value asserted in the compartment-mapper test. - * Parity is verified by construction: if both tests pass, SES enforces the - * same temporal-dead-zone semantics on the cross-module namespace read as - * Node.js for this cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarConstRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, renamer first, const TDZ (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarConstRenamerFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js deleted file mode 100644 index fe9fb0300e..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-const-renamer-first.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `const y - * = 42`, main.js imports the renamer before the star-reexporter. The - * star-reexporter's probe observes ReferenceError on the cross-module read - * through the namespace import during the temporal dead zone window. - * Exercised through the compartment-mapper test scaffold; the Node.js - * parity sibling in cycle-rename-tdz-const-renamer-first-node-parity.test.js - * asserts the same expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarConstRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarConstRenamerFirst); -}; - -scaffold( - 'cycle-rename-tdz const renamer-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js deleted file mode 100644 index f170f3f91c..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `const y = 42`, main.js imports the star-reexporter first. This test - * runs the same fixture under plain Node.js (no SES, no compartment mapper) - * and asserts the same probe value asserted in the compartment-mapper - * test. Parity is verified by construction: if both tests pass, SES - * resolves the cycle in the same depth-first order as Node.js for this - * cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarConstStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, star first, const value (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarConstStarFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js deleted file mode 100644 index c012592076..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-const-star-first.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `const y - * = 42`, main.js imports the star-reexporter before the renamer. - * Depth-first cycle resolution evaluates the renamer's body to completion - * before the star-reexporter's body runs, so the probe captures the - * assigned value 42. This cell has no TDZ window to observe; it pins the - * expected non-observation that completes the matrix. Exercised through - * the compartment-mapper test scaffold; the Node.js parity sibling in - * cycle-rename-tdz-const-star-first-node-parity.test.js asserts the same - * expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarConstStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarConstStarFirst); -}; - -scaffold( - 'cycle-rename-tdz const star-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js deleted file mode 100644 index 06a59577ec..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `let y = 42`, main.js imports the renamer first. This test runs the - * same fixture under plain Node.js (no SES, no compartment mapper) and - * asserts the same probe value asserted in the compartment-mapper test. - * Parity is verified by construction: if both tests pass, SES enforces the - * same temporal-dead-zone semantics on the cross-module namespace read as - * Node.js for this cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarLetRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, renamer first, let TDZ (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarLetRenamerFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js deleted file mode 100644 index cc714a179b..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-let-renamer-first.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `let y = - * 42`, main.js imports the renamer before the star-reexporter. The - * star-reexporter's probe observes ReferenceError on the cross-module read - * through the namespace import during the temporal dead zone window. - * Exercised through the compartment-mapper test scaffold; the Node.js - * parity sibling in cycle-rename-tdz-let-renamer-first-node-parity.test.js - * asserts the same expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarLetRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarLetRenamerFirst); -}; - -scaffold( - 'cycle-rename-tdz let renamer-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js deleted file mode 100644 index 70ba0789eb..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `let y = 42`, main.js imports the star-reexporter first. This test - * runs the same fixture under plain Node.js (no SES, no compartment mapper) - * and asserts the same probe value asserted in the compartment-mapper - * test. Parity is verified by construction: if both tests pass, SES - * resolves the cycle in the same depth-first order as Node.js for this - * cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarLetStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, star first, let value (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarLetStarFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js deleted file mode 100644 index 9e44f14aab..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-let-star-first.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `let y = - * 42`, main.js imports the star-reexporter before the renamer. - * Depth-first cycle resolution evaluates the renamer's body to completion - * before the star-reexporter's body runs, so the probe captures the - * assigned value 42. This cell has no TDZ window to observe; it pins the - * expected non-observation that completes the matrix. Exercised through - * the compartment-mapper test scaffold; the Node.js parity sibling in - * cycle-rename-tdz-let-star-first-node-parity.test.js asserts the same - * expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarLetStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarLetStarFirst); -}; - -scaffold( - 'cycle-rename-tdz let star-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js new file mode 100644 index 0000000000..1894051ca7 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js @@ -0,0 +1,31 @@ +/** + * Table-driven Node.js parity test for the TDZ-observation matrix from + * endojs/endo#59. Each row in the `SCENARIOS` table from + * `_cycle-rename-tdz-assertions.js` corresponds to one fixture directory + * under `packages/compartment-mapper/test/` (named by the scenario's + * `fixture` field). This test runs each fixture's `main.js` under plain + * Node.js (no SES, no compartment mapper) and asserts the same probe + * value asserted in the compartment-mapper sibling + * `cycle-rename-tdz-matrix.test.js`. Parity is verified by construction: + * if both tests pass for a scenario, SES enforces the same + * temporal-dead-zone (or hoisting, or cycle-resolution) semantics on the + * cross-module namespace read as Node.js for that cell. See + * `_cycle-rename-tdz-assertions.js` for the matrix's framing. + */ + +import test from 'ava'; +import { + SCENARIOS, + assertCycleRenameTdz, +} from './_cycle-rename-tdz-assertions.js'; + +for (const scenario of SCENARIOS) { + test(`cycle-rename-tdz ${scenario.name} (endojs/endo#59) - node parity`, async t => { + t.plan(1); + const namespace = await import( + new URL(`${scenario.fixture}/node_modules/app/main.js`, import.meta.url) + .href + ); + assertCycleRenameTdz(t, namespace, scenario.expectedProbe); + }); +} diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js new file mode 100644 index 0000000000..532ab8ce5c --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js @@ -0,0 +1,49 @@ +/** + * Table-driven test for the TDZ-observation matrix of the cyclic + * star-export and named-reexport scenarios from endojs/endo#59 exercised + * through the compartment-mapper test scaffold. Each row in the + * `SCENARIOS` table from `_cycle-rename-tdz-assertions.js` corresponds to + * one fixture directory under + * `packages/compartment-mapper/test/` (named by the scenario's `fixture` + * field) and to one matrix cell described in that module's preamble. The + * Node.js parity sibling in `cycle-rename-tdz-matrix-node-parity.test.js` + * walks the same table and asserts the same expected probe values against + * plain Node.js; if both layers pass for every scenario, parity is + * verified by construction. See `_cycle-rename-tdz-assertions.js` for the + * matrix's framing and the per-scenario expected-probe rationale. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { + SCENARIOS, + assertCycleRenameTdz, +} from './_cycle-rename-tdz-assertions.js'; + +const fixtureAssertionCount = 1; + +for (const scenario of SCENARIOS) { + const fixture = new URL( + `${scenario.fixture}/node_modules/app/main.js`, + import.meta.url, + ).toString(); + + /** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ + const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, scenario.expectedProbe); + }; + + scaffold( + `cycle-rename-tdz ${scenario.name} (endojs/endo#59)`, + test, + fixture, + assertFixture, + fixtureAssertionCount, + ); +} diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js deleted file mode 100644 index af55ed6522..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `var y = 42`, main.js imports the renamer first. This test runs the - * same fixture under plain Node.js (no SES, no compartment mapper) and - * asserts the same probe value asserted in the compartment-mapper test. - * Parity is verified by construction: if both tests pass, SES enforces the - * same hoisting semantics on the cross-module namespace read as Node.js for - * this cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarVarRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, renamer first, var hoisting (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarVarRenamerFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js deleted file mode 100644 index 9c87017c7a..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-var-renamer-first.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `var y = - * 42`, main.js imports the renamer before the star-reexporter. The - * star-reexporter's probe observes `undefined` rather than ReferenceError - * because the hoisting preamble clears the upstream's TDZ before the - * downstream observes (`var` declarations are hoisted and initialized to - * `undefined` during `InitializeEnvironment`). Exercised through the - * compartment-mapper test scaffold; the Node.js parity sibling in - * cycle-rename-tdz-var-renamer-first-node-parity.test.js asserts the same - * expected value against plain Node.js. See `_cycle-rename-tdz-assertions.js` - * for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarVarRenamerFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarVarRenamerFirst); -}; - -scaffold( - 'cycle-rename-tdz var renamer-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js deleted file mode 100644 index 1dfd932da9..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first-node-parity.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Node.js parity test for one cell of the TDZ-observation matrix from - * issue #59: cyclic star-export with renaming reexport, renamer's binding - * is `var y = 42`, main.js imports the star-reexporter first. This test - * runs the same fixture under plain Node.js (no SES, no compartment mapper) - * and asserts the same probe value asserted in the compartment-mapper - * test. Parity is verified by construction: if both tests pass, SES - * resolves the cycle in the same depth-first order as Node.js for this - * cell. - */ - -import test from 'ava'; -import { - assertCycleRenameTdz, - expectedProbeStarVarStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -test('cyclic star export with renaming reexport, star first, var value (issue #59) - node parity', async t => { - t.plan(1); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameTdz(t, namespace, expectedProbeStarVarStarFirst); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js deleted file mode 100644 index 505c6def5b..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-var-star-first.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * One cell of the TDZ-observation matrix for the cyclic star-export with - * renaming reexport scenario from issue #59: renamer's binding is `var y = - * 42`, main.js imports the star-reexporter before the renamer. - * Depth-first cycle resolution evaluates the renamer's body to completion - * before the star-reexporter's body runs, so the probe captures the - * assigned value 42. This cell has no TDZ window to observe; it pins the - * expected non-observation that completes the matrix. Exercised through - * the compartment-mapper test scaffold; the Node.js parity sibling in - * cycle-rename-tdz-var-star-first-node-parity.test.js asserts the same - * expected value against plain Node.js. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -/** @import {ExecutionContext} from 'ava' */ - -import 'ses'; -import test from 'ava'; -import { scaffold } from './scaffold.js'; -import { - assertCycleRenameTdz, - expectedProbeStarVarStarFirst, -} from './_cycle-rename-tdz-assertions.js'; - -const fixture = new URL( - 'fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js', - import.meta.url, -).toString(); - -const fixtureAssertionCount = 1; - -/** - * @param {ExecutionContext} t - * @param {{namespace: object}} result - */ -const assertFixture = (t, { namespace }) => { - assertCycleRenameTdz(t, namespace, expectedProbeStarVarStarFirst); -}; - -scaffold( - 'cycle-rename-tdz var star-first (issue #59)', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 512ba98275..5b84a4ec06 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -379,13 +379,14 @@ test('cyclic star export with renaming reexport, unused live binding', async t = // // Each cell is also exercised through the compartment-mapper scaffold and // pinned to Node.js's reference behavior with a shared assertion module. -// The six star-reexport cells live under -// packages/compartment-mapper/test/fixtures-cycle-rename-tdz--/ -// with sibling tests cycle-rename-tdz--.test.js and -// cycle-rename-tdz---node-parity.test.js for binding in -// {const, let, var} and order in {renamer-first, star-first}. The named- -// reexport cell below has its own fixture under -// fixtures-cycle-named-reexport-tdz-const-renamer-first/ and sibling tests. +// The six star-reexport cells and the named-reexport cell are enumerated +// in a single SCENARIOS table in +// packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js and +// walked by the table-driven test pair +// cycle-rename-tdz-matrix.test.js (SES under the compartment-mapper +// scaffold) and cycle-rename-tdz-matrix-node-parity.test.js (plain +// Node.js). Each scenario's `fixture` field names its directory under +// packages/compartment-mapper/test/fixtures-cycle-{rename,named-reexport}-tdz-*/. test('cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { t.plan(1); From 5013d3cdb894a7a4b9f49b56ce85b5d1303d982b Mon Sep 17 00:00:00 2001 From: endolinbot Date: Sat, 13 Jun 2026 06:45:17 +0000 Subject: [PATCH 17/24] docs(ses): expand changeset to cover TDZ enforcement alongside the cycle-rename defect fix The original changeset described only the deferred forwarding notifier that resolved the spurious cycle-rename `SyntaxError`. The branch has since grown a second user-visible change: SES now enforces ECMA-262 temporal-dead-zone semantics for cross-module reads through a module namespace import while the upstream's body is still mid-evaluation, for both the `export *` and `export { y } from` reexport forms. Update the changeset prose to name both behaviors so consumers reading the release notes see the full surface affected by the cycle-rename work. Per kriskowal review 4491014140 on PR endojs/endo-but-for-bots#379. --- .changeset/fix-ses-star-export-cycle-rename.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/fix-ses-star-export-cycle-rename.md b/.changeset/fix-ses-star-export-cycle-rename.md index 21e80f0dc6..02b0895df5 100644 --- a/.changeset/fix-ses-star-export-cycle-rename.md +++ b/.changeset/fix-ses-star-export-cycle-rename.md @@ -4,4 +4,8 @@ Fix a star-export cycle defect where a module reached more than once via `export *` and a renaming reexport with a different exported name (`export { y as x } from ...`) raised a spurious `SyntaxError: ... does not provide an export named 'X'` (latterly `TypeError: notify is not a function`). The reexport wire-up now installs a deferred forwarding notifier that resolves through the upstream's notifier table on first subscription, so cyclic star-export fixed-points converge. + +Additionally, enforce ECMA-262 temporal-dead-zone semantics for cross-module reads through a module namespace import during cycle evaluation, for both the `export *` and `export { y } from` reexport forms. +Previously, when the importing side of a cycle observed the upstream's binding through a namespace import (`r.y`) while the upstream's body was still on the evaluation stack and the binding's declaration had not yet been evaluated, the read returned the uninitialized slot value instead of raising; SES now matches Node.js's reference behavior and raises `ReferenceError` for `const` and `let` bindings during the TDZ window, while `var` bindings continue to read `undefined` because the hoisting preamble pre-initializes them before any downstream observation. + Resolves endojs/endo#59. From 2104fd751a6e1b62088e51841171232df4bf11a8 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Sat, 13 Jun 2026 06:47:55 +0000 Subject: [PATCH 18/24] test(compartment-mapper): drop issue-number references from the cycle-rename-tdz matrix files The bot-fork-side review on PR endojs/endo-but-for-bots#379 noted that the issue numbers (#59 and the qualified endojs/endo#59) will not translate when the cycle-rename branch lands on the upstream endojs/endo repository. Sweep the issue-number references from the consolidated table-driven test pair (cycle-rename-tdz-matrix.test.js and the node-parity sibling) and the shared assertions module so the scenario titles and module-level prose stand on their own description of the matrix's framing. The bot-fork-side PR number itself (endojs/endo-but-for-bots#379) and the per-commit SHAs that lived in the assertions module's preamble go away with the same sweep; the module continues to point readers at the SCENARIOS table and at the import-gauntlet.test.js sibling for the in-process scenarios. Per kriskowal review 4491014140 on PR endojs/endo-but-for-bots#379. --- .../test/_cycle-rename-tdz-assertions.js | 31 +++++++++---------- ...ycle-rename-tdz-matrix-node-parity.test.js | 7 +++-- .../test/cycle-rename-tdz-matrix.test.js | 6 ++-- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js index 8394c08a27..a8f92a158a 100644 --- a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js +++ b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js @@ -1,15 +1,14 @@ /** * Shared assertion logic and scenario table for the TDZ-observation matrix - * of the cyclic star-export and named-reexport scenarios from - * endojs/endo#59. Each scenario in the table corresponds to one cell of the - * matrix and to one fixture directory under - * `packages/compartment-mapper/test/` (named by the scenario's `fixture` - * field). Each fixture exports a `probe` value captured by the star-reexporter (or - * named-reexporter) during its top-level evaluation: the probe reads the - * renamer's binding `y` through a namespace import (`r.y`) inside a try - * block and records either the value (when the binding is already - * initialized) or the error name (when the read raises during the temporal - * dead zone window). + * of the cyclic star-export and named-reexport scenarios. Each scenario in + * the table corresponds to one cell of the matrix and to one fixture + * directory under `packages/compartment-mapper/test/` (named by the + * scenario's `fixture` field). Each fixture exports a `probe` value captured + * by the star-reexporter (or named-reexporter) during its top-level + * evaluation: the probe reads the renamer's binding `y` through a namespace + * import (`r.y`) inside a try block and records either the value (when the + * binding is already initialized) or the error name (when the read raises + * during the temporal dead zone window). * * The matrix axes are: * @@ -27,13 +26,11 @@ * (the six star-reexport cells) or through `export { y } from` (the * single named-reexport cell). * - * After the TDZ-enforcement fix landed on endojs/endo-but-for-bots#379 - * (commit 94c88465d, plus the named-reexport coverage in 53d8662a7), SES - * enforces the same TDZ on the cross-module namespace path that Node.js - * enforces natively. Each scenario's parity test pins the compartment - * mapper's behavior to Node.js's reference behavior by importing from this - * module so the expected values live in exactly one place; if both layers - * pass, parity is verified by construction. + * SES enforces the same TDZ on the cross-module namespace path that + * Node.js enforces natively. Each scenario's parity test pins the + * compartment mapper's behavior to Node.js's reference behavior by + * importing from this module so the expected values live in exactly one + * place; if both layers pass, parity is verified by construction. * * The companion in-process scenarios live in * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js index 1894051ca7..65fcfb2223 100644 --- a/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js @@ -1,6 +1,7 @@ /** - * Table-driven Node.js parity test for the TDZ-observation matrix from - * endojs/endo#59. Each row in the `SCENARIOS` table from + * Table-driven Node.js parity test for the TDZ-observation matrix of + * the cyclic star-export and named-reexport scenarios. Each row in the + * `SCENARIOS` table from * `_cycle-rename-tdz-assertions.js` corresponds to one fixture directory * under `packages/compartment-mapper/test/` (named by the scenario's * `fixture` field). This test runs each fixture's `main.js` under plain @@ -20,7 +21,7 @@ import { } from './_cycle-rename-tdz-assertions.js'; for (const scenario of SCENARIOS) { - test(`cycle-rename-tdz ${scenario.name} (endojs/endo#59) - node parity`, async t => { + test(`cycle-rename-tdz ${scenario.name} - node parity`, async t => { t.plan(1); const namespace = await import( new URL(`${scenario.fixture}/node_modules/app/main.js`, import.meta.url) diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js index 532ab8ce5c..b0ae4617d2 100644 --- a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js @@ -1,7 +1,7 @@ /** * Table-driven test for the TDZ-observation matrix of the cyclic - * star-export and named-reexport scenarios from endojs/endo#59 exercised - * through the compartment-mapper test scaffold. Each row in the + * star-export and named-reexport scenarios exercised through the + * compartment-mapper test scaffold. Each row in the * `SCENARIOS` table from `_cycle-rename-tdz-assertions.js` corresponds to * one fixture directory under * `packages/compartment-mapper/test/` (named by the scenario's `fixture` @@ -40,7 +40,7 @@ for (const scenario of SCENARIOS) { }; scaffold( - `cycle-rename-tdz ${scenario.name} (endojs/endo#59)`, + `cycle-rename-tdz ${scenario.name}`, test, fixture, assertFixture, From e95ebe5800d0cea8253c5dfadea7a97367d6b0a1 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Sun, 14 Jun 2026 08:18:01 +0000 Subject: [PATCH 19/24] test(compartment-mapper): merge cycle-rename-tdz SES+Node parity tests into single module per kriskowal review Consolidate the cycle-rename-tdz matrix's SES treatment (cycle-rename-tdz-matrix.test.js) and Node.js parity treatment (cycle-rename-tdz-matrix-node-parity.test.js) into a single test module. Each scenario now registers two tests back-to-back in the same module: the SES treatment through the compartment-mapper scaffold and the Node.js parity treatment through a direct dynamic import. The paired registration makes the shared fixture coverage legible at a glance: a fixture appears in the module exactly twice with the same expected probe. The SCENARIOS table (and the assertCycleRenameTdz helper) move inline into the consolidated module; the prior shared module (_cycle-rename-tdz-assertions.js) had only one consumer remaining after the merge and is deleted. The split node-parity sibling (cycle-rename-tdz-matrix-node-parity.test.js) is deleted with the same sweep. Update the prose in packages/ses/test/import-gauntlet.test.js that pointed readers at the prior split-file shape to point at the consolidated module instead. Per kriskowal review 4492610183 on PR endojs/endo-but-for-bots#379. --- .../test/_cycle-rename-tdz-assertions.js | 184 --------------- ...ycle-rename-tdz-matrix-node-parity.test.js | 32 --- .../test/cycle-rename-tdz-matrix.test.js | 214 ++++++++++++++++-- packages/ses/test/import-gauntlet.test.js | 11 +- 4 files changed, 203 insertions(+), 238 deletions(-) delete mode 100644 packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js diff --git a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js b/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js deleted file mode 100644 index a8f92a158a..0000000000 --- a/packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Shared assertion logic and scenario table for the TDZ-observation matrix - * of the cyclic star-export and named-reexport scenarios. Each scenario in - * the table corresponds to one cell of the matrix and to one fixture - * directory under `packages/compartment-mapper/test/` (named by the - * scenario's `fixture` field). Each fixture exports a `probe` value captured - * by the star-reexporter (or named-reexporter) during its top-level - * evaluation: the probe reads the renamer's binding `y` through a namespace - * import (`r.y`) inside a try block and records either the value (when the - * binding is already initialized) or the error name (when the read raises - * during the temporal dead zone window). - * - * The matrix axes are: - * - * 1. Which module main.js imports first (the export-renamer or the - * star-reexporter or the named-reexporter). The first-imported module - * starts evaluating first; depth-first traversal of the cycle then - * determines which module's body runs while the other is on the - * evaluation stack with bindings not yet initialized. - * 2. The renamer's binding form for `y` (`const`, `let`, or `var`). Under - * ECMA-262 semantics, `const` and `let` create a binding that is in - * the temporal dead zone until its declaration is evaluated, so a read - * raises ReferenceError; `var` is hoisted and reads `undefined` until - * the assignment runs. - * 3. Whether the upstream side of the cycle is reached through `export *` - * (the six star-reexport cells) or through `export { y } from` (the - * single named-reexport cell). - * - * SES enforces the same TDZ on the cross-module namespace path that - * Node.js enforces natively. Each scenario's parity test pins the - * compartment mapper's behavior to Node.js's reference behavior by - * importing from this module so the expected values live in exactly one - * place; if both layers pass, parity is verified by construction. - * - * The companion in-process scenarios live in - * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells - * starting at "cyclic star export with renaming reexport, renamer imported - * first, const binding observes ReferenceError during temporal dead zone". - * - * @module - */ - -/** @import {ExecutionContext} from 'ava' */ - -/** - * @typedef {object} CycleRenameTdzScenario - * @property {string} name - * Short identifier used in test titles and the fixture directory's - * final path component. Each name is unique across the matrix. - * @property {string} fixture - * The fixture directory name under - * `packages/compartment-mapper/test/`. Its `node_modules/app/main.js` - * is the import target for both the compartment-mapper test and its - * Node.js parity sibling. - * @property {'star'|'named'} reexportForm - * Whether the upstream side of the cycle is reached through - * `export *` (`star`) or through `export { y } from` (`named`). - * @property {'const'|'let'|'var'} binding - * The renamer's binding form for `y`. `const` and `let` participate in - * the temporal dead zone; `var` is hoisted. - * @property {'renamer-first'|'star-first'} order - * Which module main.js imports first. The first-imported module starts - * evaluating first; the second is the one that observes the cycle - * partner mid-evaluation. - * @property {{kind: 'error', name: string}|{kind: 'value', value: unknown}} expectedProbe - * What the probe is expected to record: - * - `{ kind: 'error', name: 'ReferenceError' }` for cells where the - * cross-module namespace read lands during the TDZ window of a - * lexically-bound (`const` or `let`) declaration that has not yet - * been initialized. - * - `{ kind: 'value', value: }` for cells where the read - * either follows depth-first cycle resolution of the renamer's body - * (the star-first cells, expected `42`) or observes the hoisting - * preamble's pre-initialization of a `var` binding (renamer-first - * plus `var`, expected `undefined`). - */ - -/** @type {ReadonlyArray} */ -export const SCENARIOS = Object.freeze([ - // Renamer imported first: depth-first traversal evaluates the - // star-reexporter's body while the renamer is on the evaluation stack - // with its binding `y` not yet initialized. Under ECMA-262, `const` and - // `let` raise ReferenceError on read; `var` reads undefined (the - // hoisting preamble clears the upstream TDZ before the downstream - // observes). - Object.freeze({ - name: 'star const renamer-first', - fixture: 'fixtures-cycle-rename-tdz-const-renamer-first', - reexportForm: 'star', - binding: 'const', - order: 'renamer-first', - expectedProbe: Object.freeze({ - kind: 'error', - name: 'ReferenceError', - }), - }), - Object.freeze({ - name: 'star let renamer-first', - fixture: 'fixtures-cycle-rename-tdz-let-renamer-first', - reexportForm: 'star', - binding: 'let', - order: 'renamer-first', - expectedProbe: Object.freeze({ - kind: 'error', - name: 'ReferenceError', - }), - }), - Object.freeze({ - name: 'star var renamer-first', - fixture: 'fixtures-cycle-rename-tdz-var-renamer-first', - reexportForm: 'star', - binding: 'var', - order: 'renamer-first', - expectedProbe: Object.freeze({ - kind: 'value', - value: undefined, - }), - }), - // Star-reexporter imported first: depth-first cycle resolution - // evaluates the renamer's body to completion before the star-reexporter - // body runs, so the probe captures the assigned value for every binding - // form. The "star reexporter imported first" cases therefore have no - // TDZ window to observe; they are the expected non-observation that - // completes the matrix. - Object.freeze({ - name: 'star const star-first', - fixture: 'fixtures-cycle-rename-tdz-const-star-first', - reexportForm: 'star', - binding: 'const', - order: 'star-first', - expectedProbe: Object.freeze({ - kind: 'value', - value: 42, - }), - }), - Object.freeze({ - name: 'star let star-first', - fixture: 'fixtures-cycle-rename-tdz-let-star-first', - reexportForm: 'star', - binding: 'let', - order: 'star-first', - expectedProbe: Object.freeze({ - kind: 'value', - value: 42, - }), - }), - Object.freeze({ - name: 'star var star-first', - fixture: 'fixtures-cycle-rename-tdz-var-star-first', - reexportForm: 'star', - binding: 'var', - order: 'star-first', - expectedProbe: Object.freeze({ - kind: 'value', - value: 42, - }), - }), - // Named-reexport variant with renamer imported first: the cycle is - // reached through `export { y } from` rather than `export *`. The TDZ - // semantics live with the binding, not with the reexport form, so the - // const cell raises ReferenceError just as it does for the - // star-reexport cell. This confirms the gap is not specific to - // `export *`. - Object.freeze({ - name: 'named const renamer-first', - fixture: 'fixtures-cycle-named-reexport-tdz-const-renamer-first', - reexportForm: 'named', - binding: 'const', - order: 'renamer-first', - expectedProbe: Object.freeze({ - kind: 'error', - name: 'ReferenceError', - }), - }), -]); - -/** - * @param {ExecutionContext} t - * @param {object} namespace - * @param {object} expectedProbe - */ -export const assertCycleRenameTdz = (t, namespace, expectedProbe) => { - t.deepEqual(namespace.probe, expectedProbe); -}; diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js deleted file mode 100644 index 65fcfb2223..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-tdz-matrix-node-parity.test.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Table-driven Node.js parity test for the TDZ-observation matrix of - * the cyclic star-export and named-reexport scenarios. Each row in the - * `SCENARIOS` table from - * `_cycle-rename-tdz-assertions.js` corresponds to one fixture directory - * under `packages/compartment-mapper/test/` (named by the scenario's - * `fixture` field). This test runs each fixture's `main.js` under plain - * Node.js (no SES, no compartment mapper) and asserts the same probe - * value asserted in the compartment-mapper sibling - * `cycle-rename-tdz-matrix.test.js`. Parity is verified by construction: - * if both tests pass for a scenario, SES enforces the same - * temporal-dead-zone (or hoisting, or cycle-resolution) semantics on the - * cross-module namespace read as Node.js for that cell. See - * `_cycle-rename-tdz-assertions.js` for the matrix's framing. - */ - -import test from 'ava'; -import { - SCENARIOS, - assertCycleRenameTdz, -} from './_cycle-rename-tdz-assertions.js'; - -for (const scenario of SCENARIOS) { - test(`cycle-rename-tdz ${scenario.name} - node parity`, async t => { - t.plan(1); - const namespace = await import( - new URL(`${scenario.fixture}/node_modules/app/main.js`, import.meta.url) - .href - ); - assertCycleRenameTdz(t, namespace, scenario.expectedProbe); - }); -} diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js index b0ae4617d2..be9c665b14 100644 --- a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js @@ -1,16 +1,44 @@ /** - * Table-driven test for the TDZ-observation matrix of the cyclic - * star-export and named-reexport scenarios exercised through the - * compartment-mapper test scaffold. Each row in the - * `SCENARIOS` table from `_cycle-rename-tdz-assertions.js` corresponds to - * one fixture directory under + * TDZ-observation matrix for the cyclic star-export and named-reexport + * scenarios. Each row in the inline `SCENARIOS` table below corresponds to + * one cell of the matrix and to one fixture directory under * `packages/compartment-mapper/test/` (named by the scenario's `fixture` - * field) and to one matrix cell described in that module's preamble. The - * Node.js parity sibling in `cycle-rename-tdz-matrix-node-parity.test.js` - * walks the same table and asserts the same expected probe values against - * plain Node.js; if both layers pass for every scenario, parity is - * verified by construction. See `_cycle-rename-tdz-assertions.js` for the - * matrix's framing and the per-scenario expected-probe rationale. + * field). Each fixture exports a `probe` value captured by the + * star-reexporter (or named-reexporter) during its top-level evaluation: + * the probe reads the renamer's binding `y` through a namespace import + * (`r.y`) inside a try block and records either the value (when the + * binding is already initialized) or the error name (when the read raises + * during the temporal dead zone window). + * + * The matrix axes are: + * + * 1. Which module main.js imports first (the export-renamer or the + * star-reexporter or the named-reexporter). The first-imported module + * starts evaluating first; depth-first traversal of the cycle then + * determines which module's body runs while the other is on the + * evaluation stack with bindings not yet initialized. + * 2. The renamer's binding form for `y` (`const`, `let`, or `var`). Under + * ECMA-262 semantics, `const` and `let` create a binding that is in + * the temporal dead zone until its declaration is evaluated, so a + * read raises ReferenceError; `var` is hoisted and reads `undefined` + * until the assignment runs. + * 3. Whether the upstream side of the cycle is reached through `export *` + * (the six star-reexport cells) or through `export { y } from` (the + * single named-reexport cell). + * + * Each scenario is exercised twice in this module, back-to-back: once + * through the compartment-mapper scaffold (the SES treatment) and once + * through plain Node.js (the parity treatment). Both treatments assert the + * same expected probe value, declared once per row in `expectedProbe`. The + * paired registration makes the shared coverage of each fixture legible at + * a glance: a fixture appears in this module exactly twice, with the same + * expected probe, and SES is confirmed to enforce the same TDZ on the + * cross-module namespace path that Node.js enforces natively. + * + * The companion in-process scenarios live in + * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells + * starting at "cyclic star export with renaming reexport, renamer imported + * first, const binding observes ReferenceError during temporal dead zone". */ /** @import {ExecutionContext} from 'ava' */ @@ -18,13 +46,154 @@ import 'ses'; import test from 'ava'; import { scaffold } from './scaffold.js'; -import { - SCENARIOS, - assertCycleRenameTdz, -} from './_cycle-rename-tdz-assertions.js'; + +/** + * @typedef {object} CycleRenameTdzScenario + * @property {string} name + * Short identifier used in test titles and the fixture directory's + * final path component. Each name is unique across the matrix. + * @property {string} fixture + * The fixture directory name under + * `packages/compartment-mapper/test/`. Its `node_modules/app/main.js` + * is the import target for both the SES treatment (through the + * compartment-mapper scaffold) and the Node.js parity treatment. + * @property {'star'|'named'} reexportForm + * Whether the upstream side of the cycle is reached through + * `export *` (`star`) or through `export { y } from` (`named`). + * @property {'const'|'let'|'var'} binding + * The renamer's binding form for `y`. `const` and `let` participate in + * the temporal dead zone; `var` is hoisted. + * @property {'renamer-first'|'star-first'} order + * Which module main.js imports first. The first-imported module starts + * evaluating first; the second is the one that observes the cycle + * partner mid-evaluation. + * @property {{kind: 'error', name: string}|{kind: 'value', value: unknown}} expectedProbe + * What the probe is expected to record: + * - `{ kind: 'error', name: 'ReferenceError' }` for cells where the + * cross-module namespace read lands during the TDZ window of a + * lexically-bound (`const` or `let`) declaration that has not yet + * been initialized. + * - `{ kind: 'value', value: }` for cells where the read + * either follows depth-first cycle resolution of the renamer's body + * (the star-first cells, expected `42`) or observes the hoisting + * preamble's pre-initialization of a `var` binding (renamer-first + * plus `var`, expected `undefined`). + */ + +/** @type {ReadonlyArray} */ +const SCENARIOS = Object.freeze([ + // Renamer imported first: depth-first traversal evaluates the + // star-reexporter's body while the renamer is on the evaluation stack + // with its binding `y` not yet initialized. Under ECMA-262, `const` and + // `let` raise ReferenceError on read; `var` reads undefined (the + // hoisting preamble clears the upstream TDZ before the downstream + // observes). + Object.freeze({ + name: 'star const renamer-first', + fixture: 'fixtures-cycle-rename-tdz-const-renamer-first', + reexportForm: 'star', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star let renamer-first', + fixture: 'fixtures-cycle-rename-tdz-let-renamer-first', + reexportForm: 'star', + binding: 'let', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star var renamer-first', + fixture: 'fixtures-cycle-rename-tdz-var-renamer-first', + reexportForm: 'star', + binding: 'var', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: undefined, + }), + }), + // Star-reexporter imported first: depth-first cycle resolution + // evaluates the renamer's body to completion before the star-reexporter + // body runs, so the probe captures the assigned value for every binding + // form. The "star reexporter imported first" cases therefore have no + // TDZ window to observe; they are the expected non-observation that + // completes the matrix. + Object.freeze({ + name: 'star const star-first', + fixture: 'fixtures-cycle-rename-tdz-const-star-first', + reexportForm: 'star', + binding: 'const', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star let star-first', + fixture: 'fixtures-cycle-rename-tdz-let-star-first', + reexportForm: 'star', + binding: 'let', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star var star-first', + fixture: 'fixtures-cycle-rename-tdz-var-star-first', + reexportForm: 'star', + binding: 'var', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + // Named-reexport variant with renamer imported first: the cycle is + // reached through `export { y } from` rather than `export *`. The TDZ + // semantics live with the binding, not with the reexport form, so the + // const cell raises ReferenceError just as it does for the + // star-reexport cell. This confirms the gap is not specific to + // `export *`. + Object.freeze({ + name: 'named const renamer-first', + fixture: 'fixtures-cycle-named-reexport-tdz-const-renamer-first', + reexportForm: 'named', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), +]); const fixtureAssertionCount = 1; +/** + * @param {ExecutionContext} t + * @param {object} namespace + * @param {CycleRenameTdzScenario['expectedProbe']} expectedProbe + */ +const assertCycleRenameTdz = (t, namespace, expectedProbe) => { + t.deepEqual(namespace.probe, expectedProbe); +}; + +// Register one SES test (through the compartment-mapper scaffold) and one +// Node.js parity test for each scenario, back-to-back, so the shared +// coverage of each fixture is legible at a glance. Both treatments target +// the same `main.js` and assert the same `expectedProbe`. for (const scenario of SCENARIOS) { const fixture = new URL( `${scenario.fixture}/node_modules/app/main.js`, @@ -39,11 +208,24 @@ for (const scenario of SCENARIOS) { assertCycleRenameTdz(t, namespace, scenario.expectedProbe); }; + // SES treatment: load through the compartment-mapper scaffold, which + // exercises loadLocation, importLocation, and the archive paths. scaffold( - `cycle-rename-tdz ${scenario.name}`, + `cycle-rename-tdz ${scenario.name} (ses)`, test, fixture, assertFixture, fixtureAssertionCount, ); + + // Node.js parity treatment: import the same `main.js` directly under + // plain Node.js (no SES, no compartment mapper) and assert the same + // probe. If both treatments pass, SES enforces the same + // temporal-dead-zone (or hoisting, or cycle-resolution) semantics on + // the cross-module namespace read as Node.js for this cell. + test(`cycle-rename-tdz ${scenario.name} (node parity)`, async t => { + t.plan(1); + const namespace = await import(fixture); + assertCycleRenameTdz(t, namespace, scenario.expectedProbe); + }); } diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 5b84a4ec06..511ec4f911 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -378,14 +378,13 @@ test('cyclic star export with renaming reexport, unused live binding', async t = // upstream's TDZ before the downstream observes. // // Each cell is also exercised through the compartment-mapper scaffold and -// pinned to Node.js's reference behavior with a shared assertion module. +// pinned to Node.js's reference behavior side-by-side in the same module. // The six star-reexport cells and the named-reexport cell are enumerated // in a single SCENARIOS table in -// packages/compartment-mapper/test/_cycle-rename-tdz-assertions.js and -// walked by the table-driven test pair -// cycle-rename-tdz-matrix.test.js (SES under the compartment-mapper -// scaffold) and cycle-rename-tdz-matrix-node-parity.test.js (plain -// Node.js). Each scenario's `fixture` field names its directory under +// packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js, which +// registers a SES treatment (through the compartment-mapper scaffold) and +// a Node.js parity treatment back-to-back for each scenario. Each row's +// `fixture` field names its directory under // packages/compartment-mapper/test/fixtures-cycle-{rename,named-reexport}-tdz-*/. test('cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { From 586be4c2c6a7ed37755ed37a35cd9171d4135cc8 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Mon, 15 Jun 2026 06:24:51 +0000 Subject: [PATCH 20/24] docs(ses): add @module to notifier-with-resolver; link via {@link} (#379) Per boneskull's review on endojs/endo#3276 (review 4489675443): - Add a `@module` docstring to `packages/ses/src/notifier-with-resolver.js`. - Drop the brittle prose backreference to `module-instance.js` `wireUpExportNotifier` from the helper's JSDoc, and instead reference `{@link makeNotifierWithResolver}` from `wireUpExportNotifier`'s own inline narrative, so the cross-module link is owned by the consumer rather than the producer. --- packages/ses/src/module-instance.js | 7 ++++--- packages/ses/src/notifier-with-resolver.js | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/ses/src/module-instance.js b/packages/ses/src/module-instance.js index f9455f63e4..a5d9c9630f 100644 --- a/packages/ses/src/module-instance.js +++ b/packages/ses/src/module-instance.js @@ -395,9 +395,10 @@ export const makeModuleInstance = ( // that name may be wired only later, in its own candidate-all walk, // after this module's `imports()` returns. Install a notifier that // queues subscribers until the upstream resolves, then forwards them - // through. `makeNotifierWithResolver` is the synchronous variant of - // `Promise.withResolvers` that captures this pattern; each `notify` - // call lazily attempts to resolve against the upstream's notifiers. + // through. {@link makeNotifierWithResolver} is the synchronous + // variant of `Promise.withResolvers` that captures this pattern; each + // `notify` call lazily attempts to resolve against the upstream's + // notifiers. const { notify: queueOrForward, resolve: resolveUpstream } = makeNotifierWithResolver(); notify = update => { diff --git a/packages/ses/src/notifier-with-resolver.js b/packages/ses/src/notifier-with-resolver.js index d548fca9f1..b2437daafa 100644 --- a/packages/ses/src/notifier-with-resolver.js +++ b/packages/ses/src/notifier-with-resolver.js @@ -1,3 +1,11 @@ +/** + * @module Synchronous variant of `Promise.withResolvers` for the + * star-export notifier wiring used by `module-instance.js`. Provides a + * `notify` / `resolve` pair where subscribers attached before the + * resolver is settled are queued and replayed once a target notifier is + * supplied. + */ + import { arrayPush } from './commons.js'; /** @@ -16,12 +24,6 @@ import { arrayPush } from './commons.js'; * target lazily may safely call `resolve` again on each `notify`; only the * first call has effect. * - * Used by `module-instance.js` `wireUpExportNotifier` to resolve the - * star-export-cycle case (endojs/endo#59): a re-export may be wired before - * the upstream module has exposed its notifier for the imported name, and - * the upstream notifier becomes available only after a second pass of - * candidate-all wiring elsewhere in the graph. - * * @returns {{ * notify: (update: (value: any) => void) => void, * resolve: (targetNotify: (update: (value: any) => void) => void) => void, From a71b4d9734220fe0f74c0c97e61b7570c9e3a943 Mon Sep 17 00:00:00 2001 From: endolinbot Date: Mon, 15 Jun 2026 06:25:13 +0000 Subject: [PATCH 21/24] refactor(ses): strict equality and else over early return in notifier-with-resolver (#379) Per boneskull's style suggestion on endojs/endo#3276 (review 4489675443): restructure `notify` and `resolve` in `packages/ses/src/notifier-with-resolver.js` so the conditional uses strict equality (`resolvedTargetNotify === undefined`) and an `else` branch rather than an `!== undefined` test with an early return. Same observable behavior; reads as one decision per call rather than two. --- packages/ses/src/notifier-with-resolver.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/ses/src/notifier-with-resolver.js b/packages/ses/src/notifier-with-resolver.js index b2437daafa..dd5acb755c 100644 --- a/packages/ses/src/notifier-with-resolver.js +++ b/packages/ses/src/notifier-with-resolver.js @@ -36,22 +36,21 @@ export const makeNotifierWithResolver = () => { let resolvedTargetNotify; const notify = update => { - if (resolvedTargetNotify !== undefined) { + if (resolvedTargetNotify === undefined) { + arrayPush(pendingUpdaters, update); + } else { resolvedTargetNotify(update); - return; } - arrayPush(pendingUpdaters, update); }; const resolve = targetNotify => { - if (resolvedTargetNotify !== undefined) { - return; + if (resolvedTargetNotify === undefined) { + resolvedTargetNotify = targetNotify; + for (const pending of pendingUpdaters) { + targetNotify(pending); + } + pendingUpdaters.length = 0; } - resolvedTargetNotify = targetNotify; - for (const pending of pendingUpdaters) { - targetNotify(pending); - } - pendingUpdaters.length = 0; }; return { notify, resolve }; From 814dbaac10e2317c65b7e140a686c6d76aab7e4e Mon Sep 17 00:00:00 2001 From: endolinbot Date: Mon, 15 Jun 2026 06:25:27 +0000 Subject: [PATCH 22/24] test(ses): TODO comment linking the CjsModuleSource mock to endojs/endo#3220 (#379) Per boneskull's note on endojs/endo#3276 (review 4489675443): the local `CjsModuleSource` helper in `packages/ses/test/import-cjs.test.js` is a heuristic-regex stand-in for the AST-based parser that endojs/endo#3220 will expose from `@endo/module-source`. Add a TODO at the mock's definition so the swap is discoverable from a grep when #3220 lands. (#3220 is still open; addressing the note-to-self in place rather than swapping the implementation.) --- packages/ses/test/import-cjs.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ses/test/import-cjs.test.js b/packages/ses/test/import-cjs.test.js index 8bcf902ea2..8c88f39ef0 100644 --- a/packages/ses/test/import-cjs.test.js +++ b/packages/ses/test/import-cjs.test.js @@ -26,6 +26,10 @@ function heuristicAnalysis(moduleSource) { }; } +// TODO(endojs/endo#3220): replace this in-test mock with the AST-based +// `CjsModuleSource` that endojs/endo#3220 will export from +// `@endo/module-source`. Until that lands, this heuristic-regex mock is the +// only synchronous CommonJS module source available to the SES test suite. const CjsModuleSource = (moduleSource, moduleLocation) => { if (typeof moduleSource !== 'string') { throw TypeError( From 9fbaaf7af90c91e813830b55cc63d1520b4a4eea Mon Sep 17 00:00:00 2001 From: endolinbot Date: Mon, 15 Jun 2026 07:08:18 +0000 Subject: [PATCH 23/24] test(compartment-mapper): merge remaining SES+Node parity tests into single modules per kriskowal review Extend the consolidation pattern from cycle-rename-tdz-matrix.test.js (commit ca17e11e4) to the five remaining parity-test pairs in packages/compartment-mapper/test/. Each pair now lives as one module that registers a (ses) treatment through the compartment-mapper scaffold and a (node parity) treatment through plain Node.js (dynamic import or spawnSync) back-to-back. The paired registration makes the shared fixture coverage legible at a glance: a fixture appears in the module once for the SES treatment and once for the Node.js parity treatment, asserting the same expected values through the shared assertion module. Pairs consolidated (parity file merged into sibling and deleted): - cycle-cjs-reexporter-node-parity.test.js -> cycle-cjs-reexporter.test.js - cycle-esm-in-cjs-node-parity.test.js -> cycle-esm-in-cjs.test.js - cycle-rename-node-parity.test.js -> cycle-rename.test.js - cycle-rename-unused-node-parity.test.js -> cycle-rename-unused.test.js - subpath-patterns-node-parity.test.js -> subpath-patterns.test.js For subpath-patterns, the two sides do not pair test-for-test (SES has archive-shape and policy tests with no Node analog; Node has multi-star / globstar exclusions with no SES analog). Scenarios with a direct counterpart on both sides are registered back-to-back as " (ses)" and "<title> (node parity)"; scenarios on only one side are registered once. For cycle-esm-in-cjs, the Node parity treatment spawns a Node process to assert ERR_REQUIRE_CYCLE_MODULE rather than dynamic-importing the fixture, so the test runner's own module graph is not exposed to the rejected topology. The SES treatment loads the same fixture through the scaffold and asserts the divergent SES-side success behavior. Drop bare "(issue #59 ...)" markers from test titles touched by this consolidation per pre-push-gates (no-pull-citations probe); the qualified "endojs/endo#59" references in docstrings are preserved. Update prose references in packages/ses/test/import-cjs.test.js, packages/ses/test/import-gauntlet.test.js, and packages/compartment-mapper/designs/subpath-pattern-replacement.md that pointed at the prior split-file shape to point at the consolidated modules instead. Per kriskowal review on PR endojs/endo-but-for-bots#379 (2026-06-15T06:35:31Z, inline on cycle-esm-in-cjs-node-parity.test.js:1): "I would, for example, like this module's test to be moved into cycle-esm-in-cjs.test.js so that it is evident at a glance that the same test passes both Node.js parity and with Endo. I would like this principled generally to the other new parity tests." --- .../designs/subpath-pattern-replacement.md | 12 +- .../cycle-cjs-reexporter-node-parity.test.js | 25 -- .../test/cycle-cjs-reexporter.test.js | 31 ++- .../test/cycle-esm-in-cjs-node-parity.test.js | 40 --- .../test/cycle-esm-in-cjs.test.js | 51 +++- .../test/cycle-rename-node-parity.test.js | 20 -- .../cycle-rename-unused-node-parity.test.js | 22 -- .../test/cycle-rename-unused.test.js | 25 +- .../test/cycle-rename.test.js | 25 +- .../test/subpath-patterns-node-parity.test.js | 172 ------------- .../test/subpath-patterns.test.js | 228 ++++++++++++++++-- packages/ses/test/import-cjs.test.js | 14 +- packages/ses/test/import-gauntlet.test.js | 10 +- 13 files changed, 331 insertions(+), 344 deletions(-) delete mode 100644 packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js delete mode 100644 packages/compartment-mapper/test/subpath-patterns-node-parity.test.js diff --git a/packages/compartment-mapper/designs/subpath-pattern-replacement.md b/packages/compartment-mapper/designs/subpath-pattern-replacement.md index a0711b5d76..11d6815e76 100644 --- a/packages/compartment-mapper/designs/subpath-pattern-replacement.md +++ b/packages/compartment-mapper/designs/subpath-pattern-replacement.md @@ -209,14 +209,16 @@ Assertions are shared via `_subpath-patterns-assertions.js`, so parity is verified by construction: if both test suites pass, the behaviors are equivalent. -- `subpath-patterns-node-parity.test.js` runs fixtures under plain - Node.js using dynamic `import()`. +- `subpath-patterns.test.js` runs fixtures both through the `scaffold()` + harness (the SES treatment, exercising `loadLocation`, `importLocation`, + `makeArchive`, `parseArchive`, `writeArchive`, `loadArchive`, and + `importArchive`) and under plain Node.js via dynamic `import()` (the + Node.js parity treatment). + Both treatments are registered in the same module, back-to-back per + scenario where the scenario has both sides. - `subpath-patterns-node-condition.node-condition.test.js` runs under `--conditions=blue-moon` via ses-ava (`nodeArguments: ['-C', 'blue-moon']` in `_ava-node-condition.config.js`). -- `subpath-patterns.test.js` runs fixtures through the `scaffold()` - harness, exercising `loadLocation`, `importLocation`, `makeArchive`, - `parseArchive`, `writeArchive`, `loadArchive`, and `importArchive`. ### Unit Tests (`pattern-replacement.test.js`) diff --git a/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js b/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js deleted file mode 100644 index 88881fcf5d..0000000000 --- a/packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Node.js parity test for the cyclic CommonJS reexporter scenario. This - * test runs the same three-module pure-CommonJS fixture under plain Node.js - * (no SES, no compartment mapper) and asserts the same expected values - * asserted in cycle-cjs-reexporter.test.js. Parity is verified by - * construction: if both tests pass, the compartment mapper's CommonJS - * cycle behavior matches Node.js for this case. - */ - -import test from 'ava'; -import { assertCycleCjsReexporter } from './_cycle-cjs-reexporter-assertions.js'; - -test('cyclic CommonJS reexporter - node parity', async t => { - t.plan(3); - // Dynamic ESM import of a CommonJS module: Node exposes the module's - // module.exports as the namespace's default export. Re-use the shared - // assertion module by projecting through `default`. - const moduleNamespace = await import( - new URL( - 'fixtures-cycle-cjs-reexporter/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleCjsReexporter(t, moduleNamespace.default); -}); diff --git a/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js index 215aaec789..ae3d37b422 100644 --- a/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js +++ b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js @@ -1,14 +1,14 @@ /** - * Cyclic CommonJS reexporter scenario exercised through the - * compartment-mapper test scaffold. The companion Node.js parity test in - * cycle-cjs-reexporter-node-parity.test.js imports the same fixture under - * Node.js and asserts the same expected values; together the two tests - * teach the compartment mapper's CommonJS cycle behavior and pin it to - * Node.js's reference behavior. + * Cyclic CommonJS reexporter scenario exercised twice in this module, + * back-to-back: once through the compartment-mapper test scaffold (the SES + * treatment) and once through plain Node.js (the parity treatment). Both + * treatments target the same fixture and assert the same expected values + * through the shared assertion module. The paired registration makes the + * shared coverage legible at a glance and pins the compartment mapper's + * CommonJS cycle behavior to Node.js's reference behavior. * * This is the pure-CommonJS counterpart to the ESM-in-CJS-cycle divergence - * exercised by cycle-esm-in-cjs.test.js and - * cycle-esm-in-cjs-node-parity.test.js, where Node.js rejects the topology + * exercised by cycle-esm-in-cjs.test.js, where Node.js rejects the topology * with ERR_REQUIRE_CYCLE_MODULE but SES allows it. */ @@ -34,10 +34,23 @@ const assertFixture = (t, { namespace }) => { assertCycleCjsReexporter(t, namespace); }; +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. scaffold( - 'cycle-cjs-reexporter (issue #59 follow-up)', + 'cycle-cjs-reexporter (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. Node exposes a CommonJS module's `module.exports` as the +// namespace's default export, so the shared assertion module is reused by +// projecting through `default`. +test('cycle-cjs-reexporter (node parity)', async t => { + t.plan(3); + const moduleNamespace = await import(fixture); + assertCycleCjsReexporter(t, moduleNamespace.default); +}); diff --git a/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js b/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js deleted file mode 100644 index 034fc5ce80..0000000000 --- a/packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Node.js parity test for the ESM-in-CommonJS-cycle divergence scenario. - * This test runs the same fixture under plain Node.js (no SES, no - * compartment mapper) and asserts that Node.js rejects the topology with - * ERR_REQUIRE_CYCLE_MODULE. The companion compartment-mapper / SES test - * in cycle-esm-in-cjs.test.js asserts the divergent behavior: SES allows - * the same fixture to load and exposes the cycle's snapshot / live-binding - * shape on the namespace. Together the two tests verify the divergence - * programmatically rather than narratively. - */ - -import test from 'ava'; -import process from 'process'; -import { spawnSync } from 'child_process'; -import { fileURLToPath } from 'url'; - -test('ESM-in-CJS-cycle - node parity (rejects with ERR_REQUIRE_CYCLE_MODULE)', t => { - t.plan(2); - const fixture = new URL( - 'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs', - import.meta.url, - ); - // Spawn a fresh Node process to execute the fixture. The expected outcome - // is a non-zero exit with the ERR_REQUIRE_CYCLE_MODULE error code printed - // on stderr. Spawning isolates the failure from the test runner's own - // module graph and keeps the rest of the suite running. - const result = spawnSync(process.execPath, [fileURLToPath(fixture)], { - encoding: 'utf8', - }); - t.not( - result.status, - 0, - `Expected Node to reject ESM-in-CJS-cycle, got exit ${result.status}`, - ); - t.regex( - result.stderr, - /ERR_REQUIRE_CYCLE_MODULE/, - `Expected ERR_REQUIRE_CYCLE_MODULE in stderr, got:\n${result.stderr}`, - ); -}); diff --git a/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js index af2ef0c259..8b494400ef 100644 --- a/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js +++ b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js @@ -1,12 +1,11 @@ /** - * Cyclic ESM-in-CommonJS divergence scenario exercised through the - * compartment-mapper test scaffold. SES allows the topology that Node.js - * rejects with ERR_REQUIRE_CYCLE_MODULE; this test pins SES's actual - * behavior so the divergence is verified programmatically rather than - * documented narratively. The companion Node.js parity test in - * cycle-esm-in-cjs-node-parity.test.js verifies the Node.js side of the - * divergence by spawning Node on the same fixture and asserting the error - * code. + * Cyclic ESM-in-CommonJS divergence scenario exercised twice in this module, + * back-to-back: once through the compartment-mapper test scaffold (the SES + * treatment, where the topology loads and `main.bridgeValue` resolves to 42) + * and once through plain Node.js (the parity treatment, where Node rejects + * the topology with ERR_REQUIRE_CYCLE_MODULE). The paired registration + * verifies the divergence programmatically rather than narratively: SES + * allows the topology that Node rejects. * * Topology (under fixtures-cycle-esm-in-cjs/node_modules/app/): * @@ -28,12 +27,16 @@ import 'ses'; import test from 'ava'; +import process from 'process'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; import { scaffold } from './scaffold.js'; -const fixture = new URL( +const fixtureUrl = new URL( 'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs', import.meta.url, -).toString(); +); +const fixture = fixtureUrl.toString(); const fixtureAssertionCount = 1; @@ -45,10 +48,36 @@ const assertFixture = (t, { namespace }) => { t.is(namespace.bridgeValue, 42); }; +// SES treatment: load through the compartment-mapper scaffold. SES allows +// the topology Node rejects and exposes the cycle's snapshot / live-binding +// shape on the namespace. scaffold( - 'cycle-esm-in-cjs (issue #59 follow-up: divergence)', + 'cycle-esm-in-cjs divergence (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); + +// Node.js parity treatment: spawn a fresh Node process to execute the same +// fixture. The expected outcome is a non-zero exit with +// ERR_REQUIRE_CYCLE_MODULE printed on stderr. Spawning isolates the failure +// from the test runner's own module graph and keeps the rest of the suite +// running. Together with the SES treatment above, this pins the divergence +// programmatically: SES allows what Node rejects. +test('cycle-esm-in-cjs divergence (node parity)', t => { + t.plan(2); + const result = spawnSync(process.execPath, [fileURLToPath(fixtureUrl)], { + encoding: 'utf8', + }); + t.not( + result.status, + 0, + `Expected Node to reject ESM-in-CJS-cycle, got exit ${result.status}`, + ); + t.regex( + result.stderr, + /ERR_REQUIRE_CYCLE_MODULE/, + `Expected ERR_REQUIRE_CYCLE_MODULE in stderr, got:\n${result.stderr}`, + ); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-node-parity.test.js deleted file mode 100644 index 3c1916a81b..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-node-parity.test.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Node.js parity test for the cyclic star-export with renaming reexport - * regression (endojs/endo#59). This test runs the same three-module fixture - * under plain Node.js (no SES, no compartment mapper) and asserts the same - * expected values asserted in cycle-rename.test.js. Parity is verified by - * construction: if both tests pass, the compartment mapper's linker - * behavior matches Node.js for this case. - */ - -import test from 'ava'; -import { assertCycleRename } from './_cycle-rename-assertions.js'; - -test('cyclic star export with renaming reexport (issue #59) - node parity', async t => { - t.plan(3); - const namespace = await import( - new URL('fixtures-cycle-rename/node_modules/app/main.js', import.meta.url) - .href - ); - assertCycleRename(t, namespace); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js b/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js deleted file mode 100644 index 418c3591de..0000000000 --- a/packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Node.js parity test for the unused-live-binding shape of the cyclic - * star-export regression (endojs/endo#59). This test runs the same fixture - * under plain Node.js (no SES, no compartment mapper) and asserts the same - * expected values asserted in cycle-rename-unused.test.js. Parity is - * verified by construction: if both tests pass, the compartment mapper's - * linker behavior matches Node.js for this case. - */ - -import test from 'ava'; -import { assertCycleRenameUnused } from './_cycle-rename-unused-assertions.js'; - -test('cyclic star export with renaming reexport, unused live binding (issue #59) - node parity', async t => { - t.plan(3); - const namespace = await import( - new URL( - 'fixtures-cycle-rename-unused/node_modules/app/main.js', - import.meta.url, - ).href - ); - assertCycleRenameUnused(t, namespace); -}); diff --git a/packages/compartment-mapper/test/cycle-rename-unused.test.js b/packages/compartment-mapper/test/cycle-rename-unused.test.js index b7871d4dcf..aefe64652d 100644 --- a/packages/compartment-mapper/test/cycle-rename-unused.test.js +++ b/packages/compartment-mapper/test/cycle-rename-unused.test.js @@ -2,11 +2,13 @@ * Companion to cycle-rename.test.js covering the unused-live-binding shape * of the cyclic star-export regression (endojs/endo#59). The renamer's * `export var y` has no initializer; every projection of the cycle reads - * `undefined`. Exercised through the compartment-mapper test scaffold; the - * Node.js parity sibling in cycle-rename-unused-node-parity.test.js asserts - * the same expected values against plain Node.js. Together the two tests - * pin the compartment mapper's behavior for this shape to Node.js's - * reference behavior. + * `undefined`. Exercised twice in this module, back-to-back: once through + * the compartment-mapper test scaffold (the SES treatment) and once through + * plain Node.js (the parity treatment). Both treatments target the same + * fixture and assert the same expected values through the shared assertion + * module. The paired registration makes the shared coverage legible at a + * glance and pins the compartment mapper's behavior for this shape to + * Node.js's reference behavior. */ /** @import {ExecutionContext} from 'ava' */ @@ -31,10 +33,21 @@ const assertFixture = (t, { namespace }) => { assertCycleRenameUnused(t, namespace); }; +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. scaffold( - 'cycle-rename-unused (issue #59: unused live binding)', + 'cycle-rename-unused unused live binding (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. +test('cycle-rename-unused unused live binding (node parity)', async t => { + t.plan(3); + const namespace = await import(fixture); + assertCycleRenameUnused(t, namespace); +}); diff --git a/packages/compartment-mapper/test/cycle-rename.test.js b/packages/compartment-mapper/test/cycle-rename.test.js index 5f20b4722e..40a445b073 100644 --- a/packages/compartment-mapper/test/cycle-rename.test.js +++ b/packages/compartment-mapper/test/cycle-rename.test.js @@ -1,10 +1,12 @@ /** * Regression for endojs/endo#59 (cyclic star export with renaming reexport) - * exercised through the compartment-mapper test scaffold. The companion - * Node.js parity test in cycle-rename-node-parity.test.js imports the same - * fixture under Node.js and asserts the same expected values; together the - * two tests tease the linker behavior out of SES and pin it to Node.js's - * reference behavior. + * exercised twice in this module, back-to-back: once through the + * compartment-mapper test scaffold (the SES treatment) and once through + * plain Node.js (the parity treatment). Both treatments target the same + * three-module fixture and assert the same expected values through the + * shared assertion module. The paired registration makes the shared + * coverage legible at a glance and pins the compartment mapper's linker + * behavior to Node.js's reference behavior. */ /** @import {ExecutionContext} from 'ava' */ @@ -29,10 +31,21 @@ const assertFixture = (t, { namespace }) => { assertCycleRename(t, namespace); }; +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. scaffold( - 'cycle-rename (issue #59)', + 'cycle-rename (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. +test('cycle-rename (node parity)', async t => { + t.plan(3); + const namespace = await import(fixture); + assertCycleRename(t, namespace); +}); diff --git a/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js b/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js deleted file mode 100644 index fbc03d3990..0000000000 --- a/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Node.js parity test for subpath pattern replacement. - * - * This test runs the fixtures under plain Node.js to verify they are valid - * Node.js packages. The same expected values are asserted in the Compartment - * Mapper test (subpath-patterns.test.js), so parity is verified by - * construction: if both tests pass, the behaviors are equivalent. - */ -import test from 'ava'; -import { - assertMain, - assertConditionalDefault, - assertPrecedence, - assertImportsEdgeCasesDefault, -} from './_subpath-patterns-assertions.js'; - -const fixtureBase = new URL( - 'fixtures-package-imports-exports/node_modules/app/', - import.meta.url, -); - -test('subpath patterns - node parity', async t => { - const ns = await import(new URL('main.js', fixtureBase).href); - assertMain(t, ns); -}); - -test('null-target patterns are excluded by Node.js', async t => { - // The file exists on disk but the null-target export prevents resolution. - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', - import.meta.url, - ).href - ), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('conditional patterns - default condition in Node.js', async t => { - // Without --conditions=blue-moon, "default" is selected. - const ns = await import(new URL('conditional-import.js', fixtureBase).href); - assertConditionalDefault(t, ns); -}); - -test('multi-star patterns are not resolved by Node.js', async t => { - // Node.js restricts subpath patterns to exactly one `*` per side. - // Entries with multiple `*` are silently ignored (never match). - // This test will fail if Node.js begins to support multi-star patterns, - // signaling that we should revisit our implementation. - const fixtureDir = new URL( - 'fixtures-package-imports-exports/node_modules/', - import.meta.url, - ); - // The main export (no wildcards) should still work. - const main = await import( - new URL('multi-star-lib/src/main.js', fixtureDir).href - ); - t.is(main.main, 'main'); - - // The multi-star subpath pattern should NOT resolve. - await t.throwsAsync( - () => import(new URL('app/multi-star-import.js', fixtureDir).href), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('imports edge cases - node parity', async t => { - // Non-wildcard alias (#helper) and conditional import (#cond under default - // conditions) resolve correctly under Node.js. - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', - import.meta.url, - ).href - ); - assertImportsEdgeCasesDefault(t, ns); -}); - -test('array imports field is silently ignored by Node.js', async t => { - // Node.js silently ignores an invalid array `imports` field and resolves - // the package via `exports` instead. The compartment-mapper is stricter - // and throws. This test documents the Node.js behavior. - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', - import.meta.url, - ).href - ); - t.is(ns.value, 'should not reach here'); -}); - -test('exports edge cases - node parity', async t => { - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', - import.meta.url, - ).href - ); - t.is(ns.main, 'exports-edge-cases-main'); - t.is(ns.nested, 'nested-esm'); -}); - -test('non-object exports field is rejected by Node.js', async t => { - // Node.js rejects the invalid numeric exports field. The error code varies - // by version: ERR_PACKAGE_PATH_NOT_EXPORTED on 18/20, ERR_MODULE_NOT_FOUND - // on 22+. We just verify it throws. - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', - import.meta.url, - ).href - ), - ); -}); - -test('globstar patterns are not resolved by Node.js', async t => { - // Node.js does not support globstar (**) in subpath patterns. - // Entries with ** are silently ignored (never match). - // This test will fail if Node.js begins to support globstar patterns, - // signaling that we should revisit our implementation. - const fixtureDir = new URL( - 'fixtures-package-imports-exports/node_modules/', - import.meta.url, - ); - // The main export (no wildcards) should still work. - const main = await import( - new URL('globstar-lib/src/main.js', fixtureDir).href - ); - t.is(main.main, 'main'); - - // The globstar subpath pattern should NOT resolve. - await t.throwsAsync( - () => import(new URL('app/globstar-import.js', fixtureDir).href), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('absolute path in subpath pattern is rejected by Node.js', async t => { - // A package whose exports map "./smuggle/*.js" to "/etc/*.js" should not - // allow importing absolute paths. Node.js rejects this because the - // resolved target does not start with "./". - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', - import.meta.url, - ).href - ), - { - code: 'ERR_INVALID_PACKAGE_TARGET', - }, - ); -}); - -test('Node prefers the longer full pattern key on equal prefix length', async t => { - // This exercises Node's pattern key ordering with overlapping keys: - // "./tie/*" and "./tie/*.js". Node resolves "patterns-lib/tie/bar.js" - // through "./tie/*.js", not the broader "./tie/*" entry. - const ns = await import(new URL('precedence-import.js', fixtureBase).href); - assertPrecedence(t, ns); -}); diff --git a/packages/compartment-mapper/test/subpath-patterns.test.js b/packages/compartment-mapper/test/subpath-patterns.test.js index 6fb5c760ae..cf24d4de6a 100644 --- a/packages/compartment-mapper/test/subpath-patterns.test.js +++ b/packages/compartment-mapper/test/subpath-patterns.test.js @@ -1,12 +1,13 @@ /** - * Compartment Mapper test for subpath pattern replacement. - * - * Uses the scaffold harness to exercise the fixture through all execution - * paths (loadLocation, importLocation, makeArchive, parseArchive, etc.). - * - * The expected values match those asserted in node-parity-subpath-patterns.test.js, - * so if both tests pass, the Compartment Mapper has parity with Node.js for - * these cases. + * Subpath pattern replacement scenarios exercised in this module in two + * treatments side-by-side: the SES treatment (through the compartment-mapper + * scaffold and the package's import / archive surfaces) and the Node.js + * parity treatment (through plain Node.js imports). Where a scenario has a + * direct Node-side analog the two tests are registered back-to-back; where + * the scenario only exists on one side (SES-only archive shape checks, + * Node-only multi-star / globstar exclusions) the test is registered once. + * Together the SES and Node.js treatments verify that the compartment mapper + * has parity with Node.js where the fixtures support both. */ /** @import {ExecutionContext} from 'ava' */ @@ -21,6 +22,7 @@ import { assertConditionalDefault, assertPrecedence, assertImportsEdgeCasesDev, + assertImportsEdgeCasesDefault, } from './_subpath-patterns-assertions.js'; const fixture = new URL( @@ -28,6 +30,11 @@ const fixture = new URL( import.meta.url, ).toString(); +const fixtureBase = new URL( + 'fixtures-package-imports-exports/node_modules/app/', + import.meta.url, +); + const fixtureAssertionCount = 1; /** @@ -38,14 +45,22 @@ const assertFixture = (t, { namespace }) => { assertMain(t, namespace); }; +// Main subpath pattern resolution: SES treatment through the scaffold, then +// the Node.js parity treatment importing the same main.js directly. scaffold( - 'subpath-patterns', + 'subpath-patterns (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); +test('subpath-patterns (node parity)', async t => { + const ns = await import(new URL('main.js', fixtureBase).href); + assertMain(t, ns); +}); + +// Archive shape: SES-only. The Node.js side has no archive analog. test('patterns are stripped from archived compartment-map.json', async t => { const archive = await makeArchive(readPowers, fixture, { modules: {}, @@ -68,7 +83,10 @@ test('patterns are stripped from archived compartment-map.json', async t => { } }); -test('conditional pattern resolves under user-specified condition', async t => { +// Conditional patterns: SES exercises the explicit blue-moon condition; the +// Node.js parity sibling exercises the default fall-through (Node has no +// API for user-specified conditions in this scaffold). +test('conditional pattern resolves under user-specified condition (ses)', async t => { const conditionalFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', import.meta.url, @@ -79,7 +97,7 @@ test('conditional pattern resolves under user-specified condition', async t => { assertConditionalBlue(t, namespace); }); -test('conditional pattern falls back to default without user condition', async t => { +test('conditional pattern falls back to default without user condition (ses)', async t => { const conditionalFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', import.meta.url, @@ -88,6 +106,13 @@ test('conditional pattern falls back to default without user condition', async t assertConditionalDefault(t, namespace); }); +test('conditional patterns - default condition (node parity)', async t => { + // Without --conditions=blue-moon, "default" is selected. + const ns = await import(new URL('conditional-import.js', fixtureBase).href); + assertConditionalDefault(t, ns); +}); + +// Policy gating: SES-only. test('policy allows pattern-matched imports when package is permitted', async t => { const policy = { entry: { packages: { 'patterns-lib': true } }, @@ -105,7 +130,9 @@ test('policy rejects pattern-matched imports when package is not permitted', asy await t.throwsAsync(() => importLocation(readPowers, fixture, { policy })); }); -test('array imports field in package.json causes an exception', async t => { +// Array imports field: the compartment-mapper throws; Node.js silently +// ignores and resolves through exports. +test('array imports field in package.json causes an exception (ses)', async t => { const arrayImportsFixture = new URL( 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', import.meta.url, @@ -115,7 +142,24 @@ test('array imports field in package.json causes an exception', async t => { }); }); -test('imports edge cases: non-wildcard alias, conditional, null, invalid key, bad value, mismatched wildcard', async t => { +test('array imports field is silently ignored by Node.js (node parity)', async t => { + // Node.js silently ignores an invalid array `imports` field and resolves + // the package via `exports` instead. The compartment-mapper is stricter + // and throws (see the SES treatment above). This test documents the + // Node.js behavior. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.value, 'should not reach here'); +}); + +// Imports edge cases: SES exercises the development condition (which yields +// the assertImportsEdgeCasesDev shape); Node.js exercises the default +// condition (assertImportsEdgeCasesDefault). +test('imports edge cases: non-wildcard alias, conditional, null, invalid key, bad value, mismatched wildcard (ses)', async t => { const edgeCasesFixture = new URL( 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', import.meta.url, @@ -132,6 +176,20 @@ test('imports edge cases: non-wildcard alias, conditional, null, invalid key, ba // - "#mismatched/*" / "./mismatched-export/*": mismatched wildcard count }); +test('imports edge cases (node parity)', async t => { + // Non-wildcard alias (#helper) and conditional import (#cond under default + // conditions) resolve correctly under Node.js. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + assertImportsEdgeCasesDefault(t, ns); +}); + +// Browser field handling: SES-only (Node's loader does not consult the +// browser field). test('browser field and commonjs default module', async t => { const browserCjsFixture = new URL( 'fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js', @@ -158,7 +216,9 @@ test('browser field as string remaps main export', async t => { t.is(namespace.env, 'browser-string'); }); -test('exports edge cases: ./ key skipped, nested subpath with name != "."', async t => { +// Exports edge cases: SES and Node parity assertions are essentially +// identical for this fixture; register them back-to-back. +test('exports edge cases: ./ key skipped, nested subpath with name != "." (ses)', async t => { const exportsEdgeCasesFixture = new URL( 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', import.meta.url, @@ -171,7 +231,20 @@ test('exports edge cases: ./ key skipped, nested subpath with name != "."', asyn t.is(namespace.nested, 'nested-esm'); }); -test('non-object exports field causes an exception', async t => { +test('exports edge cases (node parity)', async t => { + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.main, 'exports-edge-cases-main'); + t.is(ns.nested, 'nested-esm'); +}); + +// Non-object exports field: the compartment-mapper produces a specific +// message; Node.js produces a version-dependent code. +test('non-object exports field causes an exception (ses)', async t => { const badExportsFixture = new URL( 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', import.meta.url, @@ -181,6 +254,23 @@ test('non-object exports field causes an exception', async t => { }); }); +test('non-object exports field is rejected by Node.js (node parity)', async t => { + // Node.js rejects the invalid numeric exports field. The error code varies + // by version: ERR_PACKAGE_PATH_NOT_EXPORTED on 18/20, ERR_MODULE_NOT_FOUND + // on 22+. We just verify it throws. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', + import.meta.url, + ).href + ), + ); +}); + +// Non-string non-object browser field: SES-only (Node's loader does not +// consult the browser field). test('non-string non-object browser field causes an exception', async t => { const badBrowserFixture = new URL( 'fixtures-package-imports-exports/node_modules/bad-browser-app/main.js', @@ -197,7 +287,9 @@ test('non-string non-object browser field causes an exception', async t => { ); }); -test('null-target pattern excludes matching specifier', async t => { +// Null-target patterns: SES rejects with a message; Node.js rejects with +// ERR_PACKAGE_PATH_NOT_EXPORTED. +test('null-target pattern excludes matching specifier (ses)', async t => { const nullTargetFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', import.meta.url, @@ -207,7 +299,25 @@ test('null-target pattern excludes matching specifier', async t => { }); }); -test('absolute path in subpath pattern replacement is rejected', async t => { +test('null-target patterns are excluded by Node.js (node parity)', async t => { + // The file exists on disk but the null-target export prevents resolution. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', + import.meta.url, + ).href + ), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +// Absolute path in subpath pattern: SES rejects with a "Cannot find file" +// message; Node.js rejects with ERR_INVALID_PACKAGE_TARGET. +test('absolute path in subpath pattern replacement is rejected (ses)', async t => { const absolutePatternFixture = new URL( 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', import.meta.url, @@ -220,6 +330,26 @@ test('absolute path in subpath pattern replacement is rejected', async t => { ); }); +test('absolute path in subpath pattern is rejected by Node.js (node parity)', async t => { + // A package whose exports map "./smuggle/*.js" to "/etc/*.js" should not + // allow importing absolute paths. Node.js rejects this because the + // resolved target does not start with "./". + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', + import.meta.url, + ).href + ), + { + code: 'ERR_INVALID_PACKAGE_TARGET', + }, + ); +}); + +// Module field ESM entry: SES-only (this exercises compartment-mapper's +// module-field handling specifically). test('module field selects ESM entry point', async t => { const moduleFieldFixture = new URL( 'fixtures-package-imports-exports/node_modules/module-field-app/main.js', @@ -232,7 +362,10 @@ test('module field selects ESM entry point', async t => { t.is(namespace.entry, 'esm'); }); -test('pattern tie-break matches Node precedence rules', async t => { +// Pattern tie-break precedence: SES exercises through importLocation; Node +// parity exercises through a direct import of the precedence-import.js +// driver. +test('pattern tie-break matches Node precedence rules (ses)', async t => { const precedenceFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/precedence-import.js', import.meta.url, @@ -240,3 +373,62 @@ test('pattern tie-break matches Node precedence rules', async t => { const { namespace } = await importLocation(readPowers, precedenceFixture); assertPrecedence(t, namespace); }); + +test('Node prefers the longer full pattern key on equal prefix length (node parity)', async t => { + // This exercises Node's pattern key ordering with overlapping keys: + // "./tie/*" and "./tie/*.js". Node resolves "patterns-lib/tie/bar.js" + // through "./tie/*.js", not the broader "./tie/*" entry. + const ns = await import(new URL('precedence-import.js', fixtureBase).href); + assertPrecedence(t, ns); +}); + +// Multi-star and globstar exclusions: Node-only. The compartment-mapper has +// no analogous test on the SES side; the parity test pins Node's behavior +// so a future Node change is caught. +test('multi-star patterns are not resolved by Node.js (node parity)', async t => { + // Node.js restricts subpath patterns to exactly one `*` per side. + // Entries with multiple `*` are silently ignored (never match). + // This test will fail if Node.js begins to support multi-star patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('multi-star-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The multi-star subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/multi-star-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +test('globstar patterns are not resolved by Node.js (node parity)', async t => { + // Node.js does not support globstar (**) in subpath patterns. + // Entries with ** are silently ignored (never match). + // This test will fail if Node.js begins to support globstar patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('globstar-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The globstar subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/globstar-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); diff --git a/packages/ses/test/import-cjs.test.js b/packages/ses/test/import-cjs.test.js index 8c88f39ef0..86e95d3621 100644 --- a/packages/ses/test/import-cjs.test.js +++ b/packages/ses/test/import-cjs.test.js @@ -685,12 +685,14 @@ test('importNow handles a cycle in CommonJS modules', t => { // // Node.js rejects this ESM-in-CJS-cycle topology with ERR_REQUIRE_CYCLE_MODULE; // the divergence is verified programmatically in -// packages/compartment-mapper/test/cycle-esm-in-cjs.test.js (SES side) -// together with packages/compartment-mapper/test/cycle-esm-in-cjs-node-parity.test.js -// (Node side). For the parity case where Node and SES agree on a -// pure-CommonJS cyclic reexporter, see -// packages/compartment-mapper/test/cycle-cjs-reexporter.test.js together with -// packages/compartment-mapper/test/cycle-cjs-reexporter-node-parity.test.js. +// packages/compartment-mapper/test/cycle-esm-in-cjs.test.js (one module +// registering both the SES treatment, where the topology loads, and the +// Node.js parity treatment, which spawns Node and asserts the +// ERR_REQUIRE_CYCLE_MODULE rejection). For the parity case where Node and +// SES agree on a pure-CommonJS cyclic reexporter, see +// packages/compartment-mapper/test/cycle-cjs-reexporter.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). test('cyclic star-export with CommonJS reexporter', async t => { t.plan(3); diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 511ec4f911..340cbee34b 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -248,8 +248,9 @@ test('export name as default', async t => { // populated star-reexporter's notifier for `y`. The same fixture shape is // also exercised through compartment-mapper's scaffold and pinned to // Node.js's reference behavior; see -// packages/compartment-mapper/test/cycle-rename.test.js and -// packages/compartment-mapper/test/cycle-rename-node-parity.test.js. +// packages/compartment-mapper/test/cycle-rename.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). test('cyclic star export with renaming reexport (issue #59)', async t => { t.plan(3); @@ -295,8 +296,9 @@ test('cyclic star export with renaming reexport (issue #59)', async t => { // this shape, which is the parity property the test pins. The same fixture // shape is also exercised through compartment-mapper's scaffold and pinned // to Node.js's reference behavior with a shared assertion module; see -// packages/compartment-mapper/test/cycle-rename-unused.test.js and -// packages/compartment-mapper/test/cycle-rename-unused-node-parity.test.js. +// packages/compartment-mapper/test/cycle-rename-unused.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). test('cyclic star export with renaming reexport, unused live binding', async t => { t.plan(3); From cb597b2e61a8c473d2d4b87884b94b25e7183956 Mon Sep 17 00:00:00 2001 From: endolinbot <main.barn5084@fastmail.com> Date: Mon, 22 Jun 2026 05:31:43 +0000 Subject: [PATCH 24/24] test(compartment-mapper): drop issue citations from unused-assertions module The _cycle-rename-unused-assertions.js module still had two references that the no-pull-citations gate catches: endojs/endo#59 in the module preamble and "issue #59 fix" in the body. Rewrite prose to stand on its own description (cyclic-star-export regression, deferring closure) without citing the bot-fork or upstream issue number. This continues the sweep begun in b7e77cf38 which swept the same references from the cycle-rename-tdz-matrix files. --- .../test/_cycle-rename-unused-assertions.js | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js index 0d027979de..4bdd44b094 100644 --- a/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js +++ b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js @@ -1,13 +1,13 @@ /** * Shared assertion logic for the unused-live-binding shape of the cyclic - * star-export with renaming reexport regression (endojs/endo#59). This is - * the companion to the populated shape exercised by - * `_cycle-rename-assertions.js`; the only difference is that the renamer's - * `export var y` here has no initializer, so the live binding is declared - * but never updated. Every projection of the cycle therefore reads - * `undefined`. Both the Node.js parity test and the Compartment Mapper test - * import from this module so the expected values live in exactly one place; - * if both tests pass, parity with Node.js is verified by construction. + * star-export with renaming reexport regression. This is the companion to + * the populated shape exercised by `_cycle-rename-assertions.js`; the only + * difference is that the renamer's `export var y` here has no initializer, + * so the live binding is declared but never updated. Every projection of the + * cycle therefore reads `undefined`. Both the Node.js parity test and the + * Compartment Mapper test import from this module so the expected values + * live in exactly one place; if both tests pass, parity with Node.js is + * verified by construction. * * The fixture under fixtures-cycle-rename-unused/node_modules/app/ exercises * this arrangement: @@ -22,9 +22,9 @@ * export const namespace1 = { x: ns1.x, y: ns1.y }; * export const namespace2 = { x: ns2.x, y: ns2.y }; * - * The deferring closure introduced by the issue #59 fix queues subscribers - * until the upstream notifier resolves, then forwards them. With no - * initializer the upstream's value never updates, so every read is + * The deferring closure introduced by the cyclic-star-export fix queues + * subscribers until the upstream notifier resolves, then forwards them. + * With no initializer the upstream's value never updates, so every read is * `undefined`. Node.js exhibits the same shape, so the parity test pins * both layers to a single expected projection. *