Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[
has_receiver: false,
method: "createRequire",
class_filter: None,
runtime: "js_module_create_require",
// #6644: the devirt wrapper arms the nm/submod install-all hooks (the
// returned require closure resolves builtins from a runtime string, so
// codegen can't emit precise per-module installs). Mirrors
// js_process_get_builtin_module_devirt.
runtime: "js_module_create_require_devirt",
args: &[NA_F64],
ret: NR_F64,
},
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,9 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
// #5389 Tier 2: synchronous ambient require(spec) resolution — the codegen
// fallthrough when a computed require() didn't const-fold to a compiled target.
module.declare_function("js_module_ambient_require_apply", DOUBLE, &[DOUBLE]);
// #6644: `module.createRequire(...)` devirt entry — arms the nm/submod
// install-all hooks before delegating (see js_process_get_builtin_module_devirt).
module.declare_function("js_module_create_require_devirt", DOUBLE, &[DOUBLE]);
// Non-throwing global read for `typeof <unresolved>` + global read-modify-
// write for `i++`/`i--` on a sloppy implicit global (#3575).
module.declare_function("js_global_get_optional", DOUBLE, &[DOUBLE]);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ impl LoweringContext {
next_anon_shape_id: 0,
class_method_return_types: Vec::new(),
class_captures: Vec::new(),
body_class_expr_captures: Vec::new(),
let_class_aliases: Vec::new(),
global_this_aliases: HashSet::new(),
prototype_aliases: HashMap::new(),
Expand Down
42 changes: 42 additions & 0 deletions crates/perry-hir/src/lower/expr_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) ->
// #4101: retain source text for `fn.toString()`.
capture_function_source(ctx, func_id, &arrow.span, arrow.is_async);
let scope_mark = ctx.enter_scope();
// #6604: truncate mark for capturing class expressions recorded while
// lowering THIS arrow — placed at scope entry so default-param
// expressions are covered too; see the truncate below the body match.
let body_class_expr_captures_mark = ctx.body_class_expr_captures.len();
let strict = ctx.current_strict_mode()
|| match &*arrow.body {
ast::BlockStmtOrExpr::BlockStmt(block) => {
Expand Down Expand Up @@ -377,6 +381,17 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) ->
vec![Stmt::Return(Some(return_expr))]
}
};
// #6604: a capturing class expression in an EXPRESSION-bodied arrow
// (`x => new (class { … })(x)`) records a body-class-expr entry that no
// body twin will drain (the block-bodied arm drains its own inside
// `lower_fn_body_block_stmt`; default-param entries are self-truncated by
// `get_param_default`). Truncate on exit so the entry — whose ids are
// only meaningful in the arrow's own local numbering — never leaks into
// the ENCLOSING body's refresh statements. Nothing is lost: a
// single-expression body has no later statements that could reassign the
// class's captured locals.
ctx.body_class_expr_captures
.truncate(body_class_expr_captures_mark);
ctx.current_strict = outer_strict;

// Prepend destructuring statements to body
Expand Down Expand Up @@ -579,6 +594,13 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul
fn_expr.function.is_async,
);
let scope_mark = ctx.enter_scope();
// #6604: capturing class EXPRESSIONS lowered in THIS function register
// from here for the end-of-body refresh (twin of
// `lower_fn_body_block_stmt`); the mark sits at scope entry so nothing
// recorded for this function can leak into the enclosing body.
// (Default-param entries never reach the drain — `get_param_default`
// self-truncates.)
let body_class_expr_captures_mark = ctx.body_class_expr_captures.len();
// A plain function has its own `arguments` object, so a direct `eval`
// inside its body may reference `arguments` even when the function sits
// in a class field initializer. Cleared here, restored at the end.
Expand Down Expand Up @@ -1221,6 +1243,26 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul
}
}
}
// #6604: capturing class EXPRESSIONS lowered directly in this body —
// the semver/esbuild `__commonJS` wrapper shape `var Comparator =
// class _Comparator { … }; …; var parseOptions = require_…()` — join
// the same refresh machinery as class declarations, so the snapshot
// tracks captured vars assigned AFTER the class. Recorded by
// `lower_class_expr` under the RESOLVED registration name; see the
// block-body twin (`lower_fn_body_block_stmt`) for why no
// `append_new_args_stmt` pass runs for expressions.
for (cname, ids) in ctx
.body_class_expr_captures
.split_off(body_class_expr_captures_mark)
{
let captures: Vec<Expr> = ids.iter().map(|id| Expr::LocalGet(*id)).collect();
let re_reg = Stmt::Expr(Expr::RegisterClassCaptures {
class_name: cname,
captures,
});
re_reg_capsets.push((re_reg.clone(), ids.iter().copied().collect()));
re_regs.push(re_reg);
}
if !re_regs.is_empty() {
// Audit P0-B twin of the block-body path: refresh after every
// same-body assignment to a captured local so mid-body constructs
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-hir/src/lower/lower_expr/arm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ pub(crate) fn lower_class_expr(
// expressions inside a function body (factories like effect's
// `make()`), which produce a distinct class object per call.
let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0;
// #6604: register this capturing class EXPRESSION with the enclosing
// body's end-of-body capture-refresh machinery (#6037/#6052), which
// previously scanned class DECLARATION statements only. Without the
// refresh, a captured var assigned AFTER the class expression (semver's
// `var Comparator = class _Comparator { … }; …; var parseOptions =
// require_parse_options()`) stays `undefined` in the decl-site snapshot,
// and dynamic construction of the escaped class value replays that stale
// snapshot. Recording the RESOLVED registration name here (post
// rename/dedup) sidesteps re-deriving it from the AST at body end. Module
// top is skipped — module-level ids are stripped from capture lists by
// `filter_module_level_captures`, so there is nothing to refresh.
if !at_module_top && !captured_args.is_empty() {
if let Some(ids) = ctx.lookup_class_captures(&synthetic_name) {
ctx.body_class_expr_captures
.push((synthetic_name.clone(), ids.to_vec()));
}
}
Comment on lines +189 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve per-evaluation captures for assigned-after-class factories.

This registers ClassExprFresh captures in one global, name-keyed snapshot. With A = make("a"); B = make("b"); new A(), stale slots in A can be backfilled from B’s later snapshot.

Keep refresh state on the fresh class object, or otherwise key it per evaluation. Add this assigned-after-class multi-evaluation regression alongside the existing assigned-before-class case.

🤖 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/lower_expr/arm_class.rs` around lines 189 - 205,
Update the class-expression capture refresh flow around body_class_expr_captures
and lookup_class_captures so refresh state is stored per fresh class evaluation
rather than in a single name-keyed global snapshot. Ensure captures from later
evaluations such as B cannot populate stale slots for A, while preserving
assigned-after-class refresh behavior. Add a regression covering multiple
assigned-after-class factory evaluations and construction of the earlier class.

if !at_module_top
&& (!named_statics.is_empty()
|| !static_symbol_registrations.is_empty()
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,23 @@ pub struct LoweringContext {
/// here so the `Expr::New { class_name }` lowering can append
/// `LocalGet(id)` for each captured id at every construction site.
pub(crate) class_captures: Vec<(String, Vec<LocalId>)>,
/// #6604: capturing class EXPRESSIONS lowered while the CURRENT function
/// body is being lowered — `(registration_name, captured_outer_ids)`,
/// pushed by `lower_class_expr` (skipped at module top, where
/// `filter_module_level_captures` already strips module-level ids). The
/// #6037/#6052 end-of-body capture-refresh machinery previously scanned
/// only `ast::Decl::Class` DECLARATION statements, so `var Comparator =
/// class _Comparator { … }` (semver's shape in every bundled class file)
/// never got refresh statements: a captured var assigned AFTER the class
/// (`var parseOptions = require_parse_options()` at file bottom) stayed
/// `undefined` in the snapshot forever, and dynamic construction of the
/// escaped class value threw "value is not a function" at pi-native init.
/// Both body twins (`lower_fn_body_block_stmt` and `lower_fn_expr`) mark
/// this list's length at entry and drain their own suffix at body end;
/// every other body-lowering path must truncate back to its entry mark so
/// entries (whose ids are only meaningful in THEIR OWN function scope)
/// never leak into an enclosing body's refresh statements.
pub(crate) body_class_expr_captures: Vec<(String, Vec<LocalId>)>,
/// Issue #740: `let_name → class_name` for `let/const/var <name> = <ClassRef>`
/// initializers. Lets `Expr::New { class_name }` (where `class_name` is
/// the source-level identifier of an alias binding) resolve to the
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-hir/src/lower_decl/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,10 @@ pub fn lower_fn_body_block_stmt(
// Used by the Phase 1.6 forward `let`/`const` pre-registration so a const
// that shadows an outer binding still gets a fresh this-body local.
let body_entry_locals_len = ctx.locals.len();
// #6604: entries pushed while lowering THIS body belong to THIS body's
// capture-refresh pass (their ids are this function's locals); drain the
// suffix at body end, truncate on the error path so nothing leaks upward.
let body_class_expr_captures_mark = ctx.body_class_expr_captures.len();
let hoisted_var_slots = predefine_var_bindings_in_function_body(ctx, block);

// Phase 1: pre-define hoisted FnDecl locals so forward references in
Expand Down Expand Up @@ -1224,6 +1228,8 @@ pub fn lower_fn_body_block_stmt(
let mut body = match lower_block_stmt(ctx, block) {
Ok(body) => body,
Err(err) => {
ctx.body_class_expr_captures
.truncate(body_class_expr_captures_mark);
ctx.current_strict = parent_strict;
ctx.forward_class_names = saved_forward_class_names;
ctx.forward_class_decl_depth = saved_forward_class_decl_depth;
Expand Down Expand Up @@ -1291,6 +1297,30 @@ pub fn lower_fn_body_block_stmt(
}
}
}
// #6604: capturing class EXPRESSIONS lowered directly in this body
// (`var Comparator = class _Comparator { … }`, argument-position
// `register(class { … })`, …) need the same assignment-tracking
// refresh as class declarations: semver assigns the captured
// `parseOptions`/`debug` vars AFTER the class, so the snapshot (and
// the per-evaluation `__perry_ctor_caps` array, whose stale-undefined
// slots the runtime construct path now backfills from this snapshot)
// must be re-registered with the live values. Entries were recorded
// by `lower_class_expr` under the RESOLVED registration name; no
// `append_new_args_stmt` pass — a class expression's construct sites
// are either static (binding-name `new C()`, live locals appended at
// the site) or dynamic (replayed through the snapshot).
for (cname, ids) in ctx
.body_class_expr_captures
.split_off(body_class_expr_captures_mark)
{
let captures: Vec<Expr> = ids.iter().map(|id| Expr::LocalGet(*id)).collect();
let re_reg = Stmt::Expr(Expr::RegisterClassCaptures {
class_name: cname,
captures,
});
re_reg_capsets.push((re_reg.clone(), ids.iter().copied().collect()));
re_regs.push(re_reg);
}
if !re_regs.is_empty() {
// Audit P0-B: the decl-site snapshot is authoritative at
// construct time, so keep it TRACKING same-body assignments —
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-hir/src/lower_patterns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,7 +1454,19 @@ pub(crate) fn get_param_default(ctx: &mut LoweringContext, pat: &ast::Pat) -> Re
}
}
ast::Pat::Assign(assign) => {
// #6604: a capturing class EXPRESSION used as a default value
// (`function f(C = class { … }) {}`) must NOT register with the
// enclosing body's end-of-body capture-refresh machinery: param
// defaults are lowered BEFORE the callee's own body twin takes
// its list mark (fn-decl / ctor / method param sites), so the
// entry would be drained by the WRONG (enclosing) body and its
// ids interpreted in the wrong function's local numbering.
// Truncate whatever this default expression recorded — the
// default is re-evaluated at every call anyway, so its
// evaluation-time snapshot is per-call fresh.
let mark = ctx.body_class_expr_captures.len();
let default_expr = lower_expr(ctx, &assign.right)?;
ctx.body_class_expr_captures.truncate(mark);
Comment on lines +1457 to +1469

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not discard default class expressions that capture body-mutated bindings.

Per-call evaluation is fresh, but the class must still observe later body assignments:

function f(x, C = class { get() { return x; } }) {
  x = 2;
  return C;
}

Truncating here excludes C from assignment refresh, leaving its snapshot at 1. Scope these entries to the callee body instead of dropping them, and add regression coverage.

🤖 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_patterns.rs` around lines 1457 - 1469, Update the
default-expression handling around lower_expr in the parameter-lowering path to
retain body_class_expr_captures entries for capturing class expressions instead
of truncating them. Scope those entries to the callee body’s capture-refresh
machinery so they are not consumed by the enclosing body while still observing
later parameter/body assignments; add regression coverage for a default class
capturing a reassigned binding.

Ok(Some(default_expr))
}
_ => Ok(None),
Expand Down
39 changes: 38 additions & 1 deletion crates/perry-runtime/src/module_require.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ fn supported_require_builtin(specifier: &str) -> Option<&str> {
// allowlist (they have runtime registry buckets + dispatch, but
// `require('tls')` etc. via createRequire was rejected as "package/file").
| "dgram" | "domain" | "inspector" | "inspector/promises" | "repl"
| "sqlite" => Some(name),
| "sqlite"
// #6644: implemented as a node_submodules spec (real pub/sub channel
// registry in node_submodules/diagnostics.rs) but missing here, so
// `require('node:diagnostics_channel')` through createRequire (the
// esbuild banner shim in any ESM bundle of CJS deps — lru-cache's node
// build in the pi bundle) was rejected as "package/file".
| "diagnostics_channel" => Some(name),
_ => None,
}
}
Expand All @@ -123,6 +129,19 @@ fn require_builtin_value(module_name: &str) -> f64 {
)
};
}
// #6644: diagnostics_channel lives in the node_submodules registry (not a
// native-module dispatch bucket); route it there like timers/promises so
// `require('diagnostics_channel')` / `require('node:diagnostics_channel')`
// return the real channel/subscribe/tracingChannel exports instead of an
// empty native-module namespace.
if module_name == "diagnostics_channel" {
return unsafe {
crate::node_submodules::js_node_submodule_namespace(
b"diagnostics_channel".as_ptr(),
"diagnostics_channel".len() as u32,
)
};
}
crate::object::native_module_get_builtin_module_value(module_name)
}

Expand Down Expand Up @@ -223,6 +242,24 @@ pub extern "C" fn js_module_create_require(filename_or_url: f64) -> f64 {
make_require(undefined())
}

/// Devirt codegen entry for `module.createRequire(...)` (#6644). The require
/// closure it returns resolves builtins from a RUNTIME string, so — exactly like
/// `js_process_get_builtin_module_devirt` — codegen could not emit the precise
/// per-module dispatch installs. Arm both install-all hooks so a dynamically
/// required module's methods (`require('node:diagnostics_channel').channel(...)`,
/// `require('tls').connect(...)`) can dispatch. Codegen targets THIS symbol, so
/// the all-buckets `js_nm_install_all` / `js_node_submod_install_all` are
/// referenced only by programs whose source actually calls `createRequire`; the
/// plain `js_module_create_require` (reachable from the always-pinned ambient
/// require keepalives via the module dispatch bucket) stays free of that
/// reference, preserving per-module stripping.
#[no_mangle]
pub extern "C" fn js_module_create_require_devirt(filename_or_url: f64) -> f64 {
crate::object::js_nm_enable_install_all();
crate::node_submodules::js_node_submod_enable_install_all();
js_module_create_require(filename_or_url)
}

/// Next.js wall 54: registry mapping an AOT-compiled CJS module's absolute
/// source path to its evaluated `module.exports`, so a RUNTIME
/// `require(absolutePath.js)` (Next.js / turbopack load page + chunk modules by
Expand Down
50 changes: 43 additions & 7 deletions crates/perry-runtime/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,49 @@ pub(crate) fn is_function_value(value: f64) -> bool {

pub(crate) fn supported_builtin_module_name(name: &str) -> Option<&str> {
match name {
"assert" | "assert/strict" | "async_hooks" | "buffer" | "child_process" | "cluster"
| "console" | "constants" | "crypto" | "dns" | "dns/promises" | "events" | "fs"
| "http" | "http2" | "https" | "module" | "net" | "os" | "path" | "perf_hooks"
| "process" | "punycode" | "querystring" | "readline" | "readline/promises" | "sea"
| "stream" | "stream/promises" | "string_decoder" | "sys" | "test" | "test/reporters"
| "timers" | "timers/promises" | "tty" | "url" | "util" | "util/types" | "vm"
| "worker_threads" | "zlib" => Some(name),
"assert"
| "assert/strict"
| "async_hooks"
| "buffer"
| "child_process"
| "cluster"
| "console"
| "constants"
| "crypto"
| "diagnostics_channel"
| "dns"
| "dns/promises"
| "events"
| "fs"
| "http"
| "http2"
| "https"
| "module"
| "net"
| "os"
| "path"
| "perf_hooks"
| "process"
| "punycode"
| "querystring"
| "readline"
| "readline/promises"
| "sea"
| "stream"
| "stream/promises"
| "string_decoder"
| "sys"
| "test"
| "test/reporters"
| "timers"
| "timers/promises"
| "tty"
| "url"
| "util"
| "util/types"
| "vm"
| "worker_threads"
| "zlib" => Some(name),
_ => None,
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/perry-runtime/src/process/node_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,17 @@ pub extern "C" fn js_process_get_builtin_module(id: f64) -> f64 {
)
};
}
// #6644: diagnostics_channel is a node_submodules spec, not a native-module
// dispatch bucket — route it there (mirrors createRequire's
// require_builtin_value).
if module_name == "diagnostics_channel" {
return unsafe {
crate::node_submodules::js_node_submodule_namespace(
b"diagnostics_channel".as_ptr(),
"diagnostics_channel".len() as u32,
)
};
}
crate::object::native_module_get_builtin_module_value(module_name)
}

Expand Down
Loading
Loading