Skip to content

Commit ad76da0

Browse files
perf: ~8.6x faster ESLint rule (#10943)
### Description #10748 pointed out that our ESLint rule was _wildly_ expensive. This PR improves our ESLint rule's performance by ~8.6x in one of our large, internal monorepos. Before: ~69.4s After: ~8.1s Strategies used: - Hash-based change detection for `turbo.json`s so we only run `project.reload()` when needed - Introduce cache for ESLint `Project` - Introduce cache for framework - Introduce cache for package.json dependency detection ### Testing Instructions - Manually checked that changing `turbo.json` and visiting an affected file reflects changes in IDE (e.g. red squiggly disappears) - Manually tested that diagnostics are produced/not produced as expected - Tests continue passing --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
1 parent 53770fe commit ad76da0

File tree

5 files changed

+253
-24
lines changed

5 files changed

+253
-24
lines changed

packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.commonjs.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from "node:path";
22
import { RuleTester } from "eslint";
3+
import { afterEach } from "@jest/globals";
34
import { RULES } from "../../../../lib/constants";
4-
import rule from "../../../../lib/rules/no-undeclared-env-vars";
5+
import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars";
56

67
const ruleTester = new RuleTester({
78
parserOptions: { ecmaVersion: 2020 },
@@ -19,6 +20,10 @@ const options = (extra: Record<string, unknown> = {}) => ({
1920
],
2021
});
2122

23+
afterEach(() => {
24+
clearCache();
25+
});
26+
2227
ruleTester.run(RULES.noUndeclaredEnvVars, rule, {
2328
valid: [
2429
{

packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.module.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from "node:path";
22
import { RuleTester } from "eslint";
3+
import { afterEach } from "@jest/globals";
34
import { RULES } from "../../../../lib/constants";
4-
import rule from "../../../../lib/rules/no-undeclared-env-vars";
5+
import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars";
56

67
const ruleTester = new RuleTester({
78
parserOptions: { ecmaVersion: 2020, sourceType: "module" },
@@ -19,6 +20,10 @@ const options = (extra: Record<string, unknown> = {}) => ({
1920
],
2021
});
2122

23+
afterEach(() => {
24+
clearCache();
25+
});
26+
2227
ruleTester.run(RULES.noUndeclaredEnvVars, rule, {
2328
valid: [
2429
{

packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts

Lines changed: 229 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import path from "node:path";
2-
import { readFileSync } from "node:fs";
2+
import fs from "node:fs";
3+
import crypto from "node:crypto";
34
import type { Rule } from "eslint";
45
import type { Node, MemberExpression } from "estree";
5-
import { type PackageJson, logger, searchUp } from "@turbo/utils";
6+
import {
7+
type PackageJson,
8+
logger,
9+
searchUp,
10+
clearConfigCaches,
11+
} from "@turbo/utils";
612
import { frameworks } from "@turbo/types";
713
import { RULES } from "../constants";
814
import { Project, getWorkspaceFromFilePath } from "../utils/calculate-inputs";
@@ -13,6 +19,17 @@ const debug = process.env.RUNNER_DEBUG
1319
/* noop */
1420
};
1521

22+
// Module-level caches to share state across all files in a single ESLint run
23+
interface CachedProject {
24+
project: Project;
25+
turboConfigHashes: Map<string, string>;
26+
configPaths: Array<string>;
27+
}
28+
29+
const projectCache = new Map<string, CachedProject>();
30+
const frameworkEnvCache = new Map<string, Set<RegExp>>();
31+
const packageJsonDepCache = new Map<string, Set<string>>();
32+
1633
export interface RuleContextWithOptions extends Rule.RuleContext {
1734
options: Array<{
1835
cwd?: string;
@@ -77,25 +94,34 @@ function normalizeCwd(
7794

7895
/** for a given `package.json` file path, this will compile a Set of that package's listed dependencies */
7996
const packageJsonDependencies = (filePath: string): Set<string> => {
97+
const cached = packageJsonDepCache.get(filePath);
98+
if (cached) {
99+
return cached;
100+
}
101+
80102
// get the contents of the package.json
81103
let packageJsonString;
82104

83105
try {
84-
packageJsonString = readFileSync(filePath, "utf-8");
106+
packageJsonString = fs.readFileSync(filePath, "utf-8");
85107
} catch (e) {
86108
logger.error(`Could not read package.json at ${filePath}`);
87-
return new Set();
109+
const emptySet = new Set<string>();
110+
packageJsonDepCache.set(filePath, emptySet);
111+
return emptySet;
88112
}
89113

90114
let packageJson: PackageJson;
91115
try {
92116
packageJson = JSON.parse(packageJsonString) as PackageJson;
93117
} catch (e) {
94118
logger.error(`Could not parse package.json at ${filePath}`);
95-
return new Set();
119+
const emptySet = new Set<string>();
120+
packageJsonDepCache.set(filePath, emptySet);
121+
return emptySet;
96122
}
97123

98-
return (
124+
const dependencies = (
99125
[
100126
"dependencies",
101127
"devDependencies",
@@ -105,8 +131,112 @@ const packageJsonDependencies = (filePath: string): Set<string> => {
105131
)
106132
.flatMap((key) => Object.keys(packageJson[key] ?? {}))
107133
.reduce((acc, dependency) => acc.add(dependency), new Set<string>());
134+
135+
packageJsonDepCache.set(filePath, dependencies);
136+
return dependencies;
108137
};
109138

139+
/**
140+
* Find turbo.json or turbo.jsonc in a directory if it exists
141+
*/
142+
function findTurboConfigInDir(dirPath: string): string | null {
143+
const turboJsonPath = path.join(dirPath, "turbo.json");
144+
const turboJsoncPath = path.join(dirPath, "turbo.jsonc");
145+
146+
if (fs.existsSync(turboJsonPath)) {
147+
return turboJsonPath;
148+
}
149+
if (fs.existsSync(turboJsoncPath)) {
150+
return turboJsoncPath;
151+
}
152+
return null;
153+
}
154+
155+
/**
156+
* Get all turbo config file paths that are currently loaded in the project
157+
*/
158+
function getTurboConfigPaths(project: Project): Array<string> {
159+
const paths: Array<string> = [];
160+
161+
// Add root turbo config if it exists and is loaded
162+
if (project.projectRoot?.turboConfig) {
163+
const configPath = findTurboConfigInDir(project.projectRoot.workspacePath);
164+
if (configPath) {
165+
paths.push(configPath);
166+
}
167+
}
168+
169+
// Add workspace turbo configs that are loaded
170+
for (const workspace of project.projectWorkspaces) {
171+
if (workspace.turboConfig) {
172+
const configPath = findTurboConfigInDir(workspace.workspacePath);
173+
if (configPath) {
174+
paths.push(configPath);
175+
}
176+
}
177+
}
178+
179+
return paths;
180+
}
181+
182+
/**
183+
* Scan filesystem for all turbo.json/turbo.jsonc files across all workspaces.
184+
* This scans ALL workspaces regardless of whether they currently have turboConfig loaded,
185+
* allowing detection of newly created turbo.json files.
186+
*/
187+
function scanForTurboConfigs(project: Project): Array<string> {
188+
const paths: Array<string> = [];
189+
190+
// Check root turbo config
191+
if (project.projectRoot) {
192+
const configPath = findTurboConfigInDir(project.projectRoot.workspacePath);
193+
if (configPath) {
194+
paths.push(configPath);
195+
}
196+
}
197+
198+
// Check ALL workspaces for turbo configs (not just those with turboConfig already loaded)
199+
for (const workspace of project.projectWorkspaces) {
200+
const configPath = findTurboConfigInDir(workspace.workspacePath);
201+
if (configPath) {
202+
paths.push(configPath);
203+
}
204+
}
205+
206+
return paths;
207+
}
208+
209+
/**
210+
* Compute hashes for all turbo.config(c) files
211+
*/
212+
function computeTurboConfigHashes(
213+
configPaths: Array<string>
214+
): Map<string, string> {
215+
const hashes = new Map<string, string>();
216+
217+
for (const configPath of configPaths) {
218+
const content = fs.readFileSync(configPath, "utf-8");
219+
const hash = crypto.createHash("md5").update(content).digest("hex");
220+
hashes.set(configPath, hash);
221+
}
222+
223+
return hashes;
224+
}
225+
226+
/**
227+
* Check if a single config file has changed by comparing its hash
228+
*/
229+
function hasConfigChanged(filePath: string, expectedHash: string): boolean {
230+
try {
231+
const content = fs.readFileSync(filePath, "utf-8");
232+
const currentHash = crypto.createHash("md5").update(content).digest("hex");
233+
return currentHash !== expectedHash;
234+
} catch {
235+
// File no longer exists or is unreadable
236+
return true;
237+
}
238+
}
239+
110240
/**
111241
* Turborepo does some nice framework detection based on the dependencies in the package.json. This function ports that logic to this ESLint rule.
112242
*
@@ -119,15 +249,21 @@ const frameworkEnvMatches = (filePath: string): Set<RegExp> => {
119249
logger.error(`Could not determine package for ${filePath}`);
120250
return new Set<RegExp>();
121251
}
252+
253+
// Use package.json path as cache key since all files in same package share the same framework config
254+
const cacheKey = `${packageJsonDir}/package.json`;
255+
const cached = frameworkEnvCache.get(cacheKey);
256+
if (cached) {
257+
return cached;
258+
}
259+
122260
debug(`found package.json in: ${packageJsonDir}`);
123261

124-
const dependencies = packageJsonDependencies(
125-
`${packageJsonDir}/package.json`
126-
);
262+
const dependencies = packageJsonDependencies(cacheKey);
127263
const hasDependency = (dep: string) => dependencies.has(dep);
128264
debug(`dependencies for ${filePath}: ${Array.from(dependencies).join(",")}`);
129265

130-
return frameworks.reduce(
266+
const result = frameworks.reduce(
131267
(
132268
acc,
133269
{
@@ -150,6 +286,9 @@ const frameworkEnvMatches = (filePath: string): Set<RegExp> => {
150286
},
151287
new Set<RegExp>()
152288
);
289+
290+
frameworkEnvCache.set(cacheKey, result);
291+
return result;
153292
};
154293

155294
function create(context: RuleContextWithOptions): Rule.RuleListener {
@@ -166,7 +305,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener {
166305
}
167306
});
168307

169-
const filename = context.getFilename();
308+
const filename = context.filename;
170309
debug(`Checking file: ${filename}`);
171310

172311
const matches = frameworkEnvMatches(filename);
@@ -177,18 +316,80 @@ function create(context: RuleContextWithOptions): Rule.RuleListener {
177316
}`
178317
);
179318

180-
const cwd = normalizeCwd(
181-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed to support older eslint versions
182-
context.getCwd ? context.getCwd() : undefined,
183-
options
184-
);
319+
const cwd = normalizeCwd(context.cwd ? context.cwd : undefined, options);
320+
321+
// Use cached Project instance to avoid expensive re-initialization for every file
322+
const projectKey = cwd ?? process.cwd();
323+
const cachedProject = projectCache.get(projectKey);
324+
let project: Project;
325+
326+
if (!cachedProject) {
327+
project = new Project(cwd);
328+
if (project.valid()) {
329+
const configPaths = getTurboConfigPaths(project);
330+
const hashes = computeTurboConfigHashes(configPaths);
331+
projectCache.set(projectKey, {
332+
project,
333+
turboConfigHashes: hashes,
334+
configPaths,
335+
});
336+
debug(`Cached new project for ${projectKey}`);
337+
}
338+
} else {
339+
project = cachedProject.project;
340+
341+
// Check if any turbo.json(c) configs have changed
342+
try {
343+
const currentConfigPaths = scanForTurboConfigs(project);
344+
345+
// Quick path comparison - cheapest check first
346+
const pathsUnchanged =
347+
currentConfigPaths.length === cachedProject.configPaths.length &&
348+
currentConfigPaths.every((p, i) => p === cachedProject.configPaths[i]);
349+
350+
if (!pathsUnchanged) {
351+
// Paths changed (added/removed configs), must reload
352+
debug(`Turbo config paths changed for ${projectKey}, reloading...`);
353+
const newHashes = computeTurboConfigHashes(currentConfigPaths);
354+
project.reload();
355+
cachedProject.turboConfigHashes = newHashes;
356+
cachedProject.configPaths = currentConfigPaths;
357+
} else {
358+
// Paths unchanged - check if any file content changed (early exit on first change)
359+
let contentChanged = false;
360+
for (const [
361+
filePath,
362+
expectedHash,
363+
] of cachedProject.turboConfigHashes) {
364+
if (hasConfigChanged(filePath, expectedHash)) {
365+
contentChanged = true;
366+
break;
367+
}
368+
}
369+
370+
if (contentChanged) {
371+
debug(`Turbo config content changed for ${projectKey}, reloading...`);
372+
const newHashes = computeTurboConfigHashes(currentConfigPaths);
373+
project.reload();
374+
cachedProject.turboConfigHashes = newHashes;
375+
cachedProject.configPaths = currentConfigPaths;
376+
}
377+
}
378+
} catch (error) {
379+
// Config file was deleted or is unreadable, reload project
380+
debug(`Error computing hashes for ${projectKey}, reloading...`);
381+
project.reload();
382+
const configPaths = scanForTurboConfigs(project);
383+
cachedProject.turboConfigHashes = computeTurboConfigHashes(configPaths);
384+
cachedProject.configPaths = configPaths;
385+
}
386+
}
185387

186-
const project = new Project(cwd);
187388
if (!project.valid()) {
188389
return {};
189390
}
190391

191-
const filePath = context.getPhysicalFilename();
392+
const filePath = context.physicalFilename;
192393
const hasWorkspaceConfigs = project.projectWorkspaces.some(
193394
(workspaceConfig) => Boolean(workspaceConfig.turboConfig)
194395
);
@@ -263,10 +464,6 @@ function create(context: RuleContextWithOptions): Rule.RuleListener {
263464
};
264465

265466
return {
266-
Program() {
267-
// Reload project configuration so that changes show in the user's editor
268-
project.reload();
269-
},
270467
MemberExpression(node) {
271468
// we only care about complete process env declarations and non-computed keys
272469
if (isProcessEnv(node) || isImportMetaEnv(node)) {
@@ -302,5 +499,15 @@ function create(context: RuleContextWithOptions): Rule.RuleListener {
302499
};
303500
}
304501

502+
/**
503+
* Clear all module-level caches. This is primarily useful for test isolation.
504+
*/
505+
export function clearCache(): void {
506+
projectCache.clear();
507+
frameworkEnvCache.clear();
508+
packageJsonDepCache.clear();
509+
clearConfigCaches();
510+
}
511+
305512
const rule = { create, meta };
306513
export default rule;

0 commit comments

Comments
 (0)