fix(compile): keep CJS classes reading exports inside the IIFE (#5251)#5254
Conversation
A top-level `class` in a natively-compiled CJS package whose method/ constructor body reads the module-level `exports` object (`exports.X`) got hoisted out of the cjs_wrap IIFE by `extract_top_level_class_decls`. Hoisting severs the class's closure over the preamble-injected `var exports`, so `exports.X` resolved as an unknown global and lowered to perry's numeric `0` sentinel — the dominant root of the native-compile "sentinel-0" class (e.g. ajv's `exports.IDENTIFIER.test(s)` inside a class constructor → `(number).test is not a function`). The #2310 hoist guard already refuses to hoist a class whose body references a top-level `let`/`const`/`var`, but its textual scan only sees declarations in the ORIGINAL source — the `exports`/`module`/`require` bindings are injected by the wrap preamble, so the guard never saw them. Fix: always add `exports`, `module`, `require` to the IIFE-local set that gates hoisting. A class reading any of them now stays inside the IIFE and closes over the injected `var exports`, matching free functions and module-level code (which already work). Version bump / CHANGELOG left for the maintainer to fold in at merge.
📝 WalkthroughWalkthrough
ChangesCJS Class Hoisting Fix
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry/src/commands/compile/cjs_wrap/mod.rs (1)
651-698: ⚡ Quick winAdd direct regression cases for
moduleandrequirebinding reads.Nice coverage for
exports; consider adding one class case each that referencesmodule.exportsandrequire(...)so all three injected bindings guarded by the fix are explicitly pinned by tests.Proposed test additions
+ #[test] + fn issue_5251_class_reading_module_stays_in_iife() { + let src = "\"use strict\";\nclass M { get() { return module.exports; } }\nexports.m = M;\n"; + let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/app/node_modules/re/index.js")); + let iife_start = wrapped.find("const _cjs = (function() {").expect("expected IIFE"); + let class_pos = wrapped.find("class M ").expect("class M missing"); + assert!(class_pos > iife_start, "class reading module must stay inside IIFE"); + } + + #[test] + fn issue_5251_class_reading_require_stays_in_iife() { + let src = "\"use strict\";\nclass R { load() { return require('./x'); } }\nexports.r = R;\n"; + let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/app/node_modules/re/index.js")); + let iife_start = wrapped.find("const _cjs = (function() {").expect("expected IIFE"); + let class_pos = wrapped.find("class R ").expect("class R missing"); + assert!(class_pos > iife_start, "class reading require must stay inside IIFE"); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry/src/commands/compile/cjs_wrap/mod.rs` around lines 651 - 698, The current regression tests only cover the case where a class reads the injected `exports` binding, but the fix also guards against classes reading `module` and `require` bindings. Add two new test functions following the same pattern as `issue_5251_class_reading_exports_stays_in_iife()`: one named `issue_5251_class_reading_module_stays_in_iife()` that tests a class with `module.exports` access inside a method, and one named `issue_5251_class_reading_require_stays_in_iife()` that tests a class with a `require(...)` call inside a method. Both should verify that the class stays inside the IIFE and the output parses correctly, mirroring the structure and assertions of the existing exports test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/perry/src/commands/compile/cjs_wrap/mod.rs`:
- Around line 651-698: The current regression tests only cover the case where a
class reads the injected `exports` binding, but the fix also guards against
classes reading `module` and `require` bindings. Add two new test functions
following the same pattern as
`issue_5251_class_reading_exports_stays_in_iife()`: one named
`issue_5251_class_reading_module_stays_in_iife()` that tests a class with
`module.exports` access inside a method, and one named
`issue_5251_class_reading_require_stays_in_iife()` that tests a class with a
`require(...)` call inside a method. Both should verify that the class stays
inside the IIFE and the output parses correctly, mirroring the structure and
assertions of the existing exports test.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 06ebf3ce-5e88-4442-a1e7-e70d25ded676
📒 Files selected for processing (2)
crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rscrates/perry/src/commands/compile/cjs_wrap/mod.rs
Summary
Fixes #5251. Inside a class method/constructor of a natively-compiled CJS package (
compilePackages), reading a module-levelexports.Xyielded perry's numeric0sentinel instead of the value — the dominant root of the native-compile "sentinel-0" class (e.g. ajv'sexports.IDENTIFIER.test(s)inside a class constructor →(number).test is not a function).Root cause
The cjs_wrap preamble injects
var exports,var module, and arequirefunction as IIFE-local bindings.extract_top_level_class_decls(the #652 class-hoist pass) hoists top-level classes out of the IIFE so consumers canimport { X }and resolve to the real class. The #2310 guard already refuses to hoist a class whose body references a top-levellet/const/var— but its textual scan only sees declarations in the original source, andexports/module/requireare never declared there (they come from the preamble). So a class readingexports.Xgot hoisted, severing its closure over the injectedvar exports;exportsthen resolved as an unknown global (warning: "unknown identifier 'exports' … bare reads lower to 0") andexports.Xlowered to0. Free functions and module-level code stay inside the IIFE, which is why they worked.Fix
Always add
exports,module,requireto the IIFE-local set that gates hoisting (extract_top_level_class_decls). A class reading any of them now stays inside the IIFE and closes over the injected bindings — same resolution path as free functions / module-level code. Classes that don't touch those bindings still hoist normally.One file of logic (
hoist_classes.rs, +16 lines) plus two unit tests (mod.rs).Test evidence
Minimal
rerepro (class method readsexports.TAG):0!hi!(matches Node)Extended fixture, all matching Node byte-for-byte:
this.v = exports.TAG→hiexports.RE = /x/i; … m(){ return exports.RE.test("X") }→trueexports.TAGread →hi?(regression: still works)const TOP = 42read from a class method →42(that class still hoists; regression intact)Added unit tests:
issue_5251_class_reading_exports_stays_in_iife,issue_5251_class_without_exports_still_hoists.cargo test --release -p perry -p perry-hirgreen (one HTTP integration test flaked under full parallel load — ext-http link race — and passes when run isolated; unrelated to this change).ajv before/after (payoff check)
import Ajv from "ajv"; const a = new Ajv(); const v = a.compile({…}); console.log(v({n:1}), v({}))withcompilePackages:["*"]:unknown identifier 'exports'warnings; the(number).test is not a functionsentinel-0 error.exportswarnings, the(number).testerror is gone. It now hits a different, later wall:TypeError: undefined is not a constructoratnew Ajv()— theAjvdefault-export/constructor binding, a separate issue not addressed here.Risks
Low. The change only widens the set of bindings that keep a class inside the IIFE (more conservative hoisting). Word-boundary matching (
class_body_references_any) already excludes.-prefixed property accesses, sothis.exportsetc. won't trip it. Worst case: a class that merely mentionsexports/module/requireas a bare identifier stays un-hoisted and is reached via_cjs.X— which still works.Maintainer note
No version /
CHANGELOG.md/Cargo.lockedits — left for fold-in at merge per CLAUDE.md.Summary by CodeRabbit
Bug Fixes
exports,module,require), preventing potential runtime errors.Tests