Skip to content

Compile inline require(), computed and chained CommonJS exports, and a module's own exports object - #1329

Merged
ctate merged 6 commits into
mainfrom
opt/cjs-exports-model
Oct 18, 2026
Merged

ctate merged 6 commits into
mainfrom
opt/cjs-exports-model

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

The npm compatibility research ranked four CommonJS export forms among the top blockers: inline require() and non-literal module.exports (#1), nested exports in UMD wrappers (#2), function-valued exports and export tables with methods (#6), and require() 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 the module.exports value 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(...) and new (require("./x").C)() over a table module run the module's run-once %init at exactly the call's position, then read the member statically, exactly as a const 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"))().
  • A table module's exports object used as a value keeps a named refusal: "the exports object of './x' as a value". The same applies to a member call whose callee may read this, because Node binds this to the exports object.
  • Inline requires are now preflight module edges, collected in any expression position. An edge inside a function body is lazy: it orders the dependency after the requiring module and never closes a cycle through that module's initialization, which matches when Node actually runs it. If a module has no initializer, it is refused rather than read uninitialized.
  • Any other 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 SC2009 ScriptcRequire). A package that nothing installed resolves throws Node's catchable MODULE_NOT_FOUND at the call.

2. module.exports = with computed values and re-exports

  • module.exports = require("./y") and const y = require("./y"); module.exports = y re-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.
  • Chained exports.a = exports.b = value evaluates the value once and assigns the innermost member first. tsc's = void 0 preamble keeps its existing path.
  • exports.x = value in 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 of exports is refused by name, because later exports.<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.exports and !exports work while the module never replaces module.exports and never rebinds exports. They compare and test the object that top-level this already 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) and module.exports.f(x) call the member's export global when every value assigned to it is a function that never reads this. Otherwise the call is refused by name. This was retry's case: it called exports.timeouts(options), which reads this.createTimeout.
  • Passing or storing the object itself is refused by name instead of reporting "the reference to 'exports' (a binding form with no lowering)" or "the CommonJS 'module' object in an ES module" for a CommonJS file.

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.f reads a stamped module's export f, x_1.default.f reads an unstamped module's export f, and x_1.default is an unstamped single-value module's value. The (0, x_1.default)() call spelling works as well.
  • The helper declarations, including their this && this.__importDefault self-probe, lower to nothing once every call is such a binding.

5. Export tables, function members and nested destructuring (#6, #10)

  • Method entries in module.exports = { f() {…} } compile when the method never reads this.
  • A ...require("./x") spread entry now evaluates x at its place in the table. Before this change, x's %init never ran (a silent miss), and with x joining the graph the reads crashed.
  • TypeScript consumers can read members that a JavaScript module attached to an exported function (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 } = ns over a CommonJS module binding snapshots the exports. This is picomatch's const { MAX_LENGTH, … } = constants.
  • Rest elements and defaults keep SC1012.

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 carries SC1012 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

  • chore: release v0.0.15 #2, nested exports (UMD wrappers, IIFEs, environment probes, fs-extra's spread table). The lowering resolves every export through the checker's export table. tsgo does not bind 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 truthy module.exports. The rewrite would apply to program files, not only --npm-static packages, 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 passes exports to a function, and lodash and crypto-js use dynamic objects.
  • exports = module.exports = fn followed by exports.x = … (object-hash): the members are expandos of fn, reached through the rebound binding. It is refused by name.
  • Lazy require bindings inside functions (var util = require("./util"), bluebird's factory body) keep their fence.
  • Existing behavior, found and not changed here because changing it would alter previously compiling programs: a member call through a const m = require(...) binding (m.f()) calls f without this. Node passes the exports object, so an exported function that reads this misbehaves. The new inline-require and exports.f() paths refuse that case.

Probe delta

Setup:

Results:

  • The first blocker moved for 33 of 58 probes.
  • cookie-signature now matches Node.
  • Five probes moved from refused to compiling: object-assign, statuses, arg, long and fast-safe-stringify. Each now hits a later runtime fence outside this scope.
  • Three probes went the wrong way:
    • jsonwebtoken and jszip went from fence to refused. They now reach code that was unreachable before: an SC9001 compiler ICE in ecdsa-sig-formatter (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.
    • chardet went from fence to crash. The tsc interop now gets it past its helpers, and its module initialization builds an array of instances of 27 different classes. That segfaults, and the instance count matters: five mbcs subclasses crash, any single one works. The bug is in arrays of heterogeneous class instances and is not CommonJS-specific. A synthetic one-file array of five sibling classes already gives r.match is not a function on main 329ea43, identically before and after.
Package Fix Before (329ea43) After First blocker after
semver #1 fence fence assignment to non-array elements (instantiating 'createToken' with (string, string, unknown)) [SC1090 at semver/internal/re.js:47]
jsonwebtoken #1 fence refused SC9001 internal compiler error: in %m6.joseToDer%0: bytesSet on non-bytes union — please report this @ecdsa-sig-formatter/src/ecdsa-sig-formatter.js:1
qs #1 fence fence 'Map.prototype' is part of the standard library types but has no scriptc lowering yet [SC2020 at object-inspect/index.js:1]
micromatch #1 fence fence module namespace objects as values (access 'constants' members directly) [SC1090 at micromatch/node_modules/picomatch/lib/picomatch.js:336]
table #1 fence fence unchanged
chardet #1 fence crash
bluebird #1 fence fence require() with bindings outside the module's top level (move it to the top of the file) [SC1090 at bluebird/js/release/promise.js:14]
jszip #1 fence refused SC2013 cannot compile npm package 'jszip' statically: SC1016 at jszip/lib/utils.js:1: circular imports (/vercel/sandbox/npm/node_modules/jszip/lib/uti
qrcode #1 fence fence assignment to non-variables [SC1090 at dijkstrajs/dijkstra.js:164]
retry #1 fence fence calling 'exports.timeouts' through the module's exports object when the function may read 'this' (Node binds it to the exports object, which compiles
async-retry #1 fence fence Promise executors whose reject parameter is not '(reason: Error) => void' (instantiating 'retry' with ((unknown, unknown) => Promise, unknown)
connect #1 fence fence the exports object of 'debug' as a value (its exports compile to a static table: read require("debug").member directly, or bind const m = require("deb
finalhandler #1 fence fence the exports object of 'debug' as a value (its exports compile to a static table: read require("debug").member directly, or bind const m = require("deb
send #1 fence fence 'Error.prepareStackTrace' is typed by @types/node but has no scriptc lowering yet [SC2020 at depd/index.js:384]
resolve #1 fence fence library functions as values (call 'path.parse' directly) [SC1090 at resolve/lib/node-modules-paths.js:2]
object-hash #1 fence fence rebinding 'exports' (exports = module.exports = value): members attached through 'exports' afterwards are properties of that value, which the static e
express #1 refused refused unchanged
fast-glob #1 refused refused SC2013 cannot compile npm package 'run-parallel' statically: its entry '/vercel/sandbox/npm/node_modules/run-parallel/index.js' did not join the type-
globby #1 refused refused SC2013 cannot compile npm package 'to-regex-range' statically: its entry '/vercel/sandbox/npm/node_modules/to-regex-range/index.js' did not join the t
debug #2 refused refused unchanged
lodash #2 refused refused unchanged
dayjs #2 refused refused unchanged
crypto-js #2 refused refused unchanged
cors #2 refused refused unchanged
sax #2 refused refused unchanged
loglevel #2 refused refused unchanged
tweetnacl #2 refused refused unchanged
uri-js #2 refused refused unchanged
jsonc-parser #2 refused refused unchanged
superstruct #2 refused refused unchanged
xml2js #2 refused refused unchanged
prompts #2 refused refused SC1031 optional destructuring pattern parameters @prompts/lib/index.js:15
esprima #2 refused refused unchanged
ipaddr.js #2 refused refused unchanged
crc-32 #2 refused refused unchanged
error-stack-parser #2 refused refused unchanged
sprintf-js #2 refused refused unchanged
xmlbuilder #2 refused refused unchanged
object-assign #2 refused fence parameterized 'arguments' outside an ES module (sloppy-mode parameter aliases) [SC1090 at object-assign/index.js:65]
pluralize #2 refused refused unchanged
slugify #2 refused refused unchanged
anymatch #2 refused refused unchanged
fs-extra #2 refused refused SC2013 cannot compile npm package 'graceful-fs' statically: SC1010 at graceful-fs/polyfills.js:1: the 'constants' module @src/fs-extra.ts:1
mime-types #6 fence fence compound '+=' assignments of 'string' values to 'boolean | string | undefined' targets [SC1043 at mime-types/index.js:94]
statuses #6 refused fence dynamic keyed writes to records without an index signature (declared fields of ONE shared type dispatch by key — spell the field name otherwise) (inst
picomatch #6 fence fence module namespace objects as values (access 'constants' members directly) [SC1090 at picomatch/lib/picomatch.js:355]
tinyglobby #6 fence fence module namespace objects as values (access 'constants' members directly) [SC1090 at picomatch/lib/picomatch.js:355]
markdown-it #6 fence fence unchanged
arg #6 refused fence record shapes must match exactly or width-coerce: expected '[unknown, unknown]', got 'string[]' (instantiating 'arg' with ({ --name: (string) => strin
long #6 refused fence functions with optional or defaulted parameters as values, except where the target type spells the completed signature with required parameters (a '(x
fast-safe-stringify #6 refused fence dynamic keyed writes to records without an index signature (declared fields of ONE shared type dispatch by key — spell the field name otherwise) (inst
cookie-signature #6 fence ok
uuid #6 refused refused unchanged
ws #10 refused refused SC1090 extending classes not declared in the program ('Writable') @ws/lib/receiver.js:30
readable-stream #10 refused refused SC1090 constructing values other than classes declared in the program @src/readable-stream.ts:3
fastify #10 refused refused SC2013 cannot compile npm package 'fastify' statically: SC1012 at fastify/fastify.js:55: require() destructuring with defaults, rest, or nested patter
socket.io #10 refused refused SC2013 cannot compile npm package 'engine.io-client' statically: SC1013 at engine.io-client/build/esm-debug/transports/polling-xhr.node.js:1: namespac
through2 #10 refused refused SC2020 'ReadableStream.pipe' is typed by @types/node but has no scriptc lowering yet @src/through2.ts:4

openai, the pi-diag item:

  • Before: SC2013 cannot compile npm package 'openai' statically: SC1013: namespace imports of CommonJS modules are not supported yet, with no location.
  • After: … SC1013 at openai/internal/auth/x509-transport-state.mjs:2: ….
  • The construct is an ES module that does import * as nodeState from './x509-transport-state.js' (a CommonJS file) and import * as browserState from './…-browser.mjs', selects one with typeof nodeState.findRegisteredX509Transport === 'function' ? nodeState : browserState, and export const { … } = state.
  • The CommonJS side defines all its exports inside a top-level if (typeof module !== 'undefined' && … module.exports === exports) { … } probe, with values such as WeakMap.prototype.get.bind(registry).
  • Compiling it needs a namespace object as a value (S9), exports inside an environment probe (chore: release v0.0.15 #2), and bound builtin prototype methods as values (S8). It is not fixed here.

Evidence

  • Corpus (Node vs native, byte-identical):
    • 6150-cjs-inline-require: evaluation order, cache hits, mutation visibility, lazy requires, whole-export calls, construction, table entries, re-export identity, builtin, ternary, || and new exports.
    • 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.ts pins 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 reads this. The capture-buffer cases from opt/cjs-exports-value now compile root === module.exports, so they moved to eval, which still fences.

Validation

All runs were in Linux sandboxes, Node 24.15.

  • pnpm lint and pnpm format:check pass, and so does pnpm test:ts7 --baselines-only.
  • A full SCRIPTC_UPDATE_BASELINES=1 re-record of order-parity.test.ts changed no existing baseline. The only additions are the seven new fixtures.
  • The self-hosting build packages/compiler/src/native/cli.ts --emit=ir reports no diagnostics. tests/harness/self-hosting-native-driver.test.ts passes on the final candidate (1 of 1 test, 20 minutes).
  • Differential corpus, the cjs|require|commonjs|umd|615 subset (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.ts and shard.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.ts and npm.test.ts: 196/197 pass. The failure is node24-url-authority, a known baseline-only failure (Node 24.15 vs the pinned release).
  • Four KNOWN expectations in npm.test.ts changed, each with the reason:
    • create-require: run SC2009 → SC1090. The deferred refusal is now "the exports object of './core.js' as a value".
    • dotfolder: run SC2009 → SC1090. A require cycle through an inline require is refused at run time.
    • fetch-destructure: build SC1090 → run SC1031. module.exports = typeof fetch now compiles, and the next fence is the globalThis destructure.
    • node24-commonjs-metadata: run SC1090 → SC2020. this === exports now compiles, and the next fence is Object.hasOwn.
  • Zero-cost gate (scripts/zero-cost-gate.mjs --set=bench,self,corpus --corpus=all, baseline 329ea43). This branch includes the merged capture-buffer fence fix.
    • First run, on a build without the final fixes: 3057 of 3058 identical. All 29 bench workloads and the self-hosted compiler (IR and LLVM) were identical. Compile time was within tolerance: bench geomean −1.04% [95% CI −3.05%, +1.03%], self-hosted +1.21%.
    • The one difference was 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 replaces module.exports keeps its existing reads.
    • Rerun on the final build (the committed tree, without timing runs): PASS, all 3058 programs identical, including 2032, every bench workload and the self-hosted compiler.

🤖 Generated with Claude Code

cramforce and others added 4 commits October 18, 2026 12:05
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>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 18, 2026 10:17pm UTC

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

1 active deployment
Preview — 238d782f Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants