feat(compile): compile user .js/.cjs natively; support require() (#668)#1711
Merged
Conversation
User .js/.cjs files (entry or project sources outside node_modules) now
feed the native AOT pipeline instead of the removed V8 interpreter, and
their CommonJS (require(...) / module.exports) is rewritten to ESM by the
existing cjs_wrap path before SWC parses. So require("literal") lowers to
a static namespace import and flows through normal resolution, init-order
and codegen — closing the #668 require() bail for user code.
Two gates in collect_modules.rs:
- should_use_js_runtime: scope the .js->js-runtime classification to
node_modules, so user .js compiles natively.
- was_cjs_wrapped: extend the CommonJS->ESM rewrite to any user file,
not just compilePackages targets.
Validated: raw Node test/parallel CommonJS tests (require('../common') +
require('assert')/require('path')) compile and run natively.
ee1f8d1 to
14cf2b6
Compare
#1712) A coverage radar that runs Node's own test/parallel corpus (per API in supported-apis.txt) under both Perry and Node, bucketing divergence by exit-code parity: pass / diff / runtime-fail / compile-fail / node-skip. Complements the hand-authored test-parity/node-suite by exercising Node's canonical tests — the same corpus Deno and Bun use for Node-compat. Relies on the companion require()/.js native-compile support to run the raw CommonJS tests; a Perry-compilable CommonJS shim is staged as common/ so the API under test is real while the harness is neutralized. - scripts/node_core_subset.py — the runner (vendor a Node subset, stage, compare, write report.json). - test-compat/node-core/shim/ — CommonJS replacements for test/common. - .gitignore — re-include node-core source (the /test-* rule swept it). Advisory tooling, not wired into CI gating yet. First sweep surfaced real gaps (path.join not a first-class value, path.basename/normalize diffs).
This was referenced May 25, 2026
Closed
proggeramlug
added a commit
that referenced
this pull request
May 25, 2026
…) (#1726) Binding a user CommonJS module via `const c = require('./m')` returned undefined under `perry compile` (side-effect require and builtin-module binding already worked). Two layers, both follow-ups to #668/#1711: 1. cjs_wrap self-shadowing: the importer adopts the alias `c` as the hoisted import name (`import c from './m'`) and the synthetic require returns `c`, but the original `const c = require('./m')` body line was only blanked when hoisting classes. Without classes it survived, redeclaring `c` inside the IIFE so require() returned the shadowed, not-yet-initialized binding. Now blank adopted-alias body lines regardless of hoisted classes. 2. import-edge misclassification: a relative/absolute/file: `.js` import was tagged ModuleKind::Interpreted by extension, routing it to the removed (#1696) V8 bridge so the default/named export symbols never linked (_perry_fn___v8___..._default). Now node_modules-gated, mirroring collect_modules' should_use_js_runtime: user `.js` outside node_modules is NativeCompiled. Adds a cjs_wrap regression unit test. Validated: `const c = require('./m')` binds the module object; raw Node test/parallel cases using `const common = require('../common')` now run.
proggeramlug
added a commit
that referenced
this pull request
May 25, 2026
…ember (#1723) (#1741) The #503 dynamic-stdlib-dispatch guard refused every computed member access on a stdlib namespace (`path[platform]`, `fs[k]`, …), including the legit `ns[dynamicKey].staticMember` shape where the dynamic index merely selects a SUB-namespace (`path.win32` / `path.posix`) and the member actually used is a source-visible static name. That is NOT the obfuscation the guard targets: `ns[runtimeVar]()` hides the called method behind a runtime string, whereas here the method/property name is in plaintext and fully auditable, and the dynamic index only picks among a namespace's tiny, known set of sub-namespaces. Surfaced by the #800 node-core radar: Node's own test-path-glob.js does `path[platform].matchesGlob(pathStr, glob)` and hit a hard `(#503)` compile error. The require()-imported form (#1711 cjs_wrap → default import) and the ESM `import * as` / default-import forms were all refused identically; the discriminator in this fix is the ENCLOSING ACCESS SHAPE, not the binding origin, so all three forms now behave the same. Fix: a new `stdlib_ns_subnamespace_static_access` recognizes `<stdlib-ns>[<nonLiteral>] . <staticName>` (static = Ident or literal-string key). When matched, a one-shot context flag tells the nested `ns[dynamicKey]` lowering to skip the refusal; the guard consumes the flag on read, so a dynamic key INSIDE the index (`ns[fs[evil]].x`) and chained dynamic access (`ns[d1][d2]`) stay refused. The flag is set in two places — at the top of `lower_member` (before its early-return arms lower `member.obj`) for the property-access form, and after args in `lower_call` for the dot method-call form, whose receiver never routes through `lower_member` for the `.method` part. Args are lowered before the call-path flag is set, so a dynamic stdlib key in an argument is still refused. Verified end-to-end: `path[v].sep` / `path[v].delimiter` for win32/posix now compile and match `node --experimental-strip-types` byte-for-byte. All 12 existing #503 security tests still pass (terminal `ns[k]`, `ns[k]()`, `ns[a][b]()`, node_modules self-annotation, etc. stay refused), plus 5 new unit tests. Dynamic sub-namespace METHOD dispatch (`path[v].matchesGlob(...)` returning the right value at runtime) is a separate runtime-layer gap tracked in #1740; this change is the guard fix only.
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.
What
User
.js/.cjsfiles (entry or project sources outsidenode_modules) now compile through the native AOT pipeline instead of the removed V8/QuickJS interpreter, and their CommonJS (require(...)/module.exports) is rewritten to ESM by the existingcjs_wrappath before SWC parses. As a resultrequire("literal")lowers to a static namespace import and flows through the normal resolution + init-order + codegen machinery — closing the #668require()compile-time bail for user code, and letting Perry consume plain CommonJS.js.Why
Two long-standing limits, both deliberate stubs rather than architectural blockers:
.jsfiles were classified by extension and shunted to the (deleted) JS runtime → hard error.require(literal)bailed with a fix-it (AOT:require('./mod')from inside a class method returns a non-callable value #668 comment: "until we wire up synthetic namespace-imports…").cjs_wrapalready performs exactly therequire→import+module.exports→exportrewrite (trusted forcompilePackagesCommonJS like pino/react), so this reuses that proven path instead of new HIR lowering.Change
Two gates in
crates/perry/src/commands/compile/collect_modules.rs:should_use_js_runtime: scope the.js→js-runtime classification tonode_modulesonly, so user.jscompiles natively. node_modules.jskeeps the legacy (gated) behavior.was_cjs_wrapped: extend the CommonJS→ESM rewrite to any user file (!is_in_node_modules), not justcompilePackagestargets.Blast radius
.jsbehavior is unchanged (still gated on--enable-js-runtime)..js/.cjsoutside node_modules, which previously hard-errored — so this strictly expands what compiles..tswritten in pure CommonJS (require, no top-levelimport/export) is now also wrapped; mixed ESM+require files are untouched (is_commonjsreturns false when top-level ESM is present).Validation
Raw Node
test/parallelCommonJS tests —require('../common')+require('assert')/require('path')— compile natively ("2 native, 0 JavaScript") and run, producing real assertion results.__filenameresolves correctly. (Companion PR adds the #800 radar that exercises this corpus.)Follow-ups: dynamic
require(expr)and CJS-modulemodule.exportsconsumed by other modules remain out of scope (the hard tail).