mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-19 22:04:48 +00:00
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:
4 files changed
+26
-28
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user