@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 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;
@@ -0,0 +1,4 @@
1
+ import { ESLint } from "eslint";
2
+ //#region src/index.d.ts
3
+ declare const plugin: ESLint.Plugin;
4
+ export = plugin;
@@ -0,0 +1,5 @@
1
+ import { ESLint } from "eslint";
2
+ //#region src/index.d.ts
3
+ declare const plugin: ESLint.Plugin;
4
+ //#endregion
5
+ export { plugin as default };
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,4 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-non-barrel-index.d.ts
3
+ declare const noNonBarrelIndex: Rule.RuleModule;
4
+ export = noNonBarrelIndex;
@@ -0,0 +1,5 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-non-barrel-index.d.ts
3
+ declare const noNonBarrelIndex: Rule.RuleModule;
4
+ //#endregion
5
+ export { noNonBarrelIndex 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
+ 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,4 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-non-barrel-reexport.d.ts
3
+ declare const noNonBarrelReexport: Rule.RuleModule;
4
+ export = noNonBarrelReexport;
@@ -0,0 +1,5 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-non-barrel-reexport.d.ts
3
+ declare const noNonBarrelReexport: Rule.RuleModule;
4
+ //#endregion
5
+ export { noNonBarrelReexport 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
+ 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,4 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-pointless-reassignment.d.ts
3
+ declare const noPointlessReassignment: Rule.RuleModule;
4
+ export = noPointlessReassignment;
@@ -0,0 +1,5 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-pointless-reassignment.d.ts
3
+ declare const noPointlessReassignment: Rule.RuleModule;
4
+ //#endregion
5
+ export { noPointlessReassignment 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
+ 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,4 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-side-effects-in-index.d.ts
3
+ declare const noSideEffectsInIndex: Rule.RuleModule;
4
+ export = noSideEffectsInIndex;
@@ -0,0 +1,5 @@
1
+ import { Rule } from "eslint";
2
+ //#region src/rules/no-side-effects-in-index.d.ts
3
+ declare const noSideEffectsInIndex: Rule.RuleModule;
4
+ //#endregion
5
+ export { noSideEffectsInIndex 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
+ export { noSideEffectsInIndex as default };
package/package.json CHANGED
@@ -1,5 +1,80 @@
1
1
  {
2
2
  "name": "@exadev/eslint-config",
3
- "version": "0.0.0",
4
- "private": false
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
  }