11import path from "node:path" ;
2- import { readFileSync } from "node:fs" ;
2+ import fs from "node:fs" ;
3+ import crypto from "node:crypto" ;
34import type { Rule } from "eslint" ;
45import 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" ;
612import { frameworks } from "@turbo/types" ;
713import { RULES } from "../constants" ;
814import { 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+
1633export 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 */
7996const 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
155294function 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+
305512const rule = { create, meta } ;
306513export default rule ;
0 commit comments