Repository: import-js/eslint-plugin-import
Pull request #3072 (2024-09-26 01:57:03)

[Fix] `exportMap`: improve `cacheKey` when using flat config 
Discovered this issue in https://github.com/import-js/eslint-plugin-import/pull/2996#issuecomment-2372522774.  

This change improves the logic for generating the cache key used for both the ExportMap cache and the resolver cache when using flat config.  Prior to this change, the cache key was a combination of the `parserPath`, a hash of the `parserOptions`, a hash of the plugin settings, and the path of the file.  When using flat config, `parserPath` isn't provided.  So, there's the possibility of incorrect / stale cache objects being used if someone ran with this plugin using different parsers within the same lint execution, if `parserOptions` and settings are the same.  

For flat config, we can achieve similar results by constructing the cacheKey using `languageOptions` as a component of the key, rather than `parserPath` and `parserOptions`.  One caveat is that the `parser` property of `languageOptions` is an object that oftentimes has the same two functions (`parse` and `parseForESLint`).  This won't be reliably distinct when using the base `JSON.stringify` function to detect changes.  So, this implementation uses a replacer function along with `JSON.stringify` to stringify function properties along with other property types in order to detect different parsers.

To ensure that this works properly with v9, I also tested this against https://github.com/import-js/eslint-plugin-import/pull/2996 with v9 installed.  Not only does it pass all tests in that branch, but it also removes the need to add this exception: https://github.com/import-js/eslint-plugin-import/pull/2996#discussion_r1774135785

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/import-js/eslint-plugin-import/pull/3072?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.09%. Comparing base [(`743ebca`)](https://app.codecov.io/gh/import-js/eslint-plugin-import/commit/743ebca91a5bc151ce3d5862b52474ceaaf6c8de?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js) to head [(`cddfae4`)](https://app.codecov.io/gh/import-js/eslint-plugin-import/commit/cddfae49b0de7a3c2dafeabce94a032b4f2859e8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3072      +/-   ##
==========================================
- Coverage   95.13%   95.09%   -0.05%     
==========================================
  Files          82       82              
  Lines        3559     3568       +9     
  Branches     1243     1246       +3     
==========================================
+ Hits         3386     3393       +7     
- Misses        173      175       +2     
```

| [Flag](https://app.codecov.io/gh/import-js/eslint-plugin-import/pull/3072/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js) | Coverage Δ | |
|---|---|---|
| [](https://app.codecov.io/gh/import-js/eslint-plugin-import/pull/3072/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js) | `95.09% <100.00%> (-0.05%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/import-js/eslint-plugin-import/pull/3072?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=import-js).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8dcb3a98..90cabd4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 - [`no-named-as-default`]: Allow using an identifier if the export is both a named and a default export ([#3032], thanks [@akwodkiewicz])
 - [`export`]: False positive for exported overloaded functions in TS ([#3065], thanks [@liuxingbaoyu])
 - `exportMap`: export map cache is tainted by unreliable parse results ([#3062], thanks [@michaelfaith])
+- `exportMap`: improve cacheKey when using flat config ([#3072], thanks [@michaelfaith])
 
 ### Changed
 - [Docs] [`no-relative-packages`]: fix typo ([#3066], thanks [@joshuaobrien])
@@ -1144,6 +1145,7 @@ for info on changes for earlier releases.
 
 [`memo-parser`]: ./memo-parser/README.md
 
+[#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072
 [#3071]: https://github.com/import-js/eslint-plugin-import/pull/3071
 [#3070]: https://github.com/import-js/eslint-plugin-import/pull/3070
 [#3068]: https://github.com/import-js/eslint-plugin-import/pull/3068
diff --git a/package.json b/package.json
index 1126b198..c0a6b56e 100644
--- a/package.json
+++ b/package.json
@@ -117,7 +117,7 @@
     "debug": "^3.2.7",
     "doctrine": "^2.1.0",
     "eslint-import-resolver-node": "^0.3.9",
-    "eslint-module-utils": "^2.11.1",
+    "eslint-module-utils": "^2.12.0",
     "hasown": "^2.0.2",
     "is-core-module": "^2.15.1",
     "is-glob": "^4.0.3",
diff --git a/src/exportMap/childContext.js b/src/exportMap/childContext.js
index 3534c591..8994ac20 100644
--- a/src/exportMap/childContext.js
+++ b/src/exportMap/childContext.js
@@ -1,10 +1,18 @@
 import { hashObject } from 'eslint-module-utils/hash';
 
-let parserOptionsHash = '';
-let prevParserOptions = '';
+let optionsHash = '';
+let prevOptions = '';
 let settingsHash = '';
 let prevSettings = '';
 
+// Replacer function helps us with serializing the parser nested within `languageOptions`.
+function stringifyReplacerFn(_, value) {
+  if (typeof value === 'function') {
+    return String(value);
+  }
+  return value;
+}
+
 /**
  * don't hold full context object in memory, just grab what we need.
  * also calculate a cacheKey, where parts of the cacheKey hash are memoized
@@ -17,13 +25,28 @@ export default function childContext(path, context) {
     prevSettings = JSON.stringify(settings);
   }
 
-  if (JSON.stringify(parserOptions) !== prevParserOptions) {
-    parserOptionsHash = hashObject({ parserOptions }).digest('hex');
-    prevParserOptions = JSON.stringify(parserOptions);
+  // We'll use either a combination of `parserOptions` and `parserPath` or `languageOptions`
+  // to construct the cache key, depending on whether this is using a flat config or not.
+  let optionsToken;
+  if (!parserPath && languageOptions) {
+    if (JSON.stringify(languageOptions, stringifyReplacerFn) !== prevOptions) {
+      optionsHash = hashObject({ languageOptions }).digest('hex');
+      prevOptions = JSON.stringify(languageOptions, stringifyReplacerFn);
+    }
+    // For languageOptions, we're just using the hashed options as the options token
+    optionsToken = optionsHash;
+  } else {
+    if (JSON.stringify(parserOptions) !== prevOptions) {
+      optionsHash = hashObject({ parserOptions }).digest('hex');
+      prevOptions = JSON.stringify(parserOptions);
+    }
+    // When not using flat config, we use a combination of the hashed parserOptions
+    // and parserPath as the token
+    optionsToken = String(parserPath) + optionsHash;
   }
 
   return {
-    cacheKey: String(parserPath) + parserOptionsHash + settingsHash + String(path),
+    cacheKey: optionsToken + settingsHash + String(path),
     settings,
     parserOptions,
     parserPath,
diff --git a/utils/CHANGELOG.md b/utils/CHANGELOG.md
index 6e71a26f..619d0506 100644
--- a/utils/CHANGELOG.md
+++ b/utils/CHANGELOG.md
@@ -5,6 +5,11 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
 
 ## Unreleased
 
+## v2.12.0 - 2024-09-26
+
+### Added
+- `hash`: add support for hashing functions ([#3072], thanks [@michaelfaith])
+
 ## v2.11.1 - 2024-09-23
 
 ### Fixed
@@ -172,6 +177,7 @@ Yanked due to critical issue with cache key resulting from #839.
 ### Fixed
 - `unambiguous.test()` regex is now properly in multiline mode
 
+[#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072
 [#3061]: https://github.com/import-js/eslint-plugin-import/pull/3061
 [#3057]: https://github.com/import-js/eslint-plugin-import/pull/3057
 [#3049]: https://github.com/import-js/eslint-plugin-import/pull/3049
diff --git a/utils/hash.js b/utils/hash.js
index b3ce618b..21ed524a 100644
--- a/utils/hash.js
+++ b/utils/hash.js
@@ -17,6 +17,8 @@ function hashify(value, hash) {
 
   if (Array.isArray(value)) {
     hashArray(value, hash);
+  } else if (typeof value === 'function') {
+    hash.update(String(value));
   } else if (value instanceof Object) {
     hashObject(value, hash);
   } else {
diff --git a/utils/package.json b/utils/package.json
index 709142fa..017eb719 100644
--- a/utils/package.json
+++ b/utils/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-module-utils",
-  "version": "2.11.1",
+  "version": "2.12.0",
   "description": "Core utilities to support eslint-plugin-import and other module-related plugins.",
   "engines": {
     "node": ">=4"


Test patch:
diff --git a/tests/src/exportMap/childContext.js b/tests/src/exportMap/childContext.js
index 06fa04af..5bc53fdb 100644
--- a/tests/src/exportMap/childContext.js
+++ b/tests/src/exportMap/childContext.js
@@ -1,4 +1,5 @@
 import { expect } from 'chai';
+import { hashObject } from 'eslint-module-utils/hash';
 
 import childContext from '../../../src/exportMap/childContext';
 
@@ -16,8 +17,13 @@ describe('childContext', () => {
   const languageOptions = {
     ecmaVersion: 2024,
     sourceType: 'module',
-    parser: {},
+    parser: {
+      parseForESLint() { return 'parser1'; },
+    },
   };
+  const languageOptionsHash = hashObject({ languageOptions }).digest('hex');
+  const parserOptionsHash = hashObject({ parserOptions }).digest('hex');
+  const settingsHash = hashObject({ settings }).digest('hex');
 
   // https://github.com/import-js/eslint-plugin-import/issues/3051
   it('should pass context properties through, if present', () => {
@@ -48,4 +54,68 @@ describe('childContext', () => {
     expect(result.path).to.equal(path);
     expect(result.cacheKey).to.be.a('string');
   });
+
+  it('should construct cache key out of languageOptions if present', () => {
+    const mockContext = {
+      settings,
+      languageOptions,
+    };
+
+    const result = childContext(path, mockContext);
+
+    expect(result.cacheKey).to.equal(languageOptionsHash + settingsHash + path);
+  });
+
+  it('should use the same cache key upon multiple calls', () => {
+    const mockContext = {
+      settings,
+      languageOptions,
+    };
+
+    let result = childContext(path, mockContext);
+
+    const expectedCacheKey = languageOptionsHash + settingsHash + path;
+    expect(result.cacheKey).to.equal(expectedCacheKey);
+
+    result = childContext(path, mockContext);
+    expect(result.cacheKey).to.equal(expectedCacheKey);
+  });
+
+  it('should update cacheKey if different languageOptions are passed in', () => {
+    const mockContext = {
+      settings,
+      languageOptions,
+    };
+
+    let result = childContext(path, mockContext);
+
+    const firstCacheKey = languageOptionsHash + settingsHash + path;
+    expect(result.cacheKey).to.equal(firstCacheKey);
+
+    // Second run with different parser function
+    mockContext.languageOptions = {
+      ...languageOptions,
+      parser: {
+        parseForESLint() { return 'parser2'; },
+      },
+    };
+
+    result = childContext(path, mockContext);
+
+    const secondCacheKey = hashObject({ languageOptions: mockContext.languageOptions }).digest('hex') + settingsHash + path;
+    expect(result.cacheKey).to.not.equal(firstCacheKey);
+    expect(result.cacheKey).to.equal(secondCacheKey);
+  });
+
+  it('should construct cache key out of parserOptions and parserPath if no languageOptions', () => {
+    const mockContext = {
+      settings,
+      parserOptions,
+      parserPath,
+    };
+
+    const result = childContext(path, mockContext);
+
+    expect(result.cacheKey).to.equal(String(parserPath) + parserOptionsHash + settingsHash + path);
+  });
 });