diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b0bca60a70..8014243b92 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2726,6 +2726,8 @@ pub(crate) mod string_window; mod ptr_numarray_access; mod ta_param_f64_read; #[cfg(test)] +mod unary_bigint_tests; +#[cfg(test)] mod unary_bitnot_tests; pub(crate) use index_get::{ numeric_index_has_integer_array_index_proof, packed_f64_loop_index_parts, diff --git a/crates/perry-codegen/src/expr/unary.rs b/crates/perry-codegen/src/expr/unary.rs index 4bd640d99b..cc9f4b7440 100644 --- a/crates/perry-codegen/src/expr/unary.rs +++ b/crates/perry-codegen/src/expr/unary.rs @@ -9,8 +9,7 @@ use perry_hir::{Expr, UnaryOp}; use crate::lower_conditional::lower_expr_with_truthy; use crate::type_analysis::{ - expr_may_return_boxed_value_from_raw_f64_fallback, is_bigint_expr, is_numeric_expr, - is_provably_not_bigint, + expr_may_return_boxed_value_from_raw_f64_fallback, is_numeric_expr, is_provably_not_bigint, }; use crate::types::{DOUBLE, I32, I64}; @@ -19,16 +18,23 @@ use super::{is_known_i32_range, lower_expr, FnCtx}; pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::Unary { op, operand } => { - let numeric = is_numeric_expr(ctx, operand) + let statically_numeric = is_numeric_expr(ctx, operand); + let numeric = statically_numeric && !expr_may_return_boxed_value_from_raw_f64_fallback(ctx, operand); let native_bitnot = matches!(op, UnaryOp::BitNot) && numeric && is_provably_not_bigint(ctx, operand); let bitnot_known_i32 = native_bitnot && is_known_i32_range(ctx, operand); - // `-` must stay a BigInt (`typeof -1n === "bigint"`). - // `fneg` on a NaN-boxed BigInt flips the NaN payload's sign bit - // and produces a garbage number, so route negation through the - // runtime dynamic helper when the operand is statically bigint. - let is_big = matches!(op, UnaryOp::Neg) && is_bigint_expr(ctx, operand); + // Unary minus performs ToNumeric, not ToNumber: a possible BigInt + // operand must use the dynamic helper so `-1n` remains a BigInt. + // A statically numeric expression is already proven to yield a + // Number (even if a boxed cold fallback still needs coercion), and + // a separately proven non-BigInt value can use `js_number_coerce`. + // Everything else may be a BigInt at runtime — notably an indexed + // read from a plain array — and routing it through ToNumber would + // silently round large BigInts before `fneg` (#9142). + let dynamic_neg = matches!(op, UnaryOp::Neg) + && !statically_numeric + && !is_provably_not_bigint(ctx, operand); let (v, precomputed_truthy) = if matches!(op, UnaryOp::Not) { let (boxed, truthy) = lower_expr_with_truthy(ctx, operand)?; (boxed, Some(truthy)) @@ -38,7 +44,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); match op { UnaryOp::Neg => { - if is_big { + if dynamic_neg { Ok(blk.call(DOUBLE, "js_dynamic_neg", &[(DOUBLE, &v)])) } else if numeric { Ok(blk.fneg(&v)) diff --git a/crates/perry-codegen/src/expr/unary_bigint_tests.rs b/crates/perry-codegen/src/expr/unary_bigint_tests.rs new file mode 100644 index 0000000000..ba41603be5 --- /dev/null +++ b/crates/perry-codegen/src/expr/unary_bigint_tests.rs @@ -0,0 +1,49 @@ +//! BigInt-aware unary negation routing. + +use perry_hir::types::Type; +use perry_hir::{Expr, Stmt, UnaryOp}; + +use crate::temp_root_coverage::main_ir_for as ir_for; + +const VALUES: u32 = 1; +const RESULT: u32 = 2; + +#[test] +fn bigint_array_element_negation_uses_dynamic_numeric_dispatch() { + let ir = ir_for( + "bigint_array_element_negation", + vec![ + Stmt::Let { + id: VALUES, + name: "values".to_string(), + ty: Type::Array(Box::new(Type::BigInt)), + mutable: false, + init: Some(Expr::Array(vec![Expr::BigInt( + "18446744073709551616".to_string(), + )])), + }, + Stmt::Let { + id: RESULT, + name: "result".to_string(), + ty: Type::BigInt, + mutable: false, + init: Some(Expr::Unary { + op: UnaryOp::Neg, + operand: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(VALUES)), + index: Box::new(Expr::Integer(0)), + }), + }), + }, + ], + ); + + assert!( + ir.contains("call double @js_dynamic_neg("), + "a BigInt array element must retain its tag and exact value:\n{ir}" + ); + assert!( + !ir.contains("call double @js_number_coerce("), + "BigInt negation must use ToNumeric rather than ToNumber:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index c6ed2287a9..b060149ae1 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -309,8 +309,18 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // operand must not be treated as numeric. (`!x` is a boolean, not // a number — handled by `is_bool_expr`.) Expr::Unary { op, operand } => { + // The result is a clean f64 only when the unary lowering did NOT + // route through the dynamic helper — and that lowering keys off + // `is_provably_not_bigint`, a POSITIVE proof, not off + // `!is_bigint_expr`, which merely says "we failed to prove it is + // one". An indexed read from a `bigint[]` is exactly the gap + // between those two: unproven either way, so it takes the dynamic + // path and yields a NaN-boxed BigInt. Claiming that numeric made + // the ENCLOSING operator emit a native `fneg`/`fmul` over the + // BigInt payload, so `-(-a[0])` came back `NaN` and + // `(-a[0]) * (-a[1])` came back `-2n` (#9142 follow-up). matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::BitNot) - && !is_bigint_expr(ctx, operand) + && (is_numeric_expr(ctx, operand) || is_provably_not_bigint(ctx, operand)) } // Explicit numeric-coercion node — lowers to `js_number_coerce`, // which always yields a clean f64. diff --git a/test-files/test_gap_9142_bigint_array_negation.ts b/test-files/test_gap_9142_bigint_array_negation.ts new file mode 100644 index 0000000000..945c80021a --- /dev/null +++ b/test-files/test_gap_9142_bigint_array_negation.ts @@ -0,0 +1,11 @@ +// #9142: unary minus on an indexed BigInt must preserve BigInt semantics. +// The old lowering treated array elements as Numbers, rounding values above +// 2^53 through f64 before negating them. + +const inferred = [2n ** 64n]; +console.log(-inferred[0]); +console.log(typeof -inferred[0]); + +const annotated: bigint[] = [2n ** 100n + 12345n]; +console.log(-annotated[0]); +console.log(typeof -annotated[0]);