diff --git a/Cargo.lock b/Cargo.lock index c1f2dc8119..cf2804c853 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 0826ae1c39..655dd0fc4f 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 5567aace3f..b3048fa58e 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1565,6 +1565,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 daa864bc27..10f57df939 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -1048,7 +1048,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/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 27e897139c..a6581cd15f 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). @@ -1101,7 +1101,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); @@ -1112,8 +1113,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) @@ -1136,15 +1137,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/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 339208ad2d..50cb97dd32 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -257,16 +257,6 @@ fn lower_string_literal_strict_eq( ctx.block().phi(I1, &incoming) } -/// Magnitude comparands for the inline heap-address test in -/// [`lower_strict_eq_inline_any`]. These mirror -/// `perry-runtime::value::addr_class::{HANDLE_BAND_MAX, is_valid_obj_ptr}`: -/// a `POINTER_TAG` payload below `HANDLE_BAND_MAX` is a registry id -/// (net.Socket, fetch, zlib, revocable Proxy, UI widget), NOT an address, and -/// dereferencing one reads unmapped low memory. Anything outside the window -/// takes the runtime call instead of a header load. -const HANDLE_BAND_MAX_I64: &str = "1048576"; -const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; - /// Inline prefix for the generic `===`/`!==` tail — the arm where BOTH /// operands are statically unconstrained, which emitted one /// `js_eq` → `js_jsvalue_equals` call per comparison and nothing else. @@ -277,7 +267,7 @@ const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; /// **misses**, so a fast path that settles only the hit is worth nothing — /// each case below settles one direction of the real traffic. /// -/// Four cases leave without a call. Each is an exact restatement of what +/// Three cases leave without a call. Each is an exact restatement of what /// `js_jsvalue_equals` computes for that input, not an approximation: /// /// * **identical bits** ⇒ equal, *unless* the value is a plain (untagged) @@ -291,16 +281,12 @@ const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; /// pattern — which is the argument `lower_string_strict_eq_inline` and the /// runtime's own both-short-string arm already rely on. /// * **both INT32, different bits** ⇒ different integers, same argument. -/// * **both `POINTER_TAG`, different payloads, and neither header carries -/// `GC_FLAG_FORWARDED`** ⇒ distinct objects. The runtime's pointer arm is -/// `resolve_forwarding(a) == resolve_forwarding(b)`, and -/// `resolve_forwarding` returns its argument unchanged when the forwarding -/// bit is clear — so two *unforwarded* distinct addresses are exactly its -/// `0` case. Anything forwarded (a post-`js_array_grow` alias, a stale -/// pre-evacuation pointer) takes the call and gets the full walk. The -/// header read is the same one `expr/array_push.rs` emits — `gc_flags` at -/// `ptr - 7`, mask `GC_FLAG_FORWARDED` (0x80) — behind the same magnitude -/// guard the runtime applies before any `GcHeader` dereference. +/// +/// Distinct `POINTER_TAG` values always take the runtime call. Not every +/// pointer-tag payload is a GC allocation: registered and well-known symbols, +/// for example, are process-lifetime `Box` allocations with no `GcHeader`. +/// Generated code has no access to the runtime's allocation registries, so an +/// address-magnitude check cannot make reading `ptr - GC_HEADER_SIZE` safe. /// /// Everything else — a raw-bits module-level object slot (top16 zero), a heap /// string, a bigint, a mixed pair, a boxed wrapper — falls through to @@ -314,18 +300,12 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let same_idx = ctx.new_block("anyeq.same"); let diff_idx = ctx.new_block("anyeq.diff"); - let canon_idx = ctx.new_block("anyeq.canon"); - let band_idx = ctx.new_block("anyeq.band"); - let fwd_idx = ctx.new_block("anyeq.fwd"); let slow_idx = ctx.new_block("anyeq.slow"); let true_idx = ctx.new_block("anyeq.true"); let false_idx = ctx.new_block("anyeq.false"); let merge_idx = ctx.new_block("anyeq.merge"); let same_l = ctx.block_label(same_idx); let diff_l = ctx.block_label(diff_idx); - let canon_l = ctx.block_label(canon_idx); - let band_l = ctx.block_label(band_idx); - let fwd_l = ctx.block_label(fwd_idx); let slow_l = ctx.block_label(slow_idx); let true_l = ctx.block_label(true_idx); let false_l = ctx.block_label(false_idx); @@ -349,21 +329,12 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let same_ok = ctx.block().or(I1, &tagged, ¬_nan); ctx.block().cond_br(&same_ok, &true_l, &slow_l); - // Different bits: only a same-tag pair whose encoding is canonical, or a - // pair of unforwarded heap pointers, is decidable here. + // Different bits: only a same-tag pair whose encoding is canonical is + // decidable here. Pointer pairs need the runtime's allocation registries + // before either payload can safely be treated as a GC allocation. ctx.current_block = diff_idx; let l_tag = ctx.block().lshr(I64, &l_bits, "48"); let r_tag = ctx.block().lshr(I64, &r_bits, "48"); - let l_ptr = ctx - .block() - .icmp_eq(I64, &l_tag, crate::nanbox::POINTER_TAG_TOP16_I64); - let r_ptr = ctx - .block() - .icmp_eq(I64, &r_tag, crate::nanbox::POINTER_TAG_TOP16_I64); - let both_ptr = ctx.block().and(I1, &l_ptr, &r_ptr); - ctx.block().cond_br(&both_ptr, &band_l, &canon_l); - - ctx.current_block = canon_idx; let l_sso = ctx .block() .icmp_eq(I64, &l_tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); @@ -381,32 +352,6 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let canonical = ctx.block().or(I1, &both_sso, &both_i32); ctx.block().cond_br(&canonical, &false_l, &slow_l); - // Both POINTER_TAG. Classify by magnitude before touching a header. - ctx.current_block = band_idx; - let l_addr = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); - let r_addr = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); - let l_above = ctx.block().icmp_uge(I64, &l_addr, HANDLE_BAND_MAX_I64); - let l_below = ctx.block().icmp_ult(I64, &l_addr, HEAP_ADDR_CEILING_I64); - let r_above = ctx.block().icmp_uge(I64, &r_addr, HANDLE_BAND_MAX_I64); - let r_below = ctx.block().icmp_ult(I64, &r_addr, HEAP_ADDR_CEILING_I64); - let l_heap = ctx.block().and(I1, &l_above, &l_below); - let r_heap = ctx.block().and(I1, &r_above, &r_below); - let both_heap = ctx.block().and(I1, &l_heap, &r_heap); - ctx.block().cond_br(&both_heap, &fwd_l, &slow_l); - - ctx.current_block = fwd_idx; - let l_flags_addr = ctx.block().sub(I64, &l_addr, "7"); - let l_flags_ptr = ctx.block().inttoptr(I64, &l_flags_addr); - let l_flags = ctx.block().load(I8, &l_flags_ptr); - let r_flags_addr = ctx.block().sub(I64, &r_addr, "7"); - let r_flags_ptr = ctx.block().inttoptr(I64, &r_flags_addr); - let r_flags = ctx.block().load(I8, &r_flags_ptr); - let either = ctx.block().or(I8, &l_flags, &r_flags); - // GC_FLAG_FORWARDED = 0x80; LLVM i8 literals are signed. - let fwd_bits = ctx.block().and(I8, &either, "-128"); - let no_fwd = ctx.block().icmp_eq(I8, &fwd_bits, "0"); - ctx.block().cond_br(&no_fwd, &false_l, &slow_l); - ctx.current_block = slow_idx; let slow_res = ctx .block() 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 1df50da00a..1b91d93e68 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, }; @@ -2921,10 +2921,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, )); @@ -2942,7 +2949,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) @@ -2973,7 +2980,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/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 abf55e6313..4eb5c09e51 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( @@ -862,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( @@ -893,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( @@ -925,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( @@ -956,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 = @@ -1129,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))); } }; @@ -1334,9 +1349,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 @@ -1346,6 +1358,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/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs index c61826233c..fd14763c34 100644 --- a/crates/perry-codegen/src/lower_call/field_init.rs +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -718,6 +718,28 @@ pub(crate) fn apply_field_initializers_recursive( ); } for (prop, init_expr, is_private) in init_pairs { + // A scalar-replaced `new C()` has no heap receiver. Its fields are + // represented by the allocas in `ctx.scalar_replaced`, and the + // dummy `this_stack` slot exists only so ordinary constructor + // assignments can reach the scalar PropertySet fast path. DefineField + // lowering bypasses PropertySet, so route public named initializers + // to those allocas directly as well. Otherwise `js_class_field_add` + // receives the uninitialized dummy `this` value. + if !is_private { + if let Some(target_id) = ctx.scalar_ctor_target.last().copied() { + let slot = ctx + .scalar_replaced + .get(&target_id) + .and_then(|fields| fields.get(&prop)) + .cloned(); + let value = lower_expr(ctx, &init_expr)?; + if let Some(slot) = slot { + ctx.block().store(DOUBLE, &value, &slot); + crate::expr::root_scalar_replaced_slot(ctx, &slot, &init_expr); + } + continue; + } + } if is_private { let value = lower_expr(ctx, &init_expr)?; let this_val = ctx 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/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 { diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index edb39054ee..140c8d2266 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -151,6 +151,31 @@ fn compile_ir_for_module_with_opts(module: Module, opts: CompileOptions) -> anyh Ok(String::from_utf8(compile_module(&module, opts)?)?) } +#[test] +fn generic_strict_equality_does_not_read_unverified_pointer_headers() { + let module = module_with_classes_and_params( + "generic_strict_equality_pointer_safety.ts", + Vec::new(), + vec![param(1, "left", Type::Any), param(2, "right", Type::Any)], + Type::Boolean, + vec![Stmt::Return(Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(local(1)), + right: Box::new(local(2)), + }))], + ); + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + + assert!( + ir.contains("anyeq.slow") && ir.contains("call i64 @js_eq"), + "distinct generic pointer values need the registry-aware runtime fallback:\n{ir}" + ); + assert!( + !ir.contains("anyeq.fwd"), + "generic equality must not read a GC header after only a pointer-tag/magnitude check:\n{ir}" + ); +} + fn contains_inline_direct_method_shape_guard(ir: &str) -> bool { ir.contains("method_direct.inline_deref") && ir.contains("load atomic i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED acquire") @@ -272,6 +297,7 @@ fn class_with_computed_member(id: u32, name: &str, fields: Vec) -> C }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); class } @@ -9781,6 +9807,39 @@ fn scalar_method_summary_module() -> Module { ) } +fn scalar_field_initializer_module() -> Module { + let mut value_field = class_field("value", Type::Number); + value_field.init = Some(number(42.0)); + let holder = class(109, "Holder", vec![value_field]); + + module_with_classes_and_params( + "scalar_field_initializer.ts", + vec![holder], + Vec::new(), + Type::Number, + vec![ + Stmt::Let { + id: 20, + name: "holder".to_string(), + ty: Type::Named("Holder".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Holder".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Return(Some(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(local(20)), + property: "value".to_string(), + })), + ], + ) +} + fn scalar_method_field_write_module() -> Module { let mut counter = class(111, "Counter", vec![class_field("value", Type::Number)]); counter.constructor = Some(Function { @@ -10421,6 +10480,7 @@ fn scalar_method_boolean_negative_module(case: &str) -> Module { }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); } "inherited_field_shadow" => { @@ -13279,6 +13339,23 @@ fn scalar_replaced_simple_method_call_inlines_summary_without_dispatch() { ); } +#[test] +fn scalar_replaced_class_field_initializer_uses_its_field_slot() { + let ir = String::from_utf8( + compile_module(&scalar_field_initializer_module(), empty_opts()).unwrap(), + ) + .unwrap(); + let probe_ir = function_ir_section(&ir, "perry_fn_scalar_field_initializer_ts__probe"); + assert!( + !probe_ir.contains("call double @js_class_field_add"), + "a scalar-replaced construction has no receiver for DefineField:\n{probe_ir}" + ); + assert!( + probe_ir.contains("store double 42.0"), + "the initializer must populate the scalar field slot:\n{probe_ir}" + ); +} + #[test] fn artifact_records_scalar_replaced_method_summary_inline() { let artifact = compile_artifact_json_for_module(scalar_method_summary_module()); 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 b2c9799908..f5f1f74f8f 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_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/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/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 564d811065..0983973c4d 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -225,6 +225,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // Try to extract class name from callee match callee_expr { ast::Expr::Ident(ident) => { + let source_class_name = ident.sym.as_str(); // Hidden dynamic-function constructors reached through // `.constructor` are pre-classified by // `fn_ctor_env`. Their call form already const-folds; construction @@ -273,7 +274,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R let mut class_name = if is_current_class_self { ctx.current_class.clone().unwrap() } else { - ctx.resolve_class_name(ident.sym.as_str()) + ctx.resolve_class_name(source_class_name) }; // Snapshot the callee identifier's local/param binding at the TOP // of the ident arm, before any argument lowering or native-module @@ -365,7 +366,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R || callee_local_at_entry.is_some() || ctx.lookup_func(&class_name).is_some() || ctx.lookup_imported_func(&class_name).is_some() - || ctx.forward_class_names.contains(class_name.as_str())); + || ctx.forward_class_names.contains(source_class_name)); if matches!( ctx.lookup_native_module(&class_name), Some(("url", Some("Url"))) @@ -1573,6 +1574,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R && ctx.lookup_func(&class_name).is_none() && ctx.lookup_imported_func(&class_name).is_none() && ctx.lookup_native_module(&class_name).is_none() + && !ctx.forward_class_names.contains(source_class_name) && !is_reified_global_builtin_constructor(&class_name) { return Ok(Expr::NewDynamic { 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..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,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) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_expr.class.body, + &class, + &synthetic_name, + ); 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()) @@ -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 @@ -291,6 +292,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,7 +302,7 @@ pub(crate) fn lower_class_expr( parent_expr: p, }); } - seq.extend(computed_member_registrations); + seq.extend(computed_name_evaluations); let fresh_expr = if let Some(owner) = capture_owner { Expr::Sequence(vec![ Expr::LocalSet(owner, Box::new(fresh_expr)), @@ -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 @@ -359,48 +362,46 @@ 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); - 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/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 07ff2a052e..4ebb06a232 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1301,36 +1301,24 @@ 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, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.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 +1864,24 @@ 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, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &synth_class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.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..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,31 +132,80 @@ 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() { - rewrite_stmts(&mut f.body, s, s); + 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 + // `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 ids.contains(¶m.id) { + param.ty = Type::Any; + Some(param.id) + } else { + None + } + }) + .collect(); + rewrite_stmts(&mut f.body, ids, ids); + 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() { - 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 ------------------------ @@ -161,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); } @@ -243,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 {}", @@ -355,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; @@ -463,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); @@ -476,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 } @@ -614,11 +686,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..be47e4de4e 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1232,14 +1232,15 @@ 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, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.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 +1260,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/tests.rs b/crates/perry-hir/src/lower/tests.rs index 92b3df7cdb..bdc52af2a3 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -909,6 +909,141 @@ fn nested_class_shadowing_outer_var_constructs_the_class_not_the_local() { ); } +/// A sibling class declaration is already a known lexical binding while an +/// earlier class method is lowered, even though its registry entry is emitted +/// later. The unresolved-constructor guard must preserve that forward binding. +#[test] +fn nested_method_constructs_forward_declared_sibling_class() { + let source = r#" + function make() { + class Base { + makeChild(): any { + return new Child(); + } + } + class Child extends Base {} + return Base; + } + "#; + 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 make_child = hir + .classes + .iter() + .find(|class| class.name == "Base") + .expect("Base class is lowered") + .methods + .iter() + .find(|method| method.name == "makeChild") + .expect("makeChild method is lowered"); + + assert!( + matches!( + make_child.body.as_slice(), + [crate::Stmt::Return(Some(crate::Expr::New { class_name, .. }))] + if class_name == "Child" + ), + "forward sibling construction must remain a static class construct: {:#?}", + make_child.body + ); +} + +/// Forward-declaration bookkeeping uses source identifiers, while a sibling +/// class may use a collision-safe registration name. Constructor resolution +/// must compare the source identifier before rejecting the forward binding. +#[test] +fn nested_method_constructs_collision_renamed_forward_sibling_class() { + let source = r#" + function first() { + class Child {} + return Child; + } + function make() { + class Base { + makeChild(): any { + return new Child(); + } + } + class Child extends Base {} + return Base; + } + "#; + 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 make_child = hir + .classes + .iter() + .find(|class| class.name == "Base") + .expect("Base class is lowered") + .methods + .iter() + .find(|method| method.name == "makeChild") + .expect("makeChild method is lowered"); + + assert!( + matches!( + make_child.body.as_slice(), + [crate::Stmt::Return(Some(crate::Expr::New { class_name, .. }))] + if class_name.starts_with("Child$") + ), + "collision-renamed forward sibling construction must remain a static class construct: {:#?}", + make_child.body + ); +} + +/// 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 93ae4f1764..db318cd4c7 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