v4: oxlint and oxfmt replace ESLint and Prettier - #12
Conversation
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
…and package.json only
|
|
||
| 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 |
There was a problem hiding this comment.
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.
| }; | ||
| } | ||
|
|
||
| // @vitest/eslint-plugin recommended listed explicitly, plus the v3 additions (no-focused-tests, no-console) |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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.
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coding-standard-')); | ||
| try { | ||
| fs.cpSync(path.join(fixturesRoot, fixture), dir, { recursive: true }); | ||
| fs.writeFileSync(path.join(dir, '.oxlintrc.json'), JSON.stringify(config)); |
There was a problem hiding this comment.
The smoke tests write the shared config straight in as the root .oxlintrc.json, but every consumer and every README example uses it through extends: [oxlint()] — so the one path everybody actually uses is the one path never exercised. That feels worth closing, because the whole architecture rests on a claim about extends semantics: that it keeps rules, plugins, jsPlugins, overrides and categories and drops env, globals, settings and ignorePatterns.
| const severities = (rules: Record<string, unknown>) => | ||
| Object.values(rules).map((value) => (Array.isArray(value) ? value[0] : value)); | ||
|
|
||
| test('no rule is configured at warn', () => { |
There was a problem hiding this comment.
I read through this file hoping to find a parity assertion and I don't think there is one — the tests here check severities, option plumbing and a handful of named rules, all of which are useful, but nothing asserts "our list equals upstream recommended minus documented deltas". Delete ten rules from stylisticRules() and the suite stays green.
Two things would close it, and the first is the higher value: generate v3's resolved rule set once with ESLint --print-config, commit it, and fail a test when a rule in it is neither enforced by v4 nor listed in an intentionally-dropped file with a reason. That would have caught all of the gaps above, and it replaces the prose claim "keeping every check v3 enforced" with a machine-checked one. Second, commit an oxlint --print-config snapshot per option combination, so an oxlint bump that moves a rule out of correctness becomes a reviewable diff instead of a silent change.
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
eslint()from./eslintoxlint()from./oxlint, used throughextendsinoxlint.config.tsoxfmt()from./oxfmt(0.66);prettierexport kept for projects still running Prettier@stylisticrules under ESLint@stylisticrules under oxlint (v5.10); oxfmt and Prettier are on-demand and produce output that passes themdisableTypeCheckedescape hatchno-for-in-arrayalways on,typeCheckedtier of 16 catcherscategories: { correctness: 'error' }tscbuild todist/, exports.,./oxlint,./oxfmt,./prettier,./plugins/*>=18^20.19.0 || >=22.18.0(oxlint loadsoxlint.config.tsthrough Node's type stripping)Benefits
Measured on one private monorepo (about 6,000 ts/tsx files, 13 lint targets), warm cache:
oxlintrun from the root lints every package; each package keeps its ownoxlint.config.tswith its own ignores, rules and overridesoxlint --fixfixes the formatting rules;oxfmtformats on demand with the same style, so formatting on save cannot create lint errorscorrectnesscategory adds rules v3 never had (unicorn/*,oxc/const-comparisons,no-unreachablein ts files,no-unsafe-optional-chainingthroughascasts); the validation run found a real precedence bug withoxc/const-comparisons(a ?? 0 > 0)Tradeoffs
eslintand@typescript-eslint/eslint-pluginremain dependencies@typescript-eslint/naming-conventionhas no oxlint equivalent (tracked in tsgolint #186); perfectionist imports@typescript-eslint/utils, which requireseslintat runtime (oxc #17734); eslint-plugin-mocha 12 importseslintsrc/oxlint/plugins.ts,src/plugins/typescript.tsindentalone is about 7 s per 4,000 filessrc/oxlint/plugins.tsoxlintandoxfmtranges are bumped by this package, consumers do not pin themreact-hooksrules to plain ts files,jest/valid-expectto helpers). A consumer override that changes one of their rules must list the plugin too, or oxlint drops the rule silentlyfilesrulesignorePatternsare per configextends, root patterns do not reach files under a nested config; packages with__snapshots__ignore them themselves/* eslint no-console: [...] */) are not supported by oxlintOptions
oxlint(options):console'ban-log'withreact, else'allow'ban-logallowsconsole.error,warn,infoindent2stylistic/indent,stylistic/jsx-indent-propstestsDir'{spec,test,tests}'__tests__and*.spec.*/*.test.*count anywherejest,vitest,mocha,playwrightfalsereactfalsereactCompilerfalsetypeCheckedfalseno-floating-promises,no-misused-promises, ...); needsoptions: { typeAware: true }in the root config andoxlint-tsgolinta11yfalsejsx-a11yrulesoxfmt({ indent })returns the oxfmt config (printWidth: 100, single quotes,trailingComma: 'all', import declaration sorting with v3'simport/ordergroups).prettieris the same base as a Prettier options object.Rule changes against v3
@typescript-eslint/naming-conventiontypescript-js/naming-convention: this package's wrapper around the upstream rule (approach from oxc-config-seek #13), AST-only, same five selectorscamelcase(js files)eslint-js/camelcasefrom oxlint-plugin-eslintperfectionist/sort-named-imports,sort-named-exportsignoreAlias); oxlint's nativesort-importsand the oxfmt plans (oxc #23456, oxc #22521) do not cover them yetcheck-file/filename-naming-convention@stylistic/*(45 rules)stylistic/*, v5.10, same options;func-call-spacingisfunction-call-spacing;jsx-self-closing-compandjsx-curly-brace-presenceare the nativereact/*rules@stylistic/generator-star-spacing,yield-star-spacingafter(function* f), what oxfmt and Prettier print; auto-fixable@stylistic/jsx-props-no-multi-spacesimport/ordersortImports, on demandradix: ['error', 'as-needed']react/jsx-no-bindreact/prop-types,react/no-deprecatedunicorn/no-thenable(oxlint correctness)thenis the JSON Schema keywordplaywrightoption insteadComparison notes from the validation run:
indentreports a few constructs 3.1 accepted; 70 findings in 11 files out of 6,000, verified under ESLint with both versions, all auto-fixablereact-hooks/rules-of-hooksalso reports hooks called from anonymous functions (storybookrender: () => { useState() }), which eslint-plugin-react-hooks 5 acceptedno-useless-catchis reported at thecatchclause andprefer-constat the declaration, so aneslint-disable-next-lineplaced for ESLint's position may need to moveMigration
npm i -D @ovos-media/coding-standard@4once, at the root of a monorepo; removeeslint,prettierand everyeslint-plugin-*the project only had for v3.Replace
eslint.config.jswithoxlint.config.ts:oxfmt.config.ts:export default defineConfig({ ...oxfmt(), ignorePatterns: ['**/*.{md,yml,yaml}'] });prettier.config.jsre-exportsprettierfrom this package if Prettier stays for other file types.Rename directives (table below), run
oxlint --fix, review what remains.Type-aware rules:
options: { typeAware: true }in the root config only,oxlint-tsgolintinstalled, tsconfigs withoutbaseUrlandmoduleResolution: node10(tsgolint has TypeScript 7 semantics; ts5to6 automates it).eslint-disable @typescript-eslint/naming-conventioneslint-disable typescript-js/naming-conventioneslint-disable camelcaseeslint-disable eslint-js/camelcaseeslint-disable @stylistic/<rule>eslint-disable stylistic/<rule>eslint-disable mocha/no-global-testseslint-disable mocha/no-top-level-testseslint-disable react/jsx-no-bindeslint-disable @typescript-eslint/no-explicit-anyand other native rulesValidation
Built, published with yalc into the monorepo above,
oxlintfrom 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):unicorn/no-useless-fallback-in-spread,no-useless-spread,no-new-array,oxc/const-comparisons, ...)react-hooks/rules-of-hooksin anonymous storybook render functionsstylistic/indent, stylistic 3.1 to 5.10/* eslint no-console: ... */configuration commentafterdecisionno-unreachableandno-unsafe-optional-chainingnow checked in ts files (4), one real bug (2)stylistic/semi-spacingin a type literal,jsx-child-element-spacingafter ,jest/valid-expectwith a computed matcher,react/jsx-curly-brace-presenceon strings with quotes,no-unused-varsonletinsidedeclare module, onecheck-file2.8 vs 3.3 differenceBehaviours verified with fixtures during validation, all documented in the README: override precedence, per-override plugins and their consumer-side requirement,
ignorePatternsscope, override globs anchored to the config directory,categoriesnot reaching plugins enabled per override.Dependencies
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,globalsoxlint^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 peeroxlint-tsgolint>=7.0.2001What it takes to drop
eslintand typescript-eslint, with full parityThree features keep them in the tree. Verified:
@stylistic/eslint-plugin,oxlint-plugin-eslint,eslint-plugin-check-fileandeslint-plugin-playwrightload witheslintand@typescript-eslint/*absent.typescript-js/naming-convention(wrapper insrc/plugins/typescript.ts, about 7 s per 4,000 files)@typescript-eslint/eslint-pluginand its tree,eslintperfectionist/sort-named-imports,sort-named-exports(about 1.2 s)@typescript-eslint/utils,eslintignoreAlias, export sorting), about 80 to 100 lines. oxfmt's plannedsortNamedImports(oxc #23456, oxc #22521, first attempt oxc #26211 closed) would be formatting, not enforcementeslint(eslint-plugin-mocha 12 imports it)jestplugin covers focused tests, identical titles, async suites, duplicate hooks, exports and pending tests; thedone-callback rules andno-mocha-arrowshave no equivalent and would need in-house rules (about 60 lines)After all three:
eslint,@typescript-eslint/eslint-plugin,eslint-plugin-perfectionistandeslint-plugin-mochaleavedependencies; the JS plugins left are stylistic, oxlint-plugin-eslint, check-file, playwright and this package's own rules.Follow-ups (not in this PR)
list-stylereplacesarray-bracket-spacingandobject-curly-spacingand enforces line breaks inside lists; notes insrc/oxlint/rules/stylistic.ts@stylistic/jsx-props-no-multi-spacesand theno-unused-varsambient-declaration case upstream