exadev-eslint-config 2.1.1 → 2.2.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 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, and `@typescript-eslint/ban-ts-comment` banning `@ts-expect-error` outright -- the last two relaxed in test files (see below). Spread it directly into `tseslint.config(...)`:
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/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 (all 46 of its rules are a subset of `recommendedTypeChecked`'s 73), and its base config registers the `@typescript-eslint` plugin and sets `languageOptions.parser` itself. That is why **you must remove your own `...tseslint.configs.recommended`/`recommendedTypeChecked`/`stylisticTypeChecked` spreads** -- flat config rejects two different plugin object instances registered under the same namespace. You still supply `languageOptions.parserOptions.project`/`projectService` pointing at your own tsconfig(s).
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 four rules, plus linterOptions.noInlineConfig -- no type-checked rules at all
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
  ]);
@@ -111,7 +111,10 @@ export default tseslint.config(
111
111
  | `no-non-barrel-reexport` | ✓ | Re-exports belong only in a barrel. Catches the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`) which no AST selector alone can match. Autofix deletes the export and the now-pointless import when it was the import's only use. Self-scopes away from any index file. |
112
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
- | `no-pointless-reassignment` | ✓ | `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. |
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.1.1";
34
+ var version = "2.2.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,10 +471,89 @@ 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";
377
547
  }
548
+ function hasTypeAnnotation(id) {
549
+ return "typeAnnotation" in id && id.typeAnnotation !== void 0 && id.typeAnnotation !== null;
550
+ }
551
+ function resolveFrom(scope, name) {
552
+ for (let current = scope; current; current = current.upper) {
553
+ const found = current.set.get(name);
554
+ if (found) return found;
555
+ }
556
+ }
378
557
  //#endregion
379
558
  //#region src/plugin.ts
380
559
  const plugin = {
@@ -386,9 +565,12 @@ const plugin = {
386
565
  rules: {
387
566
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
388
567
  "barrel-policy": barrelPolicy,
568
+ "no-enum-number-widening": noEnumNumberWidening,
389
569
  "no-index-files": noIndexFiles,
570
+ "no-mutable-union-array-param": noMutableUnionArrayParam,
390
571
  "no-non-barrel-index": noNonBarrelIndex,
391
572
  "no-non-barrel-reexport": noNonBarrelReexport,
573
+ "no-object-assign": noObjectAssign,
392
574
  "no-pointless-reassignment": {
393
575
  meta: {
394
576
  type: "problem",
@@ -405,6 +587,7 @@ const plugin = {
405
587
  if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
406
588
  const aliasName = node.id.name;
407
589
  const originalName = node.init.name;
590
+ const aliasIsAnnotated = hasTypeAnnotation(node.id);
408
591
  context.report({
409
592
  node,
410
593
  messageId: "pointlessReassignment",
@@ -415,6 +598,7 @@ const plugin = {
415
598
  fix(fixer) {
416
599
  const variable = scope.set.get(aliasName);
417
600
  if (!variable) return null;
601
+ if (aliasIsAnnotated) return null;
418
602
  if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
419
603
  const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
420
604
  if (readRefs.some((reference) => {
@@ -430,10 +614,11 @@ const plugin = {
430
614
  }
431
615
  return false;
432
616
  })) return null;
617
+ if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
433
618
  const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
434
619
  const declaration = node.parent;
435
620
  if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
436
- fixes.push(fixer.remove(declaration));
621
+ fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
437
622
  return fixes;
438
623
  }
439
624
  });
@@ -465,6 +650,8 @@ const plugin = {
465
650
  linterOptions: { noInlineConfig: true },
466
651
  rules: {
467
652
  "exadev/barrel-policy": ["error", { mode: "banned" }],
653
+ "exadev/no-mutable-union-array-param": "error",
654
+ "exadev/no-object-assign": "error",
468
655
  "exadev/no-pointless-reassignment": "error"
469
656
  }
470
657
  };
@@ -488,9 +675,13 @@ const recommendedTypeChecked = [
488
675
  linterOptions: { noInlineConfig: true },
489
676
  rules: {
490
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",
491
681
  "exadev/no-pointless-reassignment": "error",
492
682
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
493
- "@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"]
494
685
  }
495
686
  },
496
687
  {
package/dist/index.d.cts CHANGED
@@ -1,10 +1,10 @@
1
- import { ESLint } from "eslint";
1
+ import { TSESLint } from "@typescript-eslint/utils";
2
2
  //#region src/recommended-type-checked.d.ts
3
- type ConfigValue = NonNullable<ESLint.Plugin['configs']>[string];
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: ESLint.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 { ESLint } from "eslint";
1
+ import { TSESLint } from "@typescript-eslint/utils";
2
2
  //#region src/recommended-type-checked.d.ts
3
- type ConfigValue = NonNullable<ESLint.Plugin['configs']>[string];
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: ESLint.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.1.1";
6
+ var version = "2.2.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,10 +443,89 @@ 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";
350
519
  }
520
+ function hasTypeAnnotation(id) {
521
+ return "typeAnnotation" in id && id.typeAnnotation !== void 0 && id.typeAnnotation !== null;
522
+ }
523
+ function resolveFrom(scope, name) {
524
+ for (let current = scope; current; current = current.upper) {
525
+ const found = current.set.get(name);
526
+ if (found) return found;
527
+ }
528
+ }
351
529
  //#endregion
352
530
  //#region src/plugin.ts
353
531
  const plugin = {
@@ -359,9 +537,12 @@ const plugin = {
359
537
  rules: {
360
538
  "barrel-direct-siblings-only": barrelDirectSiblingsOnly,
361
539
  "barrel-policy": barrelPolicy,
540
+ "no-enum-number-widening": noEnumNumberWidening,
362
541
  "no-index-files": noIndexFiles,
542
+ "no-mutable-union-array-param": noMutableUnionArrayParam,
363
543
  "no-non-barrel-index": noNonBarrelIndex,
364
544
  "no-non-barrel-reexport": noNonBarrelReexport,
545
+ "no-object-assign": noObjectAssign,
365
546
  "no-pointless-reassignment": {
366
547
  meta: {
367
548
  type: "problem",
@@ -378,6 +559,7 @@ const plugin = {
378
559
  if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && !reference.init)) return;
379
560
  const aliasName = node.id.name;
380
561
  const originalName = node.init.name;
562
+ const aliasIsAnnotated = hasTypeAnnotation(node.id);
381
563
  context.report({
382
564
  node,
383
565
  messageId: "pointlessReassignment",
@@ -388,6 +570,7 @@ const plugin = {
388
570
  fix(fixer) {
389
571
  const variable = scope.set.get(aliasName);
390
572
  if (!variable) return null;
573
+ if (aliasIsAnnotated) return null;
391
574
  if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
392
575
  const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
393
576
  if (readRefs.some((reference) => {
@@ -403,10 +586,11 @@ const plugin = {
403
586
  }
404
587
  return false;
405
588
  })) return null;
589
+ if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
406
590
  const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
407
591
  const declaration = node.parent;
408
592
  if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
409
- fixes.push(fixer.remove(declaration));
593
+ fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
410
594
  return fixes;
411
595
  }
412
596
  });
@@ -438,6 +622,8 @@ const plugin = {
438
622
  linterOptions: { noInlineConfig: true },
439
623
  rules: {
440
624
  "exadev/barrel-policy": ["error", { mode: "banned" }],
625
+ "exadev/no-mutable-union-array-param": "error",
626
+ "exadev/no-object-assign": "error",
441
627
  "exadev/no-pointless-reassignment": "error"
442
628
  }
443
629
  };
@@ -461,9 +647,13 @@ const recommendedTypeChecked = [
461
647
  linterOptions: { noInlineConfig: true },
462
648
  rules: {
463
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",
464
653
  "exadev/no-pointless-reassignment": "error",
465
654
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
466
- "@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"]
467
657
  }
468
658
  },
469
659
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
- "version": "2.1.1",
3
+ "version": "2.2.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.65.0",
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",