Skip to content

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

Merged
camc314 merged 1 commit into
oxc-project:mainfrom
leemr:prefer-code-point-dangerous-fix
Aug 9, 2026
Merged

fix(linter/unicorn/prefer-code-point): downgrade the auto-fix to dangerous#25412
camc314 merged 1 commit into
oxc-project:mainfrom
leemr:prefer-code-point-dangerous-fix

Conversation

@leemr

@leemr leemr commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

I hit this maintaining 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.

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.

…erous

## Summary

- Declare `fix_dangerous` and report with `ctx.diagnostic_with_dangerous_fix`, so a
plain `--fix` no longer applies the rename.
- `charCodeAt` returns a UTF-16 code unit while `codePointAt` returns a whole code
point, so the rename changes the result of an index-stepping loop over astral text.
This silently corrupted an FNV-1a hash.
- `String.fromCodePoint` throws a `RangeError` on negative, fractional, and
out-of-range arguments that `String.fromCharCode` truncates, so the rename can turn
working code into a throw.
- Upstream `eslint-plugin-unicorn` does not auto-fix this rule at all, and withholds
even the suggestion when the result is consumed as a number.
- Follows the shape requested on oxc-project#17466 for `unicorn/no-null`. Diagnostics are
unchanged, so no snapshot moves and `--fix-dangerously` still applies the fix.
- Add a `FixKind::SafeFix` case expecting the source back unchanged, so a future move
to the safe tier fails the suite.
@leemr
leemr requested a review from camc314 as a code owner August 9, 2026 01:04

@camc314 camc314 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@camc314 camc314 self-assigned this Aug 9, 2026
@camc314 camc314 added the A-linter Area - Linter label Aug 9, 2026
@camc314
camc314 enabled auto-merge (squash) August 9, 2026 09:09
@camc314
camc314 merged commit 95ece63 into oxc-project:main Aug 9, 2026
29 checks passed
@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 5 untouched benchmarks
⏩ 71 skipped benchmarks1


Comparing leemr:prefer-code-point-dangerous-fix (d9451a1) with main (12937b3)2

Open in CodSpeed

Footnotes

  1. 71 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (36d4d9b) during the generation of this report, so 12937b3 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

graphite-app Bot pushed a commit that referenced this pull request Aug 10, 2026
# Oxlint
### 💥 BREAKING CHANGES

- a33788e ast: [**BREAKING**] Group class heritage into `ClassHeritage` (#25360) (camc314)
- 5c5cdcd ast: [**BREAKING**] Narrow `TSInterfaceHeritage::expression` to TSTypeName (#24360) (camc314)
- 6be314f ast: [**BREAKING**] Remove duplicated `VariableDeclarator::kind` (#25319) (camc314)
- 44fd320 ast: [**BREAKING**] Split TS external modules & Namespace Declarations (#25284) (camc314)

### 🚀 Features

- ccb8fe8 linter/jsdoc: Implement `no-blank-blocks` rule (#25207) (Mikhail Baev)
- d4a897c linter/eslint: Implement `one-var` rule (#24470) (Cole Ellison)
- 5ab9340 linter/jsx-a11y/anchor-has-content: Add options to match eslint (#24571) (Cole Ellison)

### 🐛 Bug Fixes

- b746e00 linter/eslint/no-implicit-coercion: Preserve template coercion whitespace (#25470) (camc314)
- a92c541 linter: Preserve source text for JS plugin ignore fixes (#25280) (Norcleeh)
- 675c840 linter/eslint/prefer-promise-reject-errors: Handle parenthesized calls (#25378) (camc314)
- 1703739 linter/unicorn/new-for-builtins: Ignore optional chains (#25411) (tanakalucky)
- 95ece63 linter/unicorn/prefer-code-point: Downgrade the auto-fix to dangerous (#25412) (leemr)
- c451a0e linter/vitest: Validate `consistent-test-filename` regex patterns (#25408) (Mikhail Baev)
- 937825c react_compiler: Disable exhaustive memo validation by default (#25417) (Boshen)
- f0f7dae linter/eslint/no-unused-vars: Report invalid regex options (#25380) (Cameron)
- 44e73fd linter/unicorn/prefer-array-flat: Fix `concat.apply` suggestions (#25373) (Cameron)
- 6846a9a linter/react/rules-of-hooks: Detect constructor callbacks (#25377) (camc314)
- b247a9d linter/unicorn/new-for-builtins: Support `Float16Array` (#25382) (tanakalucky)
- 19109cd linter/unicorn/error-message: Support `SuppressedError` messages (#25375) (camc314)
- 9c13f5e linter: Assert token lookup invariants (#25368) (camc314)
- bc35f83 linter/eslint/no-unused-vars: Bound catch parameter lookup (#25367) (camc314)
- c159fb9 linter/unicorn/switch-case-braces: Bound token lookup (#25363) (camc314)
- 03b2eb2 linter/unicorn/no-static-only-class: Bound token lookup (#25361) (camc314)
- 0afc59e linter/unicorn/empty-brace-spaces: Bound token lookup (#25353) (camc314)
- 2963d98 linter/eslint/no-unreachable-loop: Do not report loops whose body has a finally block (#25335) (Todor Andonov)
- 589e5fb linter/eslint/no-param-reassign: Validate `ignorePropertyModificationsForRegex` property (#25346) (Mikhail Baev)
- aae5d8b linter/eslint/no-throw-literal: False positive on variable declared without initializer (#25275) (cjnoname)
- 6b1c479 oxlint: Normalize customized rule names (#25316) (camc314)
- d494eb5 linter/unicorn/consistent-existence-index-check: Bound token lookup (#25325) (camc314)
- 4266037 linter/typescript/prefer-namespace-keyword: Bound token lookup (#25322) (camc314)
- 4745b4e linter/typescript/no-namespace: Bound token lookup (#25321) (camc314)
- 648a481 linter/eslint/one-var: Avoid joining exported declarations (#25314) (camc314)
- 9573937 linter/typescript: Validate `ban-ts-comment` description_format (#25320) (Mikhail Baev)
- ebf7d18 linter/typescript/consistent-type-definitions: Bound token lookup (#25281) (camc314)
- 1501ccf linter/typescript/consistent-generic-constructors: Bound token lookup (#25258) (camc314)

### ⚡ Performance

- 8f784f3 linter: Reduce rule config dispatch size (#25461) (Boshen)
- 2de4ec2 linter: Reduce visitor code size (#25441) (Boshen)
- 6fb7f47 linter/unicorn/prefer-export-from: Narrow `ExportFromDeclaration` lookup (#25381) (camc314)
- e3f6263 linter/unicorn/prefer-default-parameters: Avoid reference allocation  (#25379) (camc314)
- d863473 linter/vue/max-props: Narrow AST dispatch (#25372) (camc314)
- 273d867 linter: Avoid diagnostic sorting after applying fixes (#25079) (Sysix)
- 4ec9189 oxlint/lsp: Avoid second lock for getting/removing unused directives (#25350) (Sysix)
- 6c0d01b oxlint/lsp: Preallocate fix-content vec (#25351) (Sysix)
- 3a94055 linter: Avoid per-call heap allocations in jest and unicorn helpers (#25210) (Connor Shea)
- 4abff11 linter: Avoid redundant message work in the agent reporter (#25315) (Connor Shea)
- 6bb5421 linter: Hoist `current_dir` out of the stylish reporter loop (#25313) (Connor Shea)
- 9a7c323 linter: Compute diagnostic `Info` once per diagnostic in junit reporter (#25312) (Connor Shea)
- 1cf7dde oxlint: Render JSON report into a single buffer (#25295) (connorshea)
- 8492cfd linter/typescript/ban-ts-comment: Bail early with substring guard (#25301) (Jacob Asper)
- 7607f04 linter/typescript/ban-tslint-comment: Replace regex with manual parser (#25299) (Jacob Asper)
# Oxfmt
### 💥 BREAKING CHANGES

- a33788e ast: [**BREAKING**] Group class heritage into `ClassHeritage` (#25360) (camc314)
- 5c5cdcd ast: [**BREAKING**] Narrow `TSInterfaceHeritage::expression` to TSTypeName (#24360) (camc314)
- 6be314f ast: [**BREAKING**] Remove duplicated `VariableDeclarator::kind` (#25319) (camc314)
- 44fd320 ast: [**BREAKING**] Split TS external modules & Namespace Declarations (#25284) (camc314)

### 🚀 Features

- fd02a89 oxfmt: Dispatch yaml-in-css(frontmatter) to `oxc_formatter_yaml` (#25336) (leaysgur)
- ab12665 formatter_core: Add `hardlineWithoutBreakParent` equivalent IR (#25273) (leaysgur)

### 🐛 Bug Fixes

- 95dc917 oxfmt: Drop IR Space at line start for js-in-xxx (#25460) (leaysgur)
- b63eccc formatter: Keep comments after TS this_param (#25459) (leaysgur)
- ab52a59 formatter: Format xxx-in-js inside JSDoc js fence (#25414) (leaysgur)
- 1a2c64a formatter,oxfmt: Apply effective print width for JSDoc fence (#25413) (leaysgur)
- 2eaede9 formatter_core: Unify leading-BOM handlings (#25340) (leaysgur)
- ef1d04b formatter: Break mapped type brackets (#25297) (leaysgur)
- 4e6f3f1 formatter: Break index signature brackets (#25296) (leaysgur)
- e23dccf formatter_css: Bump oxc-css-parser to accept unknown at-rule with interpolated (#25277) (leaysgur)
- c29b587 formatter_core: Measure decided-flat fill separator as flat during group re-measure (#25276) (leaysgur)
- f3c6953 formatter_yaml: Don't rewrite overflowing key to implicit (#25274) (leaysgur)

### ⚡ Performance

- c9d1a5b oxfmt: Spawn tinypool lazily (#25298) (leaysgur)

### 📚 Documentation

- 6eae5c9 formatter,oxfmt: Record embed-layer decisions in place (#25422) (leaysgur)
- 51224a7 formatter_yaml: Pin EOF blank lines divergence (#25269) (leaysgur)
camc314 pushed a commit that referenced this pull request Aug 12, 2026
…ry (#25445)

`test_suppressing_errors_update_the_file_when_errors_are_decreased` and
`test_prunning_errors_update_the_file_when_errors_are_decreased` both
point at `fixtures/suppression/with_arg_and_decreased_errors`. Every
other `SuppressionTester` fixture has a single owner. Both of these run
oxlint in that directory, which rewrites `oxlint-suppressions.json`, and
both then delete it and restore it from the backup in `Drop`.

It showed up as an intermittent red in `just ready` while I was working
on something unrelated, and it cost enough re-runs that I went looking.
I flagged it at the end of #25412. This is that fix.

Measured with `cargo test -p oxlint suppress`, ten runs per setting, on
an 18 core machine:

```
--test-threads   failures
1                0/10
12               0/10
16               0/10
17               0/10
18               3/10
20               0/10
24               0/10
```

18 is `hw.ncpu` there, and it is what `cargo test` picks by default. 20
and 24 are more parallel and never failed, so this is not load and it is
not machine speed. The two tests have to land in the same dispatch wave,
which depends on the divisor. The `Test Linux` job runs `cargo test
--all-features` and stays green, which I read as a different core count
rather than anything about the runner.

Three distinct failure modes came out of the one shared directory:

```
tester.rs:249   one test's opening assert runs while the other sits
                between remove_file and fs::copy
tester.rs:295   both Drop calls delete the same path, and the second gets ENOENT
tester.rs:274   one test reads the file while the other is inside a truncating write
```

Before settling on the fixture split I checked whether repairing `Drop`
would be enough, since deleting before copying is what opens the widest
window. It is not enough. Guarding the delete with `&&
!self.have_backup_file`, so the file is never absent, left the rate
where it was and only changed the message:

```
Drop shape                   directory   failures
delete then copy, as today   shared      3/20
copy only                    shared      4/40
unchanged                    separate    0/40
```

To find out what the reader was actually getting in that third mode, I
patched the panic at `tester.rs:274` to print the observed content. That
patch was throwaway and is not in this diff:

```
PROBE observed len=0 serde=EOF while parsing a value at line 1 column 0 first80=""
```

Length zero, not a torn file. `SuppressionFile::save` uses `fs::write`,
which is `File::create` plus `write_all`, and `File::create` opens with
`O_TRUNC`. The truncate commits at open time and the bytes arrive on the
next syscall, so the file is briefly empty. A separate directory is the
only change that closes both that window and the `Drop` one.

Calling out one thing up front: the new directory is a byte for byte
copy of the old one. `--suppress-all` and `--prune-suppressions`
converge in the decreased case, so the expected content is the same for
both tests. The directory exists for isolation, not for content.
`with_arg_and_increased_errors` and
`with_arg_and_increased_errors_prune` are the same split for the case
where the two commands diverge, so five of six files match there and six
of six match here.

This is the same shape as #20717, where `--init` writes leaked across
parallel tests.

With the change in, the suppression module is 0/20 at 18 threads and
0/10 at the default. The rest of `just ready` is green as well: `typos`,
`cargo lintgen`, `just fmt`, `just check`, `just test`, `just lint`,
`just doc` and `just ast`, with 4446 tests passing under
`--test-threads=1`.

Three other suppression fixtures have more than one owner, and I left
all three alone. `fixed_violations_are_reported` has two readers.
`type_check_only_with_regular_rule` also has two, and although the
second passes `--suppress-all` and `--prune-suppressions`, `lint.rs:426`
rejects that combination before any lint runs, so nothing writes.
`diagnostics_filtered_if_count_is_the_same` is the only other one where
a write really happens, so it carries the truncating write window in
principle, but that window is a single `fs::write` over a few hundred
bytes, where the one this PR fixes spanned a whole `Drop`
delete-then-copy plus the other test's entire run. Forty runs with those
two tests forced to overlap found nothing. I did not want to change
something I cannot show is broken. Happy to fold it in if the whole
class should close in one go.

Disclosure, per CONTRIBUTING.md: I used Claude for the investigation and
for the patch. I have reviewed and tested all of it myself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter

2 participants