eslint 9.0.0-alpha.1 → 9.0.0-beta.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
@@ -117,7 +117,7 @@ Yes, ESLint natively supports parsing JSX syntax (this must be enabled in [confi
117
117
 
118
118
  ### What ECMAScript versions does ESLint support?
119
119
 
120
- ESLint has full support for ECMAScript 3, 5 (default), 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, and 2023. You can set your desired ECMAScript syntax (and other settings, like global variables or your target environments) through [configuration](https://eslint.org/docs/latest/use/configure).
120
+ ESLint has full support for ECMAScript 3, 5, and every year from 2015 up until the most recent stage 4 specification (the default). You can set your desired ECMAScript syntax and other settings (like global variables) through [configuration](https://eslint.org/docs/latest/use/configure).
121
121
 
122
122
  ### What about experimental features?
123
123
 
@@ -255,11 +255,6 @@ Josh Goldberg ✨
255
255
  Francesco Trotta
256
256
  </a>
257
257
  </td><td align="center" valign="top" width="11%">
258
- <a href="https://github.com/ota-meshi">
259
- <img src="https://github.com/ota-meshi.png?s=75" width="75" height="75" alt="Yosuke Ota's Avatar"><br />
260
- Yosuke Ota
261
- </a>
262
- </td><td align="center" valign="top" width="11%">
263
258
  <a href="https://github.com/Tanujkanti4441">
264
259
  <img src="https://github.com/Tanujkanti4441.png?s=75" width="75" height="75" alt="Tanuj Kanti's Avatar"><br />
265
260
  Tanuj Kanti
@@ -299,7 +294,7 @@ The following companies, organizations, and individuals support ESLint's ongoing
299
294
  <p><a href="#"><img src="https://images.opencollective.com/2021-frameworks-fund/logo.png" alt="Chrome Frameworks Fund" height="undefined"></a> <a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="undefined"></a></p><h3>Gold Sponsors</h3>
300
295
  <p><a href="https://engineering.salesforce.com"><img src="https://images.opencollective.com/salesforce/ca8f997/logo.png" alt="Salesforce" height="96"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="96"></a></p><h3>Silver Sponsors</h3>
301
296
  <p><a href="https://www.jetbrains.com/"><img src="https://images.opencollective.com/jetbrains/eb04ddc/logo.png" alt="JetBrains" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="64"></a> <a href="https://www.workleap.com"><img src="https://avatars.githubusercontent.com/u/53535748?u=d1e55d7661d724bf2281c1bfd33cb8f99fe2465f&v=4" alt="Workleap" height="64"></a></p><h3>Bronze Sponsors</h3>
302
- <p><a href="https://themeisle.com"><img src="https://images.opencollective.com/themeisle/d5592fe/logo.png" alt="ThemeIsle" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://transloadit.com/"><img src="https://avatars.githubusercontent.com/u/125754?v=4" alt="Transloadit" height="32"></a> <a href="https://www.ignitionapp.com"><img src="https://avatars.githubusercontent.com/u/5753491?v=4" alt="Ignition" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104?v=4" alt="Nx" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774?v=4" alt="HeroCoders" height="32"></a></p>
297
+ <p><a href="https://themeisle.com"><img src="https://images.opencollective.com/themeisle/d5592fe/logo.png" alt="ThemeIsle" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://transloadit.com/"><img src="https://avatars.githubusercontent.com/u/125754?v=4" alt="Transloadit" height="32"></a> <a href="https://www.ignitionapp.com"><img src="https://avatars.githubusercontent.com/u/5753491?v=4" alt="Ignition" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104?v=4" alt="Nx" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774?v=4" alt="HeroCoders" height="32"></a> <a href="https://usenextbase.com"><img src="https://avatars.githubusercontent.com/u/145838380?v=4" alt="Nextbase Starter Kit" height="32"></a></p>
303
298
  <!--sponsorsend-->
304
299
 
305
300
  ## Technology Sponsors
package/lib/api.js CHANGED
@@ -9,17 +9,41 @@
9
9
  // Requirements
10
10
  //-----------------------------------------------------------------------------
11
11
 
12
- const { ESLint } = require("./eslint/eslint");
12
+ const { ESLint, shouldUseFlatConfig } = require("./eslint/eslint");
13
+ const { LegacyESLint } = require("./eslint/legacy-eslint");
13
14
  const { Linter } = require("./linter");
14
15
  const { RuleTester } = require("./rule-tester");
15
16
  const { SourceCode } = require("./source-code");
16
17
 
18
+ //-----------------------------------------------------------------------------
19
+ // Functions
20
+ //-----------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Loads the correct ESLint constructor given the options.
24
+ * @param {Object} [options] The options object
25
+ * @param {boolean} [options.useFlatConfig] Whether or not to use a flat config
26
+ * @returns {Promise<ESLint|LegacyESLint>} The ESLint constructor
27
+ */
28
+ async function loadESLint({ useFlatConfig } = {}) {
29
+
30
+ /*
31
+ * Note: The v8.x version of this function also accepted a `cwd` option, but
32
+ * it is not used in this implementation so we silently ignore it.
33
+ */
34
+
35
+ const shouldESLintUseFlatConfig = useFlatConfig ?? (await shouldUseFlatConfig());
36
+
37
+ return shouldESLintUseFlatConfig ? ESLint : LegacyESLint;
38
+ }
39
+
17
40
  //-----------------------------------------------------------------------------
18
41
  // Exports
19
42
  //-----------------------------------------------------------------------------
20
43
 
21
44
  module.exports = {
22
45
  Linter,
46
+ loadESLint,
23
47
  ESLint,
24
48
  RuleTester,
25
49
  SourceCode
@@ -565,6 +565,12 @@ function createExtraneousResultsError() {
565
565
  */
566
566
  class ESLint {
567
567
 
568
+ /**
569
+ * The type of configuration used by this class.
570
+ * @type {string}
571
+ */
572
+ static configType = "flat";
573
+
568
574
  /**
569
575
  * Creates a new instance of the main ESLint API.
570
576
  * @param {ESLintOptions} options The options for this instance.
@@ -613,6 +619,13 @@ class ESLint {
613
619
  });
614
620
  }
615
621
 
622
+ // Check for the .eslintignore file, and warn if it's present.
623
+ if (existsSync(path.resolve(processedOptions.cwd, ".eslintignore"))) {
624
+ process.emitWarning(
625
+ "The \".eslintignore\" file is no longer supported. Switch to using the \"ignores\" property in \"eslint.config.js\": https://eslint.org/docs/latest/use/configure/migration-guide#ignoring-files",
626
+ "ESLintIgnoreWarning"
627
+ );
628
+ }
616
629
  }
617
630
 
618
631
  /**
@@ -438,6 +438,12 @@ function compareResultsByFilePath(a, b) {
438
438
  */
439
439
  class LegacyESLint {
440
440
 
441
+ /**
442
+ * The type of configuration used by this class.
443
+ * @type {string}
444
+ */
445
+ static configType = "eslintrc";
446
+
441
447
  /**
442
448
  * Creates a new instance of the main ESLint API.
443
449
  * @param {LegacyESLintOptions} options The options for this instance.
@@ -177,8 +177,8 @@ class CodePath {
177
177
  // tracks the traversal steps
178
178
  const stack = [[startSegment, 0]];
179
179
 
180
- // tracks the last skipped segment during traversal
181
- let skippedSegment = null;
180
+ // segments that have been skipped during traversal
181
+ const skipped = new Set();
182
182
 
183
183
  // indicates if we exited early from the traversal
184
184
  let broken = false;
@@ -193,11 +193,7 @@ class CodePath {
193
193
  * @returns {void}
194
194
  */
195
195
  skip() {
196
- if (stack.length <= 1) {
197
- broken = true;
198
- } else {
199
- skippedSegment = stack.at(-2)[0];
200
- }
196
+ skipped.add(segment);
201
197
  },
202
198
 
203
199
  /**
@@ -222,6 +218,18 @@ class CodePath {
222
218
  );
223
219
  }
224
220
 
221
+ /**
222
+ * Checks if a given previous segment has been skipped.
223
+ * @param {CodePathSegment} prevSegment A previous segment to check.
224
+ * @returns {boolean} `true` if the segment has been skipped.
225
+ */
226
+ function isSkipped(prevSegment) {
227
+ return (
228
+ skipped.has(prevSegment) ||
229
+ segment.isLoopedPrevSegment(prevSegment)
230
+ );
231
+ }
232
+
225
233
  // the traversal
226
234
  while (stack.length > 0) {
227
235
 
@@ -258,17 +266,21 @@ class CodePath {
258
266
  continue;
259
267
  }
260
268
 
261
- // Reset the skipping flag if all branches have been skipped.
262
- if (skippedSegment && segment.prevSegments.includes(skippedSegment)) {
263
- skippedSegment = null;
264
- }
265
269
  visited.add(segment);
266
270
 
271
+
272
+ // Skips the segment if all previous segments have been skipped.
273
+ const shouldSkip = (
274
+ skipped.size > 0 &&
275
+ segment.prevSegments.length > 0 &&
276
+ segment.prevSegments.every(isSkipped)
277
+ );
278
+
267
279
  /*
268
280
  * If the most recent segment hasn't been skipped, then we call
269
281
  * the callback, passing in the segment and the controller.
270
282
  */
271
- if (!skippedSegment) {
283
+ if (!shouldSkip) {
272
284
  resolvedCallback.call(this, segment, controller);
273
285
 
274
286
  // exit if we're at the last segment
@@ -284,6 +296,10 @@ class CodePath {
284
296
  if (broken) {
285
297
  break;
286
298
  }
299
+ } else {
300
+
301
+ // If the most recent segment has been skipped, then mark it as skipped.
302
+ skipped.add(segment);
287
303
  }
288
304
  }
289
305
 
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const { Linter } = require("./linter");
4
- const interpolate = require("./interpolate");
4
+ const { interpolate } = require("./interpolate");
5
5
  const SourceCodeFixer = require("./source-code-fixer");
6
6
 
7
7
  module.exports = {
@@ -9,13 +9,30 @@
9
9
  // Public Interface
10
10
  //------------------------------------------------------------------------------
11
11
 
12
- module.exports = (text, data) => {
12
+ /**
13
+ * Returns a global expression matching placeholders in messages.
14
+ * @returns {RegExp} Global regular expression matching placeholders
15
+ */
16
+ function getPlaceholderMatcher() {
17
+ return /\{\{([^{}]+?)\}\}/gu;
18
+ }
19
+
20
+ /**
21
+ * Replaces {{ placeholders }} in the message with the provided data.
22
+ * Does not replace placeholders not available in the data.
23
+ * @param {string} text Original message with potential placeholders
24
+ * @param {Record<string, string>} data Map of placeholder name to its value
25
+ * @returns {string} Message with replaced placeholders
26
+ */
27
+ function interpolate(text, data) {
13
28
  if (!data) {
14
29
  return text;
15
30
  }
16
31
 
32
+ const matcher = getPlaceholderMatcher();
33
+
17
34
  // Substitution content for any {{ }} markers.
18
- return text.replace(/\{\{([^{}]+?)\}\}/gu, (fullMatch, termWithWhitespace) => {
35
+ return text.replace(matcher, (fullMatch, termWithWhitespace) => {
19
36
  const term = termWithWhitespace.trim();
20
37
 
21
38
  if (term in data) {
@@ -25,4 +42,9 @@ module.exports = (text, data) => {
25
42
  // Preserve old behavior: If parameter name not provided, don't replace it.
26
43
  return fullMatch;
27
44
  });
45
+ }
46
+
47
+ module.exports = {
48
+ getPlaceholderMatcher,
49
+ interpolate
28
50
  };
@@ -11,7 +11,7 @@
11
11
 
12
12
  const assert = require("assert");
13
13
  const ruleFixer = require("./rule-fixer");
14
- const interpolate = require("./interpolate");
14
+ const { interpolate } = require("./interpolate");
15
15
 
16
16
  //------------------------------------------------------------------------------
17
17
  // Typedefs
@@ -13,10 +13,13 @@
13
13
  const
14
14
  assert = require("assert"),
15
15
  util = require("util"),
16
+ path = require("path"),
16
17
  equal = require("fast-deep-equal"),
17
18
  Traverser = require("../shared/traverser"),
18
19
  { getRuleOptionsSchema } = require("../config/flat-config-helpers"),
19
- { Linter, SourceCodeFixer, interpolate } = require("../linter");
20
+ { Linter, SourceCodeFixer } = require("../linter"),
21
+ { interpolate, getPlaceholderMatcher } = require("../linter/interpolate"),
22
+ stringify = require("json-stable-stringify-without-jsonify");
20
23
 
21
24
  const { FlatConfigArray } = require("../config/flat-config-array");
22
25
  const { defaultConfig } = require("../config/default-config");
@@ -26,6 +29,7 @@ const ajv = require("../shared/ajv")({ strictDefaults: true });
26
29
  const parserSymbol = Symbol.for("eslint.RuleTester.parser");
27
30
  const { SourceCode } = require("../source-code");
28
31
  const { ConfigArraySymbol } = require("@humanwhocodes/config-array");
32
+ const { isSerializable } = require("../shared/serialization");
29
33
 
30
34
  //------------------------------------------------------------------------------
31
35
  // Typedefs
@@ -301,6 +305,39 @@ function throwForbiddenMethodError(methodName, prototype) {
301
305
  };
302
306
  }
303
307
 
308
+ /**
309
+ * Extracts names of {{ placeholders }} from the reported message.
310
+ * @param {string} message Reported message
311
+ * @returns {string[]} Array of placeholder names
312
+ */
313
+ function getMessagePlaceholders(message) {
314
+ const matcher = getPlaceholderMatcher();
315
+
316
+ return Array.from(message.matchAll(matcher), ([, name]) => name.trim());
317
+ }
318
+
319
+ /**
320
+ * Returns the placeholders in the reported messages but
321
+ * only includes the placeholders available in the raw message and not in the provided data.
322
+ * @param {string} message The reported message
323
+ * @param {string} raw The raw message specified in the rule meta.messages
324
+ * @param {undefined|Record<unknown, unknown>} data The passed
325
+ * @returns {string[]} Missing placeholder names
326
+ */
327
+ function getUnsubstitutedMessagePlaceholders(message, raw, data = {}) {
328
+ const unsubstituted = getMessagePlaceholders(message);
329
+
330
+ if (unsubstituted.length === 0) {
331
+ return [];
332
+ }
333
+
334
+ // Remove false positives by only counting placeholders in the raw message, which were not provided in the data matcher or added with a data property
335
+ const known = getMessagePlaceholders(raw);
336
+ const provided = Object.keys(data);
337
+
338
+ return unsubstituted.filter(name => known.includes(name) && !provided.includes(name));
339
+ }
340
+
304
341
  const metaSchemaDescription = `
305
342
  \t- If the rule has options, set \`meta.schema\` to an array or non-empty object to enable options validation.
306
343
  \t- If the rule doesn't have options, omit \`meta.schema\` to enforce that no options can be passed to the rule.
@@ -499,6 +536,9 @@ class RuleTester {
499
536
  linter = this.linter,
500
537
  ruleId = `rule-to-test/${ruleName}`;
501
538
 
539
+ const seenValidTestCases = new Set();
540
+ const seenInvalidTestCases = new Set();
541
+
502
542
  if (!rule || typeof rule !== "object" || typeof rule.create !== "function") {
503
543
  throw new TypeError("Rule must be an object with a `create` method");
504
544
  }
@@ -577,7 +617,15 @@ class RuleTester {
577
617
  * @private
578
618
  */
579
619
  function runRuleForItem(item) {
580
- const configs = new FlatConfigArray(testerConfig, { baseConfig });
620
+ const flatConfigArrayOptions = {
621
+ baseConfig
622
+ };
623
+
624
+ if (item.filename) {
625
+ flatConfigArrayOptions.basePath = path.parse(item.filename).root;
626
+ }
627
+
628
+ const configs = new FlatConfigArray(testerConfig, flatConfigArrayOptions);
581
629
 
582
630
  /*
583
631
  * Modify the returned config so that the parser is wrapped to catch
@@ -631,7 +679,11 @@ class RuleTester {
631
679
  configs.push(itemConfig);
632
680
  }
633
681
 
634
- if (item.filename) {
682
+ if (hasOwnProperty(item, "only")) {
683
+ assert.ok(typeof item.only === "boolean", "Optional test case property 'only' must be a boolean");
684
+ }
685
+ if (hasOwnProperty(item, "filename")) {
686
+ assert.ok(typeof item.filename === "string", "Optional test case property 'filename' must be a string");
635
687
  filename = item.filename;
636
688
  }
637
689
 
@@ -794,6 +846,32 @@ class RuleTester {
794
846
  }
795
847
  }
796
848
 
849
+ /**
850
+ * Check if this test case is a duplicate of one we have seen before.
851
+ * @param {Object} item test case object
852
+ * @param {Set<string>} seenTestCases set of serialized test cases we have seen so far (managed by this function)
853
+ * @returns {void}
854
+ * @private
855
+ */
856
+ function checkDuplicateTestCase(item, seenTestCases) {
857
+ if (!isSerializable(item)) {
858
+
859
+ /*
860
+ * If we can't serialize a test case (because it contains a function, RegExp, etc), skip the check.
861
+ * This might happen with properties like: options, plugins, settings, languageOptions.parser, languageOptions.parserOptions.
862
+ */
863
+ return;
864
+ }
865
+
866
+ const serializedTestCase = stringify(item);
867
+
868
+ assert(
869
+ !seenTestCases.has(serializedTestCase),
870
+ "detected duplicate test case"
871
+ );
872
+ seenTestCases.add(serializedTestCase);
873
+ }
874
+
797
875
  /**
798
876
  * Check if the template is valid or not
799
877
  * all valid cases go through this
@@ -809,6 +887,8 @@ class RuleTester {
809
887
  assert.ok(typeof item.name === "string", "Optional test case property 'name' must be a string");
810
888
  }
811
889
 
890
+ checkDuplicateTestCase(item, seenValidTestCases);
891
+
812
892
  const result = runRuleForItem(item);
813
893
  const messages = result.messages;
814
894
 
@@ -860,6 +940,8 @@ class RuleTester {
860
940
  assert.fail("Invalid cases must have at least one error");
861
941
  }
862
942
 
943
+ checkDuplicateTestCase(item, seenInvalidTestCases);
944
+
863
945
  const ruleHasMetaMessages = hasOwnProperty(rule, "meta") && hasOwnProperty(rule.meta, "messages");
864
946
  const friendlyIDList = ruleHasMetaMessages ? `[${Object.keys(rule.meta.messages).map(key => `'${key}'`).join(", ")}]` : null;
865
947
 
@@ -916,6 +998,7 @@ class RuleTester {
916
998
 
917
999
  // Just an error message.
918
1000
  assertMessageMatches(message.message, error);
1001
+ assert.ok(message.suggestions === void 0, `Error at index ${i} has suggestions. Please convert the test error into an object and specify 'suggestions' property on it to test suggestions.`);
919
1002
  } else if (typeof error === "object" && error !== null) {
920
1003
 
921
1004
  /*
@@ -948,6 +1031,18 @@ class RuleTester {
948
1031
  error.messageId,
949
1032
  `messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`
950
1033
  );
1034
+
1035
+ const unsubstitutedPlaceholders = getUnsubstitutedMessagePlaceholders(
1036
+ message.message,
1037
+ rule.meta.messages[message.messageId],
1038
+ error.data
1039
+ );
1040
+
1041
+ assert.ok(
1042
+ unsubstitutedPlaceholders.length === 0,
1043
+ `The reported message has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property in the context.report() call.`
1044
+ );
1045
+
951
1046
  if (hasOwnProperty(error, "data")) {
952
1047
 
953
1048
  /*
@@ -964,13 +1059,10 @@ class RuleTester {
964
1059
  `Hydrated message "${rehydratedMessage}" does not match "${message.message}"`
965
1060
  );
966
1061
  }
1062
+ } else {
1063
+ assert.fail("Test error must specify either a 'messageId' or 'message'.");
967
1064
  }
968
1065
 
969
- assert.ok(
970
- hasOwnProperty(error, "data") ? hasOwnProperty(error, "messageId") : true,
971
- "Error must specify 'messageId' if 'data' is used."
972
- );
973
-
974
1066
  if (error.type) {
975
1067
  assert.strictEqual(message.nodeType, error.type, `Error type should be ${error.type}, found ${message.nodeType}`);
976
1068
  }
@@ -991,81 +1083,103 @@ class RuleTester {
991
1083
  assert.strictEqual(message.endColumn, error.endColumn, `Error endColumn should be ${error.endColumn}`);
992
1084
  }
993
1085
 
1086
+ assert.ok(!message.suggestions || hasOwnProperty(error, "suggestions"), `Error at index ${i} has suggestions. Please specify 'suggestions' property on the test error object.`);
994
1087
  if (hasOwnProperty(error, "suggestions")) {
995
1088
 
996
1089
  // Support asserting there are no suggestions
997
- if (!error.suggestions || (Array.isArray(error.suggestions) && error.suggestions.length === 0)) {
998
- if (Array.isArray(message.suggestions) && message.suggestions.length > 0) {
999
- assert.fail(`Error should have no suggestions on error with message: "${message.message}"`);
1000
- }
1001
- } else {
1002
- assert.strictEqual(Array.isArray(message.suggestions), true, `Error should have an array of suggestions. Instead received "${message.suggestions}" on error with message: "${message.message}"`);
1003
- assert.strictEqual(message.suggestions.length, error.suggestions.length, `Error should have ${error.suggestions.length} suggestions. Instead found ${message.suggestions.length} suggestions`);
1004
-
1005
- error.suggestions.forEach((expectedSuggestion, index) => {
1006
- assert.ok(
1007
- typeof expectedSuggestion === "object" && expectedSuggestion !== null,
1008
- "Test suggestion in 'suggestions' array must be an object."
1009
- );
1010
- Object.keys(expectedSuggestion).forEach(propertyName => {
1090
+ const expectsSuggestions = Array.isArray(error.suggestions) ? error.suggestions.length > 0 : Boolean(error.suggestions);
1091
+ const hasSuggestions = message.suggestions !== void 0;
1092
+
1093
+ if (!hasSuggestions && expectsSuggestions) {
1094
+ assert.ok(!error.suggestions, `Error should have suggestions on error with message: "${message.message}"`);
1095
+ } else if (hasSuggestions) {
1096
+ assert.ok(expectsSuggestions, `Error should have no suggestions on error with message: "${message.message}"`);
1097
+ if (typeof error.suggestions === "number") {
1098
+ assert.strictEqual(message.suggestions.length, error.suggestions, `Error should have ${error.suggestions} suggestions. Instead found ${message.suggestions.length} suggestions`);
1099
+ } else if (Array.isArray(error.suggestions)) {
1100
+ assert.strictEqual(message.suggestions.length, error.suggestions.length, `Error should have ${error.suggestions.length} suggestions. Instead found ${message.suggestions.length} suggestions`);
1101
+
1102
+ error.suggestions.forEach((expectedSuggestion, index) => {
1011
1103
  assert.ok(
1012
- suggestionObjectParameters.has(propertyName),
1013
- `Invalid suggestion property name '${propertyName}'. Expected one of ${friendlySuggestionObjectParameterList}.`
1104
+ typeof expectedSuggestion === "object" && expectedSuggestion !== null,
1105
+ "Test suggestion in 'suggestions' array must be an object."
1014
1106
  );
1015
- });
1016
-
1017
- const actualSuggestion = message.suggestions[index];
1018
- const suggestionPrefix = `Error Suggestion at index ${index} :`;
1019
-
1020
- if (hasOwnProperty(expectedSuggestion, "desc")) {
1021
- assert.ok(
1022
- !hasOwnProperty(expectedSuggestion, "data"),
1023
- `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`
1024
- );
1025
- assert.strictEqual(
1026
- actualSuggestion.desc,
1027
- expectedSuggestion.desc,
1028
- `${suggestionPrefix} desc should be "${expectedSuggestion.desc}" but got "${actualSuggestion.desc}" instead.`
1029
- );
1030
- }
1107
+ Object.keys(expectedSuggestion).forEach(propertyName => {
1108
+ assert.ok(
1109
+ suggestionObjectParameters.has(propertyName),
1110
+ `Invalid suggestion property name '${propertyName}'. Expected one of ${friendlySuggestionObjectParameterList}.`
1111
+ );
1112
+ });
1031
1113
 
1032
- if (hasOwnProperty(expectedSuggestion, "messageId")) {
1033
- assert.ok(
1034
- ruleHasMetaMessages,
1035
- `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`
1036
- );
1037
- assert.ok(
1038
- hasOwnProperty(rule.meta.messages, expectedSuggestion.messageId),
1039
- `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`
1040
- );
1041
- assert.strictEqual(
1042
- actualSuggestion.messageId,
1043
- expectedSuggestion.messageId,
1044
- `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`
1045
- );
1046
- if (hasOwnProperty(expectedSuggestion, "data")) {
1047
- const unformattedMetaMessage = rule.meta.messages[expectedSuggestion.messageId];
1048
- const rehydratedDesc = interpolate(unformattedMetaMessage, expectedSuggestion.data);
1114
+ const actualSuggestion = message.suggestions[index];
1115
+ const suggestionPrefix = `Error Suggestion at index ${index}:`;
1049
1116
 
1117
+ if (hasOwnProperty(expectedSuggestion, "desc")) {
1118
+ assert.ok(
1119
+ !hasOwnProperty(expectedSuggestion, "data"),
1120
+ `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`
1121
+ );
1122
+ assert.ok(
1123
+ !hasOwnProperty(expectedSuggestion, "messageId"),
1124
+ `${suggestionPrefix} Test should not specify both 'desc' and 'messageId'.`
1125
+ );
1050
1126
  assert.strictEqual(
1051
1127
  actualSuggestion.desc,
1052
- rehydratedDesc,
1053
- `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`
1128
+ expectedSuggestion.desc,
1129
+ `${suggestionPrefix} desc should be "${expectedSuggestion.desc}" but got "${actualSuggestion.desc}" instead.`
1130
+ );
1131
+ } else if (hasOwnProperty(expectedSuggestion, "messageId")) {
1132
+ assert.ok(
1133
+ ruleHasMetaMessages,
1134
+ `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`
1135
+ );
1136
+ assert.ok(
1137
+ hasOwnProperty(rule.meta.messages, expectedSuggestion.messageId),
1138
+ `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`
1139
+ );
1140
+ assert.strictEqual(
1141
+ actualSuggestion.messageId,
1142
+ expectedSuggestion.messageId,
1143
+ `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`
1144
+ );
1145
+
1146
+ const unsubstitutedPlaceholders = getUnsubstitutedMessagePlaceholders(
1147
+ actualSuggestion.desc,
1148
+ rule.meta.messages[expectedSuggestion.messageId],
1149
+ expectedSuggestion.data
1150
+ );
1151
+
1152
+ assert.ok(
1153
+ unsubstitutedPlaceholders.length === 0,
1154
+ `The message of the suggestion has ${unsubstitutedPlaceholders.length > 1 ? `unsubstituted placeholders: ${unsubstitutedPlaceholders.map(name => `'${name}'`).join(", ")}` : `an unsubstituted placeholder '${unsubstitutedPlaceholders[0]}'`}. Please provide the missing ${unsubstitutedPlaceholders.length > 1 ? "values" : "value"} via the 'data' property for the suggestion in the context.report() call.`
1155
+ );
1156
+
1157
+ if (hasOwnProperty(expectedSuggestion, "data")) {
1158
+ const unformattedMetaMessage = rule.meta.messages[expectedSuggestion.messageId];
1159
+ const rehydratedDesc = interpolate(unformattedMetaMessage, expectedSuggestion.data);
1160
+
1161
+ assert.strictEqual(
1162
+ actualSuggestion.desc,
1163
+ rehydratedDesc,
1164
+ `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`
1165
+ );
1166
+ }
1167
+ } else if (hasOwnProperty(expectedSuggestion, "data")) {
1168
+ assert.fail(
1169
+ `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`
1170
+ );
1171
+ } else {
1172
+ assert.fail(
1173
+ `${suggestionPrefix} Test must specify either 'messageId' or 'desc'.`
1054
1174
  );
1055
1175
  }
1056
- } else {
1057
- assert.ok(
1058
- !hasOwnProperty(expectedSuggestion, "data"),
1059
- `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`
1060
- );
1061
- }
1062
1176
 
1063
- if (hasOwnProperty(expectedSuggestion, "output")) {
1177
+ assert.ok(hasOwnProperty(expectedSuggestion, "output"), `${suggestionPrefix} The "output" property is required.`);
1064
1178
  const codeWithAppliedSuggestion = SourceCodeFixer.applyFixes(item.code, [actualSuggestion]).output;
1065
1179
 
1066
1180
  // Verify if suggestion fix makes a syntax error or not.
1067
1181
  const errorMessageInSuggestion =
1068
- linter.verify(codeWithAppliedSuggestion, result.configs, result.filename).find(m => m.fatal);
1182
+ linter.verify(codeWithAppliedSuggestion, result.configs, result.filename).find(m => m.fatal);
1069
1183
 
1070
1184
  assert(!errorMessageInSuggestion, [
1071
1185
  "A fatal parsing error occurred in suggestion fix.",
@@ -1075,8 +1189,11 @@ class RuleTester {
1075
1189
  ].join("\n"));
1076
1190
 
1077
1191
  assert.strictEqual(codeWithAppliedSuggestion, expectedSuggestion.output, `Expected the applied suggestion fix to match the test suggestion output for suggestion at index: ${index} on error with message: "${message.message}"`);
1078
- }
1079
- });
1192
+ assert.notStrictEqual(expectedSuggestion.output, item.code, `The output of a suggestion should differ from the original source code for suggestion at index: ${index} on error with message: "${message.message}"`);
1193
+ });
1194
+ } else {
1195
+ assert.fail("Test error object property 'suggestions' should be an array or a number");
1196
+ }
1080
1197
  }
1081
1198
  }
1082
1199
  } else {
@@ -1096,6 +1213,7 @@ class RuleTester {
1096
1213
  );
1097
1214
  } else {
1098
1215
  assert.strictEqual(result.output, item.output, "Output is incorrect.");
1216
+ assert.notStrictEqual(item.code, item.output, "Test property 'output' matches 'code'. If no autofix is expected, then omit the 'output' property or set it to null.");
1099
1217
  }
1100
1218
  } else {
1101
1219
  assert.strictEqual(
@@ -188,6 +188,7 @@ function getNonEmptyOperand(node) {
188
188
  /** @type {import('../shared/types').Rule} */
189
189
  module.exports = {
190
190
  meta: {
191
+ hasSuggestions: true,
191
192
  type: "suggestion",
192
193
 
193
194
  docs: {
@@ -229,7 +230,8 @@ module.exports = {
229
230
  }],
230
231
 
231
232
  messages: {
232
- useRecommendation: "use `{{recommendation}}` instead."
233
+ implicitCoercion: "Unexpected implicit coercion encountered. Use `{{recommendation}}` instead.",
234
+ useRecommendation: "Use `{{recommendation}}` instead."
233
235
  }
234
236
  },
235
237
 
@@ -241,32 +243,54 @@ module.exports = {
241
243
  * Reports an error and autofixes the node
242
244
  * @param {ASTNode} node An ast node to report the error on.
243
245
  * @param {string} recommendation The recommended code for the issue
246
+ * @param {bool} shouldSuggest Whether this report should offer a suggestion
244
247
  * @param {bool} shouldFix Whether this report should fix the node
245
248
  * @returns {void}
246
249
  */
247
- function report(node, recommendation, shouldFix) {
250
+ function report(node, recommendation, shouldSuggest, shouldFix) {
251
+
252
+ /**
253
+ * Fix function
254
+ * @param {RuleFixer} fixer The fixer to fix.
255
+ * @returns {Fix} The fix object.
256
+ */
257
+ function fix(fixer) {
258
+ const tokenBefore = sourceCode.getTokenBefore(node);
259
+
260
+ if (
261
+ tokenBefore?.range[1] === node.range[0] &&
262
+ !astUtils.canTokensBeAdjacent(tokenBefore, recommendation)
263
+ ) {
264
+ return fixer.replaceText(node, ` ${recommendation}`);
265
+ }
266
+
267
+ return fixer.replaceText(node, recommendation);
268
+ }
269
+
248
270
  context.report({
249
271
  node,
250
- messageId: "useRecommendation",
251
- data: {
252
- recommendation
253
- },
272
+ messageId: "implicitCoercion",
273
+ data: { recommendation },
254
274
  fix(fixer) {
255
275
  if (!shouldFix) {
256
276
  return null;
257
277
  }
258
278
 
259
- const tokenBefore = sourceCode.getTokenBefore(node);
260
-
261
- if (
262
- tokenBefore &&
263
- tokenBefore.range[1] === node.range[0] &&
264
- !astUtils.canTokensBeAdjacent(tokenBefore, recommendation)
265
- ) {
266
- return fixer.replaceText(node, ` ${recommendation}`);
279
+ return fix(fixer);
280
+ },
281
+ suggest: [
282
+ {
283
+ messageId: "useRecommendation",
284
+ data: { recommendation },
285
+ fix(fixer) {
286
+ if (shouldFix || !shouldSuggest) {
287
+ return null;
288
+ }
289
+
290
+ return fix(fixer);
291
+ }
267
292
  }
268
- return fixer.replaceText(node, recommendation);
269
- }
293
+ ]
270
294
  });
271
295
  }
272
296
 
@@ -278,8 +302,10 @@ module.exports = {
278
302
  operatorAllowed = options.allow.includes("!!");
279
303
  if (!operatorAllowed && options.boolean && isDoubleLogicalNegating(node)) {
280
304
  const recommendation = `Boolean(${sourceCode.getText(node.argument.argument)})`;
305
+ const variable = astUtils.getVariableByName(sourceCode.getScope(node), "Boolean");
306
+ const booleanExists = variable?.identifiers.length === 0;
281
307
 
282
- report(node, recommendation, true);
308
+ report(node, recommendation, true, booleanExists);
283
309
  }
284
310
 
285
311
  // ~foo.indexOf(bar)
@@ -290,7 +316,7 @@ module.exports = {
290
316
  const comparison = node.argument.type === "ChainExpression" ? ">= 0" : "!== -1";
291
317
  const recommendation = `${sourceCode.getText(node.argument)} ${comparison}`;
292
318
 
293
- report(node, recommendation, false);
319
+ report(node, recommendation, false, false);
294
320
  }
295
321
 
296
322
  // +foo
@@ -298,7 +324,7 @@ module.exports = {
298
324
  if (!operatorAllowed && options.number && node.operator === "+" && !isNumeric(node.argument)) {
299
325
  const recommendation = `Number(${sourceCode.getText(node.argument)})`;
300
326
 
301
- report(node, recommendation, true);
327
+ report(node, recommendation, true, false);
302
328
  }
303
329
 
304
330
  // -(-foo)
@@ -306,7 +332,7 @@ module.exports = {
306
332
  if (!operatorAllowed && options.number && node.operator === "-" && node.argument.type === "UnaryExpression" && node.argument.operator === "-" && !isNumeric(node.argument.argument)) {
307
333
  const recommendation = `Number(${sourceCode.getText(node.argument.argument)})`;
308
334
 
309
- report(node, recommendation, false);
335
+ report(node, recommendation, true, false);
310
336
  }
311
337
  },
312
338
 
@@ -322,7 +348,7 @@ module.exports = {
322
348
  if (nonNumericOperand) {
323
349
  const recommendation = `Number(${sourceCode.getText(nonNumericOperand)})`;
324
350
 
325
- report(node, recommendation, true);
351
+ report(node, recommendation, true, false);
326
352
  }
327
353
 
328
354
  // foo - 0
@@ -330,7 +356,7 @@ module.exports = {
330
356
  if (!operatorAllowed && options.number && node.operator === "-" && node.right.type === "Literal" && node.right.value === 0 && !isNumeric(node.left)) {
331
357
  const recommendation = `Number(${sourceCode.getText(node.left)})`;
332
358
 
333
- report(node, recommendation, true);
359
+ report(node, recommendation, true, false);
334
360
  }
335
361
 
336
362
  // "" + foo
@@ -338,7 +364,7 @@ module.exports = {
338
364
  if (!operatorAllowed && options.string && isConcatWithEmptyString(node)) {
339
365
  const recommendation = `String(${sourceCode.getText(getNonEmptyOperand(node))})`;
340
366
 
341
- report(node, recommendation, true);
367
+ report(node, recommendation, true, false);
342
368
  }
343
369
  },
344
370
 
@@ -351,7 +377,7 @@ module.exports = {
351
377
  const code = sourceCode.getText(getNonEmptyOperand(node));
352
378
  const recommendation = `${code} = String(${code})`;
353
379
 
354
- report(node, recommendation, true);
380
+ report(node, recommendation, true, false);
355
381
  }
356
382
  },
357
383
 
@@ -389,7 +415,7 @@ module.exports = {
389
415
  const code = sourceCode.getText(node.expressions[0]);
390
416
  const recommendation = `String(${code})`;
391
417
 
392
- report(node, recommendation, true);
418
+ report(node, recommendation, true, false);
393
419
  }
394
420
  };
395
421
  }
@@ -161,17 +161,25 @@ module.exports = {
161
161
  (Object.hasOwn(options[0], "paths") || Object.hasOwn(options[0], "patterns"));
162
162
 
163
163
  const restrictedPaths = (isPathAndPatternsObject ? options[0].paths : context.options) || [];
164
- const restrictedPathMessages = restrictedPaths.reduce((memo, importSource) => {
164
+ const groupedRestrictedPaths = restrictedPaths.reduce((memo, importSource) => {
165
+ const path = typeof importSource === "string"
166
+ ? importSource
167
+ : importSource.name;
168
+
169
+ if (!memo[path]) {
170
+ memo[path] = [];
171
+ }
172
+
165
173
  if (typeof importSource === "string") {
166
- memo[importSource] = { message: null };
174
+ memo[path].push({});
167
175
  } else {
168
- memo[importSource.name] = {
176
+ memo[path].push({
169
177
  message: importSource.message,
170
178
  importNames: importSource.importNames
171
- };
179
+ });
172
180
  }
173
181
  return memo;
174
- }, {});
182
+ }, Object.create(null));
175
183
 
176
184
  // Handle patterns too, either as strings or groups
177
185
  let restrictedPatterns = (isPathAndPatternsObject ? options[0].patterns : []) || [];
@@ -203,57 +211,59 @@ module.exports = {
203
211
  * @private
204
212
  */
205
213
  function checkRestrictedPathAndReport(importSource, importNames, node) {
206
- if (!Object.hasOwn(restrictedPathMessages, importSource)) {
214
+ if (!Object.hasOwn(groupedRestrictedPaths, importSource)) {
207
215
  return;
208
216
  }
209
217
 
210
- const customMessage = restrictedPathMessages[importSource].message;
211
- const restrictedImportNames = restrictedPathMessages[importSource].importNames;
218
+ groupedRestrictedPaths[importSource].forEach(restrictedPathEntry => {
219
+ const customMessage = restrictedPathEntry.message;
220
+ const restrictedImportNames = restrictedPathEntry.importNames;
212
221
 
213
- if (restrictedImportNames) {
214
- if (importNames.has("*")) {
215
- const specifierData = importNames.get("*")[0];
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
+ }
216
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 {
217
257
  context.report({
218
258
  node,
219
- messageId: customMessage ? "everythingWithCustomMessage" : "everything",
220
- loc: specifierData.loc,
259
+ messageId: customMessage ? "pathWithCustomMessage" : "path",
221
260
  data: {
222
261
  importSource,
223
- importNames: restrictedImportNames,
224
262
  customMessage
225
263
  }
226
264
  });
227
265
  }
228
-
229
- restrictedImportNames.forEach(importName => {
230
- if (importNames.has(importName)) {
231
- const specifiers = importNames.get(importName);
232
-
233
- specifiers.forEach(specifier => {
234
- context.report({
235
- node,
236
- messageId: customMessage ? "importNameWithCustomMessage" : "importName",
237
- loc: specifier.loc,
238
- data: {
239
- importSource,
240
- customMessage,
241
- importName
242
- }
243
- });
244
- });
245
- }
246
- });
247
- } else {
248
- context.report({
249
- node,
250
- messageId: customMessage ? "pathWithCustomMessage" : "path",
251
- data: {
252
- importSource,
253
- customMessage
254
- }
255
- });
256
- }
266
+ });
257
267
  }
258
268
 
259
269
  /**
@@ -197,11 +197,26 @@ module.exports = {
197
197
  return;
198
198
  }
199
199
 
200
+ /**
201
+ * A collection of nodes to avoid duplicate reports.
202
+ * @type {Set<ASTNode>}
203
+ */
204
+ const reported = new Set();
205
+
200
206
  codePath.traverseSegments((segment, controller) => {
201
207
  const info = segInfoMap[segment.id];
208
+ const invalidNodes = info.invalidNodes
209
+ .filter(
210
+
211
+ /*
212
+ * Avoid duplicate reports.
213
+ * When there is a `finally`, invalidNodes may contain already reported node.
214
+ */
215
+ node => !reported.has(node)
216
+ );
202
217
 
203
- for (let i = 0; i < info.invalidNodes.length; ++i) {
204
- const invalidNode = info.invalidNodes[i];
218
+ for (const invalidNode of invalidNodes) {
219
+ reported.add(invalidNode);
205
220
 
206
221
  context.report({
207
222
  messageId: "noBeforeSuper",
@@ -273,14 +288,12 @@ module.exports = {
273
288
  const info = segInfoMap[segment.id];
274
289
 
275
290
  if (info.superCalled) {
276
- info.invalidNodes = [];
277
291
  controller.skip();
278
292
  } else if (
279
293
  segment.prevSegments.length > 0 &&
280
294
  segment.prevSegments.every(isCalled)
281
295
  ) {
282
296
  info.superCalled = true;
283
- info.invalidNodes = [];
284
297
  }
285
298
  }
286
299
  );
@@ -92,7 +92,7 @@ module.exports = {
92
92
  vars: "all",
93
93
  args: "after-used",
94
94
  ignoreRestSiblings: false,
95
- caughtErrors: "none"
95
+ caughtErrors: "all"
96
96
  };
97
97
 
98
98
  const firstOption = context.options[0];
@@ -101,7 +101,7 @@ module.exports = {
101
101
  properties: {
102
102
  enforceForClassMembers: {
103
103
  type: "boolean",
104
- default: false
104
+ default: true
105
105
  }
106
106
  },
107
107
  additionalProperties: false
@@ -114,7 +114,7 @@ module.exports = {
114
114
  },
115
115
  create(context) {
116
116
  const sourceCode = context.sourceCode;
117
- const enforceForClassMembers = context.options[0] && context.options[0].enforceForClassMembers;
117
+ const enforceForClassMembers = context.options[0]?.enforceForClassMembers ?? true;
118
118
 
119
119
  /**
120
120
  * Reports a given node if it violated this rule.
@@ -21,9 +21,17 @@ const astUtils = require("./utils/ast-utils");
21
21
  * @returns {boolean} `true` if the node is 'NaN' identifier.
22
22
  */
23
23
  function isNaNIdentifier(node) {
24
- return Boolean(node) && (
25
- astUtils.isSpecificId(node, "NaN") ||
26
- astUtils.isSpecificMemberAccess(node, "Number", "NaN")
24
+ if (!node) {
25
+ return false;
26
+ }
27
+
28
+ const nodeToCheck = node.type === "SequenceExpression"
29
+ ? node.expressions.at(-1)
30
+ : node;
31
+
32
+ return (
33
+ astUtils.isSpecificId(nodeToCheck, "NaN") ||
34
+ astUtils.isSpecificMemberAccess(nodeToCheck, "Number", "NaN")
27
35
  );
28
36
  }
29
37
 
@@ -34,6 +42,7 @@ function isNaNIdentifier(node) {
34
42
  /** @type {import('../shared/types').Rule} */
35
43
  module.exports = {
36
44
  meta: {
45
+ hasSuggestions: true,
37
46
  type: "problem",
38
47
 
39
48
  docs: {
@@ -63,7 +72,9 @@ module.exports = {
63
72
  comparisonWithNaN: "Use the isNaN function to compare with NaN.",
64
73
  switchNaN: "'switch(NaN)' can never match a case clause. Use Number.isNaN instead of the switch.",
65
74
  caseNaN: "'case NaN' can never match. Use Number.isNaN before the switch.",
66
- indexOfNaN: "Array prototype method '{{ methodName }}' cannot find NaN."
75
+ indexOfNaN: "Array prototype method '{{ methodName }}' cannot find NaN.",
76
+ replaceWithIsNaN: "Replace with Number.isNaN.",
77
+ replaceWithCastingAndIsNaN: "Replace with Number.isNaN cast to a Number."
67
78
  }
68
79
  },
69
80
 
@@ -71,6 +82,35 @@ module.exports = {
71
82
 
72
83
  const enforceForSwitchCase = !context.options[0] || context.options[0].enforceForSwitchCase;
73
84
  const enforceForIndexOf = context.options[0] && context.options[0].enforceForIndexOf;
85
+ const sourceCode = context.sourceCode;
86
+
87
+ const fixableOperators = new Set(["==", "===", "!=", "!=="]);
88
+ const castableOperators = new Set(["==", "!="]);
89
+
90
+ /**
91
+ * Get a fixer for a binary expression that compares to NaN.
92
+ * @param {ASTNode} node The node to fix.
93
+ * @param {function(string): string} wrapValue A function that wraps the compared value with a fix.
94
+ * @returns {function(Fixer): Fix} The fixer function.
95
+ */
96
+ function getBinaryExpressionFixer(node, wrapValue) {
97
+ return fixer => {
98
+ const comparedValue = isNaNIdentifier(node.left) ? node.right : node.left;
99
+ const shouldWrap = comparedValue.type === "SequenceExpression";
100
+ const shouldNegate = node.operator[0] === "!";
101
+
102
+ const negation = shouldNegate ? "!" : "";
103
+ let comparedValueText = sourceCode.getText(comparedValue);
104
+
105
+ if (shouldWrap) {
106
+ comparedValueText = `(${comparedValueText})`;
107
+ }
108
+
109
+ const fixedValue = wrapValue(comparedValueText);
110
+
111
+ return fixer.replaceText(node, `${negation}${fixedValue}`);
112
+ };
113
+ }
74
114
 
75
115
  /**
76
116
  * Checks the given `BinaryExpression` node for `foo === NaN` and other comparisons.
@@ -82,7 +122,32 @@ module.exports = {
82
122
  /^(?:[<>]|[!=]=)=?$/u.test(node.operator) &&
83
123
  (isNaNIdentifier(node.left) || isNaNIdentifier(node.right))
84
124
  ) {
85
- context.report({ node, messageId: "comparisonWithNaN" });
125
+ const suggestedFixes = [];
126
+ const NaNNode = isNaNIdentifier(node.left) ? node.left : node.right;
127
+
128
+ const isSequenceExpression = NaNNode.type === "SequenceExpression";
129
+ const isFixable = fixableOperators.has(node.operator) && !isSequenceExpression;
130
+ const isCastable = castableOperators.has(node.operator);
131
+
132
+ if (isFixable) {
133
+ suggestedFixes.push({
134
+ messageId: "replaceWithIsNaN",
135
+ fix: getBinaryExpressionFixer(node, value => `Number.isNaN(${value})`)
136
+ });
137
+
138
+ if (isCastable) {
139
+ suggestedFixes.push({
140
+ messageId: "replaceWithCastingAndIsNaN",
141
+ fix: getBinaryExpressionFixer(node, value => `Number.isNaN(Number(${value}))`)
142
+ });
143
+ }
144
+ }
145
+
146
+ context.report({
147
+ node,
148
+ messageId: "comparisonWithNaN",
149
+ suggest: suggestedFixes
150
+ });
86
151
  }
87
152
  }
88
153
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @fileoverview Serialization utils.
3
+ * @author Bryan Mishkin
4
+ */
5
+
6
+ "use strict";
7
+
8
+ /**
9
+ * Check if a value is a primitive or plain object created by the Object constructor.
10
+ * @param {any} val the value to check
11
+ * @returns {boolean} true if so
12
+ * @private
13
+ */
14
+ function isSerializablePrimitiveOrPlainObject(val) {
15
+ return (
16
+ val === null ||
17
+ typeof val === "string" ||
18
+ typeof val === "boolean" ||
19
+ typeof val === "number" ||
20
+ (typeof val === "object" && val.constructor === Object) ||
21
+ Array.isArray(val)
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Check if a value is serializable.
27
+ * Functions or objects like RegExp cannot be serialized by JSON.stringify().
28
+ * Inspired by: https://stackoverflow.com/questions/30579940/reliable-way-to-check-if-objects-is-serializable-in-javascript
29
+ * @param {any} val the value
30
+ * @returns {boolean} true if the value is serializable
31
+ */
32
+ function isSerializable(val) {
33
+ if (!isSerializablePrimitiveOrPlainObject(val)) {
34
+ return false;
35
+ }
36
+ if (typeof val === "object") {
37
+ for (const property in val) {
38
+ if (Object.hasOwn(val, property)) {
39
+ if (!isSerializablePrimitiveOrPlainObject(val[property])) {
40
+ return false;
41
+ }
42
+ if (typeof val[property] === "object") {
43
+ if (!isSerializable(val[property])) {
44
+ return false;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ return true;
51
+ }
52
+
53
+ module.exports = {
54
+ isSerializable
55
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint",
3
- "version": "9.0.0-alpha.1",
3
+ "version": "9.0.0-beta.0",
4
4
  "author": "Nicholas C. Zakas <nicholas+npm@nczconsulting.com>",
5
5
  "description": "An AST-based pattern checker for JavaScript.",
6
6
  "bin": {
@@ -65,8 +65,8 @@
65
65
  "dependencies": {
66
66
  "@eslint-community/eslint-utils": "^4.2.0",
67
67
  "@eslint-community/regexpp": "^4.6.1",
68
- "@eslint/eslintrc": "^3.0.0",
69
- "@eslint/js": "9.0.0-alpha.0",
68
+ "@eslint/eslintrc": "^3.0.1",
69
+ "@eslint/js": "9.0.0-beta.0",
70
70
  "@humanwhocodes/config-array": "^0.11.14",
71
71
  "@humanwhocodes/module-importer": "^1.0.1",
72
72
  "@nodelib/fs.walk": "^1.2.8",
@@ -76,12 +76,12 @@
76
76
  "debug": "^4.3.2",
77
77
  "escape-string-regexp": "^4.0.0",
78
78
  "eslint-scope": "^8.0.0",
79
- "eslint-visitor-keys": "^3.4.3",
80
- "espree": "^9.6.1",
79
+ "eslint-visitor-keys": "^4.0.0",
80
+ "espree": "^10.0.1",
81
81
  "esquery": "^1.4.2",
82
82
  "esutils": "^2.0.2",
83
83
  "fast-deep-equal": "^3.1.3",
84
- "file-entry-cache": "^6.0.1",
84
+ "file-entry-cache": "^8.0.0",
85
85
  "find-up": "^5.0.0",
86
86
  "glob-parent": "^6.0.2",
87
87
  "globals": "^13.19.0",
@@ -135,8 +135,8 @@
135
135
  "load-perf": "^0.2.0",
136
136
  "markdown-it": "^12.2.0",
137
137
  "markdown-it-container": "^3.0.0",
138
- "markdownlint": "^0.32.0",
139
- "markdownlint-cli": "^0.38.0",
138
+ "markdownlint": "^0.33.0",
139
+ "markdownlint-cli": "^0.39.0",
140
140
  "marked": "^4.0.8",
141
141
  "memfs": "^3.0.1",
142
142
  "metascraper": "^5.25.7",
@@ -155,7 +155,7 @@
155
155
  "regenerator-runtime": "^0.14.0",
156
156
  "rollup-plugin-node-polyfills": "^0.2.1",
157
157
  "semver": "^7.5.3",
158
- "shelljs": "^0.8.2",
158
+ "shelljs": "^0.8.5",
159
159
  "sinon": "^11.0.0",
160
160
  "vite-plugin-commonjs": "^0.10.0",
161
161
  "webdriverio": "^8.14.6",