exadev-eslint-config 2.19.4 → 2.20.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
@@ -57,6 +57,7 @@ export default defineConfig(
57
57
  - `exadev/prefer-readonly-array-param`
58
58
  - `exadev/prefer-readonly-object-param`
59
59
  - `exadev/prefer-numeric-sort-compare`
60
+ - `exadev/prefer-options-object-param`
60
61
  - `exadev/no-pointless-reassignment`
61
62
  - `exadev/test-file-kind`
62
63
 
@@ -84,6 +85,8 @@ export default defineConfig(
84
85
  - **[`no-magic-numbers`](https://typescript-eslint.io/rules/no-magic-numbers/)** — tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`).
85
86
  - **[`max-lines`](https://eslint.org/docs/latest/rules/max-lines)** set to `{ max: 800, skipBlankLines: true, skipComments: true }`.
86
87
  - *Why:* counting only real code means a file isn't pushed over the limit by whitespace or its own WHY-explanation comments.
88
+ - **[`max-params`](https://eslint.org/docs/latest/rules/max-params)** set to `{ max: 4 }`, one above the rule's own default of `3`. Needs no type information — registered in both `plugin.configs.recommended` and the default (type-checked) export, like `exadev/prefer-readonly-array-param` above.
89
+ - *Why:* a deliberately loose backstop against a genuinely excessive number of REQUIRED parameters, distinct from `exadev/prefer-options-object-param` above, which already offers a real fix for the more common shape (a run of 2+ trailing OPTIONAL parameters) this rule structurally cannot see until the total count crosses its own threshold.
87
90
  - **[`no-warning-comments`](https://eslint.org/docs/latest/rules/no-warning-comments)** — bans any comment containing `Stryker disable`.
88
91
  - *Why:* that's Stryker's own mutation-testing suppression directive, invisible to `noInlineConfig` above since it isn't an eslint-disable comment.
89
92
 
@@ -281,6 +284,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
281
284
  | `no-enum-number-widening` | | **A bare `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`. |
282
285
  | `no-enum-reverse-lookup-widening` | suggestion | **A numeric enum's reverse lookup can silently type as `string` for an out-of-range index.** 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`. |
283
286
  | `prefer-numeric-sort-compare` | suggestion | **`.sort()` on a number array sorts lexicographically by default.** A deliberately narrow addition alongside [`@typescript-eslint/require-array-sort-compare`](https://typescript-eslint.io/rules/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. |
287
+ | `prefer-options-object-param` | suggestion | **A run of 2+ trailing optional parameters (`?`-marked or default-valued) should be bundled into one destructured `options` parameter.** Without this, a caller needing only the last optional parameter must still pass `undefined` for every optional parameter before it (the motivating case: a 10-parameter constructor with 8 trailing optional ones). The threshold is configurable (`{ minTrailingOptional }`, default `2`). A suggestion (not a full autofix) rewrites the parameter list and inserts a `const { ... } = options ?? {};` destructure as the body's first statement; call sites are never rewritten, since the signature edit alone turns every stale positional call site into a real compile error. Still reported, but with no suggestion offered, whenever collapsing the run would not be safe/mechanical: a parameter property in the run, a parameter with no simple resolvable name and explicit type of its own (including a destructured parameter, or one typed only through a separately-declared function-type alias), a decorated parameter, a rest parameter anywhere in the full parameter list, no `BlockStatement` body at all (an arrow's expression body, or any declaration-only signature — including an ambient `.d.ts` function, an interface method/construct signature, or an abstract/ambient class method), a `@param` JSDoc tag naming a parameter in the run, or a parameter/function-scope-local variable already named `options`. Complements `max-params` (see "Individual rule tuning" above), which catches the different shape (excessive REQUIRED parameters) this rule is deliberately blind to. Requires no type information, but is registered only in the default (type-checked) export — alongside `prefer-readonly-object-param` and `prefer-numeric-sort-compare` above, for a consistent "every rewrite-suggestion `prefer-*` rule lives here" story, not because it needs the checker for anything. |
284
288
  | `package-json-key-order` | ✓ | **Requires `package.json`'s keys to match `syncpack format`'s order.** See [Optional package.json key ordering](#optional-packagejson-key-ordering) — opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one — needs no type information and doesn't apply to any `.ts`/`.js` file. |
285
289
  | `test-file-kind` | | **A test file's name must declare its own test kind.** A filename suffix immediately before `.test`/`.spec` (e.g. `foo.unit.test.ts`), one of a configurable `{ kinds }` set (default: `unit`, `integration`, `e2e`). A naming-discipline rule, not a content classifier — it checks only the filename, never what the file actually tests. Self-scoped to real test/spec files (`context.filename`), so it never misfires when applied unscoped and never relies on a consumer's own `files` config. Requires no type information. |
286
290
 
package/dist/index.cjs CHANGED
@@ -295,7 +295,7 @@ function buildNextjsConfig(options = {}) {
295
295
  }
296
296
  //#endregion
297
297
  //#region package.json
298
- var version = "2.19.4";
298
+ var version = "2.20.0";
299
299
  //#endregion
300
300
  //#region src/react.ts
301
301
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -653,7 +653,7 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
653
653
  "fill",
654
654
  "copyWithin"
655
655
  ]);
656
- const createRule$7 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
656
+ const createRule$8 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
657
657
  function isArrayIsArrayCall(node) {
658
658
  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";
659
659
  }
@@ -665,7 +665,7 @@ function definitelyExits$2(statement) {
665
665
  }
666
666
  return false;
667
667
  }
668
- const noArrayIsArrayMutation = createRule$7({
668
+ const noArrayIsArrayMutation = createRule$8({
669
669
  name: "no-array-isarray-mutation",
670
670
  meta: {
671
671
  type: "problem",
@@ -822,6 +822,11 @@ function lastTokenOrThrow(sourceCode, node) {
822
822
  if (token === null) throw new Error("Unreachable: getLastToken returned null for a node expected to always have at least one token.");
823
823
  return token;
824
824
  }
825
+ function firstTokenOrThrow(sourceCode, node) {
826
+ const token = sourceCode.getFirstToken(node);
827
+ if (token === null) throw new Error("Unreachable: getFirstToken returned null for a node expected to always have at least one token.");
828
+ return token;
829
+ }
825
830
  //#endregion
826
831
  //#region src/rules/no-enum-number-widening.ts
827
832
  const noEnumNumberWidening = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`)({
@@ -944,7 +949,7 @@ const noIndexFiles = {
944
949
  };
945
950
  //#endregion
946
951
  //#region src/rules/no-map-instanceof-mutation.ts
947
- const createRule$6 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
952
+ const createRule$7 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
948
953
  const MUTATING_MAP_METHODS = /* @__PURE__ */ new Set([
949
954
  "set",
950
955
  "delete",
@@ -961,7 +966,7 @@ function definitelyExits$1(statement) {
961
966
  }
962
967
  return false;
963
968
  }
964
- const noMapInstanceofMutation = createRule$6({
969
+ const noMapInstanceofMutation = createRule$7({
965
970
  name: "no-map-instanceof-mutation",
966
971
  meta: {
967
972
  type: "problem",
@@ -1042,7 +1047,7 @@ const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
1042
1047
  "fill",
1043
1048
  "copyWithin"
1044
1049
  ]);
1045
- const createRule$5 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1050
+ const createRule$6 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1046
1051
  function buildReadonlyArrayFix(annotated, fixer) {
1047
1052
  if (annotated.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType) return fixer.insertTextBefore(annotated, "readonly ");
1048
1053
  if (annotated.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference) throw new Error(`Unreachable: expected a TSArrayType or TSTypeReference, got ${annotated.type} instead.`);
@@ -1055,7 +1060,7 @@ function isUnionArrayType(typeAnnotation) {
1055
1060
  if (firstParam?.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return firstParam;
1056
1061
  }
1057
1062
  }
1058
- const noMutableUnionArrayParam = createRule$5({
1063
+ const noMutableUnionArrayParam = createRule$6({
1059
1064
  name: "no-mutable-union-array-param",
1060
1065
  meta: {
1061
1066
  type: "problem",
@@ -1108,7 +1113,7 @@ const noNonBarrelIndex = {
1108
1113
  };
1109
1114
  //#endregion
1110
1115
  //#region src/rules/no-non-barrel-reexport.ts
1111
- function removeListMember(fixer, sourceCode, declaration, members, target) {
1116
+ function removeListMember({ fixer, sourceCode }, declaration, members, target) {
1112
1117
  if (members.length === 1) return fixer.remove(declaration);
1113
1118
  const targetIndex = members.indexOf(target);
1114
1119
  const isLast = targetIndex === members.length - 1;
@@ -1156,8 +1161,14 @@ const noNonBarrelReexport = {
1156
1161
  messageId: "splitStatementReexport",
1157
1162
  data: { name },
1158
1163
  fix(fixer) {
1159
- const fixes = [removeListMember(fixer, sourceCode, declaration, declaration.specifiers, specifier)];
1160
- if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1164
+ const fixes = [removeListMember({
1165
+ fixer,
1166
+ sourceCode
1167
+ }, declaration, declaration.specifiers, specifier)];
1168
+ if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember({
1169
+ fixer,
1170
+ sourceCode
1171
+ }, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1161
1172
  return fixes;
1162
1173
  }
1163
1174
  });
@@ -1169,7 +1180,10 @@ const noNonBarrelReexport = {
1169
1180
  data: { name },
1170
1181
  fix(fixer) {
1171
1182
  const fixes = [fixer.remove(declaration)];
1172
- if (importIsOnlyUsedByThisExport(sourceCode, trackedImport, identifierNode)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1183
+ if (importIsOnlyUsedByThisExport(sourceCode, trackedImport, identifierNode)) fixes.push(removeListMember({
1184
+ fixer,
1185
+ sourceCode
1186
+ }, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1173
1187
  return fixes;
1174
1188
  }
1175
1189
  });
@@ -1180,14 +1194,14 @@ const noNonBarrelReexport = {
1180
1194
  };
1181
1195
  //#endregion
1182
1196
  //#region src/rules/no-object-assign.ts
1183
- const createRule$4 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1197
+ const createRule$5 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1184
1198
  function resolveFrom$1(scope, name) {
1185
1199
  for (let current = scope; current; current = current.upper) {
1186
1200
  const found = current.set.get(name);
1187
1201
  if (found) return found;
1188
1202
  }
1189
1203
  }
1190
- const noObjectAssign = createRule$4({
1204
+ const noObjectAssign = createRule$5({
1191
1205
  name: "no-object-assign",
1192
1206
  meta: {
1193
1207
  type: "problem",
@@ -1333,7 +1347,7 @@ const MUTATING_SET_METHODS = /* @__PURE__ */ new Set([
1333
1347
  "delete",
1334
1348
  "clear"
1335
1349
  ]);
1336
- const createRule$3 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1350
+ const createRule$4 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1337
1351
  function isSetInstanceofExpression(node) {
1338
1352
  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";
1339
1353
  }
@@ -1345,7 +1359,7 @@ function definitelyExits(statement) {
1345
1359
  }
1346
1360
  return false;
1347
1361
  }
1348
- const noSetInstanceofMutation = createRule$3({
1362
+ const noSetInstanceofMutation = createRule$4({
1349
1363
  name: "no-set-instanceof-mutation",
1350
1364
  meta: {
1351
1365
  type: "problem",
@@ -1639,13 +1653,13 @@ const packageJsonKeyOrder = {
1639
1653
  };
1640
1654
  //#endregion
1641
1655
  //#region src/rules/prefer-numeric-sort-compare.ts
1642
- const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1656
+ const createRule$3 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1643
1657
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
1644
1658
  function isDefinitelyNumberType(type) {
1645
1659
  if (type.isUnion()) return type.types.every((constituent) => isDefinitelyNumberType(constituent));
1646
1660
  return (type.flags & typescript.TypeFlags.NumberLike) !== 0;
1647
1661
  }
1648
- const preferNumericSortCompare = createRule$2({
1662
+ const preferNumericSortCompare = createRule$3({
1649
1663
  name: "prefer-numeric-sort-compare",
1650
1664
  meta: {
1651
1665
  type: "suggestion",
@@ -1687,6 +1701,170 @@ const preferNumericSortCompare = createRule$2({
1687
1701
  }
1688
1702
  });
1689
1703
  //#endregion
1704
+ //#region src/rules/prefer-options-object-param.ts
1705
+ const createRule$2 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1706
+ const FUNCTION_LIKE_SELECTOR$2 = [
1707
+ "ArrowFunctionExpression",
1708
+ "FunctionDeclaration",
1709
+ "FunctionExpression",
1710
+ "TSCallSignatureDeclaration",
1711
+ "TSConstructSignatureDeclaration",
1712
+ "TSDeclareFunction",
1713
+ "TSEmptyBodyFunctionExpression",
1714
+ "TSFunctionType",
1715
+ "TSMethodSignature"
1716
+ ].join(", ");
1717
+ function isOptionalParam(param) {
1718
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.TSParameterProperty) return isOptionalParam(param.parameter);
1719
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern) return true;
1720
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return param.optional;
1721
+ return false;
1722
+ }
1723
+ function getTrailingOptionalRun(params) {
1724
+ const hasTrailingRest = params.length > 0 && params[params.length - 1]?.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement;
1725
+ const run = [];
1726
+ for (let i = params.length - (hasTrailingRest ? 2 : 1); i >= 0; i--) {
1727
+ const param = params[i];
1728
+ if (param === void 0 || !isOptionalParam(param)) break;
1729
+ run.unshift(param);
1730
+ }
1731
+ return run;
1732
+ }
1733
+ function resolveFixableParam(param) {
1734
+ if (param.type === _typescript_eslint_utils.AST_NODE_TYPES.TSParameterProperty) return void 0;
1735
+ const identifierNode = param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern ? param.left.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier ? param.left : void 0 : param.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier ? param : void 0;
1736
+ if (identifierNode === void 0) return void 0;
1737
+ const typeNode = identifierNode.typeAnnotation?.typeAnnotation;
1738
+ if (typeNode === void 0) return void 0;
1739
+ if (identifierNode.decorators.length > 0) return void 0;
1740
+ return {
1741
+ identifierNode,
1742
+ typeNode,
1743
+ defaultExpression: param.type === _typescript_eslint_utils.AST_NODE_TYPES.AssignmentPattern ? param.right : void 0
1744
+ };
1745
+ }
1746
+ function isDefined(value) {
1747
+ return value !== void 0;
1748
+ }
1749
+ function firstAndLastOrThrow(items) {
1750
+ const first = items[0];
1751
+ const last = items.at(-1);
1752
+ if (first === void 0 || last === void 0) throw new Error("Unreachable: expected at least one element (the caller already confirmed a minimum run length).");
1753
+ return [first, last];
1754
+ }
1755
+ function describeFunctionKind(node) {
1756
+ if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSConstructSignatureDeclaration) return "constructor";
1757
+ if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSMethodSignature) return "method";
1758
+ const { parent } = node;
1759
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.MethodDefinition || parent.type === _typescript_eslint_utils.AST_NODE_TYPES.TSAbstractMethodDefinition) return parent.kind === "constructor" ? "constructor" : "method";
1760
+ if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.Property && parent.method) return "method";
1761
+ return "function";
1762
+ }
1763
+ function getBlockBody(node) {
1764
+ return "body" in node && node.body?.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement ? node.body : void 0;
1765
+ }
1766
+ const LIFTABLE_JSDOC_PARENTS = /* @__PURE__ */ new Set([
1767
+ _typescript_eslint_utils.AST_NODE_TYPES.VariableDeclarator,
1768
+ _typescript_eslint_utils.AST_NODE_TYPES.VariableDeclaration,
1769
+ _typescript_eslint_utils.AST_NODE_TYPES.MethodDefinition,
1770
+ _typescript_eslint_utils.AST_NODE_TYPES.TSAbstractMethodDefinition,
1771
+ _typescript_eslint_utils.AST_NODE_TYPES.PropertyDefinition,
1772
+ _typescript_eslint_utils.AST_NODE_TYPES.ExportNamedDeclaration,
1773
+ _typescript_eslint_utils.AST_NODE_TYPES.ExportDefaultDeclaration
1774
+ ]);
1775
+ function getLeadingJSDocComment(sourceCode, node) {
1776
+ let current = node;
1777
+ for (;;) {
1778
+ const jsdocComment = sourceCode.getCommentsBefore(current).findLast((comment) => comment.type === _typescript_eslint_utils.AST_TOKEN_TYPES.Block && comment.value.startsWith("*"));
1779
+ if (jsdocComment) return jsdocComment;
1780
+ const { parent } = current;
1781
+ if (parent === void 0 || !LIFTABLE_JSDOC_PARENTS.has(parent.type)) return void 0;
1782
+ current = parent;
1783
+ }
1784
+ }
1785
+ function jsDocMentionsParam(commentValue, name) {
1786
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1787
+ return new RegExp(`@param\\s+(?:\\{[^}]*\\}\\s+)?\\[?${escapedName}\\b`).test(commentValue);
1788
+ }
1789
+ function hasOptionsNameCollision(scope, exemptIdentifiers) {
1790
+ return scope.variables.some((variable) => variable.name === "options" && !variable.identifiers.every((identifier) => exemptIdentifiers.has(identifier)));
1791
+ }
1792
+ const preferOptionsObjectParam = createRule$2({
1793
+ name: "prefer-options-object-param",
1794
+ meta: {
1795
+ type: "suggestion",
1796
+ hasSuggestions: true,
1797
+ docs: { description: "Suggest bundling a run of 2+ trailing optional parameters into a single destructured 'options' parameter — without this, a caller needing only the last optional parameter must still pass 'undefined' for every optional parameter before it." },
1798
+ schema: [{
1799
+ type: "object",
1800
+ properties: { minTrailingOptional: {
1801
+ type: "integer",
1802
+ minimum: 2
1803
+ } },
1804
+ additionalProperties: false
1805
+ }],
1806
+ messages: {
1807
+ tooManyTrailingOptional: "This {{ kind }} has {{ count }} trailing optional parameters ({{ names }}) — a caller needing only the last one must still pass 'undefined' for every parameter before it. Bundle the trailing optional run into a single destructured 'options' parameter instead.",
1808
+ wrapInOptionsObject: "Bundle the trailing optional parameters into a single 'options' parameter."
1809
+ },
1810
+ defaultOptions: [{ minTrailingOptional: 2 }]
1811
+ },
1812
+ create(context, [{ minTrailingOptional }]) {
1813
+ const { sourceCode } = context;
1814
+ function checkParams(node) {
1815
+ const run = getTrailingOptionalRun(node.params);
1816
+ if (run.length < minTrailingOptional) return;
1817
+ const resolved = run.map(resolveFixableParam);
1818
+ const names = run.map((param, index) => resolved[index]?.identifierNode.name ?? sourceCode.getText(param)).join(", ");
1819
+ const data = {
1820
+ kind: describeFunctionKind(node),
1821
+ count: run.length,
1822
+ names
1823
+ };
1824
+ const resolvedParams = resolved.filter(isDefined);
1825
+ const allResolvable = resolvedParams.length === run.length;
1826
+ const hasRestParam = node.params.some((param) => param.type === _typescript_eslint_utils.AST_NODE_TYPES.RestElement);
1827
+ const body = getBlockBody(node);
1828
+ let isFixable = allResolvable && !hasRestParam && body !== void 0;
1829
+ if (isFixable) {
1830
+ const jsdocComment = getLeadingJSDocComment(sourceCode, node);
1831
+ const jsDocBail = jsdocComment !== void 0 && resolvedParams.some((info) => jsDocMentionsParam(jsdocComment.value, info.identifierNode.name));
1832
+ const exemptIdentifiers = new Set(resolvedParams.map((info) => info.identifierNode));
1833
+ const optionsCollision = hasOptionsNameCollision(sourceCode.getScope(node), exemptIdentifiers);
1834
+ isFixable = !jsDocBail && !optionsCollision;
1835
+ }
1836
+ if (!isFixable || body === void 0) {
1837
+ context.report({
1838
+ node,
1839
+ messageId: "tooManyTrailingOptional",
1840
+ data
1841
+ });
1842
+ return;
1843
+ }
1844
+ context.report({
1845
+ node,
1846
+ messageId: "tooManyTrailingOptional",
1847
+ data,
1848
+ suggest: [{
1849
+ messageId: "wrapInOptionsObject",
1850
+ fix(fixer) {
1851
+ const properties = resolvedParams.map((info) => `${info.identifierNode.name}?: ${sourceCode.getText(info.typeNode)}`);
1852
+ const destructureEntries = resolvedParams.map((info) => info.defaultExpression === void 0 ? info.identifierNode.name : `${info.identifierNode.name} = ${sourceCode.getText(info.defaultExpression)}`);
1853
+ const optionsParamText = `options?: { ${properties.join("; ")} }`;
1854
+ const destructureText = `const { ${destructureEntries.join(", ")} } = options ?? {};`;
1855
+ const [firstParam, lastParam] = firstAndLastOrThrow(run);
1856
+ const openBrace = firstTokenOrThrow(sourceCode, body);
1857
+ return [fixer.replaceTextRange([firstParam.range[0], lastParam.range[1]], optionsParamText), fixer.insertTextAfter(openBrace, `\n ${destructureText}`)];
1858
+ }
1859
+ }]
1860
+ });
1861
+ }
1862
+ return { [FUNCTION_LIKE_SELECTOR$2](node) {
1863
+ checkParams(node);
1864
+ } };
1865
+ }
1866
+ });
1867
+ //#endregion
1690
1868
  //#region src/rules/prefer-readonly-array-param.ts
1691
1869
  const createRule$1 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1692
1870
  function getFixableArrayOrTupleType(typeNode) {
@@ -1904,6 +2082,7 @@ const plugin = {
1904
2082
  "no-side-effects-in-index": noSideEffectsInIndex,
1905
2083
  "package-json-key-order": packageJsonKeyOrder,
1906
2084
  "prefer-numeric-sort-compare": preferNumericSortCompare,
2085
+ "prefer-options-object-param": preferOptionsObjectParam,
1907
2086
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1908
2087
  "prefer-readonly-object-param": preferReadonlyObjectParam,
1909
2088
  "test-file-kind": {
@@ -1966,7 +2145,8 @@ const plugin = {
1966
2145
  "exadev/no-object-assign": "error",
1967
2146
  "exadev/no-pointless-reassignment": "error",
1968
2147
  "exadev/prefer-readonly-array-param": "error",
1969
- "exadev/test-file-kind": "error"
2148
+ "exadev/test-file-kind": "error",
2149
+ "max-params": ["error", { max: 4 }]
1970
2150
  }
1971
2151
  };
1972
2152
  },
@@ -2078,6 +2258,7 @@ const recommendedTypeChecked = [
2078
2258
  "exadev/no-pointless-reassignment": "error",
2079
2259
  "exadev/no-set-instanceof-mutation": "error",
2080
2260
  "exadev/prefer-numeric-sort-compare": "error",
2261
+ "exadev/prefer-options-object-param": "error",
2081
2262
  "exadev/prefer-readonly-array-param": "error",
2082
2263
  "exadev/prefer-readonly-object-param": "error",
2083
2264
  "exadev/test-file-kind": "error",
@@ -2115,6 +2296,7 @@ const recommendedTypeChecked = [
2115
2296
  skipBlankLines: true,
2116
2297
  skipComments: true
2117
2298
  }],
2299
+ "max-params": ["error", { max: 4 }],
2118
2300
  "no-warning-comments": ["error", {
2119
2301
  terms: ["stryker disable"],
2120
2302
  location: "anywhere"
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import jsdoc from "eslint-plugin-jsdoc";
4
4
  import tsdoc from "eslint-plugin-tsdoc";
5
5
  import jsonCanonical from "eslint-plugin-json-canonical";
6
6
  import { createRequire } from "node:module";
7
- import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
7
+ import { AST_NODE_TYPES, AST_TOKEN_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
8
8
  import * as ts from "typescript";
9
9
  import { isPropertyReadonlyInType, isTypeReference } from "ts-api-utils";
10
10
  import js from "@eslint/js";
@@ -261,7 +261,7 @@ function buildNextjsConfig(options = {}) {
261
261
  }
262
262
  //#endregion
263
263
  //#region package.json
264
- var version = "2.19.4";
264
+ var version = "2.20.0";
265
265
  //#endregion
266
266
  //#region src/react.ts
267
267
  const JSX_FILE_PATTERNS = ["**/*.jsx", "**/*.tsx"];
@@ -619,7 +619,7 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
619
619
  "fill",
620
620
  "copyWithin"
621
621
  ]);
622
- const createRule$7 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
622
+ const createRule$8 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
623
623
  function isArrayIsArrayCall(node) {
624
624
  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";
625
625
  }
@@ -631,7 +631,7 @@ function definitelyExits$2(statement) {
631
631
  }
632
632
  return false;
633
633
  }
634
- const noArrayIsArrayMutation = createRule$7({
634
+ const noArrayIsArrayMutation = createRule$8({
635
635
  name: "no-array-isarray-mutation",
636
636
  meta: {
637
637
  type: "problem",
@@ -788,6 +788,11 @@ function lastTokenOrThrow(sourceCode, node) {
788
788
  if (token === null) throw new Error("Unreachable: getLastToken returned null for a node expected to always have at least one token.");
789
789
  return token;
790
790
  }
791
+ function firstTokenOrThrow(sourceCode, node) {
792
+ const token = sourceCode.getFirstToken(node);
793
+ if (token === null) throw new Error("Unreachable: getFirstToken returned null for a node expected to always have at least one token.");
794
+ return token;
795
+ }
791
796
  //#endregion
792
797
  //#region src/rules/no-enum-number-widening.ts
793
798
  const noEnumNumberWidening = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`)({
@@ -910,7 +915,7 @@ const noIndexFiles = {
910
915
  };
911
916
  //#endregion
912
917
  //#region src/rules/no-map-instanceof-mutation.ts
913
- const createRule$6 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
918
+ const createRule$7 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
914
919
  const MUTATING_MAP_METHODS = /* @__PURE__ */ new Set([
915
920
  "set",
916
921
  "delete",
@@ -927,7 +932,7 @@ function definitelyExits$1(statement) {
927
932
  }
928
933
  return false;
929
934
  }
930
- const noMapInstanceofMutation = createRule$6({
935
+ const noMapInstanceofMutation = createRule$7({
931
936
  name: "no-map-instanceof-mutation",
932
937
  meta: {
933
938
  type: "problem",
@@ -1008,7 +1013,7 @@ const MUTATING_INSERT_METHODS = /* @__PURE__ */ new Set([
1008
1013
  "fill",
1009
1014
  "copyWithin"
1010
1015
  ]);
1011
- const createRule$5 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1016
+ const createRule$6 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1012
1017
  function buildReadonlyArrayFix(annotated, fixer) {
1013
1018
  if (annotated.type === AST_NODE_TYPES.TSArrayType) return fixer.insertTextBefore(annotated, "readonly ");
1014
1019
  if (annotated.type !== AST_NODE_TYPES.TSTypeReference) throw new Error(`Unreachable: expected a TSArrayType or TSTypeReference, got ${annotated.type} instead.`);
@@ -1021,7 +1026,7 @@ function isUnionArrayType(typeAnnotation) {
1021
1026
  if (firstParam?.type === AST_NODE_TYPES.TSUnionType) return firstParam;
1022
1027
  }
1023
1028
  }
1024
- const noMutableUnionArrayParam = createRule$5({
1029
+ const noMutableUnionArrayParam = createRule$6({
1025
1030
  name: "no-mutable-union-array-param",
1026
1031
  meta: {
1027
1032
  type: "problem",
@@ -1074,7 +1079,7 @@ const noNonBarrelIndex = {
1074
1079
  };
1075
1080
  //#endregion
1076
1081
  //#region src/rules/no-non-barrel-reexport.ts
1077
- function removeListMember(fixer, sourceCode, declaration, members, target) {
1082
+ function removeListMember({ fixer, sourceCode }, declaration, members, target) {
1078
1083
  if (members.length === 1) return fixer.remove(declaration);
1079
1084
  const targetIndex = members.indexOf(target);
1080
1085
  const isLast = targetIndex === members.length - 1;
@@ -1122,8 +1127,14 @@ const noNonBarrelReexport = {
1122
1127
  messageId: "splitStatementReexport",
1123
1128
  data: { name },
1124
1129
  fix(fixer) {
1125
- const fixes = [removeListMember(fixer, sourceCode, declaration, declaration.specifiers, specifier)];
1126
- if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1130
+ const fixes = [removeListMember({
1131
+ fixer,
1132
+ sourceCode
1133
+ }, declaration, declaration.specifiers, specifier)];
1134
+ if (specifier.local.type === "Identifier" && importIsOnlyUsedByThisExport(sourceCode, trackedImport, specifier.local)) fixes.push(removeListMember({
1135
+ fixer,
1136
+ sourceCode
1137
+ }, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1127
1138
  return fixes;
1128
1139
  }
1129
1140
  });
@@ -1135,7 +1146,10 @@ const noNonBarrelReexport = {
1135
1146
  data: { name },
1136
1147
  fix(fixer) {
1137
1148
  const fixes = [fixer.remove(declaration)];
1138
- if (importIsOnlyUsedByThisExport(sourceCode, trackedImport, identifierNode)) fixes.push(removeListMember(fixer, sourceCode, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1149
+ if (importIsOnlyUsedByThisExport(sourceCode, trackedImport, identifierNode)) fixes.push(removeListMember({
1150
+ fixer,
1151
+ sourceCode
1152
+ }, trackedImport.declaration, trackedImport.declaration.specifiers, trackedImport.specifier));
1139
1153
  return fixes;
1140
1154
  }
1141
1155
  });
@@ -1146,14 +1160,14 @@ const noNonBarrelReexport = {
1146
1160
  };
1147
1161
  //#endregion
1148
1162
  //#region src/rules/no-object-assign.ts
1149
- const createRule$4 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1163
+ const createRule$5 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1150
1164
  function resolveFrom$1(scope, name) {
1151
1165
  for (let current = scope; current; current = current.upper) {
1152
1166
  const found = current.set.get(name);
1153
1167
  if (found) return found;
1154
1168
  }
1155
1169
  }
1156
- const noObjectAssign = createRule$4({
1170
+ const noObjectAssign = createRule$5({
1157
1171
  name: "no-object-assign",
1158
1172
  meta: {
1159
1173
  type: "problem",
@@ -1299,7 +1313,7 @@ const MUTATING_SET_METHODS = /* @__PURE__ */ new Set([
1299
1313
  "delete",
1300
1314
  "clear"
1301
1315
  ]);
1302
- const createRule$3 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1316
+ const createRule$4 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1303
1317
  function isSetInstanceofExpression(node) {
1304
1318
  return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof" && node.right.type === AST_NODE_TYPES.Identifier && node.right.name === "Set";
1305
1319
  }
@@ -1311,7 +1325,7 @@ function definitelyExits(statement) {
1311
1325
  }
1312
1326
  return false;
1313
1327
  }
1314
- const noSetInstanceofMutation = createRule$3({
1328
+ const noSetInstanceofMutation = createRule$4({
1315
1329
  name: "no-set-instanceof-mutation",
1316
1330
  meta: {
1317
1331
  type: "problem",
@@ -1605,13 +1619,13 @@ const packageJsonKeyOrder = {
1605
1619
  };
1606
1620
  //#endregion
1607
1621
  //#region src/rules/prefer-numeric-sort-compare.ts
1608
- const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1622
+ const createRule$3 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1609
1623
  const SORT_METHOD_NAMES = /* @__PURE__ */ new Set(["sort", "toSorted"]);
1610
1624
  function isDefinitelyNumberType(type) {
1611
1625
  if (type.isUnion()) return type.types.every((constituent) => isDefinitelyNumberType(constituent));
1612
1626
  return (type.flags & ts.TypeFlags.NumberLike) !== 0;
1613
1627
  }
1614
- const preferNumericSortCompare = createRule$2({
1628
+ const preferNumericSortCompare = createRule$3({
1615
1629
  name: "prefer-numeric-sort-compare",
1616
1630
  meta: {
1617
1631
  type: "suggestion",
@@ -1653,6 +1667,170 @@ const preferNumericSortCompare = createRule$2({
1653
1667
  }
1654
1668
  });
1655
1669
  //#endregion
1670
+ //#region src/rules/prefer-options-object-param.ts
1671
+ const createRule$2 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1672
+ const FUNCTION_LIKE_SELECTOR$2 = [
1673
+ "ArrowFunctionExpression",
1674
+ "FunctionDeclaration",
1675
+ "FunctionExpression",
1676
+ "TSCallSignatureDeclaration",
1677
+ "TSConstructSignatureDeclaration",
1678
+ "TSDeclareFunction",
1679
+ "TSEmptyBodyFunctionExpression",
1680
+ "TSFunctionType",
1681
+ "TSMethodSignature"
1682
+ ].join(", ");
1683
+ function isOptionalParam(param) {
1684
+ if (param.type === AST_NODE_TYPES.TSParameterProperty) return isOptionalParam(param.parameter);
1685
+ if (param.type === AST_NODE_TYPES.AssignmentPattern) return true;
1686
+ if (param.type === AST_NODE_TYPES.Identifier) return param.optional;
1687
+ return false;
1688
+ }
1689
+ function getTrailingOptionalRun(params) {
1690
+ const hasTrailingRest = params.length > 0 && params[params.length - 1]?.type === AST_NODE_TYPES.RestElement;
1691
+ const run = [];
1692
+ for (let i = params.length - (hasTrailingRest ? 2 : 1); i >= 0; i--) {
1693
+ const param = params[i];
1694
+ if (param === void 0 || !isOptionalParam(param)) break;
1695
+ run.unshift(param);
1696
+ }
1697
+ return run;
1698
+ }
1699
+ function resolveFixableParam(param) {
1700
+ if (param.type === AST_NODE_TYPES.TSParameterProperty) return void 0;
1701
+ const identifierNode = param.type === AST_NODE_TYPES.AssignmentPattern ? param.left.type === AST_NODE_TYPES.Identifier ? param.left : void 0 : param.type === AST_NODE_TYPES.Identifier ? param : void 0;
1702
+ if (identifierNode === void 0) return void 0;
1703
+ const typeNode = identifierNode.typeAnnotation?.typeAnnotation;
1704
+ if (typeNode === void 0) return void 0;
1705
+ if (identifierNode.decorators.length > 0) return void 0;
1706
+ return {
1707
+ identifierNode,
1708
+ typeNode,
1709
+ defaultExpression: param.type === AST_NODE_TYPES.AssignmentPattern ? param.right : void 0
1710
+ };
1711
+ }
1712
+ function isDefined(value) {
1713
+ return value !== void 0;
1714
+ }
1715
+ function firstAndLastOrThrow(items) {
1716
+ const first = items[0];
1717
+ const last = items.at(-1);
1718
+ if (first === void 0 || last === void 0) throw new Error("Unreachable: expected at least one element (the caller already confirmed a minimum run length).");
1719
+ return [first, last];
1720
+ }
1721
+ function describeFunctionKind(node) {
1722
+ if (node.type === AST_NODE_TYPES.TSConstructSignatureDeclaration) return "constructor";
1723
+ if (node.type === AST_NODE_TYPES.TSMethodSignature) return "method";
1724
+ const { parent } = node;
1725
+ if (parent.type === AST_NODE_TYPES.MethodDefinition || parent.type === AST_NODE_TYPES.TSAbstractMethodDefinition) return parent.kind === "constructor" ? "constructor" : "method";
1726
+ if (parent.type === AST_NODE_TYPES.Property && parent.method) return "method";
1727
+ return "function";
1728
+ }
1729
+ function getBlockBody(node) {
1730
+ return "body" in node && node.body?.type === AST_NODE_TYPES.BlockStatement ? node.body : void 0;
1731
+ }
1732
+ const LIFTABLE_JSDOC_PARENTS = /* @__PURE__ */ new Set([
1733
+ AST_NODE_TYPES.VariableDeclarator,
1734
+ AST_NODE_TYPES.VariableDeclaration,
1735
+ AST_NODE_TYPES.MethodDefinition,
1736
+ AST_NODE_TYPES.TSAbstractMethodDefinition,
1737
+ AST_NODE_TYPES.PropertyDefinition,
1738
+ AST_NODE_TYPES.ExportNamedDeclaration,
1739
+ AST_NODE_TYPES.ExportDefaultDeclaration
1740
+ ]);
1741
+ function getLeadingJSDocComment(sourceCode, node) {
1742
+ let current = node;
1743
+ for (;;) {
1744
+ const jsdocComment = sourceCode.getCommentsBefore(current).findLast((comment) => comment.type === AST_TOKEN_TYPES.Block && comment.value.startsWith("*"));
1745
+ if (jsdocComment) return jsdocComment;
1746
+ const { parent } = current;
1747
+ if (parent === void 0 || !LIFTABLE_JSDOC_PARENTS.has(parent.type)) return void 0;
1748
+ current = parent;
1749
+ }
1750
+ }
1751
+ function jsDocMentionsParam(commentValue, name) {
1752
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1753
+ return new RegExp(`@param\\s+(?:\\{[^}]*\\}\\s+)?\\[?${escapedName}\\b`).test(commentValue);
1754
+ }
1755
+ function hasOptionsNameCollision(scope, exemptIdentifiers) {
1756
+ return scope.variables.some((variable) => variable.name === "options" && !variable.identifiers.every((identifier) => exemptIdentifiers.has(identifier)));
1757
+ }
1758
+ const preferOptionsObjectParam = createRule$2({
1759
+ name: "prefer-options-object-param",
1760
+ meta: {
1761
+ type: "suggestion",
1762
+ hasSuggestions: true,
1763
+ docs: { description: "Suggest bundling a run of 2+ trailing optional parameters into a single destructured 'options' parameter — without this, a caller needing only the last optional parameter must still pass 'undefined' for every optional parameter before it." },
1764
+ schema: [{
1765
+ type: "object",
1766
+ properties: { minTrailingOptional: {
1767
+ type: "integer",
1768
+ minimum: 2
1769
+ } },
1770
+ additionalProperties: false
1771
+ }],
1772
+ messages: {
1773
+ tooManyTrailingOptional: "This {{ kind }} has {{ count }} trailing optional parameters ({{ names }}) — a caller needing only the last one must still pass 'undefined' for every parameter before it. Bundle the trailing optional run into a single destructured 'options' parameter instead.",
1774
+ wrapInOptionsObject: "Bundle the trailing optional parameters into a single 'options' parameter."
1775
+ },
1776
+ defaultOptions: [{ minTrailingOptional: 2 }]
1777
+ },
1778
+ create(context, [{ minTrailingOptional }]) {
1779
+ const { sourceCode } = context;
1780
+ function checkParams(node) {
1781
+ const run = getTrailingOptionalRun(node.params);
1782
+ if (run.length < minTrailingOptional) return;
1783
+ const resolved = run.map(resolveFixableParam);
1784
+ const names = run.map((param, index) => resolved[index]?.identifierNode.name ?? sourceCode.getText(param)).join(", ");
1785
+ const data = {
1786
+ kind: describeFunctionKind(node),
1787
+ count: run.length,
1788
+ names
1789
+ };
1790
+ const resolvedParams = resolved.filter(isDefined);
1791
+ const allResolvable = resolvedParams.length === run.length;
1792
+ const hasRestParam = node.params.some((param) => param.type === AST_NODE_TYPES.RestElement);
1793
+ const body = getBlockBody(node);
1794
+ let isFixable = allResolvable && !hasRestParam && body !== void 0;
1795
+ if (isFixable) {
1796
+ const jsdocComment = getLeadingJSDocComment(sourceCode, node);
1797
+ const jsDocBail = jsdocComment !== void 0 && resolvedParams.some((info) => jsDocMentionsParam(jsdocComment.value, info.identifierNode.name));
1798
+ const exemptIdentifiers = new Set(resolvedParams.map((info) => info.identifierNode));
1799
+ const optionsCollision = hasOptionsNameCollision(sourceCode.getScope(node), exemptIdentifiers);
1800
+ isFixable = !jsDocBail && !optionsCollision;
1801
+ }
1802
+ if (!isFixable || body === void 0) {
1803
+ context.report({
1804
+ node,
1805
+ messageId: "tooManyTrailingOptional",
1806
+ data
1807
+ });
1808
+ return;
1809
+ }
1810
+ context.report({
1811
+ node,
1812
+ messageId: "tooManyTrailingOptional",
1813
+ data,
1814
+ suggest: [{
1815
+ messageId: "wrapInOptionsObject",
1816
+ fix(fixer) {
1817
+ const properties = resolvedParams.map((info) => `${info.identifierNode.name}?: ${sourceCode.getText(info.typeNode)}`);
1818
+ const destructureEntries = resolvedParams.map((info) => info.defaultExpression === void 0 ? info.identifierNode.name : `${info.identifierNode.name} = ${sourceCode.getText(info.defaultExpression)}`);
1819
+ const optionsParamText = `options?: { ${properties.join("; ")} }`;
1820
+ const destructureText = `const { ${destructureEntries.join(", ")} } = options ?? {};`;
1821
+ const [firstParam, lastParam] = firstAndLastOrThrow(run);
1822
+ const openBrace = firstTokenOrThrow(sourceCode, body);
1823
+ return [fixer.replaceTextRange([firstParam.range[0], lastParam.range[1]], optionsParamText), fixer.insertTextAfter(openBrace, `\n ${destructureText}`)];
1824
+ }
1825
+ }]
1826
+ });
1827
+ }
1828
+ return { [FUNCTION_LIKE_SELECTOR$2](node) {
1829
+ checkParams(node);
1830
+ } };
1831
+ }
1832
+ });
1833
+ //#endregion
1656
1834
  //#region src/rules/prefer-readonly-array-param.ts
1657
1835
  const createRule$1 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
1658
1836
  function getFixableArrayOrTupleType(typeNode) {
@@ -1870,6 +2048,7 @@ const plugin = {
1870
2048
  "no-side-effects-in-index": noSideEffectsInIndex,
1871
2049
  "package-json-key-order": packageJsonKeyOrder,
1872
2050
  "prefer-numeric-sort-compare": preferNumericSortCompare,
2051
+ "prefer-options-object-param": preferOptionsObjectParam,
1873
2052
  "prefer-readonly-array-param": preferReadonlyArrayParam,
1874
2053
  "prefer-readonly-object-param": preferReadonlyObjectParam,
1875
2054
  "test-file-kind": {
@@ -1932,7 +2111,8 @@ const plugin = {
1932
2111
  "exadev/no-object-assign": "error",
1933
2112
  "exadev/no-pointless-reassignment": "error",
1934
2113
  "exadev/prefer-readonly-array-param": "error",
1935
- "exadev/test-file-kind": "error"
2114
+ "exadev/test-file-kind": "error",
2115
+ "max-params": ["error", { max: 4 }]
1936
2116
  }
1937
2117
  };
1938
2118
  },
@@ -2044,6 +2224,7 @@ const recommendedTypeChecked = [
2044
2224
  "exadev/no-pointless-reassignment": "error",
2045
2225
  "exadev/no-set-instanceof-mutation": "error",
2046
2226
  "exadev/prefer-numeric-sort-compare": "error",
2227
+ "exadev/prefer-options-object-param": "error",
2047
2228
  "exadev/prefer-readonly-array-param": "error",
2048
2229
  "exadev/prefer-readonly-object-param": "error",
2049
2230
  "exadev/test-file-kind": "error",
@@ -2081,6 +2262,7 @@ const recommendedTypeChecked = [
2081
2262
  skipBlankLines: true,
2082
2263
  skipComments: true
2083
2264
  }],
2265
+ "max-params": ["error", { max: 4 }],
2084
2266
  "no-warning-comments": ["error", {
2085
2267
  terms: ["stryker disable"],
2086
2268
  location: "anywhere"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
4
- "version": "2.19.4",
4
+ "version": "2.20.0",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@typescript-eslint/utils": "8.67.0",