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
11 changes: 10 additions & 1 deletion crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,16 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
{
let ctor_body = if let Some(c) = class.constructor.as_ref() {
(c.params.clone(), c.body.clone(), c.captures.clone())
} else if class.extends_name.is_some() {
} else if class.extends_name.is_some() || class.extends_expr.is_some() {
// `extends_expr.is_some()` (with NO extends_name): a PURELY
// dynamic parent — `class extends <local var> {}`, the shape a
// bundled mysql2 promise-mixin takes (`module.exports = class
// extends Pool { promise() {…} }`). It needs the same
// forwarding signature; `synthesized_ctor_param_count` already
// resolves it to the unresolved-parent fixed band. Previously
// this fell to the empty-ctor branch, so the standalone ctor
// dropped every construction arg AND never called super — the
// instance silently lost all inherited ctor state (wall 7).
// No own ctor + heritage → JS spec default ctor
// `constructor(...args) { super(...args) }`. Synthesize forwarding
// params matching the closest ancestor ctor's arity (incl.
Expand Down
13 changes: 11 additions & 2 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,14 @@ pub(super) fn compile_method(
// later (after super() in own-body case, after explicit parent
// ctor call in no-own-body case).
// - no extends: apply all (= just self) here.
let init_mode = if class.extends_name.is_some() {
// A no-own-ctor class with a PURELY dynamic parent (`extends_expr`,
// no `extends_name`) now emits a synthesized dynamic super below —
// stage its self fields AFTER that call (tail SelfOnly), like any
// other heritage class, instead of applying them twice.
let no_ctor_dynamic_parent = class.constructor.is_none()
&& class.extends_name.is_none()
&& class.extends_expr.is_some();
let init_mode = if class.extends_name.is_some() || no_ctor_dynamic_parent {
crate::lower_call::FieldInitMode::AncestorsOnly
} else {
crate::lower_call::FieldInitMode::All
Expand All @@ -586,7 +593,9 @@ pub(super) fn compile_method(
// call to the parent's standalone ctor symbol here, forwarding all
// args. The walk skips empty-bodied parents (matching the JS spec
// chain semantics).
if class.constructor.is_none() && class.extends_name.is_some() {
if class.constructor.is_none()
&& (class.extends_name.is_some() || class.extends_expr.is_some())
{
let builtin_parent_runtime = match class.extends_name.as_deref() {
Some("Writable") => Some("js_node_stream_writable_subclass_init"),
Some("Duplex") => Some("js_node_stream_duplex_subclass_init"),
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 @@ -89,6 +89,7 @@ impl LoweringContext {
class_display_names: HashMap::new(),
gen_param_prologue_len: HashMap::new(),
assignment_inferred_name: None,
inferred_class_bindings: std::collections::HashSet::new(),
closure_source_text: HashMap::new(),
func_return_native_instances: Vec::new(),
pending_classes: Vec::new(),
Expand Down
23 changes: 23 additions & 0 deletions crates/perry-hir/src/lower/expr_assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,29 @@ fn lower_assignment_target(
&& ctx.lookup_func(&cls_name).is_none()
{
None
} else if ctx.lookup_local(&cls_name).is_some()
&& !ctx.inferred_class_bindings.contains(cls_name.as_str())
{
// A lexical local shadows any same-named
// module-scope class for this write too
// (wall 7's disease, 4th surface): the
// vendored eventemitter3 `function s(){}`
// + `s.prototype.emit = fn` inside a
// turbopack chunk that ALSO has minified
// `class s {…}` declarations registered
// emit onto the unrelated class — the ES5
// constructor's prototype stayed EMPTY and
// every subclass (p-queue's PQueue) lost
// the inherited surface. A function-valued
// local keys the registration by the
// closure VALUE; any other local falls to
// the ordinary property-set path.
let local_id = ctx.lookup_local(&cls_name).unwrap();
if ctx.function_valued_locals.contains(&local_id) {
Some(ProtoOwner::Func(Expr::LocalGet(local_id)))
} else {
None
}
} else if ctx.lookup_class(&cls_name).is_some()
&& class_has_accessor(ctx, &cls_name, &method_name)
{
Expand Down
55 changes: 35 additions & 20 deletions crates/perry-hir/src/lower/expr_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,20 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
// param shadows it — and the `resolve_class_alias().is_none()`
// guard on the reroute block below would then skip it.
//
// Capturing the binding here (when no real class of this name is in
// scope) keeps the reroute stable against both.
let callee_local_at_entry: Option<LocalId> = if ctx.lookup_class(&class_name).is_none()
{
ctx.lookup_local(&class_name)
} else {
None
};
// Capturing the binding here keeps the reroute stable against both.
//
// Snapshotted UNCONDITIONALLY (previously only when no class of
// this name existed): a lexical local shadows a same-named
// module-scope class for every reference in scope, `new`
// included. Minified bundles hit this constantly — mysql2's
// chunk has `class e{...}` (PacketParser) at module scope AND
// factory-local `let e = E.r(76464)` (PoolConfig); `new e(o)`
// must construct the LOCAL's value, not the name-keyed class
// (myairank wall 7: the wrong class's ctor ran, silently). A
// class-decl name only carries a local slot when the module
// reassigns it (#5833), and for a reassigned binding reading the
// slot is the spec-correct behavior for `new` too.
let callee_local_at_entry: Option<LocalId> = ctx.lookup_local(&class_name);
// #6233: a user-declared binding — `class Symbol extends Base {}`,
// a local/param, a `function` declaration, or an imported binding —
// lexically shadows the same-named global for every reference in
Expand Down Expand Up @@ -1141,18 +1147,27 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R
}
}
// A local/param binding lexically shadows any same-named outer
// `let`/`const` class alias. When `callee_local_at_entry` is set
// (a non-class local was in scope at the top of this arm, before
// arg lowering could disturb the scope), route the construct
// through that VALUE — even if `resolve_class_alias` would
// otherwise resolve `class_name` to a stale enclosing-scope alias
// (its map is name-keyed, not scope-aware). Without this, the
// `resolve_class_alias().is_none()` guard on the local-reroute
// block below is false and the construct falls through to an
// empty-object `Expr::New { class_name }` placeholder whose
// constructor body never runs.
if ctx.lookup_class(&class_name).is_none() {
if let Some(local_id) = callee_local_at_entry {
// `let`/`const` class alias AND any same-named module-scope class
// declaration. When `callee_local_at_entry` is set (a local was
// in scope at the top of this arm, before arg lowering could
// disturb the scope), route the construct through that VALUE —
// even if `resolve_class_alias` would otherwise resolve
// `class_name` to a stale enclosing-scope alias (its map is
// name-keyed, not scope-aware), and even if a class of this name
// exists (the local shadows it; constructing the class instead
// ran the WRONG constructor for mysql2's bundled factories).
if let Some(local_id) = callee_local_at_entry {
// …but NOT when the local IS the class's own alias binding
// (`const E2 = class extends Event {}` — `let_class_aliases`
// maps E2 to its class): the static path carries the exact
// builtin-parent construction (#6336's Event/Map native
// attach), which the generic dynamic construct does not.
// A shadowing local over an UNRELATED same-named class
// declaration has no alias entry, so wall 7's reroute keeps
// firing.
let local_is_class_alias =
ctx.inferred_class_bindings.contains(class_name.as_str());
if !local_is_class_alias {
return Ok(Expr::NewDynamic {
callee: Box::new(Expr::LocalGet(local_id)),
args,
Expand Down
14 changes: 13 additions & 1 deletion crates/perry-hir/src/lower/lower_expr/arm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ pub(crate) fn lower_class_expr(
match inferred {
// First class expression to claim this inferred binding name —
// reuse it directly as the registration key (and thus `.name`).
Some(name) if ctx.lookup_class(&name).is_none() => name,
Some(name) if ctx.lookup_class(&name).is_none() => {
// Record that this binding's local holds ITS OWN class, so
// `new <name>()` keeps the exact static construct path
// (see `inferred_class_bindings`).
ctx.inferred_class_bindings.insert(name.clone());
name
}
// #5592: a second anonymous class expression assigned to the
// SAME binding (`C = class {…}; C = class {…}`) infers the same
// name. Reusing the key would alias both onto one ClassId
Expand All @@ -77,6 +83,12 @@ pub(crate) fn lower_class_expr(
// registration key but keep its user-visible `.name` as the
// binding name.
Some(name) => {
// The binding's local still holds ITS OWN class even when
// the registration key is disambiguated (#5592) — or when
// the Phase-1.5 pre-scan already claimed the inferred name
// for this same expression. Record the BINDING name so
// `new <name>()` keeps the static construct path.
ctx.inferred_class_bindings.insert(name.clone());
display_override = Some(name.clone());
format!("{}__anon_dup_{}", name, ctx.fresh_class())
}
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@ pub struct LoweringContext {
/// identifier lhs can provide the `NamedEvaluation` name for an anonymous
/// function/class rhs. This slot is set only while lowering that rhs.
pub(crate) assignment_inferred_name: Option<String>,
/// Binding names whose class registration was created BY the binding's
/// own class-expression init (`const E2 = class extends Event {}` — the
/// anonymous expression claimed the inferred binding name as its
/// registration key, and `var K = class Inner {}` registers under the
/// bind name too). At a `new <name>()` site, such a name's local provably
/// holds that same class, so the static construct path (with its exact
/// builtin-parent handling) is correct; any OTHER in-scope local shadows
/// whatever same-named class exists and must construct dynamically.
pub(crate) inferred_class_bindings: std::collections::HashSet<String>,
Comment on lines +303 to +311

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

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

printf '\n== lowering_context.rs ==\n'
sed -n '250,360p' crates/perry-hir/src/lower/lowering_context.rs

printf '\n== stmt.rs relevant sections ==\n'
sed -n '1,140p' crates/perry-hir/src/lower/stmt.rs
printf '\n---\n'
sed -n '660,830p' crates/perry-hir/src/lower/stmt.rs

printf '\n== arm_class.rs ==\n'
sed -n '1,180p' crates/perry-hir/src/lower/lower_expr/arm_class.rs

printf '\n== expr_new.rs relevant section ==\n'
sed -n '1120,1205p' crates/perry-hir/src/lower/expr_new.rs

printf '\n== search inferred_class_bindings usages ==\n'
rg -n "inferred_class_bindings" crates/perry-hir/src/lower -S

printf '\n== search scope truncation/reset patterns ==\n'
rg -n "truncate|scope stack|module_shadow_stack|native_instances_index|clear\\(|push\\(|pop\\(" crates/perry-hir/src/lower -S

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

set -euo pipefail

printf '\n== exact inferred_class_bindings operations ==\n'
rg -n "inferred_class_bindings|module_shadow_stack|native_instances_index|scope_depth|enter_scope|exit_scope|truncate_native_instances" crates/perry-hir/src/lower -S

printf '\n== lowering_context methods ==\n'
ast-grep outline crates/perry-hir/src/lower/lowering_context.rs --view expanded

printf '\n== context.rs init ==\n'
sed -n '1,140p' crates/perry-hir/src/lower/context.rs

printf '\n== enter/exit scope implementations ==\n'
rg -n "fn (enter_scope|exit_scope)|scope_depth" crates/perry-hir/src/lower/lowering_context.rs crates/perry-hir/src/lower/context.rs -A 40 -B 10 -S

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

set -euo pipefail
sed -n '1520,1575p' crates/perry-hir/src/lower/context.rs

Repository: PerryTS/perry

Length of output: 3095


inferred_class_bindings needs scope-aware tracking This set is module-lifetime and never truncated on enter_scope/exit_scope, so a later unrelated local with the same name can be mistaken for the class’s own binding in new <name>(). Key it by LocalId (or truncate it with scope) instead of a bare String.

📍 Affects 4 files
  • crates/perry-hir/src/lower/lowering_context.rs#L303-L311 (this comment)
  • crates/perry-hir/src/lower/stmt.rs#L64-L66
  • crates/perry-hir/src/lower/stmt.rs#L694-L696
  • crates/perry-hir/src/lower/lower_expr/arm_class.rs#L71-L77
  • crates/perry-hir/src/lower/lower_expr/arm_class.rs#L86-L91
  • crates/perry-hir/src/lower/expr_new.rs#L1159-L1177
🤖 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/lowering_context.rs` around lines 303 - 311, The
inferred_class_bindings tracking must be scope-aware rather than keyed only by
binding name. In crates/perry-hir/src/lower/lowering_context.rs:303-311, change
the set to use LocalId (or equivalent scope-truncated storage), then update all
accesses in crates/perry-hir/src/lower/stmt.rs:64-66 and 694-696,
crates/perry-hir/src/lower/lower_expr/arm_class.rs:71-77 and 86-91, and
crates/perry-hir/src/lower/expr_new.rs:1159-1177 to insert, remove, and query
the resolved local identity so unrelated shadowing bindings cannot use the
static construct path.

/// #4101: original source text keyed by FuncId, captured by slicing the
/// module source against each function's AST span at lowering time.
/// Flushed into `Module.closure_source_text` alongside `pending_functions`.
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ fn emit_class_expression_value_binding(
ctx.mark_local_immutable(id);
}
ctx.register_let_class_alias(bind_name.to_string(), bind_name.to_string());
// The binding's local holds ITS OWN class — `new <bind_name>()` must keep
// the static construct path (see `inferred_class_bindings`).
ctx.inferred_class_bindings.insert(bind_name.to_string());
module.init.push(Stmt::Let {
id,
name: bind_name.to_string(),
Expand Down Expand Up @@ -688,6 +691,9 @@ pub(crate) fn lower_stmt(
let class_id = ctx.fresh_class();
ctx.register_class(bind_name.clone(), class_id);
ctx.register_class(inner_name.clone(), class_id);
// The bind-name local holds ITS OWN
// class (see inferred_class_bindings).
ctx.inferred_class_bindings.insert(bind_name.clone());
ctx.class_expr_aliases
.insert(inner_name.clone(), bind_name.clone());
}
Expand Down
79 changes: 69 additions & 10 deletions crates/perry-runtime/src/object/class_constructors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -950,17 +950,48 @@ pub(crate) unsafe fn replay_class_object_constructor(
args_ptr: *const f64,
args_len: usize,
) {
let Some((ctor_ptr, total_params, sig_caps)) = lookup_class_constructor(class_cid) else {
// Spec: a derived class with no own `constructor` gets the implicit
// `constructor(...args) { super(...args) }` — the nearest ancestor's ctor
// must run with the same argument list. `lookup_class_constructor` holds
// OWN ctors only, so walk the parent chain (static edges and the
// dynamically-registered `class X extends <runtime value>` edges both
// live in CLASS_REGISTRY). Bailing out here instead constructed a
// FIELD-LESS instance silently — mysql2's promise mixin
// (`module.exports = class extends Pool { promise() {…} }`) produced a
// Pool whose ctor never ran, so `pool.config` was undefined and Next.js
// requests died far from the fault (myairank wall 7).
let mut ctor_cid = class_cid;
let mut depth = 0usize;
let found = loop {
if let Some(found) = lookup_class_constructor(ctor_cid) {
break Some(found);
}
match super::class_registry::get_parent_class_id(ctor_cid) {
Some(p) if p != 0 && p != ctor_cid && depth < 32 => {
ctor_cid = p;
depth += 1;
}
_ => break None,
}
};
let Some((ctor_ptr, total_params, sig_caps)) = found else {
return;
};

// Read the snapshotted captures (an own array, in capture-param order).
// Absent → no captures.
let caps_val = crate::object::js_object_get_own_field_or_undef(
classobj_value,
b"__perry_ctor_caps".as_ptr(),
17,
);
// Absent → no captures. The `__perry_ctor_caps` snapshot on this class
// object belongs to ITS OWN ctor — when the walk above resolved an
// ANCESTOR's ctor, that snapshot doesn't apply; use the ancestor's
// decl-site snapshot (CLASS_CAPTURE_VALUES) via the fallback below.
let caps_val = if ctor_cid == class_cid {
crate::object::js_object_get_own_field_or_undef(
classobj_value,
b"__perry_ctor_caps".as_ptr(),
17,
)
} else {
f64::from_bits(crate::value::TAG_UNDEFINED)
};
let caps_jv = crate::value::JSValue::from_bits(caps_val.to_bits());
let (caps_arr, n_caps): (*const crate::array::ArrayHeader, u32) = if caps_jv.is_pointer() {
let arr = caps_jv.as_pointer::<crate::array::ArrayHeader>();
Expand All @@ -981,7 +1012,9 @@ pub(crate) unsafe fn replay_class_object_constructor(
// (p-queue's `new PQueue({...})` left `i.default` undefined and
// `new e.queueClass` threw "undefined is not a constructor").
let snapshot_caps: Vec<u64> = if n_caps == 0 {
class_capture_values(class_cid).unwrap_or_default()
// Keyed by the ctor's OWNING class (`ctor_cid` — differs from
// `class_cid` when the parent walk resolved an ancestor's ctor).
class_capture_values(ctor_cid).unwrap_or_default()
} else {
Vec::new()
};
Expand Down Expand Up @@ -1065,15 +1098,41 @@ pub(crate) unsafe fn replay_registered_class_constructor(
args_ptr: *const f64,
args_len: usize,
) {
let Some((ctor_ptr, total_params, sig_caps)) = lookup_class_constructor(class_cid) else {
// Spec: a derived class with no own `constructor` gets the implicit
// `constructor(...args) { super(...args) }` — the nearest ancestor's ctor
// runs with the same argument list. `lookup_class_constructor` holds OWN
// ctors only, so walk the parent chain (static edges and the
// dynamically-registered `class X extends <runtime value>` edges both
// live in CLASS_REGISTRY). Bailing out here instead constructed a
// FIELD-LESS instance silently — mysql2's promise mixin
// (`module.exports = class extends Pool { promise() {…} }`) produced a
// Pool whose ctor never ran, and the first `.config` read blew up far
// from the fault.
let mut ctor_cid = class_cid;
let mut depth = 0usize;
let found = loop {
if let Some(found) = lookup_class_constructor(ctor_cid) {
break Some(found);
}
match super::class_registry::get_parent_class_id(ctor_cid) {
Some(p) if p != 0 && p != ctor_cid && depth < 32 => {
ctor_cid = p;
depth += 1;
}
_ => break None,
}
};
let Some((ctor_ptr, total_params, sig_caps)) = found else {
return;
};

// A function-nested class declaration may carry a decl-site capture
// snapshot (see CLASS_CAPTURE_VALUES). The ctor's trailing
// `__perry_cap_<id>` params are filled from it; user args fill the rest.
// #5957: the split is the SIGNATURE cap count, not the snapshot length.
let caps = class_capture_values(class_cid).unwrap_or_default();
// Keyed by the ctor's OWNING class (`ctor_cid`), which differs from
// `class_cid` when the walk above resolved an ancestor's ctor.
let caps = class_capture_values(ctor_cid).unwrap_or_default();
let user_params = (total_params as usize).saturating_sub(sig_caps as usize);

let undef = f64::from_bits(crate::value::TAG_UNDEFINED);
Expand Down
Loading