exadev-eslint-config 2.1.2 → 2.3.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 +6 -3
- package/dist/index.cjs +182 -2
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +181 -2
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Consumers need `eslint >=10.0.0` and `typescript-eslint >=8.0.0` as required pee
|
|
|
16
16
|
pnpm add -D @exadev/eslint-config typescript-eslint eslint
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
The default export is the full, type-checked ruleset: typescript-eslint's `recommendedTypeChecked` + `stylisticTypeChecked` presets, `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-pointless-reassignment`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions,
|
|
19
|
+
The default export is the full, type-checked ruleset: typescript-eslint's `recommendedTypeChecked` + `stylisticTypeChecked` presets, `exadev/barrel-policy` at `mode: 'banned'` (see [Barrel policy](#barrel-policy)), `exadev/no-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-enum-number-widening`, `exadev/no-pointless-reassignment`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/no-non-null-assertion` banning the `!` operator (the same manual-override escape hatch as a type assertion, under a different spelling), `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright, and `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound) -- the type-assertion and ts-comment rules are relaxed in test files (see below). Spread it directly into `tseslint.config(...)`:
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
// eslint.config.ts
|
|
@@ -41,7 +41,7 @@ export default tseslint.config(
|
|
|
41
41
|
{ rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrel
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
`recommendedTypeChecked` subsumes typescript-eslint's plain `recommended` outright (
|
|
44
|
+
`recommendedTypeChecked` subsumes typescript-eslint's plain `recommended` outright (every rule in `recommended` is also present in `recommendedTypeChecked`), and its base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`stylisticTypeChecked` spreads** -- flat config rejects two different plugin object instances registered under the same namespace. You still supply `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s).
|
|
45
45
|
|
|
46
46
|
**Test files (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions, and only those two.** `@ts-expect-error` reverts to `allow-with-description` (a compile-time-only assertion of a type failure is a legitimate test pattern; `@ts-ignore`/`@ts-nocheck` stay banned since `@ts-expect-error` is strictly better). `consistent-type-assertions` relaxes to `assertionStyle: 'as'` (the legacy `<Type>value` form stays banned everywhere). Nothing inherited from the presets is relaxed.
|
|
47
47
|
|
|
@@ -77,7 +77,7 @@ export default defineConfig([
|
|
|
77
77
|
{
|
|
78
78
|
files: ['**/*.ts'],
|
|
79
79
|
plugins: { exadev: plugin },
|
|
80
|
-
extends: ['exadev/recommended'], // this plugin's own
|
|
80
|
+
extends: ['exadev/recommended'], // this plugin's own non-type-aware rules, plus linterOptions.noInlineConfig -- no type-checked rules at all
|
|
81
81
|
// or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
|
|
82
82
|
},
|
|
83
83
|
]);
|
|
@@ -112,6 +112,9 @@ export default tseslint.config(
|
|
|
112
112
|
| `no-side-effects-in-index` | | A barrel file may contain only re-export statements -- nothing that could execute at import time. Self-scopes to any index file. |
|
|
113
113
|
| `barrel-direct-siblings-only` | | A barrel may re-export only from a direct sibling (`./module`), never a nested path, parent, or bare package specifier (mode 3). |
|
|
114
114
|
| `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. Autofix rewrites every read to the original name and deletes the declaration (including its `export` keyword, when exported). Still reported but deliberately not auto-fixable where collapsing the alias would change meaning: an explicit type annotation (`const exhaustive: never = item` -- the annotation is the point), a read where the original name is shadowed, a read as a shorthand object property, more than one declarator in the statement, or a source that is written to anywhere. |
|
|
115
|
+
| `no-object-assign` | ✓/suggestion | `Object.assign` does not check a source object's properties against the target's declared types, unlike object spread. A fresh object-literal target autofixes to `{ ...target, ...source }`; mutating an existing reassignable binding offers a suggestion only (changes the object's identity); a `const` binding or a non-statement call site gets a plain report with no fix. |
|
|
116
|
+
| `no-mutable-union-array-param` | ✓ | A function parameter typed as an array of a union (`(string \| number)[]`) accepts a narrower caller array (`number[]`) by covariance; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` on it can then insert a value the caller's own array was never declared to hold. Autofix marks the parameter `readonly`, turning the mutating call into a real compile error to resolve deliberately. Requires no type information. |
|
|
117
|
+
| `no-enum-number-widening` | | A bare (non-literal) `number` is accepted anywhere a numeric enum is expected, without checking it is actually one of the enum's members -- only a numeric *literal* gets range-checked by `tsc`. No autofix: the only provably safe fix is a genuine runtime membership check against the enum's own values, which is a behavioural choice a mechanical fix cannot responsibly make. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
|
|
115
118
|
|
|
116
119
|
## Barrel policy
|
|
117
120
|
|
package/dist/index.cjs
CHANGED
|
@@ -27,8 +27,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
27
|
let typescript_eslint = require("typescript-eslint");
|
|
28
28
|
typescript_eslint = __toESM(typescript_eslint, 1);
|
|
29
29
|
let node_path = require("node:path");
|
|
30
|
+
let _typescript_eslint_utils = require("@typescript-eslint/utils");
|
|
31
|
+
let typescript = require("typescript");
|
|
32
|
+
typescript = __toESM(typescript, 1);
|
|
30
33
|
//#region package.json
|
|
31
|
-
var version = "2.
|
|
34
|
+
var version = "2.3.0";
|
|
32
35
|
//#endregion
|
|
33
36
|
//#region src/rules/barrel-helpers.ts
|
|
34
37
|
const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
|
|
@@ -264,6 +267,51 @@ const barrelPolicy = {
|
|
|
264
267
|
}
|
|
265
268
|
};
|
|
266
269
|
//#endregion
|
|
270
|
+
//#region src/rules/no-enum-number-widening.ts
|
|
271
|
+
const noEnumNumberWidening = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`)({
|
|
272
|
+
name: "no-enum-number-widening",
|
|
273
|
+
meta: {
|
|
274
|
+
type: "problem",
|
|
275
|
+
docs: { description: "Disallow assigning a bare (non-literal) number where a numeric enum type is expected -- TypeScript accepts any number for a numeric enum slot, not just its own members, once the value is not a literal the compiler can range-check." },
|
|
276
|
+
schema: [],
|
|
277
|
+
messages: { widening: "A plain 'number' value is being used where the numeric enum '{{ enumName }}' is expected. TypeScript does not verify the value is actually one of the enum's members here -- narrow it to a known member first (e.g. a lookup/guard against the enum's own values), or accept a plain 'number' parameter instead of widening it implicitly." }
|
|
278
|
+
},
|
|
279
|
+
defaultOptions: [],
|
|
280
|
+
create(context) {
|
|
281
|
+
const services = _typescript_eslint_utils.ESLintUtils.getParserServices(context);
|
|
282
|
+
const checker = services.program.getTypeChecker();
|
|
283
|
+
function checkNode(expression) {
|
|
284
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(expression);
|
|
285
|
+
if (!typescript.isExpression(tsNode)) return;
|
|
286
|
+
const contextualType = checker.getContextualType(tsNode);
|
|
287
|
+
if (!contextualType || !(contextualType.flags & typescript.TypeFlags.EnumLike)) return;
|
|
288
|
+
const actualType = checker.getTypeAtLocation(tsNode);
|
|
289
|
+
if (actualType.flags & typescript.TypeFlags.EnumLike) return;
|
|
290
|
+
if (actualType.isLiteral()) return;
|
|
291
|
+
if (!(actualType.flags & typescript.TypeFlags.NumberLike)) return;
|
|
292
|
+
context.report({
|
|
293
|
+
node: expression,
|
|
294
|
+
messageId: "widening",
|
|
295
|
+
data: { enumName: checker.typeToString(contextualType) }
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
VariableDeclarator(node) {
|
|
300
|
+
if (node.init) checkNode(node.init);
|
|
301
|
+
},
|
|
302
|
+
AssignmentExpression(node) {
|
|
303
|
+
checkNode(node.right);
|
|
304
|
+
},
|
|
305
|
+
ReturnStatement(node) {
|
|
306
|
+
if (node.argument) checkNode(node.argument);
|
|
307
|
+
},
|
|
308
|
+
CallExpression(node) {
|
|
309
|
+
for (const argument of node.arguments) if (argument.type !== _typescript_eslint_utils.AST_NODE_TYPES.SpreadElement) checkNode(argument);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
//#endregion
|
|
267
315
|
//#region src/rules/no-index-files.ts
|
|
268
316
|
const noIndexFiles = {
|
|
269
317
|
meta: {
|
|
@@ -282,6 +330,58 @@ const noIndexFiles = {
|
|
|
282
330
|
}
|
|
283
331
|
};
|
|
284
332
|
//#endregion
|
|
333
|
+
//#region src/rules/no-mutable-union-array-param.ts
|
|
334
|
+
const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
|
|
335
|
+
"push",
|
|
336
|
+
"unshift",
|
|
337
|
+
"splice",
|
|
338
|
+
"fill",
|
|
339
|
+
"copyWithin"
|
|
340
|
+
]);
|
|
341
|
+
const createRule$1 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
|
|
342
|
+
function isUnionArrayType(typeAnnotation) {
|
|
343
|
+
if (typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType && typeAnnotation.elementType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return typeAnnotation.elementType;
|
|
344
|
+
if (typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && typeAnnotation.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && typeAnnotation.typeName.name === "Array" && typeAnnotation.typeArguments?.params.length === 1) {
|
|
345
|
+
const firstParam = typeAnnotation.typeArguments.params[0];
|
|
346
|
+
if (firstParam?.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return firstParam;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const noMutableUnionArrayParam = createRule$1({
|
|
350
|
+
name: "no-mutable-union-array-param",
|
|
351
|
+
meta: {
|
|
352
|
+
type: "problem",
|
|
353
|
+
fixable: "code",
|
|
354
|
+
docs: { description: "Disallow mutating-insertion calls on a union-element array parameter, which lets a caller pass a narrower array whose declared element type the call can silently violate." },
|
|
355
|
+
schema: [],
|
|
356
|
+
messages: { unsound: "'{{ method }}' inserts into a parameter typed as an array of a union -- a caller may have passed a narrower array (e.g. number[] where (string | number)[] is declared), and TypeScript's covariant array typing does not catch the resulting mismatch. Mark the parameter readonly to turn this into a real compile error, or narrow the parameter type." }
|
|
357
|
+
},
|
|
358
|
+
defaultOptions: [],
|
|
359
|
+
create(context) {
|
|
360
|
+
return { CallExpression(node) {
|
|
361
|
+
const { callee } = node;
|
|
362
|
+
if (callee.type !== _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression || callee.computed || callee.object.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || callee.property.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || !MUTATING_INSERT_METHODS.has(callee.property.name)) return;
|
|
363
|
+
const parameterDefinition = (context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved)?.defs.find((definition) => definition.type === _typescript_eslint_utils.TSESLint.Scope.DefinitionType.Parameter);
|
|
364
|
+
if (!parameterDefinition) return;
|
|
365
|
+
const parameterNode = parameterDefinition.name;
|
|
366
|
+
if (parameterNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || !parameterNode.typeAnnotation) return;
|
|
367
|
+
if (!isUnionArrayType(parameterNode.typeAnnotation.typeAnnotation)) return;
|
|
368
|
+
context.report({
|
|
369
|
+
node,
|
|
370
|
+
messageId: "unsound",
|
|
371
|
+
data: { method: callee.property.name },
|
|
372
|
+
fix(fixer) {
|
|
373
|
+
const typeAnnotation = parameterNode.typeAnnotation;
|
|
374
|
+
if (!typeAnnotation) return null;
|
|
375
|
+
const annotated = typeAnnotation.typeAnnotation;
|
|
376
|
+
if (annotated.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType) return fixer.insertTextBefore(annotated, "readonly ");
|
|
377
|
+
if (annotated.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && annotated.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return fixer.replaceText(annotated.typeName, "ReadonlyArray");
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
} };
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
//#endregion
|
|
285
385
|
//#region src/rules/no-non-barrel-index.ts
|
|
286
386
|
const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
|
|
287
387
|
const noNonBarrelIndex = {
|
|
@@ -371,6 +471,76 @@ const noNonBarrelReexport = {
|
|
|
371
471
|
}
|
|
372
472
|
};
|
|
373
473
|
//#endregion
|
|
474
|
+
//#region src/rules/no-object-assign.ts
|
|
475
|
+
const createRule = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
|
|
476
|
+
function resolveFrom$1(scope, name) {
|
|
477
|
+
for (let current = scope; current; current = current.upper) {
|
|
478
|
+
const found = current.set.get(name);
|
|
479
|
+
if (found) return found;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const noObjectAssign = createRule({
|
|
483
|
+
name: "no-object-assign",
|
|
484
|
+
meta: {
|
|
485
|
+
type: "problem",
|
|
486
|
+
fixable: "code",
|
|
487
|
+
hasSuggestions: true,
|
|
488
|
+
docs: { description: "Disallow Object.assign, whose own type declarations do not check a source object's properties against the target's declared types -- object spread does." },
|
|
489
|
+
schema: [],
|
|
490
|
+
messages: {
|
|
491
|
+
unsound: "Object.assign does not verify that a source object's properties are assignable to the target's declared types, so a type mismatch here passes silently where a direct property assignment would be rejected. Use object spread ({ ...target, ...source }) to build a correctly type-checked replacement instead.",
|
|
492
|
+
suggestSpreadReassign: "Replace with object spread and reassignment (changes the object reference -- anything else already holding this object will not see the update)."
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
defaultOptions: [],
|
|
496
|
+
create(context) {
|
|
497
|
+
return { CallExpression(node) {
|
|
498
|
+
const { callee } = node;
|
|
499
|
+
if (!(callee.type === _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.object.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && callee.object.name === "Object" && callee.property.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && callee.property.name === "assign")) return;
|
|
500
|
+
const [target, ...sources] = node.arguments;
|
|
501
|
+
const sourcesAreSpreadable = sources.every((argument) => argument.type !== _typescript_eslint_utils.AST_NODE_TYPES.SpreadElement);
|
|
502
|
+
if (target?.type === _typescript_eslint_utils.AST_NODE_TYPES.ObjectExpression && sourcesAreSpreadable) {
|
|
503
|
+
const isBareStatement = node.parent.type === _typescript_eslint_utils.AST_NODE_TYPES.ExpressionStatement && node.parent.expression === node;
|
|
504
|
+
context.report({
|
|
505
|
+
node,
|
|
506
|
+
messageId: "unsound",
|
|
507
|
+
fix(fixer) {
|
|
508
|
+
const targetProps = target.properties.map((property) => context.sourceCode.getText(property));
|
|
509
|
+
const sourceSpreads = sources.map((argument) => `...${context.sourceCode.getText(argument)}`);
|
|
510
|
+
const objectLiteral = `{ ${[...targetProps, ...sourceSpreads].join(", ")} }`;
|
|
511
|
+
return fixer.replaceText(node, isBareStatement ? `(${objectLiteral})` : objectLiteral);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (target?.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && sourcesAreSpreadable && node.parent.type === _typescript_eslint_utils.AST_NODE_TYPES.ExpressionStatement && node.parent.expression === node) {
|
|
517
|
+
const targetName = target.name;
|
|
518
|
+
const variable = resolveFrom$1(context.sourceCode.getScope(node), targetName);
|
|
519
|
+
const isConst = (variable?.defs.find((candidate) => candidate.type === _typescript_eslint_utils.TSESLint.Scope.DefinitionType.Variable))?.parent.kind === "const";
|
|
520
|
+
const statement = node.parent;
|
|
521
|
+
if (variable && !isConst) {
|
|
522
|
+
context.report({
|
|
523
|
+
node,
|
|
524
|
+
messageId: "unsound",
|
|
525
|
+
suggest: [{
|
|
526
|
+
messageId: "suggestSpreadReassign",
|
|
527
|
+
fix(fixer) {
|
|
528
|
+
const sourceSpreads = sources.map((argument) => `...${context.sourceCode.getText(argument)}`);
|
|
529
|
+
return fixer.replaceText(statement, `${targetName} = { ...${targetName}, ${sourceSpreads.join(", ")} };`);
|
|
530
|
+
}
|
|
531
|
+
}]
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
context.report({
|
|
537
|
+
node,
|
|
538
|
+
messageId: "unsound"
|
|
539
|
+
});
|
|
540
|
+
} };
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
//#endregion
|
|
374
544
|
//#region src/rules/no-pointless-reassignment.ts
|
|
375
545
|
function isIdentifierReference(reference) {
|
|
376
546
|
return reference.identifier.type === "Identifier";
|
|
@@ -395,9 +565,12 @@ const plugin = {
|
|
|
395
565
|
rules: {
|
|
396
566
|
"barrel-direct-siblings-only": barrelDirectSiblingsOnly,
|
|
397
567
|
"barrel-policy": barrelPolicy,
|
|
568
|
+
"no-enum-number-widening": noEnumNumberWidening,
|
|
398
569
|
"no-index-files": noIndexFiles,
|
|
570
|
+
"no-mutable-union-array-param": noMutableUnionArrayParam,
|
|
399
571
|
"no-non-barrel-index": noNonBarrelIndex,
|
|
400
572
|
"no-non-barrel-reexport": noNonBarrelReexport,
|
|
573
|
+
"no-object-assign": noObjectAssign,
|
|
401
574
|
"no-pointless-reassignment": {
|
|
402
575
|
meta: {
|
|
403
576
|
type: "problem",
|
|
@@ -477,6 +650,8 @@ const plugin = {
|
|
|
477
650
|
linterOptions: { noInlineConfig: true },
|
|
478
651
|
rules: {
|
|
479
652
|
"exadev/barrel-policy": ["error", { mode: "banned" }],
|
|
653
|
+
"exadev/no-mutable-union-array-param": "error",
|
|
654
|
+
"exadev/no-object-assign": "error",
|
|
480
655
|
"exadev/no-pointless-reassignment": "error"
|
|
481
656
|
}
|
|
482
657
|
};
|
|
@@ -500,9 +675,14 @@ const recommendedTypeChecked = [
|
|
|
500
675
|
linterOptions: { noInlineConfig: true },
|
|
501
676
|
rules: {
|
|
502
677
|
"exadev/barrel-policy": ["error", { mode: "banned" }],
|
|
678
|
+
"exadev/no-enum-number-widening": "error",
|
|
679
|
+
"exadev/no-mutable-union-array-param": "error",
|
|
680
|
+
"exadev/no-object-assign": "error",
|
|
503
681
|
"exadev/no-pointless-reassignment": "error",
|
|
504
682
|
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
|
|
505
|
-
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }]
|
|
683
|
+
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
|
|
684
|
+
"@typescript-eslint/method-signature-style": ["error", "property"],
|
|
685
|
+
"@typescript-eslint/no-non-null-assertion": "error"
|
|
506
686
|
}
|
|
507
687
|
},
|
|
508
688
|
{
|
package/dist/index.d.cts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
//#region src/recommended-type-checked.d.ts
|
|
3
|
-
type ConfigValue = NonNullable<
|
|
3
|
+
type ConfigValue = NonNullable<TSESLint.FlatConfig.Plugin['configs']>[string];
|
|
4
4
|
type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
|
|
5
5
|
declare const recommendedTypeChecked: ConfigArrayValue;
|
|
6
6
|
//#endregion
|
|
7
7
|
//#region src/plugin.d.ts
|
|
8
|
-
declare const plugin:
|
|
8
|
+
declare const plugin: TSESLint.FlatConfig.Plugin;
|
|
9
9
|
//#endregion
|
|
10
10
|
export { recommendedTypeChecked as default, plugin };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
//#region src/recommended-type-checked.d.ts
|
|
3
|
-
type ConfigValue = NonNullable<
|
|
3
|
+
type ConfigValue = NonNullable<TSESLint.FlatConfig.Plugin['configs']>[string];
|
|
4
4
|
type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
|
|
5
5
|
declare const recommendedTypeChecked: ConfigArrayValue;
|
|
6
6
|
//#endregion
|
|
7
7
|
//#region src/plugin.d.ts
|
|
8
|
-
declare const plugin:
|
|
8
|
+
declare const plugin: TSESLint.FlatConfig.Plugin;
|
|
9
9
|
//#endregion
|
|
10
10
|
export { recommendedTypeChecked as default, plugin };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import tseslint from "typescript-eslint";
|
|
2
2
|
import { posix } from "node:path";
|
|
3
|
+
import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
|
|
4
|
+
import * as ts from "typescript";
|
|
3
5
|
//#region package.json
|
|
4
|
-
var version = "2.
|
|
6
|
+
var version = "2.3.0";
|
|
5
7
|
//#endregion
|
|
6
8
|
//#region src/rules/barrel-helpers.ts
|
|
7
9
|
const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
|
|
@@ -237,6 +239,51 @@ const barrelPolicy = {
|
|
|
237
239
|
}
|
|
238
240
|
};
|
|
239
241
|
//#endregion
|
|
242
|
+
//#region src/rules/no-enum-number-widening.ts
|
|
243
|
+
const noEnumNumberWidening = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`)({
|
|
244
|
+
name: "no-enum-number-widening",
|
|
245
|
+
meta: {
|
|
246
|
+
type: "problem",
|
|
247
|
+
docs: { description: "Disallow assigning a bare (non-literal) number where a numeric enum type is expected -- TypeScript accepts any number for a numeric enum slot, not just its own members, once the value is not a literal the compiler can range-check." },
|
|
248
|
+
schema: [],
|
|
249
|
+
messages: { widening: "A plain 'number' value is being used where the numeric enum '{{ enumName }}' is expected. TypeScript does not verify the value is actually one of the enum's members here -- narrow it to a known member first (e.g. a lookup/guard against the enum's own values), or accept a plain 'number' parameter instead of widening it implicitly." }
|
|
250
|
+
},
|
|
251
|
+
defaultOptions: [],
|
|
252
|
+
create(context) {
|
|
253
|
+
const services = ESLintUtils.getParserServices(context);
|
|
254
|
+
const checker = services.program.getTypeChecker();
|
|
255
|
+
function checkNode(expression) {
|
|
256
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(expression);
|
|
257
|
+
if (!ts.isExpression(tsNode)) return;
|
|
258
|
+
const contextualType = checker.getContextualType(tsNode);
|
|
259
|
+
if (!contextualType || !(contextualType.flags & ts.TypeFlags.EnumLike)) return;
|
|
260
|
+
const actualType = checker.getTypeAtLocation(tsNode);
|
|
261
|
+
if (actualType.flags & ts.TypeFlags.EnumLike) return;
|
|
262
|
+
if (actualType.isLiteral()) return;
|
|
263
|
+
if (!(actualType.flags & ts.TypeFlags.NumberLike)) return;
|
|
264
|
+
context.report({
|
|
265
|
+
node: expression,
|
|
266
|
+
messageId: "widening",
|
|
267
|
+
data: { enumName: checker.typeToString(contextualType) }
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
VariableDeclarator(node) {
|
|
272
|
+
if (node.init) checkNode(node.init);
|
|
273
|
+
},
|
|
274
|
+
AssignmentExpression(node) {
|
|
275
|
+
checkNode(node.right);
|
|
276
|
+
},
|
|
277
|
+
ReturnStatement(node) {
|
|
278
|
+
if (node.argument) checkNode(node.argument);
|
|
279
|
+
},
|
|
280
|
+
CallExpression(node) {
|
|
281
|
+
for (const argument of node.arguments) if (argument.type !== AST_NODE_TYPES.SpreadElement) checkNode(argument);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
//#endregion
|
|
240
287
|
//#region src/rules/no-index-files.ts
|
|
241
288
|
const noIndexFiles = {
|
|
242
289
|
meta: {
|
|
@@ -255,6 +302,58 @@ const noIndexFiles = {
|
|
|
255
302
|
}
|
|
256
303
|
};
|
|
257
304
|
//#endregion
|
|
305
|
+
//#region src/rules/no-mutable-union-array-param.ts
|
|
306
|
+
const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
|
|
307
|
+
"push",
|
|
308
|
+
"unshift",
|
|
309
|
+
"splice",
|
|
310
|
+
"fill",
|
|
311
|
+
"copyWithin"
|
|
312
|
+
]);
|
|
313
|
+
const createRule$1 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
|
|
314
|
+
function isUnionArrayType(typeAnnotation) {
|
|
315
|
+
if (typeAnnotation.type === AST_NODE_TYPES.TSArrayType && typeAnnotation.elementType.type === AST_NODE_TYPES.TSUnionType) return typeAnnotation.elementType;
|
|
316
|
+
if (typeAnnotation.type === AST_NODE_TYPES.TSTypeReference && typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier && typeAnnotation.typeName.name === "Array" && typeAnnotation.typeArguments?.params.length === 1) {
|
|
317
|
+
const firstParam = typeAnnotation.typeArguments.params[0];
|
|
318
|
+
if (firstParam?.type === AST_NODE_TYPES.TSUnionType) return firstParam;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const noMutableUnionArrayParam = createRule$1({
|
|
322
|
+
name: "no-mutable-union-array-param",
|
|
323
|
+
meta: {
|
|
324
|
+
type: "problem",
|
|
325
|
+
fixable: "code",
|
|
326
|
+
docs: { description: "Disallow mutating-insertion calls on a union-element array parameter, which lets a caller pass a narrower array whose declared element type the call can silently violate." },
|
|
327
|
+
schema: [],
|
|
328
|
+
messages: { unsound: "'{{ method }}' inserts into a parameter typed as an array of a union -- a caller may have passed a narrower array (e.g. number[] where (string | number)[] is declared), and TypeScript's covariant array typing does not catch the resulting mismatch. Mark the parameter readonly to turn this into a real compile error, or narrow the parameter type." }
|
|
329
|
+
},
|
|
330
|
+
defaultOptions: [],
|
|
331
|
+
create(context) {
|
|
332
|
+
return { CallExpression(node) {
|
|
333
|
+
const { callee } = node;
|
|
334
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.object.type !== AST_NODE_TYPES.Identifier || callee.property.type !== AST_NODE_TYPES.Identifier || !MUTATING_INSERT_METHODS.has(callee.property.name)) return;
|
|
335
|
+
const parameterDefinition = (context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved)?.defs.find((definition) => definition.type === TSESLint.Scope.DefinitionType.Parameter);
|
|
336
|
+
if (!parameterDefinition) return;
|
|
337
|
+
const parameterNode = parameterDefinition.name;
|
|
338
|
+
if (parameterNode.type !== AST_NODE_TYPES.Identifier || !parameterNode.typeAnnotation) return;
|
|
339
|
+
if (!isUnionArrayType(parameterNode.typeAnnotation.typeAnnotation)) return;
|
|
340
|
+
context.report({
|
|
341
|
+
node,
|
|
342
|
+
messageId: "unsound",
|
|
343
|
+
data: { method: callee.property.name },
|
|
344
|
+
fix(fixer) {
|
|
345
|
+
const typeAnnotation = parameterNode.typeAnnotation;
|
|
346
|
+
if (!typeAnnotation) return null;
|
|
347
|
+
const annotated = typeAnnotation.typeAnnotation;
|
|
348
|
+
if (annotated.type === AST_NODE_TYPES.TSArrayType) return fixer.insertTextBefore(annotated, "readonly ");
|
|
349
|
+
if (annotated.type === AST_NODE_TYPES.TSTypeReference && annotated.typeName.type === AST_NODE_TYPES.Identifier) return fixer.replaceText(annotated.typeName, "ReadonlyArray");
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
} };
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
//#endregion
|
|
258
357
|
//#region src/rules/no-non-barrel-index.ts
|
|
259
358
|
const INDEX_BASENAME = /^index\.[cm]?[tj]s$/;
|
|
260
359
|
const noNonBarrelIndex = {
|
|
@@ -344,6 +443,76 @@ const noNonBarrelReexport = {
|
|
|
344
443
|
}
|
|
345
444
|
};
|
|
346
445
|
//#endregion
|
|
446
|
+
//#region src/rules/no-object-assign.ts
|
|
447
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
|
|
448
|
+
function resolveFrom$1(scope, name) {
|
|
449
|
+
for (let current = scope; current; current = current.upper) {
|
|
450
|
+
const found = current.set.get(name);
|
|
451
|
+
if (found) return found;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const noObjectAssign = createRule({
|
|
455
|
+
name: "no-object-assign",
|
|
456
|
+
meta: {
|
|
457
|
+
type: "problem",
|
|
458
|
+
fixable: "code",
|
|
459
|
+
hasSuggestions: true,
|
|
460
|
+
docs: { description: "Disallow Object.assign, whose own type declarations do not check a source object's properties against the target's declared types -- object spread does." },
|
|
461
|
+
schema: [],
|
|
462
|
+
messages: {
|
|
463
|
+
unsound: "Object.assign does not verify that a source object's properties are assignable to the target's declared types, so a type mismatch here passes silently where a direct property assignment would be rejected. Use object spread ({ ...target, ...source }) to build a correctly type-checked replacement instead.",
|
|
464
|
+
suggestSpreadReassign: "Replace with object spread and reassignment (changes the object reference -- anything else already holding this object will not see the update)."
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
defaultOptions: [],
|
|
468
|
+
create(context) {
|
|
469
|
+
return { CallExpression(node) {
|
|
470
|
+
const { callee } = node;
|
|
471
|
+
if (!(callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES.Identifier && callee.object.name === "Object" && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === "assign")) return;
|
|
472
|
+
const [target, ...sources] = node.arguments;
|
|
473
|
+
const sourcesAreSpreadable = sources.every((argument) => argument.type !== AST_NODE_TYPES.SpreadElement);
|
|
474
|
+
if (target?.type === AST_NODE_TYPES.ObjectExpression && sourcesAreSpreadable) {
|
|
475
|
+
const isBareStatement = node.parent.type === AST_NODE_TYPES.ExpressionStatement && node.parent.expression === node;
|
|
476
|
+
context.report({
|
|
477
|
+
node,
|
|
478
|
+
messageId: "unsound",
|
|
479
|
+
fix(fixer) {
|
|
480
|
+
const targetProps = target.properties.map((property) => context.sourceCode.getText(property));
|
|
481
|
+
const sourceSpreads = sources.map((argument) => `...${context.sourceCode.getText(argument)}`);
|
|
482
|
+
const objectLiteral = `{ ${[...targetProps, ...sourceSpreads].join(", ")} }`;
|
|
483
|
+
return fixer.replaceText(node, isBareStatement ? `(${objectLiteral})` : objectLiteral);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (target?.type === AST_NODE_TYPES.Identifier && sourcesAreSpreadable && node.parent.type === AST_NODE_TYPES.ExpressionStatement && node.parent.expression === node) {
|
|
489
|
+
const targetName = target.name;
|
|
490
|
+
const variable = resolveFrom$1(context.sourceCode.getScope(node), targetName);
|
|
491
|
+
const isConst = (variable?.defs.find((candidate) => candidate.type === TSESLint.Scope.DefinitionType.Variable))?.parent.kind === "const";
|
|
492
|
+
const statement = node.parent;
|
|
493
|
+
if (variable && !isConst) {
|
|
494
|
+
context.report({
|
|
495
|
+
node,
|
|
496
|
+
messageId: "unsound",
|
|
497
|
+
suggest: [{
|
|
498
|
+
messageId: "suggestSpreadReassign",
|
|
499
|
+
fix(fixer) {
|
|
500
|
+
const sourceSpreads = sources.map((argument) => `...${context.sourceCode.getText(argument)}`);
|
|
501
|
+
return fixer.replaceText(statement, `${targetName} = { ...${targetName}, ${sourceSpreads.join(", ")} };`);
|
|
502
|
+
}
|
|
503
|
+
}]
|
|
504
|
+
});
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
context.report({
|
|
509
|
+
node,
|
|
510
|
+
messageId: "unsound"
|
|
511
|
+
});
|
|
512
|
+
} };
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
//#endregion
|
|
347
516
|
//#region src/rules/no-pointless-reassignment.ts
|
|
348
517
|
function isIdentifierReference(reference) {
|
|
349
518
|
return reference.identifier.type === "Identifier";
|
|
@@ -368,9 +537,12 @@ const plugin = {
|
|
|
368
537
|
rules: {
|
|
369
538
|
"barrel-direct-siblings-only": barrelDirectSiblingsOnly,
|
|
370
539
|
"barrel-policy": barrelPolicy,
|
|
540
|
+
"no-enum-number-widening": noEnumNumberWidening,
|
|
371
541
|
"no-index-files": noIndexFiles,
|
|
542
|
+
"no-mutable-union-array-param": noMutableUnionArrayParam,
|
|
372
543
|
"no-non-barrel-index": noNonBarrelIndex,
|
|
373
544
|
"no-non-barrel-reexport": noNonBarrelReexport,
|
|
545
|
+
"no-object-assign": noObjectAssign,
|
|
374
546
|
"no-pointless-reassignment": {
|
|
375
547
|
meta: {
|
|
376
548
|
type: "problem",
|
|
@@ -450,6 +622,8 @@ const plugin = {
|
|
|
450
622
|
linterOptions: { noInlineConfig: true },
|
|
451
623
|
rules: {
|
|
452
624
|
"exadev/barrel-policy": ["error", { mode: "banned" }],
|
|
625
|
+
"exadev/no-mutable-union-array-param": "error",
|
|
626
|
+
"exadev/no-object-assign": "error",
|
|
453
627
|
"exadev/no-pointless-reassignment": "error"
|
|
454
628
|
}
|
|
455
629
|
};
|
|
@@ -473,9 +647,14 @@ const recommendedTypeChecked = [
|
|
|
473
647
|
linterOptions: { noInlineConfig: true },
|
|
474
648
|
rules: {
|
|
475
649
|
"exadev/barrel-policy": ["error", { mode: "banned" }],
|
|
650
|
+
"exadev/no-enum-number-widening": "error",
|
|
651
|
+
"exadev/no-mutable-union-array-param": "error",
|
|
652
|
+
"exadev/no-object-assign": "error",
|
|
476
653
|
"exadev/no-pointless-reassignment": "error",
|
|
477
654
|
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
|
|
478
|
-
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }]
|
|
655
|
+
"@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
|
|
656
|
+
"@typescript-eslint/method-signature-style": ["error", "property"],
|
|
657
|
+
"@typescript-eslint/no-non-null-assertion": "error"
|
|
479
658
|
}
|
|
480
659
|
},
|
|
481
660
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exadev-eslint-config",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Shared custom ESLint rules and plugin for ExaDev projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"eslint": ">=10.0.0",
|
|
41
|
+
"typescript": ">=4.8.4",
|
|
41
42
|
"typescript-eslint": ">=8.0.0"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"@semantic-release/changelog": "^7.0.0",
|
|
49
50
|
"@semantic-release/git": "^11.0.1",
|
|
50
51
|
"@types/node": "^24.9.2",
|
|
52
|
+
"@typescript-eslint/rule-tester": "^8.67.0",
|
|
51
53
|
"@vitest/coverage-v8": "^4.1.10",
|
|
52
54
|
"eslint": "^10.8.0",
|
|
53
55
|
"husky": "^9.1.7",
|
|
@@ -57,9 +59,12 @@
|
|
|
57
59
|
"tsdown": "^0.22.13",
|
|
58
60
|
"turbo": "^2.10.8",
|
|
59
61
|
"typescript": "^6.0.3",
|
|
60
|
-
"typescript-eslint": "^8.
|
|
62
|
+
"typescript-eslint": "^8.67.0",
|
|
61
63
|
"vitest": "^4.1.10"
|
|
62
64
|
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@typescript-eslint/utils": "^8.67.0"
|
|
67
|
+
},
|
|
63
68
|
"scripts": {
|
|
64
69
|
"build": "turbo run _build",
|
|
65
70
|
"_build": "tsdown",
|