exadev-eslint-config 2.17.2 → 2.18.1

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.
package/README.md CHANGED
@@ -6,17 +6,17 @@
6
6
 
7
7
  ## Why
8
8
 
9
- Multiple ExaDev repos carried identical copies of a handful of custom ESLint rules (barrel/index discipline, re-export placement, pointless-alias detection). This package is the single source of truth for those rules. Only the *rules* are centralized -- not a consumer's whole `eslint.config.ts`, since file-scoping, tsconfig wiring, and runtime-isomorphism import bans are genuinely project-specific. Each consumer keeps its own `eslint.config.ts`, importing rule implementations from here.
9
+ Multiple ExaDev repos carried identical copies of a handful of custom ESLint rules (barrel/index discipline, re-export placement, pointless-alias detection). This package is the single source of truth for those rules. Only the *rules* are centralized not a consumer's whole `eslint.config.ts`, since file-scoping, tsconfig wiring, and runtime-isomorphism import bans are genuinely project-specific. Each consumer keeps its own `eslint.config.ts`, importing rule implementations from here.
10
10
 
11
11
  ## Getting started
12
12
 
13
- Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as required peer dependencies. Importing anything from this package resolves `typescript-eslint`, since both the default export and `plugin` share the same root module -- ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which export the caller reads (see [Architecture](#architecture)).
13
+ Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as required peer dependencies. Importing anything from this package resolves `typescript-eslint`, since both the default export and `plugin` share the same root module ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which export the caller reads (see [Architecture](#architecture)).
14
14
 
15
15
  ```sh
16
16
  pnpm add -D @exadev/eslint-config typescript-eslint eslint
17
17
  ```
18
18
 
19
- The default export is the full, type-checked ruleset: typescript-eslint's `strictTypeChecked` + `stylisticTypeChecked` presets (`strictTypeChecked` subsumes `recommendedTypeChecked`, so it already includes `no-deprecated`, `no-misused-spread`, `no-mixed-enums`, `no-unnecessary-condition`, `use-unknown-in-catch-callback-variable`, `return-await`, `related-getter-setter-pairs`, `no-unnecessary-type-parameters`, and more -- those are no longer re-listed below), `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-widening`, `exadev/no-map-instanceof-mutation`, `exadev/no-set-instanceof-mutation`, `exadev/prefer-readonly-array-param`, `exadev/prefer-readonly-object-param`, `exadev/prefer-numeric-sort-compare`, `exadev/no-pointless-reassignment`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/consistent-type-imports`, `@typescript-eslint/consistent-type-exports`, `@typescript-eslint/consistent-return` (a function that implicitly returns `undefined` on one path and a real value on another -- a common real bug, not a deliberate design), `@typescript-eslint/no-non-null-assertion` banning the `!` operator (the same manual-override escape hatch as a type assertion, under a different spelling), `@typescript-eslint/no-redeclare`, `@typescript-eslint/no-shadow`, `@typescript-eslint/no-use-before-define` set to `{ functions: false }` (a genuine temporal-dead-zone crash risk for `let`/`const`/`class`/enum bindings, but exempting function declarations, which are fully hoisted and therefore runtime-safe to call before their point of textual declaration -- this codebase's own rule files consistently define their helper functions after the logic that calls them), `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/strict-void-return` (not yet in any typescript-eslint preset -- disallows passing a value-returning function where a void-returning one is expected, e.g. `arr.forEach(x => otherArray.push(x))`, which typechecks today under TS's own void-return contravariance leniency), `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`), `max-lines` set to `{ max: 800, skipBlankLines: true, skipComments: true }` (counting only real code, so a file isn't pushed over the limit by whitespace or its own WHY-explanation comments), and `no-warning-comments` banning any comment containing `Stryker disable` (Stryker's own mutation-testing suppression directive, invisible to ESLint's `noInlineConfig` above since it isn't an eslint-disable comment at all) -- the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers`/`max-lines`/`no-warning-comments` are not (see below). Spread it directly into `tseslint.config(...)`:
19
+ The default export is the full, type-checked ruleset: typescript-eslint's `strictTypeChecked` + `stylisticTypeChecked` presets (`strictTypeChecked` subsumes `recommendedTypeChecked`, so it already includes `no-deprecated`, `no-misused-spread`, `no-mixed-enums`, `no-unnecessary-condition`, `use-unknown-in-catch-callback-variable`, `return-await`, `related-getter-setter-pairs`, `no-unnecessary-type-parameters`, and more those are no longer re-listed below), `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-widening`, `exadev/no-map-instanceof-mutation`, `exadev/no-set-instanceof-mutation`, `exadev/prefer-readonly-array-param`, `exadev/prefer-readonly-object-param`, `exadev/prefer-numeric-sort-compare`, `exadev/no-pointless-reassignment`, `exadev/test-file-kind`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/consistent-type-imports`, `@typescript-eslint/consistent-type-exports`, `@typescript-eslint/consistent-return` (a function that implicitly returns `undefined` on one path and a real value on another a common real bug, not a deliberate design), `@typescript-eslint/no-non-null-assertion` banning the `!` operator (the same manual-override escape hatch as a type assertion, under a different spelling), `@typescript-eslint/no-redeclare`, `@typescript-eslint/no-shadow`, `@typescript-eslint/no-use-before-define` set to `{ functions: false }` (a genuine temporal-dead-zone crash risk for `let`/`const`/`class`/enum bindings, but exempting function declarations, which are fully hoisted and therefore runtime-safe to call before their point of textual declaration this codebase's own rule files consistently define their helper functions after the logic that calls them), `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/strict-void-return` (not yet in any typescript-eslint preset disallows passing a value-returning function where a void-returning one is expected, e.g. `arr.forEach(x => otherArray.push(x))`, which typechecks today under TS's own void-return contravariance leniency), `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`), `max-lines` set to `{ max: 800, skipBlankLines: true, skipComments: true }` (counting only real code, so a file isn't pushed over the limit by whitespace or its own WHY-explanation comments), and `no-warning-comments` banning any comment containing `Stryker disable` (Stryker's own mutation-testing suppression directive, invisible to ESLint's `noInlineConfig` above since it isn't an eslint-disable comment at all) the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers`/`max-lines`/`no-warning-comments` are not (see below). Spread it directly into `tseslint.config(...)`:
20
20
 
21
21
  ```ts
22
22
  // eslint.config.ts
@@ -41,7 +41,7 @@ export default tseslint.config(
41
41
  { rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
42
42
  ```
43
43
 
44
- `strictTypeChecked` subsumes both typescript-eslint's plain `recommended` and `recommendedTypeChecked` outright (every rule in each is also present in `strictTypeChecked`), and its base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`strictTypeChecked`/`stylisticTypeChecked` spreads** -- flat config rejects two different plugin object instances registered under the same namespace. You still supply `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s).
44
+ `strictTypeChecked` subsumes both typescript-eslint's plain `recommended` and `recommendedTypeChecked` outright (every rule in each is also present in `strictTypeChecked`), and its base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`strictTypeChecked`/`stylisticTypeChecked` spreads** flat config rejects two different plugin object instances registered under the same namespace. You still supply `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s).
45
45
 
46
46
  **Test files (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions, and only those two.** `@ts-expect-error` reverts to `allow-with-description` (a compile-time-only assertion of a type failure is a legitimate test pattern; `@ts-ignore`/`@ts-nocheck` stay banned since `@ts-expect-error` is strictly better). `consistent-type-assertions` relaxes to `assertionStyle: 'as'` (the legacy `<Type>value` form stays banned everywhere). Nothing inherited from the presets is relaxed.
47
47
 
@@ -77,7 +77,7 @@ export default defineConfig([
77
77
  {
78
78
  files: ['**/*.ts'],
79
79
  plugins: { exadev: plugin },
80
- extends: ['exadev/recommended'], // this plugin's own non-type-aware rules, plus linterOptions.noInlineConfig -- no type-checked rules at all
80
+ extends: ['exadev/recommended'], // this plugin's own non-type-aware rules, plus linterOptions.noInlineConfig no type-checked rules at all
81
81
  // or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
82
82
  },
83
83
  ]);
@@ -99,19 +99,19 @@ export default tseslint.config(
99
99
  );
100
100
  ```
101
101
 
102
- **`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped** -- `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually.
102
+ **`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped** `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually.
103
103
 
104
104
  ## Optional React and Next.js support
105
105
 
106
- `import exadev from '@exadev/eslint-config'` keeps working unchanged -- it's now literally `exadevConfig()` called with no arguments, no migration required. React/hooks/a11y and Next.js rule blocks are folded in automatically, with no separate import or config needed, gated on two independent, always-both-required conditions:
106
+ `import exadev from '@exadev/eslint-config'` keeps working unchanged it's now literally `exadevConfig()` called with no arguments, no migration required. React/hooks/a11y and Next.js rule blocks are folded in automatically, with no separate import or config needed, gated on two independent, always-both-required conditions:
107
107
 
108
- 1. **The corresponding package must actually be resolvable.** `eslint-plugin-react`, `eslint-plugin-react-hooks`, `eslint-plugin-jsx-a11y`, and `@next/eslint-plugin-next` are all *optional* peer dependencies (`peerDependenciesMeta.<pkg>.optional: true`) -- install only whichever your project actually needs:
108
+ 1. **The corresponding package must actually be resolvable.** `eslint-plugin-react`, `eslint-plugin-react-hooks`, `eslint-plugin-jsx-a11y`, and `@next/eslint-plugin-next` are all *optional* peer dependencies (`peerDependenciesMeta.<pkg>.optional: true`) install only whichever your project actually needs:
109
109
  ```sh
110
110
  pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y # React support
111
111
  pnpm add -D @next/eslint-plugin-next # Next.js support
112
112
  ```
113
- If none of these resolve, `@exadev/eslint-config`'s default export is byte-for-byte identical to the plain TypeScript ruleset -- nothing about the base package changes.
114
- 2. **For React specifically, the file must actually be `.jsx`/`.tsx`.** The React/hooks/a11y rule block is scoped to `files: ['**/*.jsx', '**/*.tsx']`, so even if `eslint-plugin-react` is resolvable only incidentally (e.g. hoisted as a transitive dependency of something unrelated in a monorepo, with zero real JSX anywhere in the linted project), its rules are never matched against a file that isn't JSX -- ESLint's flat-config `files` matching happens per linted file, at lint time, not at config-build time. `@next/eslint-plugin-next`'s block carries no such glob: its own presence is already an unambiguous signal on its own (nothing installs it except a real Next.js project).
113
+ If none of these resolve, `@exadev/eslint-config`'s default export is byte-for-byte identical to the plain TypeScript ruleset nothing about the base package changes.
114
+ 2. **For React specifically, the file must actually be `.jsx`/`.tsx`.** The React/hooks/a11y rule block is scoped to `files: ['**/*.jsx', '**/*.tsx']`, so even if `eslint-plugin-react` is resolvable only incidentally (e.g. hoisted as a transitive dependency of something unrelated in a monorepo, with zero real JSX anywhere in the linted project), its rules are never matched against a file that isn't JSX ESLint's flat-config `files` matching happens per linted file, at lint time, not at config-build time. `@next/eslint-plugin-next`'s block carries no such glob: its own presence is already an unambiguous signal on its own (nothing installs it except a real Next.js project).
115
115
 
116
116
  React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `flat/jsx-runtime` config, which turns `react/react-in-jsx-scope` and `react/jsx-uses-react` back off. `flat/recommended` alone assumes the classic runtime, where every file using JSX needs `import React` in scope; the automatic JSX runtime, the default since React 17 and the only mode Next.js's own compiler supports, needs no such import. Without this pairing, a consumer on the automatic runtime would see `react/react-in-jsx-scope` fire on every JSX file in the project.
117
117
 
@@ -119,7 +119,7 @@ React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `fla
119
119
 
120
120
  Two ways to override the automatic behaviour, for anyone who doesn't want to rely on it:
121
121
 
122
- **`plugin.configs.react`/`plugin.configs.nextjs`** -- explicit tier selection, mirroring `plugin.configs.recommended`/`.barrel`. Unlike those two, which only ever reference this package's own always-present rules, selecting `configs.react`/`.nextjs` is itself an explicit request: it **throws** a clear, actionable error if the underlying peer isn't installed, rather than silently returning nothing.
122
+ **`plugin.configs.react`/`plugin.configs.nextjs`** explicit tier selection, mirroring `plugin.configs.recommended`/`.barrel`. Unlike those two, which only ever reference this package's own always-present rules, selecting `configs.react`/`.nextjs` is itself an explicit request: it **throws** a clear, actionable error if the underlying peer isn't installed, rather than silently returning nothing.
123
123
  ```ts
124
124
  import { plugin } from '@exadev/eslint-config';
125
125
  import tseslint from 'typescript-eslint';
@@ -134,7 +134,7 @@ export default tseslint.config(
134
134
  );
135
135
  ```
136
136
 
137
- **`exadevConfig(options, ...userConfigs)`** -- the named factory export, for fine-grained tri-state control per feature:
137
+ **`exadevConfig(options, ...userConfigs)`** the named factory export, for fine-grained tri-state control per feature:
138
138
 
139
139
  ```ts
140
140
  // eslint.config.ts
@@ -154,11 +154,11 @@ export default tseslint.config(
154
154
 
155
155
  | Value | React (`options.react`) | Next.js (`options.nextjs`) |
156
156
  | --- | --- | --- |
157
- | `true` | Force on -- throws if `eslint-plugin-react` isn't resolvable | Force on -- throws if `@next/eslint-plugin-next` isn't resolvable |
158
- | `false` | Force off -- always `[]`, no resolution attempted | Force off -- always `[]`, no resolution attempted |
157
+ | `true` | Force on throws if `eslint-plugin-react` isn't resolvable | Force on throws if `@next/eslint-plugin-next` isn't resolvable |
158
+ | `false` | Force off always `[]`, no resolution attempted | Force off always `[]`, no resolution attempted |
159
159
  | `undefined` / omitted | Auto-detect (the default) | Auto-detect (the default) |
160
160
 
161
- Trailing arguments are arbitrary flat-config objects, appended in order after everything else -- `exadevConfig({}, { rules: { 'no-console': 'warn' } })` is equivalent to spreading the default export plus one more config object.
161
+ Trailing arguments are arbitrary flat-config objects, appended in order after everything else `exadevConfig({}, { rules: { 'no-console': 'warn' } })` is equivalent to spreading the default export plus one more config object.
162
162
 
163
163
  ## Gitignore-derived ignores
164
164
 
@@ -166,37 +166,37 @@ Trailing arguments are arbitrary flat-config objects, appended in order after ev
166
166
 
167
167
  | Value | `options.gitignore` |
168
168
  | --- | --- |
169
- | `true` | Force on -- throws if no `.gitignore` exists |
170
- | `false` | Force off -- always `[]`, no resolution attempted |
169
+ | `true` | Force on throws if no `.gitignore` exists |
170
+ | `false` | Force off always `[]`, no resolution attempted |
171
171
  | `undefined` / omitted | Auto-detect (the default): on if the project has a `.gitignore`, silently off if it doesn't (nothing to read from a project with no version control set up yet) |
172
172
 
173
- Needs no peer to install -- `@eslint/config-helpers` is bundled into this package's own build.
173
+ Needs no peer to install `@eslint/config-helpers` is bundled into this package's own build.
174
174
 
175
175
  ## RFC 8785 canonical JSON formatting
176
176
 
177
- Every JSON file is linted against [`eslint-plugin-json-canonical`](https://github.com/ExaDev/eslint-plugin-json-canonical) v2 -- plain UTF-16 code-unit key ordering, canonical number formatting, canonical string escaping, and (as of that plugin's own v2) pretty-printed layout (2-space indentation, one member/element per line, a trailing newline), per [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). This is bundled unconditionally, the same way jsdoc/tsdoc support is: `eslint-plugin-json-canonical` is a plain dependency of this package, so every consumer already has it. There is no option to turn it off.
177
+ Every JSON file is linted against [`eslint-plugin-json-canonical`](https://github.com/ExaDev/eslint-plugin-json-canonical) v2 plain UTF-16 code-unit key ordering, canonical number formatting, canonical string escaping, and (as of that plugin's own v2) pretty-printed layout (2-space indentation, one member/element per line, a trailing newline), per [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). This is bundled unconditionally, the same way jsdoc/tsdoc support is: `eslint-plugin-json-canonical` is a plain dependency of this package, so every consumer already has it. There is no option to turn it off.
178
178
 
179
- The plugin's own full-canonicalization rule (`no-insignificant-whitespace`, which collapses a document to a single compacted line with no whitespace at all) is deliberately not part of the config this package extends -- it stays available for a consumer to opt into directly for their own genuine canonicalization pass (hashing, signing, byte-for-byte comparison).
179
+ The plugin's own full-canonicalization rule (`no-insignificant-whitespace`, which collapses a document to a single compacted line with no whitespace at all) is deliberately not part of the config this package extends it stays available for a consumer to opt into directly for their own genuine canonicalization pass (hashing, signing, byte-for-byte comparison).
180
180
 
181
181
  `**/*.jsonc`, `**/tsconfig*.json`, and `**/turbo.json` get the plugin's `configs.contentOnlyJsonc` instead of the plain-JSON config: they genuinely carry comments (TypeScript and turbo both accept them), which `@eslint/json`'s `json/json` language has no concept of and fails to parse, and neither pretty-printing nor whitespace-collapsing has a well-defined answer for a comment's own attachment to a member once its surrounding whitespace is rewritten. Content canonicalization (key order, number/string formatting) still applies to these files under `json/jsonc`.
182
182
 
183
- `**/package.json` gets everything the plain-JSON config gives every other file -- including pretty-printed layout -- except `json/sort-keys`, turned back off in its own override block, since its key order is the separate, syncpack-aware concern the next section covers.
183
+ `**/package.json` gets everything the plain-JSON config gives every other file including pretty-printed layout except `json/sort-keys`, turned back off in its own override block, since its key order is the separate, syncpack-aware concern the next section covers.
184
184
 
185
185
  ## Optional package.json key ordering
186
186
 
187
- `exadevConfig({ packageJsonKeyOrder: true })` enables `exadev/package-json-key-order` for `**/package.json`, requiring the same key order [`syncpack format`](https://syncpack.dev/command/format) would produce -- `sortFirst` fields (`name`, `description`, `version`, `author` by default) pinned to the top in that exact order, then every other top-level key alphabetically; and, inside each `sortAz`-listed field's own object or array value (`dependencies`, `devDependencies`, `scripts`, `keywords`, and the rest of syncpack's own default list), its members/elements sorted the same way. Confirmed directly against real `syncpack@15` output, not assumed from its docs -- see this rule's own source comment for the exact reverse-engineering method (a symbol-before-digit-before-letter, case-insensitive comparison syncpack's docs don't specify precisely enough to derive from prose alone).
187
+ `exadevConfig({ packageJsonKeyOrder: true })` enables `exadev/package-json-key-order` for `**/package.json`, requiring the same key order [`syncpack format`](https://syncpack.dev/command/format) would produce `sortFirst` fields (`name`, `description`, `version`, `author` by default) pinned to the top in that exact order, then every other top-level key alphabetically; and, inside each `sortAz`-listed field's own object or array value (`dependencies`, `devDependencies`, `scripts`, `keywords`, and the rest of syncpack's own default list), its members/elements sorted the same way. Confirmed directly against real `syncpack@15` output, not assumed from its docs see this rule's own source comment for the exact reverse-engineering method (a symbol-before-digit-before-letter, case-insensitive comparison syncpack's docs don't specify precisely enough to derive from prose alone).
188
188
 
189
189
  This exists for a project that wants real `package.json` canonicalization without installing syncpack, and so a project that already has syncpack never sees the two fight: `eslint --fix` and `syncpack format` converge on the identical output.
190
190
 
191
191
  | Value | `options.packageJsonKeyOrder` |
192
192
  | --- | --- |
193
- | `true` | Force on -- throws if `@eslint/json` isn't resolvable |
194
- | `false` | Force off -- always `[]`, no resolution attempted |
193
+ | `true` | Force on throws if `@eslint/json` isn't resolvable |
194
+ | `false` | Force off always `[]`, no resolution attempted |
195
195
  | `undefined` / omitted | Auto-detect (the default): on unless the project already has a syncpack config (a `.syncpackrc*`/`syncpack.config.*` file, or a `"syncpack"` key in its own `package.json`), since syncpack already produces this exact order for free |
196
196
 
197
- Like React/Next.js support, this needs its own optional peer resolvable -- `pnpm add -D @eslint/json` -- and, unlike them, also needs its `json/json` language registered for the file (this option's own config block does that for you; nothing extra to wire up).
197
+ Like React/Next.js support, this needs its own optional peer resolvable `pnpm add -D @eslint/json` and, unlike them, also needs its `json/json` language registered for the file (this option's own config block does that for you; nothing extra to wire up).
198
198
 
199
- Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-detection is (see the table above) -- `packageJsonKeyOrder: true`/`false` only forces the tri-state explicitly, it isn't the only way to reach it. Not part of `plugin.configs.recommended`, and not available as a `plugin.configs.packageJsonKeyOrder` explicit-tier config the way `.react`/`.nextjs` are, since wiring it through `plugin.configs` would need `plugin.ts` and this option's own config builder to import each other.
199
+ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-detection is (see the table above) `packageJsonKeyOrder: true`/`false` only forces the tri-state explicitly, it isn't the only way to reach it. Not part of `plugin.configs.recommended`, and not available as a `plugin.configs.packageJsonKeyOrder` explicit-tier config the way `.react`/`.nextjs` are, since wiring it through `plugin.configs` would need `plugin.ts` and this option's own config builder to import each other.
200
200
 
201
201
  ## Rules
202
202
 
@@ -204,27 +204,28 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
204
204
  | --- | --- | --- |
205
205
  | `barrel-policy` | | Umbrella over the four barrel rules below: one `{ mode }` option selecting a whole index-file policy. See [Barrel policy](#barrel-policy). |
206
206
  | `no-index-files` | | Bans any `index.*` file outright (mode 1). The strictest policy. |
207
- | `no-non-barrel-index` | | Only `src/index.ts` may be named `index.*` -- any other `index.ts`/`.js`/etc would be silently selected by a consumer's bare directory import. |
207
+ | `no-non-barrel-index` | | Only `src/index.ts` may be named `index.*` any other `index.ts`/`.js`/etc would be silently selected by a consumer's bare directory import. |
208
208
  | `no-non-barrel-reexport` | ✓ | Re-exports belong only in a barrel. Catches the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`) which no AST selector alone can match. Autofix deletes the export and the now-pointless import when it was the import's only use. Self-scopes away from any index file. |
209
- | `no-side-effects-in-index` | | A barrel file may contain only re-export statements -- nothing that could execute at import time. Self-scopes to any index file. |
209
+ | `no-side-effects-in-index` | | A barrel file may contain only re-export statements nothing that could execute at import time. Self-scopes to any index file. |
210
210
  | `barrel-direct-siblings-only` | | A barrel may re-export only from a direct sibling (`./module`), never a nested path, parent, or bare package specifier (mode 3). |
211
- | `no-control-flow` | | Bans `if`/`switch`/`for`/`for-in`/`for-of`/`while`/`do-while`/the ternary operator outright. Not part of `recommended` or `barrel` -- ordinary code legitimately needs control flow, so this is opt-in, wired via a consumer's own `files` glob for the specific packages that want it (a composition-root package selecting an adapter/strategy by a validated key, say): a lookup table replaces a branch, a declarative array method (`map`/`filter`/`some`/`every`/...) replaces a loop. Requires no type information. |
212
- | `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. Autofix rewrites every read to the original name and deletes the declaration (including its `export` keyword, when exported). Still reported but deliberately not auto-fixable where collapsing the alias would change meaning: an explicit type annotation (`const exhaustive: never = item` -- the annotation is the point), a read where the original name is shadowed, a read as a shorthand object property, more than one declarator in the statement, or a source that is written to anywhere. |
211
+ | `no-control-flow` | | Bans `if`/`switch`/`for`/`for-in`/`for-of`/`while`/`do-while`/the ternary operator outright. Not part of `recommended` or `barrel` ordinary code legitimately needs control flow, so this is opt-in, wired via a consumer's own `files` glob for the specific packages that want it (a composition-root package selecting an adapter/strategy by a validated key, say): a lookup table replaces a branch, a declarative array method (`map`/`filter`/`some`/`every`/...) replaces a loop. Requires no type information. |
212
+ | `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. Autofix rewrites every read to the original name and deletes the declaration (including its `export` keyword, when exported). Still reported but deliberately not auto-fixable where collapsing the alias would change meaning: an explicit type annotation (`const exhaustive: never = item` the annotation is the point), a read where the original name is shadowed, a read as a shorthand object property, more than one declarator in the statement, or a source that is written to anywhere. |
213
213
  | `no-object-assign` | ✓/suggestion | `Object.assign` does not check a source object's properties against the target's declared types, unlike object spread. A fresh object-literal target autofixes to `{ ...target, ...source }`; mutating an existing reassignable binding offers a suggestion only (changes the object's identity); a `const` binding or a non-statement call site gets a plain report with no fix. |
214
214
  | `no-mutable-union-array-param` | ✓ | A function parameter typed as an array of a union (`(string \| number)[]`) accepts a narrower caller array (`number[]`) by covariance; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` on it can then insert a value the caller's own array was never declared to hold. Autofix marks the parameter `readonly`, turning the mutating call into a real compile error to resolve deliberately. Requires no type information. |
215
- | `prefer-readonly-array-param` | ✓ | A narrower, safely-autofixable sibling of `@typescript-eslint/prefer-readonly-parameter-types` scoped to array/tuple parameter shapes only: fires unconditionally on every non-readonly array or tuple parameter, regardless of whether the function body mutates it, in any parameter position (a plain identifier, a rest parameter, a default-valued parameter, or a constructor parameter property) and any function-like shape (a concrete function/arrow/method, or a declaration-only ambient function, interface method, function type alias, call/construct signature, or abstract/ambient class method). A union containing an array/tuple member is fixed on that member alone. Autofix prepends `readonly ` (or renames `Array<T>` to `ReadonlyArray<T>`), turning any resulting mutation into a real compile error to resolve deliberately. Requires no type information -- registered in both `plugin.configs.recommended` and the default (type-checked) export. |
216
- | `prefer-readonly-object-param` | ✓ | The object-shape sibling of `prefer-readonly-array-param` above, scoped to "flat" object parameters where a shallow fix is provably sufficient: an inline `{ ... }` literal or a reference to a plain named type/interface where every property (and index-signature value, if any) is itself a primitive, a literal/union of primitives, or a callback -- with no nested object, array, tuple, Map, Set, class instance, union, intersection, or unconstrained type parameter anywhere in the shape. Autofix wraps the parameter's own type annotation in `Readonly<...>`, which TypeScript's own deep-readonly check accepts as fully sufficient for a shape this flat. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended` -- to resolve each property's real type via the checker. |
217
- | `no-array-isarray-mutation` | | `Array.isArray`'s own type declaration narrows to plain `any[]`, discarding the `readonly` guarantee of any array type in the narrowed parameter's or local variable's real type -- a bare `readonly T[]`, a `ReadonlyArray<T>`, one behind a type alias, or one alongside other union members -- inside the guarded branch; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` there can mutate a caller's genuinely readonly array. Recognises the direct `if (Array.isArray(x))` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: re-adding `readonly` is a no-op (the guard already discarded it) and rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended` -- specifically to see through a type alias and to catch a bare, non-union readonly array parameter or local variable, neither visible from its own syntax alone. |
218
- | `no-map-instanceof-mutation` | | `Map` is declared as extending `ReadonlyMap`, so `instanceof Map` narrows a parameter or local variable whose real type includes a `ReadonlyMap` -- bare, unioned, or reached through a type alias -- straight past the readonly guarantee to the full mutable interface; calling `set`/`delete`/`clear` there can mutate a caller's genuinely read-only map. Recognises the direct `if (input instanceof Map)` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
219
- | `no-set-instanceof-mutation` | | `instanceof Set` narrows a parameter or local variable whose real type includes a `ReadonlySet` -- bare, unioned, or reached through a type alias -- straight to the fully mutable `Set` interface, with no way to preserve the read-only guarantee through the narrowing; calling `add`/`delete`/`clear` there can mutate a caller's genuinely read-only set. Recognises the same guard idioms as `no-map-instanceof-mutation` above. No autofix, for the same aliasing reason. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
220
- | `no-enum-number-widening` | | A bare (non-literal) `number` is accepted anywhere a numeric enum is expected, without checking it is actually one of the enum's members -- only a numeric *literal* gets range-checked by `tsc`. No autofix: the only provably safe fix is a genuine runtime membership check against the enum's own values, which is a behavioural choice a mechanical fix cannot responsibly make. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
221
- | `no-enum-reverse-lookup-widening` | suggestion | Indexing a numeric enum's reverse mapping (`Direction[n]`) with a bare (non-literal) `number`, or with a different enum's member, types as plain `string` for any index, including one outside the enum's actual members, where it genuinely returns `undefined` at runtime -- `tsc` does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit `: string` annotation, a suggestion widens it to `: string \| undefined`, forcing later uses as a bare `string` to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full `--fix` autofix. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
222
- | `prefer-numeric-sort-compare` | suggestion | A deliberately narrow addition alongside `@typescript-eslint/require-array-sort-compare` (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix -- descending order is a real, if less common, alternative intent. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
223
- | `package-json-key-order` | ✓ | Requires `package.json`'s keys to be ordered the same way `syncpack format` would order them. See [Optional package.json key ordering](#optional-package-json-key-ordering) -- opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one -- needs no type information and doesn't apply to any `.ts`/`.js` file. |
215
+ | `prefer-readonly-array-param` | ✓ | A narrower, safely-autofixable sibling of `@typescript-eslint/prefer-readonly-parameter-types` scoped to array/tuple parameter shapes only: fires unconditionally on every non-readonly array or tuple parameter, regardless of whether the function body mutates it, in any parameter position (a plain identifier, a rest parameter, a default-valued parameter, or a constructor parameter property) and any function-like shape (a concrete function/arrow/method, or a declaration-only ambient function, interface method, function type alias, call/construct signature, or abstract/ambient class method). A union containing an array/tuple member is fixed on that member alone. Autofix prepends `readonly ` (or renames `Array<T>` to `ReadonlyArray<T>`), turning any resulting mutation into a real compile error to resolve deliberately. Requires no type information registered in both `plugin.configs.recommended` and the default (type-checked) export. |
216
+ | `prefer-readonly-object-param` | ✓ | The object-shape sibling of `prefer-readonly-array-param` above, scoped to "flat" object parameters where a shallow fix is provably sufficient: an inline `{ ... }` literal or a reference to a plain named type/interface where every property (and index-signature value, if any) is itself a primitive, a literal/union of primitives, or a callback with no nested object, array, tuple, Map, Set, class instance, union, intersection, or unconstrained type parameter anywhere in the shape. Autofix wraps the parameter's own type annotation in `Readonly<...>`, which TypeScript's own deep-readonly check accepts as fully sufficient for a shape this flat. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended` to resolve each property's real type via the checker. |
217
+ | `no-array-isarray-mutation` | | `Array.isArray`'s own type declaration narrows to plain `any[]`, discarding the `readonly` guarantee of any array type in the narrowed parameter's or local variable's real type a bare `readonly T[]`, a `ReadonlyArray<T>`, one behind a type alias, or one alongside other union members inside the guarded branch; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` there can mutate a caller's genuinely readonly array. Recognises the direct `if (Array.isArray(x))` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: re-adding `readonly` is a no-op (the guard already discarded it) and rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended` specifically to see through a type alias and to catch a bare, non-union readonly array parameter or local variable, neither visible from its own syntax alone. |
218
+ | `no-map-instanceof-mutation` | | `Map` is declared as extending `ReadonlyMap`, so `instanceof Map` narrows a parameter or local variable whose real type includes a `ReadonlyMap` bare, unioned, or reached through a type alias straight past the readonly guarantee to the full mutable interface; calling `set`/`delete`/`clear` there can mutate a caller's genuinely read-only map. Recognises the direct `if (input instanceof Map)` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended`. |
219
+ | `no-set-instanceof-mutation` | | `instanceof Set` narrows a parameter or local variable whose real type includes a `ReadonlySet` bare, unioned, or reached through a type alias straight to the fully mutable `Set` interface, with no way to preserve the read-only guarantee through the narrowing; calling `add`/`delete`/`clear` there can mutate a caller's genuinely read-only set. Recognises the same guard idioms as `no-map-instanceof-mutation` above. No autofix, for the same aliasing reason. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended`. |
220
+ | `no-enum-number-widening` | | A bare (non-literal) `number` is accepted anywhere a numeric enum is expected, without checking it is actually one of the enum's members only a numeric *literal* gets range-checked by `tsc`. No autofix: the only provably safe fix is a genuine runtime membership check against the enum's own values, which is a behavioural choice a mechanical fix cannot responsibly make. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended`. |
221
+ | `no-enum-reverse-lookup-widening` | suggestion | Indexing a numeric enum's reverse mapping (`Direction[n]`) with a bare (non-literal) `number`, or with a different enum's member, types as plain `string` for any index, including one outside the enum's actual members, where it genuinely returns `undefined` at runtime `tsc` does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit `: string` annotation, a suggestion widens it to `: string \| undefined`, forcing later uses as a bare `string` to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full `--fix` autofix. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended`. |
222
+ | `prefer-numeric-sort-compare` | suggestion | A deliberately narrow addition alongside `@typescript-eslint/require-array-sort-compare` (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix descending order is a real, if less common, alternative intent. Requires type information only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
223
+ | `package-json-key-order` | ✓ | Requires `package.json`'s keys to be ordered the same way `syncpack format` would order them. See [Optional package.json key ordering](#optional-package-json-key-ordering) opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one needs no type information and doesn't apply to any `.ts`/`.js` file. |
224
+ | `test-file-kind` | | Requires a test/spec file's own name to declare its test kind via a filename suffix immediately before `.test`/`.spec` (e.g. `foo.unit.test.ts`), one of a configurable `{ kinds }` set (default: `unit`, `integration`, `e2e`). A naming-discipline rule, not a content classifier — it checks only the filename, never what the file actually tests. Self-scoped to real test/spec files (`context.filename`), so it never misfires when applied unscoped and never relies on a consumer's own `files` config. Requires no type information. |
224
225
 
225
226
  ## Barrel policy
226
227
 
227
- `exadev/barrel-policy` is the convenience layer: one rule id, one `{ mode }` option selecting a complete index-file policy. Use EITHER this umbrella OR the individual rules (not both -- they double-report).
228
+ `exadev/barrel-policy` is the convenience layer: one rule id, one `{ mode }` option selecting a complete index-file policy. Use EITHER this umbrella OR the individual rules (not both they double-report).
228
229
 
229
230
  | `mode` | Which files may be barrels | What a barrel may contain | Where re-exports may come from |
230
231
  | --- | --- | --- | --- |
@@ -232,7 +233,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
232
233
  | `'single'` | exactly `src/index.ts` | only re-exports | anywhere |
233
234
  | `'siblings'` | any `index.ts` | only re-exports | a direct sibling only (`./module`) |
234
235
 
235
- In every mode, re-exports are banned outside a permitted barrel, and a permitted barrel may contain only re-export statements. The umbrella composes the identical predicates the standalone rules use (shared in `src/rules/barrel-helpers.ts`). It is non-fixable -- the autofix lives on `no-non-barrel-reexport`.
236
+ In every mode, re-exports are banned outside a permitted barrel, and a permitted barrel may contain only re-export statements. The umbrella composes the identical predicates the standalone rules use (shared in `src/rules/barrel-helpers.ts`). It is non-fixable the autofix lives on `no-non-barrel-reexport`.
236
237
 
237
238
  ## Build, test, and lint
238
239
 
@@ -244,37 +245,39 @@ pnpm test
244
245
  pnpm build
245
246
  ```
246
247
 
247
- Each rule has a co-located `*.test.ts` exercising it with ESLint's `RuleTester` under Vitest. `vitest.setup.ts` wires `RuleTester.describe`/`.it`/`.itOnly` to Vitest's `describe`/`it` explicitly (no `test.globals`). Each test uses typescript-eslint's parser for TypeScript-only fixtures; none need type information.
248
+ Each rule has a co-located `*.unit.test.ts` exercising it with ESLint's `RuleTester` under Vitest. `vitest.setup.ts` wires `RuleTester.describe`/`.it`/`.itOnly` to Vitest's `describe`/`it` explicitly (no `test.globals`). Each test uses typescript-eslint's parser for TypeScript-only fixtures; none need type information.
249
+
250
+ Every test file's own name declares its kind via a filename suffix immediately before `.test`/`.spec` — `.unit`, `.integration`, or `.e2e` by default (`exadev/test-file-kind`, part of `recommended`; see [Rules](#rules) below) — so a file's test kind is always visible from its name alone, without opening it, and downstream tooling (e.g. a Vitest project split by test kind) can select by filename glob rather than by convention nobody enforces. This package's own tests are exclusively `.unit.test.ts` today (a `.internal.unit.test.ts` variant exists for a handful of files that also test non-exported internals directly, `internal` just being an ordinary extra name segment — see `no-mutable-union-array-param.internal.unit.test.ts`).
248
251
 
249
252
  `pnpm test` always measures coverage (`@vitest/coverage-v8`), scoped to `src/**/*.ts` excluding `*.test.ts`. Text output in terminal; `html`/`lcov` in `coverage/` (gitignored alongside `.eslintcache` and `dist/`).
250
253
 
251
- The `lint`/`typecheck`/`test`/`build` npm scripts wrap turbo tasks named `_lint`/`_typecheck`/`_test`/`_build` -- run `pnpm build`, not `turbo run build`.
254
+ The `lint`/`typecheck`/`test`/`build` npm scripts wrap turbo tasks named `_lint`/`_typecheck`/`_test`/`_build` run `pnpm build`, not `turbo run build`.
252
255
 
253
256
  `pnpm build` runs `tsdown` from `src/index.ts`, bundling the whole module graph into ESM + CJS + declarations. `prepublishOnly` re-runs lint, typecheck, `test`, `tsdown`, `publint`, and `attw --pack`.
254
257
 
255
258
  ## Architecture
256
259
 
257
- `src/plugin.ts` builds a `TSESLint.FlatConfig.Plugin` (`@typescript-eslint/utils`'s own type -- not ESLint's own `ESLint.Plugin`, which can't hold a rule built with `ESLintUtils.RuleCreator`) combining `src/rules/` into a flat `rules` map. `configs.recommended`, `.barrel`, `.react`, and `.nextjs` are getters in the object literal -- each references the fully-built `plugin` (`plugins: { exadev: plugin }`), which a plain property initializer can't do mid-construction. `recommended` ships `barrel-policy` at `mode: 'banned'`; `barrel` at `mode: 'single'`; `.react`/`.nextjs` call `buildReactConfig`/`buildNextjsConfig` with `enabled: true` (see below).
260
+ `src/plugin.ts` builds a `TSESLint.FlatConfig.Plugin` (`@typescript-eslint/utils`'s own type not ESLint's own `ESLint.Plugin`, which can't hold a rule built with `ESLintUtils.RuleCreator`) combining `src/rules/` into a flat `rules` map. `configs.recommended`, `.barrel`, `.react`, and `.nextjs` are getters in the object literal each references the fully-built `plugin` (`plugins: { exadev: plugin }`), which a plain property initializer can't do mid-construction. `recommended` ships `barrel-policy` at `mode: 'banned'`; `barrel` at `mode: 'single'`; `.react`/`.nextjs` call `buildReactConfig`/`buildNextjsConfig` with `enabled: true` (see below).
258
261
 
259
- `src/config-types.ts` holds `ConfigValue`/`ConfigArrayValue` (`ConfigArrayValue = Extract<ConfigValue, unknown[]>`, the array-only member of ESLint's own config-value union), shared by every file below rather than redefined per file -- annotating a config array with the wider `ConfigValue` union directly broke `...exadev` with `TS2488` ("must have a Symbol.iterator method").
262
+ `src/config-types.ts` holds `ConfigValue`/`ConfigArrayValue` (`ConfigArrayValue = Extract<ConfigValue, unknown[]>`, the array-only member of ESLint's own config-value union), shared by every file below rather than redefined per file annotating a config array with the wider `ConfigValue` union directly broke `...exadev` with `TS2488` ("must have a Symbol.iterator method").
260
263
 
261
264
  `src/optional-plugin.ts` is the lazy-resolution helper behind React/Next.js support: `tryRequire` wraps `createRequire(import.meta.url)` in try/catch, returning `unknown` (never a cast) so every call site narrows explicitly before use; `readFlatConfig` walks a property path through that `unknown` value via a real type guard, normalizing a stray legacy top-level `parserOptions` key into `languageOptions.parserOptions` along the way (confirmed necessary: `eslint-plugin-jsx-a11y`'s own `configs.recommended` export carries exactly this legacy shape, which flat config's schema rejects outright rather than ignores).
262
265
 
263
- `src/react.ts`/`src/nextjs.ts` each export a `build*Config(options)` function: resolve the relevant optional peer(s) via `tryRequire`, extract their real flat config via `readFlatConfig`, and return an array of 0-or-more config blocks -- `[]` if unresolvable and not explicitly forced on, a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable. `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why).
266
+ `src/react.ts`/`src/nextjs.ts` each export a `build*Config(options)` function: resolve the relevant optional peer(s) via `tryRequire`, extract their real flat config via `readFlatConfig`, and return an array of 0-or-more config blocks `[]` if unresolvable and not explicitly forced on, a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable. `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why).
264
267
 
265
268
  `src/create-config.ts` is config assembly's single source of truth: `exadevConfig(options, ...userConfigs)` concatenates `recommendedTypeChecked` with both builders' output (each fed the matching tri-state option) and any trailing user configs; `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
266
269
 
267
- `src/index.ts` is the entry point, still a pure re-export barrel (required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`): `export { defaultConfig as default, exadevConfig } from './create-config'; export { default as plugin } from './plugin';`. All exports share one root module, so importing `{ plugin }` alone still resolves `typescript-eslint` via the sibling re-export -- an accepted trade-off (an earlier separate-subpath split proved more awkward in practice). React/Next.js support never adds to this cost: none of the four optional packages are ever statically imported, only passed as a runtime string to `createRequire`'s resolver, so their absence never affects module evaluation for a consumer who doesn't use them.
270
+ `src/index.ts` is the entry point, still a pure re-export barrel (required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`): `export { defaultConfig as default, exadevConfig } from './create-config'; export { default as plugin } from './plugin';`. All exports share one root module, so importing `{ plugin }` alone still resolves `typescript-eslint` via the sibling re-export an accepted trade-off (an earlier separate-subpath split proved more awkward in practice). React/Next.js support never adds to this cost: none of the four optional packages are ever statically imported, only passed as a runtime string to `createRequire`'s resolver, so their absence never affects module evaluation for a consumer who doesn't use them.
268
271
 
269
- `pnpm-workspace.yaml` declares an empty `packages: []` -- not a real workspace, just giving turbo a root for local task caching.
272
+ `pnpm-workspace.yaml` declares an empty `packages: []` not a real workspace, just giving turbo a root for local task caching.
270
273
 
271
274
  ## Conventions
272
275
 
273
- `eslint.config.ts` dogfoods this package's own factory export on itself (`import { exadevConfig } from './src/index'`), spreading `exadevConfig({ react: false, nextjs: false })` -- forced off explicitly, not the plain auto-detecting default, since `eslint-plugin-react`/`@next/eslint-plugin-next` are real devDependencies of *this* repo (needed to test `src/react.ts`/`src/nextjs.ts`'s own "package is resolvable" branch) even though this repo is neither a React nor a Next.js project. `no-side-effects-in-index` and `no-non-barrel-reexport` self-scope to `src/index.ts` internally, so no `files`/`ignores` wiring is needed here. Plugin construction lives in `src/plugin.ts` specifically so `src/index.ts` stays a pure re-export point.
276
+ `eslint.config.ts` dogfoods this package's own factory export on itself (`import { exadevConfig } from './src/index'`), spreading `exadevConfig({ react: false, nextjs: false })` forced off explicitly, not the plain auto-detecting default, since `eslint-plugin-react`/`@next/eslint-plugin-next` are real devDependencies of *this* repo (needed to test `src/react.ts`/`src/nextjs.ts`'s own "package is resolvable" branch) even though this repo is neither a React nor a Next.js project. `no-side-effects-in-index` and `no-non-barrel-reexport` self-scope to `src/index.ts` internally, so no `files`/`ignores` wiring is needed here. Plugin construction lives in `src/plugin.ts` specifically so `src/index.ts` stays a pure re-export point.
274
277
 
275
- `tsconfig.json` enables `verbatimModuleSyntax` (`import type`/`export type` required for type-only imports -- also enforced by `consistent-type-imports`) and `noUncheckedIndexedAccess` (narrow indexed access before use rather than asserting).
278
+ `tsconfig.json` enables `verbatimModuleSyntax` (`import type`/`export type` required for type-only imports also enforced by `consistent-type-imports`) and `noUncheckedIndexedAccess` (narrow indexed access before use rather than asserting).
276
279
 
277
- Conventional commits are enforced by commitlint, restricted to the type-enum defined once in `release.config.ts`'s `commitTypes` -- both commitlint and semantic-release derive from that single list.
280
+ Conventional commits are enforced by commitlint, restricted to the type-enum defined once in `release.config.ts`'s `commitTypes` both commitlint and semantic-release derive from that single list.
278
281
 
279
282
  ## Gotchas and quirks
280
283
 
@@ -282,7 +285,7 @@ Conventional commits are enforced by commitlint, restricted to the type-enum def
282
285
  - `src/index.ts` mixing a default export with a named one triggers rolldown's `MIXED_EXPORTS` warning: a raw CommonJS `require()` would see the raw exports object instead of the default. ESM `import` (the actual consumer path) resolves both correctly; `attw --pack` and `publint` report no problems, so the warning is accepted (see `tsdown.config.ts`).
283
286
  - Husky hooks: `pre-commit` runs lint-staged (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint, `pre-push` runs typecheck + test + build.
284
287
  - The CI release job sets `HUSKY=0` (commit-msg hook skips the automated release commit) and blanks `NPM_TOKEN`/`NODE_AUTH_TOKEN` explicitly so an inherited token can't win over OIDC trusted publishing.
285
- - A consumer who already has `eslint-plugin-react`/`@next/eslint-plugin-next` resolvable for unrelated reasons (e.g. hoisted in a monorepo) and writes `.jsx`/`.tsx` files may see new rule activity the moment they upgrade to a version of this package that ships React/Next.js support -- with zero action on their part. This is the normal, widely-accepted ESLint-ecosystem convention that adding rules to a shared/recommended config is a minor bump even though it can newly trip an existing `--max-warnings 0` gate, not a breaking change; see [Optional React and Next.js support](#optional-react-and-nextjs-support) for the `react`/`nextjs` options to force it off explicitly if needed.
288
+ - A consumer who already has `eslint-plugin-react`/`@next/eslint-plugin-next` resolvable for unrelated reasons (e.g. hoisted in a monorepo) and writes `.jsx`/`.tsx` files may see new rule activity the moment they upgrade to a version of this package that ships React/Next.js support with zero action on their part. This is the normal, widely-accepted ESLint-ecosystem convention that adding rules to a shared/recommended config is a minor bump even though it can newly trip an existing `--max-warnings 0` gate, not a breaking change; see [Optional React and Next.js support](#optional-react-and-nextjs-support) for the `react`/`nextjs` options to force it off explicitly if needed.
286
289
 
287
290
  ## Contributing
288
291
 
package/dist/index.cjs CHANGED
@@ -295,7 +295,7 @@ function buildNextjsConfig(options = {}) {
295
295
  }
296
296
  //#endregion
297
297
  //#region package.json
298
- var version = "2.17.2";
298
+ var version = "2.18.1";
299
299
  //#endregion
300
300
  //#region src/react.ts
301
301
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -1785,6 +1785,49 @@ const preferReadonlyObjectParam = createRule({
1785
1785
  }
1786
1786
  });
1787
1787
  //#endregion
1788
+ //#region src/rules/test-file-helpers.ts
1789
+ const TEST_FILE_EXTENSIONS = [
1790
+ "ts",
1791
+ "tsx",
1792
+ "mts",
1793
+ "cts",
1794
+ "js",
1795
+ "jsx",
1796
+ "mjs",
1797
+ "cjs"
1798
+ ];
1799
+ const EXTENSION_INDEX = -1;
1800
+ const TEST_OR_SPEC_INDEX = -2;
1801
+ const KIND_INDEX = -3;
1802
+ const SEGMENT_COUNT_WITH_A_REAL_KIND_TAG = 4;
1803
+ function classifyTestFile(filename) {
1804
+ const segments = basenameOf(filename).split(".");
1805
+ const extension = segments.at(EXTENSION_INDEX);
1806
+ const testOrSpec = segments.at(TEST_OR_SPEC_INDEX);
1807
+ if (extension === void 0 || !TEST_FILE_EXTENSIONS.includes(extension) || testOrSpec !== "test" && testOrSpec !== "spec") return {
1808
+ isTestFile: false,
1809
+ kind: void 0
1810
+ };
1811
+ return {
1812
+ isTestFile: true,
1813
+ kind: segments.length >= SEGMENT_COUNT_WITH_A_REAL_KIND_TAG ? segments.at(KIND_INDEX) : void 0
1814
+ };
1815
+ }
1816
+ //#endregion
1817
+ //#region src/rules/test-file-kind.ts
1818
+ const DEFAULT_KINDS = [
1819
+ "unit",
1820
+ "integration",
1821
+ "e2e"
1822
+ ];
1823
+ function readKinds(options) {
1824
+ if (typeof options !== "object" || options === null) throw new Error("Unreachable: exadev/test-file-kind requires options[0] to be an object, which its own schema and defaultOptions guarantee before create() ever runs.");
1825
+ if (!("kinds" in options)) return DEFAULT_KINDS;
1826
+ const { kinds } = options;
1827
+ if (!Array.isArray(kinds) || !kinds.every((kind) => typeof kind === "string")) throw new Error("Unreachable: exadev/test-file-kind requires options.kinds to be a string array, which its own schema guarantees before create() ever runs.");
1828
+ return kinds;
1829
+ }
1830
+ //#endregion
1788
1831
  //#region src/plugin.ts
1789
1832
  const plugin = {
1790
1833
  meta: {
@@ -1811,7 +1854,55 @@ const plugin = {
1811
1854
  "package-json-key-order": packageJsonKeyOrder,
1812
1855
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1813
1856
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1814
- "prefer-readonly-object-param": preferReadonlyObjectParam
1857
+ "prefer-readonly-object-param": preferReadonlyObjectParam,
1858
+ "test-file-kind": {
1859
+ meta: {
1860
+ type: "problem",
1861
+ schema: [{
1862
+ type: "object",
1863
+ properties: { kinds: {
1864
+ type: "array",
1865
+ items: { type: "string" },
1866
+ minItems: 1
1867
+ } },
1868
+ additionalProperties: false
1869
+ }],
1870
+ messages: {
1871
+ missingKind: "Test file names must declare their test kind via a filename suffix (e.g. 'foo.unit.test.ts'). '{{ filename }}' has none — expected one of: {{ kinds }}.",
1872
+ invalidKind: "Test file names must declare a recognised test kind via a filename suffix. '{{ filename }}' declares '{{ found }}', which is not one of: {{ kinds }}."
1873
+ },
1874
+ defaultOptions: [{}]
1875
+ },
1876
+ create(context) {
1877
+ const classification = classifyTestFile(context.filename);
1878
+ if (!classification.isTestFile) return {};
1879
+ const kinds = readKinds(context.options[0]);
1880
+ const { filename } = context;
1881
+ if (classification.kind === void 0) return { Program(node) {
1882
+ context.report({
1883
+ node,
1884
+ messageId: "missingKind",
1885
+ data: {
1886
+ filename,
1887
+ kinds: kinds.join(", ")
1888
+ }
1889
+ });
1890
+ } };
1891
+ const { kind } = classification;
1892
+ if (!kinds.includes(kind)) return { Program(node) {
1893
+ context.report({
1894
+ node,
1895
+ messageId: "invalidKind",
1896
+ data: {
1897
+ filename,
1898
+ found: kind,
1899
+ kinds: kinds.join(", ")
1900
+ }
1901
+ });
1902
+ } };
1903
+ return {};
1904
+ }
1905
+ }
1815
1906
  },
1816
1907
  configs: {
1817
1908
  get recommended() {
@@ -1823,7 +1914,8 @@ const plugin = {
1823
1914
  "exadev/no-mutable-union-array-param": "error",
1824
1915
  "exadev/no-object-assign": "error",
1825
1916
  "exadev/no-pointless-reassignment": "error",
1826
- "exadev/prefer-readonly-array-param": "error"
1917
+ "exadev/prefer-readonly-array-param": "error",
1918
+ "exadev/test-file-kind": "error"
1827
1919
  }
1828
1920
  };
1829
1921
  },
@@ -1905,7 +1997,7 @@ function buildPackageJsonKeyOrderConfig(options = {}) {
1905
1997
  }
1906
1998
  //#endregion
1907
1999
  //#region src/recommended-type-checked.ts
1908
- const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
2000
+ const TEST_FILE_PATTERNS = `**/*.{test,spec}.{${TEST_FILE_EXTENSIONS.join(",")}}`;
1909
2001
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1910
2002
  function scopeToJsTs(configs) {
1911
2003
  return configs.map((config) => config.files ? config : {
@@ -1937,6 +2029,7 @@ const recommendedTypeChecked = [
1937
2029
  "exadev/prefer-numeric-sort-compare": "error",
1938
2030
  "exadev/prefer-readonly-array-param": "error",
1939
2031
  "exadev/prefer-readonly-object-param": "error",
2032
+ "exadev/test-file-kind": "error",
1940
2033
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
1941
2034
  "@typescript-eslint/consistent-return": "error",
1942
2035
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
package/dist/index.js CHANGED
@@ -261,7 +261,7 @@ function buildNextjsConfig(options = {}) {
261
261
  }
262
262
  //#endregion
263
263
  //#region package.json
264
- var version = "2.17.2";
264
+ var version = "2.18.1";
265
265
  //#endregion
266
266
  //#region src/react.ts
267
267
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -1751,6 +1751,49 @@ const preferReadonlyObjectParam = createRule({
1751
1751
  }
1752
1752
  });
1753
1753
  //#endregion
1754
+ //#region src/rules/test-file-helpers.ts
1755
+ const TEST_FILE_EXTENSIONS = [
1756
+ "ts",
1757
+ "tsx",
1758
+ "mts",
1759
+ "cts",
1760
+ "js",
1761
+ "jsx",
1762
+ "mjs",
1763
+ "cjs"
1764
+ ];
1765
+ const EXTENSION_INDEX = -1;
1766
+ const TEST_OR_SPEC_INDEX = -2;
1767
+ const KIND_INDEX = -3;
1768
+ const SEGMENT_COUNT_WITH_A_REAL_KIND_TAG = 4;
1769
+ function classifyTestFile(filename) {
1770
+ const segments = basenameOf(filename).split(".");
1771
+ const extension = segments.at(EXTENSION_INDEX);
1772
+ const testOrSpec = segments.at(TEST_OR_SPEC_INDEX);
1773
+ if (extension === void 0 || !TEST_FILE_EXTENSIONS.includes(extension) || testOrSpec !== "test" && testOrSpec !== "spec") return {
1774
+ isTestFile: false,
1775
+ kind: void 0
1776
+ };
1777
+ return {
1778
+ isTestFile: true,
1779
+ kind: segments.length >= SEGMENT_COUNT_WITH_A_REAL_KIND_TAG ? segments.at(KIND_INDEX) : void 0
1780
+ };
1781
+ }
1782
+ //#endregion
1783
+ //#region src/rules/test-file-kind.ts
1784
+ const DEFAULT_KINDS = [
1785
+ "unit",
1786
+ "integration",
1787
+ "e2e"
1788
+ ];
1789
+ function readKinds(options) {
1790
+ if (typeof options !== "object" || options === null) throw new Error("Unreachable: exadev/test-file-kind requires options[0] to be an object, which its own schema and defaultOptions guarantee before create() ever runs.");
1791
+ if (!("kinds" in options)) return DEFAULT_KINDS;
1792
+ const { kinds } = options;
1793
+ if (!Array.isArray(kinds) || !kinds.every((kind) => typeof kind === "string")) throw new Error("Unreachable: exadev/test-file-kind requires options.kinds to be a string array, which its own schema guarantees before create() ever runs.");
1794
+ return kinds;
1795
+ }
1796
+ //#endregion
1754
1797
  //#region src/plugin.ts
1755
1798
  const plugin = {
1756
1799
  meta: {
@@ -1777,7 +1820,55 @@ const plugin = {
1777
1820
  "package-json-key-order": packageJsonKeyOrder,
1778
1821
  "prefer-numeric-sort-compare": preferNumericSortCompare,
1779
1822
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1780
- "prefer-readonly-object-param": preferReadonlyObjectParam
1823
+ "prefer-readonly-object-param": preferReadonlyObjectParam,
1824
+ "test-file-kind": {
1825
+ meta: {
1826
+ type: "problem",
1827
+ schema: [{
1828
+ type: "object",
1829
+ properties: { kinds: {
1830
+ type: "array",
1831
+ items: { type: "string" },
1832
+ minItems: 1
1833
+ } },
1834
+ additionalProperties: false
1835
+ }],
1836
+ messages: {
1837
+ missingKind: "Test file names must declare their test kind via a filename suffix (e.g. 'foo.unit.test.ts'). '{{ filename }}' has none — expected one of: {{ kinds }}.",
1838
+ invalidKind: "Test file names must declare a recognised test kind via a filename suffix. '{{ filename }}' declares '{{ found }}', which is not one of: {{ kinds }}."
1839
+ },
1840
+ defaultOptions: [{}]
1841
+ },
1842
+ create(context) {
1843
+ const classification = classifyTestFile(context.filename);
1844
+ if (!classification.isTestFile) return {};
1845
+ const kinds = readKinds(context.options[0]);
1846
+ const { filename } = context;
1847
+ if (classification.kind === void 0) return { Program(node) {
1848
+ context.report({
1849
+ node,
1850
+ messageId: "missingKind",
1851
+ data: {
1852
+ filename,
1853
+ kinds: kinds.join(", ")
1854
+ }
1855
+ });
1856
+ } };
1857
+ const { kind } = classification;
1858
+ if (!kinds.includes(kind)) return { Program(node) {
1859
+ context.report({
1860
+ node,
1861
+ messageId: "invalidKind",
1862
+ data: {
1863
+ filename,
1864
+ found: kind,
1865
+ kinds: kinds.join(", ")
1866
+ }
1867
+ });
1868
+ } };
1869
+ return {};
1870
+ }
1871
+ }
1781
1872
  },
1782
1873
  configs: {
1783
1874
  get recommended() {
@@ -1789,7 +1880,8 @@ const plugin = {
1789
1880
  "exadev/no-mutable-union-array-param": "error",
1790
1881
  "exadev/no-object-assign": "error",
1791
1882
  "exadev/no-pointless-reassignment": "error",
1792
- "exadev/prefer-readonly-array-param": "error"
1883
+ "exadev/prefer-readonly-array-param": "error",
1884
+ "exadev/test-file-kind": "error"
1793
1885
  }
1794
1886
  };
1795
1887
  },
@@ -1871,7 +1963,7 @@ function buildPackageJsonKeyOrderConfig(options = {}) {
1871
1963
  }
1872
1964
  //#endregion
1873
1965
  //#region src/recommended-type-checked.ts
1874
- const TEST_FILE_PATTERNS = "**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1966
+ const TEST_FILE_PATTERNS = `**/*.{test,spec}.{${TEST_FILE_EXTENSIONS.join(",")}}`;
1875
1967
  const JS_TS_FILE_PATTERNS = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
1876
1968
  function scopeToJsTs(configs) {
1877
1969
  return configs.map((config) => config.files ? config : {
@@ -1903,6 +1995,7 @@ const recommendedTypeChecked = [
1903
1995
  "exadev/prefer-numeric-sort-compare": "error",
1904
1996
  "exadev/prefer-readonly-array-param": "error",
1905
1997
  "exadev/prefer-readonly-object-param": "error",
1998
+ "exadev/test-file-kind": "error",
1906
1999
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
1907
2000
  "@typescript-eslint/consistent-return": "error",
1908
2001
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
4
- "version": "2.17.2",
4
+ "version": "2.18.1",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",