From d78fa6c9bf358b83fe54bea51639abb9fee99bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 10:51:56 +0200 Subject: [PATCH 01/17] fix remaining class semantics feedback --- Cargo.lock | 1 + Cargo.toml | 1 + crates/perry-codegen/src/codegen/helpers.rs | 16 ++ crates/perry-codegen/src/codegen/method.rs | 11 +- .../src/expr/static_field_meta.rs | 139 +++++++++-------- .../perry-codegen/src/expr/this_super_call.rs | 23 ++- crates/perry-codegen/src/lower_call/new.rs | 1 + crates/perry-codegen/tests/typed_feedback.rs | 10 +- .../src/analysis/value_types_tests.rs | 1 + crates/perry-hir/src/ir/decl.rs | 3 + crates/perry-hir/src/ir/expr.rs | 13 ++ crates/perry-hir/src/ir/mod.rs | 3 +- crates/perry-hir/src/lower/expr_assign.rs | 20 ++- crates/perry-hir/src/lower/expr_misc.rs | 8 +- crates/perry-hir/src/lower/fn_ctor_env.rs | 33 ++++ .../src/lower/lower_expr/arm_class.rs | 29 ++-- crates/perry-hir/src/lower/module_decl.rs | 109 +++++++------ .../src/lower/shared_mutable_capture.rs | 55 ++++++- crates/perry-hir/src/lower/stmt.rs | 27 +++- crates/perry-hir/src/lower_decl/body_stmt.rs | 60 +++++--- .../src/lower_decl/class_computed.rs | 105 +++++++++++++ crates/perry-hir/src/lower_decl/class_decl.rs | 63 +++++--- crates/perry-hir/src/lower_decl/mod.rs | 8 +- .../perry-hir/src/lower_decl/static_init.rs | 78 ++++++++-- crates/perry-hir/src/monomorph/specialize.rs | 1 + crates/perry-hir/src/stable_hash/decls.rs | 1 + crates/perry-hir/src/stable_hash/expr.rs | 2 +- crates/perry-parser/Cargo.toml | 1 + crates/perry-parser/src/lib.rs | 97 +++++++++++- crates/perry-runtime/src/array/subclass.rs | 2 +- crates/perry-runtime/src/exception.rs | 13 ++ .../src/node_stream_constructors/builders.rs | 4 +- .../src/object/class_constructors.rs | 54 +++++-- .../src/object/class_registry.rs | 19 +-- .../src/object/class_registry/construct.rs | 123 ++++++++------- .../class_registry/construct/class_object.rs | 19 +++ .../class_registry/construct/class_return.rs | 16 +- .../construct/promise_subclass.rs | 52 +++++++ .../object/class_registry/parent_static.rs | 18 ++- .../parent_static/private_and_dynamic.rs | 143 ++++++++++++++++-- .../class_registry/prototype_objects.rs | 14 +- .../perry-runtime/src/object/descriptors.rs | 44 +++++- .../perry-runtime/src/object/field_get_set.rs | 4 +- .../field_get_set/class_object_props.rs | 48 +++++- .../src/object/field_get_set/enumeration.rs | 4 +- .../object/field_get_set/get_field_by_name.rs | 15 +- .../ic_miss/private_member_access.rs | 24 ++- .../src/object/field_set_by_name.rs | 11 +- .../object/field_set_by_name/fast_paths.rs | 6 + .../src/object/field_set_by_name/tail.rs | 25 +++ .../src/object/global_this/fetch_globals.rs | 15 +- crates/perry-runtime/src/object/mod.rs | 1 + .../native_call_method/string_methods.rs | 2 +- .../perry-runtime/src/object/native_module.rs | 20 +-- .../native_module/class_method_values.rs | 40 ++++- .../object/native_module/class_ref_values.rs | 2 +- .../src/object/object_ops/define_property.rs | 51 ++++--- .../perry-runtime/src/object/property_key.rs | 32 ++-- .../src/object/prototype_chain.rs | 6 +- crates/perry-runtime/src/promise/subclass.rs | 45 ++++-- crates/perry-runtime/src/proxy.rs | 6 +- crates/perry-runtime/src/proxy/put_value.rs | 3 + crates/perry-runtime/src/weakref/subclass.rs | 8 +- ...ue_5579_indirect_eval_global_completion.rs | 21 +++ scripts/addr_class_ratchet_baseline.txt | 37 ++--- scripts/gc_runtime_root_holders.json | 15 ++ scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 4 +- ...test_issue_5893_private_brand_freshness.ts | 126 +++++++++++++++ 69 files changed, 1546 insertions(+), 467 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_registry/construct/class_object.rs create mode 100644 crates/perry-runtime/src/object/class_registry/construct/promise_subclass.rs diff --git a/Cargo.lock b/Cargo.lock index 56cb11a139..fe47c4c86e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6291,6 +6291,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_parser 32.0.0", + "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/Cargo.toml b/Cargo.toml index 2623e8a948..d477a7be10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -343,6 +343,7 @@ not_unsafe_ptr_arg_deref = "allow" # SWC for TypeScript parsing swc_ecma_parser = "32.0" swc_ecma_ast = "19.0" +swc_ecma_visit = "19.0" swc_common = "18.0" swc_ecma_codegen = "21.0" swc_ecma_transforms_base = "32.0" diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index bc7c28e872..2362fc8d37 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1552,6 +1552,22 @@ fn collect_inline_invoked_static_blocks( { out.insert((class_name.clone(), method_name.clone())); } + // `ClassExprFresh` invokes its static blocks directly from the + // per-evaluation source-order plan. Treat those calls as inline too; + // otherwise the module-init fallback below invokes every block once + // more with no fresh class object armed as `this`. + if let Expr::ClassExprFresh { + template, + static_init_order, + .. + } = e + { + for step in static_init_order { + if let perry_hir::ClassFreshStaticInit::Block(index) = step { + out.insert((template.clone(), format!("__perry_static_init_{index}"))); + } + } + } if let Expr::Closure { body, .. } = e { for s in body { walk_stmt(s, out); diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index f970af6868..aaf1046a02 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -1033,7 +1033,16 @@ pub(super) fn compile_method( // .pathname` threw. Forward this synthesized ctor's params to the // runtime dynamic-parent super dispatcher, mirroring the explicit // `Expr::SuperCall` dynamic-parent path in `expr/this_super_call.rs`. - if builtin_parent_runtime.is_none() && class.extends_expr.is_some() { + let parent_is_uncallable_builtin = class + .extends_name + .as_deref() + .map(crate::expr::is_other_builtin_constructor_name) + .unwrap_or(false) + && class.extends_name.as_deref() != Some("SharedArrayBuffer"); + if builtin_parent_runtime.is_none() + && class.extends_expr.is_some() + && !parent_is_uncallable_builtin + { if let Some(cid) = ctx.class_ids.get(&class.name).copied().filter(|c| *c != 0) { let undef_lit = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index ec4f748b11..1b598c2ac3 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -41,6 +41,10 @@ fn static_block_fns(ctx: &FnCtx<'_>, template: &str) -> Vec { .unwrap_or_default() } +fn private_static_storage_name(class_id: u32, field_name: &str) -> String { + format!("#") +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::StaticFieldGet { @@ -76,7 +80,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Refs #420 / #618 followup. if let Some(&class_id) = ctx.class_ids.get(class_name) { let runtime_field_name = if field_name.starts_with('#') { - format!("#") + private_static_storage_name(class_id, field_name) } else { field_name.clone() }; @@ -431,6 +435,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { named_statics, computed_keys, computed_statics, + static_init_order, captured_args, } => { let template_cid = ctx.class_ids.get(template).copied().unwrap_or(0); @@ -535,26 +540,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &obj), (I64, &storage_raw), (DOUBLE, &key_value)], ); } - for (name, init) in named_statics { - let storage_name = if name.starts_with('#') { - format!("#") - } else { - name.clone() - }; - let key_idx = ctx.strings.intern(&storage_name); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - let v = lower_expr(ctx, init)?; - let obj = group.reread_emitted(ctx, rooted); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, crate::nanbox::POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &obj), (I64, &key_raw), (DOUBLE, &v)], - ); - } // #1787: snapshot the captured outer-scope values onto the class // object as the `__perry_ctor_caps` own array (in the constructor's // capture-param order). `new ()` reads it back @@ -624,52 +609,72 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &obj), (I64, &key_raw), (DOUBLE, &caps_box)], ); } - for (key_slot, init) in computed_statics { - let value = lower_expr(ctx, init)?; - let key_idx = ctx.strings.intern(key_slot); - let entry = ctx.strings.entry(key_idx); - let key_bytes = format!("@{}", entry.bytes_global); - let key_len = entry.byte_len.to_string(); - let obj = group.reread_emitted(ctx, rooted); - let obj_box = nanbox_pointer_inline(ctx.block(), &obj); - let resolved_key = ctx.block().call( - DOUBLE, - "js_object_get_own_field_or_undef", - &[(DOUBLE, &obj_box), (PTR, &key_bytes), (I64, &key_len)], - ); - ctx.block().call( - DOUBLE, - "js_object_set_property_key", - &[ - (DOUBLE, &obj_box), - (DOUBLE, &resolved_key), - (DOUBLE, &value), - ], - ); - } - // #685: run the class's `static { … }` blocks NOW — at the class - // expression's evaluation, with `this` = THIS fresh class object. - // The `ClassExprFresh` fast path previously never invoked them - // (they are also skipped by the module-init fallback when another - // evaluation site invokes them inline), so `return class { static - // { this.viaBlock = tag } }` factories produced objects whose - // blocks simply never ran. Arm the one-shot static-`this` - // override before each call so the compiled body's - // `js_static_this_resolve` prologue binds `this` to the fresh - // object (writes land as own properties of this evaluation's - // object, not the shared template). Blocks run after the named - // static fields above — the source interleaving of fields and - // blocks is not reproduced on this path (pre-existing limitation). - // - // `block_fns` is computed above, next to `protect_handle`. - for fn_name in block_fns { - // #7154: a static block runs arbitrary user code, so re-derive - // the receiver from the root before each one. - let obj = group.reread_emitted(ctx, rooted); - let obj_box = nanbox_pointer_inline(ctx.block(), &obj); - ctx.block() - .call_void("js_static_this_arm_value", &[(DOUBLE, &obj_box)]); - ctx.block().call(DOUBLE, &fn_name, &[]); + // Static fields and blocks execute only after every computed + // name has been resolved, then in their original ClassBody + // order. Each vector index is recorded by HIR lowering. + for step in static_init_order { + match step { + perry_hir::ClassFreshStaticInit::Named(index) => { + let Some((name, init)) = named_statics.get(*index as usize) else { + continue; + }; + let storage_name = if name.starts_with('#') { + private_static_storage_name(template_cid, name) + } else { + name.clone() + }; + let key_idx = ctx.strings.intern(&storage_name); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let value = lower_expr(ctx, init)?; + let obj = group.reread_emitted(ctx, rooted); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, crate::nanbox::POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &obj), (I64, &key_raw), (DOUBLE, &value)], + ); + } + perry_hir::ClassFreshStaticInit::Computed(index) => { + let Some((key_slot, init)) = computed_statics.get(*index as usize) + else { + continue; + }; + let value = lower_expr(ctx, init)?; + let key_idx = ctx.strings.intern(key_slot); + let entry = ctx.strings.entry(key_idx); + let key_bytes = format!("@{}", entry.bytes_global); + let key_len = entry.byte_len.to_string(); + let obj = group.reread_emitted(ctx, rooted); + let obj_box = nanbox_pointer_inline(ctx.block(), &obj); + let resolved_key = ctx.block().call( + DOUBLE, + "js_object_get_own_field_or_undef", + &[(DOUBLE, &obj_box), (PTR, &key_bytes), (I64, &key_len)], + ); + ctx.block().call( + DOUBLE, + "js_object_set_property_key", + &[ + (DOUBLE, &obj_box), + (DOUBLE, &resolved_key), + (DOUBLE, &value), + ], + ); + } + perry_hir::ClassFreshStaticInit::Block(index) => { + let Some(fn_name) = block_fns.get(*index as usize) else { + continue; + }; + let obj = group.reread_emitted(ctx, rooted); + let obj_box = nanbox_pointer_inline(ctx.block(), &obj); + ctx.block() + .call_void("js_static_this_arm_value", &[(DOUBLE, &obj_box)]); + ctx.block().call(DOUBLE, fn_name, &[]); + } + } } let obj = group.reread_emitted(ctx, rooted); let obj_box = nanbox_pointer_inline(ctx.block(), &obj); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index d2bda80476..f5131b78f0 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -289,6 +289,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &first), ], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -317,6 +318,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_url_search_params_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &first)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -349,6 +351,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_dom_exception_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &message), (DOUBLE, &name)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -365,6 +368,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I32, &cid_str), (DOUBLE, &this_box), (DOUBLE, &arr_box)], ); } + bind_derived_this_after_super(ctx); // Spec: subclass field initializers run AFTER super() returns // (mirrors every other super arm). crate::lower_call::apply_field_initializers_recursive( @@ -459,6 +463,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_url_search_params_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &init)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -674,6 +679,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = node_stream_kind { let result = lower_node_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -690,6 +696,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // would otherwise leave it length-less with no Array methods. if parent_name == "Array" { let result = lower_array_super_init(ctx, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -713,6 +720,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = stream_kind { let result = lower_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); // Per JS spec field initializers run AFTER super() // returns. Without this, `this.foo = []` declared // on the subclass never executes — instance reads @@ -737,6 +745,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = node_stream_kind { let result = lower_node_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -783,6 +792,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &iterable), ], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -806,6 +816,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; lower_event_emitter_subclass_init(ctx, &this_box); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -861,6 +872,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I32, &is_custom), ], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -892,6 +904,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_dom_exception_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -924,6 +937,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_promise_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &executor)], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -955,6 +969,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { runtime_fn, &[(DOUBLE, &this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], ); + bind_derived_this_after_super(ctx); // Per JS spec, subclass field initializers run after // super() returns (mirrors the stream/error arms above). let current_class_name = @@ -1128,6 +1143,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } } + bind_derived_this_after_super(ctx); return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } }; @@ -1162,6 +1178,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &this_box, &lowered_args, ); + bind_derived_this_after_super(ctx); // Spec: derived-class field initializers run AFTER `super()` // returns. The native base is the chain root and has no TS // fields, so everything after it still needs initializing — @@ -1325,9 +1342,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let lower_result = crate::stmt::lower_stmts(ctx, &parent_ctor.body); ctx.try_depth = caller_try_depth; lower_result?; - if parent_is_derived { - pop_shared_super_called_slot(ctx); - } ctx.class_stack.pop(); let parent_return = ctx .inline_ctor_return @@ -1337,6 +1351,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.block().br(&parent_after_label); } ctx.current_block = parent_after_idx; + if parent_is_derived { + pop_shared_super_called_slot(ctx); + } let parent_raw = ctx.block().load(DOUBLE, &parent_return.result_slot); if let Some(this_slot) = ctx.this_stack.last().cloned() { let inherited_this = ctx.block().load(DOUBLE, &this_slot); diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 6c38c10f07..4576bcf463 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1360,6 +1360,7 @@ fn lower_new_impl_inner<'a>( | "BigUint64Array" ) }) { + lowered_args = refresh_rooted_args(ctx, group)?; let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); let class_id = ctx .class_ids diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 187b6fd6f0..cf928819eb 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -818,12 +818,10 @@ fn typed_feedback_guards_direct_class_method_specialization() { assert!(ir.contains("js_typed_feedback_method_direct_call_guard")); assert!(ir.contains("method_direct.fast")); assert!(ir.contains("method_direct.fallback")); - // #5334 lever A: this class has a field `x` whose synthesized field-set - // routes its guard-miss arm through the outlined fallback. (The - // method-direct fallback only records when its site_id is Some, which it - // isn't here — the old `record_fallback_call` assertion was incidentally - // satisfied by the field-set fallback that is now folded into this call.) - assert!(ir.contains("call void @js_class_field_set_fallback")); + // Class field initialization follows DefineField semantics, so the + // synthesized initializer uses the class-field add helper rather than the + // ordinary property-set fallback. + assert!(ir.contains("call double @js_class_field_add")); assert!(ir.contains("call double @js_native_call_method")); } diff --git a/crates/perry-hir/src/analysis/value_types_tests.rs b/crates/perry-hir/src/analysis/value_types_tests.rs index b5cc4a9551..98222004df 100644 --- a/crates/perry-hir/src/analysis/value_types_tests.rs +++ b/crates/perry-hir/src/analysis/value_types_tests.rs @@ -1181,6 +1181,7 @@ fn infers_class_prototype_and_super_meta_value_shapes() { named_statics: Vec::new(), computed_keys: Vec::new(), computed_statics: Vec::new(), + static_init_order: Vec::new(), captured_args: Vec::new(), }, &env, diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index b684ed6be5..4683d1b220 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -331,6 +331,9 @@ pub struct ClassComputedMember { pub function: Function, pub is_static: bool, pub kind: ClassComputedMemberKind, + /// Zero-based position in the source ClassBody. Computed field and member + /// names share this ordering during ClassDefinitionEvaluation. + pub source_order: usize, } /// A class field diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 4174de29cb..3a72c08a1c 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -17,6 +17,15 @@ pub enum WithSetFallback { SloppyImplicit(LocalId), } +/// One source-ordered static initialization step on a per-evaluation class +/// object. Computed names have already been evaluated before these steps run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ClassFreshStaticInit { + Named(u32), + Computed(u32), + Block(u32), +} + /// Expression #[derive(Debug, Clone)] pub enum Expr { @@ -554,6 +563,10 @@ pub enum Expr { computed_keys: Vec<(String, Expr)>, /// (hidden resolved-key slot name, initializer) computed_statics: Vec<(String, Expr)>, + /// Static fields and blocks in ClassBody source order. Indices address + /// `named_statics`, `computed_statics`, or the template's static-block + /// function list respectively. + static_init_order: Vec, /// #1787: the captured outer-scope values this class expression /// closes over, in the synthesized constructor's capture-param /// order (see `synthesize_class_captures`). Each entry is a diff --git a/crates/perry-hir/src/ir/mod.rs b/crates/perry-hir/src/ir/mod.rs index a51ab856c9..cdc83cca55 100644 --- a/crates/perry-hir/src/ir/mod.rs +++ b/crates/perry-hir/src/ir/mod.rs @@ -60,7 +60,8 @@ pub use stmt::{CatchClause, Stmt, SwitchCase}; // ---- expr.rs ---- pub use expr::{ - BoxedPrimitiveKind, Expr, PathWin32Method, ProcessStdinLifecycleMethod, WithSetFallback, + BoxedPrimitiveKind, ClassFreshStaticInit, Expr, PathWin32Method, ProcessStdinLifecycleMethod, + WithSetFallback, }; // ---- ops.rs ---- diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index a66d010223..7fe12a937d 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -457,7 +457,25 @@ pub(crate) fn lower_ident_assignment( throw_type_error_const_assignment(&name), ])); } - Ok(Expr::LocalSet(id, value)) + let local_set = Expr::LocalSet(id, value); + let mirrors_script_var = super::lower_expr::global_script_this_enabled() + && ctx.script_var_decl_names.contains(&name) + && ctx.local_decl_scope_depth(&name) == Some(0); + if mirrors_script_var { + let global_this = Box::new(Expr::GlobalThisExpr); + Ok(Expr::Sequence(vec![ + local_set, + Expr::PutValueSet { + target: global_this.clone(), + key: Box::new(Expr::String(name)), + value: Box::new(Expr::LocalGet(id)), + receiver: global_this, + strict: ctx.current_strict, + }, + ])) + } else { + Ok(local_set) + } } else if ctx.lookup_class(&name).is_some() || ctx.forward_class_shadows_local(&name) { let class_name = ctx.resolve_class_name(&name); Ok(Expr::Call { diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index 6276c2c6e0..f0a176f850 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -106,8 +106,12 @@ pub(super) fn lower_super_prop( ast::Expr::Lit(ast::Lit::Num(n)) if n.value.is_finite() && n.value.fract() == 0.0 - && n.value >= i64::MIN as f64 - && n.value <= i64::MAX as f64 => + // Outside the safe-integer range, formatting an exact + // f64 integer through i64 is not ECMAScript Number:: + // toString (for example 2^63 becomes the property key + // "9223372036854776000"). Let runtime ToPropertyKey + // perform the shortest-decimal conversion instead. + && n.value.abs() <= 9_007_199_254_740_991.0 => { Some(if n.value == 0.0 { "0".to_string() diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index 8f8057e4a1..cb75722c01 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -442,6 +442,13 @@ fn indirect_eval_factory_shape(expr: &ast::Expr) -> Option<(String, bool)> { let ast::Expr::Fn(function) = expr else { return None; }; + // The direct-eval rewrite below executes the wrapper body immediately and + // returns the evaluated value. That is equivalent only for an ordinary + // synchronous function: async wrappers must return a Promise, while a + // generator body must not run until the iterator is advanced. + if function.function.is_async || function.function.is_generator { + return None; + } if function.function.params.len() != 1 { return None; } @@ -1370,3 +1377,29 @@ fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shado _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + fn first_var_initializer(source: &str) -> Box { + let module = perry_parser::parse_typescript(source, "factory-shape.js").unwrap(); + let ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(var))) = &module.body[0] else { + panic!("expected variable declaration"); + }; + var.decls[0].init.clone().expect("expected initializer") + } + + #[test] + fn indirect_eval_factory_rejects_async_wrapper() { + let init = + first_var_initializer("const factory = async function (ev) { return ev(src); };"); + assert!(indirect_eval_factory_shape(&init).is_none()); + } + + #[test] + fn indirect_eval_factory_rejects_generator_wrapper() { + let init = first_var_initializer("const factory = function* (ev) { return ev(src); };"); + assert!(indirect_eval_factory_shape(&init).is_none()); + } +} diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 5716b02d8e..066447ef2f 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -137,11 +137,12 @@ pub(crate) fn lower_class_expr( // canonical case: `isSchema(C)` was called from Schema.ts's // own top-level `class extends transform(...)` chains, which // run before the module's `init_static_fields_late`. - let computed_keys = crate::lower_decl::computed_field_key_initializers( - &class_expr.class.body, - &class.fields, - &class.static_fields, - ); + let (computed_name_evaluations, computed_keys, computed_member_registrations) = + crate::lower_decl::prepare_ordered_class_computed_names( + ctx, + &class_expr.class.body, + &class, + ); let computed_statics: Vec<(String, Expr)> = class .static_fields .iter() @@ -151,6 +152,10 @@ pub(crate) fn lower_class_expr( .map(|_| (sf.name.clone(), sf.init.clone().unwrap_or(Expr::Undefined))) }) .collect(); + let static_init_order = crate::lower_decl::fresh_class_static_init_order( + &class_expr.class.body, + &class.static_fields, + ); // Issue #1772: regular-named static fields with an initializer // (`static ast = ast`). #894 only handled the Symbol-key case; // these need the same per-evaluation treatment, otherwise a class @@ -159,16 +164,11 @@ pub(crate) fn lower_class_expr( let named_statics: Vec<(String, Expr)> = class .static_fields .iter() - .filter_map(|sf| match (sf.key_expr.as_ref(), sf.init.as_ref()) { - (None, Some(v)) => Some((sf.name.clone(), v.clone())), - _ => None, + .filter_map(|sf| match sf.key_expr.as_ref() { + None => Some((sf.name.clone(), sf.init.clone().unwrap_or(Expr::Undefined))), + Some(_) => None, }) .collect(); - let computed_member_registrations: Vec = class - .computed_members - .iter() - .map(|member| class_computed_member_registration_expr(&synthetic_name, member)) - .collect(); let captured_args: Vec = ctx .lookup_class_captures(&synthetic_name) .map(|ids| ids.iter().map(|id| Expr::LocalGet(*id)).collect()) @@ -291,6 +291,7 @@ pub(crate) fn lower_class_expr( named_statics, computed_keys, computed_statics, + static_init_order, captured_args, }; let mut seq: Vec = Vec::new(); @@ -300,6 +301,7 @@ pub(crate) fn lower_class_expr( parent_expr: p, }); } + seq.extend(computed_name_evaluations); seq.extend(computed_member_registrations); let fresh_expr = if let Some(owner) = capture_owner { Expr::Sequence(vec![ @@ -322,6 +324,7 @@ pub(crate) fn lower_class_expr( parent_expr: p, }); } + seq.extend(computed_name_evaluations); // #5437 (p-queue PQueue undefined-`.default` capture): a class EXPRESSION // that captures enclosing-scope locals AND reaches the shared-template // (`ClassRef`) path — i.e. one with heritage (`class extends t { … uses diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 07ff2a052e..cfaf674698 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1301,36 +1301,34 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class_name, - member, - ))); - } - // Inject static-field-init statements in source order - // (see non-export class arm below for rationale). - for sf in &class.static_fields { - if let Some(init) = &sf.init { - // Computed-key static fields (`static [sym] = v`) - // emit a runtime-register call instead of a - // string-keyed StaticFieldSet. Refs #420. - 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()), - })); - } else { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name: sf.name.clone(), - value: Box::new(init.clone()), - })); - } - } + let (computed_name_evaluations, computed_keys, computed_member_registrations) = + crate::lower_decl::prepare_ordered_class_computed_names( + ctx, + &class_decl.class.body, + &class, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); + for (field_name, value) in computed_keys { + module.init.push(Stmt::Expr(Expr::StaticFieldSet { + class_name: class_name.clone(), + field_name, + value: Box::new(value), + })); } + module + .init + .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); + module.init.extend( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( + &class_decl.class.body, + &class_name, + &class.fields, + &class.static_fields, + &class.static_methods, + ), + ); append_legacy_decorator_init_for_class(ctx, &mut module.init, &class); push_class_dedup(module, class); module.exports.push(Export::Named { @@ -1876,33 +1874,34 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class_name, - member, - ))); - } - // Inject static-field-init statements in source order - // (see non-export class arm for rationale). - for sf in &class.static_fields { - if let Some(init) = &sf.init { - 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()), - })); - } else { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name: sf.name.clone(), - value: Box::new(init.clone()), - })); - } - } + let (computed_name_evaluations, computed_keys, computed_member_registrations) = + crate::lower_decl::prepare_ordered_class_computed_names( + ctx, + &synth_class_decl.class.body, + &class, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); + for (field_name, value) in computed_keys { + module.init.push(Stmt::Expr(Expr::StaticFieldSet { + class_name: class_name.clone(), + field_name, + value: Box::new(value), + })); } + module + .init + .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); + module.init.extend( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( + &synth_class_decl.class.body, + &class_name, + &class.fields, + &class.static_fields, + &class.static_methods, + ), + ); append_legacy_decorator_init_for_class(ctx, &mut module.init, &class); push_class_dedup(module, class); // The `local != exported` shape lets the #485 alias loop diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index d8783e410f..4fc004ce2f 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -146,7 +146,36 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // ---- declaring bodies: rewrite with ONLY the ids detected in them ------- for (f, s) in module.functions.iter_mut().zip(fn_shared.iter()) { if !s.is_empty() { + // Parameters have no `Stmt::Let` for `rewrite_stmt` to wrap. Turn + // each flagged parameter into the same one-element shared cell at + // function entry, then let the already-rewritten body use + // `param[0]`. Add this after rewriting so the initializer's + // `LocalGet(param)` reads the incoming scalar rather than being + // rewritten into an index read before the cell exists. Retype the + // holder to `Any`: its slot now carries an array pointer, not the + // source parameter's scalar representation. + let shared_params: Vec = f + .params + .iter_mut() + .filter_map(|param| { + if s.contains(¶m.id) { + param.ty = Type::Any; + Some(param.id) + } else { + None + } + }) + .collect(); rewrite_stmts(&mut f.body, s, s); + for id in shared_params.into_iter().rev() { + f.body.insert( + 0, + Stmt::Expr(Expr::LocalSet( + id, + Box::new(Expr::Array(vec![Expr::LocalGet(id)])), + )), + ); + } } } if !init_shared.is_empty() { @@ -614,11 +643,27 @@ fn find_regs_stmt(stmt: &Stmt, out: &mut Vec<(String, Vec)>) { } fn find_regs_expr(expr: &Expr, out: &mut Vec<(String, Vec)>) { - if let Expr::RegisterClassCaptures { - class_name, - captures, - } = expr - { + let registration = match expr { + Expr::RegisterClassCaptures { + class_name, + captures, + } => Some((class_name, captures)), + // A fresh class expression carries the same capture vector as a + // declaration snapshot, but it deliberately has no + // `RegisterClassCaptures`: each evaluation stores its environment on + // its own heap class object. Treat that vector as a registration for + // shared-mutable detection too. Otherwise a mutation nested in a + // fresh class member (for example a defineProperty setter created by + // a static method) receives a private scalar copy while sibling + // methods keep reading the class object's stale capture value. + Expr::ClassExprFresh { + template, + captured_args, + .. + } => Some((template, captured_args)), + _ => None, + }; + if let Some((class_name, captures)) = registration { let ids: Vec = captures .iter() .filter_map(|c| match c { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 9f15873ebe..f44e80c8b4 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1232,14 +1232,25 @@ pub(crate) fn lower_stmt( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class.name, - member, - ))); + let (computed_name_evaluations, computed_keys, computed_member_registrations) = + crate::lower_decl::prepare_ordered_class_computed_names( + ctx, + &class_decl.class.body, + &class, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); + for (field_name, value) in computed_keys { + module.init.push(Stmt::Expr(Expr::StaticFieldSet { + class_name: class.name.clone(), + field_name, + value: Box::new(value), + })); } + module + .init + .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); // Inject static-field-init and static-block-call // statements at the source position of the class // declaration, INTERLEAVED in source order (see @@ -1259,7 +1270,7 @@ pub(crate) fn lower_stmt( // declaration path; it skips blocks already invoked via // this inline call. module.init.extend( - crate::lower_decl::build_interleaved_static_init_stmts( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( &class_decl.class.body, &class.name, &class.fields, diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index d4a1169071..c1d9e96418 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -13,9 +13,7 @@ use crate::lower::{ }; use crate::lower_patterns::*; -use super::class_computed::{ - class_computed_member_registration_expr, push_deduped_class_computed_keys, -}; +use super::class_computed::push_deduped_class_computed_keys; use super::helpers::{async_iterator_method_call, is_filehandle_readlines_for_await_target}; use super::*; @@ -285,12 +283,13 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result Some((field.name.clone(), value.clone())), - _ => None, + (None, init) => Some(( + field.name.clone(), + init.cloned().unwrap_or(Expr::Undefined), + )), + (Some(_), _) => None, }, ) .collect() @@ -370,6 +367,10 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result (Vec, Vec<(String, Expr)>, Vec) { + let mut ordered: Vec<(usize, Expr)> = Vec::new(); + let mut field_keys = Vec::new(); + for (source_order, name, value) in super::computed_field_key_initializers_with_order( + class_body, + &class.fields, + &class.static_fields, + ) { + let local = ctx.define_local( + format!("__perry_computed_field_name_{}_{}", class.id, source_order), + Type::Any, + ); + ordered.push((source_order, Expr::LocalSet(local, Box::new(value)))); + field_keys.push((name, Expr::LocalGet(local))); + } + + let mut member_registrations = Vec::new(); + for member in &class.computed_members { + let local = ctx.define_local( + format!( + "__perry_computed_member_name_{}_{}", + class.id, member.source_order + ), + Type::Any, + ); + let to_property_key = Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "js_to_property_key".to_string(), + param_types: vec![Type::Any], + return_type: Type::Any, + }), + args: vec![member.key_expr.clone()], + type_args: Vec::new(), + byte_offset: 0, + }; + ordered.push(( + member.source_order, + Expr::LocalSet(local, Box::new(to_property_key)), + )); + let mut resolved = member.clone(); + resolved.key_expr = Expr::LocalGet(local); + member_registrations.push(class_computed_member_registration_expr( + &class.name, + &resolved, + )); + } + ordered.sort_by_key(|(source_order, _)| *source_order); + ( + ordered.into_iter().map(|(_, expr)| expr).collect(), + field_keys, + member_registrations, + ) +} + +/// Reconstruct the source order of static fields and static blocks for the +/// `ClassExprFresh` codegen path. Computed-name evaluation remains a separate, +/// earlier phase as required by ClassDefinitionEvaluation. +pub(crate) fn fresh_class_static_init_order( + class_body: &[ast::ClassMember], + static_fields: &[ClassField], +) -> Vec { + let mut result = Vec::new(); + let mut static_field_index = 0usize; + let mut named_index = 0u32; + let mut computed_index = 0u32; + let mut block_index = 0u32; + for member in class_body { + match member { + ast::ClassMember::ClassProp(prop) + if prop.is_static && !prop.declare && !prop.is_abstract => + { + if let Some(field) = static_fields.get(static_field_index) { + if field.key_expr.is_some() { + result.push(ClassFreshStaticInit::Computed(computed_index)); + computed_index += 1; + } else { + result.push(ClassFreshStaticInit::Named(named_index)); + named_index += 1; + } + } + static_field_index += 1; + } + ast::ClassMember::PrivateProp(prop) if prop.is_static => { + result.push(ClassFreshStaticInit::Named(named_index)); + named_index += 1; + static_field_index += 1; + } + ast::ClassMember::StaticBlock(_) => { + result.push(ClassFreshStaticInit::Block(block_index)); + block_index += 1; + } + _ => {} + } + } + result +} + /// A class declared inside a function body is name-deduped against an earlier /// same-named class (Perry's codegen is name-keyed; #336). But ECMA-262 /// ClassDefinitionEvaluation still evaluates every `class` expression's diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 13d59a1863..9ba1218b97 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -132,6 +132,7 @@ fn lower_generic_computed_class_member( ctx: &mut LoweringContext, method: &ast::ClassMethod, computed: &ast::ComputedPropName, + source_order: usize, ) -> Result { let key_expr = lower_expr(ctx, &computed.expr)?; let function_name = computed_member_name(method.kind, computed); @@ -160,6 +161,7 @@ fn lower_generic_computed_class_member( function, is_static: method.is_static, kind, + source_order, }) } @@ -179,6 +181,7 @@ fn lower_noncomputed_class_member_registration( ctx: &mut LoweringContext, method: &ast::ClassMethod, prop_name: &str, + source_order: usize, ) -> Result { let function_name = noncomputed_member_registration_name(method.kind, method); let (kind, function) = match method.kind { @@ -206,6 +209,7 @@ fn lower_noncomputed_class_member_registration( function, is_static: method.is_static, kind, + source_order, }) } @@ -753,7 +757,7 @@ pub fn lower_class_decl( let mut seen_generic_computed_member = false; // Second pass: actually lower the class members - for member in &class_decl.class.body { + for (member_index, member) in class_decl.class.body.iter().enumerate() { match member { ast::ClassMember::Constructor(ctor) => { constructor = Some(lower_constructor(ctx, &name, ctor)?); @@ -764,8 +768,12 @@ pub fn lower_class_decl( continue; } if let Some(computed) = generic_computed_member_key(ctx, method) { - computed_members - .push(lower_generic_computed_class_member(ctx, method, computed)?); + computed_members.push(lower_generic_computed_class_member( + ctx, + method, + computed, + member_index, + )?); seen_generic_computed_member = true; continue; } @@ -828,7 +836,10 @@ pub fn lower_class_decl( })?; if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { @@ -844,7 +855,10 @@ pub fn lower_class_decl( })?; if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { @@ -878,7 +892,10 @@ pub fn lower_class_decl( } if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { @@ -1361,9 +1378,9 @@ pub fn lower_class_from_ast( let old_inner_name = ctx.current_class_inner_name.take(); // A class-expression caller stashes the source ident here; fall back // to the (possibly synthetic) registration name when absent. - ctx.current_class_inner_name = ctx - .pending_class_inner_name - .take() + let explicit_inner_name = ctx.pending_class_inner_name.take(); + ctx.current_class_inner_name = explicit_inner_name + .clone() .or_else(|| Some(name.to_string())); let old_is_derived = ctx.current_class_is_derived; ctx.current_class_is_derived = class.super_class.is_some(); @@ -1402,8 +1419,7 @@ pub fn lower_class_from_ast( let (extends, extends_name, native_extends, extends_expr) = if let Some(ref super_class) = class.super_class { - if ctx - .current_class_inner_name + if explicit_inner_name .as_deref() .is_some_and(|inner| is_class_self_heritage(super_class, inner)) { @@ -1617,7 +1633,7 @@ pub fn lower_class_from_ast( let mut computed_members = Vec::new(); let mut seen_generic_computed_member = false; - for member in &class.body { + for (member_index, member) in class.body.iter().enumerate() { match member { ast::ClassMember::Constructor(ctor) => { constructor = Some(lower_constructor(ctx, name, ctor)?); @@ -1628,8 +1644,12 @@ pub fn lower_class_from_ast( continue; } if let Some(computed) = generic_computed_member_key(ctx, method) { - computed_members - .push(lower_generic_computed_class_member(ctx, method, computed)?); + computed_members.push(lower_generic_computed_class_member( + ctx, + method, + computed, + member_index, + )?); seen_generic_computed_member = true; continue; } @@ -1684,7 +1704,10 @@ pub fn lower_class_from_ast( })?; if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { @@ -1699,7 +1722,10 @@ pub fn lower_class_from_ast( })?; if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { @@ -1722,7 +1748,10 @@ pub fn lower_class_from_ast( } if seen_generic_computed_member && can_source_order_register { computed_members.push(lower_noncomputed_class_member_registration( - ctx, method, &prop_name, + ctx, + method, + &prop_name, + member_index, )?); } if method.is_static { diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index 4ccc737e2b..3b4ad692c1 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -38,7 +38,10 @@ pub(crate) use block::{ pub(crate) use body_stmt::gen_capture_scan::forward_referenced_nested_generators; pub(crate) use body_stmt::{find_native_return_in_stmts, lower_body_stmt}; pub(crate) use class_captures::{append_new_args_stmt, synthesize_class_captures}; -pub(crate) use class_computed::class_computed_member_registration_expr; +pub(crate) use class_computed::fresh_class_static_init_order; +pub(crate) use class_computed::{ + class_computed_member_registration_expr, prepare_ordered_class_computed_names, +}; pub(crate) use class_decl::{lower_class_decl, lower_class_from_ast}; pub(crate) use class_members::{ lower_class_method, lower_class_method_with_name, lower_class_prop, lower_constructor, @@ -63,6 +66,7 @@ pub(crate) use private_members::{ lower_private_setter, }; pub(crate) use static_init::{ - build_interleaved_static_init_stmts, computed_field_key_initializers, + build_interleaved_static_init_stmts, build_interleaved_static_init_stmts_after_computed_names, + computed_field_key_initializers_with_order, }; pub(crate) use type_alias::lower_type_alias_decl; diff --git a/crates/perry-hir/src/lower_decl/static_init.rs b/crates/perry-hir/src/lower_decl/static_init.rs index f8926850a6..5bac232287 100644 --- a/crates/perry-hir/src/lower_decl/static_init.rs +++ b/crates/perry-hir/src/lower_decl/static_init.rs @@ -26,10 +26,23 @@ pub(crate) fn computed_field_key_initializers( fields: &[ClassField], static_fields: &[ClassField], ) -> Vec<(String, Expr)> { + computed_field_key_initializers_with_order(class_body, fields, static_fields) + .into_iter() + .map(|(_, name, value)| (name, value)) + .collect() +} + +/// [`computed_field_key_initializers`] plus each element's absolute ClassBody +/// position, used to merge field names with computed methods/accessors. +pub(crate) fn computed_field_key_initializers_with_order( + class_body: &[ast::ClassMember], + fields: &[ClassField], + static_fields: &[ClassField], +) -> Vec<(usize, String, Expr)> { let mut result = Vec::new(); let mut field_idx = 0usize; let mut static_field_idx = 0usize; - for member in class_body { + for (source_order, member) in class_body.iter().enumerate() { match member { ast::ClassMember::ClassProp(prop) if !prop.declare && !prop.is_abstract => { let field = if prop.is_static { @@ -43,7 +56,11 @@ pub(crate) fn computed_field_key_initializers( }; if let Some(field) = field { if let Some(key) = field.key_expr.as_ref() { - result.push((field.name.clone(), to_property_key(key.clone()))); + result.push(( + source_order, + field.name.clone(), + to_property_key(key.clone()), + )); } } } @@ -87,6 +104,43 @@ pub(crate) fn build_interleaved_static_init_stmts( fields: &[ClassField], static_fields: &[ClassField], static_methods: &[Function], +) -> Vec { + build_interleaved_static_init_stmts_impl( + class_body, + class_name, + fields, + static_fields, + static_methods, + true, + ) +} + +/// Static initialization after a caller has already evaluated and stored all +/// computed names in source order. +pub(crate) fn build_interleaved_static_init_stmts_after_computed_names( + class_body: &[ast::ClassMember], + class_name: &str, + fields: &[ClassField], + static_fields: &[ClassField], + static_methods: &[Function], +) -> Vec { + build_interleaved_static_init_stmts_impl( + class_body, + class_name, + fields, + static_fields, + static_methods, + false, + ) +} + +fn build_interleaved_static_init_stmts_impl( + class_body: &[ast::ClassMember], + class_name: &str, + fields: &[ClassField], + static_fields: &[ClassField], + static_methods: &[Function], + emit_computed_names: bool, ) -> Vec { let emit_field = |out: &mut Vec, sf: &ClassField| { // A COMPUTED-key static field with no initializer still performs @@ -128,12 +182,16 @@ pub(crate) fn build_interleaved_static_init_stmts( // source order. Keep the resolved keys on hidden static slots so static // initialization and each later instance construction reuse the same key. let mut out = Vec::new(); - for (field_name, value) in computed_field_key_initializers(class_body, fields, static_fields) { - out.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.to_string(), - field_name, - value: Box::new(value), - })); + if emit_computed_names { + for (field_name, value) in + computed_field_key_initializers(class_body, fields, static_fields) + { + out.push(Stmt::Expr(Expr::StaticFieldSet { + class_name: class_name.to_string(), + field_name, + value: Box::new(value), + })); + } } // Static fields and blocks initialize only after all computed keys above @@ -142,7 +200,9 @@ pub(crate) fn build_interleaved_static_init_stmts( let mut block_idx = 0usize; for member in class_body { match member { - ast::ClassMember::ClassProp(prop) if !prop.declare && prop.is_static => { + ast::ClassMember::ClassProp(prop) + if !prop.declare && !prop.is_abstract && prop.is_static => + { if let Some(sf) = static_fields.get(field_idx) { emit_field(&mut out, sf); } diff --git a/crates/perry-hir/src/monomorph/specialize.rs b/crates/perry-hir/src/monomorph/specialize.rs index 6b544ac394..ec883fab52 100644 --- a/crates/perry-hir/src/monomorph/specialize.rs +++ b/crates/perry-hir/src/monomorph/specialize.rs @@ -227,6 +227,7 @@ pub fn specialize_class(class: &Class, type_args: &[Type], new_id: ClassId) -> C .iter() .map(|member| ClassComputedMember { key_expr: substitute_expr(&member.key_expr, &substitutions), + source_order: member.source_order, function: Function { id: member.function.id, name: member.function.name.clone(), diff --git a/crates/perry-hir/src/stable_hash/decls.rs b/crates/perry-hir/src/stable_hash/decls.rs index af59b6ecc3..56b89cad25 100644 --- a/crates/perry-hir/src/stable_hash/decls.rs +++ b/crates/perry-hir/src/stable_hash/decls.rs @@ -81,6 +81,7 @@ impl SH for ClassComputedMember { self.function.hash(h); self.is_static.hash(h); self.kind.hash(h); + self.source_order.hash(h); } } diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index 2d47348ace..680cd7baad 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -649,7 +649,7 @@ impl SH for Expr { Expr::RegisterClassStaticSymbol { class_name, key_expr, value_expr, } => { tag(h, 12025); class_name.hash(h); key_expr.as_ref().hash(h); value_expr.as_ref().hash(h); } Expr::RegisterClassComputedMethod { class_name, key_expr, method_name, is_static, param_count, has_rest } => { tag(h, 12233); class_name.hash(h); key_expr.as_ref().hash(h); method_name.hash(h); is_static.hash(h); param_count.hash(h); has_rest.hash(h); } Expr::RegisterClassComputedAccessor { class_name, key_expr, getter_name, setter_name, is_static } => { tag(h, 12234); class_name.hash(h); key_expr.as_ref().hash(h); getter_name.hash(h); setter_name.hash(h); is_static.hash(h); } - Expr::ClassExprFresh { template, named_statics, computed_keys, computed_statics, captured_args, } => { tag(h, 12026); template.hash(h); for (n, v) in named_statics { n.hash(h); v.hash(h); } for (n, k) in computed_keys { n.hash(h); k.hash(h); } for (n, v) in computed_statics { n.hash(h); v.hash(h); } for a in captured_args { a.hash(h); } } + Expr::ClassExprFresh { template, named_statics, computed_keys, computed_statics, static_init_order, captured_args, } => { tag(h, 12026); template.hash(h); for (n, v) in named_statics { n.hash(h); v.hash(h); } for (n, k) in computed_keys { n.hash(h); k.hash(h); } for (n, v) in computed_statics { n.hash(h); v.hash(h); } for step in static_init_order { match step { ClassFreshStaticInit::Named(index) => { tag(h, 0); index.hash(h); }, ClassFreshStaticInit::Computed(index) => { tag(h, 1); index.hash(h); }, ClassFreshStaticInit::Block(index) => { tag(h, 2); index.hash(h); }, } } for a in captured_args { a.hash(h); } } Expr::SetFunctionPrototype { func, proto } => { tag(h, 448); func.as_ref().hash(h); proto.as_ref().hash(h); } Expr::RegisterPrototypeMethod { class_name, method_name, value, } => { tag(h, 463); class_name.hash(h); method_name.hash(h); value.as_ref().hash(h); } Expr::RegisterFunctionPrototypeMethod { func, method_name, value, } => { tag(h, 464); func.as_ref().hash(h); method_name.hash(h); value.as_ref().hash(h); } diff --git a/crates/perry-parser/Cargo.toml b/crates/perry-parser/Cargo.toml index 1ac263a749..b11bbab19c 100644 --- a/crates/perry-parser/Cargo.toml +++ b/crates/perry-parser/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] swc_ecma_parser.workspace = true swc_ecma_ast.workspace = true +swc_ecma_visit.workspace = true swc_common.workspace = true thiserror.workspace = true diff --git a/crates/perry-parser/src/lib.rs b/crates/perry-parser/src/lib.rs index 1d417932f1..b9fb8ccc60 100644 --- a/crates/perry-parser/src/lib.rs +++ b/crates/perry-parser/src/lib.rs @@ -9,6 +9,7 @@ use std::path::Path; use swc_common::{input::StringInput, sync::Lrc, FileName, SourceMap}; use swc_ecma_ast::{Module, ModuleItem, Script}; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, Syntax, TsSyntax}; +use swc_ecma_visit::{VisitMut, VisitMutWith}; // Re-export AST types for consumers that need to inspect the AST pub use swc_ecma_ast; @@ -46,7 +47,9 @@ pub fn parse_typescript_with_cache( filename: &str, cache: &mut SourceCache, ) -> Result { - let parse_source = normalize_swc_class_syntax(&normalize_unicode_identifier_escapes(source)); + let unicode_source = normalize_unicode_identifier_escapes(source); + let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let parse_source = normalized.source; // Add the source to the cache let file_id = cache.add_file(filename, source.to_string()); @@ -58,7 +61,7 @@ pub fn parse_typescript_with_cache( ); let mut diagnostics = Diagnostics::new(); - let (module, mut parser) = + let (mut module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &parse_source).map_err( |e| { // Convert SWC error to our diagnostic @@ -71,6 +74,11 @@ pub fn parse_typescript_with_cache( anyhow::anyhow!("Parse error: {}", e.kind().msg()) }, )?; + restore_await_class_identifiers( + &mut module, + source_file.start_pos.0, + &normalized.await_name_starts, + ); // Collect recoverable errors as warnings for error in parser.take_errors() { @@ -97,16 +105,23 @@ pub fn parse_typescript_with_cache( /// This is the original parsing function for backward compatibility. /// For new code, prefer `parse_typescript_with_cache` for better diagnostics. pub fn parse_typescript(source: &str, filename: &str) -> Result { - let parse_source = normalize_swc_class_syntax(&normalize_unicode_identifier_escapes(source)); + let unicode_source = normalize_unicode_identifier_escapes(source); + let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let parse_source = normalized.source; let source_map: Lrc = Default::default(); let source_file = source_map.new_source_file( Lrc::new(FileName::Custom(filename.to_string())), parse_source, ); - let (module, mut parser) = + let (mut module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &source_file.src) .map_err(|e| anyhow::anyhow!("Parse error: {:?}", e))?; + restore_await_class_identifiers( + &mut module, + source_file.start_pos.0, + &normalized.await_name_starts, + ); // Check for recoverable errors for error in parser.take_errors() { @@ -842,7 +857,15 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { /// Normalize two valid class grammar corners that SWC currently rejects. /// String/comment contents are masked before tokenization, so source text that /// merely mentions these spellings is never rewritten. -fn normalize_swc_class_syntax(source: &str) -> String { +struct NormalizedClassSyntax { + source: String, + /// Byte offsets in the normalized source where SWC sees the synthetic + /// `_wait` class identifier. The AST is restored to the source spelling + /// after parsing so `.name` and the class-body inner binding stay correct. + await_name_starts: Vec, +} + +fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSyntax { #[derive(Clone, Copy)] struct Token<'a> { start: usize, @@ -939,11 +962,59 @@ fn normalize_swc_class_syntax(source: &str) -> String { } } + let await_name_starts = replacements + .iter() + .filter(|(_, _, replacement)| *replacement == "_wait") + .map(|(start, _, _)| { + let shift: isize = replacements + .iter() + .filter(|(prior_start, _, _)| prior_start < start) + .map(|(prior_start, prior_end, replacement)| { + replacement.len() as isize - (*prior_end - *prior_start) as isize + }) + .sum(); + (*start as isize + shift) as usize + }) + .collect(); let mut result = source.to_string(); for (start, end, replacement) in replacements.into_iter().rev() { result.replace_range(start..end, replacement); } - result + NormalizedClassSyntax { + source: result, + await_name_starts, + } +} + +#[cfg(test)] +fn normalize_swc_class_syntax(source: &str) -> String { + normalize_swc_class_syntax_with_metadata(source).source +} + +fn restore_await_class_identifiers( + module: &mut Module, + file_start: u32, + await_name_starts: &[usize], +) { + if await_name_starts.is_empty() { + return; + } + struct RestoreAwaitNames<'a> { + file_start: u32, + starts: &'a [usize], + } + impl VisitMut for RestoreAwaitNames<'_> { + fn visit_mut_ident(&mut self, ident: &mut swc_ecma_ast::Ident) { + let local_start = ident.span.lo.0.saturating_sub(self.file_start) as usize; + if ident.sym == *"_wait" && self.starts.contains(&local_start) { + ident.sym = "await".into(); + } + } + } + module.visit_mut_with(&mut RestoreAwaitNames { + file_start, + starts: await_name_starts, + }); } /// Utility to convert SWC span to our span type. @@ -1384,7 +1455,19 @@ class C { fn normalize_await_class_expression_name_for_script_parser() { let normalized = normalize_swc_class_syntax("var C = class await {};"); assert_eq!(normalized, "var C = class _wait {};"); - parse_typescript("var C = class await {};", "await-name.js").unwrap(); + let module = parse_typescript("var C = class await {};", "await-name.js").unwrap(); + let swc_ecma_ast::ModuleItem::Stmt(swc_ecma_ast::Stmt::Decl(swc_ecma_ast::Decl::Var(var))) = + &module.body[0] + else { + panic!("expected var declaration"); + }; + let Some(swc_ecma_ast::Expr::Class(class)) = var.decls[0].init.as_deref() else { + panic!("expected class expression initializer"); + }; + assert_eq!( + class.ident.as_ref().map(|ident| ident.sym.as_ref()), + Some("await") + ); parse_typescript(r"var C = class \u0061wait {};", "await-name-escaped.js").unwrap(); } diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 15a1714be7..89322e533e 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -334,7 +334,7 @@ pub(crate) fn array_object_set_length(recv: f64, new_length: f64) { let raw = (handle.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; crate::object::js_object_delete_dynamic(raw, k as f64); } - let raw = (handle.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); + let raw = (handle.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; crate::object::set_field_by_name_object_tail(raw, key, new_length); } diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index a99bae648b..745311e754 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -129,6 +129,10 @@ struct ExceptionState { /// Active derived-constructor binding cells at handler entry. A caught /// throw can skip an inline constructor's normal scope pop. derived_super_binding_depths: Box<[usize]>, + /// Pending private-member dispatch hints at handler entry. A throw while + /// evaluating the right-hand side of a guarded private write skips the + /// normal consumer, so catch entry must discard the orphaned hint. + private_member_access_hint_depths: Box<[usize]>, /// #6559: dyn-eval interpreter state (rooted-stack length + interpreter /// call depth, packed) captured when each `try` was pushed. A throw /// `longjmp`s past interpreter Rust frames without running their @@ -157,6 +161,7 @@ impl ExceptionState { static_private_owner_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), private_lexical_brand_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), derived_super_binding_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), + private_member_access_hint_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(), #[cfg(feature = "dyn-eval")] dyn_eval_savepoints: vec![0u64; MAX_TRY_DEPTH].into_boxed_slice(), try_depth: 0, @@ -222,6 +227,8 @@ fn try_push_with_kind(kind: HandlerKind) -> *mut i32 { crate::object::private_lexical_brand_stack_savepoint(); (*s).derived_super_binding_depths[depth] = crate::object::derived_super_binding_stack_savepoint(); + (*s).private_member_access_hint_depths[depth] = + crate::object::private_member_access_hints_savepoint(); // #6559: capture the dyn-eval interpreter's rooted-stack length + // call depth, so a caught throw restores interpreter state exactly // like the shadow stack. @@ -349,6 +356,9 @@ pub extern "C-unwind" fn js_throw(value: f64) -> ! { crate::object::derived_super_binding_stack_restore( (*s).derived_super_binding_depths[depth], ); + crate::object::private_member_access_hints_restore( + (*s).private_member_access_hint_depths[depth], + ); // #6559: restore the dyn-eval interpreter's rooted stack + call depth // (interpreter Rust frames unwound by this longjmp never run their // truncate/decrement epilogues). @@ -674,6 +684,9 @@ pub(crate) fn test_unwind_innermost_shadow_restore() { crate::object::prototype_chain::resolution_stack_restore( (*s).prototype_resolution_depths[depth], ); + crate::object::private_member_access_hints_restore( + (*s).private_member_access_hint_depths[depth], + ); }); } diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index cda4632429..23797f42b4 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -157,9 +157,9 @@ pub unsafe extern "C" fn js_array_subclass_init_args( let scope = crate::gc::RuntimeHandleScope::new(); let this = scope.root_nanbox_f64(this); + let args = scope.root_nanbox_f64_slice(args); js_array_subclass_init(this.get_nanbox_f64(), args.len() as f64); - for (index, value) in args.iter().copied().enumerate() { - let value = scope.root_nanbox_f64(value); + for (index, value) in args.iter().enumerate() { let name = index.to_string(); let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); let receiver = this.get_nanbox_f64(); diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index a2738d1064..93f845d51e 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -1113,6 +1113,28 @@ static KEEP_JS_ERROR_SUBCLASS_DEFAULT_INIT: unsafe extern "C" fn( *const crate::StringHeader, ) = js_error_subclass_default_init; +/// Find the per-evaluation class object that owns `target_cid` while walking a +/// fresh derived class's pinned parent chain. The template class-id registry +/// identifies which constructor to replay, but it cannot identify which +/// evaluation's captured environment belongs to that constructor. +fn pinned_class_object_for_ancestor(start: f64, target_cid: u32) -> Option { + let mut current = start; + let mut depth = 0usize; + while depth < 32 && super::class_registry::is_class_object_value(current) { + let object = + crate::value::JSValue::from_bits(current.to_bits()).as_pointer::(); + if object.is_null() { + return None; + } + if super::js_object_get_class_id(object) == target_cid { + return Some(current); + } + current = super::class_registry::class_object_pinned_parent(object)?; + depth += 1; + } + None +} + pub(crate) unsafe fn replay_class_object_constructor( classobj_value: f64, class_cid: u32, @@ -1159,19 +1181,29 @@ pub(crate) unsafe fn replay_class_object_constructor( }; // Read the snapshotted captures (an own array, in capture-param order). - // 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_handle.get_nanbox_f64(), - b"__perry_ctor_caps".as_ptr(), - 17, - ) + // When the implicit derived constructor walk resolves an ancestor, follow + // THIS class object's pinned per-evaluation parent chain and take the + // capture array from the matching ancestor object. Falling straight back + // to the template-wide declaration snapshot loses a fresh parent's + // environment (`class extends makeParent(tag) {}`), so inherited methods + // read `undefined` even though the instance's prototype chain is correct. + let capture_owner = if ctor_cid == class_cid { + classobj_handle.get_nanbox_f64() } else { - f64::from_bits(crate::value::TAG_UNDEFINED) + pinned_class_object_for_ancestor(classobj_handle.get_nanbox_f64(), ctor_cid) + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)) }; + let capture_owner_handle = scope.root_nanbox_f64(capture_owner); + let caps_val = + if super::class_registry::is_class_object_value(capture_owner_handle.get_nanbox_f64()) { + crate::object::js_object_get_own_field_or_undef( + capture_owner_handle.get_nanbox_f64(), + 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::(); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 64dd9aefa5..fecbf27961 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -169,15 +169,16 @@ pub(crate) use dispatch::{ // ── parent_static.rs ──────────────────────────────────────────────────────── pub(crate) use parent_static::{ call_private_static_method_for_owner, call_registered_static_method, call_static_method, - class_chain_has_instance_accessor, class_dynamic_static_accessor_getter_value, - class_has_instance_getter, class_has_own_static_method, class_has_symbol_member_in_chain, - class_instance_setter_apply, class_method_bind_length, class_object_own_field_bytes, - class_object_pinned_parent, class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, - class_own_symbol_method, class_private_instance_getter_value, - class_private_instance_setter_apply, class_static_accessor_getter_value, - class_static_accessor_setter_apply, class_symbol_getter_value, class_symbol_setter_apply, - get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, - register_class, register_class_dynamic_static_accessor, + class_chain_has_instance_accessor, class_dynamic_static_accessor_descriptor, + class_dynamic_static_accessor_getter_value, class_has_instance_getter, + class_has_own_static_method, class_has_symbol_member_in_chain, class_instance_setter_apply, + class_method_bind_length, class_object_own_field_bytes, class_object_pinned_parent, + class_own_symbol_accessor_ptrs, class_own_symbol_member_keys, class_own_symbol_method, + class_private_instance_getter_value, class_private_instance_setter_apply, + class_static_accessor_getter_value, class_static_accessor_setter_apply, + class_symbol_getter_value, class_symbol_setter_apply, get_parent_class_id, + lookup_class_symbol_method_in_chain, lookup_static_method_in_chain, register_class, + register_class_dynamic_static_accessor, }; pub use parent_static::{ is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object, diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index e50e4f94f0..ea3d72b198 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -937,6 +937,9 @@ pub unsafe extern "C" fn js_new_function_construct( // address. Reproduced by `new C()` where `C = mk()` is a class // EXPRESSION value. let inst_handle = scope.root_raw_mut_ptr(inst); + inst_handle.with_mut_ptr::(|inst| { + link_class_object_instance_prototype(class_handle.get_nanbox_f64(), inst) + }); // Every evaluation gets a distinct brand despite sharing its // class id. Stamp it before replay, where private access may occur. inst_handle.with_mut_ptr::(|inst| { @@ -959,20 +962,31 @@ pub unsafe extern "C" fn js_new_function_construct( args_len, ); }); - let inst: *mut ObjectHeader = inst_handle.get_raw_mut_ptr(); // `class X extends Request/Response {}` constructed via the dynamic // (class-expression value) path: the replayed ctor's `super()` // can't statically route an aliased parent, so attach the native // fetch handle here when the registered parent is a fetch builtin // and the instance didn't already get one. Refs `@hono/node-server`. if let Some(kind) = fetch_parent_kind_in_chain(class_cid) { - if super::super::field_get_set::fetch_subclass_handle_id(inst as usize).is_none() { - super::super::attach_fetch_handle_for_construction( - inst, kind, args_ptr, args_len, - ); + let has_handle = inst_handle.with_mut_ptr::(|inst| { + super::super::field_get_set::fetch_subclass_handle_id(inst as usize).is_some() + }); + if !has_handle { + inst_handle.with_mut_ptr::(|inst| { + super::super::attach_fetch_handle_for_construction( + inst, kind, args_ptr, args_len, + ) + }); } } - // Re-read: `attach_fetch_handle_for_construction` allocates. + // Class-expression values can also extend Promise and reach this + // dynamic construct path. The synthesized default constructor does + // not call construct-only builtins as plain functions; attach the + // Promise backing here, matching the ClassRef path below. An + // explicit `super(executor)` has already installed it, so avoid + // invoking the executor twice. + ensure_promise_subclass_backing(&inst_handle, class_cid, args_ptr, args_len); + // Re-read: the fetch attachment and Promise executor both allocate. return crate::value::js_nanbox_pointer( inst_handle.get_raw_mut_ptr::() as i64 ); @@ -1389,34 +1403,8 @@ fn new_target_class_id(new_target: f64) -> Option { } include!("construct/class_return.rs"); - -/// True when class `cid` (or an ancestor) `extends Promise` — its registered -/// dynamic-parent value resolves to the intrinsic `Promise` constructor. Used to -/// run `js_promise_subclass_init` on the dynamic (runtime) `new Subclass(exec)` -/// path, where codegen's `super()` Promise branch never emitted the init (e.g. -/// `NewPromiseCapability(Subclass)` inside a combinator, which calls the runtime -/// `js_new_function_construct` directly rather than a compiled `new`). -pub(crate) fn promise_parent_in_chain(class_id: u32) -> bool { - let mut cid = class_id; - let mut depth = 0u32; - while depth < 32 && cid != 0 { - let parent_val = js_get_dynamic_parent_value(cid); - if matches!( - identify_global_builtin_constructor(parent_val), - Some("Promise") - ) { - return true; - } - match get_parent_class_id(cid) { - Some(p) if p != 0 && p != cid => { - cid = p; - depth += 1; - } - _ => break, - } - } - false -} +include!("construct/class_object.rs"); +include!("construct/promise_subclass.rs"); unsafe fn construct_registered_class_ref( target_cid: u32, @@ -1492,22 +1480,8 @@ unsafe fn construct_registered_class_ref( super::super::attach_fetch_handle_for_construction(inst, kind, args_ptr, args_len); } } - // ClassRef `new` of a Promise subclass — run the Promise constructor against - // a hidden backing cell (only when the compiled ctor's `super()` didn't - // already attach one). `NewPromiseCapability(Subclass)` reaches here. - if promise_parent_in_chain(target_cid) { - // Re-read: `attach_fetch_handle_for_construction` above allocates. - let inst_val = - crate::value::js_nanbox_pointer(inst_handle.get_raw_mut_ptr::() as i64); - if crate::promise::subclass_backing_promise(inst_val).is_none() { - let executor = if args_len >= 1 && !args_ptr.is_null() { - *args_ptr - } else { - f64::from_bits(crate::value::TAG_UNDEFINED) - }; - crate::promise::js_promise_subclass_init(inst_val, executor); - } - } + // `NewPromiseCapability(Subclass)` reaches this dynamic ClassRef path. + ensure_promise_subclass_backing(&inst_handle, target_cid, args_ptr, args_len); // Re-read once more: the executor `js_promise_subclass_init` runs is user // code, so the last two blocks are both collection points. crate::value::js_nanbox_pointer(inst_handle.get_raw_mut_ptr::() as i64) @@ -1640,13 +1614,20 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( // `GetPrototypeFromConstructor(newTarget)` like the typed-array arm // below so `instanceof newTarget` and subclass prototypes hold. if ta_name == "Date" { - let proto_bits = new_target_custom_object_prototype(nt); - let result = js_new_function_construct(func_value, args_ptr, args_len); - if let Some(proto_bits) = proto_bits { + let scope = crate::gc::RuntimeHandleScope::new(); + let nt = scope.root_nanbox_f64(nt); + let func = scope.root_nanbox_f64(func_value); + let proto = new_target_custom_object_prototype(nt.get_nanbox_f64()) + .map(|bits| scope.root_heap_word_u64(bits)); + let result = js_new_function_construct(func.get_nanbox_f64(), args_ptr, args_len); + if let Some(proto) = proto { let jv = crate::value::JSValue::from_bits(result.to_bits()); if jv.is_pointer() { let addr = (jv.bits() & crate::value::POINTER_MASK) as usize; - super::super::prototype_chain::object_set_static_prototype(addr, proto_bits); + super::super::prototype_chain::object_set_static_prototype( + addr, + proto.get_heap_word_u64(), + ); } } return result; @@ -1671,11 +1652,18 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( // AllocateTypedArray, so a throwing `prototype` getter must surface // here even when later steps would also throw (test262 // `throw-type-error-before-custom-proto-access` agreement). - let proto_bits = new_target_custom_object_prototype(nt); - let result = js_new_function_construct(func_value, args_ptr, args_len); + let scope = crate::gc::RuntimeHandleScope::new(); + let nt = scope.root_nanbox_f64(nt); + let func = scope.root_nanbox_f64(func_value); + let proto = new_target_custom_object_prototype(nt.get_nanbox_f64()) + .map(|bits| scope.root_heap_word_u64(bits)); + let result = js_new_function_construct(func.get_nanbox_f64(), args_ptr, args_len); if let Some(addr) = crate::typedarray_props::typed_array_addr_from_value(result) { - if let Some(proto_bits) = proto_bits { - super::super::prototype_chain::object_set_static_prototype(addr, proto_bits); + if let Some(proto) = proto { + super::super::prototype_chain::object_set_static_prototype( + addr, + proto.get_heap_word_u64(), + ); } } return result; @@ -1691,13 +1679,19 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( | "RegExp" | "Function" ) { - let proto_bits = new_target_custom_object_prototype(nt); - let result = js_new_function_construct(func_value, args_ptr, args_len); - if let Some(proto_bits) = proto_bits { + let scope = crate::gc::RuntimeHandleScope::new(); + let nt = scope.root_nanbox_f64(nt); + let func = scope.root_nanbox_f64(func_value); + let proto = new_target_custom_object_prototype(nt.get_nanbox_f64()) + .map(|bits| scope.root_heap_word_u64(bits)); + let result = js_new_function_construct(func.get_nanbox_f64(), args_ptr, args_len); + if let Some(proto) = proto { let bits = result.to_bits(); let addr = if (bits >> 48) == 0x7FFD { (bits & crate::value::POINTER_MASK) as usize - } else if (bits >> 48) == 0 && bits >= 0x1000 { + } else if (bits >> 48) == 0 + && crate::value::addr_class::is_plausible_heap_addr(bits as usize) + { // ArrayBuffer and SharedArrayBuffer are represented by a // raw BufferHeader pointer rather than a NaN-boxed object. bits as usize @@ -1705,7 +1699,10 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target( 0 }; if addr != 0 { - super::super::prototype_chain::object_set_static_prototype(addr, proto_bits); + super::super::prototype_chain::object_set_static_prototype( + addr, + proto.get_heap_word_u64(), + ); } } return result; diff --git a/crates/perry-runtime/src/object/class_registry/construct/class_object.rs b/crates/perry-runtime/src/object/class_registry/construct/class_object.rs new file mode 100644 index 0000000000..1883a4d8a2 --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/construct/class_object.rs @@ -0,0 +1,19 @@ +/// Link an instance constructed through a fresh class value to that +/// evaluation's distinct prototype object. Class-id dispatch alone follows +/// the shared template and cannot preserve per-evaluation inheritance. +fn link_class_object_instance_prototype(class_value: f64, instance: *mut ObjectHeader) { + let scope = crate::gc::RuntimeHandleScope::new(); + let class = scope.root_nanbox_f64(class_value); + let instance = scope.root_raw_mut_ptr(instance); + let class_obj = crate::value::JSValue::from_bits(class.get_nanbox_f64().to_bits()) + .as_pointer::(); + let prototype = + unsafe { super::super::field_get_set::class_object_prototype_value(class_obj) }; + let prototype = scope.root_heap_word_u64(prototype.bits()); + instance.with_mut_ptr::(|instance| { + super::super::prototype_chain::object_link_class_default_prototype( + instance as usize, + prototype.get_heap_word_u64(), + ) + }); +} diff --git a/crates/perry-runtime/src/object/class_registry/construct/class_return.rs b/crates/perry-runtime/src/object/class_registry/construct/class_return.rs index be5091de83..6da8ef6a1e 100644 --- a/crates/perry-runtime/src/object/class_registry/construct/class_return.rs +++ b/crates/perry-runtime/src/object/class_registry/construct/class_return.rs @@ -39,7 +39,9 @@ fn constructor_return_overrides_this(value: f64) -> bool { let bits = value.to_bits(); let raw_addr = if jv.is_pointer() { (bits & crate::value::POINTER_MASK) as usize - } else if (bits >> 48) == 0 && bits >= 0x1000 { + } else if (bits >> 48) == 0 + && crate::value::addr_class::is_plausible_heap_addr(bits as usize) + { bits as usize } else { 0 @@ -72,13 +74,11 @@ fn constructor_return_overrides_this(value: f64) -> bool { if !arr.is_null() { return true; } - if !is_valid_obj_ptr(raw as *const u8) { + let Some(gc_header) = crate::value::addr_class::try_read_gc_header(raw as usize) else { return false; - } - let gc_header = - (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + }; matches!( - (*gc_header).obj_type, + gc_header.obj_type, // Per spec, a constructor returning ANY Object overrides the // implicit `this`. Promises are objects — a user constructor like // `function P(exec){ return new Promise(...) }` (the @@ -92,6 +92,10 @@ fn constructor_return_overrides_this(value: f64) -> bool { | crate::gc::GC_TYPE_ERROR | crate::gc::GC_TYPE_PROMISE | crate::gc::GC_TYPE_TEMPORAL + | crate::gc::GC_TYPE_MAP + | crate::gc::GC_TYPE_SET + | crate::gc::GC_TYPE_DATE_CELL + | crate::gc::GC_TYPE_REGEXP ) } } diff --git a/crates/perry-runtime/src/object/class_registry/construct/promise_subclass.rs b/crates/perry-runtime/src/object/class_registry/construct/promise_subclass.rs new file mode 100644 index 0000000000..013739680c --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/construct/promise_subclass.rs @@ -0,0 +1,52 @@ +/// True when `class_id` or an ancestor extends the intrinsic Promise. +pub(crate) fn promise_parent_in_chain(class_id: u32) -> bool { + let mut cid = class_id; + let mut depth = 0u32; + while depth < 32 && cid != 0 { + let parent = js_get_dynamic_parent_value(cid); + if matches!(identify_global_builtin_constructor(parent), Some("Promise")) { + return true; + } + match get_parent_class_id(cid) { + Some(parent_id) if parent_id != 0 && parent_id != cid => { + cid = parent_id; + depth += 1; + } + _ => break, + } + } + false +} + +/// Install the hidden Promise backing on a dynamically-constructed subclass +/// unless an explicit `super(executor)` already did so. +unsafe fn ensure_promise_subclass_backing( + instance: &crate::gc::RuntimeHandle<'_>, + class_id: u32, + args_ptr: *const f64, + args_len: usize, +) { + if !promise_parent_in_chain(class_id) { + return; + } + let has_backing = instance.with_mut_ptr::(|instance| { + crate::promise::subclass_backing_promise(crate::value::js_nanbox_pointer( + instance as i64, + )) + .is_some() + }); + if has_backing { + return; + } + let executor = if args_len >= 1 && !args_ptr.is_null() { + *args_ptr + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + }; + instance.with_mut_ptr::(|instance| { + crate::promise::js_promise_subclass_init( + crate::value::js_nanbox_pointer(instance as i64), + executor, + ) + }); +} diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 390de575ed..95690f6b51 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -1003,6 +1003,11 @@ pub(crate) unsafe fn class_static_accessor_getter_value( let mut cid = class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { + // A descriptor installed by `defineProperty` replaces an existing + // class-body accessor at the same inheritance level. + if let Some(result) = class_dynamic_static_accessor_getter_value(cid, name, receiver) { + return Some(result); + } if let Some(accessors) = map.and_then(|map| map.get(&cid)) { if let Some(&(getter, _)) = accessors.get(name) { if getter == 0 { @@ -1021,9 +1026,6 @@ pub(crate) unsafe fn class_static_accessor_getter_value( return Some(result); } } - if let Some(result) = class_dynamic_static_accessor_getter_value(cid, name, receiver) { - return Some(result); - } match get_parent_class_id(cid) { Some(p) if p != 0 && p != cid => { cid = p; @@ -1046,6 +1048,11 @@ pub(crate) unsafe fn class_static_accessor_setter_apply( let mut cid = class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { + if let Some(applied) = + class_dynamic_static_accessor_setter_apply(cid, name, receiver, value) + { + return applied; + } if let Some(accessors) = map.and_then(|map| map.get(&cid)) { if let Some(&(_, setter)) = accessors.get(name) { if setter != 0 { @@ -1062,11 +1069,6 @@ pub(crate) unsafe fn class_static_accessor_setter_apply( return true; } } - if let Some(applied) = - class_dynamic_static_accessor_setter_apply(cid, name, receiver, value) - { - return applied; - } match get_parent_class_id(cid) { Some(p) if p != 0 && p != cid => { cid = p; diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index c686fde852..057e82f073 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -36,7 +36,7 @@ fn dynamic_static_accessor_key(name: &str) -> String { key } -fn dynamic_static_accessor_owner(class_id: u32) -> usize { +fn shared_dynamic_static_accessor_owner(class_id: u32) -> usize { let value = class_decl_prototype_value(class_id); let bits = value.to_bits(); if (bits >> 48) == 0x7FFD { @@ -46,6 +46,47 @@ fn dynamic_static_accessor_owner(class_id: u32) -> usize { } } +/// Resolve the constructor object which owns a dynamic static descriptor at +/// this point in the receiver's per-evaluation heritage chain. Heap class +/// values with the same template id are distinct constructor objects; an +/// immediate ClassRef continues to use the shared declared prototype owner. +fn dynamic_static_accessor_owner(class_id: u32, receiver: f64) -> usize { + let mut current = receiver; + let mut depth = 0usize; + while depth < 32 { + if is_class_object_value(current) { + let object = crate::value::JSValue::from_bits(current.to_bits()) + .as_pointer::(); + if object.is_null() { + return 0; + } + let current_id = unsafe { (*object).class_id }; + if current_id == class_id { + return object as usize; + } + let Some(parent) = class_object_pinned_parent(object) else { + return 0; + }; + current = parent; + depth += 1; + continue; + } + if super::super::class_ref_id(current).is_some() { + return shared_dynamic_static_accessor_owner(class_id); + } + return 0; + } + 0 +} + +fn dynamic_static_accessor_storage_key(owner: usize, name: &str) -> String { + if is_class_object_ptr(owner as *const u8) { + name.to_string() + } else { + dynamic_static_accessor_key(name) + } +} + /// Store an accessor installed dynamically on a class constructor through /// `Object.defineProperty(C, key, { get, set })`. Class constructors are /// immediate ClassRef values rather than heap objects, so keep the rooted @@ -53,25 +94,78 @@ fn dynamic_static_accessor_owner(class_id: u32) -> usize { /// internal key; the public static lookup paths consult it by class id. pub(crate) fn register_class_dynamic_static_accessor( class_id: u32, + receiver: f64, name: &str, - get_bits: u64, - set_bits: u64, + get_bits: Option, + set_bits: Option, + enumerable: Option, + configurable: Option, ) { let scope = crate::gc::RuntimeHandleScope::new(); - let get = scope.root_nanbox_u64(get_bits); - let set = scope.root_nanbox_u64(set_bits); - let owner = dynamic_static_accessor_owner(class_id); + let receiver = scope.root_nanbox_f64(receiver); + let get = scope.root_nanbox_u64(get_bits.unwrap_or(0)); + let set = scope.root_nanbox_u64(set_bits.unwrap_or(0)); + let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); if owner == 0 { return; } + let key = dynamic_static_accessor_storage_key(owner, name); + let existing = crate::object::get_accessor_descriptor(owner, &key).unwrap_or_default(); crate::object::set_accessor_descriptor( owner, - dynamic_static_accessor_key(name), + key.clone(), crate::object::AccessorDescriptor { - get: get.get_nanbox_u64(), - set: set.get_nanbox_u64(), + get: get_bits.map(|_| get.get_nanbox_u64()).unwrap_or(existing.get), + set: set_bits.map(|_| set.get_nanbox_u64()).unwrap_or(existing.set), }, ); + let existing_attrs = if is_class_object_ptr(owner as *const u8) { + crate::object::get_property_attrs(owner, &key) + .map(|attrs| (attrs.enumerable(), attrs.configurable())) + } else { + class_static_defined_attrs(class_id, name).map(|(_, enumerable, configurable)| { + (enumerable, configurable) + }) + }; + let enumerable = enumerable + .or_else(|| existing_attrs.map(|attrs| attrs.0)) + .unwrap_or(false); + let configurable = configurable + .or_else(|| existing_attrs.map(|attrs| attrs.1)) + .unwrap_or(false); + if is_class_object_ptr(owner as *const u8) { + crate::object::set_property_attrs( + owner, + key, + crate::object::PropertyAttrs::new(false, enumerable, configurable), + ); + } else { + class_static_set_defined_attrs(class_id, name, false, enumerable, configurable); + } +} + +pub(crate) fn class_dynamic_static_accessor_descriptor( + class_id: u32, + name: &str, + receiver: f64, +) -> Option<(crate::object::AccessorDescriptor, crate::object::PropertyAttrs)> { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); + if owner == 0 { + return None; + } + let key = dynamic_static_accessor_storage_key(owner, name); + let descriptor = crate::object::get_accessor_descriptor(owner, &key)?; + let attrs = if is_class_object_ptr(owner as *const u8) { + crate::object::get_property_attrs(owner, &key) + } else { + class_static_defined_attrs(class_id, name).map(|(_, enumerable, configurable)| { + crate::object::PropertyAttrs::new(false, enumerable, configurable) + }) + } + .unwrap_or(crate::object::PropertyAttrs::new(false, false, false)); + Some((descriptor, attrs)) } pub(crate) unsafe fn class_dynamic_static_accessor_getter_value( @@ -79,15 +173,22 @@ pub(crate) unsafe fn class_dynamic_static_accessor_getter_value( name: &str, receiver: f64, ) -> Option { - let owner = dynamic_static_accessor_owner(class_id); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); let descriptor = (owner != 0) - .then(|| crate::object::get_accessor_descriptor(owner, &dynamic_static_accessor_key(name))) + .then(|| { + crate::object::get_accessor_descriptor( + owner, + &dynamic_static_accessor_storage_key(owner, name), + ) + }) .flatten()?; if descriptor.get == 0 { return Some(f64::from_bits(crate::value::TAG_UNDEFINED)); } Some(f64::from_bits( - crate::object::invoke_accessor_getter(descriptor.get, receiver).bits(), + crate::object::invoke_accessor_getter(descriptor.get, receiver.get_nanbox_f64()).bits(), )) } @@ -100,14 +201,26 @@ pub(crate) unsafe fn class_dynamic_static_accessor_setter_apply( receiver: f64, value: f64, ) -> Option { - let owner = dynamic_static_accessor_owner(class_id); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let value = scope.root_nanbox_f64(value); + let owner = dynamic_static_accessor_owner(class_id, receiver.get_nanbox_f64()); let descriptor = (owner != 0) - .then(|| crate::object::get_accessor_descriptor(owner, &dynamic_static_accessor_key(name))) + .then(|| { + crate::object::get_accessor_descriptor( + owner, + &dynamic_static_accessor_storage_key(owner, name), + ) + }) .flatten()?; if descriptor.set == 0 { return Some(false); } - crate::object::invoke_accessor_setter(descriptor.set, receiver, value); + crate::object::invoke_accessor_setter( + descriptor.set, + receiver.get_nanbox_f64(), + value.get_nanbox_f64(), + ); Some(true) } diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 8a33dda7a0..401b615274 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -407,13 +407,18 @@ unsafe fn resolve_proto_chain_field_inner( if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) { - if super::super::native_module::class_has_own_method(cid, name) - && value.bits() + let name = name.to_string(); + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_u64(value.bits()); + let receiver = scope.root_heap_word_u64(receiver.to_bits()); + if super::super::native_module::class_has_own_method(cid, &name) + && value.get_nanbox_u64() == super::super::native_module::class_prototype_method_value_for_name( - cid, name, + cid, &name, ) .to_bits() { + let receiver = f64::from_bits(receiver.get_heap_word_u64()); if let Some(brand) = super::super::private_evaluation_brand_value(receiver) { @@ -423,12 +428,13 @@ unsafe fn resolve_proto_chain_field_inner( && js_object_get_class_id(brand_obj) == cid { let method = super::super::native_module::class_evaluation_method_value_for_name( - cid, name, brand, + cid, &name, brand, ); return Some(JSValue::from_bits(method.to_bits())); } } } + return Some(JSValue::from_bits(value.get_nanbox_u64())); } } return Some(value); diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index b75fbe0173..decb9bcb65 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -192,6 +192,25 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu && crate::symbol::js_is_symbol(key_value) == 0 { if let Some(method_name) = metadata_key_to_string(key_value) { + let class_obj = extract_obj_ptr(obj_value); + if !class_obj.is_null() { + let class_id = super::js_object_get_class_id(class_obj); + if let Some((acc, attrs)) = + super::class_registry::class_dynamic_static_accessor_descriptor( + class_id, + &method_name, + obj_value, + ) + { + let undef = crate::value::TAG_UNDEFINED; + return build_accessor_descriptor( + f64::from_bits(if acc.get == 0 { undef } else { acc.get }), + f64::from_bits(if acc.set == 0 { undef } else { acc.set }), + attrs.enumerable(), + attrs.configurable(), + ); + } + } // #6943: `js_string_coerce` allocates for every non-heap-string // key and can run a user `toString` / `valueOf` for an object // key, so it can trigger a GC that **evacuates**. `obj` — the @@ -357,6 +376,23 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } return f64::from_bits(crate::value::TAG_UNDEFINED); } + if super::class_prototype_ref_id(obj_value).is_none() { + if let Some((acc, attrs)) = + super::class_registry::class_dynamic_static_accessor_descriptor( + class_id, + &method_name, + obj_value, + ) + { + let undef = crate::value::TAG_UNDEFINED; + return build_accessor_descriptor( + f64::from_bits(if acc.get == 0 { undef } else { acc.get }), + f64::from_bits(if acc.set == 0 { undef } else { acc.set }), + attrs.enumerable(), + attrs.configurable(), + ); + } + } // `C.prototype` is a non-writable, non-enumerable, non-configurable // own data property of the class constructor (ECMA-262 // MakeConstructor). Only the constructor ref carries it — the @@ -693,7 +729,13 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if crate::array::is_array_subclass_value(obj_value) && key_rust.as_deref() == Some("length") { let length = crate::object::js_object_get_field_by_name(obj, key_str); - return build_data_descriptor(f64::from_bits(length.bits()), true, false, false); + let frozen = + (*crate::object::gc_header_for(obj))._reserved & crate::gc::OBJ_FLAG_FROZEN != 0; + let writable = !frozen + && get_property_attrs(obj as usize, "length") + .map(|attrs| attrs.writable()) + .unwrap_or(true); + return build_data_descriptor(f64::from_bits(length.bits()), writable, false, false); } if (obj as usize) >= crate::gc::GC_HEADER_SIZE + 0x1000 { let gc_header = diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 7e33c7780a..9e77c94d52 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -248,6 +248,7 @@ pub(crate) use accessors::{ ordinary_object_prototype_property_value, own_data_field_by_name, primitive_builtin_prototype_property, primitive_object_prototype_accessor, string_index_value, }; +pub(crate) use class_object_props::class_object_prototype_value; pub(crate) use crypto_key::{ crypto_key_property_value, CLASS_ID_BOXED_BIGINT, CLASS_ID_BOXED_BOOLEAN, CLASS_ID_BOXED_NUMBER, CLASS_ID_BOXED_STRING, CLASS_ID_BOXED_SYMBOL, @@ -278,7 +279,8 @@ pub(crate) use ic_miss::primitive_proto_method_name_static; pub(crate) use ic_miss::{ bind_primitive_proto_method_static, is_array_method_value_name, private_evaluation_brand_value, private_lexical_brand_pop, private_lexical_brand_push, private_lexical_brand_stack_restore, - private_lexical_brand_stack_savepoint, private_member_call_by_name, private_member_get_by_name, + private_lexical_brand_stack_savepoint, private_member_access_hints_restore, + private_member_access_hints_savepoint, private_member_call_by_name, private_member_get_by_name, private_member_set_by_name, scan_private_lexical_brand_roots_mut, set_method_value_name, stamp_private_evaluation_brand, take_private_method_call_hint, take_private_method_owner_hint, timer_handle_method_name_static, diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index a4f461327f..a77ad959ed 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -63,6 +63,52 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { }); } + // Each evaluation owns a distinct prototype object, and that object's + // [[Prototype]] follows this evaluation's pinned heritage edge rather than + // the template-id (last-wins) parent table. + let pinned_parent = class.with_const_ptr::(|class| { + super::super::class_registry::class_object_pinned_parent(class) + }); + let parent_proto = match pinned_parent { + Some(parent) if parent.to_bits() == crate::value::TAG_NULL => Some(crate::value::TAG_NULL), + Some(parent) => { + let parent = scope.root_nanbox_f64(parent); + let parent_value = parent.get_nanbox_f64(); + if super::super::class_registry::is_class_object_value(parent_value) { + let parent_obj = + JSValue::from_bits(parent_value.to_bits()).as_pointer::(); + (!parent_obj.is_null()) + .then(|| class_evaluation_prototype_value(parent_obj).to_bits()) + } else if let Some(parent_id) = super::super::class_ref_id(parent_value) { + Some(super::super::class_registry::class_decl_prototype_value(parent_id).to_bits()) + } else { + let parent_js = JSValue::from_bits(parent_value.to_bits()); + if parent_js.is_pointer() + && crate::closure::is_closure_ptr(parent_js.as_pointer::() as usize) + { + let value = crate::closure::closure_get_dynamic_prop( + parent_js.as_pointer::() as usize, + "prototype", + ); + let value_js = JSValue::from_bits(value.to_bits()); + value_js.is_pointer().then_some(value.to_bits()) + } else { + None + } + } + } + None => super::super::class_registry::global_object_prototype_bits(), + }; + if let Some(parent_proto) = parent_proto { + let parent_proto = scope.root_heap_word_u64(parent_proto); + proto.with_mut_ptr::(|proto| { + super::super::prototype_chain::object_set_static_prototype( + proto as usize, + parent_proto.get_heap_word_u64(), + ) + }); + } + let proto_value = proto .with_mut_ptr::(|proto| crate::value::js_nanbox_pointer(proto as i64)); class.with_mut_ptr::(|class| { @@ -77,7 +123,7 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { /// OBJECT_TYPE_CLASS objects, not INT32 class refs. Their `.prototype` /// read must still expose the live declared-class prototype object so /// tsc/tslib decorator code can inspect and mutate method descriptors. -pub(super) unsafe fn class_object_prototype_value(obj: *const ObjectHeader) -> JSValue { +pub(crate) unsafe fn class_object_prototype_value(obj: *const ObjectHeader) -> JSValue { JSValue::from_bits(class_evaluation_prototype_value(obj).to_bits()) } diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index 303561d843..5c4fb9df03 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1326,9 +1326,11 @@ pub(crate) unsafe fn instance_private_key_hidden( pub(crate) fn is_internal_runtime_key_bytes(b: &[u8]) -> bool { b == crate::object::map_set_subclass::BACKING_KEY || b == crate::weakref::WEAK_ENTRIES_KEY + || b == crate::object::parent_static::CLASS_OBJECT_PARENT_KEY.as_bytes() + || b == b"__perry_ctor_caps" || b.starts_with(crate::node_stream::NATIVE_BASE_SUPER_PREFIX) || b.starts_with(b"__perry_computed_field_key_") - || b.starts_with(b"# usize { + PRIVATE_MEMBER_ACCESS_HINTS.with(|hints| hints.borrow().len()) +} + +pub(crate) fn private_member_access_hints_restore(depth: usize) { + PRIVATE_MEMBER_ACCESS_HINTS.with(|hints| hints.borrow_mut().truncate(depth)); +} + pub(crate) fn take_private_method_owner_hint(method_name: &str) -> Option { PRIVATE_METHOD_OWNER_HINT.with(|hint| { let mut hint = hint.borrow_mut(); @@ -88,10 +96,14 @@ pub(crate) fn private_member_get_by_name( ), ); } + let stable_name = super::super::native_module::intern_class_method_name( + hint.class_id, + &name, + ); Some(super::super::js_class_method_bind( receiver, - name.as_ptr(), - name.len(), + stable_name.as_ptr(), + stable_name.len(), )) } 2 | 4 if hint.is_static => { @@ -293,8 +305,12 @@ pub extern "C" fn js_private_brand_check( /// Throw a `TypeError` with `msg` through Perry's exception machinery so a /// surrounding `try { ... } catch (e) { ... }` catches it. Diverges. fn throw_private_type_error(msg: &str) -> ! { - let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err = crate::error::js_typeerror_new(s); + let scope = crate::gc::RuntimeHandleScope::new(); + let s = scope.root_string_ptr(crate::string::js_string_from_bytes( + msg.as_ptr(), + msg.len() as u32, + )); + let err = s.with_mut_ptr::(|s| crate::error::js_typeerror_new(s)); let v = crate::value::JSValue::pointer(err as *const u8).bits(); crate::exception::js_throw(f64::from_bits(v)) } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 71888d3b3d..e600718541 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -47,10 +47,15 @@ pub extern "C" fn js_object_set_field_by_name( // `prototype` property is non-writable, so both ordinary assignment and // a computed static field whose PropertyKey resolves to "prototype" must // fail instead of appending an ordinary shape slot. + let obj_bits = obj as u64; + let normalized_obj = if (obj_bits >> 48) == 0x7FFD { + (obj_bits & crate::value::POINTER_MASK) as *mut ObjectHeader + } else { + obj + }; if !key.is_null() - && ((obj as u64) >> 48) == 0 - && crate::value::addr_class::is_above_handle_band(obj as usize) - && crate::object::class_registry::is_class_object_ptr(obj.cast()) + && crate::value::addr_class::is_above_handle_band(normalized_obj as usize) + && crate::object::class_registry::is_class_object_ptr(normalized_obj.cast()) { unsafe { if string_key_eq(key, b"prototype") { diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index ca4b2139ee..7d6410f002 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -38,6 +38,12 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( if obj_gc.obj_type != crate::gc::GC_TYPE_OBJECT || obj_gc.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 || obj_gc._reserved & BLOCKING_FLAGS != 0 + // A per-evaluation class object can carry dynamic static accessors in + // the class registry while retaining an ordinary backing slot with the + // same key. Overwriting that slot directly bypasses the accessor + // setter, so class constructors must always take the full exotic + // `[[Set]]` path. + || crate::object::class_registry::is_class_object_ptr(obj.cast()) || (*obj).class_id == NATIVE_MODULE_CLASS_ID || crate::array::object_prototype_addr_matches(obj_addr) // URL's visible fields are live views over one backing URL. An own diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index b91a7db73f..56f28b204b 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -266,6 +266,31 @@ pub(crate) fn set_field_by_name_object_tail( let plan_fast = plan_eligible && super::prop_plan::store_plan_check(obj_class_id, interned_key as usize); + // A per-evaluation class object carries dynamically installed static + // accessors in the descriptor side table, not in its ordinary field + // slots. Invoke that setter before the generic instance-vtable and + // own-data paths below; otherwise `C.x = value` silently appends or + // overwrites a data field after `defineProperty(C, "x", { set })`. + if !plan_fast + && !key.is_null() + && crate::object::class_registry::is_class_object_ptr(obj.cast()) + { + let name_ptr = crate::string::string_data(key); + let name_len = (*key).byte_len as usize; + if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) { + let receiver = + f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); + if super::class_registry::class_static_accessor_setter_apply( + obj_class_id, + name, + receiver, + value, + ) { + return; + } + } + } + // Refs #486 (hono): class setter dispatch. JS spec: a `set X(...)` // accessor on the prototype intercepts `obj.X = value` writes // before they hit the instance's data slots. Hono's `set res(_res) diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index ad2bbc7b4e..cf6eae179a 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -889,8 +889,15 @@ pub unsafe extern "C" fn js_fetch_or_value_super( ); if parent_cid != 0 { if let Some(obj) = subclass_this_object_ptr(this_box) { - super::super::class_constructors::run_class_constructor_on_this_flat( - parent_cid, obj as i64, args_ptr, args_len, + // A fresh class object's constructor environment + // is per evaluation. Replaying by class id alone + // consults the template-wide declaration snapshot + // and drops captured values such as a factory's + // `tag`. Use the class-object replay path so this + // exact parent's `__perry_ctor_caps` supplies its + // synthesized capture params. + super::super::class_constructors::replay_class_object_constructor( + parent_val, parent_cid, obj, args_ptr, args_len, ); return undef; } @@ -903,8 +910,8 @@ pub unsafe extern "C" fn js_fetch_or_value_super( let parent_cid = crate::object::js_object_get_class_id(p as *const _); if parent_cid != 0 { if let Some(obj) = subclass_this_object_ptr(this_box) { - super::super::class_constructors::run_class_constructor_on_this_flat( - parent_cid, obj as i64, args_ptr, args_len, + super::super::class_constructors::replay_class_object_constructor( + parent_val, parent_cid, obj, args_ptr, args_len, ); } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e28f2a0b0d..6a38ffe05b 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -249,6 +249,7 @@ pub(crate) use field_get_set::FieldLookupCaches; pub(crate) use field_get_set::{ private_evaluation_brand_value, private_lexical_brand_pop, private_lexical_brand_push, private_lexical_brand_stack_restore, private_lexical_brand_stack_savepoint, + private_member_access_hints_restore, private_member_access_hints_savepoint, scan_private_lexical_brand_roots_mut, }; pub(crate) use this_binding::{ diff --git a/crates/perry-runtime/src/object/native_call_method/string_methods.rs b/crates/perry-runtime/src/object/native_call_method/string_methods.rs index c1ad8a9552..cae30b6a76 100644 --- a/crates/perry-runtime/src/object/native_call_method/string_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/string_methods.rs @@ -156,7 +156,7 @@ pub(super) unsafe fn dispatch_string( } return Some(f64::from_bits(JSValue::string_ptr(result).bits())); } - "toString" | "valueOf" => return Some(object_handle.get_nanbox_f64()), + "toString" | "valueOf" => return Some(string_receiver), // Issue #519 follow-up: hono's matcher.js does // `path2.match(matcher[0])` where `path2` is a string and // `matcher[0]` is a regex. The HIR optimistic diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 77ddf49960..3628c00b2b 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -9,8 +9,12 @@ use super::*; use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::ptr::null_mut; -use std::sync::atomic::{AtomicPtr, Ordering}; +use std::sync::{ + atomic::{AtomicPtr, Ordering}, + OnceLock, RwLock, +}; mod async_hooks_exports; mod callable_export_arity_table; @@ -1356,20 +1360,6 @@ pub(crate) fn test_take_bound_method_move() -> (usize, usize) { TEST_BOUND_METHOD_MOVE.with(|trace| trace.replace((0, 0))) } -/// Allocate a BOUND_METHOD closure binding `instance` as the receiver for the -/// named method, stamping its `.name`/`.length`. This is the raw builder used -/// by both `js_class_method_bind` (after its canonical-identity short-circuit) -/// and `class_prototype_method_value_for_name` (which caches one canonical per -/// `(class_id, name)`). Keeping it separate breaks the recursion that an -/// unconditional canonical lookup inside `js_class_method_bind` would create. -pub(crate) fn build_bound_method_closure( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, -) -> f64 { - build_bound_method_closure_with_private_brand(instance, method_name_ptr, method_name_len, None) -} - fn build_bound_method_closure_with_private_brand( instance: f64, method_name_ptr: *const u8, diff --git a/crates/perry-runtime/src/object/native_module/class_method_values.rs b/crates/perry-runtime/src/object/native_module/class_method_values.rs index f037c490e6..00bde3ed62 100644 --- a/crates/perry-runtime/src/object/native_module/class_method_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_method_values.rs @@ -15,7 +15,7 @@ pub(crate) fn class_evaluation_method_value_for_name( let scope = crate::gc::RuntimeHandleScope::new(); let brand = scope.root_nanbox_f64(evaluation_brand); - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); + let leaked = intern_class_method_name(owner_class_id, method_name); let method = build_bound_method_closure_with_private_brand( class_prototype_ref_value(owner_class_id), leaked.as_ptr(), @@ -54,7 +54,7 @@ pub(crate) fn class_private_static_method_value_for_name( let scope = crate::gc::RuntimeHandleScope::new(); let brand = scope.root_nanbox_f64(evaluation_brand); - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); + let leaked = intern_class_method_name(owner_class_id, method_name); let method = build_bound_method_closure_with_private_brand( class_constructor_ref_value(owner_class_id), leaked.as_ptr(), @@ -83,7 +83,7 @@ pub(crate) fn class_private_static_method_value_for_name( }) { return f64::from_bits(bits); } - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); + let leaked = intern_class_method_name(owner_class_id, method_name); let method = build_bound_method_closure_with_private_brand( class_constructor_ref_value(owner_class_id), leaked.as_ptr(), @@ -93,3 +93,37 @@ pub(crate) fn class_private_static_method_value_for_name( class_prototype_method_value_cache_root_store(owner_class_id, cache_name, method.to_bits()); method } +static CLASS_METHOD_NAME_INTERNER: OnceLock>> = + OnceLock::new(); + +/// Stable storage for the method-name pointer captured by bound-method +/// closures. The key set is bounded by the program's declared class methods, +/// even when one class expression is evaluated arbitrarily many times. +pub(super) fn intern_class_method_name(class_id: u32, method_name: &str) -> &'static [u8] { + let interner = CLASS_METHOD_NAME_INTERNER.get_or_init(|| RwLock::new(HashMap::new())); + let key = (class_id, method_name.to_string()); + if let Ok(guard) = interner.read() { + if let Some(bytes) = guard.get(&key).copied() { + return bytes; + } + } + let mut guard = interner + .write() + .expect("class method name interner poisoned"); + if let Some(bytes) = guard.get(&key).copied() { + return bytes; + } + let bytes: &'static [u8] = method_name.as_bytes().to_vec().leak(); + guard.insert(key, bytes); + bytes +} + +/// Allocate a bound-method closure for the named method. Keeping this raw +/// builder separate avoids recursion through the canonical method cache. +pub(crate) fn build_bound_method_closure( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, +) -> f64 { + build_bound_method_closure_with_private_brand(instance, method_name_ptr, method_name_len, None) +} diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index b9e08642de..e48cfd7b1f 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -127,7 +127,7 @@ pub fn class_prototype_method_value_for_name(class_id: u32, method_name: &str) - // `(class_id, method_name)` pair the program ever asks for, so the // total leak is bounded by the static set of decorated method // descriptors. The cache below short-circuits repeat queries. - let leaked: &'static [u8] = method_name.as_bytes().to_vec().leak(); + let leaked = intern_class_method_name(class_id, method_name); let class_ref = class_prototype_ref_value(class_id); // Build the closure DIRECTLY (not via `js_class_method_bind`, whose // canonical short-circuit would call back into this function and recurse). diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index e247e53f98..3aa6f2dd26 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -542,30 +542,41 @@ pub extern "C" fn js_object_define_property( let has_set = desc_has_field(descriptor_value, b"set"); if super::super::class_prototype_ref_id(obj_value).is_none() && (has_get || has_set) { + let descriptor_value = desc_handle.get_nanbox_f64(); let get_field = desc_read_field(descriptor_value, b"get"); - let set_field = desc_read_field(descriptor_value, b"set"); - let get_bits = if has_get && !get_field.is_undefined() { - get_field.bits() - } else { - 0 - }; - let set_bits = if has_set && !set_field.is_undefined() { - set_field.bits() - } else { - 0 - }; + let get_field = scope.root_nanbox_u64(get_field.bits()); + let set_field = desc_read_field(desc_handle.get_nanbox_f64(), b"set"); + let set_field = scope.root_nanbox_u64(set_field.bits()); + let get_bits = has_get.then(|| { + (get_field.get_nanbox_u64() != crate::value::TAG_UNDEFINED) + .then(|| get_field.get_nanbox_u64()) + .unwrap_or(0) + }); + let set_bits = has_set.then(|| { + (set_field.get_nanbox_u64() != crate::value::TAG_UNDEFINED) + .then(|| set_field.get_nanbox_u64()) + .unwrap_or(0) + }); + let class_value = f64::from_bits(obj_value_handle.get_heap_word_u64()); + let descriptor_value = desc_handle.get_nanbox_f64(); + let enumerable = desc_has_field(descriptor_value, b"enumerable") + .then(|| descriptor_enumerable(desc_handle.get_nanbox_f64())); + let descriptor_value = desc_handle.get_nanbox_f64(); + let configurable = + desc_has_field(descriptor_value, b"configurable").then(|| { + crate::value::js_is_truthy(f64::from_bits( + desc_read_field(desc_handle.get_nanbox_f64(), b"configurable") + .bits(), + )) != 0 + }); super::super::class_registry::register_class_dynamic_static_accessor( - target_cid, &name, get_bits, set_bits, - ); - super::super::class_registry::class_static_set_defined_attrs( target_cid, + class_value, &name, - false, - descriptor_enumerable(descriptor_value), - desc_has_field(descriptor_value, b"configurable") - && crate::value::js_is_truthy(f64::from_bits( - desc_read_field(descriptor_value, b"configurable").bits(), - )) != 0, + get_bits, + set_bits, + enumerable, + configurable, ); return obj_value; } diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index b98d70c8ab..cb655f9d23 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -389,14 +389,23 @@ pub unsafe extern "C" fn js_super_accessor_get( } } } - if let Some(result) = - crate::object::class_registry::class_dynamic_static_accessor_getter_value( - parent_class_id, - key_name, - receiver, - ) - { - return result; + let mut cid = parent_class_id; + let mut depth = 0usize; + while cid != 0 && depth < 32 { + if let Some(result) = + crate::object::class_registry::class_dynamic_static_accessor_getter_value( + cid, key_name, receiver, + ) + { + return result; + } + match crate::object::get_parent_class_id(cid) { + Some(parent) if parent != 0 && parent != cid => { + cid = parent; + depth += 1; + } + _ => break, + } } // (b) parent static data field (CLASS_DYNAMIC_PROPS), same walk. let mut cid = parent_class_id; @@ -422,6 +431,7 @@ pub unsafe extern "C" fn js_super_accessor_get( // own properties with the child constructor as Receiver. if let Some(child_id) = super::class_ref_id(receiver) { let parent = crate::object::js_get_dynamic_parent_value(child_id); + let parent_handle = scope.root_heap_word_u64(parent.to_bits()); let pv = crate::value::JSValue::from_bits(parent.to_bits()); if !pv.is_undefined() && !pv.is_null() { if let Some(key_name) = key_name.as_ref() { @@ -435,7 +445,11 @@ pub unsafe extern "C" fn js_super_accessor_get( } } } - return crate::proxy::js_reflect_get(parent, key_handle.get_nanbox_f64(), receiver); + return crate::proxy::js_reflect_get( + f64::from_bits(parent_handle.get_heap_word_u64()), + key_handle.get_nanbox_f64(), + f64::from_bits(receiver_handle.get_heap_word_u64()), + ); } } return f64::from_bits(crate::value::TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 63b0b37244..fe0797c5fa 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -207,13 +207,15 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, instance_ov unsafe { if let Some(obj) = meta_capable_object(obj_ptr) { // `object_meta_ensure` allocates and may evacuate the owner. Keep - // the caller's pointer rooted and reload it before the semantic - // ShapeId transition below. + // both the caller's pointer and the prototype rooted, then reload + // them before the stores below. let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); + let proto_handle = scope.root_heap_word_u64(proto_bits); let (meta, obj) = obj_handle.across_mut::(|| { crate::object::object_meta_ensure(obj) }); + let proto_bits = proto_handle.get_heap_word_u64(); (*meta).prototype = proto_bits; if instance_override { (*meta).flags |= crate::object::OBJECT_META_FLAG_PROTO_OVERRIDE; diff --git a/crates/perry-runtime/src/promise/subclass.rs b/crates/perry-runtime/src/promise/subclass.rs index 985569858a..5ad228b020 100644 --- a/crates/perry-runtime/src/promise/subclass.rs +++ b/crates/perry-runtime/src/promise/subclass.rs @@ -114,17 +114,22 @@ pub(crate) fn subclass_backing_promise(value: f64) -> Option<*mut Promise> { /// synchronously per step 2. #[no_mangle] pub extern "C" fn js_promise_subclass_init(this: f64, executor: f64) -> f64 { - let obj = match unsafe { instance_object_ptr(this) } { - Some(o) => o, - None => return this, - }; + let scope = crate::gc::RuntimeHandleScope::new(); + let this = scope.root_nanbox_f64(this); + let executor = scope.root_nanbox_f64(executor); + if unsafe { instance_object_ptr(this.get_nanbox_f64()) }.is_none() { + return this.get_nanbox_f64(); + } // 27.2.3.1 step 2: a non-callable executor throws a TypeError, before any // promise is created. - if !super::spec_combinators::is_callable_value(executor) { + if !super::spec_combinators::is_callable_value(executor.get_nanbox_f64()) { let msg = b"Promise resolver is not a function"; - let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err = crate::error::js_typeerror_new(s); + let s = scope.root_string_ptr(crate::string::js_string_from_bytes( + msg.as_ptr(), + msg.len() as u32, + )); + let err = s.with_mut_ptr::(|s| crate::error::js_typeerror_new(s)); let v = f64::from_bits(JSValue::pointer(err as *const u8).bits()); crate::exception::js_throw(v); } @@ -132,27 +137,35 @@ pub extern "C" fn js_promise_subclass_init(this: f64, executor: f64) -> f64 { // Build the backing promise + resolving functions, run the executor. Keep a // raw root on the backing cell across the string-key allocation below (which // can GC) by stashing it immediately after the executor runs. - let promise = super::js_promise_new(); - let (resolve_closure, reject_closure) = super::combinators::make_resolving_functions(promise); - let resolve_f64 = crate::value::js_nanbox_pointer(resolve_closure as i64); - let reject_f64 = crate::value::js_nanbox_pointer(reject_closure as i64); + let promise = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + super::js_promise_new() as i64 + )); + let promise_ptr = crate::value::js_nanbox_get_pointer(promise.get_nanbox_f64()) as *mut Promise; + let (resolve_closure, reject_closure) = + super::combinators::make_resolving_functions(promise_ptr); + let resolve = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(resolve_closure as i64)); + let reject = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(reject_closure as i64)); // 27.2.3.1 step 10: run the executor; a throw rejects the promise via the // shared resolving `reject` (so the [[AlreadyResolved]] guard makes a later // resolve/reject a no-op). `js_native_call_value` accepts both POINTER_TAG // closures and raw-pointer-bits closures, so `executor` is passed as-is. - let args = [resolve_f64, reject_f64]; + let args = [resolve.get_nanbox_f64(), reject.get_nanbox_f64()]; if let Err(reason) = super::combinators::combinator_catch_js(|| unsafe { - crate::closure::js_native_call_value(executor, args.as_ptr(), args.len()) + crate::closure::js_native_call_value(executor.get_nanbox_f64(), args.as_ptr(), args.len()) }) { - crate::closure::js_closure_call1(reject_closure, reason); + let reject = crate::value::js_nanbox_get_pointer(reject.get_nanbox_f64()) + as *const crate::closure::ClosureHeader; + crate::closure::js_closure_call1(reject, reason); } // #7795: arm the probe gate before the field exists, so no reader can // observe a stashed backing cell while the flag still says "never". PROMISE_SUBCLASS_EVER.store(true, std::sync::atomic::Ordering::Relaxed); let key = crate::string::js_string_from_bytes(BACKING_KEY.as_ptr(), BACKING_KEY.len() as u32); - let backing_bits = JSValue::pointer(promise as *const u8).bits(); + let obj = unsafe { instance_object_ptr(this.get_nanbox_f64()) } + .expect("rooted Promise subclass receiver must remain an object"); + let backing_bits = promise.get_nanbox_f64().to_bits(); js_object_set_field_by_name(obj, key, f64::from_bits(backing_bits)); - this + this.get_nanbox_f64() } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 465bdd9aec..5aa7f0a66b 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2195,8 +2195,7 @@ pub extern "C" fn js_super_put_value_set( // A base class's constructor inherits from Function.prototype. Perry // does not materialize that object for this path; when lookup misses, // OrdinarySet creates the own property on Receiver. - target_set(receiver, key, value); - return value; + return js_put_value_set(receiver, key, value, receiver, strict); } if parent_class_id == 0 && crate::object::is_class_object_value(receiver) { let obj = crate::value::JSValue::from_bits(receiver.to_bits()) @@ -2208,8 +2207,7 @@ pub extern "C" fn js_super_put_value_set( }; let dynamic_parent = crate::object::js_get_dynamic_parent_value(child_id); if crate::value::JSValue::from_bits(dynamic_parent.to_bits()).is_undefined() { - target_set(receiver, key, value); - return value; + return js_put_value_set(receiver, key, value, receiver, strict); } return js_put_value_set(dynamic_parent, key, value, receiver, strict); } diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 932d227f37..87413c5249 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -32,6 +32,9 @@ unsafe fn write_fast_path_receiver_kind_ok( if class_id == crate::object::NATIVE_MODULE_CLASS_ID { return false; } + if crate::object::is_class_object_ptr(obj.cast()) { + return false; + } class_id != 0 || obj_flags & crate::gc::OBJ_FLAG_PLAIN_ORDINARY != 0 } diff --git a/crates/perry-runtime/src/weakref/subclass.rs b/crates/perry-runtime/src/weakref/subclass.rs index f6f2ec3b76..c8e436c5a5 100644 --- a/crates/perry-runtime/src/weakref/subclass.rs +++ b/crates/perry-runtime/src/weakref/subclass.rs @@ -5,6 +5,7 @@ pub extern "C" fn js_weak_collection_subclass_init(this: f64, kind: i32, iterable: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let this = scope.root_nanbox_f64(this); + let iterable = scope.root_nanbox_f64(iterable); let raw = crate::value::js_nanbox_get_pointer(this.get_nanbox_f64()) as usize; let is_object = unsafe { crate::value::addr_class::try_read_gc_header(raw) @@ -13,6 +14,9 @@ pub extern "C" fn js_weak_collection_subclass_init(this: f64, kind: i32, iterabl if !is_object { return this.get_nanbox_f64(); } + let object = scope.root_raw_mut_ptr( + crate::value::js_nanbox_get_pointer(this.get_nanbox_f64()) as *mut ObjectHeader, + ); let entries = js_array_alloc(0); let entries = scope.root_raw_mut_ptr(entries); let key = crate::string::js_string_from_bytes(WEAK_ENTRIES_KEY.as_ptr(), 18); @@ -31,8 +35,8 @@ pub extern "C" fn js_weak_collection_subclass_init(this: f64, kind: i32, iterabl ) }); if kind == 0 { - js_weakmap_init_iterable(this.get_nanbox_f64(), iterable) + js_weakmap_init_iterable(this.get_nanbox_f64(), iterable.get_nanbox_f64()) } else { - js_weakset_init_iterable(this.get_nanbox_f64(), iterable) + js_weakset_init_iterable(this.get_nanbox_f64(), iterable.get_nanbox_f64()) } } diff --git a/crates/perry/tests/issue_5579_indirect_eval_global_completion.rs b/crates/perry/tests/issue_5579_indirect_eval_global_completion.rs index 858fae3072..20ff021610 100644 --- a/crates/perry/tests/issue_5579_indirect_eval_global_completion.rs +++ b/crates/perry/tests/issue_5579_indirect_eval_global_completion.rs @@ -103,6 +103,27 @@ fn indirect_eval_completion_value_global_script() { ); } +/// A script-level `var` binding and its `globalThis` property are the same +/// binding. Assignments lowered after the declaration must therefore remain +/// visible to indirect eval, which resolves through the global environment. +const VAR_ASSIGNMENT_MIRRORS_GLOBAL: &str = r#" +var x = 1; +x = 7; +console.log("eval.x:", (0, eval)("x")); +console.log("DONE"); +"#; + +#[test] +fn script_var_assignment_remains_visible_to_indirect_eval() { + let (ok, out) = compile_and_run(VAR_ASSIGNMENT_MIRRORS_GLOBAL, /* global_script */ true); + assert!(ok, "binary did not exit cleanly\n{out}"); + assert!( + out.contains("eval.x: 7"), + "assignment to a script `var` must update its global property\n{out}" + ); + assert!(out.contains("DONE"), "program must complete\n{out}"); +} + /// `cptn-nrml-expr-obj.js` shape: the eval body reads a global object and the /// completion is that very object (identity preserved). const CPTN_OBJ: &str = r#" diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index b0156607e1..9b7bed7706 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -22,13 +22,17 @@ # # Regenerate: python3 scripts/addr_class_inventory.py --write-baseline +handle-floor | crates/perry-ext-events/src/lib.rs | 3 +handle-floor | crates/perry-ext-exponential-backoff/src/lib.rs | 1 +handle-floor | crates/perry-ext-fastify/src/server.rs | 1 +handle-floor | crates/perry-ext-http/src/agent.rs | 3 +handle-floor | crates/perry-ext-http/src/lib.rs | 2 handle-floor | crates/perry-runtime/src/array/alloc.rs | 2 handle-floor | crates/perry-runtime/src/array/concat_reverse.rs | 1 handle-floor | crates/perry-runtime/src/array/flat_clone.rs | 4 handle-floor | crates/perry-runtime/src/array/generic.rs | 4 handle-floor | crates/perry-runtime/src/array/header.rs | 3 handle-floor | crates/perry-runtime/src/array/indexing.rs | 4 -handle-floor | crates/perry-runtime/src/array/iter_methods.rs | 2 handle-floor | crates/perry-runtime/src/array/iter_object.rs | 1 handle-floor | crates/perry-runtime/src/array/iterator.rs | 2 handle-floor | crates/perry-runtime/src/array/push_pop.rs | 1 @@ -105,7 +109,7 @@ handle-floor | crates/perry-runtime/src/object/buffer_dispatch.rs | 2 handle-floor | crates/perry-runtime/src/object/dataview_proto_thunks.rs | 1 handle-floor | crates/perry-runtime/src/object/delete_rest.rs | 1 handle-floor | crates/perry-runtime/src/object/descriptor_state.rs | 1 -handle-floor | crates/perry-runtime/src/object/descriptors.rs | 2 +handle-floor | crates/perry-runtime/src/object/descriptors.rs | 1 handle-floor | crates/perry-runtime/src/object/field_get_set/accessors.rs | 2 handle-floor | crates/perry-runtime/src/object/field_get_set/enumeration.rs | 4 handle-floor | crates/perry-runtime/src/object/field_get_set/field_ops.rs | 3 @@ -126,7 +130,6 @@ handle-floor | crates/perry-runtime/src/object/native_call_method/collection_met handle-floor | crates/perry-runtime/src/object/native_call_method/common_methods.rs | 1 handle-floor | crates/perry-runtime/src/object/native_call_method/handle_methods.rs | 2 handle-floor | crates/perry-runtime/src/object/native_call_method/primitive_methods.rs | 1 -handle-floor | crates/perry-runtime/src/object/native_module.rs | 1 handle-floor | crates/perry-runtime/src/object/native_module/namespace_builders.rs | 1 handle-floor | crates/perry-runtime/src/object/native_module/web_locks.rs | 1 handle-floor | crates/perry-runtime/src/object/object_literal_ops.rs | 1 @@ -142,7 +145,7 @@ handle-floor | crates/perry-runtime/src/object/polymorphic_index.rs | 2 handle-floor | crates/perry-runtime/src/object/property_key.rs | 1 handle-floor | crates/perry-runtime/src/object/prototype_chain.rs | 2 handle-floor | crates/perry-runtime/src/object/prototype_helpers.rs | 1 -handle-floor | crates/perry-runtime/src/object/reflect_support.rs | 3 +handle-floor | crates/perry-runtime/src/object/reflect_support.rs | 2 handle-floor | crates/perry-runtime/src/object/to_string_tag.rs | 10 handle-floor | crates/perry-runtime/src/object/typed_array_define.rs | 1 handle-floor | crates/perry-runtime/src/object/typed_array_proto_thunks.rs | 1 @@ -191,9 +194,6 @@ handle-floor | crates/perry-runtime/src/value/equality.rs | 2 handle-floor | crates/perry-runtime/src/value/nanbox.rs | 1 handle-floor | crates/perry-runtime/src/value/to_string.rs | 3 handle-floor | crates/perry-runtime/src/weakref.rs | 2 -handle-floor | crates/perry-stdlib/src/axios.rs | 1 -handle-floor | crates/perry-stdlib/src/container/mod.rs | 1 -handle-floor | crates/perry-stdlib/src/container/types.rs | 1 handle-floor | crates/perry-stdlib/src/crypto/kdf.rs | 4 handle-floor | crates/perry-stdlib/src/crypto/keys.rs | 1 handle-floor | crates/perry-stdlib/src/crypto/random.rs | 1 @@ -203,7 +203,6 @@ handle-floor | crates/perry-stdlib/src/domain.rs | 1 handle-floor | crates/perry-stdlib/src/events.rs | 1 handle-floor | crates/perry-stdlib/src/exponential_backoff.rs | 1 handle-floor | crates/perry-stdlib/src/fetch/dispatch.rs | 4 -handle-floor | crates/perry-stdlib/src/fetch/mod.rs | 1 handle-floor | crates/perry-stdlib/src/jsonwebtoken.rs | 1 handle-floor | crates/perry-stdlib/src/querystring.rs | 5 handle-floor | crates/perry-stdlib/src/readline/mod.rs | 1 @@ -213,13 +212,11 @@ handle-floor | crates/perry-stdlib/src/streams/byob.rs | 1 handle-floor | crates/perry-stdlib/src/streams/subclass.rs | 1 handle-floor | crates/perry-stdlib/src/streams/transform.rs | 1 handle-floor | crates/perry-stdlib/src/string_decoder.rs | 5 -handle-floor | crates/perry-stdlib/src/tls.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/aes.rs | 4 handle-floor | crates/perry-stdlib/src/webcrypto/hmac.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/jwk.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/supports.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/util.rs | 5 -handle-floor | crates/perry-stdlib/src/worker_threads.rs | 1 handle-floor | crates/perry-stdlib/src/zlib.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/array/subclass.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/buffer/access.rs | 1 @@ -229,9 +226,9 @@ lone-valid-obj-ptr | crates/perry-runtime/src/error.rs | 3 lone-valid-obj-ptr | crates/perry-runtime/src/intl.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/intl/ctor_guard.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/class_registry/class_meta.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/object/class_registry/construct.rs | 6 +lone-valid-obj-ptr | crates/perry-runtime/src/object/class_registry/construct.rs | 5 lone-valid-obj-ptr | crates/perry-runtime/src/object/class_registry/prototype_objects.rs | 1 -lone-valid-obj-ptr | crates/perry-runtime/src/object/descriptors.rs | 2 +lone-valid-obj-ptr | crates/perry-runtime/src/object/descriptors.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/accessors.rs | 2 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/enumeration.rs | 3 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/field_ops.rs | 1 @@ -271,19 +268,3 @@ lone-valid-obj-ptr | crates/perry-runtime/src/util_mime.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/util_style_text.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/value/dyn_index.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/value/to_string.rs | 1 - -# #7272: `crates/perry-ext-*` entered this gate's scope. These ten sites are -# not new code — they were invisible because the scan roots stopped at -# perry-runtime/perry-stdlib while the sibling gate -# (gc_store_site_inventory.py) had globbed the ext crates all along. -# -# Five of them are the HTTP server's, which #6826 moved out of -# crates/perry-stdlib/src/http.rs. This file used to carry an entry for that -# path, and once the file vanished the gate reported "baseline says 11, found -# 0 -- lower it to 0": an invitation to ratify a coverage loss as a fix. They -# are baselined here at their real counts instead. -handle-floor | crates/perry-ext-events/src/lib.rs | 3 -handle-floor | crates/perry-ext-exponential-backoff/src/lib.rs | 1 -handle-floor | crates/perry-ext-fastify/src/server.rs | 1 -handle-floor | crates/perry-ext-http/src/agent.rs | 3 -handle-floor | crates/perry-ext-http/src/lib.rs | 2 diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 274f72411f..3bda99c009 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -463,6 +463,21 @@ ], "_FRONTIER_README": "Identity-pinned ratchet over the perry-ui* crates and otherwise-unclassified core perry_thread_local! declarations (see the census docstring, 'The identity-pinned frontier'). Entries are debt baselines, not verdicts. A new uncovered holder fails until deliberately pinned, and a fixed holder fails until its stale entry is deleted. An optional scanner names cross-file coverage and must remain registered; deleting that registration invalidates the pin and fails the gate. Covered same-file holders must NOT be pinned.", "frontier": [ + { + "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs", + "name": "PRIVATE_METHOD_OWNER_HINT", + "why": "Only a compile-time class id and Rust-owned method-name String; no JS value or Perry heap address." + }, + { + "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs", + "name": "PRIVATE_MEMBER_ACCESS_HINTS", + "why": "Only compile-time class ids, Rust-owned Strings, and scalar flags; no JS value or Perry heap address." + }, + { + "file": "crates/perry-runtime/src/object/this_binding.rs", + "name": "DERIVED_SUPER_BINDING_STACK", + "why": "Pointers to one-byte LLVM alloca flags in active native constructor frames, not Perry heap objects. Native frames do not move; balanced push/pop and exception savepoint restoration prevent entries outliving their frames." + }, { "file": "crates/perry-runtime/src/array/element_shape.rs", "name": "ELEMENT_SHAPES" diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index ced7b1ba5b..f55dabd5d7 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -925 +922 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index 40becbb8ca..f9ec92b22a 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -89,7 +89,7 @@ 4 crates/perry-runtime/src/node_submodules/test.rs 33 crates/perry-runtime/src/object/alloc.rs 2 crates/perry-runtime/src/object/bigint_dispatch.rs -5 crates/perry-runtime/src/object/class_registry/construct.rs +3 crates/perry-runtime/src/object/class_registry/construct.rs 2 crates/perry-runtime/src/object/delete_rest.rs 12 crates/perry-runtime/src/object/descriptors.rs 5 crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -109,7 +109,7 @@ 26 crates/perry-runtime/src/object/native_module/callable_exports.rs 6 crates/perry-runtime/src/object/object_literal_ops.rs 2 crates/perry-runtime/src/object/object_ops/define_property.rs -3 crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs +2 crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs 4 crates/perry-runtime/src/object/object_ops/from_entries.rs 5 crates/perry-runtime/src/object/object_ops/keys_array.rs 6 crates/perry-runtime/src/object/polymorphic_index.rs diff --git a/test-files/test_issue_5893_private_brand_freshness.ts b/test-files/test_issue_5893_private_brand_freshness.ts index 3437d9b303..3cdcf91c89 100644 --- a/test-files/test_issue_5893_private_brand_freshness.ts +++ b/test-files/test_issue_5893_private_brand_freshness.ts @@ -212,3 +212,129 @@ function checkFreshStaticBrands(label: string, make: () => any): void { checkFreshStaticBrands("static expression", makeStaticClass); checkFreshStaticBrands("static declaration", makeStaticDeclarationClass); + +function makeOrderedStatics(label: string): any { + const events: string[] = []; + const key = (name: string): string => { + events.push("key-" + name); + return name; + }; + const C = class { + static #brand = 0; + + [key("method")](): void {} + + static [key("first")] = (events.push("init-first"), 1); + + static { + events.push("block"); + (this as any).fromBlock = label; + } + + static tail = (events.push("init-tail"), 2); + static missing; + }; + + check( + label + " computed/static order", + events.join(",") === + "key-method,key-first,init-first,block,init-tail" + ); + check(label + " static block this", C.fromBlock === label); + check( + label + " uninitialized static own", + Object.prototype.hasOwnProperty.call(C, "missing") && + C.missing === undefined + ); + return C; +} + +makeOrderedStatics("fresh order"); + +function makeDynamicAccessor(tag: string): any { + return class { + static #brand = 0; + + static install(): void { + Object.defineProperty(this, "dynamic", { + configurable: true, + enumerable: true, + get: () => tag, + set: (value: string) => { + tag = value; + }, + }); + } + + static readTag(): string { + return tag; + } + }; +} + +const accessorA = makeDynamicAccessor("a"); +const accessorB = makeDynamicAccessor("b"); +accessorA.install(); +accessorB.install(); +check("dynamic accessor evaluation A", accessorA.dynamic === "a"); +check("dynamic accessor evaluation B", accessorB.dynamic === "b"); +Object.defineProperty(accessorA, "dynamic", { + get: () => "fixed", +}); +const accessorDescriptor = Object.getOwnPropertyDescriptor( + accessorA, + "dynamic" +)!; +check( + "dynamic accessor retained halves", + typeof accessorDescriptor.set === "function" +); +check( + "dynamic accessor retained attrs", + accessorDescriptor.enumerable && accessorDescriptor.configurable +); +accessorA.dynamic = "changed"; +check("dynamic accessor retained setter", accessorA.readTag() === "changed"); +check("dynamic accessor sibling isolated", accessorB.dynamic === "b"); + +function makePrototypeParent(tag: string): any { + return class { + static #brand = 0; + + inherited(): string { + return tag; + } + }; +} + +function makePrototypeChild(parent: any): any { + return class extends parent { + static #brand = 0; + }; +} + +const prototypeParent = makePrototypeParent("parent"); +const prototypeChild = makePrototypeChild(prototypeParent); +check( + "fresh prototype parent link", + Object.getPrototypeOf(prototypeChild.prototype) === prototypeParent.prototype +); +check( + "fresh prototype inherited method", + new prototypeChild().inherited() === "parent" +); + +class HugeKeyBase {} +Object.defineProperty(HugeKeyBase.prototype, "9223372036854776000", { + value: "huge", +}); +class HugeKeyDerived extends HugeKeyBase { + read(): string { + return super[9223372036854775808]; + } +} +check("super huge numeric property key", new HugeKeyDerived().read() === "huge"); + +const boxedString = new String("payload"); +check("boxed String toString payload", boxedString.toString() === "payload"); +check("boxed String valueOf payload", boxedString.valueOf() === "payload"); From 9c2cd7e70fcc5cecc800a404cd19a9a279a2ad79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 11:03:28 +0200 Subject: [PATCH 02/17] fix computed member test initializers --- crates/perry-codegen/tests/native_proof_regressions.rs | 2 ++ crates/perry-transform/src/async_to_generator.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index edb39054ee..25833b877a 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -272,6 +272,7 @@ fn class_with_computed_member(id: u32, name: &str, fields: Vec) -> C }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); class } @@ -10421,6 +10422,7 @@ fn scalar_method_boolean_negative_module(case: &str) -> Module { }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); } "inherited_field_shadow" => { diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs index 21223e8f3f..edf43559ab 100644 --- a/crates/perry-transform/src/async_to_generator.rs +++ b/crates/perry-transform/src/async_to_generator.rs @@ -1951,6 +1951,7 @@ mod computed_and_field_async_tests { function: empty_fn(2, vec![Stmt::Expr(async_closure_with_await(70))]), is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); module.classes.push(class); From 104dcbc0e6a415ad1cf2169209a5a90528debe0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 10:56:40 +0200 Subject: [PATCH 03/17] fix runtime test runner TLS policy --- crates/perry-runtime/src/node_submodules/test_runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/node_submodules/test_runner.rs b/crates/perry-runtime/src/node_submodules/test_runner.rs index 4ce596a49e..bd2b6af912 100644 --- a/crates/perry-runtime/src/node_submodules/test_runner.rs +++ b/crates/perry-runtime/src/node_submodules/test_runner.rs @@ -100,7 +100,7 @@ enum HookKind { AfterEach, } -thread_local! { +crate::perry_thread_local! { static TEST_RUNNER: RefCell = RefCell::new(RunnerState::new()); static ACTIVE_CHILDREN: RefCell>> = const { RefCell::new(Vec::new()) }; static ACTIVE_ANCESTORS: RefCell> = const { RefCell::new(Vec::new()) }; From 1c7fc394c74ef8bad86fd3848d8a0320c8131af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 12:00:34 +0200 Subject: [PATCH 04/17] fix follow-up class semantics gates --- changelog.d/5893-class-semantics-followup.md | 2 ++ crates/perry-runtime/src/gc/mod.rs | 2 ++ crates/perry-runtime/src/node_submodules/mod.rs | 2 +- crates/perry-runtime/src/node_submodules/test.rs | 3 +-- .../perry-runtime/src/node_submodules/test_runner.rs | 7 +------ .../src/object/object_ops/descriptor_helpers.rs | 11 ++++++----- crates/perry-runtime/src/weakref/subclass.rs | 3 --- 7 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 changelog.d/5893-class-semantics-followup.md diff --git a/changelog.d/5893-class-semantics-followup.md b/changelog.d/5893-class-semantics-followup.md new file mode 100644 index 0000000000..57a4ad47a3 --- /dev/null +++ b/changelog.d/5893-class-semantics-followup.md @@ -0,0 +1,2 @@ +Completed the remaining #5893 per-evaluation class semantics for static accessors, +captured state, derived construction, prototype identity, and private methods. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index ce7b02b9ae..3068562925 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -982,6 +982,8 @@ pub fn gc_init() { // (nothing else holds them — they live for the program's lifetime // via codegen `getter` calls, not via a user-visible JSValue root). reg_scanner!(crate::node_submodules::scan_node_submodule_singleton_roots_mut,); + #[cfg(feature = "mod-node-test")] + reg_scanner!(crate::node_submodules::test::runner::scan_node_test_runner_roots_mut,); // Box-capture root scanner (mutable closure captures, esp. the // generator state-machine's `__iter` and `__step` boxes that hold // the iter object + step closure across awaits). diff --git a/crates/perry-runtime/src/node_submodules/mod.rs b/crates/perry-runtime/src/node_submodules/mod.rs index 6fc33b976d..724988489b 100644 --- a/crates/perry-runtime/src/node_submodules/mod.rs +++ b/crates/perry-runtime/src/node_submodules/mod.rs @@ -115,7 +115,7 @@ mod fs_promises; mod hono_jsx; mod stream_promises; #[cfg(feature = "mod-node-test")] -mod test; +pub(crate) mod test; mod timers; mod trace_events; mod zlib; diff --git a/crates/perry-runtime/src/node_submodules/test.rs b/crates/perry-runtime/src/node_submodules/test.rs index 599c1ab3b2..28b17a1a1f 100644 --- a/crates/perry-runtime/src/node_submodules/test.rs +++ b/crates/perry-runtime/src/node_submodules/test.rs @@ -24,7 +24,7 @@ mod property_mock; #[path = "test_reporters.rs"] mod reporters; #[path = "test_runner.rs"] -mod runner; +pub(crate) mod runner; #[path = "test_snapshot.rs"] mod snapshot; @@ -1707,7 +1707,6 @@ pub(crate) fn scan_test_module_roots_mut(visitor: &mut crate::gc::RuntimeRootVis } }); property_mock::scan_roots_mut(visitor); - runner::scan_roots_mut(visitor); } #[cfg(test)] diff --git a/crates/perry-runtime/src/node_submodules/test_runner.rs b/crates/perry-runtime/src/node_submodules/test_runner.rs index bd2b6af912..a1c4a17821 100644 --- a/crates/perry-runtime/src/node_submodules/test_runner.rs +++ b/crates/perry-runtime/src/node_submodules/test_runner.rs @@ -103,8 +103,6 @@ enum HookKind { crate::perry_thread_local! { static TEST_RUNNER: RefCell = RefCell::new(RunnerState::new()); static ACTIVE_CHILDREN: RefCell>> = const { RefCell::new(Vec::new()) }; - static ACTIVE_ANCESTORS: RefCell> = const { RefCell::new(Vec::new()) }; - static DONE_CALLED: Cell = const { Cell::new(false) }; } fn ensure_runner_scheduled() { @@ -364,7 +362,6 @@ fn take_directive_reason( } extern "C" fn test_done(_closure: *const ClosureHeader, error: f64) -> f64 { - DONE_CALLED.with(|called| called.set(true)); if !is_undefined_value(error) && !JSValue::from_bits(error.to_bits()).is_null() { crate::exception::js_throw(error); } @@ -381,7 +378,6 @@ fn call_test_callback(callback: f64, name: &str) -> Result { let arity = crate::closure::closure_length(callback_ptr).unwrap_or(0); catch_js(|| { if arity >= 2 { - DONE_CALLED.with(|called| called.set(false)); let done = scope.root_nanbox_f64(closure_value(test_done as *const u8, 1)); crate::closure::js_closure_call2( callback_ptr, @@ -526,7 +522,6 @@ fn execute_test(def: TestDef, ancestors: &[usize], blocked: bool) -> TestResult let prior = enter_context(&def.name); ACTIVE_CHILDREN.with(|children| children.borrow_mut().push(Vec::new())); - ACTIVE_ANCESTORS.with(|slot| *slot.borrow_mut() = ancestors.to_vec()); if !failed { match call_test_callback(def.callback, &def.name) { Ok(value) => { @@ -921,7 +916,7 @@ pub extern "C" fn js_node_test_run(options: f64) -> f64 { thunk_test_run(std::ptr::null(), options) } -pub(super) fn scan_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { +pub(crate) fn scan_node_test_runner_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { TEST_RUNNER.with(|runner| { let mut runner = runner.borrow_mut(); for test in &mut runner.tests { diff --git a/crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs b/crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs index f9b31b6c3f..f09f4ac277 100644 --- a/crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs +++ b/crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs @@ -815,9 +815,10 @@ pub(crate) unsafe fn define_property_force_store_value( } } // Re-fetch after a possible evacuation, then restore the immutability bits. - obj = obj_handle.get_raw_mut_ptr::(); - if !obj.is_null() && (obj as usize) > 0x10000 { - let gc = gc_header_for(obj); - (*gc)._reserved = ((*gc)._reserved & !immutability) | (saved & immutability); - } + obj_handle.with_mut_ptr::(|obj| { + if !obj.is_null() && (obj as usize) > 0x10000 { + let gc = gc_header_for(obj); + (*gc)._reserved = ((*gc)._reserved & !immutability) | (saved & immutability); + } + }); } diff --git a/crates/perry-runtime/src/weakref/subclass.rs b/crates/perry-runtime/src/weakref/subclass.rs index c8e436c5a5..94041d07c7 100644 --- a/crates/perry-runtime/src/weakref/subclass.rs +++ b/crates/perry-runtime/src/weakref/subclass.rs @@ -14,9 +14,6 @@ pub extern "C" fn js_weak_collection_subclass_init(this: f64, kind: i32, iterabl if !is_object { return this.get_nanbox_f64(); } - let object = scope.root_raw_mut_ptr( - crate::value::js_nanbox_get_pointer(this.get_nanbox_f64()) as *mut ObjectHeader, - ); let entries = js_array_alloc(0); let entries = scope.root_raw_mut_ptr(entries); let key = crate::string::js_string_from_bytes(WEAK_ENTRIES_KEY.as_ptr(), 18); From 9a1ade4c7fdb172d02f6eeec09be5a439be955a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 13:16:10 +0200 Subject: [PATCH 05/17] fix remaining class freshness review --- crates/perry-hir/src/lower/expr_member.rs | 12 +++- crates/perry-hir/src/lower/tests.rs | 53 +++++++++++++++++ crates/perry-hir/src/lower_decl/body_stmt.rs | 11 +++- .../src/object/class_constructors.rs | 20 +++++-- ...test_issue_5893_private_brand_freshness.ts | 57 ++++++++++++++++++- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 837ac06067..e71f411bfe 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -1034,8 +1034,16 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re // body-local colliding `class X` registers under `class_renames`, and // the raw name would bind the FIRST same-named registrant's statics. if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() { - let obj_name = ctx.resolve_class_name(obj_ident.sym.as_ref()); - if ctx.lookup_class(&obj_name).is_some() { + let source_name = obj_ident.sym.as_ref(); + // A fresh nested class declaration binds its evaluated heap class + // object to a real local. That local's own statics are per evaluation, + // so reading through the shared template's `StaticFieldGet` loses both + // its value and its property-presence semantics. This mirrors the + // static-call guard in `expr_call/static_and_instance.rs`. + let local_shadows_class = ctx.lookup_local(source_name).is_some() + && !ctx.inferred_class_bindings.contains(source_name); + 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 { let field_name = prop_ident.sym.to_string(); if ctx.has_static_field(&obj_name, &field_name) { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index f5d8de263d..581cb24155 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -882,6 +882,59 @@ fn nested_class_shadowing_outer_var_constructs_the_class_not_the_local() { ); } +/// A collision-safe registration key is compiler-internal; the evaluated +/// class declaration must still bind and read through its source-level name. +#[test] +fn fresh_class_declaration_collision_keeps_lexical_binding() { + let source = r#" + function first() { + class C { #x = 1; } + return C; + } + function second() { + class C { #x = 2; static missing; } + const value = C.missing; + return C; + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let second = hir + .functions + .iter() + .find(|function| function.name == "second") + .expect("second function lowers"); + let (binding_id, template) = second + .body + .iter() + .find_map(|stmt| match stmt { + crate::Stmt::Let { + id, + name, + init: Some(crate::Expr::ClassExprFresh { template, .. }), + .. + } if name == "C" => Some((*id, template.as_str())), + _ => None, + }) + .expect("fresh class is bound under source name"); + assert_ne!(template, "C", "second template should be collision-renamed"); + assert!(second.body.iter().any(|stmt| { + matches!(stmt, crate::Stmt::Return(Some(crate::Expr::LocalGet(id))) if *id == binding_id) + })); + assert!(second.body.iter().any(|stmt| { + matches!( + stmt, + crate::Stmt::Let { + name, + init: Some(crate::Expr::PropertyGet { object, property, .. }), + .. + } if name == "value" + && property == "missing" + && matches!(object.as_ref(), crate::Expr::LocalGet(id) if *id == binding_id) + ) + })); +} + /// Companion (the case the depth rule must NOT break): a module-scope `class e` /// and a factory-local `let e` holding a different constructor. JS says the /// nearer local wins, so `new e()` inside the factory must still construct the diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index c1d9e96418..12cdf7868f 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -411,11 +411,18 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result(); if arr.is_null() { - (std::ptr::null(), 0) + None } else { - (arr, crate::array::js_array_length(arr)) + Some(scope.root_raw_const_ptr(arr)) } } else { - (std::ptr::null(), 0) + None }; + let n_caps = caps_arr_handle.as_ref().map_or(0, |handle| { + handle.with_const_ptr::(|arr| { + crate::array::js_array_length(arr) + }) + }); // A class DECLARATION reached as a heap class object (webpack interop: // `t["default"] = PQueue` read back cross-module) has no per-evaluation @@ -1279,7 +1284,12 @@ pub(crate) unsafe fn replay_class_object_constructor( // (class DECLARATIONS reached as heap values), undefined last. for slot in 0..sig_caps as usize { let v = if (slot as u32) < n_caps { - crate::array::js_array_get_f64(caps_arr, slot as u32) + caps_arr_handle + .as_ref() + .expect("non-empty capture array should have a runtime handle") + .with_const_ptr::(|caps_arr| { + crate::array::js_array_get_f64(caps_arr, slot as u32) + }) } else if let Some(bits) = snapshot_caps.get(slot) { f64::from_bits(*bits) } else { diff --git a/test-files/test_issue_5893_private_brand_freshness.ts b/test-files/test_issue_5893_private_brand_freshness.ts index 3cdcf91c89..faa76235e3 100644 --- a/test-files/test_issue_5893_private_brand_freshness.ts +++ b/test-files/test_issue_5893_private_brand_freshness.ts @@ -91,6 +91,10 @@ function checkFreshBrands(label: string, make: () => any): void { check(label + " own getter", second.readGetter(second) === "test262"); first.writeSetter(first, "changed"); check(label + " own setter", first.readGetter(first) === "changed"); + check( + label + " setter isolation", + second.readGetter(second) === "test262" + ); check(label + " own in", first.hasValue(first)); check(label + " cross-evaluation in", !first.hasValue(second)); @@ -106,6 +110,14 @@ function checkFreshBrands(label: string, make: () => any): void { label + " cross-evaluation setter", throwsTypeError(() => first.writeSetter(second, "wrong")) ); + check( + label + " failed setter isolation", + second.readGetter(second) === "test262" + ); + check( + label + " own state after throw", + first.readGetter(first) === "changed" + ); } checkFreshBrands("declaration", makeDeclarationInstance); @@ -190,6 +202,7 @@ function checkFreshStaticBrands(label: string, make: () => any): void { check(label + " own getter", second.accessGetter() === "test262"); first.accessSetter("changed"); check(label + " own setter", first._written === "changed"); + check(label + " setter isolation", second._written === ""); check(label + " own in", first.hasValue(first)); check(label + " cross-evaluation in", !first.hasValue(second)); check( @@ -208,6 +221,8 @@ function checkFreshStaticBrands(label: string, make: () => any): void { label + " cross-evaluation setter", throwsTypeError(() => first.accessSetter.call(second, "wrong")) ); + check(label + " failed setter isolation", second._written === ""); + check(label + " own state after throw", first._written === "changed"); } checkFreshStaticBrands("static expression", makeStaticClass); @@ -243,14 +258,52 @@ function makeOrderedStatics(label: string): any { check(label + " static block this", C.fromBlock === label); check( label + " uninitialized static own", - Object.prototype.hasOwnProperty.call(C, "missing") && - C.missing === undefined + Object.prototype.hasOwnProperty.call(C, "missing") ); + check(label + " uninitialized static value", C.missing === undefined); return C; } makeOrderedStatics("fresh order"); +function makeOrderedDeclarationStatics(label: string): any { + const events: string[] = []; + const key = (name: string): string => { + events.push("key-" + name); + return name; + }; + class C { + static #brand = 0; + + [key("method")](): void {} + + static [key("first")] = (events.push("init-first"), 1); + + static { + events.push("block"); + (this as any).fromBlock = label; + } + + static tail = (events.push("init-tail"), 2); + static missing; + } + + check( + label + " computed/static order", + events.join(",") === + "key-method,key-first,init-first,block,init-tail" + ); + check(label + " static block this", C.fromBlock === label); + check( + label + " uninitialized static own", + Object.prototype.hasOwnProperty.call(C, "missing") + ); + check(label + " uninitialized static value", C.missing === undefined); + return C; +} + +makeOrderedDeclarationStatics("fresh declaration order"); + function makeDynamicAccessor(tag: string): any { return class { static #brand = 0; From 9957cbd201dfc71ed4102bb3a5e8a01b150c8aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 14:02:12 +0200 Subject: [PATCH 06/17] address final class semantics review --- Cargo.lock | 1 - Cargo.toml | 1 - changelog.d/5893-class-semantics-followup.md | 2 - .../src/lower/lower_expr/arm_class.rs | 74 ++++++------ .../src/lower/shared_mutable_capture.rs | 105 ++++++++++++------ crates/perry-parser/Cargo.toml | 1 - crates/perry-parser/src/lib.rs | 93 ++-------------- .../src/gc/tests/copying_side_tables.rs | 8 ++ .../src/object/class_registry.rs | 9 +- .../class_registry/construct/class_return.rs | 1 + .../src/object/class_registry/gc_roots.rs | 53 +++++++++ .../perry-runtime/src/object/descriptors.rs | 9 +- .../field_get_set/class_object_props.rs | 6 +- .../src/object/field_get_set/enumeration.rs | 9 +- .../object/field_get_set/get_field_by_name.rs | 6 +- .../src/object/field_set_by_name.rs | 2 +- .../src/object/global_this/fetch_globals.rs | 17 +-- scripts/gc_runtime_root_holders.json | 15 --- ...test_issue_5893_private_brand_freshness.ts | 83 ++++++++++++++ 19 files changed, 306 insertions(+), 189 deletions(-) delete mode 100644 changelog.d/5893-class-semantics-followup.md diff --git a/Cargo.lock b/Cargo.lock index fe47c4c86e..56cb11a139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6291,7 +6291,6 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_parser 32.0.0", - "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/Cargo.toml b/Cargo.toml index d477a7be10..2623e8a948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -343,7 +343,6 @@ not_unsafe_ptr_arg_deref = "allow" # SWC for TypeScript parsing swc_ecma_parser = "32.0" swc_ecma_ast = "19.0" -swc_ecma_visit = "19.0" swc_common = "18.0" swc_ecma_codegen = "21.0" swc_ecma_transforms_base = "32.0" diff --git a/changelog.d/5893-class-semantics-followup.md b/changelog.d/5893-class-semantics-followup.md deleted file mode 100644 index 57a4ad47a3..0000000000 --- a/changelog.d/5893-class-semantics-followup.md +++ /dev/null @@ -1,2 +0,0 @@ -Completed the remaining #5893 per-evaluation class semantics for static accessors, -captured state, derived construction, prototype identity, and private methods. diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 066447ef2f..cb05f94d9b 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -370,40 +370,46 @@ pub(crate) fn lower_class_expr( }); } seq.extend(computed_member_registrations); - for (slot, v) in computed_statics { - seq.push(Expr::RegisterClassStaticSymbol { - class_name: synthetic_name.clone(), - key_expr: Box::new(Expr::PropertyGet { - object: Box::new(Expr::ClassRef(synthetic_name.clone())), - property: slot, - byte_offset: 0, - }), - value_expr: Box::new(v), - }); - } - // Inline the named static field/element initializers at the point - // the class expression evaluates (source order), mirroring the - // class-declaration path. Without this the shared-template path - // relied solely on the late `init_static_fields_late` pass, which - // runs AFTER the surrounding top-level statements — so a read like - // `C.x` immediately after `var C = class { static x = 1 }` saw the - // uninitialized (0.0) slot. (Private statics carry a `#`-prefixed - // name and flow through the same StaticFieldSet path.) - for (name, v) in named_statics { - seq.push(Expr::StaticFieldSet { - class_name: synthetic_name.clone(), - field_name: name, - value: Box::new(v), - }); - } - // Static blocks run right after the static-field initializers, in - // source order, with the class as `this`. - for block_name in static_block_names { - seq.push(Expr::StaticMethodCall { - class_name: synthetic_name.clone(), - method_name: block_name, - args: Vec::new(), - }); + // The shared-template path must obey the same source-order plan as the + // fresh-object path. Computed names were all resolved above, but their + // initializers still interleave with named fields and static blocks. + for step in static_init_order { + match step { + ClassFreshStaticInit::Named(index) => { + let Some((name, value)) = named_statics.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::StaticFieldSet { + class_name: synthetic_name.clone(), + field_name: name, + value: Box::new(value), + }); + } + ClassFreshStaticInit::Computed(index) => { + let Some((slot, value)) = computed_statics.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::RegisterClassStaticSymbol { + class_name: synthetic_name.clone(), + key_expr: Box::new(Expr::PropertyGet { + object: Box::new(Expr::ClassRef(synthetic_name.clone())), + property: slot, + byte_offset: 0, + }), + value_expr: Box::new(value), + }); + } + ClassFreshStaticInit::Block(index) => { + let Some(block_name) = static_block_names.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::StaticMethodCall { + class_name: synthetic_name.clone(), + method_name: block_name, + args: Vec::new(), + }); + } + } } if seq.is_empty() { Ok(Expr::ClassRef(synthetic_name)) diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 4fc004ce2f..0a90e6cac9 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -71,6 +71,12 @@ fn is_cap_name_of(name: &str, ids: &HashSet) -> bool { crate::cap_fields::cap_field_outer_id(name).is_some_and(|id| ids.contains(&id)) } +#[derive(Default)] +struct BodySharedCaptures { + ids: HashSet, + by_class: HashMap>, +} + pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Bisection escape hatch (#5951): disable the desugar to isolate its effect. if std::env::var("PERRY_NO_5951").is_ok() { @@ -103,7 +109,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { .iter() .map(|c| (c.name.as_str(), c)) .collect(); - let fn_shared: Vec> = module + let fn_shared: Vec = module .functions .iter() .map(|f| detect_shared_in_body(&f.body, &classes)) @@ -115,8 +121,8 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // once across deep `Let`s + nested closure params — see // `retain_unambiguous`). Nested closures restart their id spaces, so a // numeric rewrite over the whole body is only sound for unique ids. - for (f, s) in module.functions.iter().zip(fn_shared.iter_mut()) { - if s.is_empty() { + for (f, shared) in module.functions.iter().zip(fn_shared.iter_mut()) { + if shared.ids.is_empty() { continue; } let mut counts: HashMap = HashMap::new(); @@ -126,26 +132,46 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { for st in &f.body { collect_declared_counts_stmt(st, &mut counts); } - retain_unambiguous(s, &counts); + retain_unambiguous(&mut shared.ids, &counts); + let retained = &shared.ids; + for ids in shared.by_class.values_mut() { + ids.retain(|id| retained.contains(id)); + } + shared.by_class.retain(|_, ids| !ids.is_empty()); } - if !init_shared.is_empty() { + if !init_shared.ids.is_empty() { let mut counts: HashMap = HashMap::new(); for st in &module.init { collect_declared_counts_stmt(st, &mut counts); } - retain_unambiguous(&mut init_shared, &counts); + retain_unambiguous(&mut init_shared.ids, &counts); + let retained = &init_shared.ids; + for ids in init_shared.by_class.values_mut() { + ids.retain(|id| retained.contains(id)); + } + init_shared.by_class.retain(|_, ids| !ids.is_empty()); } - let mut all_shared: HashSet = init_shared.iter().copied().collect(); - for s in &fn_shared { - all_shared.extend(s.iter().copied()); + let mut all_shared: HashSet = init_shared.ids.iter().copied().collect(); + for shared in &fn_shared { + all_shared.extend(shared.ids.iter().copied()); } if all_shared.is_empty() { return; } + let mut shared_by_class: HashMap> = HashMap::new(); + for shared in fn_shared.iter().chain(std::iter::once(&init_shared)) { + for (class_name, ids) in &shared.by_class { + shared_by_class + .entry(class_name.clone()) + .or_default() + .extend(ids.iter().copied()); + } + } // ---- declaring bodies: rewrite with ONLY the ids detected in them ------- - for (f, s) in module.functions.iter_mut().zip(fn_shared.iter()) { - if !s.is_empty() { + for (f, shared) in module.functions.iter_mut().zip(fn_shared.iter()) { + let ids = &shared.ids; + if !ids.is_empty() { // Parameters have no `Stmt::Let` for `rewrite_stmt` to wrap. Turn // each flagged parameter into the same one-element shared cell at // function entry, then let the already-rewritten body use @@ -158,7 +184,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { .params .iter_mut() .filter_map(|param| { - if s.contains(¶m.id) { + if ids.contains(¶m.id) { param.ty = Type::Any; Some(param.id) } else { @@ -166,7 +192,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } }) .collect(); - rewrite_stmts(&mut f.body, s, s); + rewrite_stmts(&mut f.body, ids, ids); for id in shared_params.into_iter().rev() { f.body.insert( 0, @@ -178,8 +204,8 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } } } - if !init_shared.is_empty() { - rewrite_stmts(&mut module.init, &init_shared, &init_shared); + if !init_shared.ids.is_empty() { + rewrite_stmts(&mut module.init, &init_shared.ids, &init_shared.ids); } // ---- lifted class members: per-member rebind ids ------------------------ @@ -190,9 +216,9 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Match them BY NAME within each member and rewrite only that member's body // with its own ids (never the declaring `shared` set — the declaring `Let` // that gets array-wrapped lives outside the class). - let targets: &HashSet = &all_shared; let no_shared: HashSet = HashSet::new(); for c in &mut module.classes { + let targets = shared_by_class.get(&c.name).unwrap_or(&no_shared); for m in &mut c.methods { rewrite_member_scoped(m, &targets, &no_shared); } @@ -272,17 +298,17 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // handle — #5951 e4). Retype them to `Any` so they use the generic pointer // representation, matching the array they now hold. if std::env::var("PERRY_5951_NO_RETYPE").is_err() { - retype_capture_holders(module, &all_shared); + retype_capture_holders(module, &shared_by_class); } if std::env::var("PERRY_5951_TRACE").as_deref() == Ok("1") { let mut per_fn: Vec = Vec::new(); - for (f, s) in module.functions.iter().zip(fn_shared.iter()) { - if !s.is_empty() { - per_fn.push(format!("{}:{:?}", f.name, s)); + for (f, shared) in module.functions.iter().zip(fn_shared.iter()) { + if !shared.ids.is_empty() { + per_fn.push(format!("{}:{:?}", f.name, shared.ids)); } } - if !init_shared.is_empty() { - per_fn.push(format!(":{init_shared:?}")); + if !init_shared.ids.is_empty() { + per_fn.push(format!(":{:?}", init_shared.ids)); } eprintln!( "[5951] module={} desugared {}", @@ -384,9 +410,13 @@ fn collect_declared_counts_expr(expr: &Expr, out: &mut HashMap) { walk_expr_children(expr, &mut |e| collect_declared_counts_expr(e, out)); } -fn retype_capture_holders(module: &mut Module, shared: &HashSet) { - let targets: &HashSet = shared; +fn retype_capture_holders( + module: &mut Module, + shared_by_class: &HashMap>, +) { + let no_shared = HashSet::new(); for c in &mut module.classes { + let targets = shared_by_class.get(&c.name).unwrap_or(&no_shared); for f in &mut c.fields { if is_cap_name_of(&f.name, targets) { f.ty = Type::Any; @@ -492,8 +522,8 @@ fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { /// Detect the shared-mutable capture ids declared in ONE body. The returned /// ids are meaningful only within that body's scope — callers must not apply /// them to other functions (LocalIds repeat across scopes; see #6089). -fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> HashSet { - let mut shared = HashSet::new(); +fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> BodySharedCaptures { + let mut shared = BodySharedCaptures::default(); let mut regs = Vec::new(); for s in body { find_regs_stmt(s, &mut regs); @@ -505,21 +535,34 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Hash for s in body { collect_assigned_deep_stmt(s, &mut assigned); } - for (class_name, ids) in regs { + for (class_name, ids) in ®s { for id in ids { // Declaring-function-side mutation (`c = 99` after `new T()`). - if assigned.contains(&id) { - shared.insert(id); + if assigned.contains(id) { + shared.ids.insert(*id); continue; } // Class-side mutation: a member assigns rebind local `__perry_cap_`. if let Some(c) = classes.get(class_name.as_str()) { - if class_mutates_capture(c, id) { - shared.insert(id); + if class_mutates_capture(c, *id) { + shared.ids.insert(*id); } } } } + // Every class that captures a boxed id must treat its synthesized holder + // as the array handle, even if a sibling class is the one that mutates it. + for (class_name, ids) in regs { + for id in ids { + if shared.ids.contains(&id) { + shared + .by_class + .entry(class_name.clone()) + .or_default() + .insert(id); + } + } + } shared } diff --git a/crates/perry-parser/Cargo.toml b/crates/perry-parser/Cargo.toml index b11bbab19c..1ac263a749 100644 --- a/crates/perry-parser/Cargo.toml +++ b/crates/perry-parser/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] swc_ecma_parser.workspace = true swc_ecma_ast.workspace = true -swc_ecma_visit.workspace = true swc_common.workspace = true thiserror.workspace = true diff --git a/crates/perry-parser/src/lib.rs b/crates/perry-parser/src/lib.rs index b9fb8ccc60..84ab3c9699 100644 --- a/crates/perry-parser/src/lib.rs +++ b/crates/perry-parser/src/lib.rs @@ -9,7 +9,6 @@ use std::path::Path; use swc_common::{input::StringInput, sync::Lrc, FileName, SourceMap}; use swc_ecma_ast::{Module, ModuleItem, Script}; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, Syntax, TsSyntax}; -use swc_ecma_visit::{VisitMut, VisitMutWith}; // Re-export AST types for consumers that need to inspect the AST pub use swc_ecma_ast; @@ -48,8 +47,7 @@ pub fn parse_typescript_with_cache( cache: &mut SourceCache, ) -> Result { let unicode_source = normalize_unicode_identifier_escapes(source); - let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); - let parse_source = normalized.source; + let parse_source = normalize_swc_class_syntax(&unicode_source); // Add the source to the cache let file_id = cache.add_file(filename, source.to_string()); @@ -61,7 +59,7 @@ pub fn parse_typescript_with_cache( ); let mut diagnostics = Diagnostics::new(); - let (mut module, mut parser) = + let (module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &parse_source).map_err( |e| { // Convert SWC error to our diagnostic @@ -74,12 +72,6 @@ pub fn parse_typescript_with_cache( anyhow::anyhow!("Parse error: {}", e.kind().msg()) }, )?; - restore_await_class_identifiers( - &mut module, - source_file.start_pos.0, - &normalized.await_name_starts, - ); - // Collect recoverable errors as warnings for error in parser.take_errors() { let span = Span::new(file_id, error.span().lo.0, error.span().hi.0); @@ -106,23 +98,16 @@ pub fn parse_typescript_with_cache( /// For new code, prefer `parse_typescript_with_cache` for better diagnostics. pub fn parse_typescript(source: &str, filename: &str) -> Result { let unicode_source = normalize_unicode_identifier_escapes(source); - let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); - let parse_source = normalized.source; + let parse_source = normalize_swc_class_syntax(&unicode_source); let source_map: Lrc = Default::default(); let source_file = source_map.new_source_file( Lrc::new(FileName::Custom(filename.to_string())), parse_source, ); - let (mut module, mut parser) = + let (module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &source_file.src) .map_err(|e| anyhow::anyhow!("Parse error: {:?}", e))?; - restore_await_class_identifiers( - &mut module, - source_file.start_pos.0, - &normalized.await_name_starts, - ); - // Check for recoverable errors for error in parser.take_errors() { eprintln!("Parse warning: {:?}", error); @@ -857,15 +842,7 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { /// Normalize two valid class grammar corners that SWC currently rejects. /// String/comment contents are masked before tokenization, so source text that /// merely mentions these spellings is never rewritten. -struct NormalizedClassSyntax { - source: String, - /// Byte offsets in the normalized source where SWC sees the synthetic - /// `_wait` class identifier. The AST is restored to the source spelling - /// after parsing so `.name` and the class-body inner binding stay correct. - await_name_starts: Vec, -} - -fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSyntax { +fn normalize_swc_class_syntax(source: &str) -> String { #[derive(Clone, Copy)] struct Token<'a> { start: usize, @@ -928,12 +905,14 @@ fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSynt .get(index + 1) .is_some_and(|next| matches!(next.text, "{" | "extends")) { - // Script grammar permits `await` as a BindingIdentifier here. - // Keep the replacement byte-for-byte the same length. + // Script grammar permits `await` as a BindingIdentifier here, but + // SWC tokenizes the plain spelling as the contextual keyword. + // Escaping the leading `a` makes SWC retain the source identifier + // value (`await`) in the AST without a post-parse visitor. replacements.push(( source_offset_for_masked[token.start], source_offset_for_masked[token.end], - "_wait", + r"\u0061wait", )); continue; } @@ -962,59 +941,11 @@ fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSynt } } - let await_name_starts = replacements - .iter() - .filter(|(_, _, replacement)| *replacement == "_wait") - .map(|(start, _, _)| { - let shift: isize = replacements - .iter() - .filter(|(prior_start, _, _)| prior_start < start) - .map(|(prior_start, prior_end, replacement)| { - replacement.len() as isize - (*prior_end - *prior_start) as isize - }) - .sum(); - (*start as isize + shift) as usize - }) - .collect(); let mut result = source.to_string(); for (start, end, replacement) in replacements.into_iter().rev() { result.replace_range(start..end, replacement); } - NormalizedClassSyntax { - source: result, - await_name_starts, - } -} - -#[cfg(test)] -fn normalize_swc_class_syntax(source: &str) -> String { - normalize_swc_class_syntax_with_metadata(source).source -} - -fn restore_await_class_identifiers( - module: &mut Module, - file_start: u32, - await_name_starts: &[usize], -) { - if await_name_starts.is_empty() { - return; - } - struct RestoreAwaitNames<'a> { - file_start: u32, - starts: &'a [usize], - } - impl VisitMut for RestoreAwaitNames<'_> { - fn visit_mut_ident(&mut self, ident: &mut swc_ecma_ast::Ident) { - let local_start = ident.span.lo.0.saturating_sub(self.file_start) as usize; - if ident.sym == *"_wait" && self.starts.contains(&local_start) { - ident.sym = "await".into(); - } - } - } - module.visit_mut_with(&mut RestoreAwaitNames { - file_start, - starts: await_name_starts, - }); + result } /// Utility to convert SWC span to our span type. @@ -1454,7 +1385,7 @@ class C { #[test] fn normalize_await_class_expression_name_for_script_parser() { let normalized = normalize_swc_class_syntax("var C = class await {};"); - assert_eq!(normalized, "var C = class _wait {};"); + assert_eq!(normalized, r"var C = class \u0061wait {};"); let module = parse_typescript("var C = class await {};", "await-name.js").unwrap(); let swc_ecma_ast::ModuleItem::Stmt(swc_ecma_ast::Stmt::Decl(swc_ecma_ast::Decl::Var(var))) = &module.body[0] diff --git a/crates/perry-runtime/src/gc/tests/copying_side_tables.rs b/crates/perry-runtime/src/gc/tests/copying_side_tables.rs index 132ea9c700..bd8cb94545 100644 --- a/crates/perry-runtime/src/gc/tests/copying_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/copying_side_tables.rs @@ -13,6 +13,7 @@ fn test_copying_minor_rewrites_class_side_table_values_and_function_keys() { let value = young_leaf(); let prototype_object = crate::object::js_object_alloc(0, 0) as usize; + let decl_prototype_object = crate::object::js_object_alloc(0, 0) as usize; let parent_closure = crate::arena::arena_alloc_gc( std::mem::size_of::(), std::mem::align_of::(), @@ -33,6 +34,7 @@ fn test_copying_minor_rewrites_class_side_table_values_and_function_keys() { crate::object::test_seed_class_prototype_method_root(0x5401, "proto", string_bits(value)); crate::object::test_seed_class_prototype_method_value_root(0x5401, "bound", string_bits(value)); crate::object::test_seed_class_prototype_object_root(0x5401, prototype_object); + crate::object::test_seed_class_decl_prototype_object_root(0x5401, decl_prototype_object); crate::object::test_seed_class_parent_closure_root(0x5401, parent_closure); crate::object::test_seed_function_class_id_key(ptr_bits(key), 0x8200_5401); @@ -42,6 +44,8 @@ fn test_copying_minor_rewrites_class_side_table_values_and_function_keys() { let prototype_bits = crate::object::test_class_prototype_method_root_bits(0x5401, "proto"); let cached_bits = crate::object::test_class_prototype_method_value_root_bits(0x5401, "bound"); let prototype_object_after = crate::object::test_class_prototype_object_root_addr(0x5401); + let decl_prototype_object_after = + crate::object::test_class_decl_prototype_object_root_addr(0x5401); let parent_closure_after = crate::object::test_class_parent_closure_root_addr(0x5401); let value_after = (dynamic_bits & POINTER_MASK) as usize; let key_after_bits = js_shadow_slot_get(0); @@ -53,6 +57,10 @@ fn test_copying_minor_rewrites_class_side_table_values_and_function_keys() { assert!(crate::arena::pointer_in_nursery(value_after)); assert_ne!(prototype_object_after, prototype_object); assert!(crate::arena::pointer_in_nursery(prototype_object_after)); + assert_ne!(decl_prototype_object_after, decl_prototype_object); + assert!(crate::arena::pointer_in_nursery( + decl_prototype_object_after + )); assert_ne!(parent_closure_after, parent_closure); assert!(crate::arena::pointer_in_nursery(parent_closure_after)); assert_ne!(key_after_bits, ptr_bits(key)); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index fecbf27961..176f77e38e 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -137,10 +137,11 @@ pub(crate) use gc_roots::{ pub use gc_roots::{scan_class_side_table_roots, scan_class_side_table_roots_mut}; #[cfg(test)] pub(crate) use gc_roots::{ - test_class_dynamic_prop_root_bits, test_class_parent_closure_root_addr, - test_class_prototype_method_root_bits, test_class_prototype_method_value_root_bits, - test_class_prototype_object_root_addr, test_clear_class_side_table_roots, - test_function_class_id_key_count, test_function_class_id_key_for_class, + test_class_decl_prototype_object_root_addr, test_class_dynamic_prop_root_bits, + test_class_parent_closure_root_addr, test_class_prototype_method_root_bits, + test_class_prototype_method_value_root_bits, test_class_prototype_object_root_addr, + test_clear_class_side_table_roots, test_function_class_id_key_count, + test_function_class_id_key_for_class, test_seed_class_decl_prototype_object_root, test_seed_class_dynamic_prop_root, test_seed_class_prototype_method_root, test_seed_class_prototype_method_value_root, test_seed_class_prototype_object_root, test_seed_function_class_id_key, diff --git a/crates/perry-runtime/src/object/class_registry/construct/class_return.rs b/crates/perry-runtime/src/object/class_registry/construct/class_return.rs index 6da8ef6a1e..86233eb664 100644 --- a/crates/perry-runtime/src/object/class_registry/construct/class_return.rs +++ b/crates/perry-runtime/src/object/class_registry/construct/class_return.rs @@ -96,6 +96,7 @@ fn constructor_return_overrides_this(value: f64) -> bool { | crate::gc::GC_TYPE_SET | crate::gc::GC_TYPE_DATE_CELL | crate::gc::GC_TYPE_REGEXP + | crate::gc::GC_TYPE_LAZY_ARRAY ) } } diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 516ab80ed6..9cc418b72b 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -17,6 +17,9 @@ enum ClassSideTableRootSlot { PrototypeObject { class_id: u32, }, + DeclPrototypeObject { + class_id: u32, + }, ParentClosure { class_id: u32, }, @@ -113,6 +116,16 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit } }); + CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(map) = guard.as_mut() { + for proto_addr in map.values_mut() { + visitor.visit_usize_slot(proto_addr); + } + } + } + }); + CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -248,6 +261,16 @@ fn class_side_table_root_snapshot() -> Vec { } }); + CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| { + if let Ok(guard) = table.read() { + if let Some(map) = guard.as_ref() { + for &class_id in map.keys() { + slots.push(ClassSideTableRootSlot::DeclPrototypeObject { class_id }); + } + } + } + }); + CLASS_PARENT_CLOSURES.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -371,6 +394,15 @@ fn scan_class_side_table_root_slot( } }); } + ClassSideTableRootSlot::DeclPrototypeObject { class_id } => { + CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| { + if let Ok(mut guard) = table.write() { + if let Some(proto_addr) = guard.as_mut().and_then(|map| map.get_mut(class_id)) { + visitor.visit_usize_slot(proto_addr); + } + } + }); + } ClassSideTableRootSlot::ParentClosure { class_id } => { CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { @@ -600,6 +632,11 @@ pub(crate) fn test_clear_class_side_table_roots() { *guard = None; } }); + CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| { + if let Ok(mut guard) = table.write() { + *guard = None; + } + }); CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { *guard = None; @@ -695,6 +732,22 @@ pub(crate) fn test_class_prototype_object_root_addr(class_id: u32) -> usize { }) } +#[cfg(test)] +pub(crate) fn test_seed_class_decl_prototype_object_root(class_id: u32, addr: usize) { + class_decl_prototype_object_root_store(class_id, addr as *mut ObjectHeader); +} + +#[cfg(test)] +pub(crate) fn test_class_decl_prototype_object_root_addr(class_id: u32) -> usize { + CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| { + table + .read() + .ok() + .and_then(|guard| guard.as_ref().and_then(|map| map.get(&class_id).copied())) + .unwrap_or(0) + }) +} + #[cfg(test)] pub(crate) fn test_class_parent_closure_root_addr(class_id: u32) -> usize { CLASS_PARENT_CLOSURES.with(|table| { diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index decb9bcb65..464ee9b0fd 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -191,7 +191,10 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu if super::class_registry::is_class_object_value(obj_value) && crate::symbol::js_is_symbol(key_value) == 0 { + let metadata_scope = crate::gc::RuntimeHandleScope::new(); + let metadata_obj_value = metadata_scope.root_heap_word_u64(obj_value.to_bits()); if let Some(method_name) = metadata_key_to_string(key_value) { + let obj_value = f64::from_bits(metadata_obj_value.get_heap_word_u64()); let class_obj = extract_obj_ptr(obj_value); if !class_obj.is_null() { let class_id = super::js_object_get_class_id(class_obj); @@ -728,7 +731,11 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } if crate::array::is_array_subclass_value(obj_value) && key_rust.as_deref() == Some("length") { - let length = crate::object::js_object_get_field_by_name(obj, key_str); + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let (length, obj) = obj_handle.across_mut::(|| { + crate::object::js_object_get_field_by_name(obj, key_str) + }); let frozen = (*crate::object::gc_header_for(obj))._reserved & crate::gc::OBJ_FLAG_FROZEN != 0; let writable = !frozen diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index a77ad959ed..2066cbb217 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -91,7 +91,11 @@ unsafe fn class_evaluation_prototype_value(obj: *const ObjectHeader) -> f64 { "prototype", ); let value_js = JSValue::from_bits(value.to_bits()); - value_js.is_pointer().then_some(value.to_bits()) + if value.to_bits() == crate::value::TAG_NULL { + Some(crate::value::TAG_NULL) + } else { + value_js.is_pointer().then_some(value.to_bits()) + } } else { None } diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index 5c4fb9df03..92e724ed31 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1330,7 +1330,14 @@ pub(crate) fn is_internal_runtime_key_bytes(b: &[u8]) -> bool { || b == b"__perry_ctor_caps" || b.starts_with(crate::node_stream::NATIVE_BASE_SUPER_PREFIX) || b.starts_with(b"__perry_computed_field_key_") - || b.starts_with(b"#" + || b == b"#" + || b.starts_with(b"#"] = 1; +check( + "user perry-prefix key enumerable", + Object.keys(internalPrefixObject).includes("#") +); + +const lazyJson = "[" + new Array(600).fill("0").join(",") + "]"; +function ReturnLazyArray(): any { + return JSON.parse(lazyJson); +} +const lazyConstructorResult = new (ReturnLazyArray as any)(); +check( + "lazy array constructor return override", + Array.isArray(lazyConstructorResult) && lazyConstructorResult.length === 600 +); + function makePrototypeParent(tag: string): any { return class { static #brand = 0; From c9658043bd3320b66db70b1bada35e57d36b66f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 14:32:13 +0200 Subject: [PATCH 07/17] fix final class review regressions --- Cargo.lock | 1 + Cargo.toml | 1 + .../src/lower/lower_expr/arm_class.rs | 1 + crates/perry-parser/Cargo.toml | 1 + crates/perry-parser/src/lib.rs | 166 ++++++++++++++++-- ...test_issue_5893_private_brand_freshness.ts | 19 ++ 6 files changed, 179 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56cb11a139..fe47c4c86e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6291,6 +6291,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_parser 32.0.0", + "swc_ecma_visit", "thiserror 1.0.69", ] diff --git a/Cargo.toml b/Cargo.toml index 2623e8a948..d477a7be10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -343,6 +343,7 @@ not_unsafe_ptr_arg_deref = "allow" # SWC for TypeScript parsing swc_ecma_parser = "32.0" swc_ecma_ast = "19.0" +swc_ecma_visit = "19.0" swc_common = "18.0" swc_ecma_codegen = "21.0" swc_ecma_transforms_base = "32.0" diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index cb05f94d9b..b8c289709d 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -249,6 +249,7 @@ pub(crate) fn lower_class_expr( && (!named_statics.is_empty() || !computed_keys.is_empty() || !captured_args.is_empty() + || !static_block_names.is_empty() || has_private_elements) { // #6438: a class expression WITH heritage (`class extends `) used diff --git a/crates/perry-parser/Cargo.toml b/crates/perry-parser/Cargo.toml index 1ac263a749..b11bbab19c 100644 --- a/crates/perry-parser/Cargo.toml +++ b/crates/perry-parser/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] swc_ecma_parser.workspace = true swc_ecma_ast.workspace = true +swc_ecma_visit.workspace = true swc_common.workspace = true thiserror.workspace = true diff --git a/crates/perry-parser/src/lib.rs b/crates/perry-parser/src/lib.rs index 84ab3c9699..f6b0161166 100644 --- a/crates/perry-parser/src/lib.rs +++ b/crates/perry-parser/src/lib.rs @@ -6,9 +6,10 @@ use anyhow::Result; use perry_diagnostics::{Diagnostic, DiagnosticCode, Diagnostics, FileId, SourceCache, Span}; use std::path::Path; -use swc_common::{input::StringInput, sync::Lrc, FileName, SourceMap}; +use swc_common::{input::StringInput, sync::Lrc, BytePos, FileName, SourceMap}; use swc_ecma_ast::{Module, ModuleItem, Script}; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, Syntax, TsSyntax}; +use swc_ecma_visit::{VisitMut, VisitMutWith}; // Re-export AST types for consumers that need to inspect the AST pub use swc_ecma_ast; @@ -47,7 +48,8 @@ pub fn parse_typescript_with_cache( cache: &mut SourceCache, ) -> Result { let unicode_source = normalize_unicode_identifier_escapes(source); - let parse_source = normalize_swc_class_syntax(&unicode_source); + let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let parse_source = &normalized.source; // Add the source to the cache let file_id = cache.add_file(filename, source.to_string()); @@ -59,11 +61,11 @@ pub fn parse_typescript_with_cache( ); let mut diagnostics = Diagnostics::new(); - let (module, mut parser) = + let (mut module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &parse_source).map_err( |e| { // Convert SWC error to our diagnostic - let span = Span::new(file_id, e.span().lo.0, e.span().hi.0); + let span = normalized.perry_span(e.span(), source_file.start_pos, file_id); let diag = Diagnostic::error(DiagnosticCode::ParseError, format!("{}", e.kind().msg())) .with_span(span) @@ -72,9 +74,10 @@ pub fn parse_typescript_with_cache( anyhow::anyhow!("Parse error: {}", e.kind().msg()) }, )?; + normalized.remap_module_spans(&mut module, source_file.start_pos); // Collect recoverable errors as warnings for error in parser.take_errors() { - let span = Span::new(file_id, error.span().lo.0, error.span().hi.0); + let span = normalized.perry_span(error.span(), source_file.start_pos, file_id); diagnostics.push( Diagnostic::warning( DiagnosticCode::ParseError, @@ -98,16 +101,18 @@ pub fn parse_typescript_with_cache( /// For new code, prefer `parse_typescript_with_cache` for better diagnostics. pub fn parse_typescript(source: &str, filename: &str) -> Result { let unicode_source = normalize_unicode_identifier_escapes(source); - let parse_source = normalize_swc_class_syntax(&unicode_source); + let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let parse_source = &normalized.source; let source_map: Lrc = Default::default(); let source_file = source_map.new_source_file( Lrc::new(FileName::Custom(filename.to_string())), - parse_source, + parse_source.clone(), ); - let (module, mut parser) = + let (mut module, mut parser) = parse_source_file_with_typescript_fallback(&source_file, filename, &source_file.src) .map_err(|e| anyhow::anyhow!("Parse error: {:?}", e))?; + normalized.remap_module_spans(&mut module, source_file.start_pos); // Check for recoverable errors for error in parser.take_errors() { eprintln!("Parse warning: {:?}", error); @@ -842,7 +847,79 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { /// Normalize two valid class grammar corners that SWC currently rejects. /// String/comment contents are masked before tokenization, so source text that /// merely mentions these spellings is never rewritten. -fn normalize_swc_class_syntax(source: &str) -> String { +#[derive(Clone, Copy, Debug)] +struct NormalizedSourceEdit { + original_start: usize, + original_end: usize, + normalized_start: usize, + normalized_end: usize, +} + +struct NormalizedClassSyntax { + source: String, + edits: Vec, +} + +impl NormalizedClassSyntax { + fn original_offset(&self, normalized_offset: usize) -> usize { + let mut cumulative_delta = 0isize; + for edit in &self.edits { + if normalized_offset < edit.normalized_start { + break; + } + if normalized_offset <= edit.normalized_end { + if normalized_offset == edit.normalized_end { + return edit.original_end; + } + let normalized_len = edit.normalized_end - edit.normalized_start; + let original_len = edit.original_end - edit.original_start; + let relative = normalized_offset - edit.normalized_start; + return edit.original_start + + relative.saturating_mul(original_len) / normalized_len.max(1); + } + cumulative_delta += (edit.normalized_end - edit.normalized_start) as isize + - (edit.original_end - edit.original_start) as isize; + } + (normalized_offset as isize - cumulative_delta).max(0) as usize + } + + fn swc_span(&self, span: swc_common::Span, file_start: BytePos) -> swc_common::Span { + if span.lo < file_start || span.hi < file_start { + return span; + } + let lo = span.lo.0.saturating_sub(file_start.0) as usize; + let hi = span.hi.0.saturating_sub(file_start.0) as usize; + swc_common::Span::new( + BytePos(file_start.0 + self.original_offset(lo) as u32), + BytePos(file_start.0 + self.original_offset(hi) as u32), + ) + } + + fn perry_span(&self, span: swc_common::Span, file_start: BytePos, file_id: FileId) -> Span { + let span = self.swc_span(span, file_start); + Span::new(file_id, span.lo.0, span.hi.0) + } + + fn remap_module_spans(&self, module: &mut Module, file_start: BytePos) { + struct RemapSpans<'a> { + normalized: &'a NormalizedClassSyntax, + file_start: BytePos, + } + + impl VisitMut for RemapSpans<'_> { + fn visit_mut_span(&mut self, span: &mut swc_common::Span) { + *span = self.normalized.swc_span(*span, self.file_start); + } + } + + module.visit_mut_with(&mut RemapSpans { + normalized: self, + file_start, + }); + } +} + +fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSyntax { #[derive(Clone, Copy)] struct Token<'a> { start: usize, @@ -941,11 +1018,34 @@ fn normalize_swc_class_syntax(source: &str) -> String { } } + let mut cumulative_delta = 0isize; + let edits = replacements + .iter() + .map(|(start, end, replacement)| { + let normalized_start = (*start as isize + cumulative_delta) as usize; + let normalized_end = normalized_start + replacement.len(); + cumulative_delta += replacement.len() as isize - (*end - *start) as isize; + NormalizedSourceEdit { + original_start: *start, + original_end: *end, + normalized_start, + normalized_end, + } + }) + .collect(); let mut result = source.to_string(); for (start, end, replacement) in replacements.into_iter().rev() { result.replace_range(start..end, replacement); } - result + NormalizedClassSyntax { + source: result, + edits, + } +} + +#[cfg(test)] +fn normalize_swc_class_syntax(source: &str) -> String { + normalize_swc_class_syntax_with_metadata(source).source } /// Utility to convert SWC span to our span type. @@ -1402,6 +1502,52 @@ class C { parse_typescript(r"var C = class \u0061wait {};", "await-name-escaped.js").unwrap(); } + #[test] + fn class_syntax_normalization_preserves_following_ast_offsets() { + for (source, filename) in [ + ( + "var C = class await {}; function afterAwait() {}", + "await-offset.js", + ), + ( + "class C { static constructor() {} } function afterConstructor() {}", + "constructor-offset.js", + ), + ] { + let module = parse_typescript(source, filename).unwrap(); + let function = module + .body + .iter() + .find_map(|item| match item { + swc_ecma_ast::ModuleItem::Stmt(swc_ecma_ast::Stmt::Decl( + swc_ecma_ast::Decl::Fn(function), + )) => Some(function), + _ => None, + }) + .expect("expected trailing function declaration"); + let expected = source.find("function").unwrap() as u32 + 1; + assert_eq!(function.function.span.lo.0, expected, "{filename}"); + assert_eq!(module.span.hi.0, source.len() as u32 + 1, "{filename}"); + } + } + + #[test] + fn class_syntax_normalization_preserves_following_diagnostic_offsets() { + let source = "class C { static constructor() {} constructor() {} constructor() {} }"; + let mut cache = SourceCache::new(); + let result = parse_typescript_with_cache(source, "constructor-diagnostic.js", &mut cache) + .expect("duplicate constructor is a recoverable parse error"); + let duplicate = result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.message.contains("only have one constructor")) + .expect("expected duplicate-constructor diagnostic"); + assert_eq!( + duplicate.span.start, + source.rfind("constructor").unwrap() as u32 + 1 + ); + } + #[test] fn normalize_swc_class_syntax_walks_char_boundaries() { // The tokenizer slices `&masked[start..i]`, so its cursor must never diff --git a/test-files/test_issue_5893_private_brand_freshness.ts b/test-files/test_issue_5893_private_brand_freshness.ts index 3956d7f608..60d1a647cc 100644 --- a/test-files/test_issue_5893_private_brand_freshness.ts +++ b/test-files/test_issue_5893_private_brand_freshness.ts @@ -320,6 +320,25 @@ check( SharedTemplateOrder.last === 2 ); +function makeStaticBlockOnlyClass(): any { + return class { + static { + (this as any).n = ((this as any).n ?? 0) + 1; + } + }; +} + +const staticBlockOnlyFirst = makeStaticBlockOnlyClass(); +const staticBlockOnlySecond = makeStaticBlockOnlyClass(); +check( + "static-block-only class identity", + staticBlockOnlyFirst !== staticBlockOnlySecond +); +check( + "static-block-only class state", + staticBlockOnlyFirst.n === 1 && staticBlockOnlySecond.n === 1 +); + function makeMutableParameter(value: string): any { class MutableParameter { read(): string { From e2a31d53c321a4cde8c37d6990dc33464028378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 14:56:02 +0200 Subject: [PATCH 08/17] docs regenerate API reference --- docs/src/api/reference.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 22de32cc51..579ca5f971 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2988 entries across 135 modules. +Total: 2994 entries across 135 modules. ## Modules @@ -1609,6 +1609,7 @@ Total: 2988 entries across 135 modules. - `__get_aborted` — instance *(class: `ClientRequest`)* - `__get_aborted` — instance *(class: `IncomingMessage`)* - `__get_complete` — instance *(class: `IncomingMessage`)* +- `__get_connection` — instance *(class: `IncomingMessage`)* - `__get_connection` — instance *(class: `ClientRequest`)* - `__get_createConnection` — instance *(class: `Agent`)* - `__get_createSocket` — instance *(class: `Agent`)* @@ -1641,9 +1642,11 @@ Total: 2988 entries across 135 modules. - `__get_path` — instance *(class: `ClientRequest`)* - `__get_protocol` — instance *(class: `Agent`)* - `__get_protocol` — instance *(class: `ClientRequest`)* +- `__get_req` — instance *(class: `IncomingMessage`)* - `__get_requestTimeout` — instance *(class: `HttpServer`)* - `__get_requests` — instance *(class: `Agent`)* - `__get_reusedSocket` — instance *(class: `ClientRequest`)* +- `__get_socket` — instance *(class: `IncomingMessage`)* - `__get_socket` — instance *(class: `ClientRequest`)* - `__get_sockets` — instance *(class: `Agent`)* - `__get_statusCode` — instance *(class: `IncomingMessage`)* @@ -1687,6 +1690,7 @@ Total: 2988 entries across 135 modules. - `close` — instance *(class: `HttpServer`)* - `closeAllConnections` — instance *(class: `HttpServer`)* - `closeIdleConnections` — instance *(class: `HttpServer`)* +- `connection` — instance *(class: `IncomingMessage`)* - `cork` — instance *(class: `ClientRequest`)* - `cork` — instance *(class: `ServerResponse`)* - `createServer` — module @@ -1738,6 +1742,7 @@ Total: 2988 entries across 135 modules. - `ref` — instance *(class: `HttpServer`)* - `removeHeader` — instance *(class: `ClientRequest`)* - `removeHeader` — instance *(class: `ServerResponse`)* +- `req` — instance *(class: `IncomingMessage`)* - `request` — module - `requestTimeout` — instance *(class: `HttpServer`)* - `requests` — instance *(class: `Agent`)* @@ -1756,6 +1761,7 @@ Total: 2988 entries across 135 modules. - `setTimeout` — instance *(class: `IncomingMessage`)* - `setTimeout` — instance *(class: `ClientRequest`)* - `setTimeout` — instance *(class: `ServerResponse`)* +- `socket` — instance *(class: `IncomingMessage`)* - `sockets` — instance *(class: `Agent`)* - `statusCode` — instance *(class: `IncomingMessage`)* - `statusMessage` — instance *(class: `IncomingMessage`)* From 7cbc1c29e5208671e512aa93d47fd43c54c9c513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 16:27:57 +0200 Subject: [PATCH 09/17] fix unsafe compound ToInt32 lowering --- crates/perry-codegen/src/expr/binary.rs | 16 +-- .../perry-codegen/src/expr/i32_fast_path.rs | 105 +++--------------- crates/perry-codegen/src/expr/mod.rs | 17 ++- crates/perry-codegen/src/expr/slot_rep.rs | 8 +- crates/perry-codegen/src/stmt/let_stmt.rs | 8 +- 5 files changed, 46 insertions(+), 108 deletions(-) diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index c289ae6c08..e718cfe652 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -25,7 +25,7 @@ use crate::types::{DOUBLE, I1, I128, I32, I64}; use crate::rooting::with_operands_rooted; -use super::{is_known_finite, lower_expr, FnCtx}; +use super::{is_known_i32_range, lower_expr, FnCtx}; /// `helper(left, right)` with each operand rooted across the other's lowering /// and the group released on every path out (#6951). @@ -1080,7 +1080,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // entirely — just fptosi + sitofp (identity for in-range // values, LLVM eliminates via instcombine). BinaryOp::BitOr - if matches!(right.as_ref(), Expr::Integer(0)) && is_known_finite(ctx, left) => + if matches!(right.as_ref(), Expr::Integer(0)) + && is_known_i32_range(ctx, left) => { let blk = ctx.block(); let li = blk.toint32_fast(&l); @@ -1091,8 +1092,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | BinaryOp::BitXor | BinaryOp::Shl | BinaryOp::Shr => { - let l_safe = is_known_finite(ctx, left); - let r_safe = is_known_finite(ctx, right); + let l_safe = is_known_i32_range(ctx, left); + let r_safe = is_known_i32_range(ctx, right); let blk = ctx.block(); let li = if l_safe { blk.toint32_fast(&l) @@ -1115,15 +1116,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { blk.sitofp(I32, &v, DOUBLE) } BinaryOp::UShr - if matches!(right.as_ref(), Expr::Integer(0)) && is_known_finite(ctx, left) => + if matches!(right.as_ref(), Expr::Integer(0)) + && is_known_i32_range(ctx, left) => { let blk = ctx.block(); let li = blk.toint32_fast(&l); blk.uitofp(I32, &li, DOUBLE) } BinaryOp::UShr => { - let l_safe = is_known_finite(ctx, left); - let r_safe = is_known_finite(ctx, right); + let l_safe = is_known_i32_range(ctx, left); + let r_safe = is_known_i32_range(ctx, right); let blk = ctx.block(); let li = if l_safe { blk.toint32_fast(&l) diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 76240710f2..76264063d1 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -27,89 +27,19 @@ use native_narrow::{ lower_expr_native_u8, }; -/// Returns true if `e` provably produces a finite double whose magnitude is -/// small enough (`|v| < 2^63`) for the unguarded `toint32_fast` lowering. -/// Used to skip the NaN/Inf/range guard in `toint32` for integer-arithmetic -/// hot paths — saving 5 instructions per bitwise op. -pub(crate) fn is_known_finite(ctx: &FnCtx<'_>, e: &Expr) -> bool { - known_finite_magnitude_bits(ctx, e).is_some_and(|bits| bits <= 62) -} - -/// Conservative magnitude bound for `e`'s numeric value: `Some(b)` proves the -/// value is finite AND `|v| < 2^b`. `toint32_fast` is a bare -/// `fptosi f64 → i64` + `trunc` — exactly JS ToInt32 for every `|v| < 2^63`, -/// but LLVM *poison* at or beyond it. Finiteness alone is NOT enough: -/// `(1e20) | 0` and nested integer multiplies (`(a*a)*a | 0` with i32-range -/// `a`) are finite yet exceed 2^63, and pre-fix produced NaN instead of the -/// ToInt32-wrapped value (CodeRabbit review on #5466; the same hole shipped -/// on main). Composition keeps the proof airtight where the old boolean -/// recursion silently escalated: Add/Sub grow the bound by one bit, Mul sums -/// the operand bounds, and anything unprovable returns `None` so callers fall -/// back to the guarded `toint32` runtime helper. -fn known_finite_magnitude_bits(ctx: &FnCtx<'_>, e: &Expr) -> Option { - match e { - Expr::Integer(n) => Some(64 - n.unsigned_abs().leading_zeros()), - // Pod layout sizes/alignments/offsets are u32-class quantities. - Expr::PodLayoutSizeOf { .. } - | Expr::PodLayoutAlignOf { .. } - | Expr::PodLayoutOffsetOf { .. } => Some(32), - // Number literals can be NaN or ±Infinity (e.g., `Number(NaN)`, - // `Number(f64::INFINITY)`). Inspect the value: `fptosi NaN` is - // poison in LLVM and produced subnormal-double output (which - // downstream code interpreted as a NaN-boxed string with - // STRING_TAG bits, leading to garbled `console.log` output). - Expr::Number(n) => { - if !n.is_finite() { - return None; - } - let magnitude = n.abs(); - if magnitude < 1.0 { - Some(0) - } else { - Some(magnitude.log2() as u32 + 1) - } - } - Expr::LocalGet(id) | Expr::Update { id, .. } => (ctx.integer_locals.contains(id) - || ctx.unsigned_i32_locals.contains(id)) - .then_some(32), - Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => Some(8), - // In-bounds loads from an int-element typed array are integers in - // i32 range by construction (see `ta_int_elem_load_is_i32_provable`), - // as are i32-tier masked-window plain-array loads (the dense-i32 - // range guard proved every window value is an i32 integer). - Expr::IndexGet { object, index } - if ta_int_elem_load_is_i32_provable(ctx, object, index) - || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) => - { - Some(32) - } - Expr::MathImul(_, _) => Some(32), // Math.imul returns i32 → always finite - Expr::Call { callee, .. } => { - matches!(callee.as_ref(), Expr::FuncRef(fid) if ctx.integer_returning_functions.contains(fid)) - .then_some(32) - } - Expr::Binary { op, left, right } => match op { - BinaryOp::Add | BinaryOp::Sub => { - let l = known_finite_magnitude_bits(ctx, left)?; - let r = known_finite_magnitude_bits(ctx, right)?; - Some(l.max(r) + 1) - } - BinaryOp::Mul => { - let l = known_finite_magnitude_bits(ctx, left)?; - let r = known_finite_magnitude_bits(ctx, right)?; - Some(l + r) - } - // Bitwise results are already ToInt32/ToUint32-wrapped. - BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr - | BinaryOp::UShr => Some(32), - _ => None, - }, - _ => None, - } +/// Returns true if `e` is proven to fit a signed i32 at this program point. +/// +/// `toint32_fast` currently emits `fptosi f64 -> i64` followed by `trunc`, but +/// LLVM's optimized output may combine that pair into `fptosi f64 -> i32`. +/// Its caller therefore needs an i32-range proof, not merely finiteness or an +/// i64-range magnitude bound. In particular, `integer_locals` proves only that +/// every write is integer-valued: a mutable local can hold the out-of-i32 +/// result of a prior `*=` or `+=`. Treating that coarse fact as a 32-bit bound +/// made the final `c &= 0x7fffffff` in #7232 convert a ~1.5e18 double with +/// poison and print `0` instead of applying ECMAScript ToInt32 wrapping. +pub(crate) fn is_known_i32_range(ctx: &FnCtx<'_>, e: &Expr) -> bool { + super::range_facts::int_range_expr(ctx, e) + .is_some_and(|range| range.min >= i64::from(i32::MIN) && range.max <= i64::from(i32::MAX)) } /// (Issue #50) If `IndexGet { object, index }` is a flat-const access @@ -355,10 +285,9 @@ fn is_i32_chain_op(op: BinaryOp) -> bool { /// Magnitude bound of `left right` from the operands' bounds. /// -/// `Add`/`Sub` grow the bound by one bit and `Mul` sums them — the same -/// composition [`known_finite_magnitude_bits`] uses — but capped at 2^53 -/// instead of 2^63, because this bound gates *exact integer arithmetic* rather -/// than a single `fptosi`. +/// `Add`/`Sub` grow the bound by one bit and `Mul` sums them, capped at 2^53 +/// because this bound gates exact integer arithmetic rather than ToInt32 +/// materialization. /// /// The ToInt32/ToUint32-wrapped operators reset the bound to 32. Two of them /// carry a tighter one, which is what keeps masked/shifted hash mixing on the @@ -1438,7 +1367,7 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result // Index/internal i32 materialization — packed-store RHS and // numeric-index consumers prove their ranges upstream, so // keep the lean guard here (see toint32 vs toint32_wrap). - if is_known_finite(ctx, e) { + if is_known_i32_range(ctx, e) { Some(ctx.block().toint32_fast(&lowered.value)) } else { Some(ctx.block().toint32(&lowered.value)) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e19662636a..a7c288ba32 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -87,7 +87,7 @@ pub(crate) use helpers::{ }; pub(crate) use i32_fast_path::{ can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, - imul_operand_i32_lowerable_in_current_region, is_known_finite, lower_expr_as_i32, + imul_operand_i32_lowerable_in_current_region, is_known_i32_range, lower_expr_as_i32, lower_expr_native, lower_imul_operand_i32, lower_packed_u32_loop_index_get, try_flat_const_2d_int, try_lower_flat_const_index_get, }; @@ -2913,10 +2913,17 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result i32`. After `c *= 1103515245; c += 12345`, that is + // poison rather than ECMAScript ToInt32. Let the F64 arm below apply its + // program-point range proof and otherwise use `toint32_wrap`. + if !matches!(expr, Expr::LocalGet(_)) && can_lower_expr_as_i32_in_current_region(ctx, expr) { return Ok(Some( lower_expr_native(ctx, expr, ExpectedNativeRep::I32)?.value, )); @@ -2934,7 +2941,7 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value = lower_expr(ctx, expr)?; - return Ok(Some(if is_known_finite(ctx, expr) { + return Ok(Some(if is_known_i32_range(ctx, expr) { ctx.block().toint32_fast(&value) } else { ctx.block().toint32_wrap(&value) @@ -2965,7 +2972,7 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { - if is_known_finite(ctx, expr) { + if is_known_i32_range(ctx, expr) { ctx.block().toint32_fast(&lowered.value) } else { ctx.block().toint32_wrap(&lowered.value) diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index b51d5788e9..f8964cea22 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -597,8 +597,8 @@ pub(crate) fn load_canonical_local_boxed(ctx: &mut FnCtx<'_>, id: u32) -> Option /// finite value (an OOB int-typed-array read is a NaN-boxed `undefined`) must /// enter the slot as spec `ToInt32` — raw `fptosi` of a NaN is poison on /// x86-64. `rhs` (when available) lets known-finite writes keep the cheaper -/// `fptosi→i64→trunc`, bit-identical for finite values; pass `None` for -/// values of unknown provenance (always `toint32_wrap`). +/// `fptosi→i64→trunc`, bit-identical for signed-i32-range values; pass +/// `None` for values of unknown provenance (always `toint32_wrap`). /// /// Returns `true` when the local was canonical and the store was emitted. pub(crate) fn store_canonical_local_from_double( @@ -610,8 +610,8 @@ pub(crate) fn store_canonical_local_from_double( let Some((slot, _rep)) = canonical_local_i32_slot(ctx, id) else { return false; }; - let known_finite = rhs.is_some_and(|e| super::is_known_finite(ctx, e)); - let v_i32 = if known_finite { + let known_i32_range = rhs.is_some_and(|e| super::is_known_i32_range(ctx, e)); + let v_i32 = if known_i32_range { let v_i64 = ctx.block().fptosi(DOUBLE, value, I64); ctx.block().trunc(I64, &v_i64, I32) } else { diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 01181fc960..c0d8e4224a 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1936,10 +1936,10 @@ pub(crate) fn lower_let( // sentinel on x86-64 — so it is NOT portable. `int_valued_ta` // locals (and any other i32-shadow local with a non-known-finite // init) are only ever observed through ToInt32, so seeding with - // the exact ToInt32 keeps every arm identical. Known-finite - // inits keep the cheaper `fptosi→i64→trunc` (bit-identical for - // finite values), so existing i32-shadow locals are unchanged. - let v_i32 = if crate::expr::is_known_finite(ctx, init_expr) { + // the exact ToInt32 keeps every arm identical. Proven-i32-range + // inits keep the cheaper `fptosi→i64→trunc`, so existing + // i32-shadow locals are unchanged. + let v_i32 = if crate::expr::is_known_i32_range(ctx, init_expr) { let v_i64 = ctx.block().fptosi(DOUBLE, &v, crate::types::I64); ctx.block().trunc(crate::types::I64, &v_i64, I32) } else { From 0156284a6f8be5493b0820ae4898654199f4fe39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 16:31:52 +0200 Subject: [PATCH 10/17] fix composed parser span remapping --- crates/perry-parser/src/lib.rs | 143 +++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/crates/perry-parser/src/lib.rs b/crates/perry-parser/src/lib.rs index f6b0161166..82fc446d43 100644 --- a/crates/perry-parser/src/lib.rs +++ b/crates/perry-parser/src/lib.rs @@ -47,8 +47,9 @@ pub fn parse_typescript_with_cache( filename: &str, cache: &mut SourceCache, ) -> Result { - let unicode_source = normalize_unicode_identifier_escapes(source); - let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let unicode_source = normalize_unicode_identifier_escapes_with_metadata(source); + let class_source = normalize_swc_class_syntax_with_metadata(&unicode_source.source); + let normalized = unicode_source.then(class_source); let parse_source = &normalized.source; // Add the source to the cache let file_id = cache.add_file(filename, source.to_string()); @@ -100,8 +101,9 @@ pub fn parse_typescript_with_cache( /// This is the original parsing function for backward compatibility. /// For new code, prefer `parse_typescript_with_cache` for better diagnostics. pub fn parse_typescript(source: &str, filename: &str) -> Result { - let unicode_source = normalize_unicode_identifier_escapes(source); - let normalized = normalize_swc_class_syntax_with_metadata(&unicode_source); + let unicode_source = normalize_unicode_identifier_escapes_with_metadata(source); + let class_source = normalize_swc_class_syntax_with_metadata(&unicode_source.source); + let normalized = unicode_source.then(class_source); let parse_source = &normalized.source; let source_map: Lrc = Default::default(); let source_file = source_map.new_source_file( @@ -603,7 +605,12 @@ fn script_to_module(script: Script) -> Module { } } +#[cfg(test)] fn normalize_unicode_identifier_escapes(source: &str) -> String { + normalize_unicode_identifier_escapes_with_metadata(source).source +} + +fn normalize_unicode_identifier_escapes_with_metadata(source: &str) -> NormalizedSource { #[derive(Clone, Copy, PartialEq, Eq)] enum State { Code, @@ -714,6 +721,7 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { let mut i = 0; let mut state = State::Code; let mut last_sig = LastSig::None; + let mut edits = Vec::new(); while i < bytes.len() { match state { State::Code => { @@ -742,7 +750,14 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { last_sig = LastSig::Char(b'/'); i += 1; } else if let Some((ch, next)) = read_escape(bytes, i) { + let normalized_start = out.len(); out.push(ch); + edits.push(NormalizedSourceEdit { + original_start: i, + original_end: next, + normalized_start, + normalized_end: out.len(), + }); if ch == '_' || ch == '$' || ch.is_alphanumeric() { last_sig = LastSig::Ident { start: i, @@ -841,12 +856,14 @@ fn normalize_unicode_identifier_escapes(source: &str) -> String { } } } - out + NormalizedSource { + source: out, + edit_stages: vec![edits], + } } -/// Normalize two valid class grammar corners that SWC currently rejects. -/// String/comment contents are masked before tokenization, so source text that -/// merely mentions these spellings is never rewritten. +/// Describes one width-changing source edit so offsets can be mapped back to +/// the source cached for diagnostics. #[derive(Clone, Copy, Debug)] struct NormalizedSourceEdit { original_start: usize, @@ -855,32 +872,25 @@ struct NormalizedSourceEdit { normalized_end: usize, } -struct NormalizedClassSyntax { +struct NormalizedSource { source: String, - edits: Vec, + edit_stages: Vec>, } -impl NormalizedClassSyntax { +impl NormalizedSource { + fn then(mut self, next: Self) -> Self { + self.source = next.source; + self.edit_stages.extend(next.edit_stages); + self + } + fn original_offset(&self, normalized_offset: usize) -> usize { - let mut cumulative_delta = 0isize; - for edit in &self.edits { - if normalized_offset < edit.normalized_start { - break; - } - if normalized_offset <= edit.normalized_end { - if normalized_offset == edit.normalized_end { - return edit.original_end; - } - let normalized_len = edit.normalized_end - edit.normalized_start; - let original_len = edit.original_end - edit.original_start; - let relative = normalized_offset - edit.normalized_start; - return edit.original_start - + relative.saturating_mul(original_len) / normalized_len.max(1); - } - cumulative_delta += (edit.normalized_end - edit.normalized_start) as isize - - (edit.original_end - edit.original_start) as isize; - } - (normalized_offset as isize - cumulative_delta).max(0) as usize + self.edit_stages + .iter() + .rev() + .fold(normalized_offset, |offset, edits| { + original_offset_for_edits(offset, edits) + }) } fn swc_span(&self, span: swc_common::Span, file_start: BytePos) -> swc_common::Span { @@ -902,7 +912,7 @@ impl NormalizedClassSyntax { fn remap_module_spans(&self, module: &mut Module, file_start: BytePos) { struct RemapSpans<'a> { - normalized: &'a NormalizedClassSyntax, + normalized: &'a NormalizedSource, file_start: BytePos, } @@ -919,7 +929,32 @@ impl NormalizedClassSyntax { } } -fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSyntax { +fn original_offset_for_edits(normalized_offset: usize, edits: &[NormalizedSourceEdit]) -> usize { + let mut cumulative_delta = 0isize; + for edit in edits { + if normalized_offset < edit.normalized_start { + break; + } + if normalized_offset <= edit.normalized_end { + if normalized_offset == edit.normalized_end { + return edit.original_end; + } + let normalized_len = edit.normalized_end - edit.normalized_start; + let original_len = edit.original_end - edit.original_start; + let relative = normalized_offset - edit.normalized_start; + return edit.original_start + + relative.saturating_mul(original_len) / normalized_len.max(1); + } + cumulative_delta += (edit.normalized_end - edit.normalized_start) as isize + - (edit.original_end - edit.original_start) as isize; + } + (normalized_offset as isize - cumulative_delta).max(0) as usize +} + +/// Normalize two valid class grammar corners that SWC currently rejects. +/// String/comment contents are masked before tokenization, so source text that +/// merely mentions these spellings is never rewritten. +fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedSource { #[derive(Clone, Copy)] struct Token<'a> { start: usize, @@ -1037,9 +1072,9 @@ fn normalize_swc_class_syntax_with_metadata(source: &str) -> NormalizedClassSynt for (start, end, replacement) in replacements.into_iter().rev() { result.replace_range(start..end, replacement); } - NormalizedClassSyntax { + NormalizedSource { source: result, - edits, + edit_stages: vec![edits], } } @@ -1548,6 +1583,46 @@ class C { ); } + #[test] + fn unicode_and_class_normalization_preserve_following_ast_offsets() { + let source = r#"const \u0061 = 0; class C { static constructor() {} } function after() {}"#; + let module = parse_typescript(source, "composed-offset.js").unwrap(); + let function = module + .body + .iter() + .find_map(|item| match item { + swc_ecma_ast::ModuleItem::Stmt(swc_ecma_ast::Stmt::Decl( + swc_ecma_ast::Decl::Fn(function), + )) => Some(function), + _ => None, + }) + .expect("expected trailing function declaration"); + + assert_eq!( + function.function.span.lo.0, + source.find("function").unwrap() as u32 + 1 + ); + assert_eq!(module.span.hi.0, source.len() as u32 + 1); + } + + #[test] + fn unicode_and_class_normalization_preserve_diagnostic_offsets() { + let source = r#"const \u0061 = 0; class C { static constructor() {} constructor() {} constructor() {} }"#; + let mut cache = SourceCache::new(); + let result = parse_typescript_with_cache(source, "composed-diagnostic.js", &mut cache) + .expect("duplicate constructor is a recoverable parse error"); + let duplicate = result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.message.contains("only have one constructor")) + .expect("expected duplicate-constructor diagnostic"); + + assert_eq!( + duplicate.span.start, + source.rfind("constructor").unwrap() as u32 + 1 + ); + } + #[test] fn normalize_swc_class_syntax_walks_char_boundaries() { // The tokenizer slices `&masked[start..i]`, so its cursor must never From d964441d9c9f42cd0dac97831ee518e9850c0801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 17:07:21 +0200 Subject: [PATCH 11/17] fix computed class expression registrations --- crates/perry-hir/src/lower/lower_expr/arm_class.rs | 1 + crates/perry-hir/src/lower/module_decl.rs | 2 ++ crates/perry-hir/src/lower/stmt.rs | 1 + crates/perry-hir/src/lower_decl/body_stmt.rs | 1 + crates/perry-hir/src/lower_decl/class_computed.rs | 3 ++- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index b8c289709d..89ea23724f 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -142,6 +142,7 @@ pub(crate) fn lower_class_expr( ctx, &class_expr.class.body, &class, + &synthetic_name, ); let computed_statics: Vec<(String, Expr)> = class .static_fields diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index cfaf674698..76efd02913 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1306,6 +1306,7 @@ pub(crate) fn lower_module_decl( ctx, &class_decl.class.body, &class, + &class.name, ); module .init @@ -1879,6 +1880,7 @@ pub(crate) fn lower_module_decl( ctx, &synth_class_decl.class.body, &class, + &class.name, ); module .init diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index f44e80c8b4..8ff8a24c2e 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1237,6 +1237,7 @@ pub(crate) fn lower_stmt( ctx, &class_decl.class.body, &class, + &class.name, ); module .init diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 12cdf7868f..ca690ab9a3 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -288,6 +288,7 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result (Vec, Vec<(String, Expr)>, Vec) { let mut ordered: Vec<(usize, Expr)> = Vec::new(); let mut field_keys = Vec::new(); @@ -98,7 +99,7 @@ pub(crate) fn prepare_ordered_class_computed_names( let mut resolved = member.clone(); resolved.key_expr = Expr::LocalGet(local); member_registrations.push(class_computed_member_registration_expr( - &class.name, + registration_class_name, &resolved, )); } From a70bc8fa2f2006f98d0d69a994a09e1e03ced74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 18:12:22 +0200 Subject: [PATCH 12/17] fix ordered computed class registrations --- .../src/lower/lower_expr/arm_class.rs | 12 +--- crates/perry-hir/src/lower/module_decl.rs | 26 +-------- crates/perry-hir/src/lower/stmt.rs | 13 +---- crates/perry-hir/src/lower_decl/body_stmt.rs | 13 +---- .../src/lower_decl/class_computed.rs | 55 +++++++++---------- 5 files changed, 31 insertions(+), 88 deletions(-) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 89ea23724f..c7367e650e 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -137,9 +137,8 @@ pub(crate) fn lower_class_expr( // canonical case: `isSchema(C)` was called from Schema.ts's // own top-level `class extends transform(...)` chains, which // run before the module's `init_static_fields_late`. - let (computed_name_evaluations, computed_keys, computed_member_registrations) = + let (computed_name_evaluations, computed_keys) = crate::lower_decl::prepare_ordered_class_computed_names( - ctx, &class_expr.class.body, &class, &synthetic_name, @@ -304,7 +303,6 @@ pub(crate) fn lower_class_expr( }); } seq.extend(computed_name_evaluations); - seq.extend(computed_member_registrations); let fresh_expr = if let Some(owner) = capture_owner { Expr::Sequence(vec![ Expr::LocalSet(owner, Box::new(fresh_expr)), @@ -364,14 +362,6 @@ pub(crate) fn lower_class_expr( captures: captured_args.clone(), }); } - for (field_name, value) in computed_keys { - seq.push(Expr::StaticFieldSet { - class_name: synthetic_name.clone(), - field_name, - value: Box::new(value), - }); - } - seq.extend(computed_member_registrations); // The shared-template path must obey the same source-order plan as the // fresh-object path. Computed names were all resolved above, but their // initializers still interleave with named fields and static blocks. diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 76efd02913..4ebb06a232 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1301,9 +1301,8 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - let (computed_name_evaluations, computed_keys, computed_member_registrations) = + let (computed_name_evaluations, _) = crate::lower_decl::prepare_ordered_class_computed_names( - ctx, &class_decl.class.body, &class, &class.name, @@ -1311,16 +1310,6 @@ pub(crate) fn lower_module_decl( module .init .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); - for (field_name, value) in computed_keys { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name, - value: Box::new(value), - })); - } - module - .init - .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); module.init.extend( crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( &class_decl.class.body, @@ -1875,9 +1864,8 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - let (computed_name_evaluations, computed_keys, computed_member_registrations) = + let (computed_name_evaluations, _) = crate::lower_decl::prepare_ordered_class_computed_names( - ctx, &synth_class_decl.class.body, &class, &class.name, @@ -1885,16 +1873,6 @@ pub(crate) fn lower_module_decl( module .init .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); - for (field_name, value) in computed_keys { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name, - value: Box::new(value), - })); - } - module - .init - .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); module.init.extend( crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( &synth_class_decl.class.body, diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 8ff8a24c2e..be47e4de4e 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1232,9 +1232,8 @@ pub(crate) fn lower_stmt( parent_expr: extends_expr.clone(), })); } - let (computed_name_evaluations, computed_keys, computed_member_registrations) = + let (computed_name_evaluations, _) = crate::lower_decl::prepare_ordered_class_computed_names( - ctx, &class_decl.class.body, &class, &class.name, @@ -1242,16 +1241,6 @@ pub(crate) fn lower_stmt( module .init .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); - for (field_name, value) in computed_keys { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class.name.clone(), - field_name, - value: Box::new(value), - })); - } - module - .init - .extend(computed_member_registrations.into_iter().map(Stmt::Expr)); // Inject static-field-init and static-block-call // statements at the source position of the class // declaration, INTERLEAVED in source order (see diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index ca690ab9a3..ceb02177cd 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -283,9 +283,8 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result