fix(ecmascript): derive ToNumber of !x from ToBoolean (#25595)

Found by the semantic fuzzer added in #25594 (seed `15979`).

## What

`ToNumber(!x)` was computed from `ToNumber(x)` instead of
`ToBoolean(x)`:

```rust
Expression::UnaryExpression(unary) if unary.operator.is_not() => {
    let number = unary.argument.to_number(ctx)?;
    Some(if number == 0.0 { 1.0 } else { 0.0 })
}
```

`!x` is `ToBoolean(x)` negated, so the two predicates must agree — and
they don't. `NaN` is falsy but does not number to `0`, while `"0"`, `"
"`, `"0.0"` and `[]` all number to `0` yet are truthy.

## Why it was not caught

`minimize_conditional_expression` rewrites `a ? 1 : 0` into `+!!a`. That
rewrite is correct, but when a parent expression evaluated the freshly
built `+!!a` in the same pass, the broken branch flipped the result:

```js
(NaN ? 1 : 0) || 2   // 2, minified to 0
('0' ? 1 : 0) || 2   // 1, minified to 2
([]  ? 1 : 0) || 2   // 1, minified to 2
```

A `!!x` written in the source is folded correctly by `fold_unary_expr`
before any parent observes it, so only pass-generated nodes were
affected. That is why existing tests and the conformance suites pass.

refs https://github.com/oxc-project/oxc/pull/8267

## How

Derive the value from `to_boolean`. This is strictly more conservative:
when `to_boolean` returns `None` the fold is skipped rather than
answered wrongly.

## Testing

- New regression test `test_conditional_to_unary_plus_uses_to_boolean`
covering `NaN`, `"0"`, `" "`, `"0.0"` and `[]`.
- `cargo test -p oxc_minifier -p oxc_ecmascript`, `cargo clippy
--all-targets`, `cargo fmt --check` all pass.
- `just minsize`: no snapshot change, so no size regression.

## AI disclosure

This change was produced with AI assistance (Claude Code). I have
reviewed and understand every line; the root cause and each minimal
reproduction were verified by hand against Node.js.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kotaro ChikubaandClaude Opus 5 authored and GitHub committed 2026-08-14 00:35:39 +00:00
1 parent 757f3d4113
commit e82495ba54
2 files changed
+19 -3

No files matched your search

+3 -3
View File
@@ -1,7 +1,7 @@
use oxc_ast::ast::*;
use crate::{
GlobalContext, StringToNumber, ToJsString,
GlobalContext, StringToNumber, ToBoolean, ToJsString,
to_primitive::maybe_object_with_to_primitive_related_properties_overridden,
};
@@ -31,8 +31,8 @@ impl<'a> ToNumber<'a> for Expression<'a> {
},
Expression::StringLiteral(lit) => Some(lit.value.as_str().string_to_number()),
Expression::UnaryExpression(unary) if unary.operator.is_not() => {
let number = unary.argument.to_number(ctx)?;
Some(if number == 0.0 { 1.0 } else { 0.0 })
let boolean = unary.argument.to_boolean(ctx)?;
Some(if boolean { 0.0 } else { 1.0 })
}
Expression::ObjectExpression(obj) => {
// If `toString` / `valueOf` / `Symbol.toPrimitive` is not overridden,
@@ -163,3 +163,19 @@ fn test_minimize_conditional_boolean_value_context() {
test("use(flag ? false : (value = touch()))", "use(flag ? !1 : value = touch())");
test("use(a === b || c === d ? value : false)", "use(a === b || c === d ? value : !1)");
}
/// `a ? 1 : 0` becomes `+!!a`, whose value is `ToBoolean(a)` coerced to a
/// number. Deriving it from `ToNumber(a)` instead flips the result for every
/// value where `ToNumber(a) === 0` and `ToBoolean(a)` disagree.
#[test]
fn test_conditional_to_unary_plus_uses_to_boolean() {
// `ToNumber(NaN)` is not `0`, but `NaN` is falsy.
test("x = (NaN ? 1 : 0) || 2", "x = 2");
test("x = (NaN ? 1 : 0) && 2", "x = 0");
// `ToNumber("0")` is `0`, but a non-empty string is truthy.
test("x = ('0' ? 1 : 0) || 2", "x = 1");
test("x = (' ' ? 1 : 0) || 2", "x = 1");
test("x = ('0.0' ? 1 : 0) || 2", "x = 1");
// `ToNumber([])` is `0`, but every object is truthy.
test("x = ([] ? 1 : 0) || 2", "x = 1");
}