exadev-eslint-config 2.18.1 → 2.19.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.
Files changed (4) hide show
  1. package/README.md +142 -88
  2. package/dist/index.cjs +152 -101
  3. package/dist/index.js +153 -102
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -4,19 +4,19 @@
4
4
 
5
5
  > A real ESLint plugin (not a shareable config) exposing custom rules shared across ExaDev projects. Also published under the unscoped alias `exadev-eslint-config`.
6
6
 
7
+ **Contents:** [Why](#why) · [Getting started](#getting-started) · [The lighter option](#the-lighter-option-the-plugin-named-export) · [Optional features](#optional-features) · [Rules](#rules) · [Barrel policy](#barrel-policy) · [Development](#development) · [License](#license)
8
+
7
9
  ## Why
8
10
 
9
11
  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
12
 
11
13
  ## Getting started
12
14
 
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
-
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`, `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(...)`:
19
+ Requires `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as peer dependencies. Importing anything from this package resolves `typescript-eslint`, since the default export and the `plugin` named export share one root module — see [Architecture](#architecture).
20
20
 
21
21
  ```ts
22
22
  // eslint.config.ts
@@ -34,18 +34,57 @@ export default tseslint.config(
34
34
  );
35
35
  ```
36
36
 
37
- **A published package whose `src/index.ts` is its package entry point overrides `banned` to `single` in one line** (flat-config later blocks override earlier rule settings), since deleting its barrel would break every downstream importer:
37
+ **Remove your own `tseslint.configs.recommended`/`recommendedTypeChecked`/`strictTypeChecked`/`stylisticTypeChecked` spreads.** The default export already includes `strictTypeChecked` (which subsumes both plain `recommended` and `recommendedTypeChecked`) plus `stylisticTypeChecked`, and registers the `@typescript-eslint` plugin/parser itself — flat config rejects two different plugin object instances registered under the same namespace. You still supply your own `languageOptions.parserOptions.project`/`projectService` pointing at your tsconfig(s).
38
38
 
39
- ```ts
40
- ...exadev,
41
- { rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
42
- ```
39
+ ### What the default export includes
40
+
41
+ **typescript-eslint presets:**
42
+
43
+ - `strictTypeChecked` + `stylisticTypeChecked` — already covers `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 (not re-listed individually below).
44
+
45
+ **This package's own rules** (full details in [Rules](#rules)):
43
46
 
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).
47
+ - `exadev/barrel-policy` at its auto-detecting default — see [Barrel policy](#barrel-policy)
48
+ - `exadev/no-object-assign`
49
+ - `exadev/no-mutable-union-array-param`
50
+ - `exadev/no-array-isarray-mutation`
51
+ - `exadev/no-enum-number-widening`
52
+ - `exadev/no-enum-reverse-lookup-widening`
53
+ - `exadev/no-map-instanceof-mutation`
54
+ - `exadev/no-set-instanceof-mutation`
55
+ - `exadev/prefer-readonly-array-param`
56
+ - `exadev/prefer-readonly-object-param`
57
+ - `exadev/prefer-numeric-sort-compare`
58
+ - `exadev/no-pointless-reassignment`
59
+ - `exadev/test-file-kind`
45
60
 
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.
61
+ **Individual rule tuning**, with the reasoning for each:
47
62
 
48
- ### The lighter option: the `plugin` named export
63
+ - `linterOptions.noInlineConfig` — no `eslint-disable` comments of any kind.
64
+ - `@typescript-eslint/consistent-type-assertions` — bans all type assertions (relaxed in test files, see below).
65
+ - `@typescript-eslint/consistent-type-imports` and `@typescript-eslint/consistent-type-exports`.
66
+ - `@typescript-eslint/consistent-return` — a function that implicitly returns `undefined` on one path and a real value on another is usually a bug, not a deliberate design.
67
+ - `@typescript-eslint/no-non-null-assertion` — bans the `!` operator, the same manual-override escape hatch as a type assertion under a different spelling.
68
+ - `@typescript-eslint/no-redeclare` and `@typescript-eslint/no-shadow`.
69
+ - `@typescript-eslint/no-use-before-define` set to `{ functions: false }` — a genuine temporal-dead-zone crash risk for `let`/`const`/`class`/enum bindings, but function declarations are fully hoisted and safe to call before their point of textual declaration; this codebase's own rule files consistently define helper functions after the logic that calls them.
70
+ - `@typescript-eslint/ban-ts-comment` — bans `@ts-expect-error` outright (relaxed in test files, see below).
71
+ - `@typescript-eslint/method-signature-style` set to `'property'` — method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound.
72
+ - `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`.
73
+ - `@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 only because of TS's own void-return contravariance leniency.
74
+ - `@typescript-eslint/switch-exhaustiveness-check`.
75
+ - `@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.
76
+ - `@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`).
77
+ - `max-lines` set to `{ max: 800, skipBlankLines: true, skipComments: true }` — counts only real code, so a file isn't pushed over the limit by whitespace or its own WHY-explanation comments.
78
+ - `no-warning-comments` — bans any comment containing `Stryker disable` (Stryker's own mutation-testing suppression directive, invisible to `noInlineConfig` above since it isn't an eslint-disable comment).
79
+
80
+ **Test files** (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions, and only these two:
81
+
82
+ - `@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.
83
+ - `consistent-type-assertions` relaxes to `assertionStyle: 'as'` — the legacy `<Type>value` form stays banned everywhere.
84
+
85
+ Nothing else inherited from the presets is relaxed.
86
+
87
+ ## The lighter option: the `plugin` named export
49
88
 
50
89
  For a project that wants only this package's own rules without the full type-checked bundle, import the named `plugin` export and wire rules individually:
51
90
 
@@ -99,42 +138,18 @@ export default tseslint.config(
99
138
  );
100
139
  ```
101
140
 
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
-
104
- ## Optional React and Next.js support
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:
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:
109
- ```sh
110
- pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y # React support
111
- pnpm add -D @next/eslint-plugin-next # Next.js support
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).
115
-
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
-
118
- ### Explicit control
119
-
120
- Two ways to override the automatic behaviour, for anyone who doesn't want to rely on it:
141
+ `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.
121
142
 
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
- ```ts
124
- import { plugin } from '@exadev/eslint-config';
125
- import tseslint from 'typescript-eslint';
143
+ ## Optional features
126
144
 
127
- export default tseslint.config(
128
- // ...your own config...
129
- {
130
- files: ['**/*.tsx'],
131
- plugins: { exadev: plugin },
132
- extends: [plugin.configs.react], // throws if eslint-plugin-react isn't installed
133
- },
134
- );
135
- ```
145
+ | Feature | Default | Control it with |
146
+ | --- | --- | --- |
147
+ | [React & Next.js linting](#optional-react-and-nextjs-support) | Auto-detected: on if the relevant peer package is installed | `exadevConfig({ react, nextjs })` |
148
+ | [Gitignore-derived ignores](#gitignore-derived-ignores) | On if the project has a `.gitignore` | `exadevConfig({ gitignore })` |
149
+ | [RFC 8785 canonical JSON formatting](#rfc-8785-canonical-json-formatting) | Always on | Not optional |
150
+ | [package.json key ordering](#optional-packagejson-key-ordering) | On, unless the project already has a syncpack config | `exadevConfig({ packageJsonKeyOrder })` |
136
151
 
137
- **`exadevConfig(options, ...userConfigs)`** — the named factory export, for fine-grained tri-state control per feature:
152
+ Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
138
153
 
139
154
  ```ts
140
155
  // eslint.config.ts
@@ -152,15 +167,49 @@ export default tseslint.config(
152
167
  );
153
168
  ```
154
169
 
170
+ 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. `import exadev from '@exadev/eslint-config'` (the default export) is just `exadevConfig()` called with no arguments.
171
+
172
+ ### Optional React and Next.js support
173
+
174
+ React/hooks/a11y and Next.js rule blocks fold in automatically, with no separate import or config needed, gated on two independent, always-both-required conditions:
175
+
176
+ 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:
177
+ ```sh
178
+ pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y # React support
179
+ pnpm add -D @next/eslint-plugin-next # Next.js support
180
+ ```
181
+ 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.
182
+ 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 never match a file that isn't JSX. `@next/eslint-plugin-next`'s block carries no such glob: its own presence is already an unambiguous signal (nothing installs it except a real Next.js project).
183
+
184
+ React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `flat/jsx-runtime` config, turning `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.
185
+
186
+ **Explicit control**, for anyone who doesn't want to rely on auto-detection:
187
+
188
+ - **`plugin.configs.react`/`plugin.configs.nextjs`** — explicit tier selection, mirroring `plugin.configs.recommended`/`.barrel`. Unlike those two, selecting `.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.
189
+ ```ts
190
+ import { plugin } from '@exadev/eslint-config';
191
+ import tseslint from 'typescript-eslint';
192
+
193
+ export default tseslint.config(
194
+ // ...your own config...
195
+ {
196
+ files: ['**/*.tsx'],
197
+ plugins: { exadev: plugin },
198
+ extends: [plugin.configs.react], // throws if eslint-plugin-react isn't installed
199
+ },
200
+ );
201
+ ```
202
+ - **`exadevConfig({ react, nextjs })`** — see the tri-state table below.
203
+
155
204
  | Value | React (`options.react`) | Next.js (`options.nextjs`) |
156
205
  | --- | --- | --- |
157
206
  | `true` | Force on — throws if `eslint-plugin-react` isn't resolvable | Force on — throws if `@next/eslint-plugin-next` isn't resolvable |
158
207
  | `false` | Force off — always `[]`, no resolution attempted | Force off — always `[]`, no resolution attempted |
159
208
  | `undefined` / omitted | Auto-detect (the default) | Auto-detect (the default) |
160
209
 
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.
210
+ **Compatibility note:** 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. Use the `react`/`nextjs` options above to force it off explicitly if needed.
162
211
 
163
- ## Gitignore-derived ignores
212
+ ### Gitignore-derived ignores
164
213
 
165
214
  `exadevConfig()`'s default output includes an `ignores` block derived directly from your project's own `.gitignore` (via [`@eslint/config-helpers`](https://www.npmjs.com/package/@eslint/config-helpers)'s `includeIgnoreFile`), so a generated directory your `.gitignore` already knows about (`dist/`, `coverage/`, a tool's own report output) is never linted, without hand-duplicating that list in `eslint.config.ts` too. This closes a real gap: a `.gitignore`d directory that nothing previously linted broadly enough to reach could still get linted the moment a wide-reaching rule (this package's own bundled RFC 8785 JSON canonicalization, say) started matching every file its glob covers.
166
215
 
@@ -172,7 +221,7 @@ Trailing arguments are arbitrary flat-config objects, appended in order after ev
172
221
 
173
222
  Needs no peer to install — `@eslint/config-helpers` is bundled into this package's own build.
174
223
 
175
- ## RFC 8785 canonical JSON formatting
224
+ ### RFC 8785 canonical JSON formatting
176
225
 
177
226
  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
227
 
@@ -182,7 +231,7 @@ The plugin's own full-canonicalization rule (`no-insignificant-whitespace`, whic
182
231
 
183
232
  `**/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
233
 
185
- ## Optional package.json key ordering
234
+ ### Optional package.json key ordering
186
235
 
187
236
  `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
237
 
@@ -196,7 +245,7 @@ This exists for a project that wants real `package.json` canonicalization withou
196
245
 
197
246
  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
247
 
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.
248
+ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-detection is — `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
249
 
201
250
  ## Rules
202
251
 
@@ -220,22 +269,34 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
220
269
  | `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
270
  | `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
271
  | `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. |
272
+ | `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-packagejson-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
273
  | `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. |
225
274
 
226
275
  ## Barrel policy
227
276
 
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).
277
+ `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). Omitting `mode` entirely, or passing an options object with no `mode` key, means `'auto'`.
229
278
 
230
279
  | `mode` | Which files may be barrels | What a barrel may contain | Where re-exports may come from |
231
280
  | --- | --- | --- | --- |
232
- | `'banned'` (default/recommended) | none | — | — |
233
- | `'single'` | exactly `src/index.ts` | only re-exports | anywhere |
281
+ | `'auto'` (what an omitted `mode` means) | detected per file — walks up to the nearest ancestor `package.json`; a real `exports`/`main` there resolves to `single`, otherwise `banned` | only re-exports when detected as `single` | anywhere, when detected as `single` |
282
+ | `'banned'` (`plugin.configs.recommended`'s explicit choice) | none | — | — |
283
+ | `'single'` (`plugin.configs.barrel`'s explicit choice) | exactly `src/index.ts` | only re-exports | anywhere |
234
284
  | `'siblings'` | any `index.ts` | only re-exports | a direct sibling only (`./module`) |
235
285
 
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`.
286
+ Notes on `'auto'`:
287
+
288
+ - It only ever resolves to `banned` or `single` — there's no single-signal auto-equivalent for `siblings` (which package.json field would suggest "any index file, not just the entry point"?), so a project wanting that policy states it explicitly, e.g. to permit any index file as a barrel rather than just `src/index.ts` (flat-config later blocks override earlier rule settings):
289
+ ```ts
290
+ ...exadev,
291
+ { rules: { 'exadev/barrel-policy': ['error', { mode: 'siblings' }] } }, // any index file may be a barrel, not just src/index.ts
292
+ ```
293
+ - `private: true` in `package.json` is not consulted by the detection: a pnpm workspace package is routinely both `private` and a genuine import target for sibling packages via `exports`, so `private` says nothing about whether a barrel is warranted.
294
+
295
+ 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`](src/rules/barrel-helpers.ts)). It is non-fixable — the autofix lives on `no-non-barrel-reexport`.
237
296
 
238
- ## Build, test, and lint
297
+ ## Development
298
+
299
+ ### Build, test, and lint
239
300
 
240
301
  ```sh
241
302
  pnpm install # requires Node >=20 and pnpm 11.6.0 (pinned via packageManager)
@@ -245,53 +306,46 @@ pnpm test
245
306
  pnpm build
246
307
  ```
247
308
 
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`).
251
-
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/`).
253
-
254
- The `lint`/`typecheck`/`test`/`build` npm scripts wrap turbo tasks named `_lint`/`_typecheck`/`_test`/`_build` — run `pnpm build`, not `turbo run build`.
255
-
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`.
257
-
258
- ## Architecture
259
-
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).
261
-
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").
263
-
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).
265
-
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).
267
-
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.
309
+ - Each rule has a co-located `*.unit.test.ts` exercising it with ESLint's `RuleTester` under Vitest. [`vitest.setup.ts`](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.
310
+ - 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)) — 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`](src/rules/no-mutable-union-array-param.internal.unit.test.ts)).
311
+ - `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/`).
312
+ - The `lint`/`typecheck`/`test`/`build` npm scripts wrap turbo tasks named `_lint`/`_typecheck`/`_test`/`_build` — run `pnpm build`, not `turbo run build`.
313
+ - `pnpm build` runs `tsdown` from [`src/index.ts`](src/index.ts), bundling the whole module graph into ESM + CJS + declarations. `prepublishOnly` re-runs lint, typecheck, `test`, `tsdown`, `publint`, and `attw --pack`.
269
314
 
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.
315
+ ### Architecture
271
316
 
272
- `pnpm-workspace.yaml` declares an empty `packages: []` — not a real workspace, just giving turbo a root for local task caching.
317
+ <details>
318
+ <summary>Expand for implementation internals (not needed for ordinary consumption)</summary>
273
319
 
274
- ## Conventions
320
+ - [`src/plugin.ts`](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/`](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).
321
+ - [`src/config-types.ts`](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").
322
+ - [`src/optional-plugin.ts`](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).
323
+ - [`src/react.ts`](src/react.ts)/[`src/nextjs.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).
324
+ - [`src/create-config.ts`](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.
325
+ - [`src/index.ts`](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.
326
+ - [`pnpm-workspace.yaml`](pnpm-workspace.yaml) declares an empty `packages: []` — not a real workspace, just giving turbo a root for local task caching.
275
327
 
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.
328
+ </details>
277
329
 
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).
330
+ ### Conventions
279
331
 
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.
332
+ - [`eslint.config.ts`](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/react.ts)/[`src/nextjs.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`](src/index.ts) internally, so no `files`/`ignores` wiring is needed here. Plugin construction lives in [`src/plugin.ts`](src/plugin.ts) specifically so `src/index.ts` stays a pure re-export point.
333
+ - [`tsconfig.json`](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).
334
+ - Conventional commits are enforced by commitlint, restricted to the type-enum defined once in [`release.config.ts`](release.config.ts)'s `commitTypes` — both commitlint and semantic-release derive from that single list.
281
335
 
282
- ## Gotchas and quirks
336
+ ### Gotchas and quirks
283
337
 
284
- - `.attw.json` ignores `false-export-default`: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit the `export =` form `arethetypeswrong` wants under legacy `node10` resolution. The modes ESLint flat config uses (`node16`, `bundler`) are unaffected, so the rule is suppressed rather than changing the default-export shape.
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`).
338
+ - [`.attw.json`](.attw.json) ignores `false-export-default`: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit the `export =` form `arethetypeswrong` wants under legacy `node10` resolution. The modes ESLint flat config uses (`node16`, `bundler`) are unaffected, so the rule is suppressed rather than changing the default-export shape.
339
+ - [`src/index.ts`](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`](tsdown.config.ts)).
286
340
  - Husky hooks: `pre-commit` runs lint-staged (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint, `pre-push` runs typecheck + test + build.
287
341
  - 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.
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.
342
+ - 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. See the compatibility note under [Optional React and Next.js support](#optional-react-and-nextjs-support).
289
343
 
290
- ## Contributing
344
+ ### Contributing
291
345
 
292
346
  Conventional commits are enforced by a husky `commit-msg` hook and re-checked in CI. CI runs commitlint, lint, and typecheck+test+build+attw on every push and pull request; the release job runs only on push to `main`, after all pass.
293
347
 
294
- ## Release
348
+ ### Release
295
349
 
296
350
  Conventional commits drive [semantic-release](https://semantic-release.gitbook.io/semantic-release) on every push to `main`: version bump, `CHANGELOG.md`, GitHub Release, and npm publish via OIDC (no stored token). A second CI job republishes the identical build under the unscoped alias `exadev-eslint-config`.
297
351
 
package/dist/index.cjs CHANGED
@@ -260,13 +260,13 @@ function tryRequire(specifier, requireFn = nodeRequire) {
260
260
  return;
261
261
  }
262
262
  }
263
- function isRecord$1(value) {
263
+ function isRecord$2(value) {
264
264
  return typeof value === "object" && value !== null;
265
265
  }
266
266
  function normalizeLegacyParserOptions(record) {
267
267
  if (!("parserOptions" in record)) return record;
268
268
  const { parserOptions, languageOptions, ...rest } = record;
269
- const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
269
+ const existingLanguageOptions = isRecord$2(languageOptions) ? languageOptions : {};
270
270
  return {
271
271
  ...rest,
272
272
  languageOptions: {
@@ -278,10 +278,10 @@ function normalizeLegacyParserOptions(record) {
278
278
  function readFlatConfig(module, path) {
279
279
  let current = module;
280
280
  for (const key of path) {
281
- if (!isRecord$1(current)) return void 0;
281
+ if (!isRecord$2(current)) return void 0;
282
282
  current = current[key];
283
283
  }
284
- if (!isRecord$1(current)) return void 0;
284
+ if (!isRecord$2(current)) return void 0;
285
285
  return normalizeLegacyParserOptions(current);
286
286
  }
287
287
  //#endregion
@@ -295,7 +295,7 @@ function buildNextjsConfig(options = {}) {
295
295
  }
296
296
  //#endregion
297
297
  //#region package.json
298
- var version = "2.18.1";
298
+ var version = "2.19.1";
299
299
  //#endregion
300
300
  //#region src/react.ts
301
301
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -362,6 +362,9 @@ function isDirectSibling(specifier) {
362
362
  function isBarrelMode(value) {
363
363
  return value === "banned" || value === "single" || value === "siblings";
364
364
  }
365
+ function isRawBarrelMode(value) {
366
+ return value === "auto" || isBarrelMode(value);
367
+ }
365
368
  function isPermittedBarrel(filename, mode) {
366
369
  if (mode === "banned") return false;
367
370
  if (mode === "single") return isMainBarrel(filename);
@@ -468,79 +471,139 @@ const barrelDirectSiblingsOnly = {
468
471
  }
469
472
  };
470
473
  //#endregion
474
+ //#region src/rules/barrel-auto-detect.ts
475
+ function isRecord$1(value) {
476
+ return typeof value === "object" && value !== null;
477
+ }
478
+ function hasRealExports(value) {
479
+ if (typeof value === "string") return value.length > 0;
480
+ if (Array.isArray(value)) return value.length > 0;
481
+ return isRecord$1(value) && Object.keys(value).length > 0;
482
+ }
483
+ function decideAutoBarrelMode(packageJson) {
484
+ const hasExports = hasRealExports(packageJson["exports"]);
485
+ const main = packageJson["main"];
486
+ const hasMain = typeof main === "string" && main.length > 0;
487
+ return hasExports || hasMain ? "single" : "banned";
488
+ }
489
+ const packageJsonByStartDir = /* @__PURE__ */ new Map();
490
+ function findNearestPackageJson(startDir) {
491
+ const cached = packageJsonByStartDir.get(startDir);
492
+ if (packageJsonByStartDir.has(startDir)) return cached;
493
+ let dir = startDir;
494
+ for (;;) {
495
+ const candidate = (0, node_path.join)(dir, "package.json");
496
+ if ((0, node_fs.existsSync)(candidate)) {
497
+ const parsed = JSON.parse((0, node_fs.readFileSync)(candidate, "utf8"));
498
+ const result = isRecord$1(parsed) ? parsed : void 0;
499
+ packageJsonByStartDir.set(startDir, result);
500
+ return result;
501
+ }
502
+ const parent = (0, node_path.dirname)(dir);
503
+ if (parent === dir) {
504
+ packageJsonByStartDir.set(startDir, void 0);
505
+ return;
506
+ }
507
+ dir = parent;
508
+ }
509
+ }
510
+ function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
511
+ const packageJson = readPackageJsonFn((0, node_path.dirname)(filename));
512
+ return packageJson === void 0 ? "banned" : decideAutoBarrelMode(packageJson);
513
+ }
514
+ //#endregion
471
515
  //#region src/rules/barrel-policy.ts
472
516
  function readMode(options) {
473
- if (typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
517
+ if (options === void 0) return "auto";
518
+ if (typeof options !== "object" || options === null) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
519
+ if (!("mode" in options)) return "auto";
520
+ if (!isRawBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
474
521
  return options.mode;
475
522
  }
476
- const barrelPolicy = {
477
- meta: {
478
- type: "problem",
479
- schema: [{
480
- type: "object",
481
- properties: { mode: {
482
- type: "string",
483
- enum: [
484
- "banned",
485
- "single",
486
- "siblings"
487
- ]
488
- } },
489
- required: ["mode"],
490
- additionalProperties: false
491
- }],
492
- messages: {
493
- indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
494
- nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
495
- sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
496
- reexportOutsideBarrel: "Re-exports belong only in a barrel (index) file — import this value directly in the file that uses it instead of re-exporting it through this one.",
497
- notADirectSibling: "A barrel may re-export only from a direct sibling file or folder ('./module' or './module.ts') — found '{{ source }}'. Move the source closer, or import it directly at the call site rather than re-exporting it through this barrel."
498
- }
499
- },
500
- create(context) {
501
- const mode = readMode(context.options[0]);
502
- const filename = context.filename;
503
- const detector = createSplitReexportDetector();
504
- return {
505
- Program(node) {
506
- if (mode === "banned") {
507
- if (isIndexFile(filename)) context.report({
508
- node,
509
- messageId: "indexFileBanned"
510
- });
511
- return;
512
- }
513
- if (mode === "single") {
514
- if (isIndexFile(filename) && !isMainBarrel(filename)) {
515
- context.report({
523
+ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
524
+ return {
525
+ meta: {
526
+ type: "problem",
527
+ schema: [{
528
+ type: "object",
529
+ properties: { mode: {
530
+ type: "string",
531
+ enum: [
532
+ "banned",
533
+ "single",
534
+ "siblings",
535
+ "auto"
536
+ ]
537
+ } },
538
+ additionalProperties: false
539
+ }],
540
+ messages: {
541
+ indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
542
+ nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
543
+ sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
544
+ reexportOutsideBarrel: "Re-exports belong only in a barrel (index) file — import this value directly in the file that uses it instead of re-exporting it through this one.",
545
+ notADirectSibling: "A barrel may re-export only from a direct sibling file or folder ('./module' or './module.ts') — found '{{ source }}'. Move the source closer, or import it directly at the call site rather than re-exporting it through this barrel."
546
+ }
547
+ },
548
+ create(context) {
549
+ const filename = context.filename;
550
+ const rawMode = readMode(context.options[0]);
551
+ const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJsonFn) : rawMode;
552
+ const detector = createSplitReexportDetector();
553
+ return {
554
+ Program(node) {
555
+ if (mode === "banned") {
556
+ if (isIndexFile(filename)) context.report({
516
557
  node,
517
- messageId: "nonMainIndexFile"
558
+ messageId: "indexFileBanned"
518
559
  });
519
560
  return;
520
561
  }
521
- if (isMainBarrel(filename)) {
562
+ if (mode === "single") {
563
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
564
+ context.report({
565
+ node,
566
+ messageId: "nonMainIndexFile"
567
+ });
568
+ return;
569
+ }
570
+ if (isMainBarrel(filename)) {
571
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
572
+ node: statement,
573
+ messageId: "sideEffectInBarrel",
574
+ data: { description: statement.type }
575
+ });
576
+ }
577
+ return;
578
+ }
579
+ if (isIndexFile(filename)) {
522
580
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
523
581
  node: statement,
524
582
  messageId: "sideEffectInBarrel",
525
583
  data: { description: statement.type }
526
584
  });
527
585
  }
528
- return;
529
- }
530
- if (isIndexFile(filename)) {
531
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
532
- node: statement,
533
- messageId: "sideEffectInBarrel",
534
- data: { description: statement.type }
535
- });
536
- }
537
- },
538
- ImportDeclaration: (node) => {
539
- detector.visitImport(node);
540
- },
541
- ExportNamedDeclaration(node) {
542
- detector.visitExportNamed(node);
543
- if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
586
+ },
587
+ ImportDeclaration: (node) => {
588
+ detector.visitImport(node);
589
+ },
590
+ ExportNamedDeclaration(node) {
591
+ detector.visitExportNamed(node);
592
+ if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
593
+ const source = moduleSpecifierValue(node.source);
594
+ if (!isPermittedBarrel(filename, mode)) context.report({
595
+ node,
596
+ messageId: "reexportOutsideBarrel"
597
+ });
598
+ else if (mode === "siblings" && !isDirectSibling(source)) context.report({
599
+ node,
600
+ messageId: "notADirectSibling",
601
+ data: { source }
602
+ });
603
+ }
604
+ },
605
+ ExportAllDeclaration(node) {
606
+ if (isInsideAmbientModuleDeclaration(node)) return;
544
607
  const source = moduleSpecifierValue(node.source);
545
608
  if (!isPermittedBarrel(filename, mode)) context.report({
546
609
  node,
@@ -551,42 +614,30 @@ const barrelPolicy = {
551
614
  messageId: "notADirectSibling",
552
615
  data: { source }
553
616
  });
617
+ },
618
+ ExportDefaultDeclaration: (node) => {
619
+ detector.visitExportDefault(node);
620
+ },
621
+ "Program:exit"() {
622
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
623
+ if (mode === "siblings") {
624
+ const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
625
+ if (!isDirectSibling(importSource)) context.report({
626
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
627
+ messageId: "notADirectSibling",
628
+ data: { source: importSource }
629
+ });
630
+ }
631
+ } else context.report({
632
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
633
+ messageId: "reexportOutsideBarrel"
634
+ });
554
635
  }
555
- },
556
- ExportAllDeclaration(node) {
557
- if (isInsideAmbientModuleDeclaration(node)) return;
558
- const source = moduleSpecifierValue(node.source);
559
- if (!isPermittedBarrel(filename, mode)) context.report({
560
- node,
561
- messageId: "reexportOutsideBarrel"
562
- });
563
- else if (mode === "siblings" && !isDirectSibling(source)) context.report({
564
- node,
565
- messageId: "notADirectSibling",
566
- data: { source }
567
- });
568
- },
569
- ExportDefaultDeclaration: (node) => {
570
- detector.visitExportDefault(node);
571
- },
572
- "Program:exit"() {
573
- for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
574
- if (mode === "siblings") {
575
- const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
576
- if (!isDirectSibling(importSource)) context.report({
577
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
578
- messageId: "notADirectSibling",
579
- data: { source: importSource }
580
- });
581
- }
582
- } else context.report({
583
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
584
- messageId: "reexportOutsideBarrel"
585
- });
586
- }
587
- };
588
- }
589
- };
636
+ };
637
+ }
638
+ };
639
+ }
640
+ var barrel_policy_default = createBarrelPolicyRule();
590
641
  //#endregion
591
642
  //#region src/rules/scope-guards.ts
592
643
  function asIdentifierName(name) {
@@ -1837,7 +1888,7 @@ const plugin = {
1837
1888
  },
1838
1889
  rules: {
1839
1890
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
1840
- "barrel-policy": barrelPolicy,
1891
+ "barrel-policy": barrel_policy_default,
1841
1892
  "no-array-isarray-mutation": noArrayIsArrayMutation,
1842
1893
  "no-control-flow": noControlFlow,
1843
1894
  "no-enum-number-widening": noEnumNumberWidening,
@@ -2017,7 +2068,7 @@ const recommendedTypeChecked = [
2017
2068
  plugins: { exadev: plugin },
2018
2069
  linterOptions: { noInlineConfig: true },
2019
2070
  rules: {
2020
- "exadev/barrel-policy": ["error", { mode: "banned" }],
2071
+ "exadev/barrel-policy": "error",
2021
2072
  "exadev/no-array-isarray-mutation": "error",
2022
2073
  "exadev/no-enum-number-widening": "error",
2023
2074
  "exadev/no-enum-reverse-lookup-widening": "error",
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs, { existsSync, readFileSync } from "node:fs";
2
- import path, { join, posix } from "node:path";
2
+ import path, { dirname, join, posix } from "node:path";
3
3
  import jsdoc from "eslint-plugin-jsdoc";
4
4
  import tsdoc from "eslint-plugin-tsdoc";
5
5
  import jsonCanonical from "eslint-plugin-json-canonical";
@@ -226,13 +226,13 @@ function tryRequire(specifier, requireFn = nodeRequire) {
226
226
  return;
227
227
  }
228
228
  }
229
- function isRecord$1(value) {
229
+ function isRecord$2(value) {
230
230
  return typeof value === "object" && value !== null;
231
231
  }
232
232
  function normalizeLegacyParserOptions(record) {
233
233
  if (!("parserOptions" in record)) return record;
234
234
  const { parserOptions, languageOptions, ...rest } = record;
235
- const existingLanguageOptions = isRecord$1(languageOptions) ? languageOptions : {};
235
+ const existingLanguageOptions = isRecord$2(languageOptions) ? languageOptions : {};
236
236
  return {
237
237
  ...rest,
238
238
  languageOptions: {
@@ -244,10 +244,10 @@ function normalizeLegacyParserOptions(record) {
244
244
  function readFlatConfig(module, path) {
245
245
  let current = module;
246
246
  for (const key of path) {
247
- if (!isRecord$1(current)) return void 0;
247
+ if (!isRecord$2(current)) return void 0;
248
248
  current = current[key];
249
249
  }
250
- if (!isRecord$1(current)) return void 0;
250
+ if (!isRecord$2(current)) return void 0;
251
251
  return normalizeLegacyParserOptions(current);
252
252
  }
253
253
  //#endregion
@@ -261,7 +261,7 @@ function buildNextjsConfig(options = {}) {
261
261
  }
262
262
  //#endregion
263
263
  //#region package.json
264
- var version = "2.18.1";
264
+ var version = "2.19.1";
265
265
  //#endregion
266
266
  //#region src/react.ts
267
267
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -328,6 +328,9 @@ function isDirectSibling(specifier) {
328
328
  function isBarrelMode(value) {
329
329
  return value === "banned" || value === "single" || value === "siblings";
330
330
  }
331
+ function isRawBarrelMode(value) {
332
+ return value === "auto" || isBarrelMode(value);
333
+ }
331
334
  function isPermittedBarrel(filename, mode) {
332
335
  if (mode === "banned") return false;
333
336
  if (mode === "single") return isMainBarrel(filename);
@@ -434,79 +437,139 @@ const barrelDirectSiblingsOnly = {
434
437
  }
435
438
  };
436
439
  //#endregion
440
+ //#region src/rules/barrel-auto-detect.ts
441
+ function isRecord$1(value) {
442
+ return typeof value === "object" && value !== null;
443
+ }
444
+ function hasRealExports(value) {
445
+ if (typeof value === "string") return value.length > 0;
446
+ if (Array.isArray(value)) return value.length > 0;
447
+ return isRecord$1(value) && Object.keys(value).length > 0;
448
+ }
449
+ function decideAutoBarrelMode(packageJson) {
450
+ const hasExports = hasRealExports(packageJson["exports"]);
451
+ const main = packageJson["main"];
452
+ const hasMain = typeof main === "string" && main.length > 0;
453
+ return hasExports || hasMain ? "single" : "banned";
454
+ }
455
+ const packageJsonByStartDir = /* @__PURE__ */ new Map();
456
+ function findNearestPackageJson(startDir) {
457
+ const cached = packageJsonByStartDir.get(startDir);
458
+ if (packageJsonByStartDir.has(startDir)) return cached;
459
+ let dir = startDir;
460
+ for (;;) {
461
+ const candidate = join(dir, "package.json");
462
+ if (existsSync(candidate)) {
463
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
464
+ const result = isRecord$1(parsed) ? parsed : void 0;
465
+ packageJsonByStartDir.set(startDir, result);
466
+ return result;
467
+ }
468
+ const parent = dirname(dir);
469
+ if (parent === dir) {
470
+ packageJsonByStartDir.set(startDir, void 0);
471
+ return;
472
+ }
473
+ dir = parent;
474
+ }
475
+ }
476
+ function resolveAutoMode(filename, readPackageJsonFn = findNearestPackageJson) {
477
+ const packageJson = readPackageJsonFn(dirname(filename));
478
+ return packageJson === void 0 ? "banned" : decideAutoBarrelMode(packageJson);
479
+ }
480
+ //#endregion
437
481
  //#region src/rules/barrel-policy.ts
438
482
  function readMode(options) {
439
- if (typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
483
+ if (options === void 0) return "auto";
484
+ if (typeof options !== "object" || options === null) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
485
+ if (!("mode" in options)) return "auto";
486
+ if (!isRawBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' | 'auto' }.");
440
487
  return options.mode;
441
488
  }
442
- const barrelPolicy = {
443
- meta: {
444
- type: "problem",
445
- schema: [{
446
- type: "object",
447
- properties: { mode: {
448
- type: "string",
449
- enum: [
450
- "banned",
451
- "single",
452
- "siblings"
453
- ]
454
- } },
455
- required: ["mode"],
456
- additionalProperties: false
457
- }],
458
- messages: {
459
- indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
460
- nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
461
- sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
462
- reexportOutsideBarrel: "Re-exports belong only in a barrel (index) file — import this value directly in the file that uses it instead of re-exporting it through this one.",
463
- notADirectSibling: "A barrel may re-export only from a direct sibling file or folder ('./module' or './module.ts') — found '{{ source }}'. Move the source closer, or import it directly at the call site rather than re-exporting it through this barrel."
464
- }
465
- },
466
- create(context) {
467
- const mode = readMode(context.options[0]);
468
- const filename = context.filename;
469
- const detector = createSplitReexportDetector();
470
- return {
471
- Program(node) {
472
- if (mode === "banned") {
473
- if (isIndexFile(filename)) context.report({
474
- node,
475
- messageId: "indexFileBanned"
476
- });
477
- return;
478
- }
479
- if (mode === "single") {
480
- if (isIndexFile(filename) && !isMainBarrel(filename)) {
481
- context.report({
489
+ function createBarrelPolicyRule(readPackageJsonFn = findNearestPackageJson) {
490
+ return {
491
+ meta: {
492
+ type: "problem",
493
+ schema: [{
494
+ type: "object",
495
+ properties: { mode: {
496
+ type: "string",
497
+ enum: [
498
+ "banned",
499
+ "single",
500
+ "siblings",
501
+ "auto"
502
+ ]
503
+ } },
504
+ additionalProperties: false
505
+ }],
506
+ messages: {
507
+ indexFileBanned: "Index (barrel) files are banned in this project — import directly from the module that owns the export instead. Rename this file to something descriptive.",
508
+ nonMainIndexFile: "Only src/index.ts may be a barrel in this project — this index file is not it. Move its contents into the module that owns them or give the file a descriptive name.",
509
+ sideEffectInBarrel: "A barrel may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') — nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}.",
510
+ reexportOutsideBarrel: "Re-exports belong only in a barrel (index) file — import this value directly in the file that uses it instead of re-exporting it through this one.",
511
+ notADirectSibling: "A barrel may re-export only from a direct sibling file or folder ('./module' or './module.ts') — found '{{ source }}'. Move the source closer, or import it directly at the call site rather than re-exporting it through this barrel."
512
+ }
513
+ },
514
+ create(context) {
515
+ const filename = context.filename;
516
+ const rawMode = readMode(context.options[0]);
517
+ const mode = rawMode === "auto" ? resolveAutoMode(filename, readPackageJsonFn) : rawMode;
518
+ const detector = createSplitReexportDetector();
519
+ return {
520
+ Program(node) {
521
+ if (mode === "banned") {
522
+ if (isIndexFile(filename)) context.report({
482
523
  node,
483
- messageId: "nonMainIndexFile"
524
+ messageId: "indexFileBanned"
484
525
  });
485
526
  return;
486
527
  }
487
- if (isMainBarrel(filename)) {
528
+ if (mode === "single") {
529
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
530
+ context.report({
531
+ node,
532
+ messageId: "nonMainIndexFile"
533
+ });
534
+ return;
535
+ }
536
+ if (isMainBarrel(filename)) {
537
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
538
+ node: statement,
539
+ messageId: "sideEffectInBarrel",
540
+ data: { description: statement.type }
541
+ });
542
+ }
543
+ return;
544
+ }
545
+ if (isIndexFile(filename)) {
488
546
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
489
547
  node: statement,
490
548
  messageId: "sideEffectInBarrel",
491
549
  data: { description: statement.type }
492
550
  });
493
551
  }
494
- return;
495
- }
496
- if (isIndexFile(filename)) {
497
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
498
- node: statement,
499
- messageId: "sideEffectInBarrel",
500
- data: { description: statement.type }
501
- });
502
- }
503
- },
504
- ImportDeclaration: (node) => {
505
- detector.visitImport(node);
506
- },
507
- ExportNamedDeclaration(node) {
508
- detector.visitExportNamed(node);
509
- if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
552
+ },
553
+ ImportDeclaration: (node) => {
554
+ detector.visitImport(node);
555
+ },
556
+ ExportNamedDeclaration(node) {
557
+ detector.visitExportNamed(node);
558
+ if (hasSource(node) && !isInsideAmbientModuleDeclaration(node)) {
559
+ const source = moduleSpecifierValue(node.source);
560
+ if (!isPermittedBarrel(filename, mode)) context.report({
561
+ node,
562
+ messageId: "reexportOutsideBarrel"
563
+ });
564
+ else if (mode === "siblings" && !isDirectSibling(source)) context.report({
565
+ node,
566
+ messageId: "notADirectSibling",
567
+ data: { source }
568
+ });
569
+ }
570
+ },
571
+ ExportAllDeclaration(node) {
572
+ if (isInsideAmbientModuleDeclaration(node)) return;
510
573
  const source = moduleSpecifierValue(node.source);
511
574
  if (!isPermittedBarrel(filename, mode)) context.report({
512
575
  node,
@@ -517,42 +580,30 @@ const barrelPolicy = {
517
580
  messageId: "notADirectSibling",
518
581
  data: { source }
519
582
  });
583
+ },
584
+ ExportDefaultDeclaration: (node) => {
585
+ detector.visitExportDefault(node);
586
+ },
587
+ "Program:exit"() {
588
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
589
+ if (mode === "siblings") {
590
+ const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
591
+ if (!isDirectSibling(importSource)) context.report({
592
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
593
+ messageId: "notADirectSibling",
594
+ data: { source: importSource }
595
+ });
596
+ }
597
+ } else context.report({
598
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
599
+ messageId: "reexportOutsideBarrel"
600
+ });
520
601
  }
521
- },
522
- ExportAllDeclaration(node) {
523
- if (isInsideAmbientModuleDeclaration(node)) return;
524
- const source = moduleSpecifierValue(node.source);
525
- if (!isPermittedBarrel(filename, mode)) context.report({
526
- node,
527
- messageId: "reexportOutsideBarrel"
528
- });
529
- else if (mode === "siblings" && !isDirectSibling(source)) context.report({
530
- node,
531
- messageId: "notADirectSibling",
532
- data: { source }
533
- });
534
- },
535
- ExportDefaultDeclaration: (node) => {
536
- detector.visitExportDefault(node);
537
- },
538
- "Program:exit"() {
539
- for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
540
- if (mode === "siblings") {
541
- const importSource = moduleSpecifierValue(violation.trackedImport.declaration.source);
542
- if (!isDirectSibling(importSource)) context.report({
543
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
544
- messageId: "notADirectSibling",
545
- data: { source: importSource }
546
- });
547
- }
548
- } else context.report({
549
- node: violation.kind === "named" ? violation.specifier : violation.declaration,
550
- messageId: "reexportOutsideBarrel"
551
- });
552
- }
553
- };
554
- }
555
- };
602
+ };
603
+ }
604
+ };
605
+ }
606
+ var barrel_policy_default = createBarrelPolicyRule();
556
607
  //#endregion
557
608
  //#region src/rules/scope-guards.ts
558
609
  function asIdentifierName(name) {
@@ -1803,7 +1854,7 @@ const plugin = {
1803
1854
  },
1804
1855
  rules: {
1805
1856
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
1806
- "barrel-policy": barrelPolicy,
1857
+ "barrel-policy": barrel_policy_default,
1807
1858
  "no-array-isarray-mutation": noArrayIsArrayMutation,
1808
1859
  "no-control-flow": noControlFlow,
1809
1860
  "no-enum-number-widening": noEnumNumberWidening,
@@ -1983,7 +2034,7 @@ const recommendedTypeChecked = [
1983
2034
  plugins: { exadev: plugin },
1984
2035
  linterOptions: { noInlineConfig: true },
1985
2036
  rules: {
1986
- "exadev/barrel-policy": ["error", { mode: "banned" }],
2037
+ "exadev/barrel-policy": "error",
1987
2038
  "exadev/no-array-isarray-mutation": "error",
1988
2039
  "exadev/no-enum-number-widening": "error",
1989
2040
  "exadev/no-enum-reverse-lookup-widening": "error",
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.18.1",
4
+ "version": "2.19.1",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",