Skip to content

fix(module): complete Node 26 parity - #7312

Open
TheHypnoo wants to merge 10 commits into
mainfrom
fix/node-module-node26-parity
Open

fix(module): complete Node 26 parity#7312
TheHypnoo wants to merge 10 commits into
mainfrom
fix/node-module-node26-parity

Conversation

@TheHypnoo

@TheHypnoo TheHypnoo commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Complete Perry's Node.js 26.5.0 compatibility for node:module across CommonJS, loader hooks, SourceMap, resolution, and builtin-export identity.

Changes

  • Implement the Node 26 Module/CommonJS surface, cache lifecycle, cycles, JSON/CJS loading, parent/children links, and package resolution.
  • Add loader-hook ordering, SourceMap lookup/validation, TypeScript stripping, and compile-cache/source-map semantics.
  • Preserve builtin default/static identity and syncBuiltinESMExports() behavior.
  • Rebase the implementation onto current main (305698688) and fix the resulting parity, warnings, GC inventory, compiler-output metadata, and file-size regressions.

Related issue

Closes #6769

Validation

LLVM_SYS_221_PREFIX=/opt/homebrew/opt/llvm cargo build --release --quiet -p perry -p perry-runtime-static
PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --suite node-suite --module module
LLVM_SYS_221_PREFIX=/opt/homebrew/opt/llvm RUSTFLAGS='-D warnings' cargo check -p perry --bins
cargo fmt --all -- --check
./scripts/check_file_size.sh
python3 scripts/gc_store_site_inventory.py
python3 scripts/addr_class_inventory.py

Final node:module report (parity_report_20260804_115429.json):

Parity Pass:   69
Parity Fail:   0
Compile Fail:  0
Crashed:       0
Skipped:       0
Parity Rate:   100.0%

Focused regressions also pass:

  • test_gap_node_module_3118plus
  • test_gap_process_misc_3045plus
  • test_gap_diagchannel_3082_3084_3085_3086
  • test_gap_6301_event_target_subclass

The public benchmark freshness check remains red on current main: both the source and harness fingerprints already differ from the committed artifact. This PR does not modify benchmark inputs, expected results, or benchmark runners.

Checklist

  • No tests, fixtures, expected outputs, runners, skips, or timeouts changed
  • No workspace version, CLAUDE.md, or CHANGELOG.md changes from this PR
  • Conventional commits only
  • Full node:module gate passes with zero non-PASS counters

@TheHypnoo TheHypnoo added the type:bug Bug fix label Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds Node.js 26.5.0 node:module parity. It expands CommonJS resolution, caching, module lifecycle, native module exports, loader hooks, TypeScript stripping, SourceMap support, and compiler interop.

Changes

Node module parity

Layer / File(s) Summary
Compiler module graph and CJS wrapping
crates/perry/src/commands/compile/..., crates/perry-codegen/src/collectors/cjs_scaffolding.rs
Static require resolution, package exports, module discovery, AOT tracking, CJS records, cycles, parent links, and deferred factories are supported.
Dynamic import and top-level await analysis
crates/perry-hir/src/dynamic_import/..., crates/perry-hir/src/lower/expr_call/...
Top-level await detection and fixed-point local resolution are added. Callable native imports use dynamic .call(...) handling.
Native module namespaces and CJS interop
crates/perry-codegen/src/expr/..., crates/perry-runtime/src/object/...
Compiled namespaces are rooted and finalized. Native ESM snapshots, builtin synchronization, CJS statics, descriptors, constructor identity, and namespace tags are implemented.
Filesystem require and module lifecycle
crates/perry-runtime/src/module_require.rs, crates/perry-runtime/src/process/node_module.rs
createRequire, filesystem and package resolution, CJS and JSON loading, cache records, parent/children links, extensions, resolve, and paths are implemented.
SourceMap, loader hooks, and TypeScript runtime
crates/perry-runtime/src/process/node_module/..., crates/perry-runtime/src/process/node_module/source_map.rs
Composable resolve/load hooks, masked TypeScript stripping, SourceMap construction, mapping lookup, inline maps, and findSourceMap are added.
Runtime safety and compiler support
crates/perry-runtime/src/gc/..., crates/perry-codegen/src/..., crates/perry-runtime/src/url/node_compat.rs
GC root handling, unwind bindings, macOS stack APIs, test-only compilation gates, typed-array helpers, linker resolution, and file-URL helper extraction are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • PerryTS/perry#5874: Both PRs modify namespace materialization and native module interoperability.
  • PerryTS/perry#6489: The PRs overlap in node:module parity coverage across CommonJS loading, SourceMaps, exports, and builtin synchronization.
  • PerryTS/perry#6647: Both PRs modify module.createRequire signatures and runtime handling.

Suggested labels: parity, ready, rust

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Several changes appear unrelated to node:module parity, including EventTarget handling, unwind bindings, stack-map changes, typed-array helpers, and linker behavior. Remove unrelated changes or link them to separate issues, and keep this pull request focused on the node:module parity objectives.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address the linked issue’s CommonJS, resolution, loader, SourceMap, TypeScript, dynamic import, and builtin-export objectives, with all 69 parity tests passing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly and concisely identifies the main change: completing Node.js 26 module parity.
Description check ✅ Passed The description covers the required summary, changes, related issue, validation, and checklist with concrete test results.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/node-module-node26-parity
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/node-module-node26-parity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs (1)

138-195: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move the imported_callable guard after the existing .call special cases.

EventEmitter, Stream, and Server are registered as ApiKind::Class, so Server.call(this, handler) and { EventEmitter }.call(this, ...) / { Stream }.call(this, ...) hit this guard first and return Ok(Err(args)). Place the guard after the events, stream, and http/https Server branches so the constructor-forward paths keep priority.

🤖 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-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs`
around lines 138 - 195, The imported_callable guard in the method_name == "call"
branch currently preempts constructor special cases. Move it below the stream,
events, and http/https Server branches so those constructor-forward paths retain
priority, while preserving the guard’s existing behavior for other callable
imports.
crates/perry-runtime/src/object/descriptors.rs (1)

614-625: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root the class-method descriptor value.

The class-method branch also stores value into a raw descriptor field after js_object_alloc_with_shape; use a root handle and store the reloaded bits through get_nanbox_f64() before rebuilding layout.

🤖 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-runtime/src/object/descriptors.rs` around lines 614 - 625,
Update the class-method descriptor path around js_object_alloc_with_shape to
root value before allocation, then store the handle’s reloaded NaN-box bits via
get_nanbox_f64() into the raw descriptor field before rebuilding the layout.
Preserve the existing descriptor shape and publication flow.

Source: Learnings

🟠 Major comments (20)
crates/perry-runtime/src/process/node_module/source_map.rs-105-112 (1)

105-112: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Three sites re-implement the heap-address check instead of calling is_plausible_heap_addr. Each site takes a raw pointer from a NaN-boxed value, guards it with is_pointer() plus a null test, and then dereferences ptr.sub(crate::gc::GC_HEADER_SIZE) as a GcHeader. A pointer payload that is not a plausible heap address passes both guards and produces an out-of-bounds read. The shared root cause is the missing canonical predicate.

  • crates/perry-runtime/src/process/node_module/source_map.rs#L105-L112: in source_map_line_lengths_getter, replace the null test with crate::value::addr_class::is_plausible_heap_addr before the GcHeader read.
  • crates/perry-runtime/src/process/node_module/source_map.rs#L380-L383: in source_map_array_element, apply the same guard to the pointer taken on Line 376.
  • crates/perry-runtime/src/process/node_module/source_map.rs#L487-L488: in source_map_array_value, apply the same guard to the pointer taken on Line 483.

The PR summary states that this layer "adopts shared address-classification helpers". These three sites did not adopt them.

Based on learnings: "use the canonical predicate crate::value::addr_class::is_plausible_heap_addr for the handle-band/heap-floor check. Do not duplicate lower-level address checks elsewhere; review should fail code paths that re-implement or bypass is_plausible_heap_addr".

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 105
- 112, Replace the duplicated null-only heap checks with the canonical
crate::value::addr_class::is_plausible_heap_addr predicate before each GcHeader
dereference: update source_map_line_lengths_getter at
crates/perry-runtime/src/process/node_module/source_map.rs:105-112,
source_map_array_element at :380-383 using its pointer from line 376, and
source_map_array_value at :487-488 using its pointer from line 483; preserve
module_undefined() for invalid pointers and do not reimplement lower-level
address validation.

Source: Learnings

crates/perry-runtime/src/process/node_module/source_map.rs-681-688 (1)

681-688: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The marker match is too strict for common inline source maps.

Line 681 requires the exact prefix sourceMappingURL=data:application/json;base64,. Node matches the data URL with a pattern that also accepts a media-type parameter, so sourceMappingURL=data:application/json;charset=utf-8;base64, is valid. TypeScript and esbuild both emit that form. findSourceMap returns undefined for those files.

Match sourceMappingURL=data:application/json first, then accept an optional charset parameter before ;base64,.

Lines 684-688 have a related gap. .lines().next().trim() keeps a trailing */ when the comment is a block comment, and the base64 decode then fails. Stop the encoded text at the first character that is outside the base64 alphabet.

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 681
- 688, Update the source-map marker parsing in findSourceMap to accept data URLs
beginning with sourceMappingURL=data:application/json, including an optional
charset parameter before ;base64,. When extracting encoded, stop at the first
character outside the base64 alphabet so block-comment terminators such as */
are excluded before decoding.
crates/perry-runtime/src/process/node_module/source_map.rs-392-412 (1)

392-412: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The collected argument vector is not rooted across to_number().

source_map_collect_args copies raw NaN-boxed values into a Vec<f64>. source_map_arg_number then calls JSValue::to_number(), which runs valueOf or toString for an object argument. That is user code and a collection point.

source_map_find_origin_thunk coerces argument 0 on Line 602 and argument 1 on Line 603. If the first coercion triggers a moving collection, the pointer stored in the second element of the vector is stale when Line 603 reads it. source_map_find_entry_thunk has the same sequence on Lines 549-550.

Root the argument values before any coercion, for example by rooting each element in a RuntimeHandleScope inside source_map_collect_args and reading them back through handles.

Based on learnings that a NaN-boxed f64 held in Rust storage must be rooted and reloaded from its handle after an operation that can invoke user code or allocate. As per coding guidelines: "GC-managed values must remain rooted across every possible collection point".

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 392
- 412, Root every collected argument in source_map_collect_args using a
RuntimeHandleScope, and reload each value from its handle before returning or
coercing it. Ensure source_map_arg_number and the sequential coercions in
source_map_find_origin_thunk and source_map_find_entry_thunk never read stale
NaN-boxed values after to_number() may trigger user code or garbage collection.

Sources: Coding guidelines, Learnings

crates/perry-runtime/src/process/node_module/source_map.rs-612-620 (1)

612-620: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

line as i64 - 1 can overflow for a large negative argument.

source_map_arg_number accepts any finite double, including a large negative one. A float-to-integer cast saturates, so findOrigin(-1e300) produces i64::MIN and the following - 1 overflows. With debug-assertions or overflow-checks on, which the perry-dev profile normally enables, the process panics. The same applies to col.map(|n| n as i64 - 1).

Use saturating_sub.

🐛 Proposed fix
     let entry = if let Some(line) = line {
         source_map_lookup(
             payload,
-            line as i64 - 1,
-            col.map(|n| n as i64 - 1).unwrap_or(i64::MAX),
+            (line as i64).saturating_sub(1),
+            col.map(|n| (n as i64).saturating_sub(1))
+                .unwrap_or(i64::MAX),
         )
     } else {
🤖 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-runtime/src/process/node_module/source_map.rs` around lines 612
- 620, Update the one-based coordinate conversions in the source-map lookup
block to use saturating subtraction after casting to i64. Apply this to both the
line expression and the column mapping so large negative finite inputs clamp at
i64::MIN without overflowing, while preserving the existing i64::MAX fallback
behavior.
crates/perry-runtime/src/process/node_module/source_map.rs-309-329 (1)

309-329: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t attach the previous entry’s name to 4-field segments.

name_idx is cumulative across mappings, but a 4-field segment has no name association. Only 5-field segments should set the entry’s name; carry-over makes later named entries resolve the prior name instead of this segment’s name.

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 309
- 329, The mapping decode logic in the 4-field branch incorrectly carries
forward name_idx. Update the name selection within the original segment handling
so only 5-field segments set and return a name, while 4-field segments return no
name regardless of has_name or prior mappings; preserve cumulative source and
coordinate state.
crates/perry-runtime/src/object/native_module.rs-973-999 (1)

973-999: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not cache an undefined resolution in NATIVE_ESM_EXPORT_VALUES.

The function caches whatever native_module_property_by_name_impl returns, including undefined. Several resolution paths in this file depend on lazily installed state: the per-module dispatch rows installed by js_nm_install_<module>(), the fs.promises submodule singleton, and perf_hooks.performance. If a named ESM import reads a property before that state exists, the undefined result is pinned for the rest of the process, and only an explicit syncBuiltinESMExports() call clears it.

Skip the cache insert when the resolved value is undefined, so a later read can resolve the real export.

🐛 Proposed fix
     let value = unsafe {
         native_module_property_by_name_impl(
             module.as_ptr(),
             module.len(),
             property.as_ptr(),
             property.len(),
             false,
         )
     };
+    if value.to_bits() == crate::value::TAG_UNDEFINED {
+        return value;
+    }
     NATIVE_ESM_EXPORT_VALUES.with(|values| {
         values.borrow_mut().insert(key, value.to_bits());
     });
     crate::gc::runtime_write_barrier_root_nanbox(value.to_bits());
     value
🤖 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-runtime/src/object/native_module.rs` around lines 973 - 999,
Update js_native_module_esm_export_value so the result from
native_module_property_by_name_impl is inserted into NATIVE_ESM_EXPORT_VALUES
only when it is not undefined. Preserve the existing cache lookup and
write-barrier behavior for defined values, allowing later reads to retry
resolution when the initial result is undefined.
crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs-253-271 (1)

253-271: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prototype method names and attributes do not match Node.

Two parity gaps:

  • set_bound_native_closure_name(closure, "") discards the name argument, so Module.prototype.load.name is "" instead of "load".
  • The methods are installed with PropertyAttrs::new(true, true, true), which makes them enumerable. Node installs prototype methods as writable, non-enumerable, configurable, so for (const k in mod) must not yield _compile, load, or require.
🛠️ Proposed fix
-    set_bound_native_closure_name(closure.get_raw_mut_ptr(), "");
+    set_bound_native_closure_name(closure.get_raw_mut_ptr(), name);
             crate::object::set_property_attrs(
                 proto_ptr as usize,
                 name.to_string(),
-                crate::object::PropertyAttrs::new(true, true, true),
+                crate::object::PropertyAttrs::new(true, false, true),
             );

Also applies to: 366-380

🤖 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-runtime/src/object/native_module/callable_exports/module_cjs.rs`
around lines 253 - 271, Update module_prototype_method to pass the name argument
to set_bound_native_closure_name so each method reports its correct name, and
change the Module prototype method property descriptors from
PropertyAttrs::new(true, true, true) to writable, non-enumerable, configurable
attributes for _compile, load, and require.
crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs-235-271 (1)

235-271: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Module.prototype.require and Module.prototype._compile return undefined.

module_prototype_method_thunk ignores its arguments and returns undefined. load routes to js_module_instance_load, but require and _compile do not load anything. A program that calls mod.require("./x") receives undefined instead of the module exports.

crates/perry-runtime/src/process/node_module.rs already provides module_require_thunk, and crate::module_require::js_require_path_module performs the load. Do you want me to wire require to the existing require path and open an issue for _compile?

🤖 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-runtime/src/object/native_module/callable_exports/module_cjs.rs`
around lines 235 - 271, Update module_prototype_method_thunk and
module_prototype_method so the "require" method uses the existing
module_require_thunk and require path, allowing mod.require("./x") to return the
loaded module exports instead of undefined. Preserve the current load routing
through js_module_instance_load, and leave _compile behavior unchanged unless
existing symbols require a distinct mapping.
crates/perry-codegen/src/expr/dyn_extern_i18n.rs-417-469 (1)

417-469: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve namespace identity instead of materializing a fresh object for each read.

Expr::ExternFuncRef for ctx.namespace_imports currently calls materialize_compiled_namespace, which allocates, populates, finalizes, and releases a new namespace object on every lowering. Whole-value namespace reads should load the already-populated member globals from the module’s singleton namespace object instead.

🤖 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-codegen/src/expr/dyn_extern_i18n.rs` around lines 417 - 469,
Update materialize_compiled_namespace and the Expr::ExternFuncRef handling for
ctx.namespace_imports to return the module’s existing singleton namespace object
rather than allocating and populating a new object per read. Load the
already-populated namespace/member globals through the established namespace
representation, preserving namespace identity and avoiding the js_object_alloc,
member lowering, finalization, and release path for whole-value reads.
crates/perry-runtime/src/module_require.rs-211-239 (1)

211-239: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Both resolvers use Path::with_extension, which replaces an existing extension instead of appending one. Node's LOAD_AS_FILE appends .js, .json, and .node to the request. with_extension replaces everything after the last dot, so require('./index.min') probes index.js rather than index.min.js, and require('./v1.2') probes v1.js. Dotted filenames without an extension are common in published packages, and the compile-side and runtime-side resolvers must agree.

  • crates/perry-runtime/src/module_require.rs#L211-L239: in resolve_file, build each candidate by appending to the file name, for example path.with_file_name(format!("{}.{ext}", file_name)), instead of path.with_extension(ext).
  • crates/perry/src/commands/compile/collect_modules/static_require_transform.rs#L244-L253: apply the same append-based candidate construction in resolve_require_path, keeping its extension list unchanged so compile-time and runtime resolution stay identical.
🤖 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-runtime/src/module_require.rs` around lines 211 - 239, Update
resolve_file in crates/perry-runtime/src/module_require.rs (lines 211-239) and
resolve_require_path in
crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
(lines 244-253) to append each existing extension to the full filename rather
than replacing its current extension. Preserve the existing extension lists and
keep both resolvers’ candidate ordering identical.
crates/perry/src/commands/compile/cjs_wrap/wrap.rs-1082-1097 (1)

1082-1097: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Memoize the require-graph reachability walk.

cyclic_require_specs runs require_graph_reaches once per require specifier, and each call performs a fresh DFS that canonicalizes and reads every transitive dependency file. The visited set is per-call, so nothing is shared between specifiers or between modules. For a wrapped CJS module with N specifiers over a graph of E edges, the wrap performs O(N·E) canonicalize syscalls plus file reads, and wrap_commonjs_with_body_offset runs for every CJS file in the build. On a large node_modules graph this dominates collection time.

Two changes keep the behavior and bound the cost:

  • Build the dependency set of source_path once, then run a single DFS that records which specifiers reach source_key.
  • Cache the parsed specifier list per canonical path (and the reachability result per (path, target)) in a process-level map, so repeated wraps reuse it.

require_graph_reaches also recurses without a depth bound. Deep chains can exhaust the stack; an explicit worklist removes that risk while you restructure the walk.

Also applies to: 1177-1198

🤖 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/wrap.rs` around lines 1082 - 1097,
Refactor cyclic_require_specs and require_graph_reaches to memoize parsed
require specifiers by canonical path and reachability by (path, target) in
process-level caches. Build source_path’s dependency set once, then use one
iterative worklist traversal to record which resolved specifiers reach
source_key instead of running a fresh DFS per specifier. Preserve existing
resolution behavior while avoiding recursive stack growth and reusing cached
file reads and canonicalization across wrapped modules.
crates/perry-runtime/src/module_require.rs-802-805 (1)

802-805: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root MODULE_PATH_REGISTRY values through the GC scanner

MODULE_PATH_REGISTRY stores raw f64 bits and reads them back as exported values. Add it as a mutable root scanner in gc/mod.rs so copied-minor GC can mark these exports and rewrite the cached bits after the target object moves.

🤖 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-runtime/src/module_require.rs` around lines 802 - 805, Update
the GC root scanning implementation in gc/mod.rs to include MODULE_PATH_REGISTRY
as a mutable root. Scan each cached raw f64 value as a NaN-boxed export, mark it
during copied-minor GC, and rewrite the registry entry with the relocated bits
after objects move; preserve the existing registry access and locking behavior.

Source: Coding guidelines

crates/perry/src/commands/compile/collect_modules/static_require_transform.rs-133-151 (1)

133-151: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A resolvable .node addon now gains an AOT import edge and can fail the build.

resolve_require_path probes the node extension, so require('./build/Release/foo.node') now resolves. This branch keeps the runtime require but still emits import _lazyreq_static_N from "<abs>.node";. That import enters the module graph, and collect_module_one calls refuse_node_addon_binary on the resolved path, which hard-errors. Before this change the same call site produced no import edge, so the build succeeded and only the runtime require failed if reached.

Exclude node targets from the emitted import so a native addon keeps its runtime-only behavior.

🐛 Proposed fix
             if let Some(target) = require_target.as_ref() {
+                // A `.node` addon has no AOT representation; an import edge would
+                // reach `refuse_node_addon_binary` and fail the whole build.
+                let is_native_addon =
+                    target.extension().and_then(|e| e.to_str()) == Some("node");
                 if !matches!(
                     target.extension().and_then(|e| e.to_str()),
                     Some("ts" | "tsx" | "mts" | "cts" | "js" | "mjs")
                 ) {
-                    if discovered_side_effects.insert(target.clone()) {
+                    if !is_native_addon && discovered_side_effects.insert(target.clone()) {
                         let binding = unique_lazy_require_name(source, &mut next_id);
                         imports.push(format!(
                             "import {binding} from {:?};",
                             target.to_string_lossy()
                         ));
                     }
                     continue;
                 }
             }
🤖 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/collect_modules/static_require_transform.rs`
around lines 133 - 151, Update the extension filter in the require-target
handling branch so resolved .node targets are excluded from the emitted static
import. Preserve the existing runtime require path and side-effect imports for
the other non-JS extensions, while keeping native addons runtime-only as before.
crates/perry/src/commands/compile/collect_modules.rs-1286-1300 (1)

1286-1300: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce the two-key opt-in for AOT-promoted node_modules imports.

A resolved Interpreted node_modules file is now promoted to NativeCompiled and added to ctx.aot_discovered_modules; collect_module_one() then treats that path as is_in_compiled_pkg, so it avoids should_use_js_runtime and skips the final V8-free gate. That single imported file can enter the binary even when the owning package is not listed in ctx.compile_packages/ctx.allow_compile_packages. The same path also makes refuse_compile_package_native_addon(ctx, &canonical) reachable. Either gate single-file node_modules promotion with allow_compile_packages as part of compilePackages trust, or avoid promoting these discovered imports so the runtime JS failure stays explicit.

🤖 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/collect_modules.rs` around lines 1286 -
1300, Update the AOT promotion logic for resolved Interpreted imports in the
module collection flow so node_modules files are promoted only when both
compile_packages and allow_compile_packages authorize the owning package. Apply
this gate before inserting into ctx.aot_discovered_modules or changing the kind
to NativeCompiled, preserving the existing exclusions for Perry-native and
declaration files; otherwise retain the original resolved.kind so the V8-free
failure remains explicit.
crates/perry/src/commands/compile/run_pipeline.rs-2874-2890 (1)

2874-2890: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope default to the namespace member when claiming var exports.

imported_vars is flat in CrossModuleCtx, while namespace member metadata is keyed by (namespace, member). The loop inserts both "default" and "module.exports" unconditionally, so a later bare import { default } from ... or import default in the same module can be treated as var-shaped and routed through the getter. Keep the "module.exports" insert unguarded, but gate "default" on the module exporting a var-shaped default.

🤖 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/run_pipeline.rs` around lines 2874 - 2890,
Update the loop around namespace member handling so imported_vars always
includes "module.exports" but includes "default" only when the module’s default
export is var-shaped. Keep the namespace metadata and import prefix/origin
registrations unchanged, using the existing default-export shape information
available in this pipeline.
crates/perry-runtime/src/module_require.rs-263-269 (1)

263-269: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use Node exports condition order instead of scan order. Node exports resolution uses match order in the JSON object; "default" should only act as the final fallback when node or require does not precede it. Since serde_json has no preserve_order feature set, re-iterate without a fixed lookup order and return the first matching condition instead.

🤖 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-runtime/src/module_require.rs` around lines 263 - 269, The
exports-condition resolution loops in crates/perry-runtime/src/module_require.rs
lines 263-269 and
crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
lines 284-290 must honor the JSON object's iteration order rather than imposing
a fixed condition priority. In both corresponding resolution paths, iterate once
through each map, consider only node, require, and default, and return the first
condition whose resolve_exports result matches; this preserves default as a
fallback only when it appears after applicable conditions.
crates/perry-runtime/src/process/node_module.rs-1290-1309 (1)

1290-1309: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A multi-line type alias is only partially stripped.

The scan at lines 1299-1303 stops at the first \n or \r. For a common multi-line alias the remainder stays in the output:

type A = {
  x: number;
};

The pass blanks only type A = {. The lines x: number; and }; remain. The result is invalid JavaScript, so the stripped source fails to parse.

The alias body can be balanced by tracking {, [, ( depth on masked code bytes, and by stopping at a newline only when every depth is zero.

🐛 Proposed fix to balance the alias body
-        while cursor < bytes.len()
-            && !(mask[cursor] && matches!(bytes[cursor], b';' | b'\n' | b'\r'))
-        {
-            cursor += 1;
-        }
+        let mut depth = 0usize;
+        while cursor < bytes.len() {
+            if mask[cursor] {
+                match bytes[cursor] {
+                    b'{' | b'[' | b'(' => depth += 1,
+                    b'}' | b']' | b')' => depth = depth.saturating_sub(1),
+                    b';' | b'\n' | b'\r' if depth == 0 => break,
+                    _ => {}
+                }
+            }
+            cursor += 1;
+        }
🤖 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-runtime/src/process/node_module.rs` around lines 1290 - 1309,
Update the alias-scanning logic in the visible module-space stripping routine so
multi-line type aliases are consumed through their complete balanced body. Track
masked `{}`, `[]`, and `()` nesting while scanning after `=`, and only treat
`\n` or `\r` as terminators when all nesting depths are zero; preserve semicolon
termination and call `module_space_span` over the entire alias.
crates/perry-runtime/src/process/node_module.rs-1156-1201 (1)

1156-1201: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

The mask misclassifies nested template literals and regex literals.

Two lexical forms break the state machine:

  1. Nested template literals. The TEMPLATE state does not track ${ ... } interpolation depth. For `a${`b`}c`, the inner opening backtick ends the outer template. The following backtick then re-opens a template. Every byte after that point is classified with inverted polarity, so real code is treated as string content and string content is treated as code.
  2. Regex literals. A / that starts a regex is neither // nor /*, so the state stays CODE. A {, interface, type, or as inside a regex body is then treated as code. module_strip_interfaces and module_strip_type_clause can space out real source in that case.

Both cases can corrupt the stripped output rather than fail loudly. Track interpolation depth for ${ and } in the TEMPLATE state, and add a regex-literal state that uses the previous significant code byte to distinguish division from a regex start.

🤖 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-runtime/src/process/node_module.rs` around lines 1156 - 1201,
Update the masking state machine to track nested template interpolation depth,
switching between template text and embedded code at `${` and matching `}` so
nested backticks remain correctly classified. Add a regex-literal state and use
the previous significant code byte to distinguish regex starts from division,
while handling escapes and regex character classes. Preserve existing comment,
string, and template masking behavior for non-nested literals.
crates/perry-runtime/src/process/node_module.rs-127-137 (1)

127-137: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the parent as an object, not as any non-string pointer.

has_parent accepts every pointer value that is not a string. A closure, an array, or a typed handle passes this check. nm_dispatch_module maps ("module", "Module") to js_module_module_new(arg(0), arg(1)), so user code can pass any value as parent. Lines 147-151 then treat that pointer as an ObjectHeader and read field index 5 as an array. For a closure receiver this reads past the closure payload.

module_object_ptr already validates the GC object type. Use it for the check.

🛡️ Proposed fix to classify the parent value
-    let parent_value = JSValue::from_bits(parent.get_nanbox_f64().to_bits());
-    let has_parent = parent_value.is_pointer() && !parent_value.is_string();
+    let has_parent = module_object_ptr(parent.get_nanbox_f64()).is_some();

Based on learnings: "when classifying raw pointers for … receiver/address classification logic, use the canonical predicate … Do not duplicate lower-level address checks elsewhere".

🤖 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-runtime/src/process/node_module.rs` around lines 127 - 137,
Update the has_parent classification in the parent initialization flow to use
the canonical module_object_ptr validation instead of accepting every non-string
pointer. Preserve module_null() for invalid or non-object parents, ensuring only
validated object pointers reach the later ObjectHeader field access.

Source: Learnings

crates/perry-runtime/src/process/node_module.rs-1278-1289 (1)

1278-1289: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Blank the whole import type / export type statement during stripping.

module_strip_type_aliases only blanks type aliases, and the later strips only handle satisfies/as clauses. Statements like import type { A } from "./types";, export type { A };, and import type A from "./a"; remain in the emitted JavaScript, and can import runtime-less type modules.

🤖 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-runtime/src/process/node_module.rs` around lines 1278 - 1289,
Update module_strip_type_aliases to detect import type and export type
statements before processing type aliases, then blank each complete statement
through its terminator while preserving surrounding code. Handle named, default,
and from-based forms such as import type { A }, export type { A }, and import
type A, without stripping ordinary imports or exports.
🟡 Minor comments (14)
crates/perry-runtime/src/process/node_module/source_map.rs-649-656 (1)

649-656: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the existing path-to-file-URL path for inline source sources.

format!("file://{}", path.to_string_lossy()) does not percent-encode spaces or special characters, and it leaves Windows and \\?\ paths in an invalid file: URL shape. Use js_url_path_to_file_url/the existing url helper with the current platform so SourceMap#payload.sources matches Node.

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 649
- 656, Update the source URL construction in the source-map sources population
flow to use the existing js_url_path_to_file_url or url helper with the current
platform instead of formatting file:// directly from the path. Preserve the
canonicalize fallback and ensure inline source entries are percent-encoded and
correctly shaped across POSIX, Windows, and \\?\ paths.
crates/perry-runtime/src/process/node_module/source_map.rs-231-235 (1)

231-235: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Set SourceMap.prototype property attributes explicitly.

closure_set_dynamic_prop only stores the prototype value; it does not record descriptor attributes, so prototype remains writable/enumerable/configurable unless the object property exists under a different name. Add a non-writable, non-enumerable, non-configurable descriptor for prototype, matching other class-constructor prototype install patterns.

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 231
- 235, Update the constructor prototype installation near
closure_set_dynamic_prop so SourceMap.prototype is defined with non-writable,
non-enumerable, and non-configurable attributes. Reuse the existing
class-constructor prototype descriptor mechanism rather than only storing the
value through closure_set_dynamic_prop.
crates/perry-runtime/src/process/node_module/source_map.rs-386-388 (1)

386-388: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

index as u32 truncates and can pass the bound check.

index is an i64 that comes from decoded VLQ deltas, so a crafted mappings string controls it. For a value such as 0x1_0000_0000, the integer-to-integer cast truncates to 0 and the comparison against len succeeds. The function then returns sources[0] instead of undefined.

Compare in i64 before the cast.

🐛 Proposed fix
-    if index as u32 >= len {
+    if index >= i64::from(len) {
         return undefined_value();
     }
🤖 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-runtime/src/process/node_module/source_map.rs` around lines 386
- 388, Update the bounds check around the source lookup to compare the i64 index
directly against len before casting, then cast only after validation. Preserve
the existing undefined_value() return for out-of-range indices and prevent large
decoded VLQ values from truncating into valid indices.
crates/perry-runtime/src/process/node_module/source_map.rs-51-77 (1)

51-77: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Make the SourceMap internal slots non-enumerable.

The _payload and _lineLengths fields are inserted into keys_array, but they are not marked non-enumerable; js_object_set_field/js_object_alloc_with_shape do not set descriptor attributes. Keep these as real internal slots, but mark the shaped keys non-enumerable through descriptor attributes or hide them from the observable keys array if they should not appear on the instance shape.

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 51 -
77, Update the SourceMap object construction around SOURCE_MAP_CLASS_ID so
`_payload` and `_lineLengths` remain usable internal slots but are marked
non-enumerable in their property descriptors. Reuse the existing shape or
descriptor-attribute mechanism instead of changing field storage, and ensure the
keys are absent from observable enumeration while remaining accessible
internally.
crates/perry-runtime/src/process/node_module/source_map.rs-689-691 (1)

689-691: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use an indifferent-padding base64 engine for inline source maps.

base64::engine::general_purpose::STANDARD in the workspace-pinned base64 0.22.1 requires canonical padding and rejects unpadded data: payloads. Decode this sourceMappingURL with a GeneralPurpose engine configured with decode_padding_mode(DecodePaddingMode::Indifferent).

🤖 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-runtime/src/process/node_module/source_map.rs` around lines 689
- 691, Update the inline source-map decoding in the surrounding sourceMappingURL
handling to use a GeneralPurpose base64 engine configured with
DecodePaddingMode::Indifferent instead of STANDARD, while preserving the
existing module_undefined fallback for decode failures.
crates/perry-runtime/src/object/native_module.rs-1037-1040 (1)

1037-1040: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

js_module_run_main is a silent no-op.

In Node, module.runMain() executes the main entry point. This stub returns undefined and performs no work, so a program that relies on runMain() continues silently with no effect and no diagnostic. Add a comment that records the intentional limitation, or emit a deferred "not implemented" error so the gap is observable.

I can open an issue to track the real implementation. Tell me if you want that.

🤖 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-runtime/src/object/native_module.rs` around lines 1037 - 1040,
Update js_module_run_main to make its unsupported behavior observable: either
add a comment documenting that module.runMain execution is intentionally
unavailable, or emit a deferred “not implemented” error while preserving the
exported function’s ABI and return contract.
crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs-78-110 (1)

78-110: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

globalPaths depends on HOME and PREFIX only.

On Windows HOME is usually unset, so the two per-user entries are dropped and only /usr/local/lib/node remains. Node derives the list from USERPROFILE on Windows and from the execution prefix elsewhere. Consider falling back to USERPROFILE and to a platform-appropriate prefix.

🤖 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-runtime/src/object/native_module/callable_exports/module_cjs.rs`
around lines 78 - 110, Update module_cjs_global_paths_value to use USERPROFILE
as the Windows fallback when HOME is unavailable, while preserving HOME-based
entries elsewhere. Replace the hard-coded "/usr/local" default for PREFIX with a
platform-appropriate execution-prefix fallback, keeping the existing PREFIX
environment override and generated path structure intact.
crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs-390-405 (1)

390-405: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Accessor descriptors are installed as non-configurable.

install_accessor applies PropertyAttrs::new(false, false, false) to constructor, isPreloading, and parent. In Node these are configurable, and Module.prototype.constructor is also writable. With configurable: false, Object.defineProperty and delete on those keys fail, and getOwnPropertyDescriptor reports the wrong flags.

🤖 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-runtime/src/object/native_module/callable_exports/module_cjs.rs`
around lines 390 - 405, Update the accessor installation for Module prototype
properties in the surrounding initialization code so constructor, isPreloading,
and parent use configurable descriptors, while preserving parent’s setter and
making constructor writable as required. Ensure getOwnPropertyDescriptor,
Object.defineProperty, and delete reflect Node’s expected flags by adjusting the
relevant install_accessor/property attribute handling rather than changing
getter behavior.
crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs-238-239 (1)

238-239: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align SourceMap arity with Node 26.

findPackageJSON(specifier[, base]) has arity 1 because base has a default value. module.findSourceMap(path) also has arity 1. However, new SourceMap(payload[, { lineLengths }]) omits the lineLengths object, so its Node 26 length is 2; also update the exhaustive reference entry.

🤖 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-runtime/src/object/native_module/callable_export_arity_table.rs`
around lines 238 - 239, Update the callable arity table entry for module
SourceMap to return arity 2, reflecting the constructor’s optional second
argument, while keeping findPackageJSON and findSourceMap at arity 1. Update the
corresponding exhaustive reference entry for SourceMap as well.
crates/perry-runtime/src/module_require.rs-410-433 (1)

410-433: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

link_parent sets parent but never appends the child to parent.children.

The compile-side shim in crates/perry/src/commands/compile/cjs_wrap/wrap.rs pushes the child record into module.children (the link_child fragment). The runtime path does not. A module reached through require_path therefore gets a parent link while the parent's children array stays empty, so the two directions of the relationship disagree.

The synthetic parent created on Line 424 also has only an id field and no children array, so appending requires allocating one. Add the array to the synthetic parent, then push the child record when it is not already present.

🤖 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-runtime/src/module_require.rs` around lines 410 - 433, Update
link_parent to initialize a children array on synthetic parents, then append the
record to the resolved parent’s children array when it is not already present.
Preserve the existing parent-link and cache behavior, and mirror the child
insertion semantics used by the compile-side link_child fragment.
crates/perry/src/commands/compile/cjs_wrap/wrap.rs-385-408 (1)

385-408: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The parent handoff into a child module load is not exception-safe in either layer. Both layers publish the requiring module, load the child, then clear the published value on the next statement. Neither clears it when the child load exits abruptly. A MODULE_NOT_FOUND throw or any user-level throw during child evaluation leaves the handoff set, and the next module that consumes it receives an unrelated module.parent. try/catch around require is common in CommonJS, so the abrupt path is reachable in normal programs.

  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs#L385-L408: wrap the emitted __perry_require_path_module(...) call in try { ... } finally { globalThis.__perry_cjs_pending_parent = undefined; } so the generated shim clears the global on both paths.
  • crates/perry-runtime/src/module_require.rs#L443-L450: replace the set-call-take sequence with a guard type whose Drop clears PENDING_REQUIRE_PARENT, so an unwind out of js_require_path_module cannot leave a stale parent filename in the thread-local.
🤖 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/wrap.rs` around lines 385 - 408,
Make both parent-handoff layers exception-safe: in
crates/perry/src/commands/compile/cjs_wrap/wrap.rs lines 385-408, update the
emitted code around __perry_require_path_module to clear
globalThis.__perry_cjs_pending_parent in a finally block; in
crates/perry-runtime/src/module_require.rs lines 443-450, replace the
set-call-take sequence in js_require_path_module with a guard type whose Drop
clears PENDING_REQUIRE_PARENT during unwinding. Ensure normal and abrupt child
loads leave no stale parent handoff.
crates/perry/src/commands/compile/collect_modules/static_require_transform.rs-439-441 (1)

439-441: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the orphaned doc comment.

This doc comment describes the relative-specifier existence helper that the change deleted. It now documents whatever item follows it, and its text ("Non-relative specifiers return true so they keep today's hoisting") no longer matches any code in the file. Delete it, or move it onto resolve_static_require with corrected wording.

🤖 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/collect_modules/static_require_transform.rs`
around lines 439 - 441, Remove the orphaned doc comment preceding the deleted
relative-specifier helper, since it no longer documents the following code. Do
not move it unless attaching it to resolve_static_require with wording that
accurately describes that function’s current behavior.
crates/perry-runtime/src/process/node_module.rs-1083-1103 (1)

1083-1103: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve load hook results for dynamic import parity.

js_module_dynamic_import_apply_hooks calls the load chain at lines 1083-1100, but discards its value and returns the resolved URL unchanged. Add one of the following:

  • If this API is intentionally observe-only, document that load hooks receive only call-order/deregistration semantics and cannot rewrite the imported module.
  • If the hook result should affect execution, pass the returned { url, source, format } from the active load chain into the dynamic-import emitter instead of ignoring it.
🤖 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-runtime/src/process/node_module.rs` around lines 1083 - 1103,
Update js_module_dynamic_import_apply_hooks so the result of the active
load_chain is not discarded: capture the returned { url, source, format } value
and pass it into the dynamic-import emitter so load hooks can rewrite module
execution. If this API is intentionally observe-only, instead document that
contract explicitly and preserve the unchanged URL behavior.
crates/perry-runtime/src/process/node_module.rs-505-512 (1)

505-512: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use ERR_INVALID_ARG_TYPE for non-string specifiers.

Node rejects a non-string/non-URL specifier in module.findPackageJSON(specifier) with TypeError [ERR_INVALID_ARG_TYPE]. Keep ERR_MODULE_NOT_FOUND only for the empty-string resolution failure so type errors are not mixed with search failures.

🤖 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-runtime/src/process/node_module.rs` around lines 505 - 512,
Update the specifier validation around js_string_key_bytes so non-string/non-URL
values throw a TypeError with code ERR_INVALID_ARG_TYPE, while preserving
ERR_MODULE_NOT_FOUND exclusively for empty-string package resolution failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b6b5ae57-657b-4c16-93b1-37688bfbe354

📥 Commits

Reviewing files that changed from the base of the PR and between b50e857 and 3a4dcac.

📒 Files selected for processing (35)
  • changelog.d/7312-node-module-node26-parity.md
  • crates/perry-codegen/src/collectors/cjs_scaffolding.rs
  • crates/perry-codegen/src/expr/dyn_extern_i18n.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/lower_call/native_module_dispatch.rs
  • crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-hir/src/dynamic_import.rs
  • crates/perry-hir/src/dynamic_import/top_level_await.rs
  • crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/object/descriptors.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/namespace_create.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs
  • crates/perry-runtime/src/object/native_module/callable_exports.rs
  • crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs
  • crates/perry-runtime/src/object/native_module/module_keys.rs
  • crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs
  • crates/perry-runtime/src/object/to_string_tag.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry-runtime/src/process/env_misc.rs
  • crates/perry-runtime/src/process/node_module.rs
  • crates/perry-runtime/src/process/node_module/source_map.rs
  • crates/perry-runtime/src/symbol/get.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/discovery.rs
  • crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/types.rs

Comment thread crates/perry-runtime/src/module_require.rs
Comment thread crates/perry-runtime/src/module_require.rs
Comment thread crates/perry-runtime/src/process/node_module.rs
Comment thread crates/perry-runtime/src/process/node_module/source_map.rs Outdated
Comment thread crates/perry-runtime/src/process/node_module/source_map.rs
Comment thread crates/perry-runtime/src/symbol/get.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/perry-runtime/src/module_require.rs (3)

619-648: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Reload handler_ptr after the allocating string_value call.

handler_ptr (Line 632-633) is a raw usize extracted from handler_handle before string_value(&filename) runs (Line 639). string_value can allocate and trigger GC. If the closure is moved during that allocation, handler_ptr is stale, and js_closure_call2 at Line 640-644 dereferences a dangling pointer. Reload the pointer from handler_handle right before the call, matching the pattern already used for factory_handle earlier in this file.

🔒️ Proposed fix
                 let handler_handle = scope.root_nanbox_f64(f64::from_bits(handler.bits()));
                 let handler_ptr =
                     crate::value::js_nanbox_get_pointer(handler_handle.get_nanbox_f64()) as usize;
                 if !handler.is_pointer() || !crate::closure::is_closure_ptr(handler_ptr) {
                     crate::process::module_throw_plain_type_error(
                         "Module._extensions[extension] is not a function",
                     );
                 }
                 let filename_value = string_value(&filename);
+                let handler_ptr =
+                    crate::value::js_nanbox_get_pointer(handler_handle.get_nanbox_f64()) as usize;
                 crate::closure::js_closure_call2(
                     handler_ptr as *mut ClosureHeader,
                     record_handle.get_nanbox_f64(),
                     filename_value,
                 );

Based on learnings, "if you hold an object/value represented as a NaN-boxed f64 and you then perform an allocating or user-code-invoking operation... root the value using crate::gc::RuntimeHandleScope and reload it from the rewritten handle (e.g., via get_nanbox_f64()) before any subsequent reuse."

🤖 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-runtime/src/module_require.rs` around lines 619 - 648, In
run_custom_extension, do not reuse the handler_ptr extracted before
string_value(&filename), since that call may move the closure during GC. Reload
the closure pointer from the rooted handler_handle immediately before
js_closure_call2, preserving the existing validation and invocation flow.

Source: Learnings


518-572: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Move the require-cache guard before __perry_cjs_factory execution.

AOT CJS wrappers register the module early while loaded is false, then set loaded = true only after the wrapper runs. For a cyclic require, the nested call can hit this AOT factory branch and call the same factory while the parent wrapper is still executing. Return the early-registered pending module record from require_path (or mark loaded pending) before invoking js_closure_call0(factory), and let cached-exports handling apply after the factory returns.

🤖 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-runtime/src/module_require.rs` around lines 518 - 572, Move the
cached_record guard in require_path ahead of js_closure_call0 so cyclic requires
return the already-registered pending module record before invoking
__perry_cjs_factory. Preserve link_parent and run_custom_extension for this
early-return path, and retain the existing cached-exports handling after the
factory completes.

412-495: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cache the synthesized placeholder parent.

When cached_parent is undefined, link_parent allocates a placeholder and returns it, but it never writes it back into cache_handle under cache_key. Repeated requires from the same uncached parent, such as ambient require('a') followed by require('b'), therefore get distinct module.parent objects with separate children arrays. Store the placeholder in cache_handle after allocation so siblings share one parent object.

🤖 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-runtime/src/module_require.rs` around lines 412 - 495, Update
link_parent’s cached_parent.is_undefined() branch to store the newly allocated
placeholder parent in cache_handle under cache_key before returning it, while
preserving the existing parent initialization and reuse behavior for cached
entries.
🤖 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.

Outside diff comments:
In `@crates/perry-runtime/src/module_require.rs`:
- Around line 619-648: In run_custom_extension, do not reuse the handler_ptr
extracted before string_value(&filename), since that call may move the closure
during GC. Reload the closure pointer from the rooted handler_handle immediately
before js_closure_call2, preserving the existing validation and invocation flow.
- Around line 518-572: Move the cached_record guard in require_path ahead of
js_closure_call0 so cyclic requires return the already-registered pending module
record before invoking __perry_cjs_factory. Preserve link_parent and
run_custom_extension for this early-return path, and retain the existing
cached-exports handling after the factory completes.
- Around line 412-495: Update link_parent’s cached_parent.is_undefined() branch
to store the newly allocated placeholder parent in cache_handle under cache_key
before returning it, while preserving the existing parent initialization and
reuse behavior for cached entries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a1482bc-be29-4b1b-a7ad-af42ce0c08a0

📥 Commits

Reviewing files that changed from the base of the PR and between 3a4dcac and 2ff3435.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • Cargo.toml
  • crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry-runtime/src/process/node_module.rs
  • crates/perry-runtime/src/process/node_module/source_map.rs
  • crates/perry-runtime/src/symbol/get.rs
  • crates/perry-runtime/src/url/node_compat.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
🚧 Files skipped from review as they are similar to previous changes (11)
  • crates/perry-runtime/src/symbol/get.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
  • crates/perry-runtime/src/object/native_module/callable_exports/module_cjs.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/process/node_module.rs
  • crates/perry-runtime/src/process/node_module/source_map.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
crates/perry/src/commands/compile/collect_modules/static_require_transform.rs (1)

363-380: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Complete the exports selection before resolving targets.

resolve_require_exports() returns a target for every key when exports is a string or array, and it falls back to root conditions when no exact ./features/* entry exists. As a result, require("pkg/features/foo") may resolve only "." when "." is the only root export, and package_subpath_is_blocked() returns the same incorrect allow/block decision.

Match ".", the exact subpath, or a matching export pattern first, then iterate declared child maps in order with the standard CJS condition set including node-addons, module-sync, require, node, and default.

🤖 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/collect_modules/static_require_transform.rs`
around lines 363 - 380, The resolve_require_exports function must select the
appropriate export entry before resolving its target: only treat "." or the
exact key as direct matches, and support matching export patterns for subpaths
instead of returning arbitrary string/array or root-condition targets. For
nested child maps, iterate declarations in order using the CJS conditions
node-addons, module-sync, require, node, and default, then resolve the selected
target so package_subpath_is_blocked receives the correct result.
crates/perry-codegen/src/expr/index_set.rs (1)

1610-1734: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Root obj_box and idx_box before lowering value in the dynamic index-set fallback.

obj_box and idx_box are lowered before lower_value_for_dynamic_index_set, then live through allocation points inside that call. That leaves the receiver/key registers naming from-space after a GC move. Add a receiver guard for object, a non-literal key guard for index, re-read them after the value lowering, and release the key guard before the receiver guard.

🤖 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-codegen/src/expr/index_set.rs` around lines 1610 - 1734, In the
dynamic index-set fallback, update the lowering flow around
lower_value_for_dynamic_index_set to guard object and non-literal index roots
before lowering value, then re-lower or re-read obj_box and idx_box afterward so
they reference current-space values. Release the index guard before releasing
the receiver guard, while preserving the existing symbol/string/numeric dispatch
logic.

Source: Learnings

crates/perry-runtime/src/gc/roots/stack_maps.rs (2)

491-512: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Use the encoded entry width for record counts.

When header bit 0 is clear, entry is 12 bytes. Line 528 still uses a 16-byte stride and offset. It reads an instruction offset instead of the function record count.

The decoder then computes the wrong stream start and rejects or misdecodes every ILP32 map. The collector receives no valid native roots.

Use entry for the stride and entry - 4 for the record-count offset. Add a 32-bit-entry parser test.

🤖 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-runtime/src/gc/roots/stack_maps.rs` around lines 491 - 512,
Update the stack-map decoder’s function-table parsing to use the computed entry
width: apply entry as the record stride and entry - 4 as the record-count field
offset, including the logic around stream-start calculation and record decoding.
Preserve the existing 64-bit behavior while correctly parsing 32-bit entries,
and add a parser test covering a 32-bit-entry map.

Source: Coding guidelines


280-319: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Decode shifted AArch64 stack adjustments.

The masks require the shift bit to be zero. AArch64 sub sp, sp, #imm, lsl #12`` does not match this branch.

For a trailing shifted allocation, fp_to_sp_offset stops early and returns a stack-pointer offset that is too small. The fast walker then visits incorrect slots and can miss live roots.

Ignore the shift bit in the opcode masks. Apply the shift when decoding the immediate. Handle the same encoding for add x29, sp, #imm`` if it occurs.

🤖 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-runtime/src/gc/roots/stack_maps.rs` around lines 280 - 319,
Update the AArch64 instruction masks and immediate decoding in the stack-map
prologue scan around ADD_FP_SP_MASK, ADD_FP_SP_PATTERN, SUB_SP_SP_MASK, and
SUB_SP_SP_PATTERN to ignore the shift bit and support both immediate shift
modes. Decode the 12-bit immediate and apply the encoded lsl `#12` shift for add
x29, sp and trailing sub sp, sp instructions so fp_offset accumulates the true
byte offset.

Source: Coding guidelines

🤖 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.

Inline comments:
In `@crates/perry-codegen/src/gc_map.rs`:
- Around line 898-900: Update the arch_supported logic in the target
classification flow to use an explicit platform-and-pointer-width allowlist
matching the runtime loader and walkers. Exclude arm64_32 targets before any
arm64 prefix match, and reject otherwise unsupported operating-system targets
rather than enabling native GC maps based solely on architecture prefixes.

In `@crates/perry-hir/src/lower/expr_member.rs`:
- Around line 419-427: Remove the process.namespace exception from the guard in
the member-lowering path around process_metadata_native_property, so namespace
imports no longer use process_native_property for sourceMapsEnabled. Ensure
p.sourceMapsEnabled falls through to the existing live NativeMethodCall branch,
matching bare process and globalThis.process behavior while preserving other
metadata property handling.

---

Outside diff comments:
In `@crates/perry-codegen/src/expr/index_set.rs`:
- Around line 1610-1734: In the dynamic index-set fallback, update the lowering
flow around lower_value_for_dynamic_index_set to guard object and non-literal
index roots before lowering value, then re-lower or re-read obj_box and idx_box
afterward so they reference current-space values. Release the index guard before
releasing the receiver guard, while preserving the existing
symbol/string/numeric dispatch logic.

In `@crates/perry-runtime/src/gc/roots/stack_maps.rs`:
- Around line 491-512: Update the stack-map decoder’s function-table parsing to
use the computed entry width: apply entry as the record stride and entry - 4 as
the record-count field offset, including the logic around stream-start
calculation and record decoding. Preserve the existing 64-bit behavior while
correctly parsing 32-bit entries, and add a parser test covering a 32-bit-entry
map.
- Around line 280-319: Update the AArch64 instruction masks and immediate
decoding in the stack-map prologue scan around ADD_FP_SP_MASK,
ADD_FP_SP_PATTERN, SUB_SP_SP_MASK, and SUB_SP_SP_PATTERN to ignore the shift bit
and support both immediate shift modes. Decode the 12-bit immediate and apply
the encoded lsl `#12` shift for add x29, sp and trailing sub sp, sp instructions
so fp_offset accumulates the true byte offset.

In
`@crates/perry/src/commands/compile/collect_modules/static_require_transform.rs`:
- Around line 363-380: The resolve_require_exports function must select the
appropriate export entry before resolving its target: only treat "." or the
exact key as direct matches, and support matching export patterns for subpaths
instead of returning arbitrary string/array or root-condition targets. For
nested child maps, iterate declarations in order using the CJS conditions
node-addons, module-sync, require, node, and default, then resolve the selected
target so package_subpath_is_blocked receives the correct result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6dd0ac79-1e66-46ff-9fd2-769c43b849ba

📥 Commits

Reviewing files that changed from the base of the PR and between 90f4c1d and eec60ef.

📒 Files selected for processing (22)
  • crates/perry-codegen/src/dialect/mod.rs
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/index_set_helpers.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-codegen/src/gc_map.rs
  • crates/perry-codegen/src/linker.rs
  • crates/perry-codegen/src/statepoint_report.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • crates/perry-hir/src/lower/expr_member.rs
  • crates/perry-runtime/src/eh.rs
  • crates/perry-runtime/src/eh_walker.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/roots/stack_maps.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/process/node_module.rs
  • crates/perry-runtime/src/process/node_module/source_map.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/collect_modules/static_require_transform.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/types.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry/src/commands/compile/types.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry-runtime/src/process/node_module/source_map.rs
  • crates/perry-runtime/src/module_require.rs

Comment thread crates/perry-codegen/src/gc_map.rs
Comment thread crates/perry-hir/src/lower/expr_member.rs
…6-parity

# Conflicts:
#	crates/perry-codegen/src/expr/index_set.rs
#	crates/perry-codegen/src/expr/mod.rs
@jdalton

jdalton commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Heads-up (not something to fix on your side): the failing native-roots-rs4gc (ubuntu-24.04-arm, aarch64, ELF) check here isn't caused by this PR. It's a toolchain gap in gc-native-roots.yml that landed on main in #7360 — the aarch64-Linux runner image ships llvm-22's opt but no co-located clang, and the setup only installed a toolchain when opt was missing, so the pair-check fails. Every PR inheriting that workflow shows the same red arm.

The fix is up in #7384. Once it merges, re-running this arm on your branch (or rebasing onto the updated main) will go green. It's also not a required status check, so it won't block your merge in the meantime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[parity] node:module — 39 failing node-suite tests (2026-07-22 baseline)

2 participants