Skip to content

v4: oxlint and oxfmt replace ESLint and Prettier - #12

Draft
falkenhawk wants to merge 20 commits into
masterfrom
v4-oxlint-oxfmt
Draft

v4: oxlint and oxfmt replace ESLint and Prettier#12
falkenhawk wants to merge 20 commits into
masterfrom
v4-oxlint-oxfmt

Conversation

@falkenhawk

Copy link
Copy Markdown
Member

v4 replaces ESLint as the lint runner with oxlint and Prettier with oxfmt, keeping every check v3 enforced. ESLint stays in the tree only as a runtime dependency of two plugins that oxlint runs through its JS-plugin bridge; it is never the runner or the source of truth.

Design spec: docs/superpowers/specs/2026-09-07-oxlint-oxfmt-v4-design.md. Plan: docs/superpowers/plans/2026-09-07-oxlint-oxfmt-v4.md.

What's new

v3 v4
lint runner ESLint 9 flat config, eslint() from ./eslint oxlint 1.81, oxlint() from ./oxlint, used through extends in oxlint.config.ts
formatter Prettier config export oxfmt() from ./oxfmt (0.66); prettier export kept for projects still running Prettier
enforced formatting @stylistic rules under ESLint same 45 @stylistic rules under oxlint (v5.10); oxfmt and Prettier are on-demand and produce output that passes them
type-aware rules typescript-eslint parser, always on, disableTypeChecked escape hatch oxlint-tsgolint as optional peer, root-config switch, no-for-in-array always on, typeChecked tier of 16 catchers
test frameworks jest, vitest, mocha, cypress jest, vitest, mocha, playwright (eslint-plugin-playwright)
severities mixed errors only; categories: { correctness: 'error' }
package CommonJS sources at the root ESM, tsc build to dist/, exports ., ./oxlint, ./oxfmt, ./prettier, ./plugins/*
Node >=18 ^20.19.0 || >=22.18.0 (oxlint loads oxlint.config.ts through Node's type stripping)

Benefits

Measured on one private monorepo (about 6,000 ts/tsx files, 13 lint targets), warm cache:

run files v3 (ESLint, serial per package) v4 (oxlint)
three packages 4,058 over 60 s; the largest alone 36 s 19 to 24 s with every JS plugin, 1.8 s native rules only
whole repository from the root 5,976 n/a (per-package runs) 21 s, one invocation, nested configs discovered
  • one oxlint run from the root lints every package; each package keeps its own oxlint.config.ts with its own ignores, rules and overrides
  • one install at the root, no per-package copies of the standard or of ESLint
  • oxlint --fix fixes the formatting rules; oxfmt formats on demand with the same style, so formatting on save cannot create lint errors
  • oxlint's correctness category adds rules v3 never had (unicorn/*, oxc/const-comparisons, no-unreachable in ts files, no-unsafe-optional-chaining through as casts); the validation run found a real precedence bug with oxc/const-comparisons (a ?? 0 > 0)

Tradeoffs

tradeoff why where documented
eslint and @typescript-eslint/eslint-plugin remain dependencies @typescript-eslint/naming-convention has no oxlint equivalent (tracked in tsgolint #186); perfectionist imports @typescript-eslint/utils, which requires eslint at runtime (oxc #17734); eslint-plugin-mocha 12 imports eslint README "Dependencies", src/oxlint/plugins.ts, src/plugins/typescript.ts
JS plugins cost startup and per-file time about 0.5 s startup for the naming wrapper, 0.3 s for perfectionist; stylistic indent alone is about 7 s per 4,000 files src/oxlint/plugins.ts
the JS-plugin bridge is alpha "not subject to semver" per oxlint; oxlint and oxfmt ranges are bumped by this package, consumers do not pin them README "Install"
react, jest and vitest plugins are enabled per override at the top level, oxlint's categories would apply their rules to every file (React Compiler and react-hooks rules to plain ts files, jest/valid-expect to helpers). A consumer override that changes one of their rules must list the plugin too, or oxlint drops the rule silently README "oxlint.config.ts"
rules the shared config sets can only be changed by an override with matching files oxlint applies overrides after top-level rules README "oxlint.config.ts", migration table
ignorePatterns are per config not inherited through extends, root patterns do not reach files under a nested config; packages with __snapshots__ ignore them themselves README "Monorepos"
inline rule configuration comments (/* eslint no-console: [...] */) are not supported by oxlint use a directive or an override README directive table

Options

oxlint(options):

option default effect
console 'ban-log' with react, else 'allow' ban-log allows console.error, warn, info
indent 2 stylistic/indent, stylistic/jsx-indent-props
testsDir '{spec,test,tests}' test file location; __tests__ and *.spec.* / *.test.* count anywhere
jest, vitest, mocha, playwright false framework rules and globals for test files
react false react and react-hooks rules, jsx layout rules, component file naming
reactCompiler false the 12 React Compiler rules as errors (off otherwise)
typeChecked false 16 type-aware catchers (no-floating-promises, no-misused-promises, ...); needs options: { typeAware: true } in the root config and oxlint-tsgolint
a11y false oxlint's jsx-a11y rules

oxfmt({ indent }) returns the oxfmt config (printWidth: 100, single quotes, trailingComma: 'all', import declaration sorting with v3's import/order groups). prettier is the same base as a Prettier options object.

Rule changes against v3

rule v4
ESLint core, typescript-eslint, react, react-hooks, jest, vitest recommended sets oxlint's native implementations, same options; explicit lists where oxlint's category does not reach
@typescript-eslint/naming-convention typescript-js/naming-convention: this package's wrapper around the upstream rule (approach from oxc-config-seek #13), AST-only, same five selectors
camelcase (js files) eslint-js/camelcase from oxlint-plugin-eslint
perfectionist/sort-named-imports, sort-named-exports eslint-plugin-perfectionist 5 as a JS plugin, same options (types first, custom alphabet with uppercase first, ignoreAlias); oxlint's native sort-imports and the oxfmt plans (oxc #23456, oxc #22521) do not cover them yet
check-file/filename-naming-convention same rule and globs, eslint-plugin-check-file 3.3 (loads without eslint)
@stylistic/* (45 rules) stylistic/*, v5.10, same options; func-call-spacing is function-call-spacing; jsx-self-closing-comp and jsx-curly-brace-presence are the native react/* rules
@stylistic/generator-star-spacing, yield-star-spacing after (function* f), what oxfmt and Prettier print; auto-fixable
@stylistic/jsx-props-no-multi-spaces not configured: crashes under the plugin bridge on some files, removed in stylistic v6
import/order oxfmt sortImports, on demand
radix: ['error', 'as-needed'] removed. ESLint 10 and oxlint now always require a radix, the opposite of v3
react/jsx-no-bind removed
react/prop-types, react/no-deprecated not implemented by oxlint
unicorn/no-thenable (oxlint correctness) off, then is the JSON Schema keyword
cypress and chai-friendly rules removed; playwright option instead

Comparison notes from the validation run:

  • stylistic 3.1 (what v3 consumers had installed) to 5.10: indent reports a few constructs 3.1 accepted; 70 findings in 11 files out of 6,000, verified under ESLint with both versions, all auto-fixable
  • oxlint's react-hooks/rules-of-hooks also reports hooks called from anonymous functions (storybook render: () => { useState() }), which eslint-plugin-react-hooks 5 accepted
  • no-useless-catch is reported at the catch clause and prefer-const at the declaration, so an eslint-disable-next-line placed for ESLint's position may need to move

Migration

  1. npm i -D @ovos-media/coding-standard@4 once, at the root of a monorepo; remove eslint, prettier and every eslint-plugin-* the project only had for v3.

  2. Replace eslint.config.js with oxlint.config.ts:

    import { defineConfig } from 'oxlint';
    import { oxlint } from '@ovos-media/coding-standard';
    
    export default defineConfig({
      extends: [oxlint({ react: true, vitest: true })],
      ignorePatterns: ['public', '**/__snapshots__'],
      overrides: [
        { files: ['**/*.{jsx,tsx}'], plugins: ['react'], rules: { 'react-hooks/exhaustive-deps': 'off' } },
      ],
    });
  3. oxfmt.config.ts: export default defineConfig({ ...oxfmt(), ignorePatterns: ['**/*.{md,yml,yaml}'] }); prettier.config.js re-exports prettier from this package if Prettier stays for other file types.

  4. Rename directives (table below), run oxlint --fix, review what remains.

  5. Type-aware rules: options: { typeAware: true } in the root config only, oxlint-tsgolint installed, tsconfigs without baseUrl and moduleResolution: node10 (tsgolint has TypeScript 7 semantics; ts5to6 automates it).

ESLint directive oxlint
eslint-disable @typescript-eslint/naming-convention eslint-disable typescript-js/naming-convention
eslint-disable camelcase eslint-disable eslint-js/camelcase
eslint-disable @stylistic/<rule> eslint-disable stylistic/<rule>
eslint-disable mocha/no-global-tests eslint-disable mocha/no-top-level-tests
eslint-disable react/jsx-no-bind remove
eslint-disable @typescript-eslint/no-explicit-any and other native rules unchanged

Validation

Built, published with yalc into the monorepo above, oxlint from the root, every finding classified against the v3 ESLint output of the same files (786 findings on 5,976 files after the config fixes this PR contains):

findings cause
218 playwright recommended rules; v3 had no playwright rules
162 directives with old rule names (table above)
89 oxlint correctness rules v3 did not have (unicorn/no-useless-fallback-in-spread, no-useless-spread, no-new-array, oxc/const-comparisons, ...)
76 react-hooks/rules-of-hooks in anonymous storybook render functions
71 root-level scripts and config files v3 never linted
70 stylistic/indent, stylistic 3.1 to 5.10
29 one file with an inline /* eslint no-console: ... */ configuration comment
28 generator and yield star spacing, the after decision
21 one package that was still on coding-standard 2.x
12 directive position (6), no-unreachable and no-unsafe-optional-chaining now checked in ts files (4), one real bug (2)
10 oxlint or plugin differences, one to three each: stylistic/semi-spacing in a type literal, jsx-child-element-spacing after &nbsp;, jest/valid-expect with a computed matcher, react/jsx-curly-brace-presence on strings with quotes, no-unused-vars on let inside declare module, one check-file 2.8 vs 3.3 difference

Behaviours verified with fixtures during validation, all documented in the README: override precedence, per-override plugins and their consumer-side requirement, ignorePatterns scope, override globs anchored to the config directory, categories not reaching plugins enabled per override.

Dependencies

v3 v4
eslint ^9.19, @typescript-eslint/eslint-plugin, parser, utils ^8.22, @vitest/eslint-plugin, eslint-import-resolver-typescript, eslint-plugin-chai-friendly, eslint-plugin-check-file ^2.8, eslint-plugin-cypress, eslint-plugin-import-x, eslint-plugin-jest, eslint-plugin-mocha ^10.5, eslint-plugin-perfectionist ^4.7, eslint-plugin-react, eslint-plugin-react-hooks, @stylistic/eslint-plugin ^3.0, globals oxlint ^1.81, oxfmt ^0.66, oxlint-plugin-eslint ^1.81, @stylistic/eslint-plugin ^5.10, eslint-plugin-perfectionist ^5.11, eslint-plugin-check-file ^3.3, eslint-plugin-playwright ^2.11, eslint-plugin-mocha ^12.0, @typescript-eslint/eslint-plugin ^8.69, eslint ^10.9; optional peer oxlint-tsgolint >=7.0.2001

What it takes to drop eslint and typescript-eslint, with full parity

Three features keep them in the tree. Verified: @stylistic/eslint-plugin, oxlint-plugin-eslint, eslint-plugin-check-file and eslint-plugin-playwright load with eslint and @typescript-eslint/* absent.

feature keeps replacement with the same checks
typescript-js/naming-convention (wrapper in src/plugins/typescript.ts, about 7 s per 4,000 files) @typescript-eslint/eslint-plugin and its tree, eslint an in-house rule for the five selectors in use (function, method, objectLiteralMethod, typeLike, import), AST-only, about 100 to 150 lines, with the 16 existing fixtures pinning quoted keys, function-typed properties and underscore handling to upstream behaviour. Or tsgolint's native rule once it lands (tsgolint #186), which will need type-aware mode on
perfectionist/sort-named-imports, sort-named-exports (about 1.2 s) @typescript-eslint/utils, eslint two in-house rules with the same options (types first, custom alphabet, ignoreAlias, export sorting), about 80 to 100 lines. oxfmt's planned sortNamedImports (oxc #23456, oxc #22521, first attempt oxc #26211 closed) would be formatting, not enforcement
mocha rules eslint (eslint-plugin-mocha 12 imports it) oxlint's native jest plugin covers focused tests, identical titles, async suites, duplicate hooks, exports and pending tests; the done-callback rules and no-mocha-arrows have no equivalent and would need in-house rules (about 60 lines)

After all three: eslint, @typescript-eslint/eslint-plugin, eslint-plugin-perfectionist and eslint-plugin-mocha leave dependencies; the JS plugins left are stylistic, oxlint-plugin-eslint, check-file, playwright and this package's own rules.

Follow-ups (not in this PR)

  • stylistic v6: list-style replaces array-bracket-spacing and object-curly-spacing and enforces line breaks inside lists; notes in src/oxlint/rules/stylistic.ts
  • report the plugin-bridge crash of @stylistic/jsx-props-no-multi-spaces and the no-unused-vars ambient-declaration case upstream
  • the in-house rules above, in the order listed

decisions, verified facts and measurements behind the migration, the package
and config design, consumer usage for monorepos, and the resolved open items.
drops the eslint entry point and its plugins, adds oxlint/oxfmt and the js plugins
that keep rule parity, and a node:test harness over the compiled sources
trailingComma 'all' (oxfmt and prettier 3 default), import declaration sorting with
the same groups as the former import/order rule
runs typescript-eslint's rule through oxlint's js plugin bridge with empty parserServices.
the file documents the measured cost, the alternatives and the tsgolint exit path
documents why perfectionist and eslint-plugin-mocha keep eslint in the dependency tree
and what would replace them
…orks

every rule of v3 mapped to oxlint or a js plugin; react compiler family and the
type-checked tier behind options, everything at error
assembles plugins, js plugins and overrides from the options; environments and
globals live in overrides so that consumers can use the config through extends
v6 (beta) deprecates array-bracket-spacing and object-curly-spacing in favour of
list-style, which also enforces line breaks inside lists (1,842 findings on 4,000 files
against 1) and prints a notice on every run. v5 under oxlint: same rules, no notices.
the upgrade path is documented in rules/stylistic.ts
… radix

validation against a large monorepo showed:
- react, jest and vitest plugins at the top level let oxlint's categories apply their rules to every
  file: React Compiler rules on plain ts files, jest expect rules on test helpers. the plugins are now
  enabled inside the overrides for jsx and test files, with explicit rule lists (categories do not reach
  plugins enabled per override), which is what the v3 per-file blocks did
- unicorn/filename-case rejects acronyms (AIPanel.tsx); eslint-plugin-check-file loads without eslint
  and keeps the v3 rule and directive name
- radix: ESLint 10 and oxlint 1.49+ always require a radix, the opposite of v3's as-needed; not configured
- unicorn/no-thenable off: `then` is the JSON Schema keyword in model schemas
- preserve-caught-error (ESLint 10 only), import/default and import/namespace (module resolution),
  jest/vitest rules oxlint enables beyond the recommended sets, and stylistic/jsx-props-no-multi-spaces
  (crashes under the plugin bridge) are off
- react-in-jsx-scope and require-render-return were missing from the recommended list
…ope and the rule changes found in validation
Comment thread src/plugins/typescript.ts

const require = createRequire(import.meta.url);
const pluginDir = path.dirname(require.resolve('@typescript-eslint/eslint-plugin/package.json'));
const loaded = require(path.join(pluginDir, 'dist/rules/naming-convention.js')) as

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could we wrap this private load in try/catch and fall back to the public require('@typescript-eslint/eslint-plugin').rules['naming-convention'] (instead of probably taking down the whole oxlint invocation because the load happens while oxlint is initialising the plugin), maybe printing one line to stderr when the fallback is taken? 🤔 A ~8.69.0 range for this one dependency also seems more reasonable given the package depends on its file layout, not just its API.

Comment thread src/oxlint/rules/tests.ts
};
}

// @vitest/eslint-plugin recommended listed explicitly, plus the v3 additions (no-focused-tests, no-console)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

comparing against what v3 actually enforced I count six rules that are no longer checked. Three are set to 'off' here — expect-expect, no-conditional-expect, no-standalone-expect — and three are absent from the list: vitest/no-interpolation-in-snapshots, vitest/no-mocks-import, vitest/valid-expect-in-promise. v3 spread the recommended preset wholesale and overrode only four rules, so all six were errors there. Was this intentional? I can see that this brings it closer to parity with the jest rules


export function typeAwareRules(typeChecked: boolean): Rules {
return {
'typescript/no-for-in-array': 'error',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential breaking change (intentional?): The PR body lists no-for-in-array as "always on", but I do not think it is enforced anywhere in a default install. It sits here inside typeAwareRules(), and comment at the top explains that type-aware rules run only when the consumer's root config sets options: { typeAware: true } and oxlint-tsgolint is installed, and without that they are silent.

Since typeChecked defaults to false and typeAware is a root setting this package cannot reach through extends, a consumer who installs v4 and changes nothing gets this rule nowhere — where v3 ran it on every .ts file via projectService: true.

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