exadev-eslint-config 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -6
- package/dist/index.cjs +328 -109
- package/dist/index.js +328 -109
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as peer depende
|
|
|
18
18
|
pnpm add -D @exadev/eslint-config typescript-eslint eslint
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The default export is the full, type-checked ruleset: typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets,
|
|
21
|
+
The default export is the full, type-checked ruleset: typescript-eslint's own `recommendedTypeChecked` + `stylisticTypeChecked` presets, the `exadev/barrel-policy` umbrella rule at its recommended `mode: 'banned'` (no index files at all -- 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 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(...)`:
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
24
|
// eslint.config.ts
|
|
@@ -36,13 +36,22 @@ export default tseslint.config(
|
|
|
36
36
|
);
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
**A published package whose `src/index.ts` is its package entry point overrides the default `banned` policy to `single` in one line** (flat-config later blocks override earlier rule settings), since deleting its barrel would break every downstream importer:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
...exadev,
|
|
43
|
+
{ rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
39
48
|
`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.
|
|
40
49
|
|
|
41
50
|
**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.
|
|
42
51
|
|
|
43
52
|
### The lighter option: the `plugin` named export
|
|
44
53
|
|
|
45
|
-
For a project that wants only this package's own
|
|
54
|
+
For a project that wants only this package's own 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:
|
|
46
55
|
|
|
47
56
|
```ts
|
|
48
57
|
// eslint.config.ts
|
|
@@ -104,10 +113,25 @@ The one thing self-scoping can't know on your behalf is a barrel that lives some
|
|
|
104
113
|
|
|
105
114
|
| Rule | Fixable | Description |
|
|
106
115
|
| --- | --- | --- |
|
|
116
|
+
| `barrel-policy` | | The umbrella rule over the four barrel rules below: one `{ mode }` option selecting a whole index-file policy. See [Barrel policy](#barrel-policy). |
|
|
117
|
+
| `no-index-files` | | Bans any `index.*` file outright (mode 1). The strictest policy. |
|
|
107
118
|
| `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
|
|
119
|
+
| `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. 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. Self-scopes away from any index file (not just `src/index.ts`). |
|
|
120
|
+
| `no-side-effects-in-index` | | A barrel (index) file may contain only re-export statements -- nothing that could execute at import time. Self-scopes to any index file. |
|
|
121
|
+
| `barrel-direct-siblings-only` | | A barrel may re-export only from a direct sibling file or folder (`./module`), never a nested path, a parent, or a bare package specifier (mode 3). |
|
|
109
122
|
| `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. |
|
|
110
|
-
|
|
123
|
+
|
|
124
|
+
## Barrel policy
|
|
125
|
+
|
|
126
|
+
`exadev/barrel-policy` is the convenience layer over the four granular barrel rules: one rule id, one `{ mode }` option selecting one of three complete index-file policies, so a consumer writes a single config entry instead of wiring several rules together. A consumer uses EITHER this umbrella (one line, opinionated) OR the individual rules above (full control, e.g. `single` plus one extra cross-package re-export exception); not both, since they would double-report.
|
|
127
|
+
|
|
128
|
+
| `mode` | Which files may be barrels | What a barrel may contain | Where a barrel's re-exports may come from |
|
|
129
|
+
| --- | --- | --- | --- |
|
|
130
|
+
| `'banned'` (the default/recommended) | none | — | — |
|
|
131
|
+
| `'single'` | exactly `src/index.ts` | only re-exports | anywhere |
|
|
132
|
+
| `'siblings'` | any `index.ts` | only re-exports | a direct sibling only (`./module`) |
|
|
133
|
+
|
|
134
|
+
In every mode, re-exports are banned in any file that is not a permitted barrel, and a permitted barrel may contain only re-export statements (no functional code). `'banned'` is the default the bundled configs ship; a published package whose `src/index.ts` is its package entry point overrides to `'single'` (see [Getting started](#getting-started)). The umbrella composes the identical predicates the standalone rules use (shared in `src/rules/barrel-helpers.ts`), so the convenience rule and the granular ones never drift apart. It is non-fixable -- the autofix lives on `no-non-barrel-reexport` -- so consumers who want the autofix use that granular rule directly.
|
|
111
135
|
|
|
112
136
|
## Build, test, and lint
|
|
113
137
|
|
|
@@ -119,7 +143,7 @@ pnpm test
|
|
|
119
143
|
pnpm build
|
|
120
144
|
```
|
|
121
145
|
|
|
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
|
|
146
|
+
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 rules need type information, so no `project`/`tsconfigRootDir` is configured. The barrel rules share their core predicates and the split-statement re-export detector via `src/rules/barrel-helpers.ts`, exercised both through each granular rule's own tests and through `barrel-policy.test.ts`'s per-mode coverage.
|
|
123
147
|
|
|
124
148
|
`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/`.
|
|
125
149
|
|
|
@@ -129,7 +153,7 @@ The `lint`/`typecheck`/`test`/`build` npm scripts are thin wrappers around turbo
|
|
|
129
153
|
|
|
130
154
|
## Architecture
|
|
131
155
|
|
|
132
|
-
`src/plugin.ts` builds an `ESLint.Plugin` object (ESLint's own `ESLint.Plugin` type, not a hand-written interface) combining the
|
|
156
|
+
`src/plugin.ts` builds an `ESLint.Plugin` object (ESLint's own `ESLint.Plugin` type, not a hand-written interface) combining the 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. `recommended` ships the `barrel-policy` umbrella at `mode: 'banned'`; `barrel` ships it at `mode: 'single'`.
|
|
133
157
|
|
|
134
158
|
`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.
|
|
135
159
|
|
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.
|
|
31
|
+
var version = "2.1.0";
|
|
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
|
|
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
|
|
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
|
|
84
|
-
const
|
|
85
|
-
const bareExportSpecifiers = [];
|
|
86
|
-
const defaultExportDeclarations = [];
|
|
336
|
+
if (isIndexFile(context.filename)) return {};
|
|
337
|
+
const detector = createSplitReexportDetector();
|
|
87
338
|
return {
|
|
88
|
-
ImportDeclaration(node)
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
107
|
-
const
|
|
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
|
-
|
|
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:
|
|
359
|
+
node: declaration,
|
|
129
360
|
messageId: "splitStatementDefaultReexport",
|
|
130
361
|
data: { name },
|
|
131
362
|
fix(fixer) {
|
|
132
|
-
const fixes = [fixer.remove(
|
|
133
|
-
if (
|
|
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":
|
|
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: "
|
|
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
|
|
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/
|
|
241
|
-
"exadev/no-
|
|
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/
|
|
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.
|
|
4
|
+
var version = "2.1.0";
|
|
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
|
|
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
|
|
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
|
|
57
|
-
const
|
|
58
|
-
const bareExportSpecifiers = [];
|
|
59
|
-
const defaultExportDeclarations = [];
|
|
309
|
+
if (isIndexFile(context.filename)) return {};
|
|
310
|
+
const detector = createSplitReexportDetector();
|
|
60
311
|
return {
|
|
61
|
-
ImportDeclaration(node)
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
80
|
-
const
|
|
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
|
-
|
|
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:
|
|
332
|
+
node: declaration,
|
|
102
333
|
messageId: "splitStatementDefaultReexport",
|
|
103
334
|
data: { name },
|
|
104
335
|
fix(fixer) {
|
|
105
|
-
const fixes = [fixer.remove(
|
|
106
|
-
if (
|
|
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":
|
|
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: "
|
|
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
|
|
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/
|
|
214
|
-
"exadev/no-
|
|
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/
|
|
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
|
}
|