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 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/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 (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
  ]);
@@ -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.1.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 { 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.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.1.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.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",