@exadev/eslint-config 2.0.0 → 2.1.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 +50 -34
  2. package/dist/index.cjs +328 -109
  3. package/dist/index.js +328 -109
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -6,19 +6,17 @@
6
6
 
7
7
  ## Why
8
8
 
9
- Multiple ExaDev repos independently carried identical copies of a handful of custom ESLint rules (barrel/index discipline, re-export placement, pointless-alias detection). Keeping them as per-repo copies meant a bug fix in one rule had to be found, fixed, and re-verified separately in every repo it was copied into. This package is the single source of truth for those rules instead.
10
-
11
- Only the *rules* are centralized here, not a consumer's whole `eslint.config.ts`. A repo's own file-scoping (`files`/`ignores`), tsconfig wiring, and any runtime-isomorphism import bans are genuinely project-specific -- forcing those into one shared config would mean either losing real per-project distinctions or building a heavily-parameterised config just to route around them. Each consumer keeps its own `eslint.config.ts`, importing rule implementations from here instead of a local copy.
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.
12
10
 
13
11
  ## Getting started
14
12
 
15
- Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as peer dependencies -- both required, not optional. Importing anything from this package, including the lighter `plugin` export described below, resolves `typescript-eslint`: the package's default export (the full type-checked bundle) and the `plugin` named export live in the same root module, and ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which specific export the caller reads. See [Architecture](#architecture) for why that's an accepted trade-off rather than an oversight.
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)).
16
14
 
17
15
  ```sh
18
16
  pnpm add -D @exadev/eslint-config typescript-eslint eslint
19
17
  ```
20
18
 
21
- The default export is the full, type-checked ruleset: typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets, this package's own four rules (self-scoped internally to the barrel -- see [Rules](#rules) -- so no `files`/`ignores` wiring is needed for them), `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, and `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright alongside the preset's own existing `@ts-ignore`/`@ts-nocheck` bans -- the last two relaxed automatically in `*.test.ts`/`*.spec.ts` files (see below). Spread it directly into `tseslint.config(...)`:
19
+ The default export is the full, type-checked ruleset: typescript-eslint's `recommendedTypeChecked` + `stylisticTypeChecked` presets, `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-pointless-reassignment`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, and `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright -- the last two relaxed in test files (see below). Spread it directly into `tseslint.config(...)`:
22
20
 
23
21
  ```ts
24
22
  // eslint.config.ts
@@ -36,13 +34,20 @@ export default tseslint.config(
36
34
  );
37
35
  ```
38
36
 
39
- `recommendedTypeChecked` already subsumes typescript-eslint's own plain `recommended` outright -- every one of its 46 rules is a strict subset of `recommendedTypeChecked`'s 73, confirmed by inspecting the actual rule maps. This is a real bundling, not a rule reference that assumes you already have typescript-eslint registered: `recommendedTypeChecked`'s own base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is exactly why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`stylisticTypeChecked` spreads** rather than keep them alongside this -- ESLint flat config rejects two different plugin object instances registered under the same namespace. What you still supply yourself is `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s); this bundle's base config never sets that, since it's genuinely project-specific.
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:
38
+
39
+ ```ts
40
+ ...exadev,
41
+ { rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
42
+ ```
43
+
44
+ `recommendedTypeChecked` subsumes typescript-eslint's plain `recommended` outright (all 46 of its rules are a subset of `recommendedTypeChecked`'s 73), 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`/`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).
40
45
 
41
- **Test files (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions above, and only those two.** A compile-time-only `@ts-expect-error` proving a construct genuinely fails to type-check is a well-established, legitimate test pattern -- TypeScript's own "unused `@ts-expect-error` directive" diagnostic already catches one that stops being needed, independent of this rule -- so a test file reverts to the rule's own pre-ban default, `allow-with-description`, rather than the outright ban. `@ts-ignore`/`@ts-nocheck` stay banned even in test files: `@ts-expect-error` is strictly better for both, so there's no legitimate test-specific reason to reach for either. `consistent-type-assertions` relaxes to `assertionStyle: 'as'` in test files -- letting a test construct a partial/stub value with a real `as` assertion where the full type wouldn't otherwise accept it -- while the legacy angle-bracket `<Type>value` form stays banned everywhere, tests included. Nothing inherited from `recommendedTypeChecked`/`stylisticTypeChecked` itself is relaxed in test files; only this package's own two additions are.
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.
42
47
 
43
48
  ### The lighter option: the `plugin` named export
44
49
 
45
- For a project that wants only this package's own four rules -- without the full type-checked bundle, e.g. one already running its own separate type-aware setup -- import the named `plugin` export instead and wire the rules individually:
50
+ 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:
46
51
 
47
52
  ```ts
48
53
  // eslint.config.ts
@@ -78,7 +83,7 @@ export default defineConfig([
78
83
  ]);
79
84
  ```
80
85
 
81
- `typescript-eslint`'s own `tseslint.config()` helper (rather than ESLint's `defineConfig()`) does **not** accept the string form of `extends` at all -- it throws `has an 'extends' array that contains a string ... This is a feature of eslint's defineConfig() helper and is not supported by typescript-eslint`. A `tseslint.config()`-based project passes the config value directly instead:
86
+ `tseslint.config()` does **not** accept string `extends` (only `defineConfig()` does); pass the config value directly instead:
82
87
 
83
88
  ```ts
84
89
  import { plugin } from '@exadev/eslint-config';
@@ -94,20 +99,31 @@ export default tseslint.config(
94
99
  );
95
100
  ```
96
101
 
97
- **`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` of their own, and are safe to apply unscoped anyway -- the two rules that care which file they're looking at (`no-side-effects-in-index`, `no-non-barrel-reexport`) each check `context.filename` themselves, the same self-scoping pattern `no-non-barrel-index` already used.** `no-side-effects-in-index` no-ops on every file except `src/index.ts`, since it has no legitimate target anywhere else; `no-non-barrel-reexport` no-ops specifically on `src/index.ts`, since a real single-statement re-export there is the intended, normal shape. An earlier version of this package lacked that self-scoping and genuinely misfired when `recommended`/`barrel` were applied without an external `files: ['src/index.ts']`/`ignores: ['src/index.ts']` wrapper -- 88 false-positive errors on a single real source file in a repo that tried it, since `no-side-effects-in-index` flagged every ordinary `export function`/`export const`/`export interface` declaration it saw. That's fixed at the rule level now, not documented around.
98
-
99
- The one thing self-scoping can't know on your behalf is a barrel that lives somewhere other than `src/index.ts`, or a project-specific exception beyond the barrel (an extra file you want exempt from the re-export ban). For either of those, layer an additional override on top of `recommended`/`barrel` -- e.g. `{ files: ['lib/other-legacy-reexport.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }` -- rather than falling back to wiring all four rules individually, which is still fine but no longer required for the common case.
100
-
101
- `plugin.configs.recommended`/`plugin.configs.barrel` are not usable without `typescript-eslint` installed, even though neither config itself references it: `plugin` is a named export sharing its root module with the default export, so `typescript-eslint` resolves the moment anything is imported from `@exadev/eslint-config` at all -- see [Architecture](#architecture) for the trade-off this reflects.
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
103
 
103
104
  ## Rules
104
105
 
105
106
  | Rule | Fixable | Description |
106
107
  | --- | --- | --- |
107
- | `no-non-barrel-index` | | Only `src/index.ts` may be named `index.*` -- any other module named `index.ts`/`.js`/etc would be silently selected by a consumer's bare directory import. |
108
- | `no-non-barrel-reexport` | | Re-exports belong only in the public barrel. Catches both the single-statement form (`export { x } from './y'`, already caught by a plain `no-restricted-syntax` rule) and the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`), which no AST selector alone can match. The autofix deletes the offending export, and the now-pointless import alongside it whenever that export was the import's only use anywhere in the file. |
108
+ | `barrel-policy` | | Umbrella over the four barrel rules below: one `{ mode }` option selecting a whole index-file policy. See [Barrel policy](#barrel-policy). |
109
+ | `no-index-files` | | Bans any `index.*` file outright (mode 1). The strictest policy. |
110
+ | `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. |
111
+ | `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. |
112
+ | `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. |
113
+ | `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). |
109
114
  | `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. |
110
- | `no-side-effects-in-index` | | The public barrel may contain only re-export statements -- nothing that could execute at import time. |
115
+
116
+ ## Barrel policy
117
+
118
+ `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).
119
+
120
+ | `mode` | Which files may be barrels | What a barrel may contain | Where re-exports may come from |
121
+ | --- | --- | --- | --- |
122
+ | `'banned'` (default/recommended) | none | — | — |
123
+ | `'single'` | exactly `src/index.ts` | only re-exports | anywhere |
124
+ | `'siblings'` | any `index.ts` | only re-exports | a direct sibling only (`./module`) |
125
+
126
+ 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`.
111
127
 
112
128
  ## Build, test, and lint
113
129
 
@@ -119,46 +135,46 @@ pnpm test
119
135
  pnpm build
120
136
  ```
121
137
 
122
- Each rule has a co-located `*.test.ts` file (`src/rules/no-non-barrel-index.test.ts` etc.) exercising it with ESLint's own `RuleTester`, run under Vitest. `vitest.setup.ts` wires `RuleTester.describe`/`RuleTester.it`/`RuleTester.itOnly` to Vitest's own `describe`/`it` explicitly, rather than turning on Vitest's `test.globals` project-wide, since `RuleTester.run()` only calls `describe`/`it` if something has supplied them. Each test file constructs its own `RuleTester` with `languageOptions.parser` set to `typescript-eslint`'s parser, since these rules' realistic test fixtures use TypeScript-only syntax (e.g. `export type { X } from './y'`) that the default `espree` parser can't read; none of the four rules need type information, so no `project`/`tsconfigRootDir` is configured.
138
+ 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.
123
139
 
124
- `pnpm test` always measures coverage (`coverage.enabled: true` in `vitest.config.ts`, via `@vitest/coverage-v8`) rather than needing a separate `--coverage` flag -- scoped to `src/**/*.ts` excluding the `*.test.ts` files themselves. The text reporter summarises in the terminal; the `html`/`lcov` reporters land in `coverage/`, already gitignored alongside `.eslintcache` and `dist/`.
140
+ `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/`).
125
141
 
126
- The `lint`/`typecheck`/`test`/`build` npm scripts are thin wrappers around turbo tasks whose own names carry a leading underscore (`_lint`/`_typecheck`/`_test`/`_build`, declared in `turbo.json`) -- run `pnpm build`, not `turbo run build` directly, since turbo's task names don't match the npm script names.
142
+ The `lint`/`typecheck`/`test`/`build` npm scripts wrap turbo tasks named `_lint`/`_typecheck`/`_test`/`_build` -- run `pnpm build`, not `turbo run build`.
127
143
 
128
- `pnpm build` runs `tsdown` from the single `src/index.ts` entry, bundling the whole module graph (`plugin.ts`, `recommended-type-checked.ts`, and every rule under `src/rules/`) into one ESM output and one CJS output plus declaration files (platform-neutral). Before any publish -- local or the CI alias job -- `prepublishOnly` re-runs lint, typecheck, `test`, `tsdown`, `publint`, and `attw --pack`, so a broken export shape fails at publish time even outside the main CI pipeline.
144
+ `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`.
129
145
 
130
146
  ## Architecture
131
147
 
132
- `src/plugin.ts` builds an `ESLint.Plugin` object (ESLint's own `ESLint.Plugin` type, not a hand-written interface) combining the four rule modules under `src/rules/` into a flat `rules` map. `configs.recommended` and `configs.barrel` are defined as getters directly in the object literal, not attached after construction: each needs to reference the fully-built `plugin` object itself (`plugins: { exadev: plugin }`), which a plain property initializer can't do for its own binding while it's still being constructed. A getter closes over the `plugin` binding rather than its value, so it resolves correctly the moment a consumer actually reads the property, by which point construction has finished -- no `Object.assign`, no post-construction mutation, no null-checked destructure needed.
148
+ `src/plugin.ts` builds an `ESLint.Plugin` (ESLint's own type) combining `src/rules/` into a flat `rules` map. `configs.recommended` and `configs.barrel` 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'`.
133
149
 
134
- `src/recommended-type-checked.ts` bundles typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets alongside this plugin's own rules into a flat config array. Its own value must specifically be typed as an array, not the wider `NonNullable<ESLint.Plugin['configs']>[string]` union (`LegacyConfigObject | ConfigObject | ConfigObject[]`) `plugin.ts`'s own `configs.recommended`/`configs.barrel` correctly use: that union isn't guaranteed to be an array, so annotating an always-array value with it broke `...exadev`, the way every real consumer spreads this default export, with `TS2488: Type '...' must have a '[Symbol.iterator]()' method`. `ConfigArrayValue = Extract<ConfigValue, unknown[]>` narrows to the array-only member of the identical union -- still derived from ESLint's own `Plugin` type (never typescript-eslint's own narrower internal element type, `CompatibleConfig`, which has no `plugins` field), per this codebase's "don't hand-type external libraries" convention.
150
+ `src/recommended-type-checked.ts` bundles typescript-eslint's `recommendedTypeChecked` + `stylisticTypeChecked` alongside this plugin's rules into a flat config array. Its value is typed as `ConfigArrayValue = Extract<ConfigValue, unknown[]>` (the array-only member of ESLint's own config-value union), because annotating with the wider union broke `...exadev` with `TS2488`.
135
151
 
136
- `src/index.ts` is the public entry point: `export { default } from './recommended-type-checked'; export { default as plugin } from './plugin';` -- a genuine pure re-export barrel with a default export and one named export, not just a single re-export. An earlier version of this package kept `recommended-type-checked` as a genuinely separate npm subpath (`@exadev/eslint-config/recommended-type-checked`), specifically so importing the main entry point never resolved `typescript-eslint` at all for a plain-JS consumer. Two module specifiers for one package turned out more awkward in practice than the alternative: `typescript-eslint` is now a required (not optional) peer dependency of the whole package, and both `plugin` and the default export live in the same root module -- ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which specific export the caller reads, so importing `{ plugin }` alone still resolves `typescript-eslint` via the *other* re-export statement in the same file. This is an accepted, deliberate trade-off (see [Getting started](#getting-started)), not something a future fix should try to undo without weighing the same two-entry-point cost that made the earlier split feel worse.
152
+ `src/index.ts` is the entry point: `export { default } from './recommended-type-checked'; export { default as plugin } from './plugin';`. Both 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).
137
153
 
138
- `pnpm-workspace.yaml` deliberately declares an empty `packages: []`. This is not a real multi-package pnpm workspace; its only purpose is giving turbo a workspace root to anchor local task caching against, matching the same single-package-workspace pattern used across this repo family.
154
+ `pnpm-workspace.yaml` declares an empty `packages: []` -- not a real workspace, just giving turbo a root for local task caching.
139
155
 
140
156
  ## Conventions
141
157
 
142
- `eslint.config.ts` dogfoods this package's own default export on itself, importing `./src/index` by relative path rather than as an installed dependency, and spreading it (`...exadevRecommendedTypeChecked`) exactly as a real consumer would -- the live proof that the spread typechecks and behaves correctly against this repo's own `src/index.ts` barrel and `src/plugin.ts` non-barrel module. `no-side-effects-in-index` and `no-non-barrel-reexport` (both bundled in) self-scope to `src/index.ts` internally, so no `files`/`ignores` wiring is needed for them here either. The plugin-construction logic lives in `src/plugin.ts` specifically so `src/index.ts` can stay a pure re-export point both rules assume.
158
+ `eslint.config.ts` dogfoods the default export on itself (`import exadev from './src/index'`), spreading it exactly as a real consumer would. `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.
143
159
 
144
- `tsconfig.json` enables `verbatimModuleSyntax` (type-only imports/exports must use `import type`/`export type` explicitly -- enforced too by the `consistent-type-imports` eslint rule) and `noUncheckedIndexedAccess` (indexed access returns `T | undefined`, narrow before use rather than asserting).
160
+ `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).
145
161
 
146
- Conventional commits are enforced by commitlint, restricted to the type-enum defined once in `release.config.ts`'s `commitTypes` -- both commitlint's allowed types and semantic-release's commit-analyzer release rules derive from that single list, so a commit type can't trigger a release without also being accepted by commit-msg validation, or the reverse.
162
+ 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.
147
163
 
148
164
  ## Gotchas and quirks
149
165
 
150
- - `.attw.json` ignores the `false-export-default` rule: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit the `export =` form `arethetypeswrong`'s check wants under legacy `node10` resolution. The resolution modes an ESLint flat config actually uses (`node16`, `bundler`) are unaffected, so the rule is suppressed rather than moving the plugin away from ESLint's own documented default-export shape.
151
- - `src/index.ts` mixing a default export with a named one (`plugin`) triggers rolldown's own `MIXED_EXPORTS` build warning: Node's *native* `import()` of the built `.cjs` file does not respect the `__esModule` marker TypeScript/bundler interop helpers use, so a raw `require('@exadev/eslint-config').default` differs from what a TS-compiled or bundler-mediated `import exadev from '@exadev/eslint-config'` resolves to. Confirmed empirically (packing the tarball and installing it as a real dependency in a `"type": "module"` project): the actual consumer path -- ESM `import` -- resolves both the default export and `plugin` correctly; only a hypothetical direct-`require()` CommonJS consumer would see the raw exports object instead. No current consumer of this package is CommonJS, and both `attw --pack` and `publint` report no problems, so the warning is accepted (see `tsdown.config.ts`'s own top-of-file comment) rather than restructuring the build for a consumer that doesn't exist.
152
- - Husky hooks: `pre-commit` runs lint-staged (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint against the message, `pre-push` runs `typecheck`, `test`, and `build` -- pushing here re-runs the whole test suite and rebuilds the package first.
153
- - The CI release job sets `HUSKY=0` (so the commit-msg hook never fires against the automated release commit) and blanks `NPM_TOKEN`/`NODE_AUTH_TOKEN` explicitly rather than omitting them, so an inherited token can't win over npm's OIDC trusted-publishing exchange.
166
+ - `.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.
167
+ - `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`).
168
+ - Husky hooks: `pre-commit` runs lint-staged (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint, `pre-push` runs typecheck + test + build.
169
+ - 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.
154
170
 
155
171
  ## Contributing
156
172
 
157
- Conventional commits are enforced by commitlint via 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 only runs on a push to `main`, after all three pass.
173
+ 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.
158
174
 
159
175
  ## Release
160
176
 
161
- Conventional commits drive [semantic-release](https://semantic-release.gitbook.io/semantic-release) on every push to `main`: version bump, `CHANGELOG.md`, GitHub Release, and an npm publish via OIDC trusted publishing (no stored token). A second CI job then republishes the identical build under the unscoped alias `exadev-eslint-config`.
177
+ 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`.
162
178
 
163
179
  ## License
164
180
 
package/dist/index.cjs CHANGED
@@ -26,8 +26,261 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  //#endregion
27
27
  let typescript_eslint = require("typescript-eslint");
28
28
  typescript_eslint = __toESM(typescript_eslint, 1);
29
+ let node_path = require("node:path");
29
30
  //#region package.json
30
- var version = "2.0.0";
31
+ var version = "2.1.1";
32
+ //#endregion
33
+ //#region src/rules/barrel-helpers.ts
34
+ const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
35
+ function basenameOf(filename) {
36
+ const slash = filename.lastIndexOf("/");
37
+ return slash === -1 ? filename : filename.slice(slash + 1);
38
+ }
39
+ function isIndexFile(filename) {
40
+ return INDEX_BASENAME$1.test(basenameOf(filename));
41
+ }
42
+ function isMainBarrel(filename) {
43
+ return filename.endsWith("/src/index.ts");
44
+ }
45
+ function isPureReexport(statement) {
46
+ if (statement.type === "ExportAllDeclaration") return true;
47
+ return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
48
+ }
49
+ function isDirectSibling(specifier) {
50
+ if (!specifier.startsWith("./")) return false;
51
+ let rest = node_path.posix.normalize(specifier.slice(2));
52
+ if (rest.endsWith("/")) rest = rest.slice(0, -1);
53
+ return rest !== "." && rest !== ".." && rest !== "" && !rest.includes("/");
54
+ }
55
+ function isBarrelMode(value) {
56
+ return value === "banned" || value === "single" || value === "siblings";
57
+ }
58
+ function isPermittedBarrel(filename, mode) {
59
+ if (mode === "banned") return false;
60
+ if (mode === "single") return isMainBarrel(filename);
61
+ return isIndexFile(filename);
62
+ }
63
+ function createSplitReexportDetector() {
64
+ const importsByName = /* @__PURE__ */ new Map();
65
+ const bareExportSpecifiers = [];
66
+ const defaultExportDeclarations = [];
67
+ return {
68
+ visitImport(node) {
69
+ for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
70
+ declaration: node,
71
+ specifier
72
+ });
73
+ },
74
+ visitExportNamed(node) {
75
+ if (node.source !== null && node.source !== void 0) return;
76
+ for (const specifier of node.specifiers) bareExportSpecifiers.push({
77
+ declaration: node,
78
+ specifier
79
+ });
80
+ },
81
+ visitExportDefault(node) {
82
+ defaultExportDeclarations.push(node);
83
+ },
84
+ violations() {
85
+ const out = [];
86
+ for (const { declaration, specifier } of bareExportSpecifiers) {
87
+ const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
88
+ if (name === void 0) continue;
89
+ const trackedImport = importsByName.get(name);
90
+ if (trackedImport === void 0) continue;
91
+ out.push({
92
+ kind: "named",
93
+ specifier,
94
+ declaration,
95
+ name,
96
+ trackedImport
97
+ });
98
+ }
99
+ for (const declarationNode of defaultExportDeclarations) {
100
+ const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
101
+ if (name === void 0) continue;
102
+ const trackedImport = importsByName.get(name);
103
+ if (trackedImport === void 0) continue;
104
+ out.push({
105
+ kind: "default",
106
+ declaration: declarationNode,
107
+ name,
108
+ trackedImport
109
+ });
110
+ }
111
+ return out;
112
+ }
113
+ };
114
+ }
115
+ //#endregion
116
+ //#region src/rules/barrel-direct-siblings-only.ts
117
+ const barrelDirectSiblingsOnly = {
118
+ meta: {
119
+ type: "problem",
120
+ schema: [],
121
+ messages: { 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." }
122
+ },
123
+ create(context) {
124
+ if (!isIndexFile(context.filename)) return {};
125
+ return {
126
+ ExportNamedDeclaration(node) {
127
+ if (node.source === null || node.source === void 0) return;
128
+ const source = node.source.value;
129
+ if (typeof source !== "string") return;
130
+ if (!isDirectSibling(source)) context.report({
131
+ node,
132
+ messageId: "notADirectSibling",
133
+ data: { source }
134
+ });
135
+ },
136
+ ExportAllDeclaration(node) {
137
+ const source = node.source.value;
138
+ if (typeof source !== "string") return;
139
+ if (!isDirectSibling(source)) context.report({
140
+ node,
141
+ messageId: "notADirectSibling",
142
+ data: { source }
143
+ });
144
+ }
145
+ };
146
+ }
147
+ };
148
+ //#endregion
149
+ //#region src/rules/barrel-policy.ts
150
+ function readMode(options) {
151
+ if (options === void 0 || typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
152
+ return options.mode;
153
+ }
154
+ const barrelPolicy = {
155
+ meta: {
156
+ type: "problem",
157
+ schema: [{
158
+ type: "object",
159
+ properties: { mode: {
160
+ type: "string",
161
+ enum: [
162
+ "banned",
163
+ "single",
164
+ "siblings"
165
+ ]
166
+ } },
167
+ required: ["mode"],
168
+ additionalProperties: false
169
+ }],
170
+ messages: {
171
+ 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.",
172
+ 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.",
173
+ 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 }}.",
174
+ 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.",
175
+ 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."
176
+ }
177
+ },
178
+ create(context) {
179
+ const mode = readMode(context.options[0]);
180
+ const filename = context.filename;
181
+ const detector = createSplitReexportDetector();
182
+ function hasSource(node) {
183
+ return node.source !== null && node.source !== void 0;
184
+ }
185
+ return {
186
+ Program(node) {
187
+ if (mode === "banned") {
188
+ if (isIndexFile(filename)) context.report({
189
+ node,
190
+ messageId: "indexFileBanned"
191
+ });
192
+ return;
193
+ }
194
+ if (mode === "single") {
195
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
196
+ context.report({
197
+ node,
198
+ messageId: "nonMainIndexFile"
199
+ });
200
+ return;
201
+ }
202
+ if (isMainBarrel(filename)) {
203
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
204
+ node: statement,
205
+ messageId: "sideEffectInBarrel",
206
+ data: { description: statement.type }
207
+ });
208
+ }
209
+ return;
210
+ }
211
+ if (isIndexFile(filename)) {
212
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
213
+ node: statement,
214
+ messageId: "sideEffectInBarrel",
215
+ data: { description: statement.type }
216
+ });
217
+ }
218
+ },
219
+ ImportDeclaration: (node) => detector.visitImport(node),
220
+ ExportNamedDeclaration(node) {
221
+ detector.visitExportNamed(node);
222
+ if (hasSource(node)) {
223
+ const source = node.source === null || node.source === void 0 ? void 0 : node.source.value;
224
+ if (!isPermittedBarrel(filename, mode)) context.report({
225
+ node,
226
+ messageId: "reexportOutsideBarrel"
227
+ });
228
+ else if (mode === "siblings" && typeof source === "string" && !isDirectSibling(source)) context.report({
229
+ node,
230
+ messageId: "notADirectSibling",
231
+ data: { source }
232
+ });
233
+ }
234
+ },
235
+ ExportAllDeclaration(node) {
236
+ const source = node.source.value;
237
+ if (!isPermittedBarrel(filename, mode)) context.report({
238
+ node,
239
+ messageId: "reexportOutsideBarrel"
240
+ });
241
+ else if (mode === "siblings" && typeof source === "string" && !isDirectSibling(source)) context.report({
242
+ node,
243
+ messageId: "notADirectSibling",
244
+ data: { source }
245
+ });
246
+ },
247
+ ExportDefaultDeclaration: (node) => detector.visitExportDefault(node),
248
+ "Program:exit"() {
249
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
250
+ if (mode === "siblings") {
251
+ const importSource = violation.trackedImport.declaration.source.value;
252
+ if (typeof importSource === "string" && !isDirectSibling(importSource)) context.report({
253
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
254
+ messageId: "notADirectSibling",
255
+ data: { source: importSource }
256
+ });
257
+ }
258
+ } else context.report({
259
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
260
+ messageId: "reexportOutsideBarrel"
261
+ });
262
+ }
263
+ };
264
+ }
265
+ };
266
+ //#endregion
267
+ //#region src/rules/no-index-files.ts
268
+ const noIndexFiles = {
269
+ meta: {
270
+ type: "problem",
271
+ schema: [],
272
+ messages: { indexFileBanned: "Index (barrel) files are banned -- import directly from the module that owns the export instead. Rename this file to something descriptive." }
273
+ },
274
+ create(context) {
275
+ if (!isIndexFile(context.filename)) return {};
276
+ return { Program(node) {
277
+ context.report({
278
+ node,
279
+ messageId: "indexFileBanned"
280
+ });
281
+ } };
282
+ }
283
+ };
31
284
  //#endregion
32
285
  //#region src/rules/no-non-barrel-index.ts
33
286
  const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
@@ -75,39 +328,21 @@ const noNonBarrelReexport = {
75
328
  fixable: "code",
76
329
  schema: [],
77
330
  messages: {
78
- splitStatementReexport: "'{{ name }}' is imported here and handed straight back out via a bare export -- the identical re-export 'export { {{ name }} } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel).",
79
- splitStatementDefaultReexport: "'{{ name }}' is imported here and handed straight back out via `export default` -- the identical re-export 'export { {{ name }} as default } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel)."
331
+ splitStatementReexport: "'{{ name }}' is imported here and handed straight back out via a bare export -- the identical re-export 'export { {{ name }} } from ...' would be, just split across two statements. Re-exports belong only in the public barrel.",
332
+ splitStatementDefaultReexport: "'{{ name }}' is imported here and handed straight back out via `export default` -- the identical re-export 'export { {{ name }} as default } from ...' would be, just split across two statements. Re-exports belong only in the public barrel."
80
333
  }
81
334
  },
82
335
  create(context) {
83
- if (context.filename.endsWith("/src/index.ts")) return {};
84
- const importsByName = /* @__PURE__ */ new Map();
85
- const bareExportSpecifiers = [];
86
- const defaultExportDeclarations = [];
336
+ if (isIndexFile(context.filename)) return {};
337
+ const detector = createSplitReexportDetector();
87
338
  return {
88
- ImportDeclaration(node) {
89
- for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
90
- declaration: node,
91
- specifier
92
- });
93
- },
94
- ExportNamedDeclaration(node) {
95
- if (node.source !== null && node.source !== void 0) return;
96
- for (const specifier of node.specifiers) bareExportSpecifiers.push({
97
- declaration: node,
98
- specifier
99
- });
100
- },
101
- ExportDefaultDeclaration(node) {
102
- defaultExportDeclarations.push(node);
103
- },
339
+ ImportDeclaration: (node) => detector.visitImport(node),
340
+ ExportNamedDeclaration: (node) => detector.visitExportNamed(node),
341
+ ExportDefaultDeclaration: (node) => detector.visitExportDefault(node),
104
342
  "Program:exit"() {
105
343
  const { sourceCode } = context;
106
- for (const { declaration, specifier } of bareExportSpecifiers) {
107
- const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
108
- if (name === void 0) continue;
109
- const trackedImport = importsByName.get(name);
110
- if (trackedImport === void 0) continue;
344
+ for (const violation of detector.violations()) if (violation.kind === "named") {
345
+ const { specifier, declaration, name, trackedImport } = violation;
111
346
  context.report({
112
347
  node: specifier,
113
348
  messageId: "splitStatementReexport",
@@ -118,19 +353,15 @@ const noNonBarrelReexport = {
118
353
  return fixes;
119
354
  }
120
355
  });
121
- }
122
- for (const declarationNode of defaultExportDeclarations) {
123
- const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
124
- if (name === void 0) continue;
125
- const trackedImport = importsByName.get(name);
126
- if (trackedImport === void 0) continue;
356
+ } else {
357
+ const { declaration, name, trackedImport } = violation;
127
358
  context.report({
128
- node: declarationNode,
359
+ node: declaration,
129
360
  messageId: "splitStatementDefaultReexport",
130
361
  data: { name },
131
362
  fix(fixer) {
132
- const fixes = [fixer.remove(declarationNode)];
133
- if (declarationNode.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declarationNode.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
363
+ const fixes = [fixer.remove(declaration)];
364
+ if (declaration.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declaration.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
134
365
  return fixes;
135
366
  }
136
367
  });
@@ -144,63 +375,6 @@ const noNonBarrelReexport = {
144
375
  function isIdentifierReference(reference) {
145
376
  return reference.identifier.type === "Identifier";
146
377
  }
147
- const noPointlessReassignment = {
148
- meta: {
149
- type: "problem",
150
- fixable: "code",
151
- schema: [],
152
- messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
153
- },
154
- create(context) {
155
- return { VariableDeclarator(node) {
156
- if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
157
- if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
158
- const scope = context.sourceCode.getScope(node);
159
- const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
160
- if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
161
- const aliasName = node.id.name;
162
- const originalName = node.init.name;
163
- context.report({
164
- node,
165
- messageId: "pointlessReassignment",
166
- data: {
167
- name: aliasName,
168
- value: originalName
169
- },
170
- fix(fixer) {
171
- const variable = scope.set.get(aliasName);
172
- if (!variable) return null;
173
- if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
174
- const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
175
- if (readRefs.some((reference) => {
176
- const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
177
- if (afterToken?.value === ":") return false;
178
- if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
179
- let token = context.sourceCode.getTokenBefore(reference.identifier);
180
- while (token) {
181
- if (token.value === "{") return true;
182
- if (token.value === "[" || token.value === "(") return false;
183
- if (token.value === ":") return false;
184
- token = context.sourceCode.getTokenBefore(token);
185
- }
186
- return false;
187
- })) return null;
188
- const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
189
- const declaration = node.parent;
190
- if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
191
- fixes.push(fixer.remove(declaration));
192
- return fixes;
193
- }
194
- });
195
- } };
196
- }
197
- };
198
- //#endregion
199
- //#region src/rules/no-side-effects-in-index.ts
200
- function isPureReexport(statement) {
201
- if (statement.type === "ExportAllDeclaration") return true;
202
- return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
203
- }
204
378
  //#endregion
205
379
  //#region src/plugin.ts
206
380
  const plugin = {
@@ -210,17 +384,70 @@ const plugin = {
210
384
  namespace: "exadev"
211
385
  },
212
386
  rules: {
387
+ "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
388
+ "barrel-policy": barrelPolicy,
389
+ "no-index-files": noIndexFiles,
213
390
  "no-non-barrel-index": noNonBarrelIndex,
214
391
  "no-non-barrel-reexport": noNonBarrelReexport,
215
- "no-pointless-reassignment": noPointlessReassignment,
392
+ "no-pointless-reassignment": {
393
+ meta: {
394
+ type: "problem",
395
+ fixable: "code",
396
+ schema: [],
397
+ messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
398
+ },
399
+ create(context) {
400
+ return { VariableDeclarator(node) {
401
+ if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
402
+ if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
403
+ const scope = context.sourceCode.getScope(node);
404
+ const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
405
+ if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
406
+ const aliasName = node.id.name;
407
+ const originalName = node.init.name;
408
+ context.report({
409
+ node,
410
+ messageId: "pointlessReassignment",
411
+ data: {
412
+ name: aliasName,
413
+ value: originalName
414
+ },
415
+ fix(fixer) {
416
+ const variable = scope.set.get(aliasName);
417
+ if (!variable) return null;
418
+ if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
419
+ const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
420
+ if (readRefs.some((reference) => {
421
+ const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
422
+ if (afterToken?.value === ":") return false;
423
+ if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
424
+ let token = context.sourceCode.getTokenBefore(reference.identifier);
425
+ while (token) {
426
+ if (token.value === "{") return true;
427
+ if (token.value === "[" || token.value === "(") return false;
428
+ if (token.value === ":") return false;
429
+ token = context.sourceCode.getTokenBefore(token);
430
+ }
431
+ return false;
432
+ })) return null;
433
+ const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
434
+ const declaration = node.parent;
435
+ if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
436
+ fixes.push(fixer.remove(declaration));
437
+ return fixes;
438
+ }
439
+ });
440
+ } };
441
+ }
442
+ },
216
443
  "no-side-effects-in-index": {
217
444
  meta: {
218
445
  type: "problem",
219
446
  schema: [],
220
- messages: { notAPureReexport: "The public barrel (src/index.ts) 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 }}." }
447
+ messages: { notAPureReexport: "A barrel (index) file 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 }}." }
221
448
  },
222
449
  create(context) {
223
- if (!context.filename.endsWith("/src/index.ts")) return {};
450
+ if (!isIndexFile(context.filename)) return {};
224
451
  return { Program(node) {
225
452
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
226
453
  node: statement,
@@ -237,21 +464,15 @@ const plugin = {
237
464
  plugins: { exadev: plugin },
238
465
  linterOptions: { noInlineConfig: true },
239
466
  rules: {
240
- "exadev/no-non-barrel-index": "error",
241
- "exadev/no-non-barrel-reexport": "error",
242
- "exadev/no-pointless-reassignment": "error",
243
- "exadev/no-side-effects-in-index": "error"
467
+ "exadev/barrel-policy": ["error", { mode: "banned" }],
468
+ "exadev/no-pointless-reassignment": "error"
244
469
  }
245
470
  };
246
471
  },
247
472
  get barrel() {
248
473
  return {
249
474
  plugins: { exadev: plugin },
250
- rules: {
251
- "exadev/no-non-barrel-index": "error",
252
- "exadev/no-non-barrel-reexport": "error",
253
- "exadev/no-side-effects-in-index": "error"
254
- }
475
+ rules: { "exadev/barrel-policy": ["error", { mode: "single" }] }
255
476
  };
256
477
  }
257
478
  }
@@ -266,10 +487,8 @@ const recommendedTypeChecked = [
266
487
  plugins: { exadev: plugin },
267
488
  linterOptions: { noInlineConfig: true },
268
489
  rules: {
269
- "exadev/no-non-barrel-index": "error",
270
- "exadev/no-non-barrel-reexport": "error",
490
+ "exadev/barrel-policy": ["error", { mode: "banned" }],
271
491
  "exadev/no-pointless-reassignment": "error",
272
- "exadev/no-side-effects-in-index": "error",
273
492
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
274
493
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }]
275
494
  }
package/dist/index.js CHANGED
@@ -1,6 +1,259 @@
1
1
  import tseslint from "typescript-eslint";
2
+ import { posix } from "node:path";
2
3
  //#region package.json
3
- var version = "2.0.0";
4
+ var version = "2.1.1";
5
+ //#endregion
6
+ //#region src/rules/barrel-helpers.ts
7
+ const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
8
+ function basenameOf(filename) {
9
+ const slash = filename.lastIndexOf("/");
10
+ return slash === -1 ? filename : filename.slice(slash + 1);
11
+ }
12
+ function isIndexFile(filename) {
13
+ return INDEX_BASENAME$1.test(basenameOf(filename));
14
+ }
15
+ function isMainBarrel(filename) {
16
+ return filename.endsWith("/src/index.ts");
17
+ }
18
+ function isPureReexport(statement) {
19
+ if (statement.type === "ExportAllDeclaration") return true;
20
+ return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
21
+ }
22
+ function isDirectSibling(specifier) {
23
+ if (!specifier.startsWith("./")) return false;
24
+ let rest = posix.normalize(specifier.slice(2));
25
+ if (rest.endsWith("/")) rest = rest.slice(0, -1);
26
+ return rest !== "." && rest !== ".." && rest !== "" && !rest.includes("/");
27
+ }
28
+ function isBarrelMode(value) {
29
+ return value === "banned" || value === "single" || value === "siblings";
30
+ }
31
+ function isPermittedBarrel(filename, mode) {
32
+ if (mode === "banned") return false;
33
+ if (mode === "single") return isMainBarrel(filename);
34
+ return isIndexFile(filename);
35
+ }
36
+ function createSplitReexportDetector() {
37
+ const importsByName = /* @__PURE__ */ new Map();
38
+ const bareExportSpecifiers = [];
39
+ const defaultExportDeclarations = [];
40
+ return {
41
+ visitImport(node) {
42
+ for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
43
+ declaration: node,
44
+ specifier
45
+ });
46
+ },
47
+ visitExportNamed(node) {
48
+ if (node.source !== null && node.source !== void 0) return;
49
+ for (const specifier of node.specifiers) bareExportSpecifiers.push({
50
+ declaration: node,
51
+ specifier
52
+ });
53
+ },
54
+ visitExportDefault(node) {
55
+ defaultExportDeclarations.push(node);
56
+ },
57
+ violations() {
58
+ const out = [];
59
+ for (const { declaration, specifier } of bareExportSpecifiers) {
60
+ const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
61
+ if (name === void 0) continue;
62
+ const trackedImport = importsByName.get(name);
63
+ if (trackedImport === void 0) continue;
64
+ out.push({
65
+ kind: "named",
66
+ specifier,
67
+ declaration,
68
+ name,
69
+ trackedImport
70
+ });
71
+ }
72
+ for (const declarationNode of defaultExportDeclarations) {
73
+ const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
74
+ if (name === void 0) continue;
75
+ const trackedImport = importsByName.get(name);
76
+ if (trackedImport === void 0) continue;
77
+ out.push({
78
+ kind: "default",
79
+ declaration: declarationNode,
80
+ name,
81
+ trackedImport
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/rules/barrel-direct-siblings-only.ts
90
+ const barrelDirectSiblingsOnly = {
91
+ meta: {
92
+ type: "problem",
93
+ schema: [],
94
+ messages: { 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." }
95
+ },
96
+ create(context) {
97
+ if (!isIndexFile(context.filename)) return {};
98
+ return {
99
+ ExportNamedDeclaration(node) {
100
+ if (node.source === null || node.source === void 0) return;
101
+ const source = node.source.value;
102
+ if (typeof source !== "string") return;
103
+ if (!isDirectSibling(source)) context.report({
104
+ node,
105
+ messageId: "notADirectSibling",
106
+ data: { source }
107
+ });
108
+ },
109
+ ExportAllDeclaration(node) {
110
+ const source = node.source.value;
111
+ if (typeof source !== "string") return;
112
+ if (!isDirectSibling(source)) context.report({
113
+ node,
114
+ messageId: "notADirectSibling",
115
+ data: { source }
116
+ });
117
+ }
118
+ };
119
+ }
120
+ };
121
+ //#endregion
122
+ //#region src/rules/barrel-policy.ts
123
+ function readMode(options) {
124
+ if (options === void 0 || typeof options !== "object" || options === null || !("mode" in options) || !isBarrelMode(options.mode)) throw new Error("exadev/barrel-policy requires options: { mode: 'banned' | 'single' | 'siblings' }.");
125
+ return options.mode;
126
+ }
127
+ const barrelPolicy = {
128
+ meta: {
129
+ type: "problem",
130
+ schema: [{
131
+ type: "object",
132
+ properties: { mode: {
133
+ type: "string",
134
+ enum: [
135
+ "banned",
136
+ "single",
137
+ "siblings"
138
+ ]
139
+ } },
140
+ required: ["mode"],
141
+ additionalProperties: false
142
+ }],
143
+ messages: {
144
+ 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.",
145
+ 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.",
146
+ 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 }}.",
147
+ 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.",
148
+ 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."
149
+ }
150
+ },
151
+ create(context) {
152
+ const mode = readMode(context.options[0]);
153
+ const filename = context.filename;
154
+ const detector = createSplitReexportDetector();
155
+ function hasSource(node) {
156
+ return node.source !== null && node.source !== void 0;
157
+ }
158
+ return {
159
+ Program(node) {
160
+ if (mode === "banned") {
161
+ if (isIndexFile(filename)) context.report({
162
+ node,
163
+ messageId: "indexFileBanned"
164
+ });
165
+ return;
166
+ }
167
+ if (mode === "single") {
168
+ if (isIndexFile(filename) && !isMainBarrel(filename)) {
169
+ context.report({
170
+ node,
171
+ messageId: "nonMainIndexFile"
172
+ });
173
+ return;
174
+ }
175
+ if (isMainBarrel(filename)) {
176
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
177
+ node: statement,
178
+ messageId: "sideEffectInBarrel",
179
+ data: { description: statement.type }
180
+ });
181
+ }
182
+ return;
183
+ }
184
+ if (isIndexFile(filename)) {
185
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
186
+ node: statement,
187
+ messageId: "sideEffectInBarrel",
188
+ data: { description: statement.type }
189
+ });
190
+ }
191
+ },
192
+ ImportDeclaration: (node) => detector.visitImport(node),
193
+ ExportNamedDeclaration(node) {
194
+ detector.visitExportNamed(node);
195
+ if (hasSource(node)) {
196
+ const source = node.source === null || node.source === void 0 ? void 0 : node.source.value;
197
+ if (!isPermittedBarrel(filename, mode)) context.report({
198
+ node,
199
+ messageId: "reexportOutsideBarrel"
200
+ });
201
+ else if (mode === "siblings" && typeof source === "string" && !isDirectSibling(source)) context.report({
202
+ node,
203
+ messageId: "notADirectSibling",
204
+ data: { source }
205
+ });
206
+ }
207
+ },
208
+ ExportAllDeclaration(node) {
209
+ const source = node.source.value;
210
+ if (!isPermittedBarrel(filename, mode)) context.report({
211
+ node,
212
+ messageId: "reexportOutsideBarrel"
213
+ });
214
+ else if (mode === "siblings" && typeof source === "string" && !isDirectSibling(source)) context.report({
215
+ node,
216
+ messageId: "notADirectSibling",
217
+ data: { source }
218
+ });
219
+ },
220
+ ExportDefaultDeclaration: (node) => detector.visitExportDefault(node),
221
+ "Program:exit"() {
222
+ for (const violation of detector.violations()) if (isPermittedBarrel(filename, mode)) {
223
+ if (mode === "siblings") {
224
+ const importSource = violation.trackedImport.declaration.source.value;
225
+ if (typeof importSource === "string" && !isDirectSibling(importSource)) context.report({
226
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
227
+ messageId: "notADirectSibling",
228
+ data: { source: importSource }
229
+ });
230
+ }
231
+ } else context.report({
232
+ node: violation.kind === "named" ? violation.specifier : violation.declaration,
233
+ messageId: "reexportOutsideBarrel"
234
+ });
235
+ }
236
+ };
237
+ }
238
+ };
239
+ //#endregion
240
+ //#region src/rules/no-index-files.ts
241
+ const noIndexFiles = {
242
+ meta: {
243
+ type: "problem",
244
+ schema: [],
245
+ messages: { indexFileBanned: "Index (barrel) files are banned -- import directly from the module that owns the export instead. Rename this file to something descriptive." }
246
+ },
247
+ create(context) {
248
+ if (!isIndexFile(context.filename)) return {};
249
+ return { Program(node) {
250
+ context.report({
251
+ node,
252
+ messageId: "indexFileBanned"
253
+ });
254
+ } };
255
+ }
256
+ };
4
257
  //#endregion
5
258
  //#region src/rules/no-non-barrel-index.ts
6
259
  const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
@@ -48,39 +301,21 @@ const noNonBarrelReexport = {
48
301
  fixable: "code",
49
302
  schema: [],
50
303
  messages: {
51
- splitStatementReexport: "'{{ name }}' is imported here and handed straight back out via a bare export -- the identical re-export 'export { {{ name }} } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel).",
52
- splitStatementDefaultReexport: "'{{ name }}' is imported here and handed straight back out via `export default` -- the identical re-export 'export { {{ name }} as default } from ...' would be, just split across two statements. Re-exports belong only in src/index.ts (the public barrel)."
304
+ splitStatementReexport: "'{{ name }}' is imported here and handed straight back out via a bare export -- the identical re-export 'export { {{ name }} } from ...' would be, just split across two statements. Re-exports belong only in the public barrel.",
305
+ splitStatementDefaultReexport: "'{{ name }}' is imported here and handed straight back out via `export default` -- the identical re-export 'export { {{ name }} as default } from ...' would be, just split across two statements. Re-exports belong only in the public barrel."
53
306
  }
54
307
  },
55
308
  create(context) {
56
- if (context.filename.endsWith("/src/index.ts")) return {};
57
- const importsByName = /* @__PURE__ */ new Map();
58
- const bareExportSpecifiers = [];
59
- const defaultExportDeclarations = [];
309
+ if (isIndexFile(context.filename)) return {};
310
+ const detector = createSplitReexportDetector();
60
311
  return {
61
- ImportDeclaration(node) {
62
- for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
63
- declaration: node,
64
- specifier
65
- });
66
- },
67
- ExportNamedDeclaration(node) {
68
- if (node.source !== null && node.source !== void 0) return;
69
- for (const specifier of node.specifiers) bareExportSpecifiers.push({
70
- declaration: node,
71
- specifier
72
- });
73
- },
74
- ExportDefaultDeclaration(node) {
75
- defaultExportDeclarations.push(node);
76
- },
312
+ ImportDeclaration: (node) => detector.visitImport(node),
313
+ ExportNamedDeclaration: (node) => detector.visitExportNamed(node),
314
+ ExportDefaultDeclaration: (node) => detector.visitExportDefault(node),
77
315
  "Program:exit"() {
78
316
  const { sourceCode } = context;
79
- for (const { declaration, specifier } of bareExportSpecifiers) {
80
- const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
81
- if (name === void 0) continue;
82
- const trackedImport = importsByName.get(name);
83
- if (trackedImport === void 0) continue;
317
+ for (const violation of detector.violations()) if (violation.kind === "named") {
318
+ const { specifier, declaration, name, trackedImport } = violation;
84
319
  context.report({
85
320
  node: specifier,
86
321
  messageId: "splitStatementReexport",
@@ -91,19 +326,15 @@ const noNonBarrelReexport = {
91
326
  return fixes;
92
327
  }
93
328
  });
94
- }
95
- for (const declarationNode of defaultExportDeclarations) {
96
- const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
97
- if (name === void 0) continue;
98
- const trackedImport = importsByName.get(name);
99
- if (trackedImport === void 0) continue;
329
+ } else {
330
+ const { declaration, name, trackedImport } = violation;
100
331
  context.report({
101
- node: declarationNode,
332
+ node: declaration,
102
333
  messageId: "splitStatementDefaultReexport",
103
334
  data: { name },
104
335
  fix(fixer) {
105
- const fixes = [fixer.remove(declarationNode)];
106
- if (declarationNode.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declarationNode.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
336
+ const fixes = [fixer.remove(declaration)];
337
+ if (declaration.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declaration.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
107
338
  return fixes;
108
339
  }
109
340
  });
@@ -117,63 +348,6 @@ const noNonBarrelReexport = {
117
348
  function isIdentifierReference(reference) {
118
349
  return reference.identifier.type === "Identifier";
119
350
  }
120
- const noPointlessReassignment = {
121
- meta: {
122
- type: "problem",
123
- fixable: "code",
124
- schema: [],
125
- messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
126
- },
127
- create(context) {
128
- return { VariableDeclarator(node) {
129
- if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
130
- if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
131
- const scope = context.sourceCode.getScope(node);
132
- const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
133
- if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
134
- const aliasName = node.id.name;
135
- const originalName = node.init.name;
136
- context.report({
137
- node,
138
- messageId: "pointlessReassignment",
139
- data: {
140
- name: aliasName,
141
- value: originalName
142
- },
143
- fix(fixer) {
144
- const variable = scope.set.get(aliasName);
145
- if (!variable) return null;
146
- if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
147
- const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
148
- if (readRefs.some((reference) => {
149
- const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
150
- if (afterToken?.value === ":") return false;
151
- if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
152
- let token = context.sourceCode.getTokenBefore(reference.identifier);
153
- while (token) {
154
- if (token.value === "{") return true;
155
- if (token.value === "[" || token.value === "(") return false;
156
- if (token.value === ":") return false;
157
- token = context.sourceCode.getTokenBefore(token);
158
- }
159
- return false;
160
- })) return null;
161
- const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
162
- const declaration = node.parent;
163
- if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
164
- fixes.push(fixer.remove(declaration));
165
- return fixes;
166
- }
167
- });
168
- } };
169
- }
170
- };
171
- //#endregion
172
- //#region src/rules/no-side-effects-in-index.ts
173
- function isPureReexport(statement) {
174
- if (statement.type === "ExportAllDeclaration") return true;
175
- return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
176
- }
177
351
  //#endregion
178
352
  //#region src/plugin.ts
179
353
  const plugin = {
@@ -183,17 +357,70 @@ const plugin = {
183
357
  namespace: "exadev"
184
358
  },
185
359
  rules: {
360
+ "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
361
+ "barrel-policy": barrelPolicy,
362
+ "no-index-files": noIndexFiles,
186
363
  "no-non-barrel-index": noNonBarrelIndex,
187
364
  "no-non-barrel-reexport": noNonBarrelReexport,
188
- "no-pointless-reassignment": noPointlessReassignment,
365
+ "no-pointless-reassignment": {
366
+ meta: {
367
+ type: "problem",
368
+ fixable: "code",
369
+ schema: [],
370
+ messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
371
+ },
372
+ create(context) {
373
+ return { VariableDeclarator(node) {
374
+ if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
375
+ if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
376
+ const scope = context.sourceCode.getScope(node);
377
+ const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
378
+ if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
379
+ const aliasName = node.id.name;
380
+ const originalName = node.init.name;
381
+ context.report({
382
+ node,
383
+ messageId: "pointlessReassignment",
384
+ data: {
385
+ name: aliasName,
386
+ value: originalName
387
+ },
388
+ fix(fixer) {
389
+ const variable = scope.set.get(aliasName);
390
+ if (!variable) return null;
391
+ if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
392
+ const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
393
+ if (readRefs.some((reference) => {
394
+ const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
395
+ if (afterToken?.value === ":") return false;
396
+ if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
397
+ let token = context.sourceCode.getTokenBefore(reference.identifier);
398
+ while (token) {
399
+ if (token.value === "{") return true;
400
+ if (token.value === "[" || token.value === "(") return false;
401
+ if (token.value === ":") return false;
402
+ token = context.sourceCode.getTokenBefore(token);
403
+ }
404
+ return false;
405
+ })) return null;
406
+ const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
407
+ const declaration = node.parent;
408
+ if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
409
+ fixes.push(fixer.remove(declaration));
410
+ return fixes;
411
+ }
412
+ });
413
+ } };
414
+ }
415
+ },
189
416
  "no-side-effects-in-index": {
190
417
  meta: {
191
418
  type: "problem",
192
419
  schema: [],
193
- messages: { notAPureReexport: "The public barrel (src/index.ts) 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 }}." }
420
+ messages: { notAPureReexport: "A barrel (index) file 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 }}." }
194
421
  },
195
422
  create(context) {
196
- if (!context.filename.endsWith("/src/index.ts")) return {};
423
+ if (!isIndexFile(context.filename)) return {};
197
424
  return { Program(node) {
198
425
  for (const statement of node.body) if (!isPureReexport(statement)) context.report({
199
426
  node: statement,
@@ -210,21 +437,15 @@ const plugin = {
210
437
  plugins: { exadev: plugin },
211
438
  linterOptions: { noInlineConfig: true },
212
439
  rules: {
213
- "exadev/no-non-barrel-index": "error",
214
- "exadev/no-non-barrel-reexport": "error",
215
- "exadev/no-pointless-reassignment": "error",
216
- "exadev/no-side-effects-in-index": "error"
440
+ "exadev/barrel-policy": ["error", { mode: "banned" }],
441
+ "exadev/no-pointless-reassignment": "error"
217
442
  }
218
443
  };
219
444
  },
220
445
  get barrel() {
221
446
  return {
222
447
  plugins: { exadev: plugin },
223
- rules: {
224
- "exadev/no-non-barrel-index": "error",
225
- "exadev/no-non-barrel-reexport": "error",
226
- "exadev/no-side-effects-in-index": "error"
227
- }
448
+ rules: { "exadev/barrel-policy": ["error", { mode: "single" }] }
228
449
  };
229
450
  }
230
451
  }
@@ -239,10 +460,8 @@ const recommendedTypeChecked = [
239
460
  plugins: { exadev: plugin },
240
461
  linterOptions: { noInlineConfig: true },
241
462
  rules: {
242
- "exadev/no-non-barrel-index": "error",
243
- "exadev/no-non-barrel-reexport": "error",
463
+ "exadev/barrel-policy": ["error", { mode: "banned" }],
244
464
  "exadev/no-pointless-reassignment": "error",
245
- "exadev/no-side-effects-in-index": "error",
246
465
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
247
466
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }]
248
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exadev/eslint-config",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
5
5
  "type": "module",
6
6
  "sideEffects": false,