Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/perry-api-manifest/src/entries/part_1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[
method("ws", "on", true, None),
method("ws", "send", true, None),
method("ws", "close", true, None),
method("ws", "readyState", true, None),
// Node-compatible WebSocket ready-state constants. The `ws` package
// exposes these on both the module/default export and WebSocket class:
// CONNECTING=0, OPEN=1, CLOSING=2, CLOSED=3.
Expand Down
19 changes: 19 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1766,6 +1766,25 @@ fn lower_numeric_binary_value(
return Ok(None);
}

// Hand this proven shape to `binary::lower`, which owns the existing
// integer remainder and negative-zero repair. This must run before operand
// lowering so returning `None` emits no dead loads or duplicate records.
if matches!(op, BinaryOp::Mod)
&& matches!(
left,
Expr::LocalGet(id)
if ctx.i32_counter_slots.contains_key(id)
&& !ctx.unsigned_i32_locals.contains(id)
)
&& matches!(
right,
Expr::Integer(value)
if i32::try_from(*value).is_ok_and(|divisor| divisor > 0)
)
{
return Ok(None);
}

let Some(left) = lower_numeric_operand_value(ctx, left)? else {
return Ok(None);
};
Expand Down
17 changes: 14 additions & 3 deletions crates/perry-codegen/tests/native_proof_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1317,7 +1317,7 @@ fn artifact_schema_v6_records_pod_dynamic_write_fallback() {
}

#[test]
fn pod_field_read_after_dynamic_materialization_uses_number_coerce() {
fn pod_field_read_after_dynamic_materialization_uses_dynamic_numeric_sub() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move acceptance coverage to unit tests.

As per coding guidelines, put acceptance coverage in unit tests visible to cargo-test, because integration suites under crates/*/tests/*.rs do not run on every PR. Please move this regression test (and any other newly added coverage, such as the integer_modulo tests) into a #[cfg(test)] unit test module within the src/ directory to ensure they are reliably executed in CI.

🤖 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/tests/native_proof_regressions.rs` at line 1320, Move
pod_field_read_after_dynamic_materialization_uses_dynamic_numeric_sub and the
newly added integer_modulo coverage from crates/*/tests integration files into a
#[cfg(test)] unit-test module under the relevant src/ directory, preserving
their assertions and setup so cargo test discovers them on every PR.

Source: Coding guidelines

let packet_ty = pod_type(&[
("tag", Type::Named("PerryU32".to_string())),
("gain", Type::Named("PerryF32".to_string())),
Expand Down Expand Up @@ -1346,8 +1346,16 @@ fn pod_field_read_after_dynamic_materialization_uses_number_coerce() {

let ir = compile_ir("pod_dynamic_materialized_read_coerce.ts", body);
assert!(
ir.contains("call double @js_number_coerce"),
"POD field reads after dynamic materialization must not feed boxed JSValue fallbacks into raw numeric arithmetic:\n{ir}"
ir.contains("call double @js_object_get_field_by_name_f64"),
"materialized POD field reads must preserve boxed JSValue bits:\n{ir}"
);
assert!(
ir.contains("call double @js_dynamic_sub"),
"materialized POD field reads must use coercing dynamic arithmetic:\n{ir}"
);
assert!(
!ir.contains("fsub double"),
"materialized POD field reads must not feed boxed JSValue bits into raw arithmetic:\n{ir}"
);
}

Expand Down Expand Up @@ -13379,3 +13387,6 @@ fn put_value_set_index_keeps_the_numeric_array_fast_path() {

#[path = "native_proof_regressions/invalidation.rs"]
mod invalidation;

#[path = "native_proof_regressions/integer_modulo.rs"]
mod integer_modulo;
144 changes: 144 additions & 0 deletions crates/perry-codegen/tests/native_proof_regressions/integer_modulo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
use super::*;

fn modulo(left: Expr, right: Expr) -> Expr {
Expr::Binary {
op: BinaryOp::Mod,
left: Box::new(left),
right: Box::new(right),
}
}

fn factorial_shaped_ir(divisor: Expr) -> String {
compile_ir(
"value_first_i32_modulo.ts",
vec![
number_let(1, "sum", true, int(0)),
number_let(3, "iterations", false, int(64)),
for_loop(
2,
local(3),
vec![Stmt::Expr(Expr::LocalSet(
1,
Box::new(add(local(1), modulo(local(2), divisor))),
))],
),
Stmt::Return(Some(local(1))),
],
)
}

fn assert_integer_modulo_with_negative_zero_repair(ir: &str) {
assert!(
ir.contains("srem i64"),
"signed i32 counter modulo a positive Expr::Integer should reach the existing integer remainder path:\n{ir}"
);
assert!(
!ir.contains("frem double"),
"eligible signed i32 counter modulo must not retain floating remainder:\n{ir}"
);
assert!(
ir.contains("icmp eq i64")
&& ir.contains("fcmp olt double")
&& ir.contains("fneg double")
&& ir.contains("select i1"),
"integer remainder must retain the existing IEEE-754 negative-zero repair:\n{ir}"
);
}

fn assert_floating_modulo(ir: &str) {
assert!(
ir.contains("frem double"),
"ineligible modulo shape must remain on floating remainder:\n{ir}"
);
assert!(
!ir.contains("srem i64") && !ir.contains("srem i32"),
"ineligible modulo shape must not emit integer remainder:\n{ir}"
);
}

#[test]
fn i32_counter_mod_positive_literal_reaches_integer_fast_path() {
assert_integer_modulo_with_negative_zero_repair(&factorial_shaped_ir(int(1000)));
}

#[test]
fn i32_counter_mod_unsafe_or_nonliteral_divisors_keep_frem() {
let cases = [
("integral_number", number(1000.0)),
("zero", int(0)),
("negative", int(-1)),
("fractional", number(2.5)),
("out_of_i32", int(i64::from(i32::MAX) + 1)),
];
for (case, divisor) in cases {
let ir = factorial_shaped_ir(divisor);
assert!(
ir.contains("frem double"),
"{case} divisor must remain on floating remainder:\n{ir}"
);
assert!(
!ir.contains("srem i64") && !ir.contains("srem i32"),
"{case} divisor must not emit integer remainder:\n{ir}"
);
}

let dynamic_ir = compile_ir(
"value_first_i32_modulo_dynamic_divisor.ts",
vec![
number_let(1, "sum", true, int(0)),
number_let(3, "divisor", false, int(7)),
number_let(4, "iterations", false, int(64)),
for_loop(
2,
local(4),
vec![Stmt::Expr(Expr::LocalSet(
1,
Box::new(add(local(1), modulo(local(2), local(3)))),
))],
),
Stmt::Return(Some(local(1))),
],
);
assert_floating_modulo(&dynamic_ir);
}

#[test]
fn non_i32_left_operands_keep_frem() {
let f64_ir = compile_ir(
"value_first_f64_modulo.ts",
vec![
number_let(1, "value", false, number(5.5)),
Stmt::Return(Some(modulo(local(1), int(2)))),
],
);
assert_floating_modulo(&f64_ir);

// A `>>> 0` initializer plus only `>>> 0` writes is the harness's existing
// way to obtain an unsigned i32 shadow slot. The specialization must not
// treat its native bit pattern as a signed dividend.
let u32_ir = compile_ir(
"value_first_u32_modulo.ts",
vec![
number_let(1, "sum", true, int(0)),
number_let(3, "iterations", false, int(64)),
Stmt::For {
init: Some(Box::new(number_let(2, "i", true, ushr_zero(int(0))))),
condition: Some(Expr::Compare {
op: CompareOp::Lt,
left: Box::new(local(2)),
right: Box::new(local(3)),
}),
update: Some(Expr::LocalSet(
2,
Box::new(ushr_zero(add(local(2), int(1)))),
)),
body: vec![Stmt::Expr(Expr::LocalSet(
1,
Box::new(add(local(1), modulo(local(2), int(7)))),
))],
},
Stmt::Return(Some(local(1))),
],
);
assert_floating_modulo(&u32_ir);
}
3 changes: 2 additions & 1 deletion docs/src/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target.

Total: 2829 entries across 117 modules.
Total: 2830 entries across 117 modules.

## Modules

Expand Down Expand Up @@ -3754,6 +3754,7 @@ Total: 2829 entries across 117 modules.
- `handleUpgrade` — instance
- `on` — instance
- `on` — instance *(class: `Client`)*
- `readyState` — instance
- `send` — instance
- `send` — instance *(class: `Client`)*
- `sendToClient` — module
Expand Down
27 changes: 27 additions & 0 deletions test-files/test_gap_numeric_remainder_i32.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
let sum = 0;
for (let i = 0; i < 10000; i++) {
sum = sum + (i % 1000);
}
console.log("positive-loop:" + sum);

let sawNegativeZero = false;
for (let i = -6; i <= 0; i++) {
const value = i % 3;
if (Object.is(value, -0)) {
sawNegativeZero = true;
}
}
console.log("negative-loop-zero:" + sawNegativeZero);

console.log("fractional:" + (5.5 % 2));
console.log("nan:" + (NaN % 2));
console.log("infinity:" + (Infinity % 2));
console.log("zero-divisor:" + (5 % 0));
console.log("infinite-divisor:" + (5 % Infinity));
console.log("negative-zero:" + Object.is(-0 % 3, -0));

let dynamicZero = 0;
console.log("dynamic-zero-divisor:" + (5 % dynamicZero));
console.log("negative-divisor:" + (5 % -2));
console.log("min-i32-negative-one:" + Object.is(-2147483648 % -1, -0));
console.log("wide-safe-integer:" + (9007199254740991 % 97));
Loading