mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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)
|
||||
|
||||
Reference in new issue
Block a user