From c58d0fe43ae9369c3357d475f58851d299e47710 Mon Sep 17 00:00:00 2001 From: filipenevola Date: Fri, 27 Mar 2026 11:33:51 -0400 Subject: [PATCH] fix: make Walker file-list deterministic within a single ESLint run Two related bugs caused non-deterministic warning counts: 1. The module-level `handledFiles` Set persisted across Walker instances. When the file cache (`.eslint-meteor-files`) expired mid-run (default 5s TTL), re-walking the app tree skipped every file already in `handledFiles`, producing an empty `cachedParsedFile` written to disk. 2. `shouldSkip()` created a new Walker on every call and depended solely on the disk cache. If the cache expired between the `Program` handler writing it and `MemberExpression` reading it, `shouldSkip()` saw an empty file list and skipped the file. Fixes: - Move `handledFiles` from module scope into the Walker instance. - Add an in-memory cache (`inMemoryCache` Map) that survives the entire ESLint process so `shouldSkip()` always sees the complete file list regardless of disk cache TTL. Made-with: Cursor --- CHANGELOG.md | 8 ++++++++ lib/util/walker.js | 44 +++++++++++++++++++++++++++++++++----------- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd6560..e74a2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.5.3 - 2026-03-27 + +### Bug fixes + +- Fix non-deterministic warning counts caused by two related bugs in the Walker: + 1. The module-level `handledFiles` Set persisted across Walker instances, so when the file cache expired mid-run, re-walking produced an empty file list. Moved `handledFiles` into the Walker instance. + 2. `shouldSkip()` created a new Walker on every call and depended solely on the disk cache, which could expire mid-run. Added an in-memory cache (`inMemoryCache` Map) that persists for the entire ESLint process, so `shouldSkip()` always sees the complete file list regardless of disk cache TTL. + ## 1.5.2 - 2024-10-15 ### Bug fixes diff --git a/lib/util/walker.js b/lib/util/walker.js index b9a6444..6bb34d7 100644 --- a/lib/util/walker.js +++ b/lib/util/walker.js @@ -96,8 +96,6 @@ function isNpmDependency(importPath) { return !appFileImport.includes(importPath[0]); } -const handledFiles = new Set(); - function getAbsFilePath(filePath) { // some files have no ext or are only the ext (.gitignore, .editorconfig, etc.) const existingExt = @@ -114,7 +112,7 @@ function getAbsFilePath(filePath) { return filePath; } -function handleFile(_filePath, appPath, onFile, cachedParsedFile) { +function handleFile(_filePath, appPath, onFile, cachedParsedFile, handledFiles) { const filePath = getAbsFilePath(_filePath); if (!filePath) { return; @@ -149,11 +147,11 @@ function handleFile(_filePath, appPath, onFile, cachedParsedFile) { return path.resolve(path.dirname(filePath), source); }) .forEach((importPath) => { - handleFile(importPath, appPath, onFile, cachedParsedFile); + handleFile(importPath, appPath, onFile, cachedParsedFile, handledFiles); }); } -function handleFolder(folderPath, appPath, archList, onFile, cachedParsedFile) { +function handleFolder(folderPath, appPath, archList, onFile, cachedParsedFile, handledFiles) { const dirents = fs.readdirSync(folderPath, { withFileTypes: true }); for (let i = 0; i < dirents.length; i += 1) { if (dirents[i].isDirectory()) { @@ -163,12 +161,13 @@ function handleFolder(folderPath, appPath, archList, onFile, cachedParsedFile) { appPath, archList, onFile, - cachedParsedFile + cachedParsedFile, + handledFiles ); } } else if (dirents[i].isFile()) { const filePath = path.resolve(folderPath, dirents[i].name); - handleFile(filePath, appPath, onFile, cachedParsedFile); + handleFile(filePath, appPath, onFile, cachedParsedFile, handledFiles); } } } @@ -203,7 +202,7 @@ function getInitFolder(context) { } function shouldSkip(context) { - const walker = new Walker(getInitFolder(context)); + const appPath = getInitFolder(context); const realPath = fs.realpathSync.native(context.physicalFilename); const parsedFiles = process.env.METEOR_ESLINT_PLUGIN_FILES @@ -211,7 +210,7 @@ function shouldSkip(context) { (acc, item) => ({ ...acc, [item]: true }), {}, ) - : walker.cachedParsedFile; + : Walker.getParsedFiles(appPath); if (!Object.keys(parsedFiles).length || !(realPath in parsedFiles)) { debug('Skipping file', realPath); @@ -235,17 +234,36 @@ const cacheExistsAndIsStillValid = (filePath) => { return seconds <= EXPIRES_CACHE_IN_SECONDS; } +// In-memory cache keyed by appPath. Survives the entire ESLint process so +// shouldSkip() never depends on the disk cache's TTL within a single run. +const inMemoryCache = new Map(); class Walker { cachedParsedFile; appPath; + handledFiles; filePath() { return path.join(this.appPath, '.eslint-meteor-files'); } + static getParsedFiles(appPath) { + if (inMemoryCache.has(appPath)) { + return inMemoryCache.get(appPath); + } + const walker = new Walker(appPath); + return walker.cachedParsedFile; + } + constructor(appPath) { this.appPath = appPath; + this.handledFiles = new Set(); + + if (inMemoryCache.has(appPath)) { + this.cachedParsedFile = inMemoryCache.get(appPath); + return; + } + const useCache = cacheExistsAndIsStillValid(this.filePath()); if (!useCache) { debug('Cache is not going to be used'); @@ -269,8 +287,10 @@ class Walker { path.join(this.appPath, meteor.mainModule.server), this.appPath, onFile, - this.cachedParsedFile + this.cachedParsedFile, + this.handledFiles ); + inMemoryCache.set(this.appPath, this.cachedParsedFile); fs.writeFileSync(this.filePath(), JSON.stringify(this.cachedParsedFile)); return; } @@ -282,9 +302,11 @@ class Walker { this.appPath, archList, onFile, - this.cachedParsedFile + this.cachedParsedFile, + this.handledFiles ); + inMemoryCache.set(this.appPath, this.cachedParsedFile); fs.writeFileSync(this.filePath(), JSON.stringify(this.cachedParsedFile)); } diff --git a/package-lock.json b/package-lock.json index 53e67c7..7bf1c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quave/eslint-plugin-meteor-quave", - "version": "1.5.1", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quave/eslint-plugin-meteor-quave", - "version": "1.5.1", + "version": "1.5.3", "license": "MIT", "dependencies": { "babel-plugin-module-resolver": "^5.0.0", diff --git a/package.json b/package.json index 9fee56e..dd2816a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quave/eslint-plugin-meteor-quave", - "version": "1.5.2", + "version": "1.5.3", "description": "Quave linting rules for ESLint", "main": "lib/index.js", "scripts": {