Repository navigation
Compile inline require(), computed and chained CommonJS exports, and a module's own exports object - #1329
Merged
Conversation
A package-anchored preflight diagnostic that makes the build drop an npm package was reported only at the consumer's import, as `SC2013 cannot compile npm package 'ws' statically: SC1012: …`, with the package file and line lost. The refusal reason now carries them (`SC1012 at ws/lib/websocket.js:30: …`), in the SC2013 diagnostic and in the coverage report's package status. This is how the SC1012 construct behind ws, readable-stream, through2, socket.io and fastify was identified (nested require patterns, and a rest element in fastify), and how openai's SC1013 is located (`openai/internal/auth/x509-transport-state.mjs:2`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a module's own exports object
The npm compatibility probes ranked these CommonJS forms among the top blockers. They now compile with Node's semantics inside the static CommonJS model, where exports stay module globals or alias plumbing to declarations. Every remaining form keeps a refusal that names the construct.
- Inline require() of program modules (cjs-inline-require.ts). `require("./x").f`, `.f(...)` and `new (require("./x").C)()` over a table module run the module's run-once %init at the call's position, then resolve the member like a binding does. `require("./x")` as a value over a single-value module is that value (`require("debug")("ns")`, table entries, construction). Inline requires are preflight module edges. An edge in a function body is lazy: it orders the dependency after the requiring module and does not close initialization cycles. Other require() calls name their construct instead of reporting an unlowerable 'Require' value. A package that nothing installed resolves throws MODULE_NOT_FOUND.
- module.exports = require("./y") or a require binding (re-export), = TypeError (a builtin global), and = any computed value (`a || b`, ternary, `new X()`). Chained `exports.a = exports.b = v` evaluates v once. Export members assigned in value position (`const src = exports.src = []`) or inside functions write the export global. A fresh `exports = module.exports = {}` reset at the top of a module is a no-op. Any other rebinding of `exports` is refused by name.
- A module's own exports object (cjs-exports-object.ts). Identity comparisons, typeof and truthiness of `exports` / `module.exports` use the object that top-level `this` already represents, while the module keeps its original exports. This covers the `root === module.exports` statement that used to vanish from `.call(this)` wrappers. `exports.f(x)` calls the member's export global when no assigned value can read `this`. Other value uses are refused by name, including in CommonJS files where the message used to say "ES module".
- tsc interop (cjs-tsc-interop.ts). `const x_1 = __importDefault(require(...))` and `__importStar(...)` are require bindings, stamped and unstamped. The helper declarations lower to nothing once every call is such a binding.
- Export tables: method entries compile, and `...require("./x")` spread entries now evaluate x at their place. Before, the module was never initialized.
- TypeScript reads of members a JavaScript module attached to an exported function go through the function's own property table.
- Nested require destructuring of program modules, `const { codes: { E } } = require("./errors")`, destructures the named export at the require (SC1012). `const { A } = ns` over a module binding snapshots the export.
The capture-buffer fence tests merged from opt/cjs-exports-value used `root === module.exports`, which now compiles. They use eval() to keep testing the fence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full SCRIPTC_UPDATE_BASELINES=1 re-record of order-parity.test.ts changed no existing baseline; these are the seven new fixtures (6150-6156). 6150's order places late.js after main.js, the lazy inline-require edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The exports-assignment cache moves from a module-level WeakMap onto the Lowerer (cjsExportsAssignments), and the inline require() calls that close a require cycle move from a module-level WeakSet into preflight's result: checkPreflight stores them on the LoadResult, and lowering receives them with the other preflight facts (LowerOptions.inlineRequireCycleCalls). 6110's call and apply wrappers now compile `root === module.exports` and `typeof module.exports`, which print what Node prints when the wrappers' untaken branches run. The class base's `console.log(module.exports)` is the one statement left as a runtime fence, and its body is lowered once, so the program's deferred-fence count is 1.
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
The npm compatibility research ranked four CommonJS export forms among the top blockers: inline
require()and non-literalmodule.exports(#1), nested exports in UMD wrappers (#2), function-valued exports and export tables with methods (#6), andrequire()destructuring of program modules (#10). It also found a statement that disappeared from the binary:console.log(root === module.exports)inside a.call(this)wrapper. This PR compiles most of #1, #6 and #10 and themodule.exportsvalue case with Node's semantics, and keeps a refusal that names the construct everywhere else. #2 is not part of this PR; the reason is under "Not in this PR".All of it stays inside the existing static CommonJS model: exports remain module globals or alias plumbing to declarations, and requirers resolve them statically. Nothing here adds a runtime module object. The two-tier design's S9 stage (dynamic CommonJS module objects) is where the remaining refusals belong. Every refusal added here sits exactly where S9 would plug in a real object.
Builds on the capture-buffer fence fix from
opt/cjs-exports-value(merged into this branch). That fix turns the silent drop into a fence, and this PR makes the construct compile.Changes
1. Inline
require()of program modules (cjs-inline-require.ts)require("./x").f,require("./x").f(...)andnew (require("./x").C)()over a table module run the module's run-once%initat exactly the call's position, then read the member statically, exactly as aconst m = require("./x")binding does.require("./x")as a value over a single-value module evaluates the module, then yields that value. Examples:require("debug")("ns"),{ verify: require("./verify") },new (require("./Point"))().this, because Node bindsthisto the exports object.require()call of the CommonJS global names its construct: a computed specifier, or a package that is not compiled. It no longer reports the misleading "'Require' … no lowering" (or SC2009ScriptcRequire). A package that nothing installed resolves throws Node's catchableMODULE_NOT_FOUNDat the call.2.
module.exports =with computed values and re-exportsmodule.exports = require("./y")andconst y = require("./y"); module.exports = yre-export y: the statement is y's%init, and requirers alias through.module.exports = TypeError(a builtin global) is alias plumbing:new T()constructs the builtin.module.exports = a || b, ternaries,new X(), member reads and other computed values are one snapshot global, like the existing factory-call form.exports.a = exports.b = valueevaluates the value once and assigns the innermost member first. tsc's= void 0preamble keeps its existing path.exports.x = valuein value position (const src = exports.src = [], semver) and inside functions writes the export global. Writes inside functions require that the module keeps its original exports object.exports = module.exports = {}at the top of a module is a no-op reset, as in semver. Any other rebinding ofexportsis refused by name, because laterexports.<name>writes would land on the new value.3. A module's own exports object (
cjs-exports-object.ts)root === module.exports,exports === module.exports,typeof exports,typeof module.exportsand!exportswork while the module never replacesmodule.exportsand never rebindsexports. They compare and test the object that top-levelthisalready represents (Fix get-or-create records, local-class type queries, builtin typeof and CommonJS top-level this #1303). This covers the silent-drop case from the research.exports.f(x)andmodule.exports.f(x)call the member's export global when every value assigned to it is a function that never readsthis. Otherwise the call is refused by name. This was retry's case: it calledexports.timeouts(options), which readsthis.createTimeout.4. tsc interop (
cjs-tsc-interop.ts)const x_1 = __importDefault(require("./m"))and__importStar(require("./m"))over CommonJS program modules are require bindings.x_1.freads a stamped module's export f,x_1.default.freads an unstamped module's export f, andx_1.defaultis an unstamped single-value module's value. The(0, x_1.default)()call spelling works as well.this && this.__importDefaultself-probe, lower to nothing once every call is such a binding.5. Export tables, function members and nested destructuring (#6, #10)
module.exports = { f() {…} }compile when the method never readsthis....require("./x")spread entry now evaluates x at its place in the table. Before this change, x's%initnever ran (a silent miss), and with x joining the graph the reads crashed.status.message = codes; module.exports = status), through the function's own property table. The value enters static code at its inferred type.const { codes: { ERR_X } } = require("./errors")destructures the named export's value at the require. The outer keys stay alias plumbing.const { A, B } = nsover a CommonJS module binding snapshots the exports. This is picomatch'sconst { MAX_LENGTH, … } = constants.6. SC2013 reasons name the inner location. A package-anchored preflight diagnostic that makes the build drop a package used to appear only as
SC1012: …at the consumer's import. It now carriesSC1012 at fastify/fastify.js:55: …, which is how the SC1012 construct was identified: nested patterns, plus a rest element in fastify.Not in this PR
module.exports = …inside function bodies, so a UMD module has no exports for the checker, and its JavaScript requirers fail to typecheck before lowering starts. The fix is a source rewrite in the compilation host, beside the existing bundler-CJS rewrite (npm-static-rewrite-syntax.ts). It would unwrap a recognized wrapper into flat CommonJS with Node's CommonJS environment folded:typeof module === "object",typeof define === "undefined", a truthymodule.exports. The rewrite would apply to program files, not only--npm-staticpackages, so corpus programs can pin it. S9's dynamic module objects serve these modules without the rewrite, at dynamic speed. Most chore: release v0.0.15 #2 packages have further blockers behind it anyway: debug passesexportsto a function, and lodash and crypto-js use dynamic objects.exports = module.exports = fnfollowed byexports.x = …(object-hash): the members are expandos offn, reached through the rebound binding. It is refused by name.var util = require("./util"), bluebird's factory body) keep their fence.const m = require(...)binding (m.f()) calls f withoutthis. Node passes the exports object, so an exported function that readsthismisbehaves. The new inline-require andexports.f()paths refuse that case.Probe delta
Setup:
probes/run.mjs, auto selection first, then--npm-static=<all installed>);Results:
bytesSet on non-bytes union) and a real top-level require cycle in jszip (utils ↔ base64) that the Compile classic CommonJS packages: late requires, JSON requires, nested builtin destructuring, settled require cycles #1287 admission rules refuse.r.match is not a functionon main 329ea43, identically before and after.openai, the pi-diag item:
SC2013 cannot compile npm package 'openai' statically: SC1013: namespace imports of CommonJS modules are not supported yet, with no location.… SC1013 at openai/internal/auth/x509-transport-state.mjs:2: ….import * as nodeState from './x509-transport-state.js'(a CommonJS file) andimport * as browserState from './…-browser.mjs', selects one withtypeof nodeState.findRegisteredX509Transport === 'function' ? nodeState : browserState, andexport const { … } = state.if (typeof module !== 'undefined' && … module.exports === exports) { … }probe, with values such asWeakMap.prototype.get.bind(registry).Evidence
6150-cjs-inline-require: evaluation order, cache hits, mutation visibility, lazy requires, whole-export calls, construction, table entries, re-export identity, builtin, ternary,||andnewexports.6151-cjs-export-chains: single evaluation, identity, a semver-shaped reset with value-position exports.6152-cjs-tsc-interop: stamped and unstamped modules, the void preamble,(0, x.default)().6153-cjs-exports-object: identity inside.call(this), typeof and truthiness, self-calls that see a later reassignment.6154-cjs-require-nested-destructure: snapshot semantics for nested patterns and binding destructuring, evaluation order.6155-cjs-export-table-methods: methods, accessor, spread evaluation order.6156-cjs-function-members-from-ts: a TypeScript consumer of a function with members, plus a CommonJS consumer.errors.test.tspins the new refusal messages: exports passed as a value, the rebinding, a computed specifier, a table module as a value, and a self-call that readsthis. The capture-buffer cases fromopt/cjs-exports-valuenow compileroot === module.exports, so they moved toeval, which still fences.Validation
All runs were in Linux sandboxes, Node 24.15.
pnpm lintandpnpm format:checkpass, and so doespnpm test:ts7 --baselines-only.SCRIPTC_UPDATE_BASELINES=1re-record oforder-parity.test.tschanged no existing baseline. The only additions are the seven new fixtures.build packages/compiler/src/native/cli.ts --emit=irreports no diagnostics.tests/harness/self-hosting-native-driver.test.tspasses on the final candidate (1 of 1 test, 20 minutes).cjs|require|commonjs|umd|615subset (268 programs): all pass except 18. Those 18 fail identically on 329ea43 in the same sandbox, where Node 24.15 differs from the pinned Node: structured-clone, styleText, zlib, strip-vt, fs stats, statfs, crypto hash stream and stream-arg-ladders.errors.test.tsandshard.test.ts: 79/79 pass.diagnostics.test.ts: 191/191 pass. The frontend unit tests (packages/compiler/src/frontend): 786/786 pass.npm-static.test.tsandnpm.test.ts: 196/197 pass. The failure isnode24-url-authority, a known baseline-only failure (Node 24.15 vs the pinned release).npm.test.tschanged, each with the reason:module.exports = typeof fetchnow compiles, and the next fence is the globalThis destructure.this === exportsnow compiles, and the next fence isObject.hasOwn.scripts/zero-cost-gate.mjs --set=bench,self,corpus --corpus=all, baseline 329ea43). This branch includes the merged capture-buffer fence fix.2032-cjs-export-assigned-class-expression. It was a real regression:new module.exports(…)in a module whose whole export is a class expression. The final code fixes it: a module that replacesmodule.exportskeeps its existing reads.🤖 Generated with Claude Code