From 2beae82c68bcdcaf3f4137cd8b4f982b41e26e55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 22:57:47 +0000 Subject: [PATCH 1/4] Initial plan From eff6731153044b4df3b1b99d0f7a311f2f5d5862 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 23:11:39 +0000 Subject: [PATCH 2/4] Stabilize hash suffix in linter cache file based on tsconfig path Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../heft-lint-plugin/src/LintPlugin.ts | 21 +++++++--- .../heft-lint-plugin/src/LinterBase.ts | 39 ++++++++++++++++--- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 74a4e384d9..79b29bb9ba 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -39,6 +39,7 @@ interface ILintOptions { taskSession: IHeftTaskSession; heftConfiguration: HeftConfiguration; tsProgram: IExtendedProgram; + tsconfigFilePath: string; fix?: boolean; sarifLogPath?: string; changedFiles?: ReadonlySet; @@ -104,7 +105,8 @@ export default class LintPlugin implements IHeftTaskPlugin { let inTypescriptPhase: boolean = false; // Use the changed files hook to collect the files and programs from TypeScript - let typescriptChangedFiles: [IExtendedProgram, ReadonlySet][] = []; + // Also track the tsconfig path for cache file naming + let typescriptChangedFiles: [IExtendedProgram, ReadonlySet, string][] = []; taskSession.requestAccessToPluginByName( TYPESCRIPT_PLUGIN_PACKAGE_NAME, TYPESCRIPT_PLUGIN_NAME, @@ -114,9 +116,13 @@ export default class LintPlugin implements IHeftTaskPlugin { // Hook into the changed files hook to collect the changed files and their programs accessor.onChangedFilesHook.tap(PLUGIN_NAME, (changedFilesHookOptions: IChangedFilesHookOptions) => { + // When using the TypeScript plugin, we need to determine the tsconfig path + // The default tsconfig path is used when not explicitly specified + const tsconfigPath: string = path.resolve(heftConfiguration.buildFolderPath, 'tsconfig.json'); typescriptChangedFiles.push([ changedFilesHookOptions.program as IExtendedProgram, - changedFilesHookOptions.changedFiles as ReadonlySet + changedFilesHookOptions.changedFiles as ReadonlySet, + tsconfigPath ]); }); } @@ -126,20 +132,22 @@ export default class LintPlugin implements IHeftTaskPlugin { // If we are not in the typescript phase, we need to create a typescript program // from the tsconfig file if (!inTypescriptPhase) { + const tsconfigPath: string = path.resolve(heftConfiguration.buildFolderPath, 'tsconfig.json'); const tsProgram: IExtendedProgram = await this._createTypescriptProgramAsync( heftConfiguration, taskSession ); - typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles())]); + typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles()), tsconfigPath]); } // Run the linters to completion. Linters emit errors and warnings to the logger. - for (const [tsProgram, changedFiles] of typescriptChangedFiles) { + for (const [tsProgram, changedFiles, tsconfigFilePath] of typescriptChangedFiles) { try { await this._lintAsync({ taskSession, heftConfiguration, tsProgram, + tsconfigFilePath, changedFiles, fix, sarifLogPath @@ -222,7 +230,8 @@ export default class LintPlugin implements IHeftTaskPlugin { } private async _lintAsync(options: ILintOptions): Promise { - const { taskSession, heftConfiguration, tsProgram, changedFiles, fix, sarifLogPath } = options; + const { taskSession, heftConfiguration, tsProgram, tsconfigFilePath, changedFiles, fix, sarifLogPath } = + options; // Ensure that we have initialized. This promise is cached, so calling init // multiple times will only init once. @@ -232,6 +241,7 @@ export default class LintPlugin implements IHeftTaskPlugin { if (this._eslintConfigFilePath && this._eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, + tsconfigFilePath, fix, sarifLogPath, scopedLogger: taskSession.logger, @@ -246,6 +256,7 @@ export default class LintPlugin implements IHeftTaskPlugin { if (this._tslintConfigFilePath && this._tslintToolPath) { const tslintLinter: Tslint = await Tslint.initializeAsync({ tsProgram, + tsconfigFilePath, fix, scopedLogger: taskSession.logger, linterToolPath: this._tslintToolPath, diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 1c98149600..df6c45c5a4 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -21,6 +21,7 @@ export interface ILinterBaseOptions { linterToolPath: string; linterConfigFilePath: string; tsProgram: IExtendedProgram; + tsconfigFilePath: string; fix?: boolean; sarifLogPath?: string; } @@ -51,6 +52,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 { @@ -59,6 +66,7 @@ export abstract class LinterBase { protected readonly _buildFolderPath: string; protected readonly _buildMetadataFolderPath: string; protected readonly _linterConfigFilePath: string; + protected readonly _tsconfigFilePath: string; protected readonly _fix: boolean; protected _fixesPossible: boolean = false; @@ -71,6 +79,7 @@ export abstract class LinterBase { this._buildFolderPath = options.buildFolderPath; this._buildMetadataFolderPath = options.buildMetadataFolderPath; this._linterConfigFilePath = options.linterConfigFilePath; + this._tsconfigFilePath = options.tsconfigFilePath; this._linterName = linterName; this._fix = options.fix || false; } @@ -85,14 +94,31 @@ export abstract class LinterBase { const relativePaths: Map = new Map(); - const fileHash: Hash = createHash('md5'); + // Calculate the hash of the list of filenames for verification purposes + const filesHash: Hash = createHash('md5'); 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); + filesHash.update(relative); } - const hashSuffix: string = fileHash.digest('base64').replace(/\+/g, '-').replace(/\//g, '_').slice(0, 8); + const filesHashString: string = filesHash + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .slice(0, 8); + + // Calculate the hash suffix based on the project-relative path of the tsconfig file + const relativeTsconfigPath: string = Path.convertToSlashes( + path.relative(this._buildFolderPath, this._tsconfigFilePath) + ); + const tsconfigHash: Hash = createHash('md5'); + tsconfigHash.update(relativeTsconfigPath); + const hashSuffix: string = tsconfigHash + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .slice(0, 8); const linterCacheVersion: string = await this.getCacheVersionAsync(); const linterCacheFilePath: string = path.resolve( @@ -121,7 +147,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 +200,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 }); From ba0baea84ef9cd387cd59e58e4d7fb1d88272043 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 01:29:42 +0000 Subject: [PATCH 3/4] Address feedback: use program.getCompilerOptions().configFilePath, base64url encoding, and sort files Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- .../heft-lint-plugin/src/LintPlugin.ts | 21 ++------ .../heft-lint-plugin/src/LinterBase.ts | 48 +++++++++++-------- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 79b29bb9ba..74a4e384d9 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -39,7 +39,6 @@ interface ILintOptions { taskSession: IHeftTaskSession; heftConfiguration: HeftConfiguration; tsProgram: IExtendedProgram; - tsconfigFilePath: string; fix?: boolean; sarifLogPath?: string; changedFiles?: ReadonlySet; @@ -105,8 +104,7 @@ export default class LintPlugin implements IHeftTaskPlugin { let inTypescriptPhase: boolean = false; // Use the changed files hook to collect the files and programs from TypeScript - // Also track the tsconfig path for cache file naming - let typescriptChangedFiles: [IExtendedProgram, ReadonlySet, string][] = []; + let typescriptChangedFiles: [IExtendedProgram, ReadonlySet][] = []; taskSession.requestAccessToPluginByName( TYPESCRIPT_PLUGIN_PACKAGE_NAME, TYPESCRIPT_PLUGIN_NAME, @@ -116,13 +114,9 @@ export default class LintPlugin implements IHeftTaskPlugin { // Hook into the changed files hook to collect the changed files and their programs accessor.onChangedFilesHook.tap(PLUGIN_NAME, (changedFilesHookOptions: IChangedFilesHookOptions) => { - // When using the TypeScript plugin, we need to determine the tsconfig path - // The default tsconfig path is used when not explicitly specified - const tsconfigPath: string = path.resolve(heftConfiguration.buildFolderPath, 'tsconfig.json'); typescriptChangedFiles.push([ changedFilesHookOptions.program as IExtendedProgram, - changedFilesHookOptions.changedFiles as ReadonlySet, - tsconfigPath + changedFilesHookOptions.changedFiles as ReadonlySet ]); }); } @@ -132,22 +126,20 @@ export default class LintPlugin implements IHeftTaskPlugin { // If we are not in the typescript phase, we need to create a typescript program // from the tsconfig file if (!inTypescriptPhase) { - const tsconfigPath: string = path.resolve(heftConfiguration.buildFolderPath, 'tsconfig.json'); const tsProgram: IExtendedProgram = await this._createTypescriptProgramAsync( heftConfiguration, taskSession ); - typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles()), tsconfigPath]); + typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles())]); } // Run the linters to completion. Linters emit errors and warnings to the logger. - for (const [tsProgram, changedFiles, tsconfigFilePath] of typescriptChangedFiles) { + for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { await this._lintAsync({ taskSession, heftConfiguration, tsProgram, - tsconfigFilePath, changedFiles, fix, sarifLogPath @@ -230,8 +222,7 @@ export default class LintPlugin implements IHeftTaskPlugin { } private async _lintAsync(options: ILintOptions): Promise { - const { taskSession, heftConfiguration, tsProgram, tsconfigFilePath, changedFiles, fix, sarifLogPath } = - options; + const { taskSession, heftConfiguration, tsProgram, changedFiles, fix, sarifLogPath } = options; // Ensure that we have initialized. This promise is cached, so calling init // multiple times will only init once. @@ -241,7 +232,6 @@ export default class LintPlugin implements IHeftTaskPlugin { if (this._eslintConfigFilePath && this._eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, - tsconfigFilePath, fix, sarifLogPath, scopedLogger: taskSession.logger, @@ -256,7 +246,6 @@ export default class LintPlugin implements IHeftTaskPlugin { if (this._tslintConfigFilePath && this._tslintToolPath) { const tslintLinter: Tslint = await Tslint.initializeAsync({ tsProgram, - tsconfigFilePath, fix, scopedLogger: taskSession.logger, linterToolPath: this._tslintToolPath, diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index df6c45c5a4..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'; @@ -21,7 +23,6 @@ export interface ILinterBaseOptions { linterToolPath: string; linterConfigFilePath: string; tsProgram: IExtendedProgram; - tsconfigFilePath: string; fix?: boolean; sarifLogPath?: string; } @@ -66,7 +67,6 @@ export abstract class LinterBase { protected readonly _buildFolderPath: string; protected readonly _buildMetadataFolderPath: string; protected readonly _linterConfigFilePath: string; - protected readonly _tsconfigFilePath: string; protected readonly _fix: boolean; protected _fixesPossible: boolean = false; @@ -79,7 +79,6 @@ export abstract class LinterBase { this._buildFolderPath = options.buildFolderPath; this._buildMetadataFolderPath = options.buildMetadataFolderPath; this._linterConfigFilePath = options.linterConfigFilePath; - this._tsconfigFilePath = options.tsconfigFilePath; this._linterName = linterName; this._fix = options.fix || false; } @@ -94,31 +93,40 @@ export abstract class LinterBase { const relativePaths: Map = new Map(); - // Calculate the hash of the list of filenames for verification purposes - const filesHash: 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); + 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('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .slice(0, 8); + const filesHashString: string = filesHash.digest('base64url'); // Calculate the hash suffix based on the project-relative path of the tsconfig file - const relativeTsconfigPath: string = Path.convertToSlashes( - path.relative(this._buildFolderPath, this._tsconfigFilePath) - ); - const tsconfigHash: Hash = createHash('md5'); - tsconfigHash.update(relativeTsconfigPath); - const hashSuffix: string = tsconfigHash - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .slice(0, 8); + // 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 linterCacheVersion: string = await this.getCacheVersionAsync(); const linterCacheFilePath: string = path.resolve( From 40868de10b53e57f32551cf0efde7d655b77add9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:32:41 +0000 Subject: [PATCH 4/4] Add rush change file for heft-lint-plugin patch Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --- ...-stabilize-linter-cache-hash_2025-11-26-23-32.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/heft-lint-plugin/copilot-stabilize-linter-cache-hash_2025-11-26-23-32.json 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