Skip to content

chore(lint): migrate to oxlint and oxfmt - #1117

Merged
EhabY merged 2 commits into
mainfrom
chore/oxc-lint-format-migration
Sep 18, 2026
Merged

EhabY merged 2 commits into
mainfrom
chore/oxc-lint-format-migration

Conversation

@EhabY

@EhabY EhabY commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

What and why

Move linting to Oxlint and formatting to Oxfmt, keeping a small residual ESLint for what Oxlint cannot do (import-x/order, Markdown, package.json).

Benchmarks

Interleaved A/B against main on the same host, minimum of 3–5 rounds, corroborated in CPU time. Negative Δ is faster.

Case main branch Δ
CI lint job (typecheck + format + lint) 26.3s 5.2s −80%
pnpm lint cold 17.1s 4.8s −72%
pnpm lint at default heap crash (OOM, exit 134) passes fixed
pnpm format:check cold 3.22s 0.30s −91%
pnpm typecheck (root + test) 5.43s 0.95s −83%
pnpm lint warm 0.94s 2.86s +204%

Real CI agrees: Lint went from 94s on main to 39s here (run 35145079125).

The warm regression. Oxlint and Oxfmt have no cache, so warm local lint is genuinely slower than cached ESLint. Two mitigations, both applied or documented:

  • Thread capping (scripts/run-oxc.mjs): both tools default to nproc threads; above ~32 cores the per-thread startup outweighs the parallelism. Capping at 8 cut oxlint 4.0s→2.1s and oxfmt 1.8s→0.23s on a 128-core box, and is a no-op on CI (4 vCPU) and typical laptops. Upstream: oxc#21672.
  • JS plugins: the rest is a fixed ~400ms load plus per-file AST bridging in the react-x plugin, inherent to the bridge. Editor latency is unaffected; the Oxc LSP measures 60ms init and <1ms diagnostics on didChange.

Config structure

Two oxlint config files:

  • .oxlintrc.json (346 lines): correctness: "error" category + 82 curated rules from all categories (pedantic, style, restriction, suspicious). React rules are extracted to .oxlintrc.react.json via extends.
  • .oxlintrc.react.json (87 lines): 18 react/* and 52 react-x/* rules scoped to packages/**/*.{ts,tsx}.

The residual ESLint config (eslint.config.mjs) handles only what Oxlint cannot:

ESLint rule/plugin Why Oxlint can't do it
import-x/order No import ordering rule; Oxfmt's sortImports reorders differently
@eslint/markdown Oxlint only lints source extensions; Markdown needs processors
eslint-plugin-package-json (58 rules) Oxlint only lints source extensions

This list is documented in CONTRIBUTING.md so entries can be removed as Oxlint gains equivalents.

Rule coverage

I diffed every rule enabled on main against the effective rules here, per file, using --print-config. Everything left out is accounted for:

Rule Status
@eslint-react/no-leaked-conditional-rendering Genuine loss (type-aware, can't run as a JS plugin). All 4 reported sites were already safe and are now explicit ternaries; 2 no-restricted-syntax selectors cover numeric JSX &&.
curly Inert on main: eslint-config-prettier set it to 0, so it never ran. Enabling it fresh fails on 69 pre-existing sites, so it stays off rather than blocking this PR.
@typescript-eslint/prefer-optional-chain Nursery-only in Oxlint; not enabled.
no-undef Oxlint omits it; tsc + strict mode covers it.
import-x/no-unresolved Redundant; tsc reports TS2307 and runs earlier in CI.
import-x/no-relative-packages Replaced by tsconfig rootDir, which is stricter (catches even valid cross-package relative imports).
no-dupe-args, no-octal Superseded by strict mode.
react-hooks/config, react-hooks/gating Structurally inert: no react-compiler settings and no gating options are configured.
no-useless-assignment Recovered via the already-loaded eslint-js plugin.
require-await Kept off: 37 pre-existing violations (async without await).

React regression, found and fixed. The migration tool copied disable-conflict-eslint-plugin-react-hooks's react-hooks/*: off entries across as react/* (Oxlint's own namespace), silently disabling @eslint-react rules with no replacement. The config restores the 17 natively-implemented rules at their previous severities and loads the other 52 from @eslint-react as a JS plugin aliased react-x; a probe with a conditional hook, missing key, leaked fetch, and nested component reports the same 5 findings as main.

Upstream workarounds

Both guarded by test/unit/oxlintConfig.test.ts:

  1. Extglob patterns silently match nothing (oxc#21525). The tool emitted **/*.stories.@(ts|tsx|...), leaving every Storybook rule inert. Brace globs instead.
  2. JS plugin specifiers don't resolve: absolute paths embed pnpm peer hashes; bare names don't resolve for ESM-only plugins. Root-relative paths, asserted to exist by the test.

Also: no-restricted-imports patterns understand only ** and literal paths, not single *, so the UI guard is "../../../**".

Formatting

22 files reformat from exactly two divergence classes, both deterministic and idempotent: union types expand to leading-pipe style (known upstream divergence, oxc#25841) and one yield operand is parenthesised. Prettier stays a devDependency because Oxfmt delegates Markdown/YAML to it internally.

TypeScript 7

TS 7 has no programmatic API and typescript-eslint refuses to load against it, so they run side-by-side per the official guidance: typescript aliased to @typescript/typescript6 (API, tsc6) and @typescript/native to typescript@^7 (the tsc binary). This also fixes the default-heap OOM: the full type-aware ESLint config exceeded the default heap, and dropping that config removes the need for --max-old-space-size.

Reviewer checklist

  • Keeping ESLint for import-x/order is acceptable (the alternative is a 153-file sortImports reformat)
  • The 22-file reformat is formatting-only
  • The side-by-side TypeScript setup beats waiting for typescript-eslint TS7 support
  • The warm-loop cost (no cache) is acceptable
  • Capping threads at 8 is preferred to tool defaults

This PR was generated by Coder Agents.

@EhabY
EhabY marked this pull request as ready for review September 17, 2026 13:04
@EhabY
EhabY force-pushed the chore/oxc-lint-format-migration branch 9 times, most recently from 46e34a0 to e4feb1d Compare September 17, 2026 15:45
@EhabY
EhabY requested a review from jeremyruppel September 18, 2026 10:56
Move linting to Oxlint and formatting to Oxfmt, keeping a small
residual ESLint for what Oxlint cannot do (import-x/order, Markdown,
package.json).

- Add .oxlintrc.json with correctness category + curated rules from
  all categories, plus .oxlintrc.react.json for react/react-x rules
- Add .oxfmtrc.json for formatting with oxfmt
- Keep eslint.config.mjs for import-x/order, Markdown, and
  package.json linting (documented in CONTRIBUTING.md)
- Run TypeScript 7 (tsc) side-by-side with TypeScript 6 (API for
  typescript-eslint)
- Cap oxlint/oxfmt thread count at 8 (scripts/run-oxc.mjs)
- Fix 5 no-misused-spread and require-array-sort-compare findings
- Guard against oxlint config regressions with
  test/unit/oxlintConfig.test.ts

Benchmarks: CI lint job -80% (26.3s -> 5.2s), format:check -91%
(3.22s -> 0.30s), typecheck -83% (5.43s -> 0.95s). Fixes OOM on
default heap.
@EhabY
EhabY force-pushed the chore/oxc-lint-format-migration branch from e4feb1d to 2ba9e23 Compare September 18, 2026 10:56
Comment thread .oxfmtrc.jsonc
Comment thread package.json
Comment thread test/unit/oxlintConfig.test.ts Outdated
Rename .oxfmtrc.json, .oxlintrc.json, and .oxlintrc.react.json to
.jsonc. These files already carry comments, so the .jsonc extension
gets GitHub to highlight them and gets VS Code to stop flagging the
comments as errors. Oxfmt and Oxlint both discover the .jsonc names,
and `extends` resolves .oxlintrc.react.jsonc.

Parse the config with jsonc-parser in test/unit/oxlintConfig.test.ts
so comments in .oxlintrc.jsonc stay possible.

Drop the jsPlugins resolution case from that test. Oxlint loads JS
plugins eagerly when it parses the config and exits non-zero on a
missing one, even when the override glob matches nothing, so
`pnpm lint` already covers it.
@EhabY
EhabY requested a review from jeremyruppel September 18, 2026 14:49

@jeremyruppel jeremyruppel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

heck yeah ox{lint,fmt}!

@EhabY
EhabY merged commit ab45bb9 into main Sep 18, 2026
11 of 12 checks passed
@EhabY
EhabY deleted the chore/oxc-lint-format-migration branch September 18, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants