diff --git a/common/changes/@rushstack/heft-lint-plugin/copilot-stabilize-linter-cache-hash_2025-11-26-23-32.json b/common/changes/@rushstack/heft-lint-plugin/copilot-stabilize-linter-cache-hash_2025-11-26-23-32.json new file mode 100644 index 0000000000..56daa275dc --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/copilot-stabilize-linter-cache-hash_2025-11-26-23-32.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Stabilize the hash suffix in the linter cache file by using tsconfig path hash instead of file list hash", + "type": "patch", + "packageName": "@rushstack/heft-lint-plugin" + } + ], + "packageName": "@rushstack/heft-lint-plugin", + "email": "198982749+Copilot@users.noreply.github.com" +} \ No newline at end of file diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 1c98149600..00f0d32982 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -5,6 +5,8 @@ import * as path from 'node:path'; import { performance } from 'node:perf_hooks'; import { createHash, type Hash } from 'node:crypto'; +import type * as TTypescript from 'typescript'; + import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import type { IScopedLogger } from '@rushstack/heft'; @@ -51,6 +53,12 @@ interface ILinterCacheData { * each array item is the file's path and the second element is the file's hash. */ fileVersions: [string, string][]; + + /** + * A hash of the list of filenames that were linted. This is used to verify that + * the cache was run with the same files. + */ + filesHash?: string; } export abstract class LinterBase { @@ -85,14 +93,40 @@ export abstract class LinterBase { const relativePaths: Map = new Map(); - const fileHash: Hash = createHash('md5'); + // Collect and sort file paths for stable hashing + const relativePathsArray: string[] = []; for (const file of options.typeScriptFilenames) { // Need to use relative paths to ensure portability. const relative: string = Path.convertToSlashes(path.relative(commonDirectory, file)); relativePaths.set(file, relative); - fileHash.update(relative); + relativePathsArray.push(relative); + } + relativePathsArray.sort(); + + // Calculate the hash of the list of filenames for verification purposes + const filesHash: Hash = createHash('md5'); + for (const relative of relativePathsArray) { + filesHash.update(relative); + } + const filesHashString: string = filesHash.digest('base64url'); + + // Calculate the hash suffix based on the project-relative path of the tsconfig file + // Extract the config file path from the program's compiler options + const compilerOptions: TTypescript.CompilerOptions = options.tsProgram.getCompilerOptions(); + const tsconfigFilePath: string | undefined = compilerOptions.configFilePath as string | undefined; + + let hashSuffix: string; + if (tsconfigFilePath) { + const relativeTsconfigPath: string = Path.convertToSlashes( + path.relative(this._buildFolderPath, tsconfigFilePath) + ); + const tsconfigHash: Hash = createHash('md5'); + tsconfigHash.update(relativeTsconfigPath); + hashSuffix = tsconfigHash.digest('base64url').slice(0, 8); + } else { + // Fallback to a default hash if configFilePath is not available + hashSuffix = 'default'; } - const hashSuffix: string = fileHash.digest('base64').replace(/\+/g, '-').replace(/\//g, '_').slice(0, 8); const linterCacheVersion: string = await this.getCacheVersionAsync(); const linterCacheFilePath: string = path.resolve( @@ -121,7 +155,9 @@ export abstract class LinterBase { } const cachedNoFailureFileVersions: Map = new Map( - linterCacheData?.cacheVersion === linterCacheVersion ? linterCacheData.fileVersions : [] + linterCacheData?.cacheVersion === linterCacheVersion && linterCacheData?.filesHash === filesHashString + ? linterCacheData.fileVersions + : [] ); const newNoFailureFileVersions: Map = new Map(); @@ -172,7 +208,8 @@ export abstract class LinterBase { const updatedTslintCacheData: ILinterCacheData = { cacheVersion: linterCacheVersion, - fileVersions: Array.from(newNoFailureFileVersions) + fileVersions: Array.from(newNoFailureFileVersions), + filesHash: filesHashString }; await JsonFile.saveAsync(updatedTslintCacheData, linterCacheFilePath, { ensureFolderExists: true });