ADR-0004: oxlint as a fast front pass in front of ESLint
- Status: Accepted
- Date: 2026-09-12
- Issue: #64 (D5 oxlint; the grill outcome is the last comment on the issue)
Context
bun run lint is the first job of the JS gate and the slowest of the fast ones: a full ESLint run is ≈ 3.6 s on 72 files, most of it rules that a Rust linter runs in ≈ 0.1 s. oxlint 1.82 natively covers eslint core / typescript / react / jsx-a11y / import / unicorn / jest / promise / node, but has no equivalent of eslint-config-expo, eslint-plugin-react-native-a11y, simple-import-sort or the local require-testid rule, and its JS-plugin bridge (jsPlugins) is alpha. So oxlint cannot replace ESLint yet; the question was whether it earns a place in front of it.
While wiring this up it turned out that expo lint had never run ESLint in this repo: it spawns bun eslint src …, and Bun resolves a local ./eslint path before a node_modules/.bin entry, so the repo's eslint/ plugin folder was being executed (exit 0, no output) instead of ESLint — locally and in the Lint CI job.
Decision
oxlint runs first, with its defaults; ESLint stays the owner of everything oxlint cannot express.
- Rule set = oxlint defaults.
.oxlintrc.jsonat the repo root carries only ignore patterns (the same set as the ESLintignoresblock) and any per-path override needed to stay green. No@oxlint/migrate, no extra categories, nojsPlugins(alpha). - Placement = inside
bun run lint:"lint": "oxlint && expo lint". TheLintCI job, the required-checks set (scripts/repo-settings.js) andlefthook.ymlare untouched — no separate job, no pre-commit change. - Overlap removal.
eslint-plugin-oxlint'sbuildFromOxlintConfigFile('.oxlintrc.json')is spread intoeslint.config.jsafter our rules block, so ESLint stops reporting rules oxlint already runs. Our explicitunused-imports/*rules remain in effect (they carry the^_policy);bunx eslint --print-config src/components/animated-icon.tsx | grep unused-importsis the check. - Severity policy matches ESLint. Warnings print, only errors fail — no
--deny-warnings. - Trial nits stay. The no-config trial reported only warnings (
animated-icon.tsxunused var,unicorn/prefer-string-starts-ends-within twoscripts/e2e-*.js,no-useless-escapeinscripts/init.js); only errors would have been fixed, and there were none. - Versions in lockstep.
oxlintandeslint-plugin-oxlintare devDependencies pinned to the same exact version (1.82.0) and grouped asoxlintin.github/renovate.json5, because the plugin's "rules oxlint owns" list must match the binary. - Docs. This record; the
bun run lintline inCLAUDE.md, the Toolchain bullet indocs/conventions.md, theLintrows indocs/js-gate.mdanddocs/ci-overview.md. - The local plugin folder is
eslint-rules/, no longereslint/, so thatbun eslint …(whatexpo lintspawns) resolves the ESLint binary again.
Consequences
bun run lintis oxlint (≈ 0.1 s) followed by a real ESLint run (≈ 2.4 s cold, ≈ 0.3 s withexpo lint's cache in.expo/cache/eslint/). Before this change the ESLint half was a no-op.- ESLint reports fewer rules than before (the ones oxlint owns are turned off there); the same problems now surface from oxlint, in its output format, before ESLint starts. Nothing is gated that was not gated before, and the
Lintcheck is now actually enforcing ESLint's rules — a branch that was green only because ESLint never ran will show its findings on the next push. - Two linters mean two config files. Anything project-specific (rule choices, testID policy, a11y, import order) still lives in
eslint.config.js;.oxlintrc.jsonshould stay ignore-patterns and per-path overrides only. - The two packages must be bumped together: a Renovate PR for one without the other is a sign the
oxlintgroup inrenovate.json5was broken. - Revisit when oxlint's JS-plugin bridge leaves alpha: consider moving
require-testidand the react-native-a11y rules to oxlint plugins and dropping ESLint entirely (an ADR superseding this one).