@exadev/eslint-config 0.0.0 → 1.0.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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/index.cjs +39 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +43 -0
- package/dist/rules/no-non-barrel-index.cjs +24 -0
- package/dist/rules/no-non-barrel-index.d.cts +4 -0
- package/dist/rules/no-non-barrel-index.d.ts +5 -0
- package/dist/rules/no-non-barrel-index.js +24 -0
- package/dist/rules/no-non-barrel-reexport.cjs +88 -0
- package/dist/rules/no-non-barrel-reexport.d.cts +4 -0
- package/dist/rules/no-non-barrel-reexport.d.ts +5 -0
- package/dist/rules/no-non-barrel-reexport.js +88 -0
- package/dist/rules/no-pointless-reassignment.cjs +57 -0
- package/dist/rules/no-pointless-reassignment.d.cts +4 -0
- package/dist/rules/no-pointless-reassignment.d.ts +5 -0
- package/dist/rules/no-pointless-reassignment.js +57 -0
- package/dist/rules/no-side-effects-in-index.cjs +23 -0
- package/dist/rules/no-side-effects-in-index.d.cts +4 -0
- package/dist/rules/no-side-effects-in-index.d.ts +5 -0
- package/dist/rules/no-side-effects-in-index.js +23 -0
- package/package.json +77 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joseph Mearman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @exadev/eslint-config
|
|
2
|
+
|
|
3
|
+
> A real ESLint plugin (not a shareable config) exposing custom rules shared across ExaDev projects, starting with the [documents.js](https://github.com/ExaDev/documents.js) family (`documents.js`, `ooxml.js`, `odf.js`, `document-schema.js`, `pdf-codec`, `markdown-codec`). Also published under the unscoped alias `exadev-eslint-config`.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Every one of those 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.
|
|
8
|
+
|
|
9
|
+
Only the *rules* are centralized here, not each consumer's whole `eslint.config.ts`. Every repo's own file-scoping (`files`/`ignores`), tsconfig wiring, and Worker-isomorphism import bans genuinely differ from repo to repo -- forcing those into one shared config would mean either losing real per-repo 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.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add -D @exadev/eslint-config
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// eslint.config.ts
|
|
19
|
+
import exadev from '@exadev/eslint-config';
|
|
20
|
+
import tseslint from 'typescript-eslint';
|
|
21
|
+
|
|
22
|
+
export default tseslint.config(
|
|
23
|
+
// ...your own config...
|
|
24
|
+
{
|
|
25
|
+
files: ['src/**/*.ts'],
|
|
26
|
+
ignores: ['src/index.ts'],
|
|
27
|
+
plugins: { exadev },
|
|
28
|
+
rules: {
|
|
29
|
+
'exadev/no-non-barrel-reexport': 'error',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or use one of the bundled configs to enable a whole set at once:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import exadev from '@exadev/eslint-config';
|
|
39
|
+
import { defineConfig } from 'eslint/config';
|
|
40
|
+
|
|
41
|
+
export default defineConfig([
|
|
42
|
+
{
|
|
43
|
+
files: ['**/*.ts'],
|
|
44
|
+
plugins: { exadev },
|
|
45
|
+
extends: ['exadev/recommended'], // every rule this plugin defines
|
|
46
|
+
// or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Rules
|
|
52
|
+
|
|
53
|
+
| Rule | Fixable | Description |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `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. |
|
|
56
|
+
| `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. |
|
|
57
|
+
| `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. |
|
|
58
|
+
| `no-side-effects-in-index` | | The public barrel may contain only re-export statements -- nothing that could execute at import time. |
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pnpm install
|
|
64
|
+
pnpm lint
|
|
65
|
+
pnpm typecheck
|
|
66
|
+
pnpm build
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
No test suite exists for these rules currently -- each is verified by real-world usage against the repos it was extracted from, the same way it was verified before being centralized here.
|
|
70
|
+
|
|
71
|
+
## Release
|
|
72
|
+
|
|
73
|
+
Conventional commits (enforced by commitlint) 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 republishes the identical build under the unscoped alias `exadev-eslint-config`.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#endregion
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
const plugin = {
|
|
4
|
+
meta: {
|
|
5
|
+
name: "@exadev/eslint-config",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
namespace: "exadev"
|
|
8
|
+
},
|
|
9
|
+
rules: {
|
|
10
|
+
"no-non-barrel-index": require("./rules/no-non-barrel-index.cjs"),
|
|
11
|
+
"no-non-barrel-reexport": require("./rules/no-non-barrel-reexport.cjs"),
|
|
12
|
+
"no-pointless-reassignment": require("./rules/no-pointless-reassignment.cjs"),
|
|
13
|
+
"no-side-effects-in-index": require("./rules/no-side-effects-in-index.cjs")
|
|
14
|
+
},
|
|
15
|
+
configs: {}
|
|
16
|
+
};
|
|
17
|
+
const { configs } = plugin;
|
|
18
|
+
if (configs === void 0) throw new Error("Unreachable: configs was just initialized to {} in the object literal above.");
|
|
19
|
+
Object.assign(configs, {
|
|
20
|
+
recommended: {
|
|
21
|
+
plugins: { exadev: plugin },
|
|
22
|
+
rules: {
|
|
23
|
+
"exadev/no-non-barrel-index": "error",
|
|
24
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
25
|
+
"exadev/no-pointless-reassignment": "error",
|
|
26
|
+
"exadev/no-side-effects-in-index": "error"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
barrel: {
|
|
30
|
+
plugins: { exadev: plugin },
|
|
31
|
+
rules: {
|
|
32
|
+
"exadev/no-non-barrel-index": "error",
|
|
33
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
34
|
+
"exadev/no-side-effects-in-index": "error"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
//#endregion
|
|
39
|
+
module.exports = plugin;
|
package/dist/index.d.cts
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import noNonBarrelIndex from "./rules/no-non-barrel-index.js";
|
|
2
|
+
import noNonBarrelReexport from "./rules/no-non-barrel-reexport.js";
|
|
3
|
+
import noPointlessReassignment from "./rules/no-pointless-reassignment.js";
|
|
4
|
+
import noSideEffectsInIndex from "./rules/no-side-effects-in-index.js";
|
|
5
|
+
//#endregion
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
const plugin = {
|
|
8
|
+
meta: {
|
|
9
|
+
name: "@exadev/eslint-config",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
namespace: "exadev"
|
|
12
|
+
},
|
|
13
|
+
rules: {
|
|
14
|
+
"no-non-barrel-index": noNonBarrelIndex,
|
|
15
|
+
"no-non-barrel-reexport": noNonBarrelReexport,
|
|
16
|
+
"no-pointless-reassignment": noPointlessReassignment,
|
|
17
|
+
"no-side-effects-in-index": noSideEffectsInIndex
|
|
18
|
+
},
|
|
19
|
+
configs: {}
|
|
20
|
+
};
|
|
21
|
+
const { configs } = plugin;
|
|
22
|
+
if (configs === void 0) throw new Error("Unreachable: configs was just initialized to {} in the object literal above.");
|
|
23
|
+
Object.assign(configs, {
|
|
24
|
+
recommended: {
|
|
25
|
+
plugins: { exadev: plugin },
|
|
26
|
+
rules: {
|
|
27
|
+
"exadev/no-non-barrel-index": "error",
|
|
28
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
29
|
+
"exadev/no-pointless-reassignment": "error",
|
|
30
|
+
"exadev/no-side-effects-in-index": "error"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
barrel: {
|
|
34
|
+
plugins: { exadev: plugin },
|
|
35
|
+
rules: {
|
|
36
|
+
"exadev/no-non-barrel-index": "error",
|
|
37
|
+
"exadev/no-non-barrel-reexport": "error",
|
|
38
|
+
"exadev/no-side-effects-in-index": "error"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
//#endregion
|
|
43
|
+
export { plugin as default };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/rules/no-non-barrel-index.ts
|
|
2
|
+
const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
|
|
3
|
+
const noNonBarrelIndex = {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "problem",
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: { barrel: "Only src/index.ts may be named index.* (the public convenience barrel); give any other module a descriptive filename." }
|
|
8
|
+
},
|
|
9
|
+
create(context) {
|
|
10
|
+
const filename = context.filename;
|
|
11
|
+
const slash = filename.lastIndexOf("/");
|
|
12
|
+
const basename = slash === -1 ? filename : filename.slice(slash + 1);
|
|
13
|
+
if (!INDEX_BASENAME.test(basename)) return {};
|
|
14
|
+
if (filename.endsWith("/src/index.ts")) return {};
|
|
15
|
+
return { Program(node) {
|
|
16
|
+
context.report({
|
|
17
|
+
node,
|
|
18
|
+
messageId: "barrel"
|
|
19
|
+
});
|
|
20
|
+
} };
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
module.exports = noNonBarrelIndex;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/rules/no-non-barrel-index.ts
|
|
2
|
+
const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
|
|
3
|
+
const noNonBarrelIndex = {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "problem",
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: { barrel: "Only src/index.ts may be named index.* (the public convenience barrel); give any other module a descriptive filename." }
|
|
8
|
+
},
|
|
9
|
+
create(context) {
|
|
10
|
+
const filename = context.filename;
|
|
11
|
+
const slash = filename.lastIndexOf("/");
|
|
12
|
+
const basename = slash === -1 ? filename : filename.slice(slash + 1);
|
|
13
|
+
if (!INDEX_BASENAME.test(basename)) return {};
|
|
14
|
+
if (filename.endsWith("/src/index.ts")) return {};
|
|
15
|
+
return { Program(node) {
|
|
16
|
+
context.report({
|
|
17
|
+
node,
|
|
18
|
+
messageId: "barrel"
|
|
19
|
+
});
|
|
20
|
+
} };
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { noNonBarrelIndex as default };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/rules/no-non-barrel-reexport.ts
|
|
2
|
+
function removeListMember(fixer, sourceCode, declaration, members, target) {
|
|
3
|
+
if (members.length === 1) return fixer.remove(declaration);
|
|
4
|
+
const targetIndex = members.indexOf(target);
|
|
5
|
+
const isLast = targetIndex === members.length - 1;
|
|
6
|
+
const neighbor = members[isLast ? targetIndex - 1 : targetIndex + 1];
|
|
7
|
+
if (neighbor === void 0) throw new Error("Unreachable: a list with more than one member always has a neighbor either side of any member within it.");
|
|
8
|
+
return isLast ? fixer.removeRange([sourceCode.getRange(neighbor)[1], sourceCode.getRange(target)[1]]) : fixer.removeRange([sourceCode.getRange(target)[0], sourceCode.getRange(neighbor)[0]]);
|
|
9
|
+
}
|
|
10
|
+
function importIsOnlyUsedByThisExport(sourceCode, trackedImport, usageIdentifier) {
|
|
11
|
+
const variable = sourceCode.getDeclaredVariables(trackedImport.declaration).find((candidate) => candidate.defs.some((def) => def.node === trackedImport.specifier));
|
|
12
|
+
if (variable === void 0) return false;
|
|
13
|
+
if (variable.references.length !== 1) return false;
|
|
14
|
+
const [onlyReference] = variable.references;
|
|
15
|
+
if (onlyReference === void 0) throw new Error("Unreachable: the length check above guarantees exactly one element.");
|
|
16
|
+
return onlyReference.identifier === usageIdentifier;
|
|
17
|
+
}
|
|
18
|
+
const noNonBarrelReexport = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
fixable: "code",
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: {
|
|
24
|
+
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).",
|
|
25
|
+
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)."
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
const importsByName = /* @__PURE__ */ new Map();
|
|
30
|
+
const bareExportSpecifiers = [];
|
|
31
|
+
const defaultExportDeclarations = [];
|
|
32
|
+
return {
|
|
33
|
+
ImportDeclaration(node) {
|
|
34
|
+
for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
|
|
35
|
+
declaration: node,
|
|
36
|
+
specifier
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
ExportNamedDeclaration(node) {
|
|
40
|
+
if (node.source !== null && node.source !== void 0) return;
|
|
41
|
+
for (const specifier of node.specifiers) bareExportSpecifiers.push({
|
|
42
|
+
declaration: node,
|
|
43
|
+
specifier
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
ExportDefaultDeclaration(node) {
|
|
47
|
+
defaultExportDeclarations.push(node);
|
|
48
|
+
},
|
|
49
|
+
"Program:exit"() {
|
|
50
|
+
const { sourceCode } = context;
|
|
51
|
+
for (const { declaration, specifier } of bareExportSpecifiers) {
|
|
52
|
+
const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
|
|
53
|
+
if (name === void 0) continue;
|
|
54
|
+
const trackedImport = importsByName.get(name);
|
|
55
|
+
if (trackedImport === void 0) continue;
|
|
56
|
+
context.report({
|
|
57
|
+
node: specifier,
|
|
58
|
+
messageId: "splitStatementReexport",
|
|
59
|
+
data: { name },
|
|
60
|
+
fix(fixer) {
|
|
61
|
+
const fixes = [removeListMember(fixer, sourceCode, declaration, declaration.specifiers, specifier)];
|
|
62
|
+
if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
63
|
+
return fixes;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
for (const declarationNode of defaultExportDeclarations) {
|
|
68
|
+
const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
|
|
69
|
+
if (name === void 0) continue;
|
|
70
|
+
const trackedImport = importsByName.get(name);
|
|
71
|
+
if (trackedImport === void 0) continue;
|
|
72
|
+
context.report({
|
|
73
|
+
node: declarationNode,
|
|
74
|
+
messageId: "splitStatementDefaultReexport",
|
|
75
|
+
data: { name },
|
|
76
|
+
fix(fixer) {
|
|
77
|
+
const fixes = [fixer.remove(declarationNode)];
|
|
78
|
+
if (declarationNode.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declarationNode.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
79
|
+
return fixes;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
module.exports = noNonBarrelReexport;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/rules/no-non-barrel-reexport.ts
|
|
2
|
+
function removeListMember(fixer, sourceCode, declaration, members, target) {
|
|
3
|
+
if (members.length === 1) return fixer.remove(declaration);
|
|
4
|
+
const targetIndex = members.indexOf(target);
|
|
5
|
+
const isLast = targetIndex === members.length - 1;
|
|
6
|
+
const neighbor = members[isLast ? targetIndex - 1 : targetIndex + 1];
|
|
7
|
+
if (neighbor === void 0) throw new Error("Unreachable: a list with more than one member always has a neighbor either side of any member within it.");
|
|
8
|
+
return isLast ? fixer.removeRange([sourceCode.getRange(neighbor)[1], sourceCode.getRange(target)[1]]) : fixer.removeRange([sourceCode.getRange(target)[0], sourceCode.getRange(neighbor)[0]]);
|
|
9
|
+
}
|
|
10
|
+
function importIsOnlyUsedByThisExport(sourceCode, trackedImport, usageIdentifier) {
|
|
11
|
+
const variable = sourceCode.getDeclaredVariables(trackedImport.declaration).find((candidate) => candidate.defs.some((def) => def.node === trackedImport.specifier));
|
|
12
|
+
if (variable === void 0) return false;
|
|
13
|
+
if (variable.references.length !== 1) return false;
|
|
14
|
+
const [onlyReference] = variable.references;
|
|
15
|
+
if (onlyReference === void 0) throw new Error("Unreachable: the length check above guarantees exactly one element.");
|
|
16
|
+
return onlyReference.identifier === usageIdentifier;
|
|
17
|
+
}
|
|
18
|
+
const noNonBarrelReexport = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
fixable: "code",
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: {
|
|
24
|
+
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).",
|
|
25
|
+
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)."
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
create(context) {
|
|
29
|
+
const importsByName = /* @__PURE__ */ new Map();
|
|
30
|
+
const bareExportSpecifiers = [];
|
|
31
|
+
const defaultExportDeclarations = [];
|
|
32
|
+
return {
|
|
33
|
+
ImportDeclaration(node) {
|
|
34
|
+
for (const specifier of node.specifiers) importsByName.set(specifier.local.name, {
|
|
35
|
+
declaration: node,
|
|
36
|
+
specifier
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
ExportNamedDeclaration(node) {
|
|
40
|
+
if (node.source !== null && node.source !== void 0) return;
|
|
41
|
+
for (const specifier of node.specifiers) bareExportSpecifiers.push({
|
|
42
|
+
declaration: node,
|
|
43
|
+
specifier
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
ExportDefaultDeclaration(node) {
|
|
47
|
+
defaultExportDeclarations.push(node);
|
|
48
|
+
},
|
|
49
|
+
"Program:exit"() {
|
|
50
|
+
const { sourceCode } = context;
|
|
51
|
+
for (const { declaration, specifier } of bareExportSpecifiers) {
|
|
52
|
+
const name = specifier.local.type === "Identifier" ? specifier.local.name : void 0;
|
|
53
|
+
if (name === void 0) continue;
|
|
54
|
+
const trackedImport = importsByName.get(name);
|
|
55
|
+
if (trackedImport === void 0) continue;
|
|
56
|
+
context.report({
|
|
57
|
+
node: specifier,
|
|
58
|
+
messageId: "splitStatementReexport",
|
|
59
|
+
data: { name },
|
|
60
|
+
fix(fixer) {
|
|
61
|
+
const fixes = [removeListMember(fixer, sourceCode, declaration, declaration.specifiers, specifier)];
|
|
62
|
+
if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
63
|
+
return fixes;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
for (const declarationNode of defaultExportDeclarations) {
|
|
68
|
+
const name = declarationNode.declaration.type === "Identifier" ? declarationNode.declaration.name : void 0;
|
|
69
|
+
if (name === void 0) continue;
|
|
70
|
+
const trackedImport = importsByName.get(name);
|
|
71
|
+
if (trackedImport === void 0) continue;
|
|
72
|
+
context.report({
|
|
73
|
+
node: declarationNode,
|
|
74
|
+
messageId: "splitStatementDefaultReexport",
|
|
75
|
+
data: { name },
|
|
76
|
+
fix(fixer) {
|
|
77
|
+
const fixes = [fixer.remove(declarationNode)];
|
|
78
|
+
if (declarationNode.declaration.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, declarationNode.declaration)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
|
|
79
|
+
return fixes;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
export { noNonBarrelReexport as default };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/rules/no-pointless-reassignment.ts
|
|
2
|
+
function isIdentifierReference(reference) {
|
|
3
|
+
return reference.identifier.type === "Identifier";
|
|
4
|
+
}
|
|
5
|
+
const noPointlessReassignment = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
fixable: "code",
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
return { VariableDeclarator(node) {
|
|
14
|
+
if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
|
|
15
|
+
if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
|
|
16
|
+
const scope = context.sourceCode.getScope(node);
|
|
17
|
+
const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
|
|
18
|
+
if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
|
|
19
|
+
const aliasName = node.id.name;
|
|
20
|
+
const originalName = node.init.name;
|
|
21
|
+
context.report({
|
|
22
|
+
node,
|
|
23
|
+
messageId: "pointlessReassignment",
|
|
24
|
+
data: {
|
|
25
|
+
name: aliasName,
|
|
26
|
+
value: originalName
|
|
27
|
+
},
|
|
28
|
+
fix(fixer) {
|
|
29
|
+
const variable = scope.set.get(aliasName);
|
|
30
|
+
if (!variable) return null;
|
|
31
|
+
if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
|
|
32
|
+
const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
|
|
33
|
+
if (readRefs.some((reference) => {
|
|
34
|
+
const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
|
|
35
|
+
if (afterToken?.value === ":") return false;
|
|
36
|
+
if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
|
|
37
|
+
let token = context.sourceCode.getTokenBefore(reference.identifier);
|
|
38
|
+
while (token) {
|
|
39
|
+
if (token.value === "{") return true;
|
|
40
|
+
if (token.value === "[" || token.value === "(") return false;
|
|
41
|
+
if (token.value === ":") return false;
|
|
42
|
+
token = context.sourceCode.getTokenBefore(token);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
})) return null;
|
|
46
|
+
const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
|
|
47
|
+
const declaration = node.parent;
|
|
48
|
+
if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
|
|
49
|
+
fixes.push(fixer.remove(declaration));
|
|
50
|
+
return fixes;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
} };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
module.exports = noPointlessReassignment;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/rules/no-pointless-reassignment.ts
|
|
2
|
+
function isIdentifierReference(reference) {
|
|
3
|
+
return reference.identifier.type === "Identifier";
|
|
4
|
+
}
|
|
5
|
+
const noPointlessReassignment = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
fixable: "code",
|
|
9
|
+
schema: [],
|
|
10
|
+
messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
return { VariableDeclarator(node) {
|
|
14
|
+
if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
|
|
15
|
+
if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
|
|
16
|
+
const scope = context.sourceCode.getScope(node);
|
|
17
|
+
const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
|
|
18
|
+
if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
|
|
19
|
+
const aliasName = node.id.name;
|
|
20
|
+
const originalName = node.init.name;
|
|
21
|
+
context.report({
|
|
22
|
+
node,
|
|
23
|
+
messageId: "pointlessReassignment",
|
|
24
|
+
data: {
|
|
25
|
+
name: aliasName,
|
|
26
|
+
value: originalName
|
|
27
|
+
},
|
|
28
|
+
fix(fixer) {
|
|
29
|
+
const variable = scope.set.get(aliasName);
|
|
30
|
+
if (!variable) return null;
|
|
31
|
+
if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
|
|
32
|
+
const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
|
|
33
|
+
if (readRefs.some((reference) => {
|
|
34
|
+
const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
|
|
35
|
+
if (afterToken?.value === ":") return false;
|
|
36
|
+
if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
|
|
37
|
+
let token = context.sourceCode.getTokenBefore(reference.identifier);
|
|
38
|
+
while (token) {
|
|
39
|
+
if (token.value === "{") return true;
|
|
40
|
+
if (token.value === "[" || token.value === "(") return false;
|
|
41
|
+
if (token.value === ":") return false;
|
|
42
|
+
token = context.sourceCode.getTokenBefore(token);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
})) return null;
|
|
46
|
+
const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
|
|
47
|
+
const declaration = node.parent;
|
|
48
|
+
if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
|
|
49
|
+
fixes.push(fixer.remove(declaration));
|
|
50
|
+
return fixes;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
} };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { noPointlessReassignment as default };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/rules/no-side-effects-in-index.ts
|
|
2
|
+
function isPureReexport(statement) {
|
|
3
|
+
if (statement.type === "ExportAllDeclaration") return true;
|
|
4
|
+
return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
|
|
5
|
+
}
|
|
6
|
+
const noSideEffectsInIndex = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
schema: [],
|
|
10
|
+
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 }}." }
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
return { Program(node) {
|
|
14
|
+
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
15
|
+
node: statement,
|
|
16
|
+
messageId: "notAPureReexport",
|
|
17
|
+
data: { description: statement.type }
|
|
18
|
+
});
|
|
19
|
+
} };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
module.exports = noSideEffectsInIndex;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/rules/no-side-effects-in-index.ts
|
|
2
|
+
function isPureReexport(statement) {
|
|
3
|
+
if (statement.type === "ExportAllDeclaration") return true;
|
|
4
|
+
return statement.type === "ExportNamedDeclaration" && statement.source !== null && statement.source !== void 0;
|
|
5
|
+
}
|
|
6
|
+
const noSideEffectsInIndex = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
schema: [],
|
|
10
|
+
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 }}." }
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
return { Program(node) {
|
|
14
|
+
for (const statement of node.body) if (!isPureReexport(statement)) context.report({
|
|
15
|
+
node: statement,
|
|
16
|
+
messageId: "notAPureReexport",
|
|
17
|
+
data: { description: statement.type }
|
|
18
|
+
});
|
|
19
|
+
} };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { noSideEffectsInIndex as default };
|
package/package.json
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exadev/eslint-config",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared custom ESLint rules for ExaDev projects, starting with the documents.js family",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"packageManager": "pnpm@11.6.0",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": {
|
|
21
|
+
"import": "./dist/index.d.ts",
|
|
22
|
+
"require": "./dist/index.d.cts"
|
|
23
|
+
},
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
},
|
|
27
|
+
"./*": {
|
|
28
|
+
"types": {
|
|
29
|
+
"import": "./dist/*.d.ts",
|
|
30
|
+
"require": "./dist/*.d.cts"
|
|
31
|
+
},
|
|
32
|
+
"import": "./dist/*.js",
|
|
33
|
+
"require": "./dist/*.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"eslint",
|
|
41
|
+
"eslintplugin",
|
|
42
|
+
"eslint-plugin"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/ExaDev/eslint-config.git"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"eslint": ">=10.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
53
|
+
"@commitlint/cli": "^21.2.1",
|
|
54
|
+
"@commitlint/config-conventional": "^21.2.0",
|
|
55
|
+
"@eslint/js": "^10.0.1",
|
|
56
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
57
|
+
"@semantic-release/git": "^11.0.1",
|
|
58
|
+
"@types/node": "^24.9.2",
|
|
59
|
+
"eslint": "^10.8.0",
|
|
60
|
+
"husky": "^9.1.7",
|
|
61
|
+
"lint-staged": "^17.2.0",
|
|
62
|
+
"publint": "^0.3.21",
|
|
63
|
+
"semantic-release": "^25.0.8",
|
|
64
|
+
"tsdown": "^0.22.13",
|
|
65
|
+
"turbo": "^2.10.8",
|
|
66
|
+
"typescript": "^6.0.3",
|
|
67
|
+
"typescript-eslint": "^8.65.0"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "turbo run _build",
|
|
71
|
+
"_build": "tsdown",
|
|
72
|
+
"lint": "turbo run _lint",
|
|
73
|
+
"_lint": "eslint . --fix --cache --max-warnings 0",
|
|
74
|
+
"typecheck": "turbo run _typecheck",
|
|
75
|
+
"_typecheck": "tsc -p tsconfig.json",
|
|
76
|
+
"prepublishOnly": "pnpm run lint && pnpm run typecheck && tsdown && publint && attw --pack",
|
|
77
|
+
"prepare": "husky",
|
|
78
|
+
"release": "semantic-release"
|
|
79
|
+
}
|
|
5
80
|
}
|