Skip to content

fix(runtime,codegen): bitnot ToNumber coercion + modulus zero/sign-of-zero#5822

Merged
proggeramlug merged 2 commits into
mainfrom
worktree-fix+5815-test262-grind
Jun 30, 2026
Merged

fix(runtime,codegen): bitnot ToNumber coercion + modulus zero/sign-of-zero#5822
proggeramlug merged 2 commits into
mainfrom
worktree-fix+5815-test262-grind

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes two spec compliance bugs found during the test262 grind (#5815):

~xjs_dynamic_bitnot (runtime)

  • Missing ToNumber: ~new Boolean(true), ~new String("3"), ~{valueOf(){return 2}} were treating the NaN-boxed pointer as 0 (yielding -1) instead of coercing through js_number_coerce first. Added js_number_coerce(a) before the ToInt32 truncation.
  • Wrong ToInt32 for ±Infinity/NaN: Rust's saturating f64 as i64 gives i64::MAX for +Infinity, then i64::MAX as i32 = -1, so ~Infinity returned 0 instead of -1. The ES spec says ToInt32(±Infinity) = 0, so ~Infinity = ~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_T3100% parity on language/expressions/bitwise-not (15/15).

x % y — integer modulus fast path (codegen)

  • Zero-divisor UB: srem(x, 0) is LLVM undefined behaviour (ARM silently gives 0; JS requires NaN for x % 0). Added a right_is_known_zero guard that skips the fptosi/srem fast path when the RHS is a literal 0 or 0.0, falling through to frem which is IEEE 754 correct.
  • Sign-of-zero: srem always produces an integer 0, and sitofp converts that to +0.0. JS requires -0.0 when the dividend is negative (e.g. -1 % -1 === -0, checked via 1 / (-1 % -1) === -Infinity). Fixed by emitting: if m == 0 && l < 0.0 { fneg(0.0) } else { sitofp(m) }.

Fixes S11.5.3_A4_T2, S11.5.3_A4_T4.

Test plan

  • Manual test: bitnot: -2 1 -2 -4 (was -1 -1 -1 ... for wrapper objects)
  • Manual test: mod: -0 -Infinity NaN -Infinity (was 0 Infinity 0 Infinity)
  • language/expressions/bitwise-not test262 subset: 15/15 pass (was 11/15)
  • language/expressions/modulus test262 subset: 36/37 pass (remaining 1 is pre-existing Object(1n) % 1 BigInt-wrapper TypeError, unrelated to this PR)
  • Full CI (lint, cargo-test, api-docs-drift, security-audit)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved modulo (%) behavior to better match JavaScript, including correct handling of -0 results and cases involving zero divisors.
    • Refined bitwise NOT (~) for non-BigInt values so inputs are coerced and truncated according to JavaScript rules, with non-finite values treated as 0.

…-zero (#5815)

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a425221a-14be-4fce-9dc0-89be7a37b28e

📥 Commits

Reviewing files that changed from the base of the PR and between 9a4524e and 3b7f300.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/value/dynamic_arith.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/value/dynamic_arith.rs

📝 Walkthrough

Walkthrough

Two JavaScript spec compliance fixes: the % operator codegen fast path now skips known-zero RHS values and emits a select/fneg sequence to produce -0.0 when the integer remainder is zero and the dividend was negative; js_dynamic_bitnot now applies js_number_coerce and ES ToInt32 mapping before computing bitwise-not.

Changes

JS Arithmetic Correctness

Layer / File(s) Summary
Modulo fast-path: zero-RHS guard and -0.0 preservation
crates/perry-codegen/src/expr/binary.rs
Fast path is skipped when RHS is Integer(0) or Number(0.0); when remainder is zero and dividend is negative, a select/fneg sequence produces -0.0 instead of +0.0.
bitnot ToInt32 coercion in runtime
crates/perry-runtime/src/value/dynamic_arith.rs
Non-BigInt branch of js_dynamic_bitnot now calls js_number_coerce, maps NaN/non-finite to 0, truncates finite values via as i64 as i32, then returns (!a_i32) as f64.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇 A zero on the right? No srem for me!
Negative dividend? Then -0.0 shall be!
ToInt32 now called before the ~ flies,
The spec is obeyed beneath these Rust skies.
Hop hop, correctness wins the day! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately names the two spec-compliance fixes in runtime bitnot and codegen modulus.
Description check ✅ Passed The description covers the summary and test plan and includes the key fix details, though it omits some template sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-fix+5815-test262-grind

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/binary.rs`:
- Around line 223-245: The integer modulo fast path in binary expression
lowering still relies on right_is_known_zero, which only excludes literal zero
but not integer-valued RHS expressions that can evaluate to zero at runtime. In
lower_expr / the BinaryOp::Mod branch, add a runtime check after computing ri in
the integer fast path and avoid calling srem when ri is 0; if the divisor is
zero at runtime, fall back to the floating-point remainder path instead. Use the
existing is_integer_valued_expr, lower_expr, and srem handling in Expr::Mod as
the place to make this guard.

In `@crates/perry-runtime/src/value/dynamic_arith.rs`:
- Around line 464-467: The bitwise-not path in dynamic_arith.rs is using `a_num
as i64 as i32`, which can saturate on large finite numbers and produce the wrong
ToInt32 result. Update the `!` handling to follow the existing JS ToInt32 helper
flow used elsewhere in `dynamic_arith.rs`/`perry-runtime`, applying `trunc()`
with `rem_euclid(2^32)` before casting to `i32`, while keeping the
NaN/±0/±Infinity case mapping to 0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 561275ea-5fa6-46c5-a2a4-f6dc22cbfa48

📥 Commits

Reviewing files that changed from the base of the PR and between 23cf727 and 9a4524e.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-runtime/src/value/dynamic_arith.rs

Comment on lines +223 to +245
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)?;
let blk = ctx.block();
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm is_integer_valued_expr can match non-literal (variable / loop-counter) expressions,
# making a runtime-zero RHS reachable on the srem fast path.
fd -e rs | xargs rg -nP 'fn\s+is_integer_valued_expr' 
ast-grep run --pattern 'fn is_integer_valued_expr($$$) { $$$ }' --lang rust $(fd type_analysis.rs)

Repository: PerryTS/perry

Length of output: 279


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
ast-grep outline crates/perry-codegen/src/type_analysis/numeric.rs --view expanded || true
ast-grep outline crates/perry-codegen/src/expr/binary.rs --view expanded || true

# Read the helper and the modulo fast-path in manageable slices.
sed -n '240,380p' crates/perry-codegen/src/type_analysis/numeric.rs
printf '\n--- binary.rs ---\n'
sed -n '200,280p' crates/perry-codegen/src/expr/binary.rs

# Find other uses of the helper to understand intended scope.
rg -n "is_integer_valued_expr\(" crates/perry-codegen/src

Repository: PerryTS/perry

Length of output: 9006


Guard the integer modulo fast path against runtime zero divisors

right_is_known_zero only filters literal 0/0.0, but is_integer_valued_expr also accepts integer locals, updates, and nested integer arithmetic, so a non-literal RHS can still be 0 at runtime and reach srem(I64, ..., 0). Add a runtime ri == 0 check and fall back to frem in that case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/binary.rs` around lines 223 - 245, The integer
modulo fast path in binary expression lowering still relies on
right_is_known_zero, which only excludes literal zero but not integer-valued RHS
expressions that can evaluate to zero at runtime. In lower_expr / the
BinaryOp::Mod branch, add a runtime check after computing ri in the integer fast
path and avoid calling srem when ri is 0; if the divisor is zero at runtime,
fall back to the floating-point remainder path instead. Use the existing
is_integer_valued_expr, lower_expr, and srem handling in Expr::Mod as the place
to make this guard.

Comment on lines +464 to +467
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Rust f64 as i64 cast saturating behavior out of range

💡 Result:

As of Rust 1.45.0, casting a floating-point number (f64 or f32) to an integer type (i64, i32, etc.) using the as keyword is defined to perform a saturating cast [1][2][3]. This behavior is stable, safe, and explicitly defined, replacing what was previously considered undefined behavior (UB) [2][3][4]. The rules for as casting from f64 to i64 are as follows: 1. Rounding: The floating-point value is rounded towards zero (truncated) [5][2][4]. 2. Out-of-Range Values: - If the value is greater than i64::MAX, it saturates (clamps) to i64::MAX [5][2][6]. - If the value is less than i64::MIN, it saturates (clamps) to i64::MIN [5][2][6]. - Infinities (f64::INFINITY and f64::NEG_INFINITY) follow these same clamping rules (saturating to i64::MAX and i64::MIN, respectively) [2][6]. 3. NaN (Not a Number): NaN values are converted to 0 [2][3][6]. If you require performance-critical code where you can guarantee the floating-point value is within a valid range, you can use the to_int_unchecked method provided on floating-point primitives [7][3][4]. This method is unsafe because it bypasses the saturation checks; using it with values outside the representable range or with NaN/infinity results in undefined behavior [7][2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the referenced lines.
sed -n '420,500p' crates/perry-runtime/src/value/dynamic_arith.rs

echo
echo '--- SEARCH ToInt32 helpers ---'
rg -n "ToInt32|to_int32|Int32" crates/perry-runtime/src -g '!**/target/**' || true

Repository: PerryTS/perry

Length of output: 14514


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '420,500p' crates/perry-runtime/src/value/dynamic_arith.rs

echo
echo '--- SEARCH ToInt32 helpers ---'
rg -n "ToInt32|to_int32|Int32" crates/perry-runtime/src -g '!**/target/**' || true

Repository: PerryTS/perry

Length of output: 14514


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- math.rs ToInt32 helper ---'
sed -n '1,120p' crates/perry-runtime/src/math.rs

echo
echo '--- builtin_thunks.rs ToInt32 helper ---'
sed -n '70,110p' crates/perry-runtime/src/object/global_this/builtin_thunks.rs

echo
echo '--- parse_int helper ---'
sed -n '80,120p' crates/perry-runtime/src/builtins/numbers.rs

Repository: PerryTS/perry

Length of output: 5596


Use the JS ToInt32 path here instead of as i64 as i32. f64 as i64 saturates for large finite inputs, so values like 1e300 produce the wrong bitwise-not result. Reuse the existing trunc().rem_euclid(2^32) helper logic before converting to i32.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/value/dynamic_arith.rs` around lines 464 - 467, The
bitwise-not path in dynamic_arith.rs is using `a_num as i64 as i32`, which can
saturate on large finite numbers and produce the wrong ToInt32 result. Update
the `!` handling to follow the existing JS ToInt32 helper flow used elsewhere in
`dynamic_arith.rs`/`perry-runtime`, applying `trunc()` with `rem_euclid(2^32)`
before casting to `i32`, while keeping the NaN/±0/±Infinity case mapping to 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@proggeramlug proggeramlug merged commit cfe9b6c into main Jun 30, 2026
15 checks passed
@proggeramlug proggeramlug deleted the worktree-fix+5815-test262-grind branch June 30, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant