diff --git a/crates/perry-codegen/src/collectors/i32_locals.rs b/crates/perry-codegen/src/collectors/i32_locals.rs index 05711064e9..12f00914f6 100644 --- a/crates/perry-codegen/src/collectors/i32_locals.rs +++ b/crates/perry-codegen/src/collectors/i32_locals.rs @@ -13,7 +13,12 @@ pub fn is_strictly_i32_bounded_expr( use perry_hir::{BinaryOp, Expr}; match e { Expr::Integer(_) => true, - Expr::Update { .. } => true, + // `x++`/`x--` is i32-bounded ONLY when the updated local is itself a + // known integer (a loop counter). The previous unconditional `true` + // truncated `var y = x++` to an i32 slot even when `x` held a + // fractional/non-number value — so `var x = 1.1; var y = x++` stored + // `y = (i32)1.1 = 1` instead of the spec's coerced old value `1.1`. + Expr::Update { id, .. } => known_int_locals.contains(id), // `expr | 0` / `expr >>> 0` ToInt32/ToUint32 idioms — explicit i32 // coercion, hard-bounded. Expr::Binary { op, right, .. } diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 9baaed1e01..8e8a403802 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -65,11 +65,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { op, CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge ); - let bigint_fast_path = if is_relational_op { - is_bigint_expr(ctx, left) && is_bigint_expr(ctx, right) - } else { - is_bigint_expr(ctx, left) || is_bigint_expr(ctx, right) - }; + // The `js_bigint_cmp` fast path is valid ONLY when BOTH operands are + // statically BigInt. The previous equality variant fired when *either* + // side was BigInt and fed `js_bigint_cmp` a non-BigInt operand + // (`0n != undefined`, `0n == ""`), dereferencing an undefined/string + // NaN-box as a BigIntHeader → garbage. Mixed-type BigInt equality now + // falls through to `js_loose_eq` (loose, with full BigInt coercion) / + // `fcmp` (strict, where a type mismatch is correctly never-equal). + // Relational mixed-type already fell through to `js_rel_*`. + let bigint_fast_path = is_bigint_expr(ctx, left) && is_bigint_expr(ctx, right); if bigint_fast_path { let l = lower_expr(ctx, left)?; let r = lower_expr(ctx, right)?; diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index ef0124beba..517a9e49b5 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -664,6 +664,30 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // module globals. Expr::Update { id, op, prefix } => { super::invalidate_local_write_facts(ctx, *id); + // Spec ToNumber: `x++`/`++x` coerce the operand to a number + // (ToNumber on bool/null/string/object-with-valueOf) before the + // add/sub, and the *returned* value (postfix) is the coerced + // number, not the original boxed operand. Statically-integer loop + // counters already hold a real f64 and skip the call to keep the + // hot path a single `fadd`. (BigInt operands convert to Number here + // — full BigInt-preserving `++`/`--` additionally needs the Update + // result's static type to be inferred as BigInt so `===` uses the + // value path, not `fcmp`; deferred.) + let needs_numeric_coerce = + !ctx.integer_locals.contains(id) && !ctx.unsigned_i32_locals.contains(id); + let coerce_old = |blk: &mut crate::block::LlBlock, raw: &str| -> String { + if needs_numeric_coerce { + blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, raw)]) + } else { + raw.to_string() + } + }; + let step_new = |blk: &mut crate::block::LlBlock, old_num: &str| -> String { + match op { + UpdateOp::Increment => blk.fadd(old_num, "1.0"), + UpdateOp::Decrement => blk.fsub(old_num, "1.0"), + } + }; // Closure capture path: runtime get + add/sub + runtime set. if let Some(&capture_idx) = ctx.closure_captures.get(id) { let closure_ptr = ctx @@ -681,10 +705,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); let box_ptr = blk.bitcast_double_to_i64(&cap_dbl); let old = blk.call(DOUBLE, "js_box_get", &[(I64, &box_ptr)]); - let new = match op { - UpdateOp::Increment => blk.fadd(&old, "1.0"), - UpdateOp::Decrement => blk.fsub(&old, "1.0"), - }; + let old = coerce_old(blk, &old); + let new = step_new(blk, &old); blk.call_void("js_box_set", &[(I64, &box_ptr), (DOUBLE, &new)]); return Ok(if *prefix { new } else { old }); } @@ -694,10 +716,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &closure_ptr), (I32, &idx_str)], ); let blk = ctx.block(); - let new = match op { - UpdateOp::Increment => blk.fadd(&old, "1.0"), - UpdateOp::Decrement => blk.fsub(&old, "1.0"), - }; + let old = coerce_old(blk, &old); + let new = step_new(blk, &old); blk.call_void( "js_closure_set_capture_f64", &[(I64, &closure_ptr), (I32, &idx_str), (DOUBLE, &new)], @@ -713,10 +733,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let box_dbl = blk.load(DOUBLE, &slot); let box_ptr = blk.bitcast_double_to_i64(&box_dbl); let old = blk.call(DOUBLE, "js_box_get", &[(I64, &box_ptr)]); - let new = match op { - UpdateOp::Increment => blk.fadd(&old, "1.0"), - UpdateOp::Decrement => blk.fsub(&old, "1.0"), - }; + let old = coerce_old(blk, &old); + let new = step_new(blk, &old); blk.call_void("js_box_set", &[(I64, &box_ptr), (DOUBLE, &new)]); return Ok(if *prefix { new } else { old }); } @@ -731,10 +749,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; let blk = ctx.block(); let old = blk.load(DOUBLE, &storage); - let new = match op { - UpdateOp::Increment => blk.fadd(&old, "1.0"), - UpdateOp::Decrement => blk.fsub(&old, "1.0"), - }; + let old = coerce_old(blk, &old); + let new = step_new(blk, &old); if storage_is_root { // Module globals are registered mutable GC roots and route // through the root helper; the raw store below is stack-only. diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index 04820e5d37..604d485f06 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -665,7 +665,21 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< None } } - _ => None, + // Any other right-hand side (a primitive literal like + // `x instanceof true`, `this`, a call `x instanceof f()`, + // a parenthesized/conditional class ref, …) is NOT a + // statically-resolvable class name. The old `_ => "Object"` + // `ty` substitution silently treated these as + // `instanceof Object` and returned `false`; ECMAScript + // requires evaluating the operand and throwing a TypeError + // when it is not a constructor (`true instanceof true`, + // `({}) instanceof this`). Lower the operand to a value and + // route through `js_instanceof_dynamic`, which both resolves + // every constructor shape and throws on a non-callable RHS. + _ => match lower_expr(ctx, &bin.right) { + Ok(e) => Some(Box::new(e)), + Err(_) => None, + }, }; return Ok(Expr::InstanceOf { expr, ty, ty_expr }); } diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index 04f713b941..f804f5e340 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -54,6 +54,22 @@ pub extern "C" fn js_eq(a: JSValue, b: JSValue) -> JSValue { JSValue::bool(result != 0) } +/// Whether `v` has ECMAScript Type Object (not a primitive). True for plain +/// objects, arrays, functions, Dates and boxed primitive wrappers; false for +/// Symbols (which are NaN-boxed pointers but are primitives) and for the +/// native-handle id-space below `0x100000` (sockets, zlib streams, …) which +/// must not be dereferenced as heap objects in a coercion path. +fn eq_is_object(v: JSValue) -> bool { + if !v.is_pointer() { + return false; + } + let ptr = v.as_pointer::(); + if ptr.is_null() || (ptr as usize) < 0x100000 { + return false; + } + !crate::symbol::is_registered_symbol(ptr as usize) +} + /// JS abstract equality (==). Implements the coercion rules: /// - Same type: use strict equality /// - null == undefined: true @@ -82,6 +98,14 @@ pub extern "C" fn js_loose_eq(a: JSValue, b: JSValue) -> JSValue { if a.is_null() || a.is_undefined() || b.is_null() || b.is_undefined() { return JSValue::bool(false); } + // Object == Object → reference equality (ES2024 §7.2.15 step 1, same Type). + // Distinct object identities already failed the same-bits fast path above, + // so two objects here are never equal — and we must NOT unwrap a boxed + // wrapper when the other side is also an object (`new Boolean(true) != + // new Boolean(true)` is `true`). + if eq_is_object(a) && eq_is_object(b) { + return JSValue::bool(false); + } // Boxed primitives compare via their wrapped primitive value under // abstract equality (`new Number(5) == 5`, and sloppy primitive accessors // return boxed receivers). @@ -91,6 +115,56 @@ pub extern "C" fn js_loose_eq(a: JSValue, b: JSValue) -> JSValue { if let Some((_, payload)) = boxed_primitive_payload(f64::from_bits(b.bits())) { return js_loose_eq(a, JSValue::from_bits(payload.to_bits())); } + // Object == primitive → ToPrimitive(object), then retry (ES2024 §7.2.15 + // steps 10-11). Object-vs-object was settled above; symbols are primitives + // (`eq_is_object` excludes them) and correctly fall through to not-equal. + // Done before the BigInt block so `0n == { valueOf() { return 0n } }` works. + if eq_is_object(a) { + let pa = unsafe { rel_to_primitive(f64::from_bits(a.bits())) }; + return js_loose_eq(JSValue::from_bits(pa.to_bits()), b); + } + if eq_is_object(b) { + let pb = unsafe { rel_to_primitive(f64::from_bits(b.bits())) }; + return js_loose_eq(a, JSValue::from_bits(pb.to_bits())); + } + // BigInt abstract equality (ES2024 §7.2.15). Neither side is + // null/undefined here and boxed wrappers (incl. `Object(0n)`) have already + // been unwrapped above. + if a.is_bigint() || b.is_bigint() { + // BigInt == BigInt → compare by mathematical value. + if a.is_bigint() && b.is_bigint() { + return JSValue::bool( + crate::bigint::js_bigint_cmp(a.as_bigint_ptr(), b.as_bigint_ptr()) == 0, + ); + } + let (big, other) = if a.is_bigint() { (a, b) } else { (b, a) }; + // BigInt == Boolean → ToNumber(boolean) then BigInt == Number. + let other = if other.is_bool() { + JSValue::number(if other.as_bool() { 1.0 } else { 0.0 }) + } else { + other + }; + // BigInt == Number → exact integer comparison (NaN/±Infinity/fractional + // are never equal). `bigint_cmp_f64` returns 0 only on exact equality. + if other.is_number() { + return JSValue::bool( + crate::bigint::bigint_cmp_f64(big.as_bigint_ptr(), other.as_number()) == 0, + ); + } + // BigInt == String → StringToBigInt(string); a non-numeric string makes + // the result `false` (StringToBigInt is undefined → not equal). + if other.is_any_string() { + let s = unsafe { string_content_for_bigint(f64::from_bits(other.bits())) }; + return match crate::bigint::string_to_bigint(&s) { + Some(ny) => { + JSValue::bool(crate::bigint::js_bigint_cmp(big.as_bigint_ptr(), ny) == 0) + } + None => JSValue::bool(false), + }; + } + // BigInt == Symbol / anything else → not equal. + return JSValue::bool(false); + } // Both strings (heap STRING_TAG and/or inline SHORT_STRING_TAG): // content compare. The previous `is_string() && is_string()` test // missed any SSO operand — `JSON.parse(...).foo == "perry"` returned