@useinsider/eslint-config 3.0.2 → 4.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +376 -64
  2. package/dist/builders/configFiles.d.ts +13 -0
  3. package/dist/builders/oxlint.d.ts +51 -0
  4. package/dist/builders/oxlintOwnership.d.ts +43 -0
  5. package/dist/builders/scopeBlocks.d.ts +6 -0
  6. package/dist/cli.cjs +1028 -86
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/configs/dependencies.d.ts +3 -0
  9. package/dist/{javascript-VBXqjVi8.cjs → javascript-DH8-N38w.cjs} +5 -5
  10. package/dist/{javascript-VBXqjVi8.cjs.map → javascript-DH8-N38w.cjs.map} +1 -1
  11. package/dist/{javascript-dom-5jRL_yKP.cjs → javascript-dom-CvtQ6_9W.cjs} +4 -4
  12. package/dist/{javascript-dom-5jRL_yKP.cjs.map → javascript-dom-CvtQ6_9W.cjs.map} +1 -1
  13. package/dist/{javascript-node-C1AU_4X2.cjs → javascript-node-BNsy3loY.cjs} +4 -4
  14. package/dist/{javascript-node-C1AU_4X2.cjs.map → javascript-node-BNsy3loY.cjs.map} +1 -1
  15. package/dist/{jest-CHzjzBtf.cjs → jest-4Es9Ggaa.cjs} +3 -3
  16. package/dist/{jest-CHzjzBtf.cjs.map → jest-4Es9Ggaa.cjs.map} +1 -1
  17. package/dist/main.cjs +763 -78
  18. package/dist/main.cjs.map +1 -1
  19. package/dist/main.d.ts +9 -2
  20. package/dist/main.js +746 -61
  21. package/dist/main.js.map +1 -1
  22. package/dist/oxlintOwnership-7hpdB3d1.cjs +199 -0
  23. package/dist/oxlintOwnership-7hpdB3d1.cjs.map +1 -0
  24. package/dist/oxlintOwnership-Cx0hgA_J.js +127 -0
  25. package/dist/oxlintOwnership-Cx0hgA_J.js.map +1 -0
  26. package/dist/removePluginsFromConfigs-D0oSUj2C.cjs.map +1 -1
  27. package/dist/removePluginsFromConfigs-DzqX2Ju2.js.map +1 -1
  28. package/dist/{stylistic-Dio0AM2E.cjs → stylistic-CoP3MT1Y.cjs} +4 -4
  29. package/dist/{stylistic-Dio0AM2E.cjs.map → stylistic-CoP3MT1Y.cjs.map} +1 -1
  30. package/dist/{typescript-C1icmOxY.cjs → typescript-D3VeLnpn.cjs} +7 -7
  31. package/dist/{typescript-C1icmOxY.cjs.map → typescript-D3VeLnpn.cjs.map} +1 -1
  32. package/dist/{typescript-dom-RUJn2WCZ.cjs → typescript-dom-iB_xBTAD.cjs} +4 -4
  33. package/dist/{typescript-dom-RUJn2WCZ.cjs.map → typescript-dom-iB_xBTAD.cjs.map} +1 -1
  34. package/dist/{typescript-node-CBEzcc56.cjs → typescript-node-Cq6qhj53.cjs} +4 -4
  35. package/dist/{typescript-node-CBEzcc56.cjs.map → typescript-node-Cq6qhj53.cjs.map} +1 -1
  36. package/dist/utils/ensureConfigStructure.d.ts +15 -6
  37. package/dist/utils/ensureOxlintConfig.d.ts +10 -0
  38. package/dist/utils/layoutDiagnosis.d.ts +63 -0
  39. package/dist/utils/offerMigration.d.ts +42 -0
  40. package/dist/utils/silenceRules.d.ts +9 -0
  41. package/dist/{vitest-BomI8gtu.cjs → vitest-BlGQswK2.cjs} +3 -3
  42. package/dist/{vitest-BomI8gtu.cjs.map → vitest-BlGQswK2.cjs.map} +1 -1
  43. package/dist/{vue2-azKwRgaJ.cjs → vue2-Da1i_oVn.cjs} +8 -8
  44. package/dist/{vue2-azKwRgaJ.cjs.map → vue2-Da1i_oVn.cjs.map} +1 -1
  45. package/dist/{vue2-options-api-ma3J-bvM.cjs → vue2-options-api-BSdrUm8H.cjs} +8 -8
  46. package/dist/{vue2-options-api-ma3J-bvM.cjs.map → vue2-options-api-BSdrUm8H.cjs.map} +1 -1
  47. package/dist/{vue2-typescript-CZSHzp7I.cjs → vue2-typescript-dXLRSGNP.cjs} +10 -10
  48. package/dist/{vue2-typescript-CZSHzp7I.cjs.map → vue2-typescript-dXLRSGNP.cjs.map} +1 -1
  49. package/dist/{vue2-typescript-options-api-B8OzZlje.cjs → vue2-typescript-options-api-LM7U5dWq.cjs} +10 -10
  50. package/dist/{vue2-typescript-options-api-B8OzZlje.cjs.map → vue2-typescript-options-api-LM7U5dWq.cjs.map} +1 -1
  51. package/dist/{vue3-Df5UQ1Ys.cjs → vue3-621EM2Ii.cjs} +8 -8
  52. package/dist/{vue3-Df5UQ1Ys.cjs.map → vue3-621EM2Ii.cjs.map} +1 -1
  53. package/dist/{vue3-options-api-Bx7yYHza.cjs → vue3-options-api-CC9_fAiq.cjs} +8 -8
  54. package/dist/{vue3-options-api-Bx7yYHza.cjs.map → vue3-options-api-CC9_fAiq.cjs.map} +1 -1
  55. package/dist/{vue3-typescript-C0O1JZlk.cjs → vue3-typescript-DONe-IKz.cjs} +10 -10
  56. package/dist/{vue3-typescript-C0O1JZlk.cjs.map → vue3-typescript-DONe-IKz.cjs.map} +1 -1
  57. package/dist/{vue3-typescript-options-api-cPal4O-K.cjs → vue3-typescript-options-api-D4pqpDFs.cjs} +10 -10
  58. package/dist/{vue3-typescript-options-api-cPal4O-K.cjs.map → vue3-typescript-options-api-D4pqpDFs.cjs.map} +1 -1
  59. package/package.json +8 -5
  60. package/dist/rolldown-runtime-VH7oDXx4.cjs +0 -28
package/README.md CHANGED
@@ -1,19 +1,90 @@
1
1
  # @useinsider/eslint-config
2
2
 
3
- Shared ESLint 9 flat-config for Insider projects. `useInsider()` takes a single
4
- scope-keyed config object and returns a `defineConfig([...])` array — one
5
- entry per file scope you describe. There is no opaque preset list, no
6
- freeform `config` escape hatch, and no expectation that you spread the result
7
- into your own array.
8
-
9
- The default export is the resolved Promise returned by `useInsider(...)`;
10
- ESLint 9 awaits it automatically. **Do not `await` it. Do not spread it. Do
11
- not wrap it in an array.** If you find yourself wanting to append a block,
12
- that is a sign your scope coverage is incomplete — describe the extra files
13
- inside `useInsider({...})` instead.
3
+ Shared lint configuration for Insider projects, split across two linters.
4
+ `useInsider()` takes a single scope-keyed config object and returns two
5
+ renderers: `useInsiderESLint()` for ESLint 9 flat config and `useInsiderOXC()`
6
+ for oxlint. Every rule oxlint implements natively is turned **off** on the
7
+ ESLint side and enabled on the oxlint side from the same description, so the
8
+ two configs cannot drift. There is no opaque preset list, no freeform `config`
9
+ escape hatch, and no expectation that you spread either result into your own
10
+ array.
11
+
12
+ Because ESLint alone would now silently skip every rule oxlint owns, both
13
+ linters must run. `useInsider()` refuses to start if the oxlint config is
14
+ missing.
15
+
16
+ ## The three-file layout
17
+
18
+ `useInsider()` is called from exactly one module, and each linter's config is
19
+ a thin re-export of its renderer:
20
+
21
+ ```ts
22
+ // insider.lint.ts
23
+ import { useInsider } from '@useinsider/eslint-config';
24
+
25
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
26
+ // scopes go here
27
+ });
28
+ ```
29
+
30
+ ```ts
31
+ // eslint.config.ts
32
+ import { useInsiderESLint } from './insider.lint.ts';
33
+
34
+ export default useInsiderESLint();
35
+ ```
36
+
37
+ ```ts
38
+ // oxlint.config.ts
39
+ import { useInsiderOXC } from './insider.lint.ts';
40
+
41
+ export default await useInsiderOXC();
42
+ ```
43
+
44
+ Then run both:
45
+
46
+ ```bash
47
+ oxlint --disable-nested-config && eslint .
48
+ ```
49
+
50
+ Three constraints this layout is not free to break:
51
+
52
+ - **The `./insider.lint.ts` import keeps its real extension.** Neither
53
+ ESLint's loader nor oxlint's applies TypeScript's `.js` → `.ts` specifier
54
+ rewriting, so `'./insider.lint.js'` does not resolve.
55
+ - **The ESLint config needs a TypeScript extension too** — `eslint.config.ts`
56
+ or `eslint.config.mts`. ESLint routes those through jiti, which resolves the
57
+ TypeScript lint module on every supported Node version. A `.js`/`.mjs`/`.cjs`
58
+ config is loaded by Node directly, so importing `./insider.lint.ts` from one
59
+ works only where Node's type stripping is unflagged (Node >= 22.18) and dies
60
+ with `ERR_UNKNOWN_FILE_EXTENSION` on Node 20. Both CLI commands therefore
61
+ rename an existing JavaScript flat config and delete the original.
62
+ - **The oxlint config must be `oxlint.config.ts` or `oxlint.config.mts`.**
63
+ oxlint silently ignores `oxlint.config.{cts,js,mjs,cjs}` and falls through
64
+ to the nearest ancestor config. In a package with no `"type": "module"`,
65
+ use `.mts` for both generated files to avoid Node's module-type warning.
66
+ - **A TypeScript project needs `allowImportingTsExtensions: true`** in the
67
+ `tsconfig.json` that covers the layout files, or `tsc` rejects the import
68
+ with `TS5097`. Both CLI flows offer to set it for you, following `extends`
69
+ first so a shared base that already carries it is left alone. TypeScript only
70
+ accepts the flag on a project with `noEmit`, `emitDeclarationOnly` or
71
+ `rewriteRelativeImportExtensions` — on an emitting project it fails with
72
+ `TS5096` — so a project that emits is never edited; the CLI explains what to
73
+ turn on instead.
74
+ - **`oxlint` runs with `--disable-nested-config`.** The emitted config carries
75
+ options oxlint accepts only in a root config, and a discovered nested config
76
+ carrying them aborts the whole run. Disabling nested discovery also makes
77
+ the config's own `ignorePatterns` authoritative. In a monorepo each package
78
+ runs its own `lint` script, so every config is the root of its own run.
79
+
80
+ **Do not `await` `useInsiderESLint()`. Do not spread either renderer. Do not
81
+ wrap them in an array.** If you find yourself wanting to append a block, that
82
+ is a sign your scope coverage is incomplete — describe the extra files inside
83
+ `useInsider({...})` instead.
14
84
 
15
85
  ## Table of Contents
16
86
 
87
+ - [The three-file layout](#the-three-file-layout)
17
88
  - [Prerequisites](#prerequisites)
18
89
  - [Quick start](#quick-start)
19
90
  - [Manual setup](#manual-setup)
@@ -22,6 +93,10 @@ inside `useInsider({...})` instead.
22
93
  - [Vue 2 + JavaScript with jQuery globals](#vue-2--javascript-with-jquery-globals)
23
94
  - [Strict and spell-checker knobs](#strict-and-spell-checker-knobs)
24
95
  - [API reference](#api-reference)
96
+ - [Unused disable directives](#unused-disable-directives)
97
+ - [Lint scripts](#lint-scripts)
98
+ - [Migrating to v4](#migrating-to-v4)
99
+ - [The guided migration offer](#the-guided-migration-offer)
25
100
  - [Migrating from v1.x](#migrating-from-v1x)
26
101
  - [First lint after install](#first-lint-after-install)
27
102
  - [Troubleshooting](#troubleshooting)
@@ -29,8 +104,9 @@ inside `useInsider({...})` instead.
29
104
 
30
105
  ## Prerequisites
31
106
 
32
- - Node.js 20+
107
+ - Node.js 24.19+
33
108
  - ESLint 9+
109
+ - oxlint 1.83+
34
110
  - pnpm, npm, or yarn
35
111
 
36
112
  ## Quick start
@@ -40,9 +116,16 @@ npx @useinsider/eslint-config
40
116
  ```
41
117
 
42
118
  The CLI walks an interactive prompt (project type, language, framework, test
43
- runner, jQuery globals, tsconfig layout), installs every required ESLint
44
- plugin pinned to the versions this package was built against, and writes a
45
- starter `eslint.config.mjs` at the project root.
119
+ runner, jQuery globals, tsconfig layout), installs every required linter and
120
+ plugin pinned to the versions this package was built against, writes the three
121
+ layout files at the project root, wires oxlint into the script that actually
122
+ invokes ESLint (see [Lint scripts](#lint-scripts)), and offers to add
123
+ `allowImportingTsExtensions: true` to the tsconfig that really governs those
124
+ files. In a solution-style project that is the referenced `tsconfig.node.json`,
125
+ not the root — and if an applicable config already sets the flag, or no tsconfig
126
+ type-checks those files, you are not asked at all. That edit is surgical —
127
+ comments, key order and indentation survive — and declining it prints the exact
128
+ snippet to paste.
46
129
 
47
130
  To migrate an existing v1 `eslint.config.{js,mjs,cjs}` or a legacy
48
131
  `.eslintrc.{js,cjs}`:
@@ -51,20 +134,34 @@ To migrate an existing v1 `eslint.config.{js,mjs,cjs}` or a legacy
51
134
  npx @useinsider/eslint-config migrate [path]
52
135
  ```
53
136
 
54
- The migrate flow auto-detects the source format. For flat configs it
55
- parses the v1 file, prints a summary plus any dropped custom rules, asks
56
- for confirmation, then rewrites the file in place. For legacy `.eslintrc`
57
- files it writes a fresh `eslint.config.mjs` next to the original (custom
58
- rules and non-`@useinsider/eslint-config/*` extends are dropped with a
59
- loud warning so you can review what changed). Legacy `.eslintrc.{json,yml,yaml}`
60
- are not supported in this release — convert them to `.eslintrc.{js,cjs}` first.
61
-
62
- Example output for a Node + TypeScript project:
63
-
64
- ```js
137
+ The migrate flow auto-detects the source format, prints a summary plus any
138
+ dropped custom rules, asks for confirmation, then splits the config into the
139
+ three-file layout: the `useInsider()` call moves into `insider.lint.*` and
140
+ both linter configs become thin re-exports. Legacy `.eslintrc.{js,cjs}` is
141
+ also handled (custom rules and non-`@useinsider/eslint-config/*` extends are
142
+ dropped with a loud warning so you can review what changed). Legacy
143
+ `.eslintrc.{json,yml,yaml}` are not supported in this release — convert them
144
+ to `.eslintrc.{js,cjs}` first.
145
+
146
+ Migrate also rewrites your source: an `// eslint-disable-next-line <rule>`
147
+ naming a rule oxlint has taken over becomes `// oxlint-disable-next-line
148
+ <rule>`. This is not cosmetic — the emitted oxlint config refuses the
149
+ `eslint-disable` spelling (see [Unused disable
150
+ directives](#unused-disable-directives)), so an un-migrated comment suppresses
151
+ nothing on either side. A comment listing rules from both sides is split into
152
+ one comment per linter. A `-line` or `-next-line` directive targets one exact
153
+ physical line, so both halves stay on a single line as block comments — a
154
+ newline between them would point the first half at the second instead of at
155
+ the code. Only a whole-file `eslint-disable` is split across two lines. The rewrite reads each
156
+ file's syntax tree and touches only real comment nodes, so a directive spelled
157
+ out inside a string or template literal is left alone.
158
+
159
+ Example `insider.lint.ts` for a Node + TypeScript project:
160
+
161
+ ```ts
65
162
  import { useInsider } from '@useinsider/eslint-config';
66
163
 
67
- export default useInsider({
164
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
68
165
  typescriptNode: [
69
166
  {
70
167
  files: ['**/*.{ts,tsx}'],
@@ -79,6 +176,7 @@ pins):
79
176
 
80
177
  - `@useinsider/eslint-config`
81
178
  - `eslint`
179
+ - `oxlint`
82
180
  - `globals`
83
181
  - `typescript`
84
182
  - `typescript-eslint`
@@ -92,19 +190,23 @@ or `@vitest/eslint-plugin`).
92
190
  Install the package first:
93
191
 
94
192
  ```bash
95
- pnpm add -E -D @useinsider/eslint-config
193
+ pnpm add -E -D @useinsider/eslint-config oxlint
96
194
  ```
97
195
 
98
- Then create `eslint.config.mjs` and call `useInsider(...)` with the scopes
99
- your project needs. Each scope holds an array of `{ files, ... }` entries;
100
- each entry becomes its own flat-config block.
196
+ Then create the [three layout files](#the-three-file-layout) and call
197
+ `useInsider(...)` from `insider.lint.ts` with the scopes your project needs.
198
+ Each scope holds an array of `{ files, ... }` entries; each entry becomes its
199
+ own block in both emitted configs.
200
+
201
+ Every example below shows the body of `insider.lint.ts`; the two linter
202
+ configs never change.
101
203
 
102
204
  ### Node + TypeScript
103
205
 
104
- ```js
206
+ ```ts
105
207
  import { useInsider } from '@useinsider/eslint-config';
106
208
 
107
- export default useInsider({
209
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
108
210
  typescriptNode: [
109
211
  {
110
212
  files: ['**/*.{ts,tsx}'],
@@ -122,10 +224,10 @@ export default useInsider({
122
224
  A project using `tsconfig.app.json` for source and `tsconfig.test.json` for
123
225
  tests, with `RouterLink` and `RouterView` as globally-registered components.
124
226
 
125
- ```js
227
+ ```ts
126
228
  import { useInsider } from '@useinsider/eslint-config';
127
229
 
128
- export default useInsider({
230
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
129
231
  vue3Typescript: [
130
232
  {
131
233
  files: ['src/**/*.{ts,tsx,vue}'],
@@ -152,10 +254,10 @@ paths if a single scope needs more than one project reference.
152
254
 
153
255
  ### Vue 2 + JavaScript with jQuery globals
154
256
 
155
- ```js
257
+ ```ts
156
258
  import { useInsider } from '@useinsider/eslint-config';
157
259
 
158
- export default useInsider({
260
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
159
261
  vue2: [
160
262
  { files: ['src/**/*.{js,vue}'] },
161
263
  ],
@@ -179,10 +281,10 @@ layered on top of it.
179
281
  `strict` and `spellChecker` are flat cross-cutting options. They sit at the
180
282
  top level of the config, not inside a scope entry.
181
283
 
182
- ```js
284
+ ```ts
183
285
  import { useInsider } from '@useinsider/eslint-config';
184
286
 
185
- export default useInsider({
287
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
186
288
  typescriptNode: [
187
289
  {
188
290
  files: ['**/*.{ts,tsx}'],
@@ -247,10 +349,10 @@ Every scope entry shape also accepts two common fields:
247
349
  A scope entry with per-entry globals — declare project-specific bindings
248
350
  without spreading the `useInsider` return into your own array:
249
351
 
250
- ```js
352
+ ```ts
251
353
  import { useInsider } from '@useinsider/eslint-config';
252
354
 
253
- export default useInsider({
355
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({
254
356
  ignores: ['dist', 'coverage'],
255
357
  javascriptDom: [
256
358
  {
@@ -286,28 +388,239 @@ the language scopes and gets overridden by any of them that also match.
286
388
  > `*.config.*` and `eslint.config.*` are already covered by the defaults, so
287
389
  > most projects can simply drop the key.
288
390
 
289
- `useInsider` returns `Promise<Linter.Config[]>`. Export it directly — ESLint
290
- 9 resolves the Promise at load time. `useInsider`'s return type carries
291
- through, so no JSDoc annotation is needed; the `UseInsiderResult` type alias
292
- is exported for TypeScript callers that wrap `useInsider` in a helper of
293
- their own.
391
+ `useInsider` is synchronous and returns `{ useInsiderESLint, useInsiderOXC }`.
392
+ `useInsiderESLint()` returns `Promise<Linter.Config[]>` — export it without
393
+ awaiting, ESLint 9 resolves it at load time. `useInsiderOXC()` also returns a
394
+ Promise, and oxlint does *not* await a default export, so `oxlint.config.ts`
395
+ must use top-level `await`. The `UseInsiderResult` type alias is exported for
396
+ TypeScript callers that wrap the renderers in a helper of their own.
397
+
398
+ Two file-matching differences between the linters are handled for you, but
399
+ worth knowing:
400
+
401
+ - An unanchored oxlint glob matches at any depth, so `files: ['*.mjs']` would
402
+ also claim `.husky/install.mjs`. The emitted oxlint config anchors every
403
+ glob that does not already start with `**`, restoring ESLint's semantics.
404
+ - oxlint discovers a nested `oxlint.config.*` in any subdirectory and lints
405
+ that directory with it, **ignoring the parent config's `ignorePatterns`**.
406
+ The generated lint script passes `--disable-nested-config`, which turns that
407
+ off and makes the root config's `ignorePatterns` authoritative. It is also
408
+ required rather than optional: the emitted config carries root-only
409
+ `options`, and oxlint aborts with a config-parse error the moment it
410
+ discovers those in a nested file — which a monorepo whose packages each ran
411
+ this CLI always has. Run oxlint once per package, from that package.
412
+
413
+ ## Unused disable directives
414
+
415
+ Both linters report a disable comment that no longer suppresses anything, and
416
+ each one judges only its own spelling:
417
+
418
+ - The emitted oxlint config sets `options.reportUnusedDisableDirectives` to
419
+ `warn`, so a stale `// oxlint-disable-*` is reported.
420
+ - The emitted ESLint config pins
421
+ `linterOptions.reportUnusedDisableDirectives` to `warn`, so a stale
422
+ `// eslint-disable-*` is reported.
423
+
424
+ It also sets `options.respectEslintDisableDirectives: false`, which is what
425
+ makes the pair sound. oxlint judges an `eslint-disable` against the rules *it*
426
+ ran, so with the default (`true`) a comment naming `@cspell/spellchecker`,
427
+ `sonarjs/*`, `@stylistic/*` or `jsdoc/check-tag-names` — every rule the split
428
+ deliberately keeps on the ESLint side — is reported as unused. Refusing the
429
+ `eslint-disable` spelling removes that false positive entirely, at the cost of
430
+ making the ownership split explicit in your source: a rule oxlint owns must be
431
+ suppressed with `oxlint-disable`, a rule ESLint owns with `eslint-disable`.
432
+ `migrate` performs that rewrite for you; an un-migrated comment is reported by
433
+ ESLint as an unused directive, so nothing fails silently.
434
+
435
+ Neither severity fails the build on its own. Escalate with
436
+ `oxlint --deny-warnings` and `eslint . --max-warnings 0` if you want that.
294
437
 
295
438
  `silenceRules()` (named export) is also available as a global registration
296
439
  hook. Call it at module top level to register rule IDs to demote *before*
297
440
  `useInsider` is invoked; the per-call `silenceRules: [...]` option layers
298
441
  on top.
299
442
 
300
- ```js
443
+ ```ts
301
444
  import { useInsider, silenceRules } from '@useinsider/eslint-config';
302
445
 
303
446
  silenceRules(['no-magic-numbers']);
304
447
 
305
- export default useInsider({ /* ... */ });
448
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({ /* ... */ });
449
+ ```
450
+
451
+ ## Lint scripts
452
+
453
+ Both linters have to run, and oxlint has to run *first* — the ESLint side
454
+ turns off every rule oxlint owns, so ESLint alone silently drops those checks.
455
+ The canonical script is:
456
+
457
+ ```json
458
+ { "scripts": { "lint": "oxlint --disable-nested-config && eslint ." } }
459
+ ```
460
+
461
+ The CLI never replaces a script it did not author. It finds the **one** script
462
+ that really invokes the `eslint` binary and **prepends** oxlint to it, leaving
463
+ the original command verbatim inside the new value. A composed `lint` that fans
464
+ out to a type-check and a stylelint pass is therefore untouched:
465
+
466
+ ```diff
467
+ "lint": "run-p lint:type lint:es lint:style",
468
+ "lint:es": "npm run lint:es-ci -- --cache",
469
+ - "lint:es-ci": "NODE_OPTIONS=--max-old-space-size=6144 eslint .",
470
+ + "lint:es-ci": "oxlint --disable-nested-config && NODE_OPTIONS=--max-old-space-size=6144 eslint .",
471
+ "lint:style": "npm run lint:style-ci -- --cache",
472
+ "lint:type": "run-p lint:type:app lint:type:test lint:type:node",
306
473
  ```
307
474
 
475
+ Prepending rather than appending is deliberate: a `--` passthrough
476
+ (`npm run lint:es-ci -- --cache`) lands on the *last* command, so `--cache`
477
+ still reaches ESLint. A script whose name ends in a `--fix` invocation gets
478
+ `oxlint --disable-nested-config --fix`. Every rewrite is printed with its
479
+ previous value.
480
+
481
+ Exactly one script is rewritten. When several entry points run ESLint —
482
+ `lint`, `lint:fix`, `lint:ci` — prepending to all of them would run oxlint once
483
+ per entry point over the same tree, so the CLI picks the entry point `lint`
484
+ designates (itself, or the leaf it delegates to) and reports the rest with the
485
+ prefix to add if you want oxlint on them too.
486
+
487
+ If no script invokes `eslint` and a `lint` script already exists, nothing is
488
+ written — the conflict is reported with the command to add by hand.
489
+
490
+ ## Migrating to v4
491
+
492
+ v4 requires **Node.js 24.19+** (`engines.node` was `>=20` through v3). Check
493
+ your CI images and any repo pinning an older Node before upgrading: npm and
494
+ pnpm warn with `EBADENGINE`, and fail outright under `engine-strict`.
495
+
496
+ v4 splits the lint run across ESLint and oxlint. Run the CLI to do all of
497
+ this automatically:
498
+
499
+ ```bash
500
+ npx @useinsider/eslint-config migrate
501
+ ```
502
+
503
+ ### The guided migration offer
504
+
505
+ You do not have to know that command exists. Running lint against a v3 config
506
+ under v4 detects the outdated structure and, in an interactive terminal, offers
507
+ to run the migration there and then:
508
+
509
+ ```
510
+ @useinsider/eslint-config: eslint.config.js still uses the v3 single-file layout.
511
+ /repo/eslint.config.js calls `useInsider()` itself, and
512
+ insider.lint.ts and oxlint.config.ts are missing.
513
+
514
+ Starting the guided migration. It shows every change before applying it.
515
+ Skip it with INSIDER_NO_PROMPT=true.
516
+
517
+ ◆ eslint.config.js still uses the v3 single-file layout. Migrate it now?
518
+ │ ● Yes / ○ No
519
+ ```
520
+
521
+ Accepting runs the normal `migrate` flow — same summary, same confirmations —
522
+ and then exits non-zero asking you to re-run lint, because this process already
523
+ loaded the configuration that was just rewritten.
524
+
525
+ The offer is suppressed, and the failure message leads with the `migrate`
526
+ command instead, whenever prompting would be wrong: under oxlint (whose stdout
527
+ is its report), in CI, without a TTY, with `INSIDER_NO_PROMPT=true`, or with
528
+ `INSIDER_SKIP_CONFIG_VALIDATION=true`.
529
+
530
+ The directive rewrite is reported as its own step, with the file count shown
531
+ *before* anything is written, so you can take it as a separate commit or
532
+ decline it and run `migrate` again later.
533
+
534
+ What changes:
535
+
536
+ - **`eslint-disable` no longer suppresses an oxlint-owned rule. This is the
537
+ largest behavior change in v4.** The emitted oxlint config sets
538
+ `options.respectEslintDisableDirectives: false`, so oxlint reads only the
539
+ `oxlint-disable` spelling. A comment left in the v3 form suppresses nothing,
540
+ and because ESLint no longer runs that rule it reports the comment as an
541
+ unused directive rather than failing quietly. `migrate` rewrites the affected
542
+ comments for you (AST-based — it touches only real comment nodes).
543
+
544
+ ```diff
545
+ - // eslint-disable-next-line no-console -- CLI banner
546
+ + // oxlint-disable-next-line no-console -- CLI banner
547
+ console.info(banner);
548
+ ```
549
+
550
+ Rules the split keeps on the ESLint side — `@cspell/*`, `sonarjs/*`,
551
+ `@stylistic/*`, `jsdoc/check-tag-names`, `no-inner-declarations`, every
552
+ type-aware `@typescript-eslint` rule — keep the `eslint-disable` spelling.
553
+ See [Unused disable directives](#unused-disable-directives) for why refusing
554
+ the eslint spelling is what makes stale-directive reporting sound in both
555
+ directions.
556
+
557
+ - **`useInsider()` is now synchronous and returns two renderers.** A v3
558
+ `export default useInsider({...})` no longer produces a flat-config array.
559
+
560
+ ```diff
561
+ - // eslint.config.mjs
562
+ - import { useInsider } from '@useinsider/eslint-config';
563
+ -
564
+ - export default useInsider({ typescriptNode: [{ files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] }] });
565
+ + // insider.lint.ts
566
+ + import { useInsider } from '@useinsider/eslint-config';
567
+ +
568
+ + export const { useInsiderESLint, useInsiderOXC } = useInsider({
569
+ + typescriptNode: [{ files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] }],
570
+ + });
571
+ ```
572
+
573
+ - **Two new sibling files** — `eslint.config.*` and `oxlint.config.*`, each a
574
+ thin re-export. See [the three-file layout](#the-three-file-layout).
575
+ - **The lint command runs both linters**: `oxlint --disable-nested-config && eslint .`.
576
+ `--disable-nested-config` is required, not stylistic. Without it oxlint
577
+ discovers any nested `oxlint.config.*` in the tree and lints that subtree
578
+ with it, bypassing the root config's `ignorePatterns`. Worse, the emitted
579
+ config carries `options.*`, which oxlint accepts **only** in a root config —
580
+ a nested config carrying `options` aborts the whole run, even when its
581
+ directory is listed in `ignorePatterns`.
582
+ - **Monorepo consumers run oxlint per package.** oxlint has no workspace mode,
583
+ so each package owns its own three files and its own `lint` script, and each
584
+ script carries `--disable-nested-config`. Running one oxlint pass from the
585
+ repo root over sibling packages is not equivalent.
586
+ - **Only `oxlint.config.ts` and `oxlint.config.mts` are discovered.** Any other
587
+ extension — `.js`, `.mjs`, `.cjs`, `.cts` — is ignored *without a word*, and
588
+ oxlint falls through to the nearest ancestor config. The config appears live
589
+ while none of its rules run. `ensureOxlintConfig()` fails the ESLint side
590
+ when neither accepted filename sits beside the lint module, so the mistake
591
+ surfaces rather than silently disarming half the rule set.
592
+ - **`oxlint` is version-tracked like `eslint` and `globals`.** A consumer
593
+ whose installed `oxlint` is older than this package's pin is upgraded
594
+ automatically on the next lint run, because an older oxlint silently drops
595
+ whatever rules that release did not yet implement. Opt out with
596
+ `silenceDependencyWarning(['oxlint'])`.
597
+ - **`tsconfig.json` gains `allowImportingTsExtensions: true`.** `eslint.config.ts`
598
+ and `oxlint.config.ts` must import the lint module by its real extension —
599
+ nothing here applies TypeScript's `.js` → `.ts` rewriting — and `tsc` fails
600
+ with `TS5097` otherwise. The CLI prompts to add it and resolves the full
601
+ `extends` chain first, so a shared base that already sets it is left alone;
602
+ declining prints the snippet to paste. A project that emits is never edited —
603
+ TypeScript rejects the flag there with `TS5096`.
604
+ - **`@typescript-eslint` type-aware rules, `sonarjs`, `@stylistic`, `@cspell`,
605
+ `jsdoc/check-tag-names` and `no-inner-declarations` stay with ESLint.** Two
606
+ of those are permanent, for different reasons:
607
+ - `jsdoc/check-tag-names` is driven by `settings.jsdoc.tagNamePreference`
608
+ (the TypeScript scopes remap `@desc`/`@callback` onto `@use`/`@watch`), and
609
+ oxlint has no equivalent of flat-config `settings`.
610
+ - `no-inner-declarations` is implemented by both, but they disagree on the
611
+ default for an omitted option: ESLint 9 defaults `blockScopedFunctions` to
612
+ `allow`, oxlint to `disallow`. Handing oxlint the bare `'error'` would
613
+ reject every block-scoped function declaration ESLint accepts.
614
+ - **Stale disable directives warn on both sides, and neither fails the build
615
+ on its own.** Escalate with `oxlint --deny-warnings` and
616
+ `eslint . --max-warnings 0`.
617
+
308
618
  ## Migrating from v1.x
309
619
 
310
- v2 is a hard break. The summary:
620
+ This section describes the v1 → v2 step. The scope-keyed config object it
621
+ introduces is still the v4 shape; only the wiring around the call changed
622
+ again, which [Migrating to v4](#migrating-to-v4) covers. Do both steps if you
623
+ are coming from v1.
311
624
 
312
625
  - **`preset: string[]`** → top-level scope properties. The order in the
313
626
  v1 `preset` array no longer matters — the new shape has one property per
@@ -365,13 +678,12 @@ v2 is a hard break. The summary:
365
678
  paths-targeted strict checks, set `strict: [...]` instead of
366
679
  `strict: true`.
367
680
 
368
- - **Shape change**: `useInsider(...)` is now exported directly as the
369
- default export, without `await`, without spread, and without an array
370
- wrap. ESLint 9 resolves the Promise on load. The old pattern
371
- `export default [...await useInsider({...})]` still parses, but the
372
- preferred shape is `export default useInsider({...})`. There is no
373
- supported override path — describe every glob you want linted inside
374
- the call, including jQuery files via the dedicated `jquery` scope.
681
+ - **Shape change**: `useInsider(...)` stopped being an awaited, spreadable
682
+ array. In v2 it became `export default useInsider({...})`; in v4 it became
683
+ the renderer destructuring shown in
684
+ [Migrating to v4](#migrating-to-v4). Either way there is no supported
685
+ override path — describe every glob you want linted inside the call,
686
+ including jQuery files via the dedicated `jquery` scope.
375
687
 
376
688
  - **Removed**: the `preset` array, the `config: Linter.Config` escape
377
689
  hatch, and the `config` preset itself. Configuration-file linting is
@@ -381,15 +693,15 @@ v2 is a hard break. The summary:
381
693
  - **Kept**: `silenceRules` accepts the same `string[]` shape both as a
382
694
  top-level option and via the named `silenceRules()` export.
383
695
  `silenceDependencyWarning()` works unchanged. Side-effect lines such as
384
- `silenceDependencyWarning(true)` continue to sit above the
385
- `export default useInsider({...})` call.
696
+ `silenceDependencyWarning(true)` continue to sit above the `useInsider(...)`
697
+ call, now in `insider.lint.*`.
386
698
 
387
699
  ## First lint after install
388
700
 
389
- The first time you run lint after installing v2, `ensureDependencies` may
390
- detect that your project's `eslint` or `globals` version is older than the
391
- pin and auto-install them, exiting with code `1`. Run lint again and it
392
- will complete normally.
701
+ The first time you run lint after installing, `ensureDependencies` may detect
702
+ that your project's `eslint` or `globals` version is older than the pin and
703
+ auto-install them, exiting with code `1`. Run lint again and it will complete
704
+ normally.
393
705
 
394
706
  ## Troubleshooting
395
707
 
@@ -399,12 +711,12 @@ If you see this error, your project is missing a peer plugin this config
399
711
  needs. The error message lists the install command. To silence the warning
400
712
  permanently:
401
713
 
402
- ```js
714
+ ```ts
403
715
  import { useInsider, silenceDependencyWarning } from '@useinsider/eslint-config';
404
716
 
405
717
  silenceDependencyWarning(['@cspell/eslint-plugin', '@stylistic/eslint-plugin']);
406
718
 
407
- export default useInsider({ /* ... */ });
719
+ export const { useInsiderESLint, useInsiderOXC } = useInsider({ /* ... */ });
408
720
  ```
409
721
 
410
722
  Passing `true` silences every warning; that is not recommended because the
@@ -9,4 +9,17 @@ import type { Linter } from 'eslint';
9
9
  * `configFiles: string[]` knob.
10
10
  */
11
11
  export declare const DEFAULT_CONFIG_FILE_GLOBS: readonly string[];
12
+ export declare const LINT_TOPOLOGY_NAME = "insider/lint-topology";
13
+ /**
14
+ * The three files `useInsider()` requires a consumer to own. They are always linted with
15
+ * {@link buildLintTopologyBlocks}, independently of the `configFiles` option.
16
+ */
17
+ export declare const LINT_TOPOLOGY_GLOBS: readonly string[];
18
+ /**
19
+ * Relaxations the mandated three-file layout needs in order to lint clean. Emitted unconditionally
20
+ * because the layout exists whether or not the consumer passes `configFiles`.
21
+ * @example
22
+ * buildLintTopologyBlocks()[0].rules['import-x/extensions']; // 'off'
23
+ */
24
+ export declare function buildLintTopologyBlocks(): Linter.Config[];
12
25
  export declare function buildConfigFilesBlocks(value: false | string[] | undefined): Linter.Config[];
@@ -0,0 +1,51 @@
1
+ import type { Linter } from 'eslint';
2
+ export declare const OXLINT_SUPPRESSION_NAME = "insider/oxlint-suppression";
3
+ export declare const OXLINT_SFC_EXCEPTION_NAME = "insider/oxlint-suppression-sfc-exception";
4
+ export declare const UNUSED_DIRECTIVE_BLOCK_NAME = "insider/unused-disable-directives";
5
+ export declare const UNUSED_DIRECTIVE_SEVERITY = "warn";
6
+ export declare function isOxlintSuppressionBlock(block: Linter.Config): boolean;
7
+ export interface OxlintOverride {
8
+ files: string[];
9
+ excludeFiles?: string[];
10
+ rules: Record<string, Linter.RuleEntry>;
11
+ }
12
+ export interface OxlintRendererOptions {
13
+ respectEslintDisableDirectives: boolean;
14
+ reportUnusedDisableDirectives: typeof UNUSED_DIRECTIVE_SEVERITY;
15
+ }
16
+ export interface OxlintRendererConfig {
17
+ plugins: string[];
18
+ categories: Record<string, 'off'>;
19
+ ignorePatterns?: string[];
20
+ options: OxlintRendererOptions;
21
+ rules: Record<string, Linter.RuleEntry>;
22
+ overrides: OxlintOverride[];
23
+ }
24
+ export declare const OXLINT_DIRECTIVE_OPTIONS: OxlintRendererOptions;
25
+ /**
26
+ * The always-on ESLint block that makes a stale `eslint-disable` visible. ESLint 9 defaults this to
27
+ * `warn`, but the split relies on it, so it is pinned rather than inherited.
28
+ * @example
29
+ * buildUnusedDirectiveBlocks()[0].linterOptions; // { reportUnusedDisableDirectives: 'warn' }
30
+ */
31
+ export declare function buildUnusedDirectiveBlocks(): Linter.Config[];
32
+ /**
33
+ * ESLint blocks that turn off every rule the emitted oxlint config takes over. Rules oxlint cannot
34
+ * check inside an SFC are suppressed everywhere except `.vue`/`.svelte`/`.astro`, so a Vue consumer
35
+ * keeps them.
36
+ * @example
37
+ * buildOxlintSuppressionBlocks([{ files: ['src/**\/*.ts'], rules: { 'no-var': 'error' } }]);
38
+ * // [{ files: ['src/**\/*.ts'], rules: { 'no-var': 'off' } }]
39
+ */
40
+ export declare function buildOxlintSuppressionBlocks(blocks: readonly Linter.Config[]): Linter.Config[];
41
+ export declare function anchorOxlintGlob(glob: string): string;
42
+ export declare function anchorOxlintIgnorePattern(pattern: string): string;
43
+ /**
44
+ * The oxlint half of the split, derived from the same emitted ESLint blocks the suppression layer
45
+ * reads. Every oxlint category is pinned off so the config enables exactly the preset's own rules
46
+ * and nothing oxlint would otherwise turn on by default.
47
+ * @example
48
+ * buildOxlintConfig([{ files: ['src/**\/*.ts'], rules: { 'no-var': 'error' } }], ['dist']);
49
+ * // { plugins: ['eslint'], categories: { correctness: 'off', ... }, ignorePatterns: ['/dist'], ... }
50
+ */
51
+ export declare function buildOxlintConfig(blocks: readonly Linter.Config[], ignores: readonly string[] | undefined): OxlintRendererConfig;