Repository navigation
Compile classic CommonJS packages: late requires, JSON requires, nested builtin destructuring, settled require cycles - #1287
Merged
Merged
Conversation
…JSON requires
A top-level require binding that earlier code could reach was refused
(SC1013). The reachability scan now follows Node's evaluation order:
hoisted functions and classes run only when reachable code names them, a
class statement runs only its heritage, computed keys, decorators and
static initializers, timer and promise callbacks run after the module
body, and publishing a value through module.exports, exports.n,
Object.defineProperty(exports, ...) or F.prototype.m runs nothing until
reachable code reads it back. This admits the export-first layouts of
semver, glob@7, fs.realpath, on-finished, readable-stream and
tsc-emitted CommonJS.
A const or let binding that code can still reach before its require now
compiles with an initialization flag set right after the require and a
module.tdzCheck before the outermost expression of every reference that
can run earlier, so an early read throws Node's ReferenceError before
any argument evaluates. A reference whose lowering leaves no expression
to check, a var binding (Node reads undefined there) and createRequire
bindings keep SC1013.
require() of a JSON document now answers one parsed object per document,
created in the requiring module's %init prelude unless another module
already created it, so repeated requires return the same object and
mutations are visible to every requirer. Require bindings of JSON
documents are ordinary storage, so destructuring (nested patterns,
defaults, missing keys) copies at the require as in Node.
Nested destructuring of a builtin require binds the members of the
sub-module the key names (const { types: { isPromise } } =
require('util')).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A require cycle was always refused (SC1016). Node answers a require of a
module that is still evaluating with its exports as they stand, while the
compiled bindings alias the final declarations; the two agree when every
module of the cycle assigns all of its exports before its first statement
that can run another module's code, every other use of its export object
is a plain member read, and no module's top-level code can reach its
binding of another member. Each module then initializes exactly as it
would outside the cycle. This admits semver's comparator/range cycle
("hoisted class for cyclic dependency"); other cycles keep SC1016 with a
reason naming the offending statement.
The require reachability scan no longer treats a declaration's own name
as a read (esbuild's `var X = class {...}` before its requires), treats a
plain write of an export member as running only a setter published for it
(on-finished's `module.exports.isFinished = isFinished`), and keeps the
conservative scan for writes of __proto__, __esModule and members of a
replaced export object.
Evidence: corpus 6145 (the cycle), 6142 gains the esbuild layout; the
npm-static harness compiles the published fs.realpath@1.0.0,
on-finished@2.4.1 (with ee-first@1.1.1) and statuses@2.0.1 statically;
program.test.ts pins the TDZ analysis through var bindings plus the cycle
admission and refusals. Preflight/order baselines recorded for the new
fixtures; a full re-record changed no other fixture. Dependencies and
limitations docs describe the supported require forms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The test asserts that the TDZ analysis batches symbol queries over the roots it scans, observed through an SC1013 refusal. Its const binding now compiles with initialization checks instead of a refusal, so the test uses a var binding, which keeps the fence and runs the same scan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 18, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Compiling a real Node service, and comparing against Bun's AOT compiler, showed that most refused npm packages were refused by four CommonJS-interop fences rather than by the code they run. This PR replaces those fences with Node's semantics where they can be modeled exactly, and keeps a named refusal everywhere else.
1.
require()after code that could use its binding (SC1013). The reachability scan now follows Node's evaluation order instead of treating every earlier statement as running every function it names:module.exports = f,exports.n = f,Object.defineProperty(exports, "n", { get }),F.prototype.m = g) runs nothing until reachable code reads it back.Nothing outside a module can read its exports before its body completes. Require cycles are fenced unless they meet the conditions in item 4, and a compiled graph has no reflective exports object.
This admits the export-first layouts of semver, glob@7, fs.realpath, on-finished, readable-stream, esbuild bundles and tsc-emitted CommonJS.
When code can still reach a
const/letbinding before its require, the require now compiles with an initialization flag (require-initialization.ts). The flag is set right after the require evaluates the module. Amodule.tdzCheckruns before the outermost expression of every reference that can run earlier. An early read therefore throws Node'sReferenceError: Cannot access 'x' before initializationbefore any argument evaluates. These cases keep SC1013:varbinding, where Node readsundefined;createRequirebinding.2. JSON
require()(SC1012). Each JSON document becomes one object, parsed at build time (json-require.ts). Each requiring module creates it in its%initprelude unless another module already did. Every require, from any module and at any position (including lazy and inlinerequire('./package.json').version), returns that same object, so mutations are visible to every requirer. A JSON require binding is ordinary storage, so destructuring copies at the require: nested patterns, defaults and missing keys behave as in Node. ES-module JSON imports keep their own record, as Node's ESM loader does.3. Nested destructuring of a builtin require (SC1012).
const { types: { isPromise } } = require('util')binds the members of the sub-module its key names (util.typesisutil/types). Defaults, rest elements and nested patterns over program modules keep SC1012, with a clearer message.4. Require cycles (SC1016), narrowly. Node gives a module that requires a still-evaluating module that module's exports as they stand. The compiled bindings alias the final declarations. The two agree when all three conditions hold:
Each module then initializes exactly as it would outside the cycle. This admits semver's comparator/range cycle ("hoisted class for cyclic dependency"). Other cycles keep SC1016, with the offending statement named in the reason.
Package matrix (Linux sandbox, Node 24.15; the program imports the package and exercises its main API)
parseOptionsexports = module.exports = {}ininternal/re.js(assignment to theexportsparameter)glob,fs.realpath)argumentsinsync.jsMinimatchfunctionStreamoldfs.realpathSyncstored as a valuefirstimport_core6class extends import_core2.OpenFeatureError(computed heritage)PoisonErrorincollectClassShapeInnerviamapType)constants, then SC1012 nested destructuringstream/webv8(by design), native addon@datadog/native-appsec, destructuring defaults in crypto-randomuuidpunycodefirst;require("./lib/mappingTable.json")itself refused with SC1012punycodestill first; the JSON require is accepted butmappingTable.json((string | number[])[][]) has no array representation (SC2009)None of the ten runs end to end yet. Every one that was refused for CommonJS interop now gets past those fences, and the remaining blockers are listed above as findings, not fixed here.
Evidence
6140-cjs-require-tdz-throws: uncaught TDZ,@exit: 1.6141-cjs-require-tdz-checked: call, call with an argument side effect, member,new, builtin, value and renamed reads before and after the require, plus stored callbacks run later.6142-cjs-require-published-early: semver, glob and esbuild layouts.6143-cjs-require-json: identity across modules, mutation visibility, lazy and inline requires, destructuring snapshots, array documents.6144-cjs-require-builtin-nested.6145-cjs-require-cycle: semver-shaped cycle, evaluation order.cjs-require-tdzis now avarbinding (still SC1013).cjs-require-tdz-unchecked: a const read with no checkable expression.cjs-require-cyclenames its reason.program.test.ts:varbindings, so the analysis is still pinned.codes.json) are vendored and compile statically.SCRIPTC_UPDATE_BASELINES=1re-record changed no fixture other than the new and intentionally changed ones.Validation
All runs were in the Linux sandbox:
pnpm lint,pnpm format:checkandpnpm test:ts7 --baselines-onlypass.build packages/compiler/src/native/cli.ts --emit=irproduces no diagnostics.tests/harness/self-hosting-native-driver.test.tspasses (28 min). It ran on the candidate before the last commit's small reachability narrowing (export-member writes).diagnostics,npm-static,npm,library-modeand the frontend unit tests: 1194/1197. The three failures:node24-url-authority(Node 24.15 vs the pinned 24.22);program.test.ts, the new corpus programs and the CommonJS diagnostics.coverage.test.ts: all pass except "every corpus program is 100% static", which hits its 600 s timeout in the sandbox.scriptc coveragereports each new corpus program at 100% static.order-parity.test.tspasses on the final candidate. A fullSCRIPTC_UPDATE_BASELINES=1re-record changed only the new and intentionally changed fixtures.cjs|require|cycle|tdz|commonjs|npm|json, ~380 programs): the only failures are 18 programs that fail identically on the e72ee03 baseline in this sandbox (Node 24.15 vs the pinned 24.22).docs:NEXT_DIST_DIR=.next-check pnpm checkpasses (25/25 route tests).pnpm node-compat:checkreports the snapshot is current. No compiler decision tables changed, sopnpm manifestandpnpm node-compatwere not needed.🤖 Generated with Claude Code