diff --git a/changelog.d/7052-parity-regressions.md b/changelog.d/7052-parity-regressions.md new file mode 100644 index 0000000000..8f398c7cef --- /dev/null +++ b/changelog.d/7052-parity-regressions.md @@ -0,0 +1 @@ +**Fix five TypeScript parity regressions (#6828, #6876, #6884, #6906, #6967):** UTC Date calendar getters no longer depend on the process timezone; static loop unrolling preserves function-scoped `var` values between iterations; out-of-bounds numeric TypedArray reads become `NaN` in arithmetic while retaining the call-free in-bounds path; reassigned typed/class locals fall back to runtime dispatch instead of trusting stale annotations; and dynamic `__proto__` assignment invokes the inherited legacy setter without breaking own descriptors or null-prototype objects. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 865230483c..d5fe6bf440 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -229,6 +229,8 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { output_type, }; + let module_reassigned_locals = crate::collectors::reassigned_locals_in_module(hir); + for (func_id, closure_expr) in closures { if cross_module.typed_f64_closures.contains(func_id) { compile_typed_f64_closure( @@ -288,6 +290,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_prefix, module_boxed_vars, module_receiver_types, + &module_reassigned_locals, closure_rest_params, cross_module, ) diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 504323ffd6..ac8daf9a27 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -502,6 +502,10 @@ pub(super) fn compile_closure( // keeps its declared type at its read sites. NOT the typed-ABI capture // map — the typed closure clones take `module_local_types` instead. module_receiver_types: &HashMap, + // Reassignments from every executable body in the module. Captured locals + // inherit module-wide receiver types, so their invalidation scope must be + // module-wide too. + module_reassigned_locals: &HashSet, closure_rest_params: &HashMap, cross_module: &CrossModuleCtx, ) -> Result<()> { @@ -778,6 +782,9 @@ pub(super) fn compile_closure( std::collections::HashSet::new() }; + let mut reassigned_locals = module_reassigned_locals.clone(); + reassigned_locals.extend(crate::collectors::reassigned_locals(body)); + let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -787,6 +794,7 @@ pub(super) fn compile_closure( native_facts: &native_facts, locals, local_types, + reassigned_locals, const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), current_block: 0, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 308e861001..85dd373928 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -680,6 +680,7 @@ pub(super) fn compile_module_entry( native_facts: &main_native_facts, locals: HashMap::new(), local_types: init_local_types, + reassigned_locals: crate::collectors::reassigned_locals(&hir.init), const_string_locals: HashMap::new(), const_number_locals: HashMap::new(), current_block: 0, @@ -1306,6 +1307,7 @@ pub(super) fn compile_module_entry( native_facts: &init_native_facts, locals: HashMap::new(), local_types: HashMap::new(), + reassigned_locals: crate::collectors::reassigned_locals(&hir.init), const_string_locals: HashMap::new(), const_number_locals: HashMap::new(), current_block: 0, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 03014eba2a..3508942711 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -677,6 +677,7 @@ pub(super) fn compile_function( native_facts: &native_facts, locals, local_types, + reassigned_locals: crate::collectors::reassigned_locals(&f.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), current_block: 0, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index c5548c7318..4bebde0c0b 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -423,6 +423,7 @@ pub(super) fn compile_method( native_facts: &native_facts, locals, local_types, + reassigned_locals: crate::collectors::reassigned_locals(&method.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), current_block: 0, @@ -1467,6 +1468,7 @@ pub(super) fn compile_static_method( native_facts: &native_facts, locals, local_types, + reassigned_locals: crate::collectors::reassigned_locals(&f.body), const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), current_block: 0, diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index d7e2120d6d..04373a21cb 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -78,7 +78,8 @@ pub(crate) use shadow_slots::{ collect_declared_shadow_slots_in_stmts, collect_shadow_slot_clear_points, }; pub(crate) use spec_abi_sites::{ - collect_spec_abi_facts, reassigned_locals, SpecParamRep, SpecTaBinding, + collect_spec_abi_facts, reassigned_locals, reassigned_locals_in_module, SpecParamRep, + SpecTaBinding, }; pub(crate) use this_as_value::{ class_chain_extends_builtin_error, class_chain_has_unmodeled_base, class_uses_this_as_value, diff --git a/crates/perry-codegen/src/collectors/spec_abi_sites.rs b/crates/perry-codegen/src/collectors/spec_abi_sites.rs index 1122996ec7..3e1b2cb9f6 100644 --- a/crates/perry-codegen/src/collectors/spec_abi_sites.rs +++ b/crates/perry-codegen/src/collectors/spec_abi_sites.rs @@ -125,6 +125,16 @@ pub(crate) fn reassigned_locals(stmts: &[Stmt]) -> HashSet { scan.writes } +/// Every local reassigned in any executable body in `hir`. +/// +/// Closure codegen seeds receiver types from module-wide declarations, so it +/// must pair that oracle with the equally broad reassignment set. Otherwise a +/// closure can specialize a captured receiver from its declared type even +/// after an enclosing body has replaced the binding with another value. +pub(crate) fn reassigned_locals_in_module(hir: &Module) -> HashSet { + scan_whole_module(hir).writes +} + /// Single-id convenience over [`reassigned_locals`]. #[cfg(test)] pub(crate) fn local_is_reassigned(stmts: &[Stmt], id: u32) -> bool { diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index a377ff782e..e94cde7ccc 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -26,6 +26,17 @@ use super::temp_root::{lower_operand_pair_rooted, temp_root_release}; use super::{is_known_finite, lower_expr, FnCtx}; fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, bool)> { + // #6884: a statically typed numeric TypedArray read is Number|undefined, + // not an unconditional raw f64. In arithmetic context the OOB `undefined` + // must become canonical NaN. Sink that conversion into the OOB/cold arms + // so the in-bounds hot path remains a guard plus native load. + if let Expr::IndexGet { object, index } = expr { + if let Some(value) = + super::ta_param_f64_read::try_lower_ta_f64_read_for_number_context(ctx, object, index)? + { + return Ok((value, true)); + } + } // Repsel Phase 4a.0 (#6904): a numeric-proven `a || b` / `a && b` / // `a ?? b` consumed as an arithmetic operand lowers with BOTH sides in // number context, so the selection is a real-double diamond (`fcmp one` + diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index d83940d4da..c0a9b52e49 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -196,6 +196,13 @@ pub(crate) struct FnCtx<'a> { /// tracking" extension). Populated from function params and `Stmt::Let` /// declarations as they're lowered. pub local_types: std::collections::HashMap, + /// Bindings assigned after declaration anywhere in this region. + /// + /// A TypeScript annotation describes the source-level contract, but an + /// `as any` assignment can replace the runtime value with an unrelated + /// class. Class-keyed lowering must therefore ignore `local_types` for + /// these ids and use runtime dispatch (#6906). + pub reassigned_locals: std::collections::HashSet, /// Immutable locals whose initializer is a string literal. These values /// can be resolved to the module's interned string global at a use site; /// unlike a runtime dynamic-key cache, this does not retain a movable diff --git a/crates/perry-codegen/src/expr/ta_param_f64_read.rs b/crates/perry-codegen/src/expr/ta_param_f64_read.rs index cff3f3f46d..d270dd186c 100644 --- a/crates/perry-codegen/src/expr/ta_param_f64_read.rs +++ b/crates/perry-codegen/src/expr/ta_param_f64_read.rs @@ -117,8 +117,9 @@ pub(crate) fn try_lower_ta_param_f64_read( let Some((kind, elem_ty, elem_size, conv)) = checked_typed_array_f64_kind(ctx, object) else { return Ok(None); }; - let value = - lower_checked_typed_array_f64_load(ctx, object, index, kind, elem_ty, elem_size, conv)?; + let value = lower_checked_typed_array_f64_load( + ctx, object, index, kind, elem_ty, elem_size, conv, false, + )?; let lowered = LoweredValue::js_value(value.clone()); ctx.record_lowered_value_with_access_mode( "TypedArrayGet", @@ -138,6 +139,26 @@ pub(crate) fn try_lower_ta_param_f64_read( Ok(Some(value)) } +/// Number-context sibling of [`try_lower_ta_param_f64_read`]. +/// +/// The in-bounds hot path is the same guard + native load. Only the OOB and +/// cold fallback arms apply `ToNumber`, keeping arithmetic call-free for the +/// common case while making `1000 + ta[99]` produce canonical `NaN` (#6884). +pub(crate) fn try_lower_ta_f64_read_for_number_context( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + if !ta_param_f64_read_enabled() || !numeric_index_has_integer_array_index_proof(ctx, index) { + return Ok(None); + } + let Some((kind, elem_ty, elem_size, conv)) = checked_typed_array_f64_kind(ctx, object) else { + return Ok(None); + }; + lower_checked_typed_array_f64_load(ctx, object, index, kind, elem_ty, elem_size, conv, true) + .map(Some) +} + /// Emit the checked inline f64 element load. Same runtime-fact guard and header /// bounds check as [`super::i32_fast_path`]'s `lower_checked_typed_array_i32_load` /// (pointer + inline-storage `PERRY_TA_VIEW_GUARD == 0` + kind-cache addr/kind), @@ -152,6 +173,7 @@ fn lower_checked_typed_array_f64_load( elem_ty: crate::types::LlvmType, elem_size: u32, conv: F64Conv, + number_context: bool, ) -> Result { let obj_box = lower_expr(ctx, object)?; let idx_i32 = lower_expr_as_i32(ctx, index)?; @@ -231,27 +253,43 @@ fn lower_checked_typed_array_f64_load( (val, end) }; - // ---- oob: in-kind out-of-bounds -> TAG_UNDEFINED (== js_typed_array_get) -- + // ---- oob -------------------------------------------------------------- + // Value context preserves the typed-array read (`undefined`). Arithmetic + // context applies ToNumber at the read boundary, yielding a canonical NaN + // instead of allowing TAG_UNDEFINED's NaN payload to leak through fadd + // and remain observably `undefined` (#6884). ctx.current_block = oob_idx; let (oob_val, oob_end) = { let blk = ctx.block(); let end = blk.label.clone(); blk.br(&merge_label); - (double_literal(f64::from_bits(TAG_UNDEFINED)), end) + ( + if number_context { + double_literal(f64::NAN) + } else { + double_literal(f64::from_bits(TAG_UNDEFINED)) + }, + end, + ) }; // ---- slow: view / detached / wrong-kind / non-TA -> memory-safe helper --- ctx.current_block = slow_idx; let (slow_val, slow_end) = { let blk = ctx.block(); - let v = blk.call( + let value = blk.call( DOUBLE, "js_typed_array_read_f64", &[(I64, &raw), (I32, &idx_i32)], ); + let value = if number_context { + blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &value)]) + } else { + value + }; let end = blk.label.clone(); blk.br(&merge_label); - (v, end) + (value, end) }; // ---- merge ---- diff --git a/crates/perry-codegen/src/type_analysis/pod.rs b/crates/perry-codegen/src/type_analysis/pod.rs index e91dbabfdd..87391a4f8b 100644 --- a/crates/perry-codegen/src/type_analysis/pod.rs +++ b/crates/perry-codegen/src/type_analysis/pod.rs @@ -413,9 +413,14 @@ pub(crate) fn expr_may_return_boxed_value_from_raw_f64_fallback( .and_then(|class_name| class_field_declared_type(ctx, &class_name, property)) .as_ref() .is_some_and(crate::typed_shape::type_is_raw_f64_candidate), - Expr::IndexGet { object, .. } => static_type_of(ctx, object) - .as_ref() - .is_some_and(type_has_numeric_pointer_free_array_layout_for_fallback), + Expr::IndexGet { object, .. } => { + receiver_class_name(ctx, object) + .as_deref() + .is_some_and(crate::type_analysis::is_numeric_typed_array_class) + || static_type_of(ctx, object) + .as_ref() + .is_some_and(type_has_numeric_pointer_free_array_layout_for_fallback) + } // Repsel Phase 4a.0: `a || b` / `a && b` / `a ?? b` pass ONE operand // value through, so the result carries the boxed-fallback hazard when // EITHER operand does (`counts[v] || 0` can surface the read's boxed diff --git a/crates/perry-codegen/src/type_analysis/predicates.rs b/crates/perry-codegen/src/type_analysis/predicates.rs index 4e1982e0ae..5bde2dd15e 100644 --- a/crates/perry-codegen/src/type_analysis/predicates.rs +++ b/crates/perry-codegen/src/type_analysis/predicates.rs @@ -274,6 +274,11 @@ fn declared_type_overrides_shape_proof(ctx: &FnCtx<'_>, id: &u32) -> bool { /// pick the right `perry_method__` function. pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { match e { + // A declared class/typed-array type is not a lifetime proof. An + // `as any` reassignment can replace the binding with a different + // runtime value, so class-specific field/index/method lowering would + // be unsound for every use of a reassigned local (#6906). + Expr::LocalGet(id) if ctx.reassigned_locals.contains(id) => None, // Representation-selection Phase 3b: a shape-proven Ptr local // (or one of its const aliases — the exact-receiver inliner's // `__cmpd_base_N` receivers are typed `Any`) has a provenance-exact diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index 81f9c11491..640c40d23f 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -1074,7 +1074,12 @@ pub extern "C" fn js_date_new_local_components( alloc_date_cell(time_clip(local_ms - (tz_offset * 1000) as f64)) } -// --- UTC getters: same impl as the regular getters since we store UTC internally --- +// --- UTC getters ----------------------------------------------------------- +// +// Date cells store a UTC timestamp, but the regular getters intentionally +// convert that timestamp through `localtime`. UTC getters must decompose the +// stored timestamp directly; delegating to `getFullYear`/`getMonth`/`getDate` +// makes them silently timezone-dependent (#6967). #[no_mangle] pub extern "C" fn js_date_get_utc_day(timestamp: f64) -> f64 { @@ -1090,19 +1095,34 @@ pub extern "C" fn js_date_get_utc_day(timestamp: f64) -> f64 { #[no_mangle] pub extern "C" fn js_date_get_utc_full_year(timestamp: f64) -> f64 { let timestamp = date_cell_timestamp(timestamp); - js_date_get_full_year(timestamp) + if timestamp.is_nan() { + return f64::NAN; + } + let secs = (timestamp as i64).div_euclid(1000); + let (year, _, _, _, _, _) = timestamp_to_components(secs); + year as f64 } #[no_mangle] pub extern "C" fn js_date_get_utc_month(timestamp: f64) -> f64 { let timestamp = date_cell_timestamp(timestamp); - js_date_get_month(timestamp) + if timestamp.is_nan() { + return f64::NAN; + } + let secs = (timestamp as i64).div_euclid(1000); + let (_, month, _, _, _, _) = timestamp_to_components(secs); + (month - 1) as f64 } #[no_mangle] pub extern "C" fn js_date_get_utc_date(timestamp: f64) -> f64 { let timestamp = date_cell_timestamp(timestamp); - js_date_get_date(timestamp) + if timestamp.is_nan() { + return f64::NAN; + } + let secs = (timestamp as i64).div_euclid(1000); + let (_, _, day, _, _, _) = timestamp_to_components(secs); + day as f64 } #[no_mangle] @@ -1689,6 +1709,31 @@ mod tests { assert_eq!((y, m, d, h, min, s), (2024, 1, 15, 12, 30, 45)); } + #[test] + fn utc_getters_ignore_process_timezone() { + const CHILD_MARKER: &str = "PERRY_DATE_UTC_GETTER_CHILD"; + if std::env::var_os(CHILD_MARKER).is_some() { + // 2025-06-20T00:00:00.000Z is still June 19 in Los Angeles. + // The three UTC calendar getters must nevertheless keep the UTC + // date, while the old delegation to local getters returned 19. + let timestamp = 1_750_377_600_000.0; + assert_eq!(js_date_get_date(timestamp), 19.0); + assert_eq!(js_date_get_utc_full_year(timestamp), 2025.0); + assert_eq!(js_date_get_utc_month(timestamp), 5.0); + assert_eq!(js_date_get_utc_date(timestamp), 20.0); + return; + } + + let status = std::process::Command::new(std::env::current_exe().expect("current test exe")) + .arg("date::tests::utc_getters_ignore_process_timezone") + .arg("--exact") + .env("TZ", "America/Los_Angeles") + .env(CHILD_MARKER, "1") + .status() + .expect("spawn timezone-isolated date getter test"); + assert!(status.success(), "timezone-isolated child failed"); + } + // Helpers for the setter API: a plain f64 is already its own NaN-boxed // number; `undefined` is the boxed sentinel. fn undef() -> f64 { diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index c6e8f74fef..94f422af67 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -285,6 +285,15 @@ pub(crate) fn object_proto_descriptors_in_use() -> bool { /// has an own property for THIS key; an absent key cannot be intercepted, so the /// fast path stays safe even while unrelated descriptors exist on the prototype. pub(crate) fn object_proto_may_intercept_key(key: f64) -> bool { + // #6828: `%Object.prototype%` always owns the Annex-B `__proto__` + // accessor, even though Perry implements that intrinsic in the ordinary + // [[Set]] walk rather than materializing a closure-backed descriptor. + // Treat it as an interceptor so the plain-object direct-store lane cannot + // create an own enumerable `"__proto__"` property before the walk gets a + // chance to invoke the intrinsic setter. + if unsafe { reflect_support::key_to_rust_string(key) }.as_deref() == Some("__proto__") { + return true; + } if !object_proto_descriptors_in_use() { return false; } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 733f360c2f..69c1057ce4 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1633,6 +1633,33 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) } }; } + // #6828: `%Object.prototype%.__proto__` is a legacy accessor whose + // setter performs `SetPrototypeOf(Receiver, value)`. Perry exposes the + // getter intrinsically but does not materialize the built-in accessor + // in the ordinary descriptor table, so model it at the exact point in + // the [[Set]] walk where that descriptor would be found. + // + // Keep this AFTER `own_set_descriptor`: a user-installed own + // `__proto__` data/accessor property on an object earlier in the chain + // must win. A null-prototype receiver never reaches the canonical + // Object.prototype and therefore still creates an ordinary own data + // property. Per Annex B, a primitive RHS is ignored rather than + // throwing (unlike `Object.setPrototypeOf`). + let current_addr = extract_pointer(current.to_bits()) as usize; + if current_addr != 0 + && current_addr == crate::array::object_prototype_addr() + && key_to_rust_string(key).as_deref() == Some("__proto__") + { + let value_bits = value.to_bits(); + let valid_proto = value_bits == TAG_NULL + || lookup(value).is_some() + || crate::object::class_ref_id(value).is_some() + || unsafe { crate::object::value_is_object_like(value) }; + if valid_proto && reflect_value_is_object(receiver) { + crate::object::js_object_set_prototype_of(receiver, value); + } + return true; + } if crate::closure::is_closure_ptr(extract_pointer(current.to_bits()) as usize) { // ECMAScript poison pill: `fn.caller = v` / `fn.arguments = v` on // a strict-mode function (all Perry-compiled code) throws via the diff --git a/crates/perry-transform/src/unroll/escape_analysis.rs b/crates/perry-transform/src/unroll/escape_analysis.rs index 4427e679d0..29c56ab94b 100644 --- a/crates/perry-transform/src/unroll/escape_analysis.rs +++ b/crates/perry-transform/src/unroll/escape_analysis.rs @@ -1,52 +1,65 @@ //! #2308 escaping-id analysis for the static-loop unroller. //! //! Splits out the pass that decides which loop-body-declared locals are -//! hoisted, function-scoped `var`s (referenced outside the loop body) and so -//! must keep their original id across unrolled copies rather than being -//! renamed per copy by `refresh_local_ids`. See `compute_loop_escaping_ids`. +//! shared bindings and so must keep their original id across unrolled copies +//! rather than being renamed per copy by `refresh_local_ids`. See +//! `compute_loop_escaping_ids`. use perry_hir::types::LocalId; use perry_hir::walker::walk_expr_children; use perry_hir::{Expr, Stmt}; use std::collections::{HashMap, HashSet}; -/// #2308: compute the set of loop-body-declared local ids that are -/// referenced OUTSIDE the loop body declaring them — i.e. hoisted, -/// function-scoped `var`s whose value is read after (or otherwise outside) -/// the loop they're declared in. The unroller must NOT rename these per -/// copy: every unrolled copy has to write the same slot so a later read of -/// the original id observes the last iteration's value, matching JS `var` -/// semantics. A block-scoped `let`/`const` declared in a loop body can -/// never be referenced outside it, so it never lands in this set and keeps -/// getting fresh per-copy ids (preserving distinct closure captures). +/// #2308 / #6876: compute the set of loop-body-declared local ids that name +/// shared bindings. This includes: +/// +/// - ids referenced outside the loop body; and +/// - ids also declared outside the loop body. +/// +/// The second case is how lowered, hoisted `var` declarations are represented: +/// one `Stmt::Let` creates the function-scoped slot at entry and the declaration +/// in the source loop reuses that same id. Such a `var` still has to keep its +/// value across iterations even when every read is inside the loop. Looking at +/// references alone missed that case and refreshed the declaration to a new id +/// per unrolled copy. +/// +/// The unroller must NOT rename shared ids: every unrolled copy has to use the +/// same slot, matching JavaScript `var` semantics. A block-scoped `let`/`const` +/// declared in a loop body has neither an outside declaration nor a legal +/// outside reference, so it keeps getting fresh per-copy ids (preserving +/// distinct closure captures). /// /// Computed on the ORIGINAL (un-unrolled) body so reference counts are -/// stable: `total` counts every use site in the whole scope; for each loop -/// `inside` counts uses within that loop's body. `total > inside` for a -/// loop-body-declared id ⇒ it's used somewhere outside the loop ⇒ escaping. +/// stable. For references and declarations independently, `total > inside` +/// means the loop-body declaration participates in a binding visible outside +/// that body and must be protected. pub(super) fn compute_loop_escaping_ids(stmts: &[Stmt]) -> HashSet { - let mut total: HashMap = HashMap::new(); - count_local_refs_stmts(stmts, &mut total); + let mut total_refs: HashMap = HashMap::new(); + count_local_refs_stmts(stmts, &mut total_refs); + let mut total_decls: HashMap = HashMap::new(); + count_declared_ids_stmts(stmts, &mut total_decls); let mut escaping = HashSet::new(); - collect_escaping_in_stmts(stmts, &total, &mut escaping); + collect_escaping_in_stmts(stmts, &total_refs, &total_decls, &mut escaping); escaping } fn collect_escaping_in_stmts( stmts: &[Stmt], - total: &HashMap, + total_refs: &HashMap, + total_decls: &HashMap, escaping: &mut HashSet, ) { for s in stmts { if let Stmt::For { body, .. } = s { - let mut inside: HashMap = HashMap::new(); - count_local_refs_stmts(body, &mut inside); + let mut inside_refs: HashMap = HashMap::new(); + count_local_refs_stmts(body, &mut inside_refs); let mut decls: HashSet = HashSet::new(); collect_declared_ids_stmts(body, &mut decls); for id in decls { - let t = total.get(&id).copied().unwrap_or(0); - let ins = inside.get(&id).copied().unwrap_or(0); - if t > ins { + let refs_escape = total_refs.get(&id).copied().unwrap_or(0) + > inside_refs.get(&id).copied().unwrap_or(0); + let declaration_is_shared = total_decls.get(&id).copied().unwrap_or(0) > 1; + if refs_escape || declaration_is_shared { escaping.insert(id); } } @@ -54,7 +67,7 @@ fn collect_escaping_in_stmts( // Recurse into every nested stmt list so inner loops are analyzed // against the same whole-scope `total` reference counts. each_child_stmt_list(s, &mut |list| { - collect_escaping_in_stmts(list, total, escaping) + collect_escaping_in_stmts(list, total_refs, total_decls, escaping) }); } } @@ -78,6 +91,25 @@ fn collect_declared_ids_stmts(stmts: &[Stmt], out: &mut HashSet) { } } +/// Count declarations rather than collecting unique ids. A repeated id is the +/// key signal for a lowered hoisted `var`: its function-entry declaration and +/// its source-position declaration intentionally address the same slot. +fn count_declared_ids_stmts(stmts: &[Stmt], out: &mut HashMap) { + for s in stmts { + if let Stmt::Let { id, .. } = s { + *out.entry(*id).or_insert(0) += 1; + } + if let Stmt::For { init, .. } = s { + if let Some(init_stmt) = init { + if let Stmt::Let { id, .. } = init_stmt.as_ref() { + *out.entry(*id).or_insert(0) += 1; + } + } + } + each_child_stmt_list(s, &mut |list| count_declared_ids_stmts(list, out)); + } +} + /// Invoke `f` on each nested `&[Stmt]` directly owned by `stmt` (then/else /// arms, loop/switch/try bodies, labeled body). Used by the #2308 /// escaping-id analysis to recurse without duplicating the stmt match. diff --git a/crates/perry-transform/src/unroll/mod.rs b/crates/perry-transform/src/unroll/mod.rs index 2c771e7c59..71dda219d3 100644 --- a/crates/perry-transform/src/unroll/mod.rs +++ b/crates/perry-transform/src/unroll/mod.rs @@ -1395,6 +1395,66 @@ mod tests { } } + /// #6876: a hoisted `var` is declared once at function entry and again at + /// its source position with the SAME id. It must keep that id across + /// unrolled copies even when no reference escapes the loop body. + #[test] + fn loop_local_hoisted_var_keeps_original_id() { + // var value; + // for (let i = 0; i < 3; i++) { + // var value; + // if (i === 0) value = "kept"; + // use(value); + // } + let i = 1u32; + let value = 2u32; + let hoist = Stmt::Let { + id: value, + name: "value".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Undefined), + }; + let body = vec![ + Stmt::Let { + id: value, + name: "value".into(), + ty: Type::Any, + mutable: true, + init: None, + }, + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Eq, + left: Box::new(ivar(i)), + right: Box::new(integer(0)), + }, + then_branch: vec![Stmt::Expr(Expr::LocalSet( + value, + Box::new(Expr::String("kept".into())), + ))], + else_branch: None, + }, + Stmt::Expr(Expr::LocalGet(value)), + ]; + let f = make_for(i, 0, 3, body, CompareOp::Lt); + let mut stmts = vec![hoist, f]; + let mut changed = false; + run_unroll_in_stmts(&mut stmts, &mut changed); + assert!(changed, "expected unroll to fire"); + + for s in &stmts { + if let Stmt::Let { name, id, .. } = s { + if name == "value" { + assert_eq!( + *id, value, + "all copies of a hoisted var must address its function slot" + ); + } + } + } + } + /// #2308 guard: a block-scoped `let` declared in the loop body and /// referenced ONLY inside it (here, captured by a per-iteration closure) /// must still get fresh ids per copy, so each closure binds a distinct diff --git a/test-files/test_gap_object_proto_proxy_2820_2846.ts b/test-files/test_gap_object_proto_proxy_2820_2846.ts index 94443ee335..3ccff8cb98 100644 --- a/test-files/test_gap_object_proto_proxy_2820_2846.ts +++ b/test-files/test_gap_object_proto_proxy_2820_2846.ts @@ -75,6 +75,49 @@ const bare: any = {}; Object.setPrototypeOf(bare, null); console.log("setProto(obj, null) -> getProto null:", Object.getPrototypeOf(bare) === null); +// --- #6828: assignment invokes Object.prototype.__proto__ setter ----------- +const assignedProto: any = { inherited: "yes" }; +const assigned: any = {}; +assigned.__proto__ = assignedProto; +console.log( + "legacy proto assignment:", + assigned.inherited, + Object.getPrototypeOf(assigned) === assignedProto, + Object.keys(assigned).join(","), +); + +// The Annex-B setter ignores a primitive RHS rather than throwing. +assigned.__proto__ = 7; +console.log("legacy proto primitive ignored:", Object.getPrototypeOf(assigned) === assignedProto); + +// A null-prototype object does not inherit the legacy setter, so this is an +// ordinary own enumerable data property. +const noLegacySetter: any = Object.create(null); +noLegacySetter.__proto__ = assignedProto; +console.log( + "null-proto own __proto__:", + Object.getPrototypeOf(noLegacySetter) === null, + Object.prototype.hasOwnProperty.call(noLegacySetter, "__proto__"), + Object.keys(noLegacySetter).join(","), +); + +// An own data descriptor also shadows the inherited legacy accessor. +const ownProtoData: any = {}; +Object.defineProperty(ownProtoData, "__proto__", { + value: "before", + writable: true, + enumerable: true, + configurable: true, +}); +const ownProtoDataParent = Object.getPrototypeOf(ownProtoData); +ownProtoData.__proto__ = "after"; +console.log( + "own __proto__ shadows setter:", + ownProtoData.__proto__, + Object.getPrototypeOf(ownProtoData) === ownProtoDataParent, + Object.keys(ownProtoData).join(","), +); + // --- Proxy construction validation --- threw = false; try { diff --git a/test-files/test_gap_specabi_reassign.ts b/test-files/test_gap_specabi_reassign.ts index cd49236570..e44d64c88f 100644 --- a/test-files/test_gap_specabi_reassign.ts +++ b/test-files/test_gap_specabi_reassign.ts @@ -11,10 +11,25 @@ console.log("before:", first(P)); P = new Int32Array([7, 8]); console.log("after:", first(P)); -// NOTE: reassigning P to a PLAIN array (`P = [99]`) trips the pre-existing -// declared-type staleness bug tracked as #6906 (reads through the reassigned -// binding return undefined), independent of the spec-ABI routing this test -// gates — kept out so the test can gate on the routing property. +// #6906: the source-level typed-array type is not a lifetime proof. An +// `as any` assignment can replace the binding with a plain array, and every +// later access must use runtime dispatch rather than stale typed-array +// lowering. +P = [99, 101] as any; +console.log("plain:", first(P), P[1], P.length); + P = new Int32Array(1); -P[0] = 99; +P[0] = 123; console.log("third:", first(P), P.length); + +// #7052 review regression: closure receiver-type facts are module-wide, so +// reassignment invalidation must include the enclosing body too. The closure +// must observe the replacement plain array through generic property access. +function capturedAfterReassign(): string { + let captured: Int32Array = new Int32Array([5]); + const read = (): string => `${captured[0]}:${captured.length}`; + captured = [77, 88] as any; + return read(); +} + +console.log("captured:", capturedAfterReassign()); diff --git a/test-files/test_gap_ta_param_numeric_read.ts b/test-files/test_gap_ta_param_numeric_read.ts index abb052f069..185803afea 100644 --- a/test-files/test_gap_ta_param_numeric_read.ts +++ b/test-files/test_gap_ta_param_numeric_read.ts @@ -93,10 +93,9 @@ function readAdd(S: Int32Array, i: number): number { } console.log("inb", readAdd(i32, 3)); // 1000 + 7 -// ---- OOB / negative / fractional reads observed in SAFE contexts (the read -// itself yields `undefined`; we avoid `+` here because a separate, pre-existing -// codegen issue mishandles `number + ` — tracked apart -// from this fast path, which is bit-exact with the runtime getter). ---- +// ---- OOB / negative / fractional reads ------------------------------------ +// Value context preserves `undefined`; arithmetic context applies ToNumber +// and therefore yields NaN (#6884). function eqUndef(S: Int32Array, i: number): boolean { return S[i] === undefined; } @@ -105,8 +104,11 @@ function strOf(S: Int32Array, i: number): string { } console.log("oob-eq", eqUndef(i32, 8), eqUndef(i32, -1), eqUndef(i32, 3)); // true true false console.log("oob-str", strOf(i32, 8), strOf(i32, -1), strOf(i32, 3)); // undefined undefined 7 +console.log("oob-add", readAdd(i32, 99), readAdd(i32, -1), 1000 + i32[99]); +console.log("oob-arith", 1000 - i32[99], 2 * i32[99], i32[99] / 2); // Fractional index reads `undefined` (must NOT round to element 3 via ToInt32). console.log("frac-eq", eqUndef(i32, 3.9), eqUndef(i32, 3)); // true false +console.log("frac-add", readAdd(i32, 3.9)); // NaN // ---- view over an ArrayBuffer (non-inline storage -> slow fallback) ---- function viewSum(S: Int32Array, n: number): number { diff --git a/test-files/test_gap_uninit_let_loop_reset.ts b/test-files/test_gap_uninit_let_loop_reset.ts index 0d241b9d75..37a0a76408 100644 --- a/test-files/test_gap_uninit_let_loop_reset.ts +++ b/test-files/test_gap_uninit_let_loop_reset.ts @@ -72,12 +72,20 @@ for (let i = 0; i < 3; i++) { } console.log(g3.join(" ")); -// NOTE: the `var` counterpart of this — `var v;` in a loop body must KEEP its -// value across iterations, since `var` is function-scoped and hoisted — is a -// separate pre-existing defect (perry prints "kept u u", node "kept kept kept") -// and is tracked on its own. This fix deliberately excludes `var`, so it -// neither fixes nor worsens that; asserting it here would just bake in a known -// failure. +// --- `var` remains one function-scoped binding after static unrolling ------- +// #6876: the unroller used to refresh this declaration to a fresh LocalId for +// each copy. `var` is hoisted, though, so the value assigned in iteration zero +// must remain visible to the later iterations. +function varKeepsValue(): string { + const values: string[] = []; + for (let i = 0; i < 3; i++) { + var value: string | undefined; + if (i === 0) value = "kept"; + values.push(value === undefined ? "u" : value); + } + return values.join(" "); +} +console.log(varKeepsValue()); // --- declaration without init still reads undefined before assignment ----- function readsUndefined(): string {