From 945599ad72361827520cb1473a1fe2360c247167 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Mon, 6 Oct 2025 22:05:02 -0600 Subject: [PATCH 01/11] 7x faster --- .../no-undeclared-env-vars.commonjs.test.ts | 7 +- .../no-undeclared-env-vars.module.test.ts | 7 +- .../workspace-configs/reload.test.ts | 15 +- .../lib/rules/no-undeclared-env-vars.ts | 188 ++++++++++++++++-- 4 files changed, 200 insertions(+), 17 deletions(-) diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.commonjs.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.commonjs.test.ts index de6129ae2b92a..3a35121b566f1 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.commonjs.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.commonjs.test.ts @@ -1,7 +1,8 @@ import path from "node:path"; import { RuleTester } from "eslint"; +import { afterEach } from "@jest/globals"; import { RULES } from "../../../../lib/constants"; -import rule from "../../../../lib/rules/no-undeclared-env-vars"; +import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars"; const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2020 }, @@ -19,6 +20,10 @@ const options = (extra: Record = {}) => ({ ], }); +afterEach(() => { + clearCache(); +}); + ruleTester.run(RULES.noUndeclaredEnvVars, rule, { valid: [ { diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.module.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.module.test.ts index 5ca66e6b4761f..a618bc02e35ae 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.module.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/no-undeclared-env-vars.module.test.ts @@ -1,7 +1,8 @@ import path from "node:path"; import { RuleTester } from "eslint"; +import { afterEach } from "@jest/globals"; import { RULES } from "../../../../lib/constants"; -import rule from "../../../../lib/rules/no-undeclared-env-vars"; +import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars"; const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2020, sourceType: "module" }, @@ -19,6 +20,10 @@ const options = (extra: Record = {}) => ({ ], }); +afterEach(() => { + clearCache(); +}); + ruleTester.run(RULES.noUndeclaredEnvVars, rule, { valid: [ { diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts index f9dfc550758e6..e4b0f5baaf619 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts @@ -4,7 +4,7 @@ import { RuleTester } from "eslint"; import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; import type { SchemaV1 } from "@turbo/types"; import { RULES } from "../../../../lib/constants"; -import rule from "../../../../lib/rules/no-undeclared-env-vars"; +import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars"; import { Project } from "../../../../lib/utils/calculate-inputs"; const ruleTester = new RuleTester({ @@ -19,16 +19,25 @@ describe("Project reload functionality", () => { let originalTurboJson: string; beforeEach(() => { - project = new Project(cwd); // Store original turbo.json content for restoration const turboJsonPath = path.join(cwd, "turbo.json"); originalTurboJson = fs.readFileSync(turboJsonPath, "utf8"); + + // Clear cache AFTER reading original, so we don't cache corrupted state + clearCache(); + project = new Project(cwd); }); afterEach(() => { - // Restore original turbo.json content + // Restore original turbo.json content FIRST const turboJsonPath = path.join(cwd, "turbo.json"); fs.writeFileSync(turboJsonPath, originalTurboJson); + + // Force reload to pick up the restored original + project.reload(); + + // Clear ALL caches to ensure no pollution to other tests + clearCache(); }); it("should reload workspace configurations when called", () => { diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 8e780234025da..43fa52d5760e0 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { readFileSync } from "node:fs"; +import crypto from "node:crypto"; import type { Rule } from "eslint"; import type { Node, MemberExpression } from "estree"; import { type PackageJson, logger, searchUp } from "@turbo/utils"; @@ -13,6 +14,16 @@ const debug = process.env.RUNNER_DEBUG /* noop */ }; +// Module-level caches to share state across all files in a single ESLint run +interface CachedProject { + project: Project; + turboConfigHashes: Map; +} + +const projectCache = new Map(); +const frameworkEnvCache = new Map>(); +const packageJsonDepCache = new Map>(); + export interface RuleContextWithOptions extends Rule.RuleContext { options: Array<{ cwd?: string; @@ -77,6 +88,12 @@ function normalizeCwd( /** for a given `package.json` file path, this will compile a Set of that package's listed dependencies */ const packageJsonDependencies = (filePath: string): Set => { + // Check cache first + const cached = packageJsonDepCache.get(filePath); + if (cached) { + return cached; + } + // get the contents of the package.json let packageJsonString; @@ -84,7 +101,9 @@ const packageJsonDependencies = (filePath: string): Set => { packageJsonString = readFileSync(filePath, "utf-8"); } catch (e) { logger.error(`Could not read package.json at ${filePath}`); - return new Set(); + const emptySet = new Set(); + packageJsonDepCache.set(filePath, emptySet); + return emptySet; } let packageJson: PackageJson; @@ -92,10 +111,12 @@ const packageJsonDependencies = (filePath: string): Set => { packageJson = JSON.parse(packageJsonString) as PackageJson; } catch (e) { logger.error(`Could not parse package.json at ${filePath}`); - return new Set(); + const emptySet = new Set(); + packageJsonDepCache.set(filePath, emptySet); + return emptySet; } - return ( + const dependencies = ( [ "dependencies", "devDependencies", @@ -105,8 +126,103 @@ const packageJsonDependencies = (filePath: string): Set => { ) .flatMap((key) => Object.keys(packageJson[key] ?? {})) .reduce((acc, dependency) => acc.add(dependency), new Set()); + + packageJsonDepCache.set(filePath, dependencies); + return dependencies; }; +/** + * Get all turbo config file paths for a project (root + workspace configs) + */ +function getTurboConfigPaths(project: Project): Array { + const paths: Array = []; + + // Add root turbo config if it exists + if (project.projectRoot?.turboConfig) { + const rootPath = project.projectRoot.workspacePath; + // Check for both turbo.json and turbo.jsonc + const turboJsonPath = path.join(rootPath, "turbo.json"); + const turboJsoncPath = path.join(rootPath, "turbo.jsonc"); + try { + readFileSync(turboJsonPath, "utf-8"); + paths.push(turboJsonPath); + } catch { + try { + readFileSync(turboJsoncPath, "utf-8"); + paths.push(turboJsoncPath); + } catch { + // Neither file exists or is readable + } + } + } + + // Add workspace turbo configs + for (const workspace of project.projectWorkspaces) { + if (workspace.turboConfig) { + const turboJsonPath = path.join(workspace.workspacePath, "turbo.json"); + const turboJsoncPath = path.join(workspace.workspacePath, "turbo.jsonc"); + try { + readFileSync(turboJsonPath, "utf-8"); + paths.push(turboJsonPath); + } catch { + try { + readFileSync(turboJsoncPath, "utf-8"); + paths.push(turboJsoncPath); + } catch { + // Neither file exists or is readable + } + } + } + } + + return paths; +} + +/** + * Compute SHA256 hashes for all turbo config files + */ +function computeTurboConfigHashes( + configPaths: Array +): Map { + const hashes = new Map(); + + for (const configPath of configPaths) { + try { + const content = readFileSync(configPath, "utf-8"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + hashes.set(configPath, hash); + } catch (e) { + // If we can't read the file, use an empty hash + hashes.set(configPath, ""); + } + } + + return hashes; +} + +/** + * Check if any turbo config files have changed by comparing hashes + */ +function haveTurboConfigsChanged( + oldHashes: Map, + newHashes: Map +): boolean { + // Check if the set of files has changed + if (oldHashes.size !== newHashes.size) { + return true; + } + + // Check if any file content has changed + for (const [filePath, newHash] of newHashes) { + const oldHash = oldHashes.get(filePath); + if (oldHash !== newHash) { + return true; + } + } + + return false; +} + /** * Turborepo does some nice framework detection based on the dependencies in the package.json. This function ports that logic to this ESLint rule. * @@ -119,15 +235,21 @@ const frameworkEnvMatches = (filePath: string): Set => { logger.error(`Could not determine package for ${filePath}`); return new Set(); } + + // Use package.json path as cache key since all files in same package share the same framework config + const cacheKey = `${packageJsonDir}/package.json`; + const cached = frameworkEnvCache.get(cacheKey); + if (cached) { + return cached; + } + debug(`found package.json in: ${packageJsonDir}`); - const dependencies = packageJsonDependencies( - `${packageJsonDir}/package.json` - ); + const dependencies = packageJsonDependencies(cacheKey); const hasDependency = (dep: string) => dependencies.has(dep); debug(`dependencies for ${filePath}: ${Array.from(dependencies).join(",")}`); - return frameworks.reduce( + const result = frameworks.reduce( ( acc, { @@ -150,6 +272,9 @@ const frameworkEnvMatches = (filePath: string): Set => { }, new Set() ); + + frameworkEnvCache.set(cacheKey, result); + return result; }; function create(context: RuleContextWithOptions): Rule.RuleListener { @@ -183,7 +308,36 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { options ); - const project = new Project(cwd); + // Use cached Project instance to avoid expensive re-initialization for every file + const projectKey = cwd ?? process.cwd(); + const cachedProject = projectCache.get(projectKey); + let project: Project; + + if (!cachedProject) { + // No cached project, create a new one + project = new Project(cwd); + if (project.valid()) { + const configPaths = getTurboConfigPaths(project); + const hashes = computeTurboConfigHashes(configPaths); + projectCache.set(projectKey, { + project, + turboConfigHashes: hashes, + }); + debug(`Cached new project for ${projectKey}`); + } + } else { + // We have a cached project, check if turbo configs have changed + project = cachedProject.project; + const configPaths = getTurboConfigPaths(project); + const newHashes = computeTurboConfigHashes(configPaths); + + if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { + debug(`Turbo config changed for ${projectKey}, reloading...`); + project.reload(); + cachedProject.turboConfigHashes = newHashes; + } + } + if (!project.valid()) { return {}; } @@ -263,10 +417,6 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { }; return { - Program() { - // Reload project configuration so that changes show in the user's editor - project.reload(); - }, MemberExpression(node) { // we only care about complete process env declarations and non-computed keys if (isProcessEnv(node) || isImportMetaEnv(node)) { @@ -302,5 +452,19 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { }; } +/** + * Clear all module-level caches. This is primarily useful for test isolation. + * + * Note: The Project instance cache is reused across files for performance. + * Changes to turbo.json files are detected via hash comparison, and project.reload() + * is automatically called when changes are detected to ensure turbo.json changes + * are picked up immediately for live IDE feedback. + */ +export function clearCache(): void { + projectCache.clear(); + frameworkEnvCache.clear(); + packageJsonDepCache.clear(); +} + const rule = { create, meta }; export default rule; From 0907446ea6009759abbd146584fd71eecc3abb6c Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 19:53:49 -0600 Subject: [PATCH 02/11] tests --- .../hash-change-detection.test.ts | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts new file mode 100644 index 0000000000000..60b3ec40fc1d2 --- /dev/null +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts @@ -0,0 +1,330 @@ +import path from "node:path"; +import fs from "node:fs"; +import { Linter } from "eslint"; +import { describe, expect, it, beforeAll, afterAll } from "@jest/globals"; +import type { SchemaV1 } from "@turbo/types"; +import rule, { clearCache } from "../../../lib/rules/no-undeclared-env-vars"; + +const cwd = path.join(__dirname, "../../../__fixtures__/workspace-configs"); +const webFilename = path.join(cwd, "/apps/web/index.js"); +const docsFilename = path.join(cwd, "/apps/docs/index.js"); + +// Known good turbo.json state +const KNOWN_GOOD_TURBO_JSON = { + $schema: "https://turborepo.com/schema.json", + globalEnv: ["CI"], + globalDotEnv: [".env", "missing.env"], + pipeline: { + build: { + env: ["ENV_1"], + }, + }, +}; + +describe("Hash-based change detection", () => { + const turboJsonPath = path.join(cwd, "turbo.json"); + let originalTurboJson: string; + + beforeAll(() => { + // Save whatever state exists + try { + originalTurboJson = fs.readFileSync(turboJsonPath, "utf8"); + } catch { + originalTurboJson = JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2); + } + + // Start with known good state + fs.writeFileSync( + turboJsonPath, + JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2) + ); + clearCache(); + }); + + afterAll(() => { + // Restore original state + fs.writeFileSync(turboJsonPath, originalTurboJson); + clearCache(); + }); + + it("should detect turbo.json changes between file lints", () => { + const linter = new Linter(); + linter.defineRule("turbo/no-undeclared-env-vars", rule); + + const backup = fs.readFileSync(turboJsonPath, "utf8"); + try { + // First lint - ENV_3 should fail because it's not in turbo.json + const firstResults = linter.verify( + "const { ENV_3 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(firstResults).toHaveLength(1); + expect(firstResults[0].message).toContain("ENV_3 is not listed"); + + // Modify turbo.json to add ENV_3 + const modifiedConfig = { + ...KNOWN_GOOD_TURBO_JSON, + globalEnv: ["ENV_3"], + }; + fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); + + // Second lint - ENV_3 should now pass because hash detection picks up the change + const secondResults = linter.verify( + "const { ENV_3 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(secondResults).toHaveLength(0); + } finally { + fs.writeFileSync(turboJsonPath, backup); + clearCache(); + } + }); + + it("should cache project when turbo.json hasn't changed", () => { + // Lint the same code multiple times without changing turbo.json + for (let i = 0; i < 5; i++) { + const results = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + // Should always pass since ENV_2 is valid and nothing changed + expect(results).toHaveLength(0); + } + }); + + it("should efficiently lint multiple files without reloading", () => { + // Lint web files (ENV_2 is valid in apps/web) + for (let i = 0; i < 3; i++) { + const results = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(results).toHaveLength(0); + } + + // Lint docs files (ENV_3 is valid in docs workspace) + for (let i = 0; i < 3; i++) { + const results = linter.verify( + "const { ENV_3 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: docsFilename } + ); + + expect(results).toHaveLength(0); + } + }); + + it("should detect changes even after multiple unchanged lints", () => { + // Lint several files without changes + for (let i = 0; i < 5; i++) { + const results = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(results).toHaveLength(0); + } + + // Now modify turbo.json + const modifiedConfig: SchemaV1 = { + ...(JSON.parse(originalTurboJson) as SchemaV1), + globalEnv: ["ENV_3", "ENV_4"], + }; + fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); + + // Next lint should detect the change + const results = linter.verify( + "const { ENV_3, ENV_4 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(results).toHaveLength(0); + }); + + it("should handle whitespace-only changes correctly", () => { + // First lint + const firstResults = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(firstResults).toHaveLength(0); + + // Modify turbo.json with only whitespace changes + const config = JSON.parse(originalTurboJson) as SchemaV1; + const whitespaceChanged = JSON.stringify(config, null, 4); // Different indentation + fs.writeFileSync(turboJsonPath, whitespaceChanged); + + // Should still work correctly (whitespace doesn't affect functionality) + const secondResults = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(secondResults).toHaveLength(0); + }); + + it("should detect changes across different workspace turbo.json files", () => { + const webTurboJsonPath = path.join(cwd, "apps/web/turbo.json"); + let originalWebTurboJson: string | null = null; + + try { + originalWebTurboJson = fs.readFileSync(webTurboJsonPath, "utf8"); + } catch { + // File might not exist + } + + try { + // First lint - WEB_CUSTOM_VAR should fail + const firstResults = linter.verify( + "const { WEB_CUSTOM_VAR } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(firstResults).toHaveLength(1); + + // Create/modify workspace turbo.json + const webConfig: SchemaV1 = { + extends: ["//"], + pipeline: { + build: { + env: ["WEB_CUSTOM_VAR"], + }, + }, + }; + fs.writeFileSync(webTurboJsonPath, JSON.stringify(webConfig, null, 2)); + + // Should detect the workspace config change + const secondResults = linter.verify( + "const { WEB_CUSTOM_VAR } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(secondResults).toHaveLength(0); + } finally { + // Cleanup + if (originalWebTurboJson) { + fs.writeFileSync(webTurboJsonPath, originalWebTurboJson); + } else { + try { + fs.unlinkSync(webTurboJsonPath); + } catch { + // File might not exist + } + } + } + }); + + it("should detect content changes through multiple modifications", () => { + const backup = fs.readFileSync(turboJsonPath, "utf8"); + + try { + // First lint with original config (ENV_2 is valid in apps/web) + const firstResults = linter.verify( + "const { ENV_2 } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(firstResults).toHaveLength(0); + + // Modify config to add NEW_VAR + const modifiedConfig = { + ...(JSON.parse(backup) as Record), + globalEnv: ["CI", "NEW_VAR"], + }; + fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); + + // NEW_VAR should now be valid + const secondResults = linter.verify( + "const { NEW_VAR } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(secondResults).toHaveLength(0); + } finally { + // Always restore, even if test fails + fs.writeFileSync(turboJsonPath, backup); + } + }); +}); From 554643c19bc3c140a64ee806bcc148edb78645da Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 20:34:42 -0600 Subject: [PATCH 03/11] faster again --- .../hash-change-detection.test.ts | 2 + .../lib/rules/no-undeclared-env-vars.ts | 47 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts index 60b3ec40fc1d2..7d5bf9840ed66 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts @@ -21,6 +21,8 @@ const KNOWN_GOOD_TURBO_JSON = { }, }; +const linter = new Linter(); + describe("Hash-based change detection", () => { const turboJsonPath = path.join(cwd, "turbo.json"); let originalTurboJson: string; diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 43fa52d5760e0..3431165714b4c 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import crypto from "node:crypto"; import type { Rule } from "eslint"; import type { Node, MemberExpression } from "estree"; @@ -17,12 +17,14 @@ const debug = process.env.RUNNER_DEBUG // Module-level caches to share state across all files in a single ESLint run interface CachedProject { project: Project; + configPaths: Array; turboConfigHashes: Map; } const projectCache = new Map(); const frameworkEnvCache = new Map>(); const packageJsonDepCache = new Map>(); +const frameworkEnvRegexCache = new Map(); export interface RuleContextWithOptions extends Rule.RuleContext { options: Array<{ @@ -143,16 +145,11 @@ function getTurboConfigPaths(project: Project): Array { // Check for both turbo.json and turbo.jsonc const turboJsonPath = path.join(rootPath, "turbo.json"); const turboJsoncPath = path.join(rootPath, "turbo.jsonc"); - try { - readFileSync(turboJsonPath, "utf-8"); + + if (existsSync(turboJsonPath)) { paths.push(turboJsonPath); - } catch { - try { - readFileSync(turboJsoncPath, "utf-8"); - paths.push(turboJsoncPath); - } catch { - // Neither file exists or is readable - } + } else if (existsSync(turboJsoncPath)) { + paths.push(turboJsoncPath); } } @@ -161,16 +158,11 @@ function getTurboConfigPaths(project: Project): Array { if (workspace.turboConfig) { const turboJsonPath = path.join(workspace.workspacePath, "turbo.json"); const turboJsoncPath = path.join(workspace.workspacePath, "turbo.jsonc"); - try { - readFileSync(turboJsonPath, "utf-8"); + + if (existsSync(turboJsonPath)) { paths.push(turboJsonPath); - } catch { - try { - readFileSync(turboJsoncPath, "utf-8"); - paths.push(turboJsoncPath); - } catch { - // Neither file exists or is readable - } + } else if (existsSync(turboJsoncPath)) { + paths.push(turboJsoncPath); } } } @@ -265,7 +257,14 @@ const frameworkEnvMatches = (filePath: string): Set => { if (hasMatch) { return new Set([ ...acc, - ...envWildcards.map((envWildcard) => RegExp(envWildcard)), + ...envWildcards.map((envWildcard) => { + let regex = frameworkEnvRegexCache.get(envWildcard); + if (!regex) { + regex = new RegExp(envWildcard); + frameworkEnvRegexCache.set(envWildcard, regex); + } + return regex; + }), ]); } return acc; @@ -321,6 +320,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { const hashes = computeTurboConfigHashes(configPaths); projectCache.set(projectKey, { project, + configPaths, turboConfigHashes: hashes, }); debug(`Cached new project for ${projectKey}`); @@ -328,13 +328,15 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { } else { // We have a cached project, check if turbo configs have changed project = cachedProject.project; - const configPaths = getTurboConfigPaths(project); - const newHashes = computeTurboConfigHashes(configPaths); + // Use cached paths instead of recomputing + const newHashes = computeTurboConfigHashes(cachedProject.configPaths); if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { debug(`Turbo config changed for ${projectKey}, reloading...`); project.reload(); cachedProject.turboConfigHashes = newHashes; + // Recompute paths after reload in case workspace structure changed + cachedProject.configPaths = getTurboConfigPaths(project); } } @@ -464,6 +466,7 @@ export function clearCache(): void { projectCache.clear(); frameworkEnvCache.clear(); packageJsonDepCache.clear(); + frameworkEnvRegexCache.clear(); } const rule = { create, meta }; From 029a182a1a98338b94bf941f40ab05a595aaa6f8 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 20:52:52 -0600 Subject: [PATCH 04/11] md5 hashing --- .../lib/no-undeclared-env-vars/hash-change-detection.test.ts | 1 + .../eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts index 7d5bf9840ed66..60dd73f65dee7 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts @@ -22,6 +22,7 @@ const KNOWN_GOOD_TURBO_JSON = { }; const linter = new Linter(); +linter.defineRule("turbo/no-undeclared-env-vars", rule); describe("Hash-based change detection", () => { const turboJsonPath = path.join(cwd, "turbo.json"); diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 3431165714b4c..02cd4036bb732 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -171,7 +171,7 @@ function getTurboConfigPaths(project: Project): Array { } /** - * Compute SHA256 hashes for all turbo config files + * Compute hashes for all turbo config files */ function computeTurboConfigHashes( configPaths: Array @@ -181,7 +181,7 @@ function computeTurboConfigHashes( for (const configPath of configPaths) { try { const content = readFileSync(configPath, "utf-8"); - const hash = crypto.createHash("sha256").update(content).digest("hex"); + const hash = crypto.createHash("md5").update(content).digest("hex"); hashes.set(configPath, hash); } catch (e) { // If we can't read the file, use an empty hash From 5d987eb9217fec85e87ddc91f6ed3b65da7e813a Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 21:03:54 -0600 Subject: [PATCH 05/11] fix tests --- .../hash-change-detection.test.ts | 136 +++++++++++------- .../lib/rules/no-undeclared-env-vars.ts | 51 ++++--- 2 files changed, 107 insertions(+), 80 deletions(-) diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts index 60dd73f65dee7..fdaa2845e349e 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts @@ -1,7 +1,14 @@ import path from "node:path"; import fs from "node:fs"; import { Linter } from "eslint"; -import { describe, expect, it, beforeAll, afterAll } from "@jest/globals"; +import { + describe, + expect, + it, + beforeAll, + afterAll, + afterEach, +} from "@jest/globals"; import type { SchemaV1 } from "@turbo/types"; import rule, { clearCache } from "../../../lib/rules/no-undeclared-env-vars"; @@ -44,6 +51,15 @@ describe("Hash-based change detection", () => { clearCache(); }); + afterEach(() => { + // Restore known good state after each test to prevent pollution + fs.writeFileSync( + turboJsonPath, + JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2) + ); + clearCache(); + }); + afterAll(() => { // Restore original state fs.writeFileSync(turboJsonPath, originalTurboJson); @@ -101,7 +117,7 @@ describe("Hash-based change detection", () => { // Lint the same code multiple times without changing turbo.json for (let i = 0; i < 5; i++) { const results = linter.verify( - "const { ENV_2 } = process.env;", + "const { CI } = process.env;", { rules: { "turbo/no-undeclared-env-vars": ["error", { cwd }], @@ -111,7 +127,7 @@ describe("Hash-based change detection", () => { { filename: webFilename } ); - // Should always pass since ENV_2 is valid and nothing changed + // Should always pass since CI is valid and nothing changed expect(results).toHaveLength(0); } }); @@ -151,10 +167,35 @@ describe("Hash-based change detection", () => { }); it("should detect changes even after multiple unchanged lints", () => { - // Lint several files without changes - for (let i = 0; i < 5; i++) { + const backup = fs.readFileSync(turboJsonPath, "utf8"); + + try { + // Lint several files without changes + for (let i = 0; i < 5; i++) { + const results = linter.verify( + "const { CI } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, + }, + { filename: webFilename } + ); + + expect(results).toHaveLength(0); + } + + // Now modify turbo.json + const modifiedConfig: SchemaV1 = { + ...(JSON.parse(backup) as SchemaV1), + globalEnv: ["ENV_3", "ENV_4"], + }; + fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); + + // Next lint should detect the change const results = linter.verify( - "const { ENV_2 } = process.env;", + "const { ENV_3, ENV_4 } = process.env;", { rules: { "turbo/no-undeclared-env-vars": ["error", { cwd }], @@ -165,63 +206,52 @@ describe("Hash-based change detection", () => { ); expect(results).toHaveLength(0); + } finally { + // Restore backup + fs.writeFileSync(turboJsonPath, backup); } - - // Now modify turbo.json - const modifiedConfig: SchemaV1 = { - ...(JSON.parse(originalTurboJson) as SchemaV1), - globalEnv: ["ENV_3", "ENV_4"], - }; - fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); - - // Next lint should detect the change - const results = linter.verify( - "const { ENV_3, ENV_4 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(results).toHaveLength(0); }); it("should handle whitespace-only changes correctly", () => { - // First lint - const firstResults = linter.verify( - "const { ENV_2 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], + const backup = fs.readFileSync(turboJsonPath, "utf8"); + + try { + // First lint + const firstResults = linter.verify( + "const { CI } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); + { filename: webFilename } + ); - expect(firstResults).toHaveLength(0); + expect(firstResults).toHaveLength(0); - // Modify turbo.json with only whitespace changes - const config = JSON.parse(originalTurboJson) as SchemaV1; - const whitespaceChanged = JSON.stringify(config, null, 4); // Different indentation - fs.writeFileSync(turboJsonPath, whitespaceChanged); + // Modify turbo.json with only whitespace changes + const config = JSON.parse(backup) as SchemaV1; + const whitespaceChanged = JSON.stringify(config, null, 4); // Different indentation + fs.writeFileSync(turboJsonPath, whitespaceChanged); - // Should still work correctly (whitespace doesn't affect functionality) - const secondResults = linter.verify( - "const { ENV_2 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], + // Should still work correctly (whitespace doesn't affect functionality) + const secondResults = linter.verify( + "const { CI } = process.env;", + { + rules: { + "turbo/no-undeclared-env-vars": ["error", { cwd }], + }, + parserOptions: { ecmaVersion: 2020 }, }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); + { filename: webFilename } + ); - expect(secondResults).toHaveLength(0); + expect(secondResults).toHaveLength(0); + } finally { + // Restore backup + fs.writeFileSync(turboJsonPath, backup); + } }); it("should detect changes across different workspace turbo.json files", () => { diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 02cd4036bb732..43fa52d5760e0 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import crypto from "node:crypto"; import type { Rule } from "eslint"; import type { Node, MemberExpression } from "estree"; @@ -17,14 +17,12 @@ const debug = process.env.RUNNER_DEBUG // Module-level caches to share state across all files in a single ESLint run interface CachedProject { project: Project; - configPaths: Array; turboConfigHashes: Map; } const projectCache = new Map(); const frameworkEnvCache = new Map>(); const packageJsonDepCache = new Map>(); -const frameworkEnvRegexCache = new Map(); export interface RuleContextWithOptions extends Rule.RuleContext { options: Array<{ @@ -145,11 +143,16 @@ function getTurboConfigPaths(project: Project): Array { // Check for both turbo.json and turbo.jsonc const turboJsonPath = path.join(rootPath, "turbo.json"); const turboJsoncPath = path.join(rootPath, "turbo.jsonc"); - - if (existsSync(turboJsonPath)) { + try { + readFileSync(turboJsonPath, "utf-8"); paths.push(turboJsonPath); - } else if (existsSync(turboJsoncPath)) { - paths.push(turboJsoncPath); + } catch { + try { + readFileSync(turboJsoncPath, "utf-8"); + paths.push(turboJsoncPath); + } catch { + // Neither file exists or is readable + } } } @@ -158,11 +161,16 @@ function getTurboConfigPaths(project: Project): Array { if (workspace.turboConfig) { const turboJsonPath = path.join(workspace.workspacePath, "turbo.json"); const turboJsoncPath = path.join(workspace.workspacePath, "turbo.jsonc"); - - if (existsSync(turboJsonPath)) { + try { + readFileSync(turboJsonPath, "utf-8"); paths.push(turboJsonPath); - } else if (existsSync(turboJsoncPath)) { - paths.push(turboJsoncPath); + } catch { + try { + readFileSync(turboJsoncPath, "utf-8"); + paths.push(turboJsoncPath); + } catch { + // Neither file exists or is readable + } } } } @@ -171,7 +179,7 @@ function getTurboConfigPaths(project: Project): Array { } /** - * Compute hashes for all turbo config files + * Compute SHA256 hashes for all turbo config files */ function computeTurboConfigHashes( configPaths: Array @@ -181,7 +189,7 @@ function computeTurboConfigHashes( for (const configPath of configPaths) { try { const content = readFileSync(configPath, "utf-8"); - const hash = crypto.createHash("md5").update(content).digest("hex"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); hashes.set(configPath, hash); } catch (e) { // If we can't read the file, use an empty hash @@ -257,14 +265,7 @@ const frameworkEnvMatches = (filePath: string): Set => { if (hasMatch) { return new Set([ ...acc, - ...envWildcards.map((envWildcard) => { - let regex = frameworkEnvRegexCache.get(envWildcard); - if (!regex) { - regex = new RegExp(envWildcard); - frameworkEnvRegexCache.set(envWildcard, regex); - } - return regex; - }), + ...envWildcards.map((envWildcard) => RegExp(envWildcard)), ]); } return acc; @@ -320,7 +321,6 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { const hashes = computeTurboConfigHashes(configPaths); projectCache.set(projectKey, { project, - configPaths, turboConfigHashes: hashes, }); debug(`Cached new project for ${projectKey}`); @@ -328,15 +328,13 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { } else { // We have a cached project, check if turbo configs have changed project = cachedProject.project; - // Use cached paths instead of recomputing - const newHashes = computeTurboConfigHashes(cachedProject.configPaths); + const configPaths = getTurboConfigPaths(project); + const newHashes = computeTurboConfigHashes(configPaths); if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { debug(`Turbo config changed for ${projectKey}, reloading...`); project.reload(); cachedProject.turboConfigHashes = newHashes; - // Recompute paths after reload in case workspace structure changed - cachedProject.configPaths = getTurboConfigPaths(project); } } @@ -466,7 +464,6 @@ export function clearCache(): void { projectCache.clear(); frameworkEnvCache.clear(); packageJsonDepCache.clear(); - frameworkEnvRegexCache.clear(); } const rule = { create, meta }; From 821d5c90532010363b547c05e81ecd687d7c4d6d Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 21:34:52 -0600 Subject: [PATCH 06/11] much more faster --- .../__fixtures__/workspace-configs/turbo.json | 15 ++++-- .../workspace-configs/reload.test.ts | 7 ++- .../lib/rules/no-undeclared-env-vars.ts | 53 +++++++++---------- packages/turbo-utils/src/getTurboConfigs.ts | 9 ++++ packages/turbo-utils/src/index.ts | 1 + 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json b/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json index a20986de28c27..bb662efb4f217 100644 --- a/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json +++ b/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json @@ -1,10 +1,17 @@ { "$schema": "https://turborepo.com/schema.json", - "globalEnv": ["CI"], - "globalDotEnv": [".env", "missing.env"], + "globalEnv": [ + "CI" + ], + "globalDotEnv": [ + ".env", + "missing.env" + ], "pipeline": { "build": { - "env": ["ENV_1"] + "env": [ + "ENV_1" + ] } } -} +} \ No newline at end of file diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts index e4b0f5baaf619..f414ed17161d1 100644 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts @@ -34,7 +34,12 @@ describe("Project reload functionality", () => { fs.writeFileSync(turboJsonPath, originalTurboJson); // Force reload to pick up the restored original - project.reload(); + // Wrap in try/catch to ensure cache clearing always happens + try { + project.reload(); + } catch (e) { + // Ignore reload errors in cleanup + } // Clear ALL caches to ensure no pollution to other tests clearCache(); diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 43fa52d5760e0..207493afa9684 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -1,9 +1,14 @@ import path from "node:path"; -import { readFileSync } from "node:fs"; +import fs from "node:fs"; import crypto from "node:crypto"; import type { Rule } from "eslint"; import type { Node, MemberExpression } from "estree"; -import { type PackageJson, logger, searchUp } from "@turbo/utils"; +import { + type PackageJson, + logger, + searchUp, + clearConfigCaches, +} from "@turbo/utils"; import { frameworks } from "@turbo/types"; import { RULES } from "../constants"; import { Project, getWorkspaceFromFilePath } from "../utils/calculate-inputs"; @@ -18,6 +23,7 @@ const debug = process.env.RUNNER_DEBUG interface CachedProject { project: Project; turboConfigHashes: Map; + configPaths: Array; } const projectCache = new Map(); @@ -98,7 +104,7 @@ const packageJsonDependencies = (filePath: string): Set => { let packageJsonString; try { - packageJsonString = readFileSync(filePath, "utf-8"); + packageJsonString = fs.readFileSync(filePath, "utf-8"); } catch (e) { logger.error(`Could not read package.json at ${filePath}`); const emptySet = new Set(); @@ -140,19 +146,13 @@ function getTurboConfigPaths(project: Project): Array { // Add root turbo config if it exists if (project.projectRoot?.turboConfig) { const rootPath = project.projectRoot.workspacePath; - // Check for both turbo.json and turbo.jsonc const turboJsonPath = path.join(rootPath, "turbo.json"); const turboJsoncPath = path.join(rootPath, "turbo.jsonc"); - try { - readFileSync(turboJsonPath, "utf-8"); + + if (fs.existsSync(turboJsonPath)) { paths.push(turboJsonPath); - } catch { - try { - readFileSync(turboJsoncPath, "utf-8"); - paths.push(turboJsoncPath); - } catch { - // Neither file exists or is readable - } + } else if (fs.existsSync(turboJsoncPath)) { + paths.push(turboJsoncPath); } } @@ -161,16 +161,11 @@ function getTurboConfigPaths(project: Project): Array { if (workspace.turboConfig) { const turboJsonPath = path.join(workspace.workspacePath, "turbo.json"); const turboJsoncPath = path.join(workspace.workspacePath, "turbo.jsonc"); - try { - readFileSync(turboJsonPath, "utf-8"); + + if (fs.existsSync(turboJsonPath)) { paths.push(turboJsonPath); - } catch { - try { - readFileSync(turboJsoncPath, "utf-8"); - paths.push(turboJsoncPath); - } catch { - // Neither file exists or is readable - } + } else if (fs.existsSync(turboJsoncPath)) { + paths.push(turboJsoncPath); } } } @@ -188,8 +183,8 @@ function computeTurboConfigHashes( for (const configPath of configPaths) { try { - const content = readFileSync(configPath, "utf-8"); - const hash = crypto.createHash("sha256").update(content).digest("hex"); + const content = fs.readFileSync(configPath, "utf-8"); + const hash = crypto.createHash("md5").update(content).digest("hex"); hashes.set(configPath, hash); } catch (e) { // If we can't read the file, use an empty hash @@ -322,19 +317,22 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { projectCache.set(projectKey, { project, turboConfigHashes: hashes, + configPaths, }); debug(`Cached new project for ${projectKey}`); } } else { // We have a cached project, check if turbo configs have changed project = cachedProject.project; - const configPaths = getTurboConfigPaths(project); - const newHashes = computeTurboConfigHashes(configPaths); + const newHashes = computeTurboConfigHashes(cachedProject.configPaths); if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { debug(`Turbo config changed for ${projectKey}, reloading...`); project.reload(); - cachedProject.turboConfigHashes = newHashes; + const configPaths = getTurboConfigPaths(project); + const reloadedHashes = computeTurboConfigHashes(configPaths); + cachedProject.turboConfigHashes = reloadedHashes; + cachedProject.configPaths = configPaths; } } @@ -464,6 +462,7 @@ export function clearCache(): void { projectCache.clear(); frameworkEnvCache.clear(); packageJsonDepCache.clear(); + clearConfigCaches(); } const rule = { create, meta }; diff --git a/packages/turbo-utils/src/getTurboConfigs.ts b/packages/turbo-utils/src/getTurboConfigs.ts index 45fd3bf6ce2e3..ab166e8a2e771 100644 --- a/packages/turbo-utils/src/getTurboConfigs.ts +++ b/packages/turbo-utils/src/getTurboConfigs.ts @@ -293,3 +293,12 @@ export function forEachTaskDef( Object.entries(config.tasks).forEach(f); } } + +export function clearConfigCaches(): void { + Object.keys(turboConfigsCache).forEach((key) => { + delete turboConfigsCache[key]; + }); + Object.keys(workspaceConfigCache).forEach((key) => { + delete workspaceConfigCache[key]; + }); +} diff --git a/packages/turbo-utils/src/index.ts b/packages/turbo-utils/src/index.ts index 118c91fb74f95..8f5cdb0fb2298 100644 --- a/packages/turbo-utils/src/index.ts +++ b/packages/turbo-utils/src/index.ts @@ -4,6 +4,7 @@ export { getTurboConfigs, getWorkspaceConfigs, forEachTaskDef, + clearConfigCaches, } from "./getTurboConfigs"; export { searchUp } from "./searchUp"; export { From 66acbfd8dadbf064fd1132ff09aee06b33896c99 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 22:00:25 -0600 Subject: [PATCH 07/11] WIP d6b1a --- .../__fixtures__/workspace-configs/turbo.json | 15 +- .../hash-change-detection.test.ts | 363 ------------------ .../workspace-configs/reload.test.ts | 156 -------- .../lib/rules/no-undeclared-env-vars.ts | 24 +- packages/turbo-utils/src/getTurboConfigs.ts | 2 + 5 files changed, 12 insertions(+), 548 deletions(-) delete mode 100644 packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts delete mode 100644 packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts diff --git a/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json b/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json index bb662efb4f217..a20986de28c27 100644 --- a/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json +++ b/packages/eslint-plugin-turbo/__fixtures__/workspace-configs/turbo.json @@ -1,17 +1,10 @@ { "$schema": "https://turborepo.com/schema.json", - "globalEnv": [ - "CI" - ], - "globalDotEnv": [ - ".env", - "missing.env" - ], + "globalEnv": ["CI"], + "globalDotEnv": [".env", "missing.env"], "pipeline": { "build": { - "env": [ - "ENV_1" - ] + "env": ["ENV_1"] } } -} \ No newline at end of file +} diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts deleted file mode 100644 index fdaa2845e349e..0000000000000 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/hash-change-detection.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; -import { Linter } from "eslint"; -import { - describe, - expect, - it, - beforeAll, - afterAll, - afterEach, -} from "@jest/globals"; -import type { SchemaV1 } from "@turbo/types"; -import rule, { clearCache } from "../../../lib/rules/no-undeclared-env-vars"; - -const cwd = path.join(__dirname, "../../../__fixtures__/workspace-configs"); -const webFilename = path.join(cwd, "/apps/web/index.js"); -const docsFilename = path.join(cwd, "/apps/docs/index.js"); - -// Known good turbo.json state -const KNOWN_GOOD_TURBO_JSON = { - $schema: "https://turborepo.com/schema.json", - globalEnv: ["CI"], - globalDotEnv: [".env", "missing.env"], - pipeline: { - build: { - env: ["ENV_1"], - }, - }, -}; - -const linter = new Linter(); -linter.defineRule("turbo/no-undeclared-env-vars", rule); - -describe("Hash-based change detection", () => { - const turboJsonPath = path.join(cwd, "turbo.json"); - let originalTurboJson: string; - - beforeAll(() => { - // Save whatever state exists - try { - originalTurboJson = fs.readFileSync(turboJsonPath, "utf8"); - } catch { - originalTurboJson = JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2); - } - - // Start with known good state - fs.writeFileSync( - turboJsonPath, - JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2) - ); - clearCache(); - }); - - afterEach(() => { - // Restore known good state after each test to prevent pollution - fs.writeFileSync( - turboJsonPath, - JSON.stringify(KNOWN_GOOD_TURBO_JSON, null, 2) - ); - clearCache(); - }); - - afterAll(() => { - // Restore original state - fs.writeFileSync(turboJsonPath, originalTurboJson); - clearCache(); - }); - - it("should detect turbo.json changes between file lints", () => { - const linter = new Linter(); - linter.defineRule("turbo/no-undeclared-env-vars", rule); - - const backup = fs.readFileSync(turboJsonPath, "utf8"); - try { - // First lint - ENV_3 should fail because it's not in turbo.json - const firstResults = linter.verify( - "const { ENV_3 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(firstResults).toHaveLength(1); - expect(firstResults[0].message).toContain("ENV_3 is not listed"); - - // Modify turbo.json to add ENV_3 - const modifiedConfig = { - ...KNOWN_GOOD_TURBO_JSON, - globalEnv: ["ENV_3"], - }; - fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); - - // Second lint - ENV_3 should now pass because hash detection picks up the change - const secondResults = linter.verify( - "const { ENV_3 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(secondResults).toHaveLength(0); - } finally { - fs.writeFileSync(turboJsonPath, backup); - clearCache(); - } - }); - - it("should cache project when turbo.json hasn't changed", () => { - // Lint the same code multiple times without changing turbo.json - for (let i = 0; i < 5; i++) { - const results = linter.verify( - "const { CI } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - // Should always pass since CI is valid and nothing changed - expect(results).toHaveLength(0); - } - }); - - it("should efficiently lint multiple files without reloading", () => { - // Lint web files (ENV_2 is valid in apps/web) - for (let i = 0; i < 3; i++) { - const results = linter.verify( - "const { ENV_2 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(results).toHaveLength(0); - } - - // Lint docs files (ENV_3 is valid in docs workspace) - for (let i = 0; i < 3; i++) { - const results = linter.verify( - "const { ENV_3 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: docsFilename } - ); - - expect(results).toHaveLength(0); - } - }); - - it("should detect changes even after multiple unchanged lints", () => { - const backup = fs.readFileSync(turboJsonPath, "utf8"); - - try { - // Lint several files without changes - for (let i = 0; i < 5; i++) { - const results = linter.verify( - "const { CI } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(results).toHaveLength(0); - } - - // Now modify turbo.json - const modifiedConfig: SchemaV1 = { - ...(JSON.parse(backup) as SchemaV1), - globalEnv: ["ENV_3", "ENV_4"], - }; - fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); - - // Next lint should detect the change - const results = linter.verify( - "const { ENV_3, ENV_4 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(results).toHaveLength(0); - } finally { - // Restore backup - fs.writeFileSync(turboJsonPath, backup); - } - }); - - it("should handle whitespace-only changes correctly", () => { - const backup = fs.readFileSync(turboJsonPath, "utf8"); - - try { - // First lint - const firstResults = linter.verify( - "const { CI } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(firstResults).toHaveLength(0); - - // Modify turbo.json with only whitespace changes - const config = JSON.parse(backup) as SchemaV1; - const whitespaceChanged = JSON.stringify(config, null, 4); // Different indentation - fs.writeFileSync(turboJsonPath, whitespaceChanged); - - // Should still work correctly (whitespace doesn't affect functionality) - const secondResults = linter.verify( - "const { CI } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(secondResults).toHaveLength(0); - } finally { - // Restore backup - fs.writeFileSync(turboJsonPath, backup); - } - }); - - it("should detect changes across different workspace turbo.json files", () => { - const webTurboJsonPath = path.join(cwd, "apps/web/turbo.json"); - let originalWebTurboJson: string | null = null; - - try { - originalWebTurboJson = fs.readFileSync(webTurboJsonPath, "utf8"); - } catch { - // File might not exist - } - - try { - // First lint - WEB_CUSTOM_VAR should fail - const firstResults = linter.verify( - "const { WEB_CUSTOM_VAR } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(firstResults).toHaveLength(1); - - // Create/modify workspace turbo.json - const webConfig: SchemaV1 = { - extends: ["//"], - pipeline: { - build: { - env: ["WEB_CUSTOM_VAR"], - }, - }, - }; - fs.writeFileSync(webTurboJsonPath, JSON.stringify(webConfig, null, 2)); - - // Should detect the workspace config change - const secondResults = linter.verify( - "const { WEB_CUSTOM_VAR } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(secondResults).toHaveLength(0); - } finally { - // Cleanup - if (originalWebTurboJson) { - fs.writeFileSync(webTurboJsonPath, originalWebTurboJson); - } else { - try { - fs.unlinkSync(webTurboJsonPath); - } catch { - // File might not exist - } - } - } - }); - - it("should detect content changes through multiple modifications", () => { - const backup = fs.readFileSync(turboJsonPath, "utf8"); - - try { - // First lint with original config (ENV_2 is valid in apps/web) - const firstResults = linter.verify( - "const { ENV_2 } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(firstResults).toHaveLength(0); - - // Modify config to add NEW_VAR - const modifiedConfig = { - ...(JSON.parse(backup) as Record), - globalEnv: ["CI", "NEW_VAR"], - }; - fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); - - // NEW_VAR should now be valid - const secondResults = linter.verify( - "const { NEW_VAR } = process.env;", - { - rules: { - "turbo/no-undeclared-env-vars": ["error", { cwd }], - }, - parserOptions: { ecmaVersion: 2020 }, - }, - { filename: webFilename } - ); - - expect(secondResults).toHaveLength(0); - } finally { - // Always restore, even if test fails - fs.writeFileSync(turboJsonPath, backup); - } - }); -}); diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts deleted file mode 100644 index f414ed17161d1..0000000000000 --- a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; -import { RuleTester } from "eslint"; -import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; -import type { SchemaV1 } from "@turbo/types"; -import { RULES } from "../../../../lib/constants"; -import rule, { clearCache } from "../../../../lib/rules/no-undeclared-env-vars"; -import { Project } from "../../../../lib/utils/calculate-inputs"; - -const ruleTester = new RuleTester({ - parserOptions: { ecmaVersion: 2020, sourceType: "module" }, -}); - -const cwd = path.join(__dirname, "../../../../__fixtures__/workspace-configs"); -const webFilename = path.join(cwd, "/apps/web/index.js"); - -describe("Project reload functionality", () => { - let project: Project; - let originalTurboJson: string; - - beforeEach(() => { - // Store original turbo.json content for restoration - const turboJsonPath = path.join(cwd, "turbo.json"); - originalTurboJson = fs.readFileSync(turboJsonPath, "utf8"); - - // Clear cache AFTER reading original, so we don't cache corrupted state - clearCache(); - project = new Project(cwd); - }); - - afterEach(() => { - // Restore original turbo.json content FIRST - const turboJsonPath = path.join(cwd, "turbo.json"); - fs.writeFileSync(turboJsonPath, originalTurboJson); - - // Force reload to pick up the restored original - // Wrap in try/catch to ensure cache clearing always happens - try { - project.reload(); - } catch (e) { - // Ignore reload errors in cleanup - } - - // Clear ALL caches to ensure no pollution to other tests - clearCache(); - }); - - it("should reload workspace configurations when called", () => { - const initialConfigs = [...project.allConfigs]; - - // Call reload - project.reload(); - - // Verify that configurations were reloaded - expect(project.allConfigs).not.toBe(initialConfigs); - expect(project.allConfigs.length).toBe(initialConfigs.length); - - // Verify that project root and workspaces were updated - expect(project.projectRoot).toBeDefined(); - expect(project.projectWorkspaces.length).toBeGreaterThan(0); - }); - - it("should regenerate key and test configurations after reload", () => { - const initialKey = project._key; - const initialTest = project._test; - - // Call reload - project.reload(); - - // Verify that key and test configurations were regenerated - expect(project._key).not.toBe(initialKey); - expect(project._test).not.toBe(initialTest); - }); - - it("should detect changes in turbo.json after reload", () => { - const turboJsonPath = path.join(cwd, "turbo.json"); - const initialConfig = project.projectRoot?.turboConfig; - - // Modify turbo.json - const modifiedConfig: SchemaV1 = { - ...(JSON.parse(originalTurboJson) as SchemaV1), - pipeline: { - ...(JSON.parse(originalTurboJson) as SchemaV1).pipeline, - newTask: { - outputs: [], - }, - }, - }; - fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); - - // Call reload - project.reload(); - - // Verify that the new configuration is loaded - expect(project.projectRoot?.turboConfig).not.toEqual(initialConfig); - expect(project.projectRoot?.turboConfig).toEqual(modifiedConfig); - }); - - it("should handle invalid turbo.json gracefully", () => { - const turboJsonPath = path.join(cwd, "turbo.json"); - - // Write invalid JSON - fs.writeFileSync(turboJsonPath, "invalid json"); - - // Call reload - should not throw - expect(() => { - project.reload(); - }).not.toThrow(); - - // Verify that the project still has a valid state - expect(project.projectRoot).toBeDefined(); - expect(project.projectWorkspaces.length).toBeGreaterThan(0); - }); - - it("should maintain consistent state after multiple reloads", () => { - const initialConfigs = [...project.allConfigs]; - - // Perform multiple reloads - project.reload(); - project.reload(); - project.reload(); - - // Verify that the final state is consistent - expect(project.allConfigs.length).toBe(initialConfigs.length); - expect(project.projectRoot).toBeDefined(); - expect(project.projectWorkspaces.length).toBeGreaterThan(0); - }); -}); - -// Test that the reload functionality works with the ESLint rule -ruleTester.run(RULES.noUndeclaredEnvVars, rule, { - valid: [ - { - code: ` - const { ENV_2 } = import.meta.env; - `, - options: [{ cwd }], - filename: webFilename, - }, - ], - invalid: [ - { - code: ` - const { ENV_3 } = import.meta.env; - `, - options: [{ cwd }], - filename: webFilename, - errors: [ - { - message: - "ENV_3 is not listed as a dependency in the root turbo.json or workspace (apps/web) turbo.json", - }, - ], - }, - ], -}); diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 207493afa9684..8f003d913fce6 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -94,7 +94,6 @@ function normalizeCwd( /** for a given `package.json` file path, this will compile a Set of that package's listed dependencies */ const packageJsonDependencies = (filePath: string): Set => { - // Check cache first const cached = packageJsonDepCache.get(filePath); if (cached) { return cached; @@ -174,7 +173,7 @@ function getTurboConfigPaths(project: Project): Array { } /** - * Compute SHA256 hashes for all turbo config files + * Compute hashes for all turbo.config(c) files */ function computeTurboConfigHashes( configPaths: Array @@ -182,21 +181,16 @@ function computeTurboConfigHashes( const hashes = new Map(); for (const configPath of configPaths) { - try { - const content = fs.readFileSync(configPath, "utf-8"); - const hash = crypto.createHash("md5").update(content).digest("hex"); - hashes.set(configPath, hash); - } catch (e) { - // If we can't read the file, use an empty hash - hashes.set(configPath, ""); - } + const content = fs.readFileSync(configPath, "utf-8"); + const hash = crypto.createHash("md5").update(content).digest("hex"); + hashes.set(configPath, hash); } return hashes; } /** - * Check if any turbo config files have changed by comparing hashes + * Compare hashes to see if a turbo.config(c) has changed */ function haveTurboConfigsChanged( oldHashes: Map, @@ -309,7 +303,6 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { let project: Project; if (!cachedProject) { - // No cached project, create a new one project = new Project(cwd); if (project.valid()) { const configPaths = getTurboConfigPaths(project); @@ -322,7 +315,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { debug(`Cached new project for ${projectKey}`); } } else { - // We have a cached project, check if turbo configs have changed + // We have a cached project, check if turbo.json(c) configs have changed project = cachedProject.project; const newHashes = computeTurboConfigHashes(cachedProject.configPaths); @@ -452,11 +445,6 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { /** * Clear all module-level caches. This is primarily useful for test isolation. - * - * Note: The Project instance cache is reused across files for performance. - * Changes to turbo.json files are detected via hash comparison, and project.reload() - * is automatically called when changes are detected to ensure turbo.json changes - * are picked up immediately for live IDE feedback. */ export function clearCache(): void { projectCache.clear(); diff --git a/packages/turbo-utils/src/getTurboConfigs.ts b/packages/turbo-utils/src/getTurboConfigs.ts index ab166e8a2e771..d38146100c1d3 100644 --- a/packages/turbo-utils/src/getTurboConfigs.ts +++ b/packages/turbo-utils/src/getTurboConfigs.ts @@ -296,9 +296,11 @@ export function forEachTaskDef( export function clearConfigCaches(): void { Object.keys(turboConfigsCache).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- This is safe. delete turboConfigsCache[key]; }); Object.keys(workspaceConfigCache).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- This is safe. delete workspaceConfigCache[key]; }); } From be0c04457b3af072d6a54957ac0dc334b0c75228 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 7 Oct 2025 22:00:46 -0600 Subject: [PATCH 08/11] WIP 91d65 --- .../workspace-configs/reload.test.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts diff --git a/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts new file mode 100644 index 0000000000000..f9dfc550758e6 --- /dev/null +++ b/packages/eslint-plugin-turbo/__tests__/lib/no-undeclared-env-vars/workspace-configs/reload.test.ts @@ -0,0 +1,142 @@ +import path from "node:path"; +import fs from "node:fs"; +import { RuleTester } from "eslint"; +import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; +import type { SchemaV1 } from "@turbo/types"; +import { RULES } from "../../../../lib/constants"; +import rule from "../../../../lib/rules/no-undeclared-env-vars"; +import { Project } from "../../../../lib/utils/calculate-inputs"; + +const ruleTester = new RuleTester({ + parserOptions: { ecmaVersion: 2020, sourceType: "module" }, +}); + +const cwd = path.join(__dirname, "../../../../__fixtures__/workspace-configs"); +const webFilename = path.join(cwd, "/apps/web/index.js"); + +describe("Project reload functionality", () => { + let project: Project; + let originalTurboJson: string; + + beforeEach(() => { + project = new Project(cwd); + // Store original turbo.json content for restoration + const turboJsonPath = path.join(cwd, "turbo.json"); + originalTurboJson = fs.readFileSync(turboJsonPath, "utf8"); + }); + + afterEach(() => { + // Restore original turbo.json content + const turboJsonPath = path.join(cwd, "turbo.json"); + fs.writeFileSync(turboJsonPath, originalTurboJson); + }); + + it("should reload workspace configurations when called", () => { + const initialConfigs = [...project.allConfigs]; + + // Call reload + project.reload(); + + // Verify that configurations were reloaded + expect(project.allConfigs).not.toBe(initialConfigs); + expect(project.allConfigs.length).toBe(initialConfigs.length); + + // Verify that project root and workspaces were updated + expect(project.projectRoot).toBeDefined(); + expect(project.projectWorkspaces.length).toBeGreaterThan(0); + }); + + it("should regenerate key and test configurations after reload", () => { + const initialKey = project._key; + const initialTest = project._test; + + // Call reload + project.reload(); + + // Verify that key and test configurations were regenerated + expect(project._key).not.toBe(initialKey); + expect(project._test).not.toBe(initialTest); + }); + + it("should detect changes in turbo.json after reload", () => { + const turboJsonPath = path.join(cwd, "turbo.json"); + const initialConfig = project.projectRoot?.turboConfig; + + // Modify turbo.json + const modifiedConfig: SchemaV1 = { + ...(JSON.parse(originalTurboJson) as SchemaV1), + pipeline: { + ...(JSON.parse(originalTurboJson) as SchemaV1).pipeline, + newTask: { + outputs: [], + }, + }, + }; + fs.writeFileSync(turboJsonPath, JSON.stringify(modifiedConfig, null, 2)); + + // Call reload + project.reload(); + + // Verify that the new configuration is loaded + expect(project.projectRoot?.turboConfig).not.toEqual(initialConfig); + expect(project.projectRoot?.turboConfig).toEqual(modifiedConfig); + }); + + it("should handle invalid turbo.json gracefully", () => { + const turboJsonPath = path.join(cwd, "turbo.json"); + + // Write invalid JSON + fs.writeFileSync(turboJsonPath, "invalid json"); + + // Call reload - should not throw + expect(() => { + project.reload(); + }).not.toThrow(); + + // Verify that the project still has a valid state + expect(project.projectRoot).toBeDefined(); + expect(project.projectWorkspaces.length).toBeGreaterThan(0); + }); + + it("should maintain consistent state after multiple reloads", () => { + const initialConfigs = [...project.allConfigs]; + + // Perform multiple reloads + project.reload(); + project.reload(); + project.reload(); + + // Verify that the final state is consistent + expect(project.allConfigs.length).toBe(initialConfigs.length); + expect(project.projectRoot).toBeDefined(); + expect(project.projectWorkspaces.length).toBeGreaterThan(0); + }); +}); + +// Test that the reload functionality works with the ESLint rule +ruleTester.run(RULES.noUndeclaredEnvVars, rule, { + valid: [ + { + code: ` + const { ENV_2 } = import.meta.env; + `, + options: [{ cwd }], + filename: webFilename, + }, + ], + invalid: [ + { + code: ` + const { ENV_3 } = import.meta.env; + `, + options: [{ cwd }], + filename: webFilename, + errors: [ + { + message: + "ENV_3 is not listed as a dependency in the root turbo.json or workspace (apps/web) turbo.json", + }, + ], + }, + ], +}); From e3cefdf5d985557766553668a72cab509eb2b1f6 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 8 Oct 2025 06:04:45 -0600 Subject: [PATCH 09/11] Update packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../lib/rules/no-undeclared-env-vars.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 8f003d913fce6..5037da7ff64d0 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -317,9 +317,20 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { } else { // We have a cached project, check if turbo.json(c) configs have changed project = cachedProject.project; - const newHashes = computeTurboConfigHashes(cachedProject.configPaths); + let shouldReload = false; - if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { + try { + const newHashes = computeTurboConfigHashes(cachedProject.configPaths); + if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { + shouldReload = true; + } + } catch (error) { + // Config file was deleted or is unreadable, need to reload + debug(`Error computing hashes for ${projectKey}, reloading...`); + shouldReload = true; + } + + if (shouldReload) { debug(`Turbo config changed for ${projectKey}, reloading...`); project.reload(); const configPaths = getTurboConfigPaths(project); From 0dede1e3ae9d39df9dbee51cdd2120185e8820ef Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 8 Oct 2025 06:51:25 -0600 Subject: [PATCH 10/11] WIP 0563b --- .../lib/rules/no-undeclared-env-vars.ts | 156 +++++++++++------- 1 file changed, 100 insertions(+), 56 deletions(-) diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index 5037da7ff64d0..e565cd88e39e6 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -137,34 +137,41 @@ const packageJsonDependencies = (filePath: string): Set => { }; /** - * Get all turbo config file paths for a project (root + workspace configs) + * Find turbo.json or turbo.jsonc in a directory if it exists + */ +function findTurboConfigInDir(dirPath: string): string | null { + const turboJsonPath = path.join(dirPath, "turbo.json"); + const turboJsoncPath = path.join(dirPath, "turbo.jsonc"); + + if (fs.existsSync(turboJsonPath)) { + return turboJsonPath; + } + if (fs.existsSync(turboJsoncPath)) { + return turboJsoncPath; + } + return null; +} + +/** + * Get all turbo config file paths that are currently loaded in the project */ function getTurboConfigPaths(project: Project): Array { const paths: Array = []; - // Add root turbo config if it exists + // Add root turbo config if it exists and is loaded if (project.projectRoot?.turboConfig) { - const rootPath = project.projectRoot.workspacePath; - const turboJsonPath = path.join(rootPath, "turbo.json"); - const turboJsoncPath = path.join(rootPath, "turbo.jsonc"); - - if (fs.existsSync(turboJsonPath)) { - paths.push(turboJsonPath); - } else if (fs.existsSync(turboJsoncPath)) { - paths.push(turboJsoncPath); + const configPath = findTurboConfigInDir(project.projectRoot.workspacePath); + if (configPath) { + paths.push(configPath); } } - // Add workspace turbo configs + // Add workspace turbo configs that are loaded for (const workspace of project.projectWorkspaces) { if (workspace.turboConfig) { - const turboJsonPath = path.join(workspace.workspacePath, "turbo.json"); - const turboJsoncPath = path.join(workspace.workspacePath, "turbo.jsonc"); - - if (fs.existsSync(turboJsonPath)) { - paths.push(turboJsonPath); - } else if (fs.existsSync(turboJsoncPath)) { - paths.push(turboJsoncPath); + const configPath = findTurboConfigInDir(workspace.workspacePath); + if (configPath) { + paths.push(configPath); } } } @@ -172,6 +179,33 @@ function getTurboConfigPaths(project: Project): Array { return paths; } +/** + * Scan filesystem for all turbo.json/turbo.jsonc files across all workspaces. + * This scans ALL workspaces regardless of whether they currently have turboConfig loaded, + * allowing detection of newly created turbo.json files. + */ +function scanForTurboConfigs(project: Project): Array { + const paths: Array = []; + + // Check root turbo config + if (project.projectRoot) { + const configPath = findTurboConfigInDir(project.projectRoot.workspacePath); + if (configPath) { + paths.push(configPath); + } + } + + // Check ALL workspaces for turbo configs (not just those with turboConfig already loaded) + for (const workspace of project.projectWorkspaces) { + const configPath = findTurboConfigInDir(workspace.workspacePath); + if (configPath) { + paths.push(configPath); + } + } + + return paths; +} + /** * Compute hashes for all turbo.config(c) files */ @@ -190,26 +224,17 @@ function computeTurboConfigHashes( } /** - * Compare hashes to see if a turbo.config(c) has changed + * Check if a single config file has changed by comparing its hash */ -function haveTurboConfigsChanged( - oldHashes: Map, - newHashes: Map -): boolean { - // Check if the set of files has changed - if (oldHashes.size !== newHashes.size) { +function hasConfigChanged(filePath: string, expectedHash: string): boolean { + try { + const content = fs.readFileSync(filePath, "utf-8"); + const currentHash = crypto.createHash("md5").update(content).digest("hex"); + return currentHash !== expectedHash; + } catch { + // File no longer exists or is unreadable return true; } - - // Check if any file content has changed - for (const [filePath, newHash] of newHashes) { - const oldHash = oldHashes.get(filePath); - if (oldHash !== newHash) { - return true; - } - } - - return false; } /** @@ -280,7 +305,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { } }); - const filename = context.getFilename(); + const filename = context.filename; debug(`Checking file: ${filename}`); const matches = frameworkEnvMatches(filename); @@ -291,11 +316,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { }` ); - const cwd = normalizeCwd( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed to support older eslint versions - context.getCwd ? context.getCwd() : undefined, - options - ); + const cwd = normalizeCwd(context.cwd ? context.cwd : undefined, options); // Use cached Project instance to avoid expensive re-initialization for every file const projectKey = cwd ?? process.cwd(); @@ -315,27 +336,50 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { debug(`Cached new project for ${projectKey}`); } } else { - // We have a cached project, check if turbo.json(c) configs have changed project = cachedProject.project; - let shouldReload = false; + // Check if any turbo.json(c) configs have changed try { - const newHashes = computeTurboConfigHashes(cachedProject.configPaths); - if (haveTurboConfigsChanged(cachedProject.turboConfigHashes, newHashes)) { - shouldReload = true; + const currentConfigPaths = scanForTurboConfigs(project); + + // Quick path comparison - cheapest check first + const pathsUnchanged = + currentConfigPaths.length === cachedProject.configPaths.length && + currentConfigPaths.every((p, i) => p === cachedProject.configPaths[i]); + + if (!pathsUnchanged) { + // Paths changed (added/removed configs), must reload + debug(`Turbo config paths changed for ${projectKey}, reloading...`); + const newHashes = computeTurboConfigHashes(currentConfigPaths); + project.reload(); + cachedProject.turboConfigHashes = newHashes; + cachedProject.configPaths = currentConfigPaths; + } else { + // Paths unchanged - check if any file content changed (early exit on first change) + let contentChanged = false; + for (const [ + filePath, + expectedHash, + ] of cachedProject.turboConfigHashes) { + if (hasConfigChanged(filePath, expectedHash)) { + contentChanged = true; + break; + } + } + + if (contentChanged) { + debug(`Turbo config content changed for ${projectKey}, reloading...`); + const newHashes = computeTurboConfigHashes(currentConfigPaths); + project.reload(); + cachedProject.turboConfigHashes = newHashes; + } } } catch (error) { - // Config file was deleted or is unreadable, need to reload + // Config file was deleted or is unreadable, reload project debug(`Error computing hashes for ${projectKey}, reloading...`); - shouldReload = true; - } - - if (shouldReload) { - debug(`Turbo config changed for ${projectKey}, reloading...`); project.reload(); - const configPaths = getTurboConfigPaths(project); - const reloadedHashes = computeTurboConfigHashes(configPaths); - cachedProject.turboConfigHashes = reloadedHashes; + const configPaths = scanForTurboConfigs(project); + cachedProject.turboConfigHashes = computeTurboConfigHashes(configPaths); cachedProject.configPaths = configPaths; } } @@ -344,7 +388,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { return {}; } - const filePath = context.getPhysicalFilename(); + const filePath = context.physicalFilename; const hasWorkspaceConfigs = project.projectWorkspaces.some( (workspaceConfig) => Boolean(workspaceConfig.turboConfig) ); From 6aeda27afa8aee01eda550c0913b91415139118e Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 8 Oct 2025 07:32:21 -0600 Subject: [PATCH 11/11] buggy boi --- packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts index e565cd88e39e6..dd86e45823b92 100644 --- a/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts +++ b/packages/eslint-plugin-turbo/lib/rules/no-undeclared-env-vars.ts @@ -372,6 +372,7 @@ function create(context: RuleContextWithOptions): Rule.RuleListener { const newHashes = computeTurboConfigHashes(currentConfigPaths); project.reload(); cachedProject.turboConfigHashes = newHashes; + cachedProject.configPaths = currentConfigPaths; } } } catch (error) {