fix(linter/unicorn/prefer-code-point): downgrade the auto-fix to dangerous (#25412)

I hit this maintaining [daymath](https://github.com/leemr/daymath).
`oxlint --fix` rewrote an FNV-1a hash in the cross-runtime test baseline
and changed its output. The rule renames `charCodeAt` to `codePointAt`
without looking at how the result is used, and `--fix` is the tier the
help text presents as the safe one.

Here is the file. On 1.77.0, `oxlint -D pedantic --fix hash.js` rewrites
line 4.

```js
export function hash(s) {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i); // becomes s.codePointAt(i)
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h >>> 0;
}
```

The loop steps by one index. `codePointAt` returns a whole code point,
so an astral character gets consumed and then its low surrogate is read
again on the next pass. Same function, before and after the fix:

```
input     before        after
"abc"     440920331     440920331
"café"    856211068     856211068
"🗓"      506302049    3666458339
"a🗓b"   1956674582    2294310376
```

BMP text is unaffected, which is why this one hides for a while.

The `String.fromCharCode` branch has the same problem and a worse
failure mode, because `fromCodePoint` rejects input that `fromCharCode`
truncates. Results written as code points, since most of them are
control characters:

```
String.fromCharCode(-1)        -> U+FFFF      String.fromCodePoint(-1)        -> RangeError
String.fromCharCode(1.5)       -> U+0001      String.fromCodePoint(1.5)       -> RangeError
String.fromCharCode(NaN)       -> U+0000      String.fromCodePoint(NaN)       -> RangeError
String.fromCharCode(0x110000)  -> U+0000      String.fromCodePoint(0x110000)  -> RangeError
```

So a plain `--fix` can turn working code into a throw. Separately, for
values above `0xFFFF` the two disagree without throwing, and the rule's
own doc example is one of those: `String.fromCharCode(0x1f984)` is
`"濾"`, while `String.fromCodePoint(0x1f984)` is `"🦄"`.

Upstream eslint-plugin-unicorn does not auto-fix this rule at all. Their
docs say it is manually fixable by editor suggestions, and they withhold
even the suggestion in this exact situation: "When the result is used as
a number (for example, a string hash, or `charCodeAt(index) - 48` to
parse a digit), the rule reports but offers no suggestion, since
swapping to `codePointAt()` is not a safe rename there."

This is the same shape that was asked for on #17466 for
`unicorn/no-null`, so I kept to it: `fix` becomes `fix_dangerous`, and
`ctx.diagnostic_with_fix` becomes `ctx.diagnostic_with_dangerous_fix`.
The rule reports exactly as before, so no snapshot moves.
`--fix-dangerously` still applies the rename. I went with dangerous
rather than `suggestion` because `--fix-suggestions` only warns that it
may change program behavior, and a `RangeError` seemed like more than
that. Happy to move it to `suggestion` if you would rather have parity
with upstream.

On tests, the six existing fixer cases now pin `FixKind::DangerousFix`,
and there is one new case pinning `FixKind::SafeFix` that expects the
source back unchanged. That one is the regression guard: without it,
flipping the declaration back to `fix` still passes the whole suite,
because `Dangerous|Fix` contains `Fix`. `expect_fix` takes a single
tuple type, so adding that one case is what converts the others.

One `apps/oxlint` test may flake, so flagging it in case it turns up red
here.
`test_suppressing_errors_update_the_file_when_errors_are_decreased` and
`test_prunning_errors_update_the_file_when_errors_are_decreased` share
the fixture directory `with_arg_and_decreased_errors`, and
`SuppressionTester::drop` deletes `oxlint-suppressions.json` before
restoring it from the backup, so the other test's opening assert can
land in that gap. It reproduces on a clean checkout of `main` so it is
not from this change, and all 35 suppression tests pass under
`--test-threads=1`. Happy to file it separately.

Disclosure, per CONTRIBUTING.md: I used Claude for the investigation and
for the patch. I have reviewed and tested all of it myself.
This commit is contained in:
leemr authored and GitHub committed 2026-08-09 09:12:17 +00:00
1 parent c451a0ebcf
commit 95ece63e63
1 file changed
+33 -7
@@ -42,7 +42,7 @@ declare_oxc_lint!(
PreferCodePoint,
unicorn,
pedantic,
fix,
fix_dangerous,
version = "0.0.16",
short_description = "Prefer `String#codePointAt` over `String#charCodeAt` and `String.fromCodePoint` over `String.fromCharCode`.",
);
@@ -77,7 +77,7 @@ impl Rule for PreferCodePoint {
_ => return,
};
ctx.diagnostic_with_fix(
ctx.diagnostic_with_dangerous_fix(
prefer_code_point_diagnostic(span, replacement, current),
|fixer| fixer.replace(span, replacement),
);
@@ -86,6 +86,7 @@ impl Rule for PreferCodePoint {
#[test]
fn test() {
use crate::fixer::FixKind;
use crate::tester::Tester;
let pass = vec![
@@ -122,15 +123,40 @@ fn test() {
];
let fix = vec![
("string.charCodeAt(index)", "string.codePointAt(index)"),
("string.charCodeAt(index)", "string.codePointAt(index)", None, FixKind::DangerousFix),
(
"(( (( String )).fromCharCode( ((code)), ) ))",
"(( (( String )).fromCodePoint( ((code)), ) ))",
None,
FixKind::DangerousFix,
),
(
"String.fromCharCode.bind(String)",
"String.fromCodePoint.bind(String)",
None,
FixKind::DangerousFix,
),
(
"const x = String.fromCharCode",
"const x = String.fromCodePoint",
None,
FixKind::DangerousFix,
),
(r#""🦄".charCodeAt(0)"#, r#""🦄".codePointAt(0)"#, None, FixKind::DangerousFix),
(
"String.fromCharCode(0x1f984);",
"String.fromCodePoint(0x1f984);",
None,
FixKind::DangerousFix,
),
// `--fix` alone must not rewrite this. The result feeds a bitwise operator, so
// the rename changes the value for astral input. Only `--fix-dangerously` applies it.
(
"hash ^= string.charCodeAt(index)",
"hash ^= string.charCodeAt(index)",
None,
FixKind::SafeFix,
),
("String.fromCharCode.bind(String)", "String.fromCodePoint.bind(String)"),
("const x = String.fromCharCode", "const x = String.fromCodePoint"),
(r#""🦄".charCodeAt(0)"#, r#""🦄".codePointAt(0)"#),
("String.fromCharCode(0x1f984);", "String.fromCodePoint(0x1f984);"),
];
Tester::new(PreferCodePoint::NAME, PreferCodePoint::PLUGIN, pass, fail)