From d4fb220b06a8dac8f06092123be05180769460c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 11 Jun 2026 11:48:26 +0200 Subject: [PATCH] fix(hir,codegen,runtime): class elements/subclass/dstr test262 tail Three shared root causes plus stragglers, +67 tests (94.5% -> 96.0%) on the class elements/subclass/dstr dirs, zero regressions: 1. GetIterator ignored a patched/deleted Array.prototype[Symbol.iterator]: js_get_iterator short-circuited every array to the builtin values iterator. Sticky ARRAY_PROTO_ITERATOR_MODIFIED flag (noted by the symbol set/delete paths) gates the fast path; when modified, the patched method is read off the prototype and called with this=array, and a deleted method throws TypeError. Also fixes array_prototype_addr caching 0 when first probed during runtime init. Covers the async-gen dstr *-array-prototype / iter-get-err families (~50 tests). 2. Static-method 'this' was a compile-time class-ref literal, so C.m.call({}) / inherited D.m() never saw the real receiver and static private brand checks could not fire. New one-shot STATIC_THIS_OVERRIDE (object/mod.rs): armed by js_class_static_method_call, the Function.prototype call/apply arms (for static bound-method values), the codegen static-dispatch tower, and inherited direct calls (js_static_this_arm_classref); consumed by js_static_this_resolve in the compile_static_method prologue, falling back to the lexical class-ref for direct calls. Covers the static-private-* brand-check family (~30 tests) and the wrongly-throwing private-method-referenced-from-static-method cases. 3. 'this' in static field initializers evaluated in module-init context (implicit this) instead of the class constructor: substitute lexical this (including arrow bodies, which compile from these exprs) with ClassRef at class lowering; dedup init_static_fields_late against the inline spec-position StaticFieldSet stmts so initializers no longer run twice nor clobber user reassignments. Stragglers: static private getters/setters register on the static-accessor side (and the instance-ABI direct accessor call is gated off for them); static fields get real own-property descriptors (CLASS_DYNAMIC_PROPS registration + getOwnPropertyDescriptor support, computed string keys become real static props with the 'prototype' TypeError per spec); NamedEvaluation for anonymous functions in field initializers; __lookupGetter__/__lookupSetter__/__defineGetter__/__defineSetter__ dispatch on class instances; for (o.#f of iter) loop heads compile with the brand-guarded private write. --- crates/perry-codegen/src/codegen/helpers.rs | 50 +++++++ crates/perry-codegen/src/codegen/method.rs | 13 +- crates/perry-codegen/src/expr/property_get.rs | 19 ++- crates/perry-codegen/src/expr/property_set.rs | 27 ++-- .../perry-codegen/src/expr/static_method.rs | 18 +++ .../src/lower_call/console_promise.rs | 8 ++ .../src/lower_call/property_get.rs | 15 +++ .../src/runtime_decls/stdlib_ffi.rs | 6 + crates/perry-hir/src/analysis.rs | 124 ++++++++++++++++++ crates/perry-hir/src/lower/for_head.rs | 19 ++- crates/perry-hir/src/lower/stmt.rs | 27 +++- crates/perry-hir/src/lower_decl/class_decl.rs | 51 +++++++ .../perry-hir/src/lower_decl/class_members.rs | 19 ++- .../src/lower_decl/private_members.rs | 18 ++- crates/perry-runtime/src/array/indexing.rs | 35 ++++- crates/perry-runtime/src/array/mod.rs | 7 +- .../src/object/class_registry.rs | 19 +++ .../perry-runtime/src/object/descriptors.rs | 11 ++ crates/perry-runtime/src/object/mod.rs | 90 +++++++++++++ .../src/object/native_call_method.rs | 18 +++ .../perry-runtime/src/object/native_module.rs | 23 ++++ crates/perry-runtime/src/symbol.rs | 70 +++++++++- 22 files changed, 660 insertions(+), 27 deletions(-) diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 83801958de..c968929ae7 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -673,6 +673,31 @@ pub(super) fn init_static_fields_late( continue; } let key = (c.name.clone(), sf.name.clone()); + // Register the field in the runtime CLASS_DYNAMIC_PROPS side + // table (mirroring the StaticFieldSet lowering) so dynamic + // class-ref reads and `getOwnPropertyDescriptor(C, name)` see an + // own data property. Uninitialized fields (`static h;`) register + // `undefined` — per spec they are still own properties. + let emit_static_field_registration = |ctx: &mut crate::expr::FnCtx<'_>, value: &str| { + if let Some(&class_id) = ctx.class_ids.get(&c.name) { + if class_id != 0 { + let idx = ctx.strings.intern(&sf.name); + let entry = ctx.strings.entry(idx); + let bytes_ref = format!("@{}", entry.bytes_global); + let len_str = entry.byte_len.to_string(); + let cid_str = class_id.to_string(); + ctx.block().call_void( + "js_class_register_static_field", + &[ + (crate::types::I32, &cid_str), + (crate::types::PTR, &bytes_ref), + (crate::types::I64, &len_str), + (DOUBLE, value), + ], + ); + } + } + }; let Some(global_name) = ctx.static_field_globals.get(&key).cloned() else { continue; }; @@ -680,6 +705,26 @@ pub(super) fn init_static_fields_late( if init_references_out_of_scope_local(init_expr) { continue; } + // Skip fields whose initializer the HIR already emitted as an + // inline `StaticFieldSet` at the class's source position (the + // spec evaluation point). Re-running it here would (a) fire + // initializer side effects twice and (b) clobber any user + // reassignment made between the class decl and end of module + // init. Mirrors the static-block dedup below. The inline + // lowering also registers the field in CLASS_DYNAMIC_PROPS. + let inline_initialized = hir.init.iter().any(|s| { + matches!( + s, + perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { + class_name, + field_name, + .. + }) if *class_name == c.name && *field_name == sf.name + ) + }); + if inline_initialized { + continue; + } // `this` in a static field initializer is the class // constructor (`static g = this.f + '262'`). Seed the same // class-ref NaN-box a static method binds (see @@ -698,6 +743,11 @@ pub(super) fn init_static_fields_late( let v = v?; let g_ref = format!("@{}", global_name); crate::expr::emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + emit_static_field_registration(ctx, &v); + } else { + let undef = + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + emit_static_field_registration(ctx, &undef); } } } diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index cdc208bc62..a7c42d2ea7 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -592,7 +592,18 @@ pub(super) fn compile_static_method( let (this_slot, locals): (String, HashMap) = { let blk = lf.block_mut(0).unwrap(); let this_slot = blk.alloca(DOUBLE); - blk.store(DOUBLE, &class_ref_lit, &this_slot); + // Receiver-sensitive `this`: dynamic dispatch paths (inherited + // `D.m()`, `C.m.call(x)` / `.apply(x)`) arm a one-shot override that + // this prologue call consumes; direct calls fall back to the lexical + // class-ref, preserving the prior `this === C` behavior. Needed so + // static private brand checks (`this.#x` in a static method) see the + // real receiver (test262 class/elements static-private-*). + let resolved_this = blk.call( + DOUBLE, + "js_static_this_resolve", + &[(DOUBLE, &class_ref_lit)], + ); + blk.store(DOUBLE, &resolved_this, &this_slot); let mut map = HashMap::new(); for p in &f.params { let arg_name = format!("%arg{}", p.id); diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index b925d17dcd..c82cd2f72a 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1459,9 +1459,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return lower_runtime_property_get_by_name(ctx, object, property); } let getter_key = (class_name.clone(), format!("__get_{}", property)); - if let Some(fn_name) = ctx.methods.get(&getter_key).cloned() { - let recv_box = lower_expr(ctx, object)?; - return Ok(ctx.block().call(DOUBLE, &fn_name, &[(DOUBLE, &recv_box)])); + // STATIC accessors are emitted with the static (no-`this`) + // calling convention under a `perry_static_…` symbol, so the + // instance direct-call ABI here would reference a symbol that + // is never emitted (`__get_get_#f` undefined-value link error + // for `static get #f()`). Route them through the dynamic + // by-name dispatch below, which hits CLASS_STATIC_ACCESSORS. + let is_static_accessor = ctx + .classes + .get(&class_name) + .map(|c| c.static_accessor_names.iter().any(|n| n == property)) + .unwrap_or(false); + if !is_static_accessor { + if let Some(fn_name) = ctx.methods.get(&getter_key).cloned() { + let recv_box = lower_expr(ctx, object)?; + return Ok(ctx.block().call(DOUBLE, &fn_name, &[(DOUBLE, &recv_box)])); + } } // #1642: bound-method reference for Web Streams instance methods // (`typeof rs.getReader === "function"`, `const f = rs.getReader; diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index c22db2b156..b2dee78e3e 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -260,15 +260,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return lower_runtime_property_set_by_name(ctx, object, property, value); } let setter_key = (class_name.clone(), format!("__set_{}", property)); - if let Some(fn_name) = ctx.methods.get(&setter_key).cloned() { - let recv_box = lower_expr(ctx, object)?; - let val_double = lower_expr(ctx, value)?; - let _ = ctx.block().call( - DOUBLE, - &fn_name, - &[(DOUBLE, &recv_box), (DOUBLE, &val_double)], - ); - return Ok(val_double); + // STATIC accessors compile under the static (no-`this`) + // convention — see the matching gate in property_get.rs. + let is_static_accessor = ctx + .classes + .get(&class_name) + .map(|c| c.static_accessor_names.iter().any(|n| n == property)) + .unwrap_or(false); + if !is_static_accessor { + if let Some(fn_name) = ctx.methods.get(&setter_key).cloned() { + let recv_box = lower_expr(ctx, object)?; + let val_double = lower_expr(ctx, value)?; + let _ = ctx.block().call( + DOUBLE, + &fn_name, + &[(DOUBLE, &recv_box), (DOUBLE, &val_double)], + ); + return Ok(val_double); + } } // Fast path: known class instance + plain instance field. // The runtime guard checks the receiver's class/shape and diff --git a/crates/perry-codegen/src/expr/static_method.rs b/crates/perry-codegen/src/expr/static_method.rs index 55d596912f..928903c7dd 100644 --- a/crates/perry-codegen/src/expr/static_method.rs +++ b/crates/perry-codegen/src/expr/static_method.rs @@ -92,6 +92,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { crate::codegen::static_method_registry_key(method_name), ); if let Some(fn_name) = ctx.methods.get(&key).cloned() { + // Inherited static (`D.f()` resolving to a parent's body): arm + // the one-shot static-`this` override with the DISPATCH base + // class-ref so the body's `js_static_this_resolve` prologue + // sees `this === D` (spec OrdinaryCallBindThis), not the + // lexical defining class. Own methods skip the arm — the + // prologue's lexical fallback is already the right receiver. + let owns_method = ctx + .classes + .get(class_name) + .map(|c| c.static_methods.iter().any(|m| m.name == *method_name)) + .unwrap_or(true); + if !owns_method { + if let Some(&cid) = ctx.class_ids.get(class_name) { + let cid_str = cid.to_string(); + ctx.block() + .call_void("js_static_this_arm_classref", &[(I32, &cid_str)]); + } + } let mut lowered: Vec = Vec::with_capacity(args.len()); for a in args { lowered.push(lower_expr(ctx, a)?); diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index a005b8c3d8..55e888a96c 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -717,6 +717,14 @@ pub fn try_lower_native_method_str_dispatch( | "isPrototypeOf" | "toLocaleString" | "valueOf" + // Annex B §B.2.2 Object.prototype accessor helpers — handled + // by `js_native_call_method`; the static class-dispatch tower + // would read them as a non-callable property and throw + // (test262 elements/private-getter-is-not-a-own-property). + | "__lookupGetter__" + | "__lookupSetter__" + | "__defineGetter__" + | "__defineSetter__" // #4795: the `using`-declaration disposability validator is not // a class method — it must reach the runtime `js_native_call_method` // handler (which checks symbol keys + the class vtable) rather diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index b81f008b1b..907241875f 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -1145,6 +1145,21 @@ pub fn try_lower_property_get_method_call( let prev_this = ctx.block() .call(DOUBLE, "js_implicit_this_set", &[(DOUBLE, &recv_box)]); + // Receiver-sensitive static `this` for plain class-ref receivers: + // `D.f()` resolving to a parent's body at compile time must run + // with `this === D` (the prologue's `js_static_this_resolve` + // consumes this one-shot arm). Dynamic-value receiver shapes + // (ClassExprFresh / factory Call / LocalGet) keep their prior + // implicit-this-only behavior to avoid disturbing effect's + // per-evaluation class-object statics. + let plain_class_receiver = matches!( + object.as_ref(), + Expr::ClassRef(_) | Expr::ExternFuncRef { .. } + ); + if plain_class_receiver { + ctx.block() + .call_void("js_static_this_arm_value", &[(DOUBLE, &recv_box)]); + } let arg_slices: Vec<(crate::types::LlvmType, &str)> = lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); let result = ctx.block().call(DOUBLE, &fn_name, &arg_slices); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index 9844fe6b54..60631d4e51 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -1882,6 +1882,12 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { module.declare_function("js_implicit_this_get", DOUBLE, &[]); module.declare_function("js_implicit_this_get_sloppy", DOUBLE, &[]); module.declare_function("js_implicit_this_set", DOUBLE, &[DOUBLE]); + // Static-method prologue `this`: takes the one-shot receiver override + // armed by dynamic static dispatch / call/apply, else returns the + // lexical class-ref argument. + module.declare_function("js_static_this_resolve", DOUBLE, &[DOUBLE]); + module.declare_function("js_static_this_arm_classref", VOID, &[I32]); + module.declare_function("js_static_this_arm_value", VOID, &[DOUBLE]); module.declare_function("js_ctor_return_override", DOUBLE, &[DOUBLE, DOUBLE, I32]); module.declare_function("js_new_target_get", DOUBLE, &[]); module.declare_function("js_new_target_set", DOUBLE, &[DOUBLE]); diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 8553318845..7b1a46725c 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -1445,6 +1445,130 @@ pub(crate) fn collect_assigned_locals_expr(expr: &Expr, assigned: &mut Vec *expr = replacement.clone(), + Expr::Closure { + body, + captures_this, + params, + .. + } => { + if *captures_this { + for p in params.iter_mut() { + if let Some(d) = &mut p.default { + substitute_lexical_this_in_expr(d, replacement); + } + } + substitute_lexical_this_in_stmts(body, replacement); + // The body no longer reads `this`; drop the reserved capture + // slot so the closure-cache key doesn't include a stale + // implicit-this snapshot. + *captures_this = false; + } + } + _ => crate::walker::walk_expr_children_mut(expr, &mut |child| { + substitute_lexical_this_in_expr(child, replacement) + }), + } +} + +pub fn substitute_lexical_this_in_stmts(stmts: &mut [Stmt], replacement: &Expr) { + for s in stmts { + substitute_lexical_this_in_stmt(s, replacement); + } +} + +fn substitute_lexical_this_in_stmt(stmt: &mut Stmt, replacement: &Expr) { + let mut on_expr = |e: &mut Expr| substitute_lexical_this_in_expr(e, replacement); + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + on_expr(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => on_expr(e), + Stmt::Return(e) => { + if let Some(e) = e { + on_expr(e); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + on_expr(condition); + substitute_lexical_this_in_stmts(then_branch, replacement); + if let Some(eb) = else_branch { + substitute_lexical_this_in_stmts(eb, replacement); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + on_expr(condition); + substitute_lexical_this_in_stmts(body, replacement); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + substitute_lexical_this_in_stmt(i, replacement); + } + if let Some(c) = condition { + on_expr(c); + } + if let Some(u) = update { + on_expr(u); + } + substitute_lexical_this_in_stmts(body, replacement); + } + Stmt::Labeled { body, .. } => substitute_lexical_this_in_stmt(body, replacement), + Stmt::Try { + body, + catch, + finally, + } => { + substitute_lexical_this_in_stmts(body, replacement); + if let Some(c) = catch { + substitute_lexical_this_in_stmts(&mut c.body, replacement); + } + if let Some(f) = finally { + substitute_lexical_this_in_stmts(f, replacement); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + on_expr(discriminant); + for case in cases { + if let Some(t) = &mut case.test { + substitute_lexical_this_in_expr(t, replacement); + } + substitute_lexical_this_in_stmts(&mut case.body, replacement); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) => {} + } +} + pub fn replace_this_in_stmts(stmts: &mut Vec, this_id: LocalId) { for s in stmts { replace_this_in_stmt(s, this_id); diff --git a/crates/perry-hir/src/lower/for_head.rs b/crates/perry-hir/src/lower/for_head.rs index 9487789854..81cd25fbd4 100644 --- a/crates/perry-hir/src/lower/for_head.rs +++ b/crates/perry-hir/src/lower/for_head.rs @@ -167,8 +167,23 @@ pub(crate) fn for_head_binding_stmts( index: Box::new(lower_expr(ctx, &c.expr)?), value: Box::new(source), }, - ast::MemberProp::PrivateName(_) => { - return Err(anyhow!("private member as for-loop head not supported")) + ast::MemberProp::PrivateName(p) => { + // `for (o.#f of iter)` — assign each iteration value to the + // private field, brand-guarding the receiver (write op) so a + // receiver without the field throws TypeError per spec + // (test262 elements/privatefieldset-typeerror-6/7). + let property = format!("#{}", p.name); + let object = crate::lower::expr_member::wrap_private_guard( + ctx, + object, + &property, + crate::lower::expr_member::PRIV_OP_WRITE, + ); + Expr::PropertySet { + object, + property, + value: Box::new(source), + } } }; Ok(vec![Stmt::Expr(assign)]) diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index d60007961e..c4896f18f2 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -709,17 +709,26 @@ pub(crate) fn lower_stmt( .iter() .filter_map(|sf| { sf.init.as_ref().map(|init| { + // `this` in a static initializer is + // the class constructor — see the + // matching substitution in the + // `Decl::Class` arm. + let mut init_value = init.clone(); + crate::analysis::substitute_lexical_this_in_expr( + &mut init_value, + &Expr::ClassRef(bind_name.clone()), + ); if let Some(key) = sf.key_expr.as_ref() { Stmt::Expr(Expr::ClassStaticSymbolSet { class_name: bind_name.clone(), key: Box::new(key.clone()), - value: Box::new(init.clone()), + value: Box::new(init_value), }) } else { Stmt::Expr(Expr::StaticFieldSet { class_name: bind_name.clone(), field_name: sf.name.clone(), - value: Box::new(init.clone()), + value: Box::new(init_value), }) } }) @@ -1060,17 +1069,27 @@ pub(crate) fn lower_stmt( // point in source order. for sf in &class.static_fields { if let Some(init) = &sf.init { + // Per ClassDefinitionEvaluation the initializer + // runs with `this` bound to the class constructor; + // these stmts evaluate in module-init context + // (empty this_stack), so substitute lexical `this` + // — including inside arrows — with the class ref. + let mut init_value = init.clone(); + crate::analysis::substitute_lexical_this_in_expr( + &mut init_value, + &Expr::ClassRef(class.name.clone()), + ); if let Some(key) = sf.key_expr.as_ref() { module.init.push(Stmt::Expr(Expr::ClassStaticSymbolSet { class_name: class.name.clone(), key: Box::new(key.clone()), - value: Box::new(init.clone()), + value: Box::new(init_value), })); } else { module.init.push(Stmt::Expr(Expr::StaticFieldSet { class_name: class.name.clone(), field_name: sf.name.clone(), - value: Box::new(init.clone()), + value: Box::new(init_value), })); } } diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 2b47c1460f..6a718d3bee 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -774,11 +774,26 @@ pub fn lower_class_decl( // the property name, not `get_#name`). let prop_name = format!("#{}", method.key.name); let func = lower_private_getter(ctx, method)?; + // A STATIC private accessor must register on the + // class's static-accessor side (mirroring the public + // static getter/setter arms above) so `this.#f` with + // a class-ref receiver dispatches it. Pre-fix it only + // landed in the instance getter registry and the + // static read returned undefined (test262 + // static-private-getter*). + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } getters.push((prop_name, func)); } ast::MethodKind::Setter => { let prop_name = format!("#{}", method.key.name); let func = lower_private_setter(ctx, method)?; + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } setters.push((prop_name, func)); } } @@ -1027,6 +1042,21 @@ pub fn lower_class_decl( ctx.register_class_field_types(name.clone(), field_types); } + // `this` in a STATIC field initializer is the class constructor per + // ClassDefinitionEvaluation. Substitute lexically — including inside + // arrow / this-capturing closure BODIES (which compile from these very + // exprs) — so every consumer (the inline init stmts at the class-decl + // source position, init_static_fields_late) evaluates with the right + // receiver. Without the in-place rewrite, a stmt-level clone substitution + // desyncs the closure creation site from the compiled body (the body is + // compiled from this original) and `static f = () => this` returned the + // unpatched capture slot (test262 static-field-init-this-inside-arrow). + for sf in &mut static_fields { + if let Some(init) = &mut sf.init { + crate::analysis::substitute_lexical_this_in_expr(init, &Expr::ClassRef(name.clone())); + } + } + // Exit type parameter scope ctx.exit_type_param_scope(); @@ -1404,11 +1434,21 @@ pub fn lower_class_from_ast( ast::MethodKind::Getter => { let prop_name = format!("#{}", method.key.name); let func = lower_private_getter(ctx, method)?; + // Static private accessor — register on the static + // side (see the matching arm in `lower_class_decl`). + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } getters.push((prop_name, func)); } ast::MethodKind::Setter => { let prop_name = format!("#{}", method.key.name); let func = lower_private_setter(ctx, method)?; + if method.is_static { + static_accessor_names.push(prop_name.clone()); + static_accessor_fn_ids.push(func.id); + } setters.push((prop_name, func)); } } @@ -1444,6 +1484,17 @@ pub fn lower_class_from_ast( } } + // `this` in static field initializers — see the matching substitution in + // `lower_class_decl` above. + for sf in &mut static_fields { + if let Some(init) = &mut sf.init { + crate::analysis::substitute_lexical_this_in_expr( + init, + &Expr::ClassRef(name.to_string()), + ); + } + } + ctx.exit_type_param_scope(); // Issue #562: see the parallel site in `lower_class_decl` — register // native_extends so subclass instances of the three Web Stream base diff --git a/crates/perry-hir/src/lower_decl/class_members.rs b/crates/perry-hir/src/lower_decl/class_members.rs index 8a3cc2029e..4614ef7627 100644 --- a/crates/perry-hir/src/lower_decl/class_members.rs +++ b/crates/perry-hir/src/lower_decl/class_members.rs @@ -905,9 +905,26 @@ pub fn lower_class_prop(ctx: &mut LoweringContext, prop: &ast::ClassProp) -> Res // Lower initializer expression if present. Mark the field-initializer // context so a direct `eval` in the initializer rejects `arguments` // (PerformEval early error — field initializers have no arguments object). + // NamedEvaluation: an anonymous function/arrow/class initializer takes + // the field's name (`static fromArgs = function(){}` → `.name === + // "fromArgs"`, test262 elements/static-field-anonymous-function-name). + // Computed keys (key_expr) have no compile-time name to confer. let saved_field_init = ctx.in_class_field_init; ctx.in_class_field_init = true; - let init = prop.value.as_ref().map(|e| lower_expr(ctx, e)).transpose(); + let init = prop + .value + .as_ref() + .map(|e| { + if key_expr.is_none() && crate::lower::expr_assign::rhs_accepts_assignment_name(e) { + let old = ctx.assignment_inferred_name.replace(name.clone()); + let result = lower_expr(ctx, e); + ctx.assignment_inferred_name = old; + result + } else { + lower_expr(ctx, e) + } + }) + .transpose(); ctx.in_class_field_init = saved_field_init; let init = init?; diff --git a/crates/perry-hir/src/lower_decl/private_members.rs b/crates/perry-hir/src/lower_decl/private_members.rs index 9b77087618..8840c5ef91 100644 --- a/crates/perry-hir/src/lower_decl/private_members.rs +++ b/crates/perry-hir/src/lower_decl/private_members.rs @@ -368,9 +368,25 @@ pub fn lower_private_prop( // Lower initializer expression if present — field-initializer context for // the direct-eval `arguments` early error (see `lower_class_prop`). + // NamedEvaluation: an anonymous function initializer takes the private + // field's name including the `#` (`static #field = function(){}` → + // `.name === "#field"`, test262 static-field-anonymous-function-name). let saved_field_init = ctx.in_class_field_init; ctx.in_class_field_init = true; - let init = prop.value.as_ref().map(|e| lower_expr(ctx, e)).transpose(); + let init = prop + .value + .as_ref() + .map(|e| { + if crate::lower::expr_assign::rhs_accepts_assignment_name(e) { + let old = ctx.assignment_inferred_name.replace(name.clone()); + let result = lower_expr(ctx, e); + ctx.assignment_inferred_name = old; + result + } else { + lower_expr(ctx, e) + } + }) + .transpose(); ctx.in_class_field_init = saved_field_init; let init = init?; diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 2caa5f50af..397e29d59e 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -83,7 +83,32 @@ pub(crate) fn object_prototype_addr_matches(addr: usize) -> bool { addr != 0 && addr == object_prototype_addr() } -fn array_prototype_addr() -> usize { +/// Sticky flag: user code replaced or deleted `Array.prototype[Symbol.iterator]`. +/// `js_get_iterator`'s array short-circuit assumes the builtin values iterator; +/// once this flips, GetIterator on an array must consult the (patched) method +/// per spec — or throw TypeError when it was deleted. Same single-relaxed-load +/// hot-path shape as `ARRAY_PROTO_HAS_INDEX` above. +static ARRAY_PROTO_ITERATOR_MODIFIED: AtomicBool = AtomicBool::new(false); + +/// Record (if `obj` is `Array.prototype` and `sym_key` is the well-known +/// `Symbol.iterator`) that the array iteration protocol has been tampered +/// with. Called from the symbol-property set/delete paths. +pub(crate) fn note_array_proto_iterator_write(obj: usize, sym_key: usize) { + if ARRAY_PROTO_ITERATOR_MODIFIED.load(Ordering::Relaxed) || obj == 0 || sym_key == 0 { + return; + } + if obj == array_prototype_addr() + && sym_key == crate::symbol::well_known_symbol("iterator") as usize + { + ARRAY_PROTO_ITERATOR_MODIFIED.store(true, Ordering::Relaxed); + } +} + +pub(crate) fn array_proto_iterator_modified() -> bool { + ARRAY_PROTO_ITERATOR_MODIFIED.load(Ordering::Relaxed) +} + +pub(crate) fn array_prototype_addr() -> usize { let cached = ARRAY_PROTO_ADDR.load(Ordering::Relaxed); if cached != usize::MAX { return cached; @@ -102,7 +127,13 @@ fn array_prototype_addr() -> usize { } else { 0 }; - ARRAY_PROTO_ADDR.store(addr, Ordering::Relaxed); + // Don't poison the cache with 0: during runtime init the global `Array` + // constructor may not be materialized yet (symbol writes on other builtin + // prototypes call into here via `note_array_proto_iterator_write`). + // Re-derive until it resolves. + if addr != 0 { + ARRAY_PROTO_ADDR.store(addr, Ordering::Relaxed); + } addr } diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 1896b818fb..64fe20551f 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -66,9 +66,10 @@ pub use self::immutable::{ js_array_with, }; pub(crate) use self::indexing::{ - array_has_own_index, array_iteration_is_exotic, array_prototype_has_index_flag, array_spec_get, - array_spec_has_index, note_object_prototype_index_write, object_prototype_addr_matches, - object_prototype_has_index_flag, + array_has_own_index, array_iteration_is_exotic, array_proto_iterator_modified, + array_prototype_addr, array_prototype_has_index_flag, array_spec_get, array_spec_has_index, + note_array_proto_iterator_write, note_object_prototype_index_write, + object_prototype_addr_matches, object_prototype_has_index_flag, }; pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index df000ed08f..de2fdb51c4 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -79,6 +79,19 @@ pub(crate) fn class_dynamic_prop_root_store(class_id: u32, name: String, value: crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); } +/// Own static-field value for a class (no parent-chain walk) — the +/// CLASS_DYNAMIC_PROPS entry codegen registers at module init for every +/// declared static field. Consulted by `getOwnPropertyDescriptor` on a class +/// constructor ref so `verifyProperty(C, "field", …)` sees a real data +/// descriptor (test262 class/elements static-field-declaration & friends). +pub(crate) fn class_own_static_field_value(class_id: u32, name: &str) -> Option { + CLASS_DYNAMIC_PROPS.with(|m| { + m.borrow() + .get(&class_id) + .and_then(|props| props.get(name).copied()) + }) +} + pub(crate) fn class_delete_own_dynamic_prop(class_id: u32, name: &str) { CLASS_DYNAMIC_PROPS.with(|m| { if let Some(props) = m.borrow_mut().get_mut(&class_id) { @@ -5101,6 +5114,11 @@ pub unsafe extern "C" fn js_class_static_method_call( } if let Some((func_ptr, param_count, has_rest)) = lookup_static_method_in_chain(class_id, name) { let prev_this = crate::object::js_implicit_this_set(receiver); + // Receiver-sensitive static `this`: arm the one-shot override so the + // method prologue (`js_static_this_resolve`) sees the DYNAMIC receiver + // (e.g. subclass `D` for an inherited `D.f()`). If an outer + // call/apply already armed an explicit thisArg, that wins. + crate::object::static_this_arm_if_unarmed(receiver); let result = if has_rest { // `static foo(a, b, ...rest)` / `static pipe(...args)` (effect's // `pipe`/`dual`): pass the first `param_count-1` positional args @@ -5130,6 +5148,7 @@ pub unsafe extern "C" fn js_class_static_method_call( } else { call_static_method(func_ptr, args_ptr, args_len, param_count) }; + crate::object::static_this_disarm(); crate::object::js_implicit_this_set(prev_this); return result; } diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 7299e3a9a1..e3be7b9a64 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -372,6 +372,17 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu super::js_class_method_bind(obj_value, leaked.as_ptr(), leaked.len()); return build_data_descriptor(value, true, false, true); } + // Static FIELDS are own data properties of the constructor, + // created via CreateDataPropertyOrThrow → writable, enumerable, + // configurable all true. Codegen registers each declared + // static field in CLASS_DYNAMIC_PROPS at module init. + if super::class_prototype_ref_id(obj_value).is_none() { + if let Some(v) = + super::class_registry::class_own_static_field_value(class_id, &method_name) + { + return build_data_descriptor(v, true, true, true); + } + } } return f64::from_bits(crate::value::TAG_UNDEFINED); } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 2715eec81b..168d6fdbed 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -352,6 +352,90 @@ thread_local! { thread_local! { static IMPLICIT_THIS: Cell = const { Cell::new(crate::value::TAG_UNDEFINED) }; static NEW_TARGET: Cell = const { Cell::new(crate::value::TAG_UNDEFINED) }; + // One-shot receiver override for STATIC method bodies. A compiled static + // method's `this` slot used to be a compile-time class-ref literal, so + // `C.m.call({})` / `D.m()` (inherited) ran with `this === C` and static + // private brand checks could never throw (test262 class/elements + // static-private-*). Armed by the dynamic dispatch paths that know the + // real receiver (`js_class_static_method_call`, the Function.prototype + // call/apply arms for a static bound-method value); consumed (take + // semantics) by `js_static_this_resolve` in the static-method prologue. + // Direct compiled calls never arm it, so they keep the lexical class-ref. + static STATIC_THIS_OVERRIDE: Cell<(bool, u64)> = + const { Cell::new((false, crate::value::TAG_UNDEFINED)) }; +} + +/// Arm the static-`this` override unconditionally (used by the call/apply +/// receiver paths, which take precedence over the inner dynamic dispatch). +pub(crate) fn static_this_arm(value: f64) { + STATIC_THIS_OVERRIDE.with(|c| c.set((true, value.to_bits()))); +} + +/// Arm the static-`this` override only when no outer caller has already armed +/// it — `js_class_static_method_call` runs INSIDE the call/apply plumbing, and +/// the outermost receiver (the `.call(x)` thisArg) must win. +pub(crate) fn static_this_arm_if_unarmed(value: f64) { + STATIC_THIS_OVERRIDE.with(|c| { + if !c.get().0 { + c.set((true, value.to_bits())); + } + }); +} + +/// Disarm without consuming (paired with arm sites as a safety net in case +/// the invoked target never reached a static-method prologue). +pub(crate) fn static_this_disarm() { + STATIC_THIS_OVERRIDE.with(|c| c.set((false, crate::value::TAG_UNDEFINED))); +} + +/// Arm the static-`this` override with a class constructor ref. Emitted by +/// codegen immediately before a direct call to an INHERITED static method +/// (`D.f()` where `f` lives on a parent class) so the body sees the dispatch +/// base (`this === D`) instead of the lexical defining class — spec +/// OrdinaryCallBindThis for `D.f()`, and what makes static-private brand +/// checks on subclass receivers throw (test262 static-private-method- +/// subclass-receiver). +// #1561-style force-keep: only generated IR calls this. +#[used] +static KEEP_JS_STATIC_THIS_ARM_CLASSREF: extern "C" fn(u32) = js_static_this_arm_classref; + +#[no_mangle] +pub extern "C" fn js_static_this_arm_classref(class_id: u32) { + if class_id != 0 { + static_this_arm(native_module::class_constructor_ref_value(class_id)); + } +} + +/// Arm the static-`this` override with an arbitrary receiver value. Emitted +/// by the codegen static-dispatch tower (`D.f()` where the receiver is a +/// class-ref expression and the method resolves on a parent class at compile +/// time) right before the direct call. +// #1561-style force-keep: only generated IR calls this. +#[used] +static KEEP_JS_STATIC_THIS_ARM_VALUE: extern "C" fn(f64) = js_static_this_arm_value; + +#[no_mangle] +pub extern "C" fn js_static_this_arm_value(value: f64) { + static_this_arm(value); +} + +/// Static-method prologue `this` resolution: take the armed override if any, +/// else the lexical class-ref the codegen passes in. +// #1561-style force-keep: only generated IR calls this. +#[used] +static KEEP_JS_STATIC_THIS_RESOLVE: extern "C" fn(f64) -> f64 = js_static_this_resolve; + +#[no_mangle] +pub extern "C" fn js_static_this_resolve(default_this: f64) -> f64 { + STATIC_THIS_OVERRIDE.with(|c| { + let (armed, bits) = c.get(); + if armed { + c.set((false, crate::value::TAG_UNDEFINED)); + f64::from_bits(bits) + } else { + default_this + } + }) } /// Read the current implicit `this` (issue #519). @@ -439,6 +523,12 @@ pub fn scan_implicit_this_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor< c.set(bits); } }); + STATIC_THIS_OVERRIDE.with(|c| { + let (armed, mut bits) = c.get(); + if visitor.visit_nanbox_u64_slot(&mut bits) { + c.set((armed, bits)); + } + }); } /// Read the u64 bits stored at `field_index` for `obj`, or `None` if absent. diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index f1220c5e60..3ba5bc13d7 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -4375,7 +4375,17 @@ pub unsafe extern "C" fn js_native_call_method( }; let rest_len = if args_len > 1 { args_len - 1 } else { 0 }; let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); + // Static bound-method value (`C.m.call(x)`): arm the one-shot + // static-`this` override so the method body sees `x` instead + // of the lexical class-ref (static private brand checks). + let static_target = super::native_module::is_static_bound_method_value(object); + if static_target { + super::static_this_arm(this_arg); + } let result = crate::closure::js_native_call_value(object, rest_ptr, rest_len); + if static_target { + super::static_this_disarm(); + } IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } @@ -4467,8 +4477,16 @@ pub unsafe extern "C" fn js_native_call_method( (buf.as_ptr(), buf.len()) }; let prev_this = IMPLICIT_THIS.with(|c| c.replace(this_arg.to_bits())); + // Static bound-method value — see the matching `call` arm. + let static_target = super::native_module::is_static_bound_method_value(object); + if static_target { + super::static_this_arm(this_arg); + } let result = crate::closure::js_native_call_value(object, call_args_ptr, call_args_len); + if static_target { + super::static_this_disarm(); + } IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index d4d79593bc..a44d278d67 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -5958,6 +5958,29 @@ pub(crate) fn build_bound_method_closure( /// call-site `this` (IMPLICIT_THIS) provided it is itself a dispatchable class /// receiver (an instance or class ref). Otherwise the captured value is the real /// receiver and is returned unchanged. See `dispatch_bound_method`. +/// Is `value` a bound STATIC-method value — a BOUND_METHOD closure whose +/// captured receiver is a class constructor ref (`C.staticMethod` read as a +/// value)? Used by the Function.prototype call/apply arms to arm the one-shot +/// static-`this` override with the explicit thisArg, so the static method body +/// sees the receiver (`C.m.call({})` → `this === {}`) and static private brand +/// checks behave per spec. +pub(crate) fn is_static_bound_method_value(value: f64) -> bool { + let jv = JSValue::from_bits(value.to_bits()); + if !jv.is_pointer() { + return false; + } + let raw = (value.to_bits() & crate::value::POINTER_MASK) as usize; + if !crate::closure::is_closure_ptr(raw) { + return false; + } + let closure = raw as *const crate::closure::ClosureHeader; + if unsafe { (*closure).func_ptr } as usize != crate::closure::BOUND_METHOD_FUNC_PTR as usize { + return false; + } + let captured = crate::closure::js_closure_get_capture_f64(closure, 0); + class_ref_id(captured).is_some() && class_prototype_ref_id(captured).is_none() +} + pub(crate) fn canonical_bound_method_receiver(captured: f64) -> f64 { if class_prototype_ref_id(captured).is_some() { let call_this = super::js_implicit_this_get(); diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 63b23b2c77..3e54148d43 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -536,6 +536,10 @@ pub(crate) unsafe fn js_object_delete_symbol_property(obj_f64: f64, sym_f64: f64 if get_symbol_property_attrs(obj_key, sym_key).is_some_and(|attrs| !attrs.configurable()) { return 0; } + // `delete Array.prototype[Symbol.iterator]` — the builtin iterator is + // virtual (native dispatch, not in the side table), so the delete must + // still flip the modified flag for `js_get_iterator` to throw per spec. + crate::array::note_array_proto_iterator_write(obj_key, sym_key); accessors::clear_symbol_accessor_property(obj_key, sym_key); { @@ -757,6 +761,9 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 if obj_key == 0 || sym_key == 0 { return value_f64; } + // `Array.prototype[Symbol.iterator] = fn` disables the array fast path in + // `js_get_iterator` so destructuring / GetIterator see the patched method. + crate::array::note_array_proto_iterator_write(obj_key, sym_key); let has_own_data = object_symbol_data_property_exists(obj_key, sym_key); // Frozen / sealed / non-extensible receivers reject symbol-keyed writes // like string-keyed ones: an existing prop is non-writable when frozen @@ -877,7 +884,33 @@ static CLASS_STATIC_SYMBOLS: Mutex>> = Mutex:: #[no_mangle] pub unsafe extern "C" fn js_class_register_static_symbol(class_id: u32, sym: f64, value: f64) { let sym_key = sym_key_from_f64(sym); - if class_id == 0 || sym_key == 0 { + if class_id == 0 { + return; + } + if sym_key == 0 { + // Computed STATIC field whose key evaluated to a non-symbol — + // ToPropertyKey makes it a string. A "prototype"-named static field + // is a TypeError per ClassDefinitionEvaluation; anything else + // becomes an ordinary own static data property (numeric keys, a + // computed "constructor", drizzle-style `static [name] = v`). + let key_str = crate::builtins::js_string_coerce(sym); + if key_str.is_null() { + return; + } + let name_ptr = (key_str as *const u8).add(std::mem::size_of::()); + let name_len = (*key_str).byte_len as usize; + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) else { + return; + }; + if name == "prototype" { + let msg = "Classes may not have a static property named 'prototype'"; + let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err = crate::error::js_typeerror_new(s); + crate::exception::js_throw(f64::from_bits( + crate::value::JSValue::pointer(err as *const u8).bits(), + )); + } + crate::object::class_dynamic_prop_root_store(class_id, name.to_string(), value); return; } store_class_static_symbol_root(class_id, sym_key, value.to_bits()); @@ -2077,6 +2110,41 @@ pub extern "C" fn js_iterator_result_validate(result: f64) -> f64 { #[no_mangle] pub extern "C" fn js_get_iterator(val_f64: f64) -> f64 { if crate::array::js_array_is_array(val_f64).to_bits() == crate::value::TAG_TRUE { + if !crate::array::array_proto_iterator_modified() { + return crate::array::array_values_iter(val_f64); + } + // `Array.prototype[Symbol.iterator]` was replaced or deleted. Per + // GetIterator, read the (patched) method off the prototype and call it + // with `this === val`; a deleted/non-callable method is a TypeError. + // The generic symbol lookup below reads OWN symbol props only, so the + // prototype is consulted explicitly here. + let proto_addr = crate::array::array_prototype_addr(); + if proto_addr != 0 { + let iter_wk = well_known_symbol("iterator"); + if !iter_wk.is_null() { + let proto_f64 = + f64::from_bits(crate::value::JSValue::pointer(proto_addr as *const u8).bits()); + let sym_f64 = + f64::from_bits(crate::value::JSValue::pointer(iter_wk as *const u8).bits()); + let iter_fn = unsafe { own_symbol_property(proto_f64, sym_f64) } + .unwrap_or(f64::from_bits(TAG_UNDEFINED)); + let fn_ptr = crate::value::js_nanbox_get_pointer(iter_fn) + as *const crate::closure::ClosureHeader; + if iter_fn.to_bits() == TAG_UNDEFINED || fn_ptr.is_null() { + throw_value_not_iterable(); + } + let prev_this = crate::object::js_implicit_this_set(val_f64); + let rebound = crate::closure::clone_closure_rebind_this(iter_fn.to_bits(), val_f64); + let rebound_ptr = crate::value::js_nanbox_get_pointer(f64::from_bits(rebound)) + as *const crate::closure::ClosureHeader; + let iter = crate::closure::js_closure_call0(rebound_ptr); + crate::object::js_implicit_this_set(prev_this); + if !is_object_value(iter) { + throw_iterator_result_not_object(); + } + return iter; + } + } return crate::array::array_values_iter(val_f64); } // Arguments objects iterate like arrays (spec: