Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7962515
fix(ses): cyclic star export with renaming reexport (issue #59)
kriskowal May 21, 2026
b778253
test(ses): unused-live-binding parity for #59 cyclic star-export
May 29, 2026
c771da0
refactor(ses): extract makeNotifierWithResolver helper (issue #59)
Jun 2, 2026
1e15f03
test(ses): CommonJS reexporter parity in cyclic star-export (issue #59)
Jun 2, 2026
36c9384
test(compartment-mapper): cyclic CommonJS reexporter parity fixture +…
Jun 2, 2026
64095ee
test(compartment-mapper): ESM-in-CJS-cycle divergence parity test (#5…
Jun 2, 2026
0ac8b9a
test(ses): reframe cyclic CJS reexporter test prose; reference compar…
Jun 2, 2026
65d1310
test(compartment-mapper): unused-live-binding parity fixture + tests …
Jun 3, 2026
de535df
test(ses): TDZ-observation matrix for cyclic star export with renamin…
Jun 10, 2026
22867cc
docs(ses): document construction-time-notifiers consideration (issue …
Jun 10, 2026
d837202
test(ses): named-reexport variant of cyclic-export failure mode (issu…
Jun 10, 2026
88667a6
fix(ses): enforce TDZ for cross-module namespace reads during cycle (…
Jun 11, 2026
c5d3d56
fix(ses): enforce TDZ for cross-module named-reexport reads during cycle
Jun 11, 2026
9b4885a
docs(ses): archive construction-time-notifiers analysis to the garden…
Jun 11, 2026
c3f06bd
test(compartment-mapper): parity fixtures for the issue endojs/endo#5…
Jun 11, 2026
8f9f302
test(compartment-mapper): consolidate cyclic TDZ matrix into a table-…
Jun 13, 2026
5013d3c
docs(ses): expand changeset to cover TDZ enforcement alongside the cy…
Jun 13, 2026
2104fd7
test(compartment-mapper): drop issue-number references from the cycle…
Jun 13, 2026
e95ebe5
test(compartment-mapper): merge cycle-rename-tdz SES+Node parity test…
Jun 14, 2026
586be4c
docs(ses): add @module to notifier-with-resolver; link via {@link} (#…
Jun 15, 2026
a71b4d9
refactor(ses): strict equality and else over early return in notifier…
Jun 15, 2026
814dbaa
test(ses): TODO comment linking the CjsModuleSource mock to endojs/en…
Jun 15, 2026
9fbaaf7
test(compartment-mapper): merge remaining SES+Node parity tests into …
Jun 15, 2026
cb597b2
test(compartment-mapper): drop issue citations from unused-assertions…
Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-ses-star-export-cycle-rename.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update to reflect sum of changes.

@kriscendobot kriscendobot Jun 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 39b880d: the changeset now describes both user-visible changes the cycle-rename branch carries: the original deferred forwarding notifier that resolves the spurious cycle-rename SyntaxError, and the ECMA-262 temporal-dead-zone enforcement for cross-module namespace reads during cycle evaluation (covering both export * and export { y } from reexport forms).

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'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.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
};
45 changes: 45 additions & 0 deletions packages/compartment-mapper/test/_cycle-rename-assertions.js
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Shared assertion logic for the unused-live-binding shape of the cyclic
* 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:
*
* 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 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.
*
* @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);
};
56 changes: 56 additions & 0 deletions packages/compartment-mapper/test/cycle-cjs-reexporter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* 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, 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);
};

// SES treatment: load through the compartment-mapper scaffold, which
// exercises loadLocation, importLocation, and the archive paths.
scaffold(
'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);
});
83 changes: 83 additions & 0 deletions packages/compartment-mapper/test/cycle-esm-in-cjs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* 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/):
*
* 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 process from 'process';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
import { scaffold } from './scaffold.js';

const fixtureUrl = new URL(
'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs',
import.meta.url,
);
const fixture = fixtureUrl.toString();

const fixtureAssertionCount = 1;

/**
* @param {ExecutionContext} t
* @param {{namespace: object}} result
*/
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 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}`,
);
});
Loading
Loading