fix(crates/oxc_semantic): decline to evaluate unary ops on strings in enum initializers (#27447)

## Summary

`eval_unary_expression` folded unary `+`/`-`/`~` on `ConstantValue::String` using Babel's semantics (`+"s"` → `"s"`, `-"s"` → `NaN`, `~"s"` → `-1`), citing "JS coercion" — but none of those match JavaScript, where `+"2"` is `2`, `-"3"` is `-3`, and `~"4"` is `-5`. TypeScript leaves such initializers unevaluated (computed members), which the code comment itself acknowledges.

Downstream in `oxc_transformer`, a wrongly-folded enum member value does real damage:

- `enum E { A = +"2" }` folded to the *string* `"2"`, so the member was emitted as a plain `E["A"] = "2"` — wrong type and missing the reverse mapping
- `enum E { B = -"3" }` folded to `NaN`, emitting `E[E["B"] = NaN] = "B"` — a bogus `NaN` key and `B === NaN` instead of `-3`
- `~"4"` folded to `-1` — `C === -1` instead of `-5`
- member references with string values hit the same arm (`enum E { A = "4", B = -A }` → `NaN` instead of `-4`), and the bad value then propagated into following members (`enum E { A = +"2", B }` → `B = 12` via `1 + "2"` instead of `undefined`)

This was found while fixing oxc-project/oxc-node#795: `with_enum_eval(true)` is required for correct alias emission, which made this evaluator bug reachable in a release build.

## Fix

Return `None` for unary `+`/`-`/`~`/`void`/`!` on `ConstantValue::String` — the initializer stays a computed member and is emitted verbatim, matching `tsc` emit byte-for-semantics. Declining (rather than coercing with `ToNumber`) is the safer choice: verbatim emit is exactly what `tsc` produces, and a member whose init declines propagates `None`, so dependents like `B = -A` also emit verbatim.

## Test plan

- Updated `unary_on_string` to expect `None` for all three ops
- Added `unary_on_string_member_reference` covering a string-valued member reference (`-X`, `~X`, `+X` on `X = "4"`)
- `cargo test -p oxc_semantic` (87 tests) and `cargo test -p oxc_transformer` green; `cargo clippy -p oxc_semantic -D warnings` clean
This commit is contained in:
Brooooooklyn committed 2026-10-10 03:15:22 +00:00
1 parent 3d00bbd2c1
commit 72bf06fc61
4 files changed
+26 -28

No files matched your search

+5 -10
View File
@@ -258,18 +258,13 @@ fn eval_unary_expression(
) -> Option<ConstantValue> {
let value = evaluate_expression(&expr.argument, ctx)?;
// Babel uses JS coercion for unary on strings: `+"s"` → `"s"`, `-"s"` → NaN, `~"s"` → -1.
// TypeScript would leave these unevaluated (computed members). We align with Babel.
// TypeScript leaves unary operations on strings unevaluated (computed members),
// so we decline here too — folding them would need ToNumber coercion, and a
// folded value that does not match the runtime result is worse than none at
// all (`-"3"` must produce -3, `~"4"` -5).
let value = match value {
ConstantValue::Number(v) => v,
ConstantValue::String(_) => {
return match expr.operator {
UnaryOperator::UnaryPlus => Some(value),
UnaryOperator::UnaryNegation => Some(ConstantValue::Number(f64::NAN)),
UnaryOperator::BitwiseNot => Some(ConstantValue::Number(-1.0)),
_ => None,
};
}
ConstantValue::String(_) => return None,
};
match expr.operator {
@@ -218,15 +218,23 @@ fn merged_enum_cross_declaration_reference() {
#[test]
fn unary_on_string() {
// Babel uses JS coercion: +"s" → "s" (identity), -"s" → NaN, ~"s" → -1
// TypeScript leaves unary-on-string initializers unevaluated, so the enum
// member keeps its verbatim initializer instead of folding to a wrong value.
let source = r#"enum A { X = +"hello", Y = -"hello", Z = ~"hello" }"#;
debug_assert_eq!(
get_enum_member_value(source, "X"),
Some(ConstantValue::String("hello".into()))
);
match get_enum_member_value(source, "Y") {
Some(ConstantValue::Number(n)) => assert!(n.is_nan()),
other => panic!("Expected Some(Number(NaN)), got {other:?}"),
}
debug_assert_eq!(get_enum_member_value(source, "Z"), Some(ConstantValue::Number(-1.0)));
assert_eq!(get_enum_member_value(source, "X"), None);
assert_eq!(get_enum_member_value(source, "Y"), None);
assert_eq!(get_enum_member_value(source, "Z"), None);
}
#[test]
fn unary_on_string_member_reference() {
// A unary operation on a member whose value is a string must decline for the
// same reason — `+"4"` would otherwise fold to the string "4" (tsc: number 4)
// and `-"3"`/`~"4"` to NaN/-1 instead of -3/-5, also breaking the reverse
// mapping and any auto-increment member after it.
let source = r#"enum A { X = "4", Y = -X, Z = ~X, W = +X }"#;
assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::String("4".into())));
assert_eq!(get_enum_member_value(source, "Y"), None);
assert_eq!(get_enum_member_value(source, "Z"), None);
assert_eq!(get_enum_member_value(source, "W"), None);
}
@@ -18194,8 +18194,8 @@ rebuilt : ["ValidSomeCondition", "directory", "getSpecifier", "moduleFile
semantic Error: tasks/coverage/typescript/tsc/testdata/tests/cases/conformance/enums/enumClassification.ts
Missing ReferenceId: "E20"
Symbol reference IDs mismatch for "E20":
after transform: SymbolId(56): [ReferenceId(77), ReferenceId(78), ReferenceId(79), ReferenceId(80), ReferenceId(81), ReferenceId(82), ReferenceId(83), ReferenceId(84)]
rebuilt : SymbolId(23): [ReferenceId(72), ReferenceId(73), ReferenceId(74), ReferenceId(75), ReferenceId(76), ReferenceId(77), ReferenceId(78), ReferenceId(79), ReferenceId(81)]
after transform: SymbolId(56): [ReferenceId(77), ReferenceId(78), ReferenceId(79), ReferenceId(80), ReferenceId(81), ReferenceId(82), ReferenceId(83), ReferenceId(84), ReferenceId(85)]
rebuilt : SymbolId(23): [ReferenceId(72), ReferenceId(73), ReferenceId(74), ReferenceId(75), ReferenceId(76), ReferenceId(77), ReferenceId(78), ReferenceId(79), ReferenceId(80), ReferenceId(82)]
semantic Error: tasks/coverage/typescript/tsc/testdata/tests/cases/conformance/enums/enumConstantMemberWithStringEmitDeclaration.ts
Bindings mismatch:
@@ -55,12 +55,7 @@ after transform: ["dce"]
rebuilt : []
* computed-constant-value/input.ts
Unresolved references mismatch:
after transform: ["Infinity", "NaN"]
rebuilt : ["Infinity"]
Unresolved reference IDs mismatch for "Infinity":
after transform: [ReferenceId(0), ReferenceId(1), ReferenceId(2), ReferenceId(3), ReferenceId(8), ReferenceId(11), ReferenceId(14), ReferenceId(18)]
rebuilt : [ReferenceId(2), ReferenceId(5), ReferenceId(8), ReferenceId(12)]
x Output mismatch
* declare-and-definite-with-initializer/input.ts