eslint 9.0.0-beta.0 → 9.0.0-beta.2

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.
@@ -3,11 +3,18 @@
3
3
  */
4
4
  "use strict";
5
5
 
6
- const { CALL, CONSTRUCT, ReferenceTracker, getStringIfConstant } = require("@eslint-community/eslint-utils");
6
+ const {
7
+ CALL,
8
+ CONSTRUCT,
9
+ ReferenceTracker,
10
+ getStaticValue,
11
+ getStringIfConstant
12
+ } = require("@eslint-community/eslint-utils");
7
13
  const { RegExpParser, visitRegExpAST } = require("@eslint-community/regexpp");
8
14
  const { isCombiningCharacter, isEmojiModifier, isRegionalIndicatorSymbol, isSurrogatePair } = require("./utils/unicode");
9
15
  const astUtils = require("./utils/ast-utils.js");
10
16
  const { isValidWithUnicodeFlag } = require("./utils/regular-expressions");
17
+ const { parseStringLiteral, parseTemplateToken } = require("./utils/char-source");
11
18
 
12
19
  //------------------------------------------------------------------------------
13
20
  // Helpers
@@ -193,6 +200,33 @@ const findCharacterSequences = {
193
200
 
194
201
  const kinds = Object.keys(findCharacterSequences);
195
202
 
203
+ /**
204
+ * Gets the value of the given node if it's a static value other than a regular expression object,
205
+ * or the node's `regex` property.
206
+ * The purpose of this method is to provide a replacement for `getStaticValue` in environments where certain regular expressions cannot be evaluated.
207
+ * A known example is Node.js 18 which does not support the `v` flag.
208
+ * Calling `getStaticValue` on a regular expression node with the `v` flag on Node.js 18 always returns `null`.
209
+ * A limitation of this method is that it can only detect a regular expression if the specified node is itself a regular expression literal node.
210
+ * @param {ASTNode | undefined} node The node to be inspected.
211
+ * @param {Scope} initialScope Scope to start finding variables. This function tries to resolve identifier references which are in the given scope.
212
+ * @returns {{ value: any } | { regex: { pattern: string, flags: string } } | null} The static value of the node, or `null`.
213
+ */
214
+ function getStaticValueOrRegex(node, initialScope) {
215
+ if (!node) {
216
+ return null;
217
+ }
218
+ if (node.type === "Literal" && node.regex) {
219
+ return { regex: node.regex };
220
+ }
221
+
222
+ const staticValue = getStaticValue(node, initialScope);
223
+
224
+ if (staticValue?.value instanceof RegExp) {
225
+ return null;
226
+ }
227
+ return staticValue;
228
+ }
229
+
196
230
  //------------------------------------------------------------------------------
197
231
  // Rule Definition
198
232
  //------------------------------------------------------------------------------
@@ -225,62 +259,7 @@ module.exports = {
225
259
  create(context) {
226
260
  const sourceCode = context.sourceCode;
227
261
  const parser = new RegExpParser();
228
-
229
- /**
230
- * Generates a granular loc for context.report, if directly calculable.
231
- * @param {Character[]} chars Individual characters being reported on.
232
- * @param {Node} node Parent string node to report within.
233
- * @returns {Object | null} Granular loc for context.report, if directly calculable.
234
- * @see https://github.com/eslint/eslint/pull/17515
235
- */
236
- function generateReportLocation(chars, node) {
237
-
238
- // Limit to to literals and expression-less templates with raw values === their value.
239
- switch (node.type) {
240
- case "TemplateLiteral":
241
- if (node.expressions.length || sourceCode.getText(node).slice(1, -1) !== node.quasis[0].value.cooked) {
242
- return null;
243
- }
244
- break;
245
-
246
- case "Literal":
247
- if (typeof node.value === "string" && node.value !== node.raw.slice(1, -1)) {
248
- return null;
249
- }
250
- break;
251
-
252
- default:
253
- return null;
254
- }
255
-
256
- return {
257
- start: sourceCode.getLocFromIndex(node.range[0] + 1 + chars[0].start),
258
- end: sourceCode.getLocFromIndex(node.range[0] + 1 + chars.at(-1).end)
259
- };
260
- }
261
-
262
- /**
263
- * Finds the report loc(s) for a range of matches.
264
- * @param {Character[][]} matches Characters that should trigger a report.
265
- * @param {Node} node The node to report.
266
- * @returns {Object | null} Node loc(s) for context.report.
267
- */
268
- function getNodeReportLocations(matches, node) {
269
- const locs = [];
270
-
271
- for (const chars of matches) {
272
- const loc = generateReportLocation(chars, node);
273
-
274
- // If a report can't match to a range, don't report any others
275
- if (!loc) {
276
- return [node.loc];
277
- }
278
-
279
- locs.push(loc);
280
- }
281
-
282
- return locs;
283
- }
262
+ const checkedPatternNodes = new Set();
284
263
 
285
264
  /**
286
265
  * Verify a given regular expression.
@@ -320,12 +299,58 @@ module.exports = {
320
299
  } else {
321
300
  foundKindMatches.set(kind, [...findCharacterSequences[kind](chars)]);
322
301
  }
323
-
324
302
  }
325
303
  }
326
304
  }
327
305
  });
328
306
 
307
+ let codeUnits = null;
308
+
309
+ /**
310
+ * Finds the report loc(s) for a range of matches.
311
+ * Only literals and expression-less templates generate granular errors.
312
+ * @param {Character[][]} matches Lists of individual characters being reported on.
313
+ * @returns {Location[]} locs for context.report.
314
+ * @see https://github.com/eslint/eslint/pull/17515
315
+ */
316
+ function getNodeReportLocations(matches) {
317
+ if (!astUtils.isStaticTemplateLiteral(node) && node.type !== "Literal") {
318
+ return matches.length ? [node.loc] : [];
319
+ }
320
+ return matches.map(chars => {
321
+ const firstIndex = chars[0].start;
322
+ const lastIndex = chars.at(-1).end - 1;
323
+ let start;
324
+ let end;
325
+
326
+ if (node.type === "TemplateLiteral") {
327
+ const source = sourceCode.getText(node);
328
+ const offset = node.range[0];
329
+
330
+ codeUnits ??= parseTemplateToken(source);
331
+ start = offset + codeUnits[firstIndex].start;
332
+ end = offset + codeUnits[lastIndex].end;
333
+ } else if (typeof node.value === "string") { // String Literal
334
+ const source = node.raw;
335
+ const offset = node.range[0];
336
+
337
+ codeUnits ??= parseStringLiteral(source);
338
+ start = offset + codeUnits[firstIndex].start;
339
+ end = offset + codeUnits[lastIndex].end;
340
+ } else { // RegExp Literal
341
+ const offset = node.range[0] + 1; // Add 1 to skip the leading slash.
342
+
343
+ start = offset + firstIndex;
344
+ end = offset + lastIndex + 1;
345
+ }
346
+
347
+ return {
348
+ start: sourceCode.getLocFromIndex(start),
349
+ end: sourceCode.getLocFromIndex(end)
350
+ };
351
+ });
352
+ }
353
+
329
354
  for (const [kind, matches] of foundKindMatches) {
330
355
  let suggest;
331
356
 
@@ -336,7 +361,7 @@ module.exports = {
336
361
  }];
337
362
  }
338
363
 
339
- const locs = getNodeReportLocations(matches, node);
364
+ const locs = getNodeReportLocations(matches);
340
365
 
341
366
  for (const loc of locs) {
342
367
  context.report({
@@ -351,6 +376,9 @@ module.exports = {
351
376
 
352
377
  return {
353
378
  "Literal[regex]"(node) {
379
+ if (checkedPatternNodes.has(node)) {
380
+ return;
381
+ }
354
382
  verify(node, node.regex.pattern, node.regex.flags, fixer => {
355
383
  if (!isValidWithUnicodeFlag(context.languageOptions.ecmaVersion, node.regex.pattern)) {
356
384
  return null;
@@ -371,12 +399,31 @@ module.exports = {
371
399
  for (const { node: refNode } of tracker.iterateGlobalReferences({
372
400
  RegExp: { [CALL]: true, [CONSTRUCT]: true }
373
401
  })) {
402
+ let pattern, flags;
374
403
  const [patternNode, flagsNode] = refNode.arguments;
375
- const pattern = getStringIfConstant(patternNode, scope);
376
- const flags = getStringIfConstant(flagsNode, scope);
404
+ const evaluatedPattern = getStaticValueOrRegex(patternNode, scope);
405
+
406
+ if (!evaluatedPattern) {
407
+ continue;
408
+ }
409
+ if (flagsNode) {
410
+ if (evaluatedPattern.regex) {
411
+ pattern = evaluatedPattern.regex.pattern;
412
+ checkedPatternNodes.add(patternNode);
413
+ } else {
414
+ pattern = String(evaluatedPattern.value);
415
+ }
416
+ flags = getStringIfConstant(flagsNode, scope);
417
+ } else {
418
+ if (evaluatedPattern.regex) {
419
+ continue;
420
+ }
421
+ pattern = String(evaluatedPattern.value);
422
+ flags = "";
423
+ }
377
424
 
378
- if (typeof pattern === "string") {
379
- verify(patternNode, pattern, flags || "", fixer => {
425
+ if (typeof flags === "string") {
426
+ verify(patternNode, pattern, flags, fixer => {
380
427
 
381
428
  if (!isValidWithUnicodeFlag(context.languageOptions.ecmaVersion, pattern)) {
382
429
  return null;
@@ -34,10 +34,17 @@ const arrayOfStringsOrObjects = {
34
34
  items: {
35
35
  type: "string"
36
36
  }
37
+ },
38
+ allowImportNames: {
39
+ type: "array",
40
+ items: {
41
+ type: "string"
42
+ }
37
43
  }
38
44
  },
39
45
  additionalProperties: false,
40
- required: ["name"]
46
+ required: ["name"],
47
+ not: { required: ["importNames", "allowImportNames"] }
41
48
  }
42
49
  ]
43
50
  },
@@ -66,6 +73,14 @@ const arrayOfStringsOrObjectPatterns = {
66
73
  minItems: 1,
67
74
  uniqueItems: true
68
75
  },
76
+ allowImportNames: {
77
+ type: "array",
78
+ items: {
79
+ type: "string"
80
+ },
81
+ minItems: 1,
82
+ uniqueItems: true
83
+ },
69
84
  group: {
70
85
  type: "array",
71
86
  items: {
@@ -77,6 +92,9 @@ const arrayOfStringsOrObjectPatterns = {
77
92
  importNamePattern: {
78
93
  type: "string"
79
94
  },
95
+ allowImportNamePattern: {
96
+ type: "string"
97
+ },
80
98
  message: {
81
99
  type: "string",
82
100
  minLength: 1
@@ -86,7 +104,16 @@ const arrayOfStringsOrObjectPatterns = {
86
104
  }
87
105
  },
88
106
  additionalProperties: false,
89
- required: ["group"]
107
+ required: ["group"],
108
+ not: {
109
+ anyOf: [
110
+ { required: ["importNames", "allowImportNames"] },
111
+ { required: ["importNamePattern", "allowImportNamePattern"] },
112
+ { required: ["importNames", "allowImportNamePattern"] },
113
+ { required: ["importNamePattern", "allowImportNames"] },
114
+ { required: ["allowImportNames", "allowImportNamePattern"] }
115
+ ]
116
+ }
90
117
  },
91
118
  uniqueItems: true
92
119
  }
@@ -131,7 +158,23 @@ module.exports = {
131
158
 
132
159
  importName: "'{{importName}}' import from '{{importSource}}' is restricted.",
133
160
  // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period
134
- importNameWithCustomMessage: "'{{importName}}' import from '{{importSource}}' is restricted. {{customMessage}}"
161
+ importNameWithCustomMessage: "'{{importName}}' import from '{{importSource}}' is restricted. {{customMessage}}",
162
+
163
+ allowedImportName: "'{{importName}}' import from '{{importSource}}' is restricted because only '{{allowedImportNames}}' import(s) is/are allowed.",
164
+ // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period
165
+ allowedImportNameWithCustomMessage: "'{{importName}}' import from '{{importSource}}' is restricted because only '{{allowedImportNames}}' import(s) is/are allowed. {{customMessage}}",
166
+
167
+ everythingWithAllowImportNames: "* import is invalid because only '{{allowedImportNames}}' from '{{importSource}}' is/are allowed.",
168
+ // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period
169
+ everythingWithAllowImportNamesAndCustomMessage: "* import is invalid because only '{{allowedImportNames}}' from '{{importSource}}' is/are allowed. {{customMessage}}",
170
+
171
+ allowedImportNamePattern: "'{{importName}}' import from '{{importSource}}' is restricted because only imports that match the pattern '{{allowedImportNamePattern}}' are allowed from '{{importSource}}'.",
172
+ // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period
173
+ allowedImportNamePatternWithCustomMessage: "'{{importName}}' import from '{{importSource}}' is restricted because only imports that match the pattern '{{allowedImportNamePattern}}' are allowed from '{{importSource}}'. {{customMessage}}",
174
+
175
+ everythingWithAllowedImportNamePattern: "* import is invalid because only imports that match the pattern '{{allowedImportNamePattern}}' from '{{importSource}}' are allowed.",
176
+ // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period
177
+ everythingWithAllowedImportNamePatternWithCustomMessage: "* import is invalid because only imports that match the pattern '{{allowedImportNamePattern}}' from '{{importSource}}' are allowed. {{customMessage}}"
135
178
  },
136
179
 
137
180
  schema: {
@@ -175,7 +218,8 @@ module.exports = {
175
218
  } else {
176
219
  memo[path].push({
177
220
  message: importSource.message,
178
- importNames: importSource.importNames
221
+ importNames: importSource.importNames,
222
+ allowImportNames: importSource.allowImportNames
179
223
  });
180
224
  }
181
225
  return memo;
@@ -190,12 +234,18 @@ module.exports = {
190
234
  }
191
235
 
192
236
  // relative paths are supported for this rule
193
- const restrictedPatternGroups = restrictedPatterns.map(({ group, message, caseSensitive, importNames, importNamePattern }) => ({
194
- matcher: ignore({ allowRelativePaths: true, ignorecase: !caseSensitive }).add(group),
195
- customMessage: message,
196
- importNames,
197
- importNamePattern
198
- }));
237
+ const restrictedPatternGroups = restrictedPatterns.map(
238
+ ({ group, message, caseSensitive, importNames, importNamePattern, allowImportNames, allowImportNamePattern }) => (
239
+ {
240
+ matcher: ignore({ allowRelativePaths: true, ignorecase: !caseSensitive }).add(group),
241
+ customMessage: message,
242
+ importNames,
243
+ importNamePattern,
244
+ allowImportNames,
245
+ allowImportNamePattern
246
+ }
247
+ )
248
+ );
199
249
 
200
250
  // if no imports are restricted we don't need to check
201
251
  if (Object.keys(restrictedPaths).length === 0 && restrictedPatternGroups.length === 0) {
@@ -218,42 +268,9 @@ module.exports = {
218
268
  groupedRestrictedPaths[importSource].forEach(restrictedPathEntry => {
219
269
  const customMessage = restrictedPathEntry.message;
220
270
  const restrictedImportNames = restrictedPathEntry.importNames;
271
+ const allowedImportNames = restrictedPathEntry.allowImportNames;
221
272
 
222
- if (restrictedImportNames) {
223
- if (importNames.has("*")) {
224
- const specifierData = importNames.get("*")[0];
225
-
226
- context.report({
227
- node,
228
- messageId: customMessage ? "everythingWithCustomMessage" : "everything",
229
- loc: specifierData.loc,
230
- data: {
231
- importSource,
232
- importNames: restrictedImportNames,
233
- customMessage
234
- }
235
- });
236
- }
237
-
238
- restrictedImportNames.forEach(importName => {
239
- if (importNames.has(importName)) {
240
- const specifiers = importNames.get(importName);
241
-
242
- specifiers.forEach(specifier => {
243
- context.report({
244
- node,
245
- messageId: customMessage ? "importNameWithCustomMessage" : "importName",
246
- loc: specifier.loc,
247
- data: {
248
- importSource,
249
- customMessage,
250
- importName
251
- }
252
- });
253
- });
254
- }
255
- });
256
- } else {
273
+ if (!restrictedImportNames && !allowedImportNames) {
257
274
  context.report({
258
275
  node,
259
276
  messageId: customMessage ? "pathWithCustomMessage" : "path",
@@ -262,7 +279,72 @@ module.exports = {
262
279
  customMessage
263
280
  }
264
281
  });
282
+
283
+ return;
265
284
  }
285
+
286
+ importNames.forEach((specifiers, importName) => {
287
+ if (importName === "*") {
288
+ const [specifier] = specifiers;
289
+
290
+ if (restrictedImportNames) {
291
+ context.report({
292
+ node,
293
+ messageId: customMessage ? "everythingWithCustomMessage" : "everything",
294
+ loc: specifier.loc,
295
+ data: {
296
+ importSource,
297
+ importNames: restrictedImportNames,
298
+ customMessage
299
+ }
300
+ });
301
+ } else if (allowedImportNames) {
302
+ context.report({
303
+ node,
304
+ messageId: customMessage ? "everythingWithAllowImportNamesAndCustomMessage" : "everythingWithAllowImportNames",
305
+ loc: specifier.loc,
306
+ data: {
307
+ importSource,
308
+ allowedImportNames,
309
+ customMessage
310
+ }
311
+ });
312
+ }
313
+
314
+ return;
315
+ }
316
+
317
+ if (restrictedImportNames && restrictedImportNames.includes(importName)) {
318
+ specifiers.forEach(specifier => {
319
+ context.report({
320
+ node,
321
+ messageId: customMessage ? "importNameWithCustomMessage" : "importName",
322
+ loc: specifier.loc,
323
+ data: {
324
+ importSource,
325
+ customMessage,
326
+ importName
327
+ }
328
+ });
329
+ });
330
+ }
331
+
332
+ if (allowedImportNames && !allowedImportNames.includes(importName)) {
333
+ specifiers.forEach(specifier => {
334
+ context.report({
335
+ node,
336
+ loc: specifier.loc,
337
+ messageId: customMessage ? "allowedImportNameWithCustomMessage" : "allowedImportName",
338
+ data: {
339
+ importSource,
340
+ customMessage,
341
+ importName,
342
+ allowedImportNames
343
+ }
344
+ });
345
+ });
346
+ }
347
+ });
266
348
  });
267
349
  }
268
350
 
@@ -281,12 +363,14 @@ module.exports = {
281
363
  const customMessage = group.customMessage;
282
364
  const restrictedImportNames = group.importNames;
283
365
  const restrictedImportNamePattern = group.importNamePattern ? new RegExp(group.importNamePattern, "u") : null;
366
+ const allowedImportNames = group.allowImportNames;
367
+ const allowedImportNamePattern = group.allowImportNamePattern ? new RegExp(group.allowImportNamePattern, "u") : null;
284
368
 
285
- /*
369
+ /**
286
370
  * If we are not restricting to any specific import names and just the pattern itself,
287
371
  * report the error and move on
288
372
  */
289
- if (!restrictedImportNames && !restrictedImportNamePattern) {
373
+ if (!restrictedImportNames && !allowedImportNames && !restrictedImportNamePattern && !allowedImportNamePattern) {
290
374
  context.report({
291
375
  node,
292
376
  messageId: customMessage ? "patternWithCustomMessage" : "patterns",
@@ -313,6 +397,28 @@ module.exports = {
313
397
  customMessage
314
398
  }
315
399
  });
400
+ } else if (allowedImportNames) {
401
+ context.report({
402
+ node,
403
+ messageId: customMessage ? "everythingWithAllowImportNamesAndCustomMessage" : "everythingWithAllowImportNames",
404
+ loc: specifier.loc,
405
+ data: {
406
+ importSource,
407
+ allowedImportNames,
408
+ customMessage
409
+ }
410
+ });
411
+ } else if (allowedImportNamePattern) {
412
+ context.report({
413
+ node,
414
+ messageId: customMessage ? "everythingWithAllowedImportNamePatternWithCustomMessage" : "everythingWithAllowedImportNamePattern",
415
+ loc: specifier.loc,
416
+ data: {
417
+ importSource,
418
+ allowedImportNamePattern,
419
+ customMessage
420
+ }
421
+ });
316
422
  } else {
317
423
  context.report({
318
424
  node,
@@ -346,6 +452,36 @@ module.exports = {
346
452
  });
347
453
  });
348
454
  }
455
+
456
+ if (allowedImportNames && !allowedImportNames.includes(importName)) {
457
+ specifiers.forEach(specifier => {
458
+ context.report({
459
+ node,
460
+ messageId: customMessage ? "allowedImportNameWithCustomMessage" : "allowedImportName",
461
+ loc: specifier.loc,
462
+ data: {
463
+ importSource,
464
+ customMessage,
465
+ importName,
466
+ allowedImportNames
467
+ }
468
+ });
469
+ });
470
+ } else if (allowedImportNamePattern && !allowedImportNamePattern.test(importName)) {
471
+ specifiers.forEach(specifier => {
472
+ context.report({
473
+ node,
474
+ messageId: customMessage ? "allowedImportNamePatternWithCustomMessage" : "allowedImportNamePattern",
475
+ loc: specifier.loc,
476
+ data: {
477
+ importSource,
478
+ customMessage,
479
+ importName,
480
+ allowedImportNamePattern
481
+ }
482
+ });
483
+ });
484
+ }
349
485
  });
350
486
  }
351
487
 
@@ -70,6 +70,9 @@ module.exports = {
70
70
  },
71
71
  destructuredArrayIgnorePattern: {
72
72
  type: "string"
73
+ },
74
+ ignoreClassWithStaticInitBlock: {
75
+ type: "boolean"
73
76
  }
74
77
  },
75
78
  additionalProperties: false
@@ -92,7 +95,8 @@ module.exports = {
92
95
  vars: "all",
93
96
  args: "after-used",
94
97
  ignoreRestSiblings: false,
95
- caughtErrors: "all"
98
+ caughtErrors: "all",
99
+ ignoreClassWithStaticInitBlock: false
96
100
  };
97
101
 
98
102
  const firstOption = context.options[0];
@@ -105,6 +109,7 @@ module.exports = {
105
109
  config.args = firstOption.args || config.args;
106
110
  config.ignoreRestSiblings = firstOption.ignoreRestSiblings || config.ignoreRestSiblings;
107
111
  config.caughtErrors = firstOption.caughtErrors || config.caughtErrors;
112
+ config.ignoreClassWithStaticInitBlock = firstOption.ignoreClassWithStaticInitBlock || config.ignoreClassWithStaticInitBlock;
108
113
 
109
114
  if (firstOption.varsIgnorePattern) {
110
115
  config.varsIgnorePattern = new RegExp(firstOption.varsIgnorePattern, "u");
@@ -613,6 +618,14 @@ module.exports = {
613
618
  continue;
614
619
  }
615
620
 
621
+ if (type === "ClassName") {
622
+ const hasStaticBlock = def.node.body.body.some(node => node.type === "StaticBlock");
623
+
624
+ if (config.ignoreClassWithStaticInitBlock && hasStaticBlock) {
625
+ continue;
626
+ }
627
+ }
628
+
616
629
  // skip catch variables
617
630
  if (type === "CatchClause") {
618
631
  if (config.caughtErrors === "none") {
@@ -74,7 +74,8 @@ module.exports = {
74
74
  caseNaN: "'case NaN' can never match. Use Number.isNaN before the switch.",
75
75
  indexOfNaN: "Array prototype method '{{ methodName }}' cannot find NaN.",
76
76
  replaceWithIsNaN: "Replace with Number.isNaN.",
77
- replaceWithCastingAndIsNaN: "Replace with Number.isNaN cast to a Number."
77
+ replaceWithCastingAndIsNaN: "Replace with Number.isNaN and cast to a Number.",
78
+ replaceWithFindIndex: "Replace with Array.prototype.{{ methodName }}."
78
79
  }
79
80
  },
80
81
 
@@ -126,10 +127,10 @@ module.exports = {
126
127
  const NaNNode = isNaNIdentifier(node.left) ? node.left : node.right;
127
128
 
128
129
  const isSequenceExpression = NaNNode.type === "SequenceExpression";
129
- const isFixable = fixableOperators.has(node.operator) && !isSequenceExpression;
130
+ const isSuggestable = fixableOperators.has(node.operator) && !isSequenceExpression;
130
131
  const isCastable = castableOperators.has(node.operator);
131
132
 
132
- if (isFixable) {
133
+ if (isSuggestable) {
133
134
  suggestedFixes.push({
134
135
  messageId: "replaceWithIsNaN",
135
136
  fix: getBinaryExpressionFixer(node, value => `Number.isNaN(${value})`)
@@ -184,7 +185,35 @@ module.exports = {
184
185
  node.arguments.length === 1 &&
185
186
  isNaNIdentifier(node.arguments[0])
186
187
  ) {
187
- context.report({ node, messageId: "indexOfNaN", data: { methodName } });
188
+
189
+ /*
190
+ * To retain side effects, it's essential to address `NaN` beforehand, which
191
+ * is not possible with fixes like `arr.findIndex(Number.isNaN)`.
192
+ */
193
+ const isSuggestable = node.arguments[0].type !== "SequenceExpression";
194
+ const suggestedFixes = [];
195
+
196
+ if (isSuggestable) {
197
+ const shouldWrap = callee.computed;
198
+ const findIndexMethod = methodName === "indexOf" ? "findIndex" : "findLastIndex";
199
+ const propertyName = shouldWrap ? `"${findIndexMethod}"` : findIndexMethod;
200
+
201
+ suggestedFixes.push({
202
+ messageId: "replaceWithFindIndex",
203
+ data: { methodName: findIndexMethod },
204
+ fix: fixer => [
205
+ fixer.replaceText(callee.property, propertyName),
206
+ fixer.replaceText(node.arguments[0], "Number.isNaN")
207
+ ]
208
+ });
209
+ }
210
+
211
+ context.report({
212
+ node,
213
+ messageId: "indexOfNaN",
214
+ data: { methodName },
215
+ suggest: suggestedFixes
216
+ });
188
217
  }
189
218
  }
190
219
  }