From 9a4524ea56dd9343acfb519de70ea06a033b7017 Mon Sep 17 00:00:00 2001 From: Ralph Date: Tue, 30 Jun 2026 06:44:42 -0700 Subject: [PATCH 1/2] fix(runtime,codegen): bitnot ToNumber coercion + modulus zero/sign-of-zero (#5815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spec bugs caught by the test262 radar: **`~x` (bitwise NOT) — `js_dynamic_bitnot`** - Missing `ToNumber` coercion before `ToInt32`: `~new Boolean(true)` gave `-1` (treating the NaN-boxed pointer as `0`) instead of `~1 = -2`. Fixed by calling `js_number_coerce` before the i64 truncation, which dispatches through `valueOf`/`toPrimitive` for wrapper objects and strings. - Wrong `ToInt32` for `±Infinity`/`NaN`: Rust's saturating `f64 as i64` gives `i64::MAX` for `+Infinity` → `i64::MAX as i32 = -1` → `~(-1) = 0`, but the spec says `ToInt32(±Infinity) = 0` → `~0 = -1`. Fixed with an explicit `if is_nan || !is_finite { 0i32 }` guard. Fixes `S11.4.8_A2.2_T1`, `S11.4.8_A3_T1`, `S11.4.8_A3_T2`, `S11.4.8_A3_T3` (100% parity on `language/expressions/bitwise-not`). **`x % y` (modulus) — integer fast path in codegen** - Zero-divisor UB: `srem(x, 0)` is undefined behaviour in LLVM (ARM gives `0` silently; JS requires `NaN`). Guard added: skip the `fptosi/srem` fast path when the RHS is a literal `0` or `0.0` and fall through to `frem` (which gives `NaN` per IEEE 754). - Sign-of-zero: `srem` returns an `i64` zero and `sitofp` always produces `+0.0`, but JS requires `-0.0` when the dividend was negative (e.g. `-1 % -1 === -0`). Fixed by emitting: `if m == 0 && l < 0.0 → fneg(0.0)`. Fixes `S11.5.3_A4_T2`, `S11.5.3_A4_T4`. Co-Authored-By: Claude Sonnet 4.6 --- crates/perry-codegen/src/expr/binary.rs | 20 ++++++++++++++++--- .../perry-runtime/src/value/dynamic_arith.rs | 8 +++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 0755bfe33c..ad09f2570a 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -216,11 +216,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // fptosi, producing the wrong result. `is_integer_valued_expr` // only returns true when we can prove the value is a whole // number (integer literals, integer loop counters, or nested - // integer arithmetic). For everything else we fall through - // to the `frem` path. + // integer arithmetic). A zero RHS falls through to `frem` + // because srem(x,0) is UB in LLVM (on ARM the CPU silently + // gives 0, but JS requires NaN for any x % 0). For everything + // else we fall through to the `frem` path. + let right_is_known_zero = matches!(**right, Expr::Integer(0)) + || matches!(**right, Expr::Number(v) if v == 0.0); if matches!(op, BinaryOp::Mod) && crate::type_analysis::is_integer_valued_expr(ctx, left) && crate::type_analysis::is_integer_valued_expr(ctx, right) + && !right_is_known_zero { let l_raw = lower_expr(ctx, left)?; let r_raw = lower_expr(ctx, right)?; @@ -228,7 +233,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let li = blk.fptosi(DOUBLE, &l_raw, I64); let ri = blk.fptosi(DOUBLE, &r_raw, I64); let m = blk.srem(I64, &li, &ri); - return Ok(blk.sitofp(I64, &m, DOUBLE)); + // IEEE 754: when the integer remainder is 0 and the + // dividend was negative, the result must be -0.0. + // srem gives 0i64 → sitofp always produces +0.0, + // so correct: if m==0 && l<0 → fneg(0.0) = -0.0. + let result_f = blk.sitofp(I64, &m, DOUBLE); + let m_is_zero = blk.icmp_eq(I64, &m, "0"); + let l_neg = blk.fcmp("olt", &l_raw, "0.0"); + let need_neg = blk.and(I1, &m_is_zero, &l_neg); + let neg_result = blk.fneg(&result_f); + return Ok(blk.select(I1, &need_neg, DOUBLE, &neg_result, &result_f)); } let (l_raw, l_fallback_coerced) = lower_arithmetic_operand(ctx, left)?; diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index d2d9f56f3f..84257d539a 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -458,7 +458,13 @@ pub unsafe extern "C" fn js_dynamic_bitnot(a: f64) -> f64 { ); return js_nanbox_bigint(result as i64); } - (!(a as i64 as i32)) as f64 + // Apply ToNumber first so that ~"3", ~true, ~new Boolean(true), etc. + // coerce correctly before the ToInt32 truncation. + let a_num = crate::builtins::js_number_coerce(a); + // ES ToInt32: NaN, ±0, ±Infinity all map to 0; finite values use + // C-style i64 truncation (equivalent to modulo-2^32 + sign-extend). + let a_i32 = if a_num.is_nan() || !a_num.is_finite() { 0i32 } else { a_num as i64 as i32 }; + (!a_i32) as f64 } /// Dynamic right shift: BigInt >> if either operand is BigInt, else i32 >> for numbers. From 3b7f3006c810dda41221f1b11172afbecd6073a5 Mon Sep 17 00:00:00 2001 From: Ralph Date: Tue, 30 Jun 2026 06:54:54 -0700 Subject: [PATCH 2/2] style: rustfmt Co-Authored-By: Claude Sonnet 4.6 --- crates/perry-runtime/src/value/dynamic_arith.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index 84257d539a..dda5f6a0c8 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -463,7 +463,11 @@ pub unsafe extern "C" fn js_dynamic_bitnot(a: f64) -> f64 { let a_num = crate::builtins::js_number_coerce(a); // ES ToInt32: NaN, ±0, ±Infinity all map to 0; finite values use // C-style i64 truncation (equivalent to modulo-2^32 + sign-extend). - let a_i32 = if a_num.is_nan() || !a_num.is_finite() { 0i32 } else { a_num as i64 as i32 }; + let a_i32 = if a_num.is_nan() || !a_num.is_finite() { + 0i32 + } else { + a_num as i64 as i32 + }; (!a_i32) as f64 }