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
22 changes: 22 additions & 0 deletions changelog.d/11188-class-decl-self-statics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
fix(hir): a per-evaluation class declaration's own members see its evaluation, not the shared template (#11157)

bson's `ObjectId` (mongodb 7.5.0 installs bson 7.3.3) gave the same id on every `new ObjectId()` when compiled through `perry.compilePackages`: the counter was always 1 and the 5 `PROCESS_UNIQUE` bytes were all 0. mongodb's `insertMany` then failed with `E11000 duplicate key`.

Root cause: `class ObjectId extends BSONValue` sits inside `bson.cjs`, and a CommonJS module body is a function body. Its base `BSONValue` reads module-level consts, so `BSONValue` is a capturing class value and `ObjectId`'s heritage is a runtime value (`extends_expr`). That sends the declaration down the per-evaluation `ClassExprFresh` path, and its statics (`index`, `PROCESS_UNIQUE`) become own properties of the evaluated class object. The class's own members still resolved the name `ObjectId`, and `this` in a static-field arrow, to the shared template:

- `ObjectId.index = (ObjectId.index + 1) % 0x1000000` in `static getInc()` lowered to a template-keyed `StaticFieldSet`, with `StaticFieldGet` for the read. It read and wrote the template global, never the object whose `index` the static block had set.
- `static resetState = () => { this.index = …; this.PROCESS_UNIQUE = null }` had `this` replaced by `ClassRef(template)`. `this === ObjectId` was false in the static block, and the reset wrote to the template.
- `ObjectId.PROCESS_UNIQUE ??= ByteUtils.randomBytes(5)` in the constructor stored the random bytes where the next read never looked, so the packed id read `undefined` bytes, which came out as 0.

Named class *expressions* already had a compiler-private self-binding local (`class_expr_self_bindings`) that codegen fills with the evaluated class object before any static initializer runs. Class *declarations* did not. The fix:

- `lower_class_decl` registers the same self-binding for a function-body declaration that takes the fresh path because of runtime heritage or private elements. The declaration arm asks for it through `class_decl_self_binding_wanted`. Members then resolve the class name to this evaluation, and `this` in static-field initializers becomes the self-binding. `substitute_lexical_this_in_expr` now adds a local replacement to each rewritten arrow's capture list.
- The `ClassExprFresh` for the declaration carries the binding as its `evaluation_owner` when a static initializer or capture uses it. The owner is registered with the enclosing body's class-expression owners, so it is declared and rooted at body entry.
- The template-keyed `StaticFieldGet`/`StaticFieldSet`/`StaticMethodCall` fast paths no longer fire for a self-binding or for a declaration recorded as per-evaluation (`per_evaluation_class_decls`). Those accesses go through the class value.

Module-top classes and function-body classes that stay on the shared-template path are unchanged. A unit test asserts that the control keeps its `StaticFieldSet`.

Validation (Linux x64, perry-dev, Node 26.5.1):
- `test-files/test_gap_11157_class_decl_self_statics.ts` fails on main at 93a86ffb, passes with the fix, and its output is byte-identical to Node.
- bson 7.3.3 repro from the issue: main gives `distinct 1 rand-zero true counter-step 0`. With the fix it gives `distinct 3 rand-zero false counter-step 1` both with `PERRY_NO_AUTO_OPTIMIZE=1` and with auto-optimize on, which matches Node.
- `lower::tests::issue_11157_class_decl_self_statics` has 2 tests. The per-evaluation test fails with the source change reverted and passes with it.
9 changes: 9 additions & 0 deletions crates/perry-hir/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ pub fn substitute_lexical_this_in_expr(expr: &mut Expr, replacement: &Expr) {
Expr::Closure {
body,
captures_this,
captures,
params,
..
} => {
Expand All @@ -507,6 +508,14 @@ pub fn substitute_lexical_this_in_expr(expr: &mut Expr, replacement: &Expr) {
// slot so the closure-cache key doesn't include a stale
// implicit-this snapshot.
*captures_this = false;
// #11157: a local replacement (a per-evaluation class's
// self-binding) is read from the closure body now, so the
// closure must capture it like any other outer local.
if let Expr::LocalGet(id) = replacement {
if !captures.contains(id) {
captures.push(*id);
}
}
}
}
_ => crate::walker::walk_expr_children_mut(expr, &mut |child| {
Expand Down
29 changes: 29 additions & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,35 @@ impl LoweringContext {
.unwrap_or(false)
}

/// The compiler-private self-binding local `name` resolves to while a
/// per-evaluation class body is lowered (innermost first), unless a nearer
/// local shadows it.
pub(crate) fn resolve_class_self_binding(&self, name: &str) -> Option<LocalId> {
let (_, binding_depth, binding_id) = self
.class_expr_self_bindings
.iter()
.rev()
.find(|(binding_name, _, _)| binding_name == name)?;
let shadowed_by_nearer_local = self
.local_decl_scope_depth(name)
.is_some_and(|local_depth| local_depth > *binding_depth);
(!shadowed_by_nearer_local).then_some(*binding_id)
}

/// #11157: may `<ident>.<static>` be lowered to a template-keyed
/// `StaticFieldSet` / `StaticFieldGet`? Not when `ident` is a per-evaluation class's
/// self-binding, and not for a class declaration lowered per evaluation at
/// all: that class's statics are own properties of each evaluated class
/// object, which is what every read of the binding sees.
pub(crate) fn static_field_access_targets_template(
&self,
ident: &str,
resolved_class: &str,
) -> bool {
self.resolve_class_self_binding(ident).is_none()
&& !self.per_evaluation_class_decls.contains(resolved_class)
}

pub(crate) fn has_static_method(&self, class_name: &str, method_name: &str) -> bool {
self.class_statics_index
.get(class_name)
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-hir/src/lower/context_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ impl LoweringContext {
current_class_inner_name: None,
pending_class_inner_name: None,
class_expr_self_bindings: Vec::new(),
class_decl_self_binding_wanted: false,
class_decl_self_binding: None,
per_evaluation_class_decls: HashSet::new(),
current_class_member_is_static: false,
private_scopes: Vec::new(),
object_super_home_stack: Vec::new(),
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-hir/src/lower/expr_assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,9 @@ fn lower_assignment_target(
// colliding body-local `class X`'s static write targets the
// renamed registrant, not the first same-named one.
let resolved_class = ctx.resolve_class_name(&obj_name);
if ctx.lookup_class(&resolved_class).is_some() {
if ctx.lookup_class(&resolved_class).is_some()
&& ctx.static_field_access_targets_template(&obj_name, &resolved_class)
{
if let ast::MemberProp::Ident(prop_ident) = &member.prop {
let field_name = prop_ident.sym.to_string();
if ctx.has_static_field(&resolved_class, &field_name) {
Expand Down
6 changes: 5 additions & 1 deletion crates/perry-hir/src/lower/expr_call/static_and_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ pub(super) fn try_static_method_and_instance(
// itself keyed on a local/param binding via `lookup_native_instance`
// (e.g. an upgrade handler's `wsId.send(...)` parameter) and must
// still dispatch.
let local_shadows_class = ctx.lookup_local(&obj_name).is_some();
// #11157: a per-evaluation class's self-binding is a value too —
// `C.m()` inside C's own body must call THIS evaluation's `m`
// with `this` = the evaluated class object.
let local_shadows_class = ctx.lookup_local(&obj_name).is_some()
|| ctx.resolve_class_self_binding(&obj_name).is_some();
if local_shadows_class {
// fall through past the static arms to native-instance / generic
// dispatch below.
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-hir/src/lower/expr_member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,7 +1066,7 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re
ctx.inferred_class_bindings
.class_key_for(local, source_name)
!= Some(source_name)
});
}) || ctx.resolve_class_self_binding(source_name).is_some();
let obj_name = ctx.resolve_class_name(source_name);
if !local_shadows_class && ctx.lookup_class(&obj_name).is_some() {
if let ast::MemberProp::Ident(prop_ident) = &member.prop {
Expand Down
14 changes: 2 additions & 12 deletions crates/perry-hir/src/lower/lower_expr/arm_ident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,8 @@ pub(crate) fn lower_ident_expr(ctx: &mut LoweringContext, ident: &ast::Ident) ->
// compiler-private local so its own members use this evaluation and a
// nested class can capture an outer class expression's binding. Search
// innermost-first to preserve ordinary lexical shadowing.
if let Some((_, binding_depth, binding_id)) = ctx
.class_expr_self_bindings
.iter()
.rev()
.find(|(binding_name, _, _)| binding_name == &name)
{
let shadowed_by_nearer_local = ctx
.local_decl_scope_depth(&name)
.is_some_and(|local_depth| local_depth > *binding_depth);
if !shadowed_by_nearer_local {
return Ok(Expr::LocalGet(*binding_id));
}
if let Some(binding_id) = ctx.resolve_class_self_binding(&name) {
return Ok(Expr::LocalGet(binding_id));
}
if ctx.current_class_inner_name.as_deref() == Some(name.as_str()) && !nearer_local {
if let Some(current) = ctx.current_class.clone() {
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-hir/src/lower/lower_expr/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ pub(crate) fn lower_expr_assignment(
// colliding body-local `class X`'s static write targets the
// renamed registrant, not the first same-named one.
let obj_name = ctx.resolve_class_name(obj_ident.sym.as_ref());
if ctx.lookup_class(&obj_name).is_some() {
if ctx.lookup_class(&obj_name).is_some()
&& ctx.static_field_access_targets_template(obj_ident.sym.as_ref(), &obj_name)
{
if let ast::MemberProp::Ident(prop_ident) = &member.prop {
let field_name = prop_ident.sym.to_string();
if ctx.has_static_field(&obj_name, &field_name) {
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 @@ -386,6 +386,23 @@ pub struct LoweringContext {
/// while lowering that ClassBody, while nested classes can still capture
/// an outer class expression's evaluated value.
pub(crate) class_expr_self_bindings: Vec<(String, usize, LocalId)>,
/// #11157: set by the function-body class-DECLARATION arm right before it
/// calls `lower_class_decl`, and consumed there. When the declaration is
/// already known to take the per-evaluation `ClassExprFresh` path (dynamic
/// heritage or private elements), `lower_class_decl` then registers a
/// `class_expr_self_bindings` entry for the class name — exactly what a
/// named class expression gets — so the members' own references to the
/// class resolve to this evaluation instead of the shared template.
pub(crate) class_decl_self_binding_wanted: bool,
/// #11157: the compiler-private self-binding local `lower_class_decl`
/// registered for the declaration it just lowered (see above), handed
/// back to the declaration arm so it can become the `ClassExprFresh`
/// evaluation owner.
pub(crate) class_decl_self_binding: Option<LocalId>,
/// #11157: template names of class DECLARATIONS that lowered to a
/// per-evaluation `ClassExprFresh` binding. A static write through such a
/// class's name must reach the evaluated object, not the template.
pub(crate) per_evaluation_class_decls: HashSet<String>,
/// True while lowering a static class member body.
pub(crate) current_class_member_is_static: bool,
/// Lexical stack of private-name scopes — one entry per enclosing class
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-hir/src/lower/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1989,3 +1989,5 @@ mod issue_10623_require_destructured_native_super;
mod issue_10745_passthrough_heritage;

mod hoisted_sibling_in_later_closure;

mod issue_11157_class_decl_self_statics;
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! #11157: a function-body class DECLARATION lowered per evaluation
//! (`ClassExprFresh`) keeps its statics on each evaluated class object. Its
//! own members must reach them through that evaluation, never through the
//! template-keyed `StaticFieldGet`/`StaticFieldSet` or `ClassRef`.

fn lower(source: &str) -> crate::ir::Module {
let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses");
super::lower_module(&module, "t", "t.ts").expect("source lowers")
}

fn static_method_debug(hir: &crate::ir::Module, class: &str, method: &str) -> String {
let class = hir
.classes
.iter()
.find(|c| c.name == class)
.unwrap_or_else(|| panic!("{class} is lowered"));
let method = class
.static_methods
.iter()
.find(|m| m.name == method)
.unwrap_or_else(|| panic!("{method} is lowered"));
format!("{:?}", method.body)
}

#[test]
fn per_evaluation_class_decl_members_use_the_evaluation() {
let hir = lower(
r#"
function mk() {
const V = 7;
class Base { get v() { return V; } }
class C extends Base {
static n = 0;
static reset = () => { this.n = 40; };
static bump() { return (C.n = (C.n + 1) % 0x1000000); }
}
return C;
}
"#,
);
let bump = static_method_debug(&hir, "C", "bump");
assert!(!bump.contains("StaticFieldSet"), "template write: {bump}");
assert!(!bump.contains("StaticFieldGet"), "template read: {bump}");
let class = hir.classes.iter().find(|c| c.name == "C").unwrap();
let reset = class
.static_fields
.iter()
.find(|f| f.name == "reset")
.and_then(|f| f.init.as_ref())
.expect("reset has an initializer");
let reset = format!("{reset:?}");
assert!(
!reset.contains("ClassRef(\"C\")"),
"arrow this is the template: {reset}"
);
}

#[test]
fn module_top_class_keeps_the_template_static_path() {
let hir = lower(
r#"
class Base {}
class C extends Base {
static n = 0;
static bump() { return (C.n = C.n + 1); }
}
"#,
);
let bump = static_method_debug(&hir, "C", "bump");
assert!(
bump.contains("StaticFieldSet"),
"control lost its fast path: {bump}"
);
}
23 changes: 20 additions & 3 deletions crates/perry-hir/src/lower_decl/body_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ use super::class_computed::push_deduped_class_computed_keys;
use super::helpers::{async_iterator_method_call, is_filehandle_readlines_for_await_target};
use super::*;

mod class_self_binding;
mod detect;
mod for_await;
pub(crate) mod gen_capture_scan;
mod nested_fn_decl;

use class_self_binding::{decl_self_binding_owner, lower_body_class_decl};

use gen_capture_scan::nested_generator_references_outer_locals;

use detect::{
Expand Down Expand Up @@ -338,7 +341,7 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<V
let already_exists = ctx.pending_classes.iter().any(|c| c.name == class_name)
|| ctx.classes_index.contains_key(&class_name);
if !already_exists {
let class = lower_class_decl(ctx, class_decl, false)?;
let (class, decl_self_binding) = lower_body_class_decl(ctx, class_decl)?;
if let Some(extends_expr) = &class.extends_expr {
result.push(Stmt::Expr(Expr::RegisterClassParentDynamic {
class_name: class.name.clone(),
Expand Down Expand Up @@ -388,7 +391,9 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<V
let fresh_binding = has_private_elements
|| class.extends_expr.is_some()
|| !computed_keys.is_empty()
|| (!captured_exprs.is_empty() && !has_static_state);
|| (!captured_exprs.is_empty() && !has_static_state)
// #11157: members that captured the self-binding need it.
|| decl_self_binding.is_some();
let named_statics: Vec<(String, Expr)> = if fresh_binding {
class
.static_fields
Expand Down Expand Up @@ -447,7 +452,19 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<V
),
);
}
let evaluation_owner = decl_self_binding_owner(
ctx,
decl_self_binding,
&class.name,
&captured_exprs,
&named_statics,
&computed_keys,
&computed_statics,
);
let template_name = class.name.clone();
if fresh_binding {
ctx.per_evaluation_class_decls.insert(template_name.clone());
}
ctx.pending_classes.push(class);
// #6465/#5893/#9502 (see `fresh_binding` above): bind the
// declared name to a per-evaluation heap class object carrying
Expand All @@ -471,7 +488,7 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<V
ty: Type::Any,
init: Some(Expr::ClassExprFresh {
template: template_name,
evaluation_owner: None,
evaluation_owner,
named_statics,
computed_keys,
computed_statics,
Expand Down
Loading
Loading