exadev-eslint-config 2.4.0 → 2.5.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-object-assign`, `exadev/no-mutable-union-array-param`, `exadev/no-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-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, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/no-deprecated`, `@typescript-eslint/no-misused-spread`, `@typescript-eslint/no-mixed-enums`, `@typescript-eslint/no-unnecessary-condition`, `@typescript-eslint/prefer-readonly`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/use-unknown-in-catch-callback-variable`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), and `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`) -- the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers` is not (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-array-isarray-mutation`, `exadev/no-enum-number-widening`, `exadev/no-enum-reverse-lookup-widening`, `exadev/no-map-instanceof-mutation`, `exadev/no-set-instanceof-mutation`, `exadev/prefer-readonly-array-param`, `exadev/prefer-numeric-sort-compare`, `exadev/no-pointless-reassignment`, `linterOptions.noInlineConfig`, `@typescript-eslint/consistent-type-assertions` banning all type assertions, `@typescript-eslint/consistent-type-imports`, `@typescript-eslint/consistent-type-exports`, `@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, `@typescript-eslint/method-signature-style` set to `'property'` (method-shorthand signatures are checked bivariantly under `strictFunctionTypes`, which is unsound), `@typescript-eslint/no-deprecated`, `@typescript-eslint/no-misused-spread`, `@typescript-eslint/no-mixed-enums`, `@typescript-eslint/no-unnecessary-condition`, `@typescript-eslint/prefer-readonly`, `@typescript-eslint/promise-function-async`, `@typescript-eslint/require-array-sort-compare`, `@typescript-eslint/switch-exhaustiveness-check`, `@typescript-eslint/use-unknown-in-catch-callback-variable`, `@typescript-eslint/strict-boolean-expressions` at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), and `@typescript-eslint/no-magic-numbers` tuned to exempt array indexes, enum members, readonly class properties, default parameter values, and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`) -- the type-assertion and ts-comment rules are relaxed in test files, and `no-magic-numbers` is not (see below). Spread it directly into `tseslint.config(...)`:
20
20
 
21
21
  ```ts
22
22
  // eslint.config.ts
@@ -114,9 +114,13 @@ export default tseslint.config(
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
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
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
+ | `prefer-readonly-array-param` | ✓ | A narrower, safely-autofixable sibling of `@typescript-eslint/prefer-readonly-parameter-types` scoped to array/tuple parameter shapes only: fires unconditionally on every non-readonly array or tuple parameter, regardless of whether the function body mutates it, in any parameter position (a plain identifier, a rest parameter, a default-valued parameter, or a constructor parameter property) and any function-like shape (a concrete function/arrow/method, or a declaration-only ambient function, interface method, function type alias, call/construct signature, or abstract/ambient class method). A union containing an array/tuple member is fixed on that member alone. Autofix prepends `readonly ` (or renames `Array<T>` to `ReadonlyArray<T>`), turning any resulting mutation into a real compile error to resolve deliberately. Requires no type information -- registered in both `plugin.configs.recommended` and the default (type-checked) export. |
117
118
  | `no-array-isarray-mutation` | | `Array.isArray`'s own type declaration narrows to plain `any[]`, discarding the `readonly` guarantee of any array type in the narrowed parameter's real type -- a bare `readonly T[]`, a `ReadonlyArray<T>`, one behind a type alias, or one alongside other union members -- inside the guarded branch; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` there can mutate a caller's genuinely readonly array. Recognises the direct `if (Array.isArray(x))` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: re-adding `readonly` is a no-op (the guard already discarded it) and rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended` -- specifically to see through a type alias and to catch a bare, non-union readonly array parameter, neither visible from the parameter's own syntax alone. |
119
+ | `no-map-instanceof-mutation` | | `Map` is declared as extending `ReadonlyMap`, so `instanceof Map` narrows a parameter whose real type includes a `ReadonlyMap` -- bare, unioned, or reached through a type alias -- straight past the readonly guarantee to the full mutable interface; calling `set`/`delete`/`clear` there can mutate a caller's genuinely read-only map. Recognises the direct `if (input instanceof Map)` guard (braced or not), the early-return/early-throw idiom, `&&`, the ternary form, and the else-of-a-negated-test form. No autofix: rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
120
+ | `no-set-instanceof-mutation` | | `instanceof Set` narrows a parameter whose real type includes a `ReadonlySet` -- bare, unioned, or reached through a type alias -- straight to the fully mutable `Set` interface, with no way to preserve the read-only guarantee through the narrowing; calling `add`/`delete`/`clear` there can mutate a caller's genuinely read-only set. Recognises the same guard idioms as `no-map-instanceof-mutation` above. No autofix, for the same aliasing reason. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
118
121
  | `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`. |
119
122
  | `no-enum-reverse-lookup-widening` | suggestion | Indexing a numeric enum's reverse mapping (`Direction[n]`) with a bare (non-literal) `number`, or with a different enum's member, types as plain `string` for any index, including one outside the enum's actual members, where it genuinely returns `undefined` at runtime -- `tsc` does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit `: string` annotation, a suggestion widens it to `: string \| undefined`, forcing later uses as a bare `string` to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full `--fix` autofix. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`. |
123
+ | `prefer-numeric-sort-compare` | suggestion | A deliberately narrow addition alongside `@typescript-eslint/require-array-sort-compare` (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix -- descending order is a real, if less common, alternative intent. Requires type information -- only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
120
124
 
121
125
  ## Barrel policy
122
126
 
package/dist/index.cjs CHANGED
@@ -30,8 +30,9 @@ let node_path = require("node:path");
30
30
  let _typescript_eslint_utils = require("@typescript-eslint/utils");
31
31
  let typescript = require("typescript");
32
32
  typescript = __toESM(typescript, 1);
33
+ let ts_api_utils = require("ts-api-utils");
33
34
  //#region package.json
34
- var version = "2.4.0";
35
+ var version = "2.5.0";
35
36
  //#endregion
36
37
  //#region src/rules/barrel-helpers.ts
37
38
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
@@ -275,19 +276,19 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
275
276
  "fill",
276
277
  "copyWithin"
277
278
  ]);
278
- const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
279
+ const createRule$6 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
279
280
  function isArrayIsArrayCall(node) {
280
281
  return node.type === _typescript_eslint_utils.AST_NODE_TYPES.CallExpression && node.callee.type === _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.callee.object.name === "Array" && node.callee.property.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.callee.property.name === "isArray";
281
282
  }
282
- function definitelyExits(statement) {
283
+ function definitelyExits$2(statement) {
283
284
  if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ReturnStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ThrowStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ContinueStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BreakStatement) return true;
284
285
  if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement) {
285
286
  const last = statement.body.at(-1);
286
- return last !== void 0 && definitelyExits(last);
287
+ return last !== void 0 && definitelyExits$2(last);
287
288
  }
288
289
  return false;
289
290
  }
290
- const noArrayIsArrayMutation = createRule$2({
291
+ const noArrayIsArrayMutation = createRule$6({
291
292
  name: "no-array-isarray-mutation",
292
293
  meta: {
293
294
  type: "problem",
@@ -360,7 +361,7 @@ const noArrayIsArrayMutation = createRule$2({
360
361
  }
361
362
  if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
362
363
  const sibling = statements[i];
363
- if (sibling?.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedArrayIsArrayCall(sibling.test, parameterVariable, ruleContext) && definitelyExits(sibling.consequent)) return true;
364
+ if (sibling?.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedArrayIsArrayCall(sibling.test, parameterVariable, ruleContext) && definitelyExits$2(sibling.consequent)) return true;
364
365
  }
365
366
  }
366
367
  current = parent;
@@ -494,6 +495,107 @@ const noIndexFiles = {
494
495
  }
495
496
  };
496
497
  //#endregion
498
+ //#region src/rules/no-map-instanceof-mutation.ts
499
+ const createRule$5 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
500
+ const MUTATING_MAP_METHODS = /* @__PURE__ */ new Set([
501
+ "set",
502
+ "delete",
503
+ "clear"
504
+ ]);
505
+ function isInstanceofMapExpression(node) {
506
+ return node.type === _typescript_eslint_utils.AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof" && node.right.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.right.name === "Map";
507
+ }
508
+ function definitelyExits$1(statement) {
509
+ if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ReturnStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ThrowStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ContinueStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BreakStatement) return true;
510
+ if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement) {
511
+ const last = statement.body.at(-1);
512
+ return last !== void 0 && definitelyExits$1(last);
513
+ }
514
+ return false;
515
+ }
516
+ const noMapInstanceofMutation = createRule$5({
517
+ name: "no-map-instanceof-mutation",
518
+ meta: {
519
+ type: "problem",
520
+ schema: [],
521
+ docs: { description: "Disallow mutating calls on a parameter whose real type includes a ReadonlyMap, narrowed via `instanceof Map`, which silently discards the declared readonly guarantee." },
522
+ messages: { unsound: "'{{ method }}' mutates a parameter narrowed by 'instanceof Map' -- Map is declared as extending ReadonlyMap, so 'instanceof Map' narrows straight past the readonly guarantee to the full mutable interface, and a caller's genuinely read-only ReadonlyMap can be mutated here even though the parameter's real type includes ReadonlyMap. Copy the map before mutating (e.g. `new Map(input)`), or narrow with a check that preserves readonly instead of 'instanceof Map'." }
523
+ },
524
+ defaultOptions: [],
525
+ create(context) {
526
+ const services = _typescript_eslint_utils.ESLintUtils.getParserServices(context);
527
+ const checker = services.program.getTypeChecker();
528
+ function parameterHasReadonlyMapConstituent(parameterNode) {
529
+ const tsNode = services.esTreeNodeToTSNodeMap.get(parameterNode);
530
+ const parameterType = checker.getTypeAtLocation(tsNode);
531
+ return (parameterType.isUnion() ? parameterType.types : [parameterType]).some((constituent) => constituent.getSymbol()?.name === "ReadonlyMap");
532
+ }
533
+ return { CallExpression(node) {
534
+ const { callee } = node;
535
+ 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_MAP_METHODS.has(callee.property.name)) return;
536
+ const variable = context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved;
537
+ if (!variable) return;
538
+ const parameterDefinition = variable.defs.find((definition) => definition.type === _typescript_eslint_utils.TSESLint.Scope.DefinitionType.Parameter);
539
+ if (!parameterDefinition) return;
540
+ const parameterNode = parameterDefinition.name;
541
+ if (parameterNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return;
542
+ if (!parameterHasReadonlyMapConstituent(parameterNode)) return;
543
+ if (!isGuardedByInstanceofMap(node, variable, context)) return;
544
+ context.report({
545
+ node,
546
+ messageId: "unsound",
547
+ data: { method: callee.property.name }
548
+ });
549
+ } };
550
+ function resolvesToVariable(identifier, target, atNode, ruleContext) {
551
+ return ruleContext.sourceCode.getScope(atNode).references.find((reference) => reference.identifier === identifier)?.resolved === target;
552
+ }
553
+ function isNegatedInstanceofMapExpression(testNode, target, ruleContext) {
554
+ if (testNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.UnaryExpression || testNode.operator !== "!") return false;
555
+ return matchesInstanceofMapOn(testNode.argument, target, ruleContext);
556
+ }
557
+ function matchesInstanceofMapOn(testNode, target, ruleContext) {
558
+ if (!isInstanceofMapExpression(testNode)) return false;
559
+ const { left } = testNode;
560
+ return left.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && resolvesToVariable(left, target, testNode, ruleContext);
561
+ }
562
+ function isGuardedByInstanceofMap(startNode, parameterVariable, ruleContext) {
563
+ let current = startNode;
564
+ while (current.parent) {
565
+ const { parent } = current;
566
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement) {
567
+ if (parent.consequent === current && matchesInstanceofMapOn(parent.test, parameterVariable, ruleContext)) return true;
568
+ if (parent.alternate === current && isNegatedInstanceofMapExpression(parent.test, parameterVariable, ruleContext)) return true;
569
+ }
570
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.LogicalExpression && parent.operator === "&&" && parent.right === current && matchesInstanceofMapOn(parent.left, parameterVariable, ruleContext)) return true;
571
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.ConditionalExpression && parent.consequent === current && matchesInstanceofMapOn(parent.test, parameterVariable, ruleContext)) return true;
572
+ current = parent;
573
+ }
574
+ return isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext);
575
+ }
576
+ function isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext) {
577
+ let current = startNode;
578
+ while (current.parent) {
579
+ const { parent } = current;
580
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement || parent.type === _typescript_eslint_utils.AST_NODE_TYPES.Program) {
581
+ const statements = parent.body;
582
+ let ownIndex = -1;
583
+ for (let i = 0; i < statements.length; i++) if (statements[i] === current) {
584
+ ownIndex = i;
585
+ break;
586
+ }
587
+ if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
588
+ const sibling = statements[i];
589
+ if (sibling?.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedInstanceofMapExpression(sibling.test, parameterVariable, ruleContext) && definitelyExits$1(sibling.consequent)) return true;
590
+ }
591
+ }
592
+ current = parent;
593
+ }
594
+ return false;
595
+ }
596
+ }
597
+ });
598
+ //#endregion
497
599
  //#region src/rules/no-mutable-union-array-param.ts
498
600
  const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
499
601
  "push",
@@ -502,7 +604,7 @@ const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
502
604
  "fill",
503
605
  "copyWithin"
504
606
  ]);
505
- const createRule$1 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
607
+ const createRule$4 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
506
608
  function isUnionArrayType(typeAnnotation) {
507
609
  if (typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType && typeAnnotation.elementType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return typeAnnotation.elementType;
508
610
  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) {
@@ -510,7 +612,7 @@ function isUnionArrayType(typeAnnotation) {
510
612
  if (firstParam?.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return firstParam;
511
613
  }
512
614
  }
513
- const noMutableUnionArrayParam = createRule$1({
615
+ const noMutableUnionArrayParam = createRule$4({
514
616
  name: "no-mutable-union-array-param",
515
617
  meta: {
516
618
  type: "problem",
@@ -636,14 +738,14 @@ const noNonBarrelReexport = {
636
738
  };
637
739
  //#endregion
638
740
  //#region src/rules/no-object-assign.ts
639
- const createRule = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
741
+ const createRule$3 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
640
742
  function resolveFrom$1(scope, name) {
641
743
  for (let current = scope; current; current = current.upper) {
642
744
  const found = current.set.get(name);
643
745
  if (found) return found;
644
746
  }
645
747
  }
646
- const noObjectAssign = createRule({
748
+ const noObjectAssign = createRule$3({
647
749
  name: "no-object-assign",
648
750
  meta: {
649
751
  type: "problem",
@@ -718,6 +820,293 @@ function resolveFrom(scope, name) {
718
820
  if (found) return found;
719
821
  }
720
822
  }
823
+ const noPointlessReassignment = {
824
+ meta: {
825
+ type: "problem",
826
+ fixable: "code",
827
+ schema: [],
828
+ messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
829
+ },
830
+ create(context) {
831
+ return { VariableDeclarator(node) {
832
+ if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
833
+ if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
834
+ const scope = context.sourceCode.getScope(node);
835
+ const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
836
+ if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && reference.init !== true)) return;
837
+ const aliasName = node.id.name;
838
+ const originalName = node.init.name;
839
+ const aliasIsAnnotated = hasTypeAnnotation(node.id);
840
+ context.report({
841
+ node,
842
+ messageId: "pointlessReassignment",
843
+ data: {
844
+ name: aliasName,
845
+ value: originalName
846
+ },
847
+ fix(fixer) {
848
+ const variable = scope.set.get(aliasName);
849
+ if (!variable) return null;
850
+ if (aliasIsAnnotated) return null;
851
+ if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
852
+ const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
853
+ if (readRefs.some((reference) => {
854
+ const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
855
+ if (afterToken?.value === ":") return false;
856
+ if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
857
+ let token = context.sourceCode.getTokenBefore(reference.identifier);
858
+ while (token) {
859
+ if (token.value === "{") return true;
860
+ if (token.value === "[" || token.value === "(") return false;
861
+ if (token.value === ":") return false;
862
+ token = context.sourceCode.getTokenBefore(token);
863
+ }
864
+ return false;
865
+ })) return null;
866
+ if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
867
+ const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
868
+ const declaration = node.parent;
869
+ if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
870
+ fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
871
+ return fixes;
872
+ }
873
+ });
874
+ } };
875
+ }
876
+ };
877
+ //#endregion
878
+ //#region src/rules/no-set-instanceof-mutation.ts
879
+ const MUTATING_SET_METHODS = /* @__PURE__ */ new Set([
880
+ "add",
881
+ "delete",
882
+ "clear"
883
+ ]);
884
+ const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
885
+ function isSetInstanceofExpression(node) {
886
+ return node.type === _typescript_eslint_utils.AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof" && node.right.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.right.name === "Set";
887
+ }
888
+ function definitelyExits(statement) {
889
+ if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ReturnStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ThrowStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.ContinueStatement || statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BreakStatement) return true;
890
+ if (statement.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement) {
891
+ const last = statement.body.at(-1);
892
+ return last !== void 0 && definitelyExits(last);
893
+ }
894
+ return false;
895
+ }
896
+ const noSetInstanceofMutation = createRule$2({
897
+ name: "no-set-instanceof-mutation",
898
+ meta: {
899
+ type: "problem",
900
+ schema: [],
901
+ docs: { description: "Disallow mutating calls on a parameter whose real type includes a ReadonlySet, narrowed via instanceof Set, which silently discards the declared read-only guarantee." },
902
+ messages: { unsound: "'{{ method }}' mutates a parameter narrowed by instanceof Set -- instanceof Set's own narrowing widens straight to the mutable Set interface, so a caller's genuinely read-only set can be mutated here even though the parameter's real type includes a ReadonlySet. Copy the set before mutating (e.g. new Set(input)), or narrow with a check that preserves read-only instead of instanceof Set." }
903
+ },
904
+ defaultOptions: [],
905
+ create(context) {
906
+ const services = _typescript_eslint_utils.ESLintUtils.getParserServices(context);
907
+ const checker = services.program.getTypeChecker();
908
+ function parameterHasReadonlySetConstituent(parameterNode) {
909
+ const tsNode = services.esTreeNodeToTSNodeMap.get(parameterNode);
910
+ const parameterType = checker.getTypeAtLocation(tsNode);
911
+ return (parameterType.isUnion() ? parameterType.types : [parameterType]).some((constituent) => constituent.getSymbol()?.name === "ReadonlySet");
912
+ }
913
+ return { CallExpression(node) {
914
+ const { callee } = node;
915
+ 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_SET_METHODS.has(callee.property.name)) return;
916
+ const variable = context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved;
917
+ if (!variable) return;
918
+ const parameterDefinition = variable.defs.find((definition) => definition.type === _typescript_eslint_utils.TSESLint.Scope.DefinitionType.Parameter);
919
+ if (!parameterDefinition) return;
920
+ const parameterNode = parameterDefinition.name;
921
+ if (parameterNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return;
922
+ if (!parameterHasReadonlySetConstituent(parameterNode)) return;
923
+ if (!isGuardedBySetInstanceof(node, variable, context)) return;
924
+ context.report({
925
+ node,
926
+ messageId: "unsound",
927
+ data: { method: callee.property.name }
928
+ });
929
+ } };
930
+ function resolvesToVariable(identifier, target, atNode, ruleContext) {
931
+ return ruleContext.sourceCode.getScope(atNode).references.find((reference) => reference.identifier === identifier)?.resolved === target;
932
+ }
933
+ function isNegatedSetInstanceofExpression(testNode, target, ruleContext) {
934
+ if (testNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.UnaryExpression || testNode.operator !== "!") return false;
935
+ return matchesSetInstanceofOn(testNode.argument, target, ruleContext);
936
+ }
937
+ function matchesSetInstanceofOn(testNode, target, ruleContext) {
938
+ if (!isSetInstanceofExpression(testNode)) return false;
939
+ const { left } = testNode;
940
+ return left.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && resolvesToVariable(left, target, testNode, ruleContext);
941
+ }
942
+ function isGuardedBySetInstanceof(startNode, parameterVariable, ruleContext) {
943
+ let current = startNode;
944
+ while (current.parent) {
945
+ const { parent } = current;
946
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement) {
947
+ if (parent.consequent === current && matchesSetInstanceofOn(parent.test, parameterVariable, ruleContext)) return true;
948
+ if (parent.alternate === current && isNegatedSetInstanceofExpression(parent.test, parameterVariable, ruleContext)) return true;
949
+ }
950
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.LogicalExpression && parent.operator === "&&" && parent.right === current && matchesSetInstanceofOn(parent.left, parameterVariable, ruleContext)) return true;
951
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.ConditionalExpression && parent.consequent === current && matchesSetInstanceofOn(parent.test, parameterVariable, ruleContext)) return true;
952
+ current = parent;
953
+ }
954
+ return isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext);
955
+ }
956
+ function isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext) {
957
+ let current = startNode;
958
+ while (current.parent) {
959
+ const { parent } = current;
960
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement || parent.type === _typescript_eslint_utils.AST_NODE_TYPES.Program) {
961
+ const statements = parent.body;
962
+ let ownIndex = -1;
963
+ for (let i = 0; i < statements.length; i++) if (statements[i] === current) {
964
+ ownIndex = i;
965
+ break;
966
+ }
967
+ if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
968
+ const sibling = statements[i];
969
+ if (sibling?.type === _typescript_eslint_utils.AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedSetInstanceofExpression(sibling.test, parameterVariable, ruleContext) && definitelyExits(sibling.consequent)) return true;
970
+ }
971
+ }
972
+ current = parent;
973
+ }
974
+ return false;
975
+ }
976
+ }
977
+ });
978
+ //#endregion
979
+ //#region src/rules/no-side-effects-in-index.ts
980
+ const noSideEffectsInIndex = {
981
+ meta: {
982
+ type: "problem",
983
+ schema: [],
984
+ messages: { notAPureReexport: "A barrel (index) file may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') -- nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}." }
985
+ },
986
+ create(context) {
987
+ if (!isIndexFile(context.filename)) return {};
988
+ return { Program(node) {
989
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
990
+ node: statement,
991
+ messageId: "notAPureReexport",
992
+ data: { description: statement.type }
993
+ });
994
+ } };
995
+ }
996
+ };
997
+ //#endregion
998
+ //#region src/rules/prefer-numeric-sort-compare.ts
999
+ const createRule$1 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1000
+ const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
1001
+ function isDefinitelyNumberType(type) {
1002
+ if (type.isUnion()) return type.types.every((constituent) => isDefinitelyNumberType(constituent));
1003
+ return (type.flags & typescript.TypeFlags.NumberLike) !== 0;
1004
+ }
1005
+ const preferNumericSortCompare = createRule$1({
1006
+ name: "prefer-numeric-sort-compare",
1007
+ meta: {
1008
+ type: "suggestion",
1009
+ hasSuggestions: true,
1010
+ docs: { description: "Suggest an ascending numeric compare function for a bare '.sort()'/'.toSorted()' call on an array whose element type is definitively 'number' -- the default comparator sorts lexicographically, so a bare numeric sort is essentially always a bug." },
1011
+ schema: [],
1012
+ messages: {
1013
+ preferNumericCompare: "'.{{ method }}()' on a number array with no compare function sorts lexicographically (e.g. [1, 2, 10].sort() becomes [1, 10, 2]), not in ascending numeric order. Provide a compare function.",
1014
+ addAscendingCompare: "Add an ascending numeric compare function: '(a, b) => a - b'."
1015
+ }
1016
+ },
1017
+ defaultOptions: [],
1018
+ create(context) {
1019
+ const services = _typescript_eslint_utils.ESLintUtils.getParserServices(context);
1020
+ const checker = services.program.getTypeChecker();
1021
+ return { CallExpression(node) {
1022
+ if (node.arguments.length > 0) return;
1023
+ const { callee } = node;
1024
+ if (callee.type !== _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression || callee.computed) return;
1025
+ if (callee.property.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || !SORT_METHOD_NAMES.has(callee.property.name)) return;
1026
+ const receiverTsNode = services.esTreeNodeToTSNodeMap.get(callee.object);
1027
+ if (!typescript.isExpression(receiverTsNode)) return;
1028
+ const receiverType = checker.getTypeAtLocation(receiverTsNode);
1029
+ if (!checker.isArrayType(receiverType)) return;
1030
+ if (!(0, ts_api_utils.isTypeReference)(receiverType)) return;
1031
+ const [elementType] = checker.getTypeArguments(receiverType);
1032
+ if (!elementType || !isDefinitelyNumberType(elementType)) return;
1033
+ context.report({
1034
+ node,
1035
+ messageId: "preferNumericCompare",
1036
+ data: { method: callee.property.name },
1037
+ suggest: [{
1038
+ messageId: "addAscendingCompare",
1039
+ fix(fixer) {
1040
+ const closingParen = context.sourceCode.getLastToken(node);
1041
+ if (!closingParen) return null;
1042
+ return fixer.insertTextBefore(closingParen, "(a, b) => a - b");
1043
+ }
1044
+ }]
1045
+ });
1046
+ } };
1047
+ }
1048
+ });
1049
+ //#endregion
1050
+ //#region src/rules/prefer-readonly-array-param.ts
1051
+ const createRule = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1052
+ function getFixableArrayOrTupleType(typeNode) {
1053
+ if (typeNode.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeOperator && typeNode.operator === "readonly") return void 0;
1054
+ if (typeNode.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType) return typeNode;
1055
+ if (typeNode.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTupleType) return typeNode;
1056
+ if (typeNode.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && typeNode.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && typeNode.typeName.name === "Array") return typeNode;
1057
+ }
1058
+ function getFixableTypesForAnnotation(typeNode) {
1059
+ if (typeNode.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return typeNode.types.flatMap(getFixableTypesForAnnotation);
1060
+ const fixable = getFixableArrayOrTupleType(typeNode);
1061
+ return fixable ? [fixable] : [];
1062
+ }
1063
+ function getAnnotatedParamNode(param) {
1064
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.TSParameterProperty) return getAnnotatedParamNode(param.parameter);
1065
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern) return param.left.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier ? param.left : void 0;
1066
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement || param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return param;
1067
+ }
1068
+ const FUNCTION_LIKE_SELECTOR = [
1069
+ "ArrowFunctionExpression",
1070
+ "FunctionDeclaration",
1071
+ "FunctionExpression",
1072
+ "TSCallSignatureDeclaration",
1073
+ "TSConstructSignatureDeclaration",
1074
+ "TSDeclareFunction",
1075
+ "TSEmptyBodyFunctionExpression",
1076
+ "TSFunctionType",
1077
+ "TSMethodSignature"
1078
+ ].join(", ");
1079
+ const preferReadonlyArrayParam = createRule({
1080
+ name: "prefer-readonly-array-param",
1081
+ meta: {
1082
+ type: "problem",
1083
+ fixable: "code",
1084
+ docs: { description: "Require array and tuple parameters to be typed readonly, regardless of whether the function body mutates them -- a narrower, safely-autofixable sibling of @typescript-eslint/prefer-readonly-parameter-types scoped to array/tuple shapes only." },
1085
+ schema: [],
1086
+ messages: { preferReadonly: "Array and tuple parameters should be typed readonly ({{ suggestion }}) so a caller can pass a readonly or shared array with confidence, and so any mutation inside the function becomes a deliberate, visible compile error instead of a silent side effect on the caller's data." }
1087
+ },
1088
+ defaultOptions: [],
1089
+ create(context) {
1090
+ function checkParam(param) {
1091
+ const annotatedNode = getAnnotatedParamNode(param);
1092
+ if (!annotatedNode?.typeAnnotation) return;
1093
+ const fixableTypes = getFixableTypesForAnnotation(annotatedNode.typeAnnotation.typeAnnotation);
1094
+ if (fixableTypes.length === 0) return;
1095
+ const suggestion = fixableTypes.map((fixableType) => fixableType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference ? "ReadonlyArray<T>" : `readonly ${fixableType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTupleType ? "[T, U]" : "T[]"}`).join(" / ");
1096
+ context.report({
1097
+ node: param,
1098
+ messageId: "preferReadonly",
1099
+ data: { suggestion },
1100
+ fix(fixer) {
1101
+ return fixableTypes.map((fixableType) => fixableType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference ? fixer.replaceText(fixableType.typeName, "ReadonlyArray") : fixer.insertTextBefore(fixableType, "readonly "));
1102
+ }
1103
+ });
1104
+ }
1105
+ return { [FUNCTION_LIKE_SELECTOR](node) {
1106
+ for (const param of node.params) checkParam(param);
1107
+ } };
1108
+ }
1109
+ });
721
1110
  //#endregion
722
1111
  //#region src/plugin.ts
723
1112
  const plugin = {
@@ -733,81 +1122,16 @@ const plugin = {
733
1122
  "no-enum-number-widening": noEnumNumberWidening,
734
1123
  "no-enum-reverse-lookup-widening": noEnumReverseLookupWidening,
735
1124
  "no-index-files": noIndexFiles,
1125
+ "no-map-instanceof-mutation": noMapInstanceofMutation,
736
1126
  "no-mutable-union-array-param": noMutableUnionArrayParam,
737
1127
  "no-non-barrel-index": noNonBarrelIndex,
738
1128
  "no-non-barrel-reexport": noNonBarrelReexport,
739
1129
  "no-object-assign": noObjectAssign,
740
- "no-pointless-reassignment": {
741
- meta: {
742
- type: "problem",
743
- fixable: "code",
744
- schema: [],
745
- messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
746
- },
747
- create(context) {
748
- return { VariableDeclarator(node) {
749
- if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
750
- if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
751
- const scope = context.sourceCode.getScope(node);
752
- const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
753
- if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && reference.init !== true)) return;
754
- const aliasName = node.id.name;
755
- const originalName = node.init.name;
756
- const aliasIsAnnotated = hasTypeAnnotation(node.id);
757
- context.report({
758
- node,
759
- messageId: "pointlessReassignment",
760
- data: {
761
- name: aliasName,
762
- value: originalName
763
- },
764
- fix(fixer) {
765
- const variable = scope.set.get(aliasName);
766
- if (!variable) return null;
767
- if (aliasIsAnnotated) return null;
768
- if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
769
- const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
770
- if (readRefs.some((reference) => {
771
- const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
772
- if (afterToken?.value === ":") return false;
773
- if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
774
- let token = context.sourceCode.getTokenBefore(reference.identifier);
775
- while (token) {
776
- if (token.value === "{") return true;
777
- if (token.value === "[" || token.value === "(") return false;
778
- if (token.value === ":") return false;
779
- token = context.sourceCode.getTokenBefore(token);
780
- }
781
- return false;
782
- })) return null;
783
- if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
784
- const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
785
- const declaration = node.parent;
786
- if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
787
- fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
788
- return fixes;
789
- }
790
- });
791
- } };
792
- }
793
- },
794
- "no-side-effects-in-index": {
795
- meta: {
796
- type: "problem",
797
- schema: [],
798
- messages: { notAPureReexport: "A barrel (index) file may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') -- nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}." }
799
- },
800
- create(context) {
801
- if (!isIndexFile(context.filename)) return {};
802
- return { Program(node) {
803
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
804
- node: statement,
805
- messageId: "notAPureReexport",
806
- data: { description: statement.type }
807
- });
808
- } };
809
- }
810
- }
1130
+ "no-pointless-reassignment": noPointlessReassignment,
1131
+ "no-set-instanceof-mutation": noSetInstanceofMutation,
1132
+ "no-side-effects-in-index": noSideEffectsInIndex,
1133
+ "prefer-numeric-sort-compare": preferNumericSortCompare,
1134
+ "prefer-readonly-array-param": preferReadonlyArrayParam
811
1135
  },
812
1136
  configs: {
813
1137
  get recommended() {
@@ -818,7 +1142,8 @@ const plugin = {
818
1142
  "exadev/barrel-policy": ["error", { mode: "banned" }],
819
1143
  "exadev/no-mutable-union-array-param": "error",
820
1144
  "exadev/no-object-assign": "error",
821
- "exadev/no-pointless-reassignment": "error"
1145
+ "exadev/no-pointless-reassignment": "error",
1146
+ "exadev/prefer-readonly-array-param": "error"
822
1147
  }
823
1148
  };
824
1149
  },
@@ -844,11 +1169,17 @@ const recommendedTypeChecked = [
844
1169
  "exadev/no-array-isarray-mutation": "error",
845
1170
  "exadev/no-enum-number-widening": "error",
846
1171
  "exadev/no-enum-reverse-lookup-widening": "error",
1172
+ "exadev/no-map-instanceof-mutation": "error",
847
1173
  "exadev/no-mutable-union-array-param": "error",
848
1174
  "exadev/no-object-assign": "error",
849
1175
  "exadev/no-pointless-reassignment": "error",
1176
+ "exadev/no-set-instanceof-mutation": "error",
1177
+ "exadev/prefer-numeric-sort-compare": "error",
1178
+ "exadev/prefer-readonly-array-param": "error",
850
1179
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
851
1180
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
1181
+ "@typescript-eslint/consistent-type-exports": "error",
1182
+ "@typescript-eslint/consistent-type-imports": "error",
852
1183
  "@typescript-eslint/method-signature-style": ["error", "property"],
853
1184
  "@typescript-eslint/no-deprecated": "error",
854
1185
  "@typescript-eslint/no-magic-numbers": ["error", {
@@ -868,6 +1199,7 @@ const recommendedTypeChecked = [
868
1199
  "@typescript-eslint/no-non-null-assertion": "error",
869
1200
  "@typescript-eslint/no-unnecessary-condition": "error",
870
1201
  "@typescript-eslint/prefer-readonly": "error",
1202
+ "@typescript-eslint/promise-function-async": "error",
871
1203
  "@typescript-eslint/require-array-sort-compare": "error",
872
1204
  "@typescript-eslint/strict-boolean-expressions": "error",
873
1205
  "@typescript-eslint/switch-exhaustiveness-check": "error",
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@ import tseslint from "typescript-eslint";
2
2
  import { posix } from "node:path";
3
3
  import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
4
4
  import * as ts from "typescript";
5
+ import { isTypeReference } from "ts-api-utils";
5
6
  //#region package.json
6
- var version = "2.4.0";
7
+ var version = "2.5.0";
7
8
  //#endregion
8
9
  //#region src/rules/barrel-helpers.ts
9
10
  const INDEX_BASENAME$1 = /^index\.[cm]?[tj]sx?$/;
@@ -247,19 +248,19 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
247
248
  "fill",
248
249
  "copyWithin"
249
250
  ]);
250
- const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
251
+ const createRule$6 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
251
252
  function isArrayIsArrayCall(node) {
252
253
  return node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === AST_NODE_TYPES.Identifier && node.callee.object.name === "Array" && node.callee.property.type === AST_NODE_TYPES.Identifier && node.callee.property.name === "isArray";
253
254
  }
254
- function definitelyExits(statement) {
255
+ function definitelyExits$2(statement) {
255
256
  if (statement.type === AST_NODE_TYPES.ReturnStatement || statement.type === AST_NODE_TYPES.ThrowStatement || statement.type === AST_NODE_TYPES.ContinueStatement || statement.type === AST_NODE_TYPES.BreakStatement) return true;
256
257
  if (statement.type === AST_NODE_TYPES.BlockStatement) {
257
258
  const last = statement.body.at(-1);
258
- return last !== void 0 && definitelyExits(last);
259
+ return last !== void 0 && definitelyExits$2(last);
259
260
  }
260
261
  return false;
261
262
  }
262
- const noArrayIsArrayMutation = createRule$2({
263
+ const noArrayIsArrayMutation = createRule$6({
263
264
  name: "no-array-isarray-mutation",
264
265
  meta: {
265
266
  type: "problem",
@@ -332,7 +333,7 @@ const noArrayIsArrayMutation = createRule$2({
332
333
  }
333
334
  if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
334
335
  const sibling = statements[i];
335
- if (sibling?.type === AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedArrayIsArrayCall(sibling.test, parameterVariable, ruleContext) && definitelyExits(sibling.consequent)) return true;
336
+ if (sibling?.type === AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedArrayIsArrayCall(sibling.test, parameterVariable, ruleContext) && definitelyExits$2(sibling.consequent)) return true;
336
337
  }
337
338
  }
338
339
  current = parent;
@@ -466,6 +467,107 @@ const noIndexFiles = {
466
467
  }
467
468
  };
468
469
  //#endregion
470
+ //#region src/rules/no-map-instanceof-mutation.ts
471
+ const createRule$5 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
472
+ const MUTATING_MAP_METHODS = /* @__PURE__ */ new Set([
473
+ "set",
474
+ "delete",
475
+ "clear"
476
+ ]);
477
+ function isInstanceofMapExpression(node) {
478
+ return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof" && node.right.type === AST_NODE_TYPES.Identifier && node.right.name === "Map";
479
+ }
480
+ function definitelyExits$1(statement) {
481
+ if (statement.type === AST_NODE_TYPES.ReturnStatement || statement.type === AST_NODE_TYPES.ThrowStatement || statement.type === AST_NODE_TYPES.ContinueStatement || statement.type === AST_NODE_TYPES.BreakStatement) return true;
482
+ if (statement.type === AST_NODE_TYPES.BlockStatement) {
483
+ const last = statement.body.at(-1);
484
+ return last !== void 0 && definitelyExits$1(last);
485
+ }
486
+ return false;
487
+ }
488
+ const noMapInstanceofMutation = createRule$5({
489
+ name: "no-map-instanceof-mutation",
490
+ meta: {
491
+ type: "problem",
492
+ schema: [],
493
+ docs: { description: "Disallow mutating calls on a parameter whose real type includes a ReadonlyMap, narrowed via `instanceof Map`, which silently discards the declared readonly guarantee." },
494
+ messages: { unsound: "'{{ method }}' mutates a parameter narrowed by 'instanceof Map' -- Map is declared as extending ReadonlyMap, so 'instanceof Map' narrows straight past the readonly guarantee to the full mutable interface, and a caller's genuinely read-only ReadonlyMap can be mutated here even though the parameter's real type includes ReadonlyMap. Copy the map before mutating (e.g. `new Map(input)`), or narrow with a check that preserves readonly instead of 'instanceof Map'." }
495
+ },
496
+ defaultOptions: [],
497
+ create(context) {
498
+ const services = ESLintUtils.getParserServices(context);
499
+ const checker = services.program.getTypeChecker();
500
+ function parameterHasReadonlyMapConstituent(parameterNode) {
501
+ const tsNode = services.esTreeNodeToTSNodeMap.get(parameterNode);
502
+ const parameterType = checker.getTypeAtLocation(tsNode);
503
+ return (parameterType.isUnion() ? parameterType.types : [parameterType]).some((constituent) => constituent.getSymbol()?.name === "ReadonlyMap");
504
+ }
505
+ return { CallExpression(node) {
506
+ const { callee } = node;
507
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.object.type !== AST_NODE_TYPES.Identifier || callee.property.type !== AST_NODE_TYPES.Identifier || !MUTATING_MAP_METHODS.has(callee.property.name)) return;
508
+ const variable = context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved;
509
+ if (!variable) return;
510
+ const parameterDefinition = variable.defs.find((definition) => definition.type === TSESLint.Scope.DefinitionType.Parameter);
511
+ if (!parameterDefinition) return;
512
+ const parameterNode = parameterDefinition.name;
513
+ if (parameterNode.type !== AST_NODE_TYPES.Identifier) return;
514
+ if (!parameterHasReadonlyMapConstituent(parameterNode)) return;
515
+ if (!isGuardedByInstanceofMap(node, variable, context)) return;
516
+ context.report({
517
+ node,
518
+ messageId: "unsound",
519
+ data: { method: callee.property.name }
520
+ });
521
+ } };
522
+ function resolvesToVariable(identifier, target, atNode, ruleContext) {
523
+ return ruleContext.sourceCode.getScope(atNode).references.find((reference) => reference.identifier === identifier)?.resolved === target;
524
+ }
525
+ function isNegatedInstanceofMapExpression(testNode, target, ruleContext) {
526
+ if (testNode.type !== AST_NODE_TYPES.UnaryExpression || testNode.operator !== "!") return false;
527
+ return matchesInstanceofMapOn(testNode.argument, target, ruleContext);
528
+ }
529
+ function matchesInstanceofMapOn(testNode, target, ruleContext) {
530
+ if (!isInstanceofMapExpression(testNode)) return false;
531
+ const { left } = testNode;
532
+ return left.type === AST_NODE_TYPES.Identifier && resolvesToVariable(left, target, testNode, ruleContext);
533
+ }
534
+ function isGuardedByInstanceofMap(startNode, parameterVariable, ruleContext) {
535
+ let current = startNode;
536
+ while (current.parent) {
537
+ const { parent } = current;
538
+ if (parent.type === AST_NODE_TYPES.IfStatement) {
539
+ if (parent.consequent === current && matchesInstanceofMapOn(parent.test, parameterVariable, ruleContext)) return true;
540
+ if (parent.alternate === current && isNegatedInstanceofMapExpression(parent.test, parameterVariable, ruleContext)) return true;
541
+ }
542
+ if (parent.type === AST_NODE_TYPES.LogicalExpression && parent.operator === "&&" && parent.right === current && matchesInstanceofMapOn(parent.left, parameterVariable, ruleContext)) return true;
543
+ if (parent.type === AST_NODE_TYPES.ConditionalExpression && parent.consequent === current && matchesInstanceofMapOn(parent.test, parameterVariable, ruleContext)) return true;
544
+ current = parent;
545
+ }
546
+ return isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext);
547
+ }
548
+ function isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext) {
549
+ let current = startNode;
550
+ while (current.parent) {
551
+ const { parent } = current;
552
+ if (parent.type === AST_NODE_TYPES.BlockStatement || parent.type === AST_NODE_TYPES.Program) {
553
+ const statements = parent.body;
554
+ let ownIndex = -1;
555
+ for (let i = 0; i < statements.length; i++) if (statements[i] === current) {
556
+ ownIndex = i;
557
+ break;
558
+ }
559
+ if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
560
+ const sibling = statements[i];
561
+ if (sibling?.type === AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedInstanceofMapExpression(sibling.test, parameterVariable, ruleContext) && definitelyExits$1(sibling.consequent)) return true;
562
+ }
563
+ }
564
+ current = parent;
565
+ }
566
+ return false;
567
+ }
568
+ }
569
+ });
570
+ //#endregion
469
571
  //#region src/rules/no-mutable-union-array-param.ts
470
572
  const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
471
573
  "push",
@@ -474,7 +576,7 @@ const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
474
576
  "fill",
475
577
  "copyWithin"
476
578
  ]);
477
- const createRule$1 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
579
+ const createRule$4 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
478
580
  function isUnionArrayType(typeAnnotation) {
479
581
  if (typeAnnotation.type === AST_NODE_TYPES.TSArrayType && typeAnnotation.elementType.type === AST_NODE_TYPES.TSUnionType) return typeAnnotation.elementType;
480
582
  if (typeAnnotation.type === AST_NODE_TYPES.TSTypeReference && typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier && typeAnnotation.typeName.name === "Array" && typeAnnotation.typeArguments?.params.length === 1) {
@@ -482,7 +584,7 @@ function isUnionArrayType(typeAnnotation) {
482
584
  if (firstParam?.type === AST_NODE_TYPES.TSUnionType) return firstParam;
483
585
  }
484
586
  }
485
- const noMutableUnionArrayParam = createRule$1({
587
+ const noMutableUnionArrayParam = createRule$4({
486
588
  name: "no-mutable-union-array-param",
487
589
  meta: {
488
590
  type: "problem",
@@ -608,14 +710,14 @@ const noNonBarrelReexport = {
608
710
  };
609
711
  //#endregion
610
712
  //#region src/rules/no-object-assign.ts
611
- const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
713
+ const createRule$3 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
612
714
  function resolveFrom$1(scope, name) {
613
715
  for (let current = scope; current; current = current.upper) {
614
716
  const found = current.set.get(name);
615
717
  if (found) return found;
616
718
  }
617
719
  }
618
- const noObjectAssign = createRule({
720
+ const noObjectAssign = createRule$3({
619
721
  name: "no-object-assign",
620
722
  meta: {
621
723
  type: "problem",
@@ -690,6 +792,293 @@ function resolveFrom(scope, name) {
690
792
  if (found) return found;
691
793
  }
692
794
  }
795
+ const noPointlessReassignment = {
796
+ meta: {
797
+ type: "problem",
798
+ fixable: "code",
799
+ schema: [],
800
+ messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
801
+ },
802
+ create(context) {
803
+ return { VariableDeclarator(node) {
804
+ if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
805
+ if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
806
+ const scope = context.sourceCode.getScope(node);
807
+ const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
808
+ if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && reference.init !== true)) return;
809
+ const aliasName = node.id.name;
810
+ const originalName = node.init.name;
811
+ const aliasIsAnnotated = hasTypeAnnotation(node.id);
812
+ context.report({
813
+ node,
814
+ messageId: "pointlessReassignment",
815
+ data: {
816
+ name: aliasName,
817
+ value: originalName
818
+ },
819
+ fix(fixer) {
820
+ const variable = scope.set.get(aliasName);
821
+ if (!variable) return null;
822
+ if (aliasIsAnnotated) return null;
823
+ if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
824
+ const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
825
+ if (readRefs.some((reference) => {
826
+ const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
827
+ if (afterToken?.value === ":") return false;
828
+ if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
829
+ let token = context.sourceCode.getTokenBefore(reference.identifier);
830
+ while (token) {
831
+ if (token.value === "{") return true;
832
+ if (token.value === "[" || token.value === "(") return false;
833
+ if (token.value === ":") return false;
834
+ token = context.sourceCode.getTokenBefore(token);
835
+ }
836
+ return false;
837
+ })) return null;
838
+ if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
839
+ const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
840
+ const declaration = node.parent;
841
+ if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
842
+ fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
843
+ return fixes;
844
+ }
845
+ });
846
+ } };
847
+ }
848
+ };
849
+ //#endregion
850
+ //#region src/rules/no-set-instanceof-mutation.ts
851
+ const MUTATING_SET_METHODS = /* @__PURE__ */ new Set([
852
+ "add",
853
+ "delete",
854
+ "clear"
855
+ ]);
856
+ const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
857
+ function isSetInstanceofExpression(node) {
858
+ return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof" && node.right.type === AST_NODE_TYPES.Identifier && node.right.name === "Set";
859
+ }
860
+ function definitelyExits(statement) {
861
+ if (statement.type === AST_NODE_TYPES.ReturnStatement || statement.type === AST_NODE_TYPES.ThrowStatement || statement.type === AST_NODE_TYPES.ContinueStatement || statement.type === AST_NODE_TYPES.BreakStatement) return true;
862
+ if (statement.type === AST_NODE_TYPES.BlockStatement) {
863
+ const last = statement.body.at(-1);
864
+ return last !== void 0 && definitelyExits(last);
865
+ }
866
+ return false;
867
+ }
868
+ const noSetInstanceofMutation = createRule$2({
869
+ name: "no-set-instanceof-mutation",
870
+ meta: {
871
+ type: "problem",
872
+ schema: [],
873
+ docs: { description: "Disallow mutating calls on a parameter whose real type includes a ReadonlySet, narrowed via instanceof Set, which silently discards the declared read-only guarantee." },
874
+ messages: { unsound: "'{{ method }}' mutates a parameter narrowed by instanceof Set -- instanceof Set's own narrowing widens straight to the mutable Set interface, so a caller's genuinely read-only set can be mutated here even though the parameter's real type includes a ReadonlySet. Copy the set before mutating (e.g. new Set(input)), or narrow with a check that preserves read-only instead of instanceof Set." }
875
+ },
876
+ defaultOptions: [],
877
+ create(context) {
878
+ const services = ESLintUtils.getParserServices(context);
879
+ const checker = services.program.getTypeChecker();
880
+ function parameterHasReadonlySetConstituent(parameterNode) {
881
+ const tsNode = services.esTreeNodeToTSNodeMap.get(parameterNode);
882
+ const parameterType = checker.getTypeAtLocation(tsNode);
883
+ return (parameterType.isUnion() ? parameterType.types : [parameterType]).some((constituent) => constituent.getSymbol()?.name === "ReadonlySet");
884
+ }
885
+ return { CallExpression(node) {
886
+ const { callee } = node;
887
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.object.type !== AST_NODE_TYPES.Identifier || callee.property.type !== AST_NODE_TYPES.Identifier || !MUTATING_SET_METHODS.has(callee.property.name)) return;
888
+ const variable = context.sourceCode.getScope(node).references.find((reference) => reference.identifier === callee.object)?.resolved;
889
+ if (!variable) return;
890
+ const parameterDefinition = variable.defs.find((definition) => definition.type === TSESLint.Scope.DefinitionType.Parameter);
891
+ if (!parameterDefinition) return;
892
+ const parameterNode = parameterDefinition.name;
893
+ if (parameterNode.type !== AST_NODE_TYPES.Identifier) return;
894
+ if (!parameterHasReadonlySetConstituent(parameterNode)) return;
895
+ if (!isGuardedBySetInstanceof(node, variable, context)) return;
896
+ context.report({
897
+ node,
898
+ messageId: "unsound",
899
+ data: { method: callee.property.name }
900
+ });
901
+ } };
902
+ function resolvesToVariable(identifier, target, atNode, ruleContext) {
903
+ return ruleContext.sourceCode.getScope(atNode).references.find((reference) => reference.identifier === identifier)?.resolved === target;
904
+ }
905
+ function isNegatedSetInstanceofExpression(testNode, target, ruleContext) {
906
+ if (testNode.type !== AST_NODE_TYPES.UnaryExpression || testNode.operator !== "!") return false;
907
+ return matchesSetInstanceofOn(testNode.argument, target, ruleContext);
908
+ }
909
+ function matchesSetInstanceofOn(testNode, target, ruleContext) {
910
+ if (!isSetInstanceofExpression(testNode)) return false;
911
+ const { left } = testNode;
912
+ return left.type === AST_NODE_TYPES.Identifier && resolvesToVariable(left, target, testNode, ruleContext);
913
+ }
914
+ function isGuardedBySetInstanceof(startNode, parameterVariable, ruleContext) {
915
+ let current = startNode;
916
+ while (current.parent) {
917
+ const { parent } = current;
918
+ if (parent.type === AST_NODE_TYPES.IfStatement) {
919
+ if (parent.consequent === current && matchesSetInstanceofOn(parent.test, parameterVariable, ruleContext)) return true;
920
+ if (parent.alternate === current && isNegatedSetInstanceofExpression(parent.test, parameterVariable, ruleContext)) return true;
921
+ }
922
+ if (parent.type === AST_NODE_TYPES.LogicalExpression && parent.operator === "&&" && parent.right === current && matchesSetInstanceofOn(parent.left, parameterVariable, ruleContext)) return true;
923
+ if (parent.type === AST_NODE_TYPES.ConditionalExpression && parent.consequent === current && matchesSetInstanceofOn(parent.test, parameterVariable, ruleContext)) return true;
924
+ current = parent;
925
+ }
926
+ return isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext);
927
+ }
928
+ function isGuardedByPrecedingEarlyReturn(startNode, parameterVariable, ruleContext) {
929
+ let current = startNode;
930
+ while (current.parent) {
931
+ const { parent } = current;
932
+ if (parent.type === AST_NODE_TYPES.BlockStatement || parent.type === AST_NODE_TYPES.Program) {
933
+ const statements = parent.body;
934
+ let ownIndex = -1;
935
+ for (let i = 0; i < statements.length; i++) if (statements[i] === current) {
936
+ ownIndex = i;
937
+ break;
938
+ }
939
+ if (ownIndex > 0) for (let i = ownIndex - 1; i >= 0; i--) {
940
+ const sibling = statements[i];
941
+ if (sibling?.type === AST_NODE_TYPES.IfStatement && !sibling.alternate && isNegatedSetInstanceofExpression(sibling.test, parameterVariable, ruleContext) && definitelyExits(sibling.consequent)) return true;
942
+ }
943
+ }
944
+ current = parent;
945
+ }
946
+ return false;
947
+ }
948
+ }
949
+ });
950
+ //#endregion
951
+ //#region src/rules/no-side-effects-in-index.ts
952
+ const noSideEffectsInIndex = {
953
+ meta: {
954
+ type: "problem",
955
+ schema: [],
956
+ messages: { notAPureReexport: "A barrel (index) file may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') -- nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}." }
957
+ },
958
+ create(context) {
959
+ if (!isIndexFile(context.filename)) return {};
960
+ return { Program(node) {
961
+ for (const statement of node.body) if (!isPureReexport(statement)) context.report({
962
+ node: statement,
963
+ messageId: "notAPureReexport",
964
+ data: { description: statement.type }
965
+ });
966
+ } };
967
+ }
968
+ };
969
+ //#endregion
970
+ //#region src/rules/prefer-numeric-sort-compare.ts
971
+ const createRule$1 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
972
+ const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
973
+ function isDefinitelyNumberType(type) {
974
+ if (type.isUnion()) return type.types.every((constituent) => isDefinitelyNumberType(constituent));
975
+ return (type.flags & ts.TypeFlags.NumberLike) !== 0;
976
+ }
977
+ const preferNumericSortCompare = createRule$1({
978
+ name: "prefer-numeric-sort-compare",
979
+ meta: {
980
+ type: "suggestion",
981
+ hasSuggestions: true,
982
+ docs: { description: "Suggest an ascending numeric compare function for a bare '.sort()'/'.toSorted()' call on an array whose element type is definitively 'number' -- the default comparator sorts lexicographically, so a bare numeric sort is essentially always a bug." },
983
+ schema: [],
984
+ messages: {
985
+ preferNumericCompare: "'.{{ method }}()' on a number array with no compare function sorts lexicographically (e.g. [1, 2, 10].sort() becomes [1, 10, 2]), not in ascending numeric order. Provide a compare function.",
986
+ addAscendingCompare: "Add an ascending numeric compare function: '(a, b) => a - b'."
987
+ }
988
+ },
989
+ defaultOptions: [],
990
+ create(context) {
991
+ const services = ESLintUtils.getParserServices(context);
992
+ const checker = services.program.getTypeChecker();
993
+ return { CallExpression(node) {
994
+ if (node.arguments.length > 0) return;
995
+ const { callee } = node;
996
+ if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return;
997
+ if (callee.property.type !== AST_NODE_TYPES.Identifier || !SORT_METHOD_NAMES.has(callee.property.name)) return;
998
+ const receiverTsNode = services.esTreeNodeToTSNodeMap.get(callee.object);
999
+ if (!ts.isExpression(receiverTsNode)) return;
1000
+ const receiverType = checker.getTypeAtLocation(receiverTsNode);
1001
+ if (!checker.isArrayType(receiverType)) return;
1002
+ if (!isTypeReference(receiverType)) return;
1003
+ const [elementType] = checker.getTypeArguments(receiverType);
1004
+ if (!elementType || !isDefinitelyNumberType(elementType)) return;
1005
+ context.report({
1006
+ node,
1007
+ messageId: "preferNumericCompare",
1008
+ data: { method: callee.property.name },
1009
+ suggest: [{
1010
+ messageId: "addAscendingCompare",
1011
+ fix(fixer) {
1012
+ const closingParen = context.sourceCode.getLastToken(node);
1013
+ if (!closingParen) return null;
1014
+ return fixer.insertTextBefore(closingParen, "(a, b) => a - b");
1015
+ }
1016
+ }]
1017
+ });
1018
+ } };
1019
+ }
1020
+ });
1021
+ //#endregion
1022
+ //#region src/rules/prefer-readonly-array-param.ts
1023
+ const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1024
+ function getFixableArrayOrTupleType(typeNode) {
1025
+ if (typeNode.type === AST_NODE_TYPES.TSTypeOperator && typeNode.operator === "readonly") return void 0;
1026
+ if (typeNode.type === AST_NODE_TYPES.TSArrayType) return typeNode;
1027
+ if (typeNode.type === AST_NODE_TYPES.TSTupleType) return typeNode;
1028
+ if (typeNode.type === AST_NODE_TYPES.TSTypeReference && typeNode.typeName.type === AST_NODE_TYPES.Identifier && typeNode.typeName.name === "Array") return typeNode;
1029
+ }
1030
+ function getFixableTypesForAnnotation(typeNode) {
1031
+ if (typeNode.type === AST_NODE_TYPES.TSUnionType) return typeNode.types.flatMap(getFixableTypesForAnnotation);
1032
+ const fixable = getFixableArrayOrTupleType(typeNode);
1033
+ return fixable ? [fixable] : [];
1034
+ }
1035
+ function getAnnotatedParamNode(param) {
1036
+ if (param.type === AST_NODE_TYPES.TSParameterProperty) return getAnnotatedParamNode(param.parameter);
1037
+ if (param.type === AST_NODE_TYPES.AssignmentPattern) return param.left.type === AST_NODE_TYPES.Identifier ? param.left : void 0;
1038
+ if (param.type === AST_NODE_TYPES.RestElement || param.type === AST_NODE_TYPES.Identifier) return param;
1039
+ }
1040
+ const FUNCTION_LIKE_SELECTOR = [
1041
+ "ArrowFunctionExpression",
1042
+ "FunctionDeclaration",
1043
+ "FunctionExpression",
1044
+ "TSCallSignatureDeclaration",
1045
+ "TSConstructSignatureDeclaration",
1046
+ "TSDeclareFunction",
1047
+ "TSEmptyBodyFunctionExpression",
1048
+ "TSFunctionType",
1049
+ "TSMethodSignature"
1050
+ ].join(", ");
1051
+ const preferReadonlyArrayParam = createRule({
1052
+ name: "prefer-readonly-array-param",
1053
+ meta: {
1054
+ type: "problem",
1055
+ fixable: "code",
1056
+ docs: { description: "Require array and tuple parameters to be typed readonly, regardless of whether the function body mutates them -- a narrower, safely-autofixable sibling of @typescript-eslint/prefer-readonly-parameter-types scoped to array/tuple shapes only." },
1057
+ schema: [],
1058
+ messages: { preferReadonly: "Array and tuple parameters should be typed readonly ({{ suggestion }}) so a caller can pass a readonly or shared array with confidence, and so any mutation inside the function becomes a deliberate, visible compile error instead of a silent side effect on the caller's data." }
1059
+ },
1060
+ defaultOptions: [],
1061
+ create(context) {
1062
+ function checkParam(param) {
1063
+ const annotatedNode = getAnnotatedParamNode(param);
1064
+ if (!annotatedNode?.typeAnnotation) return;
1065
+ const fixableTypes = getFixableTypesForAnnotation(annotatedNode.typeAnnotation.typeAnnotation);
1066
+ if (fixableTypes.length === 0) return;
1067
+ const suggestion = fixableTypes.map((fixableType) => fixableType.type === AST_NODE_TYPES.TSTypeReference ? "ReadonlyArray<T>" : `readonly ${fixableType.type === AST_NODE_TYPES.TSTupleType ? "[T, U]" : "T[]"}`).join(" / ");
1068
+ context.report({
1069
+ node: param,
1070
+ messageId: "preferReadonly",
1071
+ data: { suggestion },
1072
+ fix(fixer) {
1073
+ return fixableTypes.map((fixableType) => fixableType.type === AST_NODE_TYPES.TSTypeReference ? fixer.replaceText(fixableType.typeName, "ReadonlyArray") : fixer.insertTextBefore(fixableType, "readonly "));
1074
+ }
1075
+ });
1076
+ }
1077
+ return { [FUNCTION_LIKE_SELECTOR](node) {
1078
+ for (const param of node.params) checkParam(param);
1079
+ } };
1080
+ }
1081
+ });
693
1082
  //#endregion
694
1083
  //#region src/plugin.ts
695
1084
  const plugin = {
@@ -705,81 +1094,16 @@ const plugin = {
705
1094
  "no-enum-number-widening": noEnumNumberWidening,
706
1095
  "no-enum-reverse-lookup-widening": noEnumReverseLookupWidening,
707
1096
  "no-index-files": noIndexFiles,
1097
+ "no-map-instanceof-mutation": noMapInstanceofMutation,
708
1098
  "no-mutable-union-array-param": noMutableUnionArrayParam,
709
1099
  "no-non-barrel-index": noNonBarrelIndex,
710
1100
  "no-non-barrel-reexport": noNonBarrelReexport,
711
1101
  "no-object-assign": noObjectAssign,
712
- "no-pointless-reassignment": {
713
- meta: {
714
- type: "problem",
715
- fixable: "code",
716
- schema: [],
717
- messages: { pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly." }
718
- },
719
- create(context) {
720
- return { VariableDeclarator(node) {
721
- if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return;
722
- if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return;
723
- const scope = context.sourceCode.getScope(node);
724
- const sourceVariable = scope.references.find((reference) => reference.identifier === node.init)?.resolved;
725
- if (!sourceVariable || sourceVariable.references.some((reference) => reference.isWrite() && reference.init !== true)) return;
726
- const aliasName = node.id.name;
727
- const originalName = node.init.name;
728
- const aliasIsAnnotated = hasTypeAnnotation(node.id);
729
- context.report({
730
- node,
731
- messageId: "pointlessReassignment",
732
- data: {
733
- name: aliasName,
734
- value: originalName
735
- },
736
- fix(fixer) {
737
- const variable = scope.set.get(aliasName);
738
- if (!variable) return null;
739
- if (aliasIsAnnotated) return null;
740
- if (variable.references.filter((reference) => reference.isWrite() && reference.identifier !== node.id).length > 0) return null;
741
- const readRefs = variable.references.filter((reference) => reference.isRead() && isIdentifierReference(reference));
742
- if (readRefs.some((reference) => {
743
- const afterToken = context.sourceCode.getTokenAfter(reference.identifier);
744
- if (afterToken?.value === ":") return false;
745
- if (afterToken?.value !== "}" && afterToken?.value !== ",") return false;
746
- let token = context.sourceCode.getTokenBefore(reference.identifier);
747
- while (token) {
748
- if (token.value === "{") return true;
749
- if (token.value === "[" || token.value === "(") return false;
750
- if (token.value === ":") return false;
751
- token = context.sourceCode.getTokenBefore(token);
752
- }
753
- return false;
754
- })) return null;
755
- if (readRefs.some((reference) => resolveFrom(reference.from, originalName) !== sourceVariable)) return null;
756
- const fixes = readRefs.map((reference) => fixer.replaceText(reference.identifier, originalName));
757
- const declaration = node.parent;
758
- if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null;
759
- fixes.push(fixer.remove(declaration.parent.type === "ExportNamedDeclaration" ? declaration.parent : declaration));
760
- return fixes;
761
- }
762
- });
763
- } };
764
- }
765
- },
766
- "no-side-effects-in-index": {
767
- meta: {
768
- type: "problem",
769
- schema: [],
770
- messages: { notAPureReexport: "A barrel (index) file may contain only re-export statements ('export * from ...' / 'export { x } from ...' / 'export type { x } from ...') -- nothing else, so it can never have a side effect at import time by construction. Found: {{ description }}." }
771
- },
772
- create(context) {
773
- if (!isIndexFile(context.filename)) return {};
774
- return { Program(node) {
775
- for (const statement of node.body) if (!isPureReexport(statement)) context.report({
776
- node: statement,
777
- messageId: "notAPureReexport",
778
- data: { description: statement.type }
779
- });
780
- } };
781
- }
782
- }
1102
+ "no-pointless-reassignment": noPointlessReassignment,
1103
+ "no-set-instanceof-mutation": noSetInstanceofMutation,
1104
+ "no-side-effects-in-index": noSideEffectsInIndex,
1105
+ "prefer-numeric-sort-compare": preferNumericSortCompare,
1106
+ "prefer-readonly-array-param": preferReadonlyArrayParam
783
1107
  },
784
1108
  configs: {
785
1109
  get recommended() {
@@ -790,7 +1114,8 @@ const plugin = {
790
1114
  "exadev/barrel-policy": ["error", { mode: "banned" }],
791
1115
  "exadev/no-mutable-union-array-param": "error",
792
1116
  "exadev/no-object-assign": "error",
793
- "exadev/no-pointless-reassignment": "error"
1117
+ "exadev/no-pointless-reassignment": "error",
1118
+ "exadev/prefer-readonly-array-param": "error"
794
1119
  }
795
1120
  };
796
1121
  },
@@ -816,11 +1141,17 @@ const recommendedTypeChecked = [
816
1141
  "exadev/no-array-isarray-mutation": "error",
817
1142
  "exadev/no-enum-number-widening": "error",
818
1143
  "exadev/no-enum-reverse-lookup-widening": "error",
1144
+ "exadev/no-map-instanceof-mutation": "error",
819
1145
  "exadev/no-mutable-union-array-param": "error",
820
1146
  "exadev/no-object-assign": "error",
821
1147
  "exadev/no-pointless-reassignment": "error",
1148
+ "exadev/no-set-instanceof-mutation": "error",
1149
+ "exadev/prefer-numeric-sort-compare": "error",
1150
+ "exadev/prefer-readonly-array-param": "error",
822
1151
  "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
823
1152
  "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
1153
+ "@typescript-eslint/consistent-type-exports": "error",
1154
+ "@typescript-eslint/consistent-type-imports": "error",
824
1155
  "@typescript-eslint/method-signature-style": ["error", "property"],
825
1156
  "@typescript-eslint/no-deprecated": "error",
826
1157
  "@typescript-eslint/no-magic-numbers": ["error", {
@@ -840,6 +1171,7 @@ const recommendedTypeChecked = [
840
1171
  "@typescript-eslint/no-non-null-assertion": "error",
841
1172
  "@typescript-eslint/no-unnecessary-condition": "error",
842
1173
  "@typescript-eslint/prefer-readonly": "error",
1174
+ "@typescript-eslint/promise-function-async": "error",
843
1175
  "@typescript-eslint/require-array-sort-compare": "error",
844
1176
  "@typescript-eslint/strict-boolean-expressions": "error",
845
1177
  "@typescript-eslint/switch-exhaustiveness-check": "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -63,7 +63,8 @@
63
63
  "vitest": "^4.1.10"
64
64
  },
65
65
  "dependencies": {
66
- "@typescript-eslint/utils": "^8.67.0"
66
+ "@typescript-eslint/utils": "^8.67.0",
67
+ "ts-api-utils": "^2.5.0"
67
68
  },
68
69
  "scripts": {
69
70
  "build": "turbo run _build",