Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/7312-node-module-node26-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Complete Node.js 26.5.0 `node:module` parity (#6769):** Perry now matches the
full tested Module/CommonJS surface, including resolution and cache lifecycle,
loader hooks, SourceMap behavior, TypeScript stripping, descriptors, and
builtin default/named export identity and synchronization.
36 changes: 28 additions & 8 deletions crates/perry-codegen/src/collectors/cjs_scaffolding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,13 +336,15 @@ impl CjsPreamble {
Stmt::Let { id, .. } => id == record,
Stmt::Expr(expr) => match expr {
Expr::ObjectDefineProperty(..) => scaffolding.exempts_shape_barrier(expr),
Expr::PutValueSet { target, key, .. } => {
Expr::PutValueSet {
target, key, value, ..
} => {
matches!(
(target.as_ref(), key.as_ref()),
(Expr::LocalGet(id), Expr::String(k))
if scaffolding.require.contains(id)
&& REQUIRE_LITERAL_KEYS.contains(&k.as_str())
)
) && matches!(value.as_ref(), Expr::New { .. })
}
_ => false,
},
Expand Down Expand Up @@ -484,18 +486,36 @@ fn record_binding(stmt: &Stmt) -> Option<u32> {
if !class_name.starts_with(ANON_SHAPE_PREFIX) {
return None;
}
// Exactly one field, whose value is an argument-less object literal. A
// record with more fields, or a non-literal field value, is not the
// template's `{ exports: {} }` and keeps its candidacy.
let [Expr::New {
// The first field is the argument-less `exports` object literal. Lowering
// may fold the wrapper's seven subsequent fixed fields into the same
// anonymous constructor, but no other multi-field record is scaffolding.
let Some(Expr::New {
class_name: inner,
args: inner_args,
..
}] = args.as_slice()
}) = args.first()
else {
return None;
};
(inner.starts_with(ANON_SHAPE_PREFIX) && inner_args.is_empty()).then_some(*id)
if !inner.starts_with(ANON_SHAPE_PREFIX) || !inner_args.is_empty() {
return None;
}
let folded_template = matches!(
args.as_slice(),
[
_,
Expr::Bool(true),
factory,
Expr::String(id_value),
Expr::String(_path),
Expr::String(filename),
Expr::Bool(false),
Expr::Array(children),
] if matches!(factory, Expr::LocalGet(_) | Expr::Undefined)
&& id_value == filename
&& children.is_empty()
);
(args.len() == 1 || folded_template).then_some(*id)
}

/// R4: `var module = __cjs_module;` at the region's top level.
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod tests;
/// native path pre-declares every define before reading any body — calls to
/// module-internal functions are forward references at module scope, exactly
/// like registers are at function scope.
#[cfg(test)]
pub(crate) fn predeclare_function_from_text<'ctx>(
context: &'ctx Context,
module: &Module<'ctx>,
Expand Down Expand Up @@ -104,6 +105,7 @@ impl<'ctx, 'm> FnStream<'ctx, 'm> {

/// Parse `fn_text` (a complete `define ... { ... }`) and build it into
/// `module`. Returns the number of instructions constructed.
#[cfg(test)]
pub(crate) fn add_function_from_text<'ctx>(
context: &'ctx Context,
module: &Module<'ctx>,
Expand Down
154 changes: 59 additions & 95 deletions crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,60 @@ fn emit_i18n_row_value(
Ok(ctx.block().load(DOUBLE, &result_slot))
}

/// Materialize a compiled-module namespace through the same runtime constructor
/// used by dynamic import. Namespace locals may also have a default-export
/// prefix for direct-call compatibility, so whole-value reads must take this
/// path before generic imported function/variable lowering.
fn materialize_compiled_namespace(ctx: &mut FnCtx<'_>, name: &str) -> Result<Option<String>> {
let mut members: Vec<String> = ctx
.namespace_member_prefixes
.keys()
.filter(|(namespace, _)| namespace == name)
.map(|(_, member)| member.clone())
.collect();
if members.is_empty() {
return Ok(None);
}
members.sort();
members.dedup();
let count = (members.len() as u32).to_string();
let zero = "0".to_string();
let object = ctx
.block()
.call(I64, "js_object_alloc", &[(I32, &zero), (I32, &count)]);
let rooted = super::temp_root::rooted_handle_begin(ctx, &object, true);
for member in &members {
let member_get = Expr::PropertyGet {
byte_offset: 0,
object: Box::new(Expr::ExternFuncRef {
name: name.to_string(),
param_types: Vec::new(),
return_type: HirType::Any,
}),
property: member.clone(),
};
let value = lower_expr(ctx, &member_get)?;
let key_index = ctx.strings.intern(member);
let key_global = format!("@{}", ctx.strings.entry(key_index).handle_global);
let object = super::temp_root::rooted_handle_get(ctx, &rooted);
let block = ctx.block();
let key = block.load(DOUBLE, &key_global);
let key_bits = block.bitcast_double_to_i64(&key);
let key = block.and(I64, &key_bits, POINTER_MASK_I64);
block.call_void(
"js_object_set_field_by_name",
&[(I64, &object), (I64, &key), (DOUBLE, &value)],
);
}
let object = super::temp_root::rooted_handle_get(ctx, &rooted);
let value = nanbox_pointer_inline(ctx.block(), &object);
let value = ctx
.block()
.call(DOUBLE, "js_finalize_namespace", &[(DOUBLE, &value)]);
super::temp_root::rooted_handle_release(ctx, rooted);
Ok(Some(value))
}

pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
match expr {
Expr::WorkerNew {
Expand Down Expand Up @@ -753,6 +807,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
],
));
}
if ctx.namespace_imports.contains(name) {
if let Some(namespace) = materialize_compiled_namespace(ctx, name)? {
return Ok(namespace);
}
}
if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() {
// Next.js lazy-require: a `_lazyreq_N` binding is the CJS require
// shim's handle to a FUNCTION-LOCAL `require('S')`. S is
Expand Down Expand Up @@ -880,101 +939,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// dedicated arms above; this catch-all only fires for
// names with no resolution at all.
if ctx.namespace_imports.contains(name) {
// A namespace import used as a whole VALUE (passed to a
// function, iterated by `Object.keys`/`for…in`/`Object.entries`,
// spread, …) must be a real object whose OWN ENUMERABLE
// properties are the source module's exports — not the empty
// `js_unresolved_namespace_stub`. Drizzle's
// `drizzle(pool, { schema })` (with `import * as schema`) and
// Stripe's `_prepResources` (`for (const name in resources)`
// over `import * as resources`) both enumerate the namespace and
// silently saw zero members otherwise. Materialize the object by
// resolving each exported member through the SAME per-member
// `ns.member` PropertyGet lowering (functions → closure
// singletons, consts → getters, classes → class refs).
let mut members: Vec<String> = ctx
.namespace_member_prefixes
.keys()
.filter(|(ns, _)| ns == name)
.map(|(_, m)| m.clone())
.collect();
if !members.is_empty() {
members.sort();
members.dedup();
let n_str = (members.len() as u32).to_string();
let zero_str = "0".to_string();
let handle = ctx.block().call(
I64,
"js_object_alloc",
&[(I32, &zero_str), (I32, &n_str)],
);
// #7280: root the half-built namespace object.
//
// Every other lowering that allocates an object and then
// fills it in carries this contract — `Expr::Object` since
// #6951, `Expr::ObjectSpread`, the class-object lowering
// since #7211. This one was added for a different reason
// (#629, Drizzle/Stripe namespace enumeration) and never
// got it, and it builds by far the LARGEST object in a
// dependency-scale program: one property per export of the
// imported module, materialized at every use site.
//
// Both halves of the loop are collection points, on every
// iteration:
//
// * `lower_expr(member_get)` is a full `ns.member`
// PropertyGet. For a const export that is a CALL into
// the exporting module's accessor — arbitrary user
// code; for a function it allocates a closure
// singleton; for a class it resolves a class ref.
// * `js_object_set_field_by_name` performs the keys-array
// transition, which allocates.
//
// With `handle` in a bare SSA register the object is
// reachable from NO root for the whole build, so a minor
// does not merely relocate it — it reclaims it, and the
// remaining stores land in recycled memory. The caller then
// receives a namespace whose members read back as garbage,
// which surfaces as `TypeError: <x> is not a function` at
// the first member call, arbitrarily far away.
//
// Measured on #7280's stock-zod reproducer: `import * as
// core` materializes 269 members here, and the emitted IR
// carried ZERO `js_gc_temp_root_*` calls beside its 269
// allocating stores.
let rooted = super::temp_root::rooted_handle_begin(ctx, &handle, true);
for member in &members {
let member_get = Expr::PropertyGet {
byte_offset: 0,
object: Box::new(Expr::ExternFuncRef {
name: name.clone(),
param_types: Vec::new(),
return_type: HirType::Any,
}),
property: member.clone(),
};
let v_box = lower_expr(ctx, &member_get)?;
let key_idx = ctx.strings.intern(member);
let key_handle_global =
format!("@{}", ctx.strings.entry(key_idx).handle_global);
// Re-read AFTER the member resolution: that is the
// collection point, so a register captured before it is
// the stale one.
let handle = super::temp_root::rooted_handle_get(ctx, &rooted);
let blk = ctx.block();
let key_box = blk.load(DOUBLE, &key_handle_global);
let key_bits = blk.bitcast_double_to_i64(&key_box);
let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64);
blk.call_void(
"js_object_set_field_by_name",
&[(I64, &handle), (I64, &key_raw), (DOUBLE, &v_box)],
);
}
let handle = super::temp_root::rooted_handle_get(ctx, &rooted);
let boxed = nanbox_pointer_inline(ctx.block(), &handle);
super::temp_root::rooted_handle_release(ctx, rooted);
return Ok(boxed);
}
return Ok(ctx
.block()
.call(DOUBLE, "js_unresolved_namespace_stub", &[]));
Expand Down
38 changes: 25 additions & 13 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,8 +596,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// Issue #649: PropertyGet on a native-module reference (`fs`,
// `os`, `crypto`, `path`, ...). `NativeModuleRef` lowers to a
// literal `0.0`, so the generic PropertyGet path can't see the
// namespace. Short-circuit to `js_native_module_property_by_name`
// which consults the constants dispatcher directly. For chained
// namespace. Short-circuit to the snapshot-backed ESM export
// lookup, which consults the constants dispatcher on first read
// and is refreshed by `syncBuiltinESMExports`. For chained
// access like `fs.constants.F_OK` only the inner read fires
// here — `constants` returns a real NATIVE_MODULE_CLASS_ID
// ObjectHeader, and the outer PropertyGet routes through
Expand Down Expand Up @@ -640,11 +641,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
return Ok(nanbox_string_inline(blk, &handle));
}
let mod_idx = ctx.strings.intern(module_name);
let mod_bytes_global = format!("@{}", ctx.strings.entry(mod_idx).bytes_global);
let mod_len_str = module_name.len().to_string();
let prop_idx = ctx.strings.intern(property);
let prop_bytes_global = format!("@{}", ctx.strings.entry(prop_idx).bytes_global);
let prop_len_str = property.len().to_string();
// The value read of a native-module callable export (`const f =
// util.inherits`) mints a BOUND_METHOD closure that, when invoked
// indirectly, dispatches through the per-module `NM_DISPATCH_REGISTRY`
Expand All @@ -659,15 +656,30 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if let Some(install_sym) = crate::nm_install::nm_install_symbol(module_name) {
ctx.block().call_void(install_sym, &[]);
}
if module_name == "fs" && property == "promises" {
let mod_bytes_global = format!("@{}", ctx.strings.entry(mod_idx).bytes_global);
let prop_bytes_global =
format!("@{}", ctx.strings.entry(prop_idx).bytes_global);
return Ok(ctx.block().call(
DOUBLE,
"js_native_module_property_by_name",
&[
(PTR, &mod_bytes_global),
(I64, &module_name.len().to_string()),
(PTR, &prop_bytes_global),
(I64, &property.len().to_string()),
],
));
}
let mod_handle_global = format!("@{}", ctx.strings.entry(mod_idx).handle_global);
let prop_handle_global = format!("@{}", ctx.strings.entry(prop_idx).handle_global);
let blk = ctx.block();
let module_value = blk.load(DOUBLE, &mod_handle_global);
let property_value = blk.load(DOUBLE, &prop_handle_global);
return Ok(ctx.block().call(
DOUBLE,
"js_native_module_property_by_name",
&[
(PTR, &mod_bytes_global),
(I64, &mod_len_str),
(PTR, &prop_bytes_global),
(I64, &prop_len_str),
],
"js_native_module_esm_export_value",
&[(DOUBLE, &module_value), (DOUBLE, &property_value)],
));
}
// Cross-module static field access. When `Base` is an imported
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/this_super_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub(crate) fn is_other_builtin_constructor_name(name: &str) -> bool {
| "Set"
| "WeakMap"
| "WeakSet"
| "EventTarget"
| "Array"
| "ArrayBuffer"
| "SharedArrayBuffer"
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/gc_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const ELF_SECTION: &str = ".perry_gcmap,\"awR\",@progbits";
const COFF_SECTION: &str = ".pgcmap,\"dw\"";
/// What the runtime looks for in a PE image. Must match `COFF_SECTION`'s name
/// and stay within eight bytes.
#[cfg(test)]
pub(crate) const COFF_SECTION_NAME: &str = ".pgcmap";

/// LLVM stack-map v3 location kinds. Only these two describe a frame slot;
Expand Down
10 changes: 7 additions & 3 deletions crates/perry-codegen/src/linker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,11 +713,14 @@ fn compile_ll_inprocess_in(
policy: TempFilePolicy,
) -> Result<Vec<u8>> {
let (paths, _pid, _nonce) = llvm_temp_paths(tmp_dir, ll_text);
// The in-process backend still needs a companion clang for structural
// analysis metadata and, for statepoints, final assembly.
let companion_clang = find_clang().unwrap_or_else(|| PathBuf::from("(in-process)"));
// Same decision inputs as the clang path — opt level (#4880 fallback
// included), CPU tuning, inlinehint threshold — via the same plan
// constructor, so the backends cannot drift on a decision independently.
let plan = build_clang_compile_plan(
PathBuf::from("(in-process)"),
companion_clang,
paths.ll_path.clone(),
paths.obj_path.clone(),
target_triple,
Expand Down Expand Up @@ -767,8 +770,9 @@ fn compile_ll_inprocess_in(
}
fs::write(asm_path, &bytes)
.with_context(|| format!("Failed to write {}", asm_path.display()))?;
// `plan.clang` is the literal `(in-process)` placeholder here, so
// resolve a real assembler. Using the system clang for this step is
// Resolve the assembler again so a missing companion recorded as
// `(in-process)` above still gets a precise error here. Using the
// system clang for this step is
// sound: the version skew that motivated the in-process backend was
// an *IR* parse failure (`unterminated attribute group`), and by
// this point the IR is gone — what is being assembled is text this
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/lower_call/native_module_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ pub fn lower_native_module_dispatch(
}
}
}
// `findPackageJSON()` distinguishes a missing first argument from an
// explicitly supplied `undefined`, although both otherwise lower to the
// same NaN-box value. Preserve the source call arity for the runtime.
if sig.runtime == "js_module_find_package_json" {
llvm_args.push((DOUBLE, double_literal(args.len() as f64)));
arg_types.push(DOUBLE);
}

// Determine return type for the declare
let ret_type = match sig.ret {
Expand Down
Loading
Loading