From 0fc5e0444e0a25ee45b254a23e5360774eb8dab9 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Thu, 15 Oct 2026 18:00:26 -0400 Subject: [PATCH] Allow caching outputless plugin operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...plugin-operations_2026-10-15-21-41-39.json | 11 +++ common/reviews/api/rush-lib.api.md | 2 + .../src/api/RushProjectConfiguration.ts | 88 +++++++++++++++---- .../api/test/RushProjectConfiguration.test.ts | 71 +++++++++++++-- .../src/logic/operations/BuildPlanPlugin.ts | 4 +- .../operations/CacheableOperationPlugin.ts | 24 ++--- .../test/CacheableOperationPlugin.test.ts | 35 ++++++++ 7 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 common/changes/@microsoft/rush/fix-cache-outputless-plugin-operations_2026-10-15-21-41-39.json diff --git a/common/changes/@microsoft/rush/fix-cache-outputless-plugin-operations_2026-10-15-21-41-39.json b/common/changes/@microsoft/rush/fix-cache-outputless-plugin-operations_2026-10-15-21-41-39.json new file mode 100644 index 00000000000..b5c9100bce4 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-cache-outputless-plugin-operations_2026-10-15-21-41-39.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Allow plugin-defined outputless operations to use the build cache without requiring rush-project.json.", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index f79cfa2a80d..a85fdb820e4 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -2087,6 +2087,8 @@ export class RushProjectConfiguration { trackedFileNames: Iterable; phaseName: string; isNoOp: boolean; + project?: RushConfigurationProject; + operationSettings?: Readonly; }): string | undefined; // @internal (undocumented) _getJsonForFingerprint(): string; diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index d8c539c21e8..2885d64b386 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -4,7 +4,14 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; -import { AlreadyReportedError, Async, FileSystem, Import, JsonFile, Path } from '@rushstack/node-core-library'; +import { + AlreadyReportedError, + Async, + FileSystem, + Import, + JsonFile, + Path +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file'; import { @@ -146,6 +153,11 @@ export interface IOperationSettings { * root folder. * * These folders should not be tracked by Git. They must not contain symlinks. + * + * @remarks + * A plugin-defined operation for a project without a `rush-project.json` file can use the build cache + * if this property is explicitly set to an empty array and the operation does not use the + * `dependsOnEnvVars`, `dependsOnNodeVersion`, or `dependsOnAdditionalFiles` settings. */ outputFolderNames?: string[]; @@ -377,6 +389,18 @@ interface IProjectConfigurationCacheEntry { const _currentConfigurationCache: WeakMap = new WeakMap(); +function getHotlinkCacheDisabledReason(project: RushConfigurationProject): string | undefined { + const rushConfiguration: RushConfiguration | undefined = project.rushConfiguration; + if (rushConfiguration) { + const hotlinkManager: HotlinkManager = HotlinkManager.loadFromRushConfiguration(rushConfiguration); + if (hotlinkManager.hasAnyHotlinksInSubspace(project.subspace.subspaceName)) { + return 'Caching has been disabled for this project because it is in a subspace with hotlinked dependencies.'; + } + } + + return undefined; +} + /** * Use this class to load the "config/rush-project.json" config file. * @@ -520,12 +544,9 @@ export class RushProjectConfiguration { phaseName: string, isNoOp: boolean ): string | undefined { - const rushConfiguration: RushConfiguration | undefined = this.project.rushConfiguration; - if (rushConfiguration) { - const hotlinkManager: HotlinkManager = HotlinkManager.loadFromRushConfiguration(rushConfiguration); - if (hotlinkManager.hasAnyHotlinksInSubspace(this.project.subspace.subspaceName)) { - return 'Caching has been disabled for this project because it is in a subspace with hotlinked dependencies.'; - } + const hotlinkReason: string | undefined = getHotlinkCacheDisabledReason(this.project); + if (hotlinkReason) { + return hotlinkReason; } // Skip no-op operations as they won't have any output/cacheable things. @@ -575,28 +596,49 @@ export class RushProjectConfiguration { /** * Source of truth for whether a project is unable to use the build cache for a given phase. - * As some operations may not have a rush-project.json file defined at all, but may be no-op operations - * we'll want to ignore those completely. + * No-op operations and explicitly outputless plugin operations do not require a rush-project.json file. */ public static getCacheDisabledReasonForProject(options: { projectConfiguration: RushProjectConfiguration | undefined; trackedFileNames: Iterable; phaseName: string; isNoOp: boolean; + project?: RushConfigurationProject; + operationSettings?: Readonly; }): string | undefined { - const { projectConfiguration, trackedFileNames, phaseName, isNoOp } = options; + const { projectConfiguration, trackedFileNames, phaseName, isNoOp, project, operationSettings } = options; if (isNoOp) { return undefined; } - if (!projectConfiguration) { - return ( - `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + - 'or one provided by a rig, so it does not support caching.' - ); + if (projectConfiguration) { + return projectConfiguration.getCacheDisabledReason(trackedFileNames, phaseName, false); + } + + if ( + project && + operationSettings?.operationName === phaseName && + operationSettings.outputFolderNames?.length === 0 && + !operationSettings.dependsOnEnvVars?.length && + !operationSettings.dependsOnNodeVersion && + !operationSettings.dependsOnAdditionalFiles?.length + ) { + const hotlinkReason: string | undefined = getHotlinkCacheDisabledReason(project); + if (hotlinkReason) { + return hotlinkReason; + } + + if (operationSettings.disableBuildCacheForOperation) { + return `Caching has been disabled for this project's "${phaseName}" command.`; + } + + return undefined; } - return projectConfiguration.getCacheDisabledReason(trackedFileNames, phaseName, isNoOp); + return ( + `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + + 'or one provided by a rig, so it does not support caching.' + ); } /** @@ -946,7 +988,10 @@ class ConfigurationInputView { let result: IConfigurationFileStat | undefined; try { // statSync follows links, so dev and ino identify the file whose content is loaded. - const stat: fs.BigIntStats | undefined = fs.statSync(filePath, { bigint: true, throwIfNoEntry: false }); + const stat: fs.BigIntStats | undefined = fs.statSync(filePath, { + bigint: true, + throwIfNoEntry: false + }); if (!stat) { result = { stamp: MISSING_FILE_STAMP, settled: true }; } else if (stat.isFile()) { @@ -994,7 +1039,11 @@ class ConfigurationInputView { if (ownFileStamp !== MISSING_FILE_STAMP) { filePath = getOwnConfigurationFilePath(project); } else if (rigConfig instanceof RealProfileFolderRigConfig) { - const rigFolderPath: string = path.join(project.projectFolder, 'node_modules', rigConfig.rigPackageName); + const rigFolderPath: string = path.join( + project.projectFolder, + 'node_modules', + rigConfig.rigPackageName + ); rigProfile = { packageJsonPath: path.join(rigFolderPath, 'package.json'), profileFolderPath: path.join(rigFolderPath, rigConfig.relativeProfileFolderPath), @@ -1091,7 +1140,8 @@ class ConfigurationInputView { files.push({ filePath, stamp: stat.stamp, - parent: parentPath !== undefined ? { specifier: specifier as string, filePath: parentPath } : undefined + parent: + parentPath !== undefined ? { specifier: specifier as string, filePath: parentPath } : undefined }); filePath = parentPath; } diff --git a/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts b/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts index 3b4b579590d..edc9452dbb7 100644 --- a/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/RushProjectConfiguration.test.ts @@ -11,7 +11,7 @@ import type { CommandLineParameter } from '@rushstack/ts-command-line'; import type { IPhase } from '../CommandLineConfiguration'; import type { RushConfigurationProject } from '../RushConfigurationProject'; -import { RushProjectConfiguration } from '../RushProjectConfiguration'; +import { RushProjectConfiguration, type IOperationSettings } from '../RushProjectConfiguration'; import { PhasedCommandEngineProjectConfigurationError } from '../PhasedCommandEngineProjectConfigurationError'; // Several cases write, link and stat many files, which can take more than the default 5 s on a busy @@ -242,23 +242,24 @@ describe(RushProjectConfiguration.name, () => { readFileAsync.mockRestore(); dateNow.mockRestore(); } - expect(readPaths.filter((p) => p.endsWith('/profiles/default/config/rush-project.json'))).toHaveLength(1); + expect(readPaths.filter((p) => p.endsWith('/profiles/default/config/rush-project.json'))).toHaveLength( + 1 + ); expect(readPaths.filter((p) => p.endsWith('/shared/rush-project.json'))).toHaveLength(1); for (const linked of projects.slice(0, 3)) { expect(getOutputFolderNames(configurations.get(linked))).toEqual(['from-rig']); expect([ - ...configurations.get(linked)!.operationSettingsByOperationName.get('_phase:test')!.outputFolderNames! + ...configurations.get(linked)!.operationSettingsByOperationName.get('_phase:test')! + .outputFolderNames! ]).toEqual(['from-shared']); } expect(getOutputFolderNames(configurations.get(projects[3]))).toEqual(['from-project']); const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); for (const linked of projects) { - const native: RushProjectConfiguration | undefined = await RushProjectConfiguration.tryLoadForProjectAsync( - linked, - terminal - ); + const native: RushProjectConfiguration | undefined = + await RushProjectConfiguration.tryLoadForProjectAsync(linked, terminal); expect(configurations.get(linked)!._getJsonForFingerprint()).toBe(native!._getJsonForFingerprint()); } }); @@ -726,4 +727,60 @@ describe(RushProjectConfiguration.name, () => { expect(reason).toMatchSnapshot(); }); }); + + describe(RushProjectConfiguration.getCacheDisabledReasonForProject.name, () => { + const project: RushConfigurationProject = { + packageName: 'plugin-project', + projectFolder: '/repo/plugin-project', + projectRelativeFolder: 'plugin-project' + } as RushConfigurationProject; + const phaseName: string = '_phase:plugin'; + const getReason = (operationSettings?: Readonly): string | undefined => + RushProjectConfiguration.getCacheDisabledReasonForProject({ + projectConfiguration: undefined, + trackedFileNames: [], + phaseName, + isNoOp: false, + project, + operationSettings + }); + + it('allows an explicitly outputless plugin operation without project configuration', () => { + expect( + getReason({ + operationName: phaseName, + outputFolderNames: [] + }) + ).toBeUndefined(); + }); + + it('requires project configuration unless the operation is explicitly outputless', () => { + expect(getReason()).toContain('does not have a rush-project.json'); + expect(getReason({ operationName: phaseName })).toContain('does not have a rush-project.json'); + expect(getReason({ operationName: phaseName, outputFolderNames: ['lib'] })).toContain( + 'does not have a rush-project.json' + ); + }); + + it('requires project configuration for operation-specific cache inputs', () => { + const settings: IOperationSettings[] = [ + { operationName: phaseName, outputFolderNames: [], dependsOnEnvVars: ['ENV_VAR'] }, + { operationName: phaseName, outputFolderNames: [], dependsOnNodeVersion: true }, + { operationName: phaseName, outputFolderNames: [], dependsOnAdditionalFiles: ['config.json'] } + ]; + for (const operationSettings of settings) { + expect(getReason(operationSettings)).toContain('does not have a rush-project.json'); + } + }); + + it('respects an explicit cache disable', () => { + expect( + getReason({ + operationName: phaseName, + outputFolderNames: [], + disableBuildCacheForOperation: true + }) + ).toContain(`Caching has been disabled for this project's "${phaseName}" command.`); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts b/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts index cc17d508895..12299578730 100644 --- a/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts @@ -81,7 +81,9 @@ export class BuildPlanPlugin implements IPhasedCommandPlugin { projectConfiguration, trackedFileNames: fileHashes.keys(), isNoOp: operation.isNoOp, - phaseName: associatedPhase.name + phaseName: associatedPhase.name, + project: associatedProject, + operationSettings: operation.settings }); buildCacheByOperation.set(operation, { cacheDisabledReason }); } diff --git a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts index 2632a88df3d..c1c7020f8a9 100644 --- a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts @@ -25,7 +25,7 @@ import { formatMegabytes } from '../buildCache/DeferredCacheEntryWrites'; import { RushConstants } from '../RushConstants'; -import type { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; +import { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; import { initializeProjectLogFilesAsync, getProjectLogFilePaths, @@ -1230,12 +1230,14 @@ function readCacheInputs( let isCacheDisabledReasonComputed: boolean = false; const getCacheDisabledReason = (): string | undefined => { if (!isCacheDisabledReasonComputed) { - cacheDisabledReason = getCacheDisabledReasonForOperation( + cacheDisabledReason = RushProjectConfiguration.getCacheDisabledReasonForProject({ projectConfiguration, - fileHashes, + trackedFileNames: fileHashes.keys(), phaseName, - operation.isNoOp - ); + isNoOp: operation.isNoOp, + project: operation.associatedProject, + operationSettings: operation.settings + }); isCacheDisabledReasonComputed = true; } return cacheDisabledReason; @@ -1248,15 +1250,3 @@ function readCacheInputs( inputFileHashes: fileHashes }; } - -function getCacheDisabledReasonForOperation( - projectConfiguration: RushProjectConfiguration | undefined, - fileHashes: ReadonlyMap, - phaseName: string, - isNoOp: boolean -): string | undefined { - return projectConfiguration - ? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, isNoOp) - : `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + - 'or one provided by a rig, so it does not support caching.'; -} diff --git a/libraries/rush-lib/src/logic/operations/test/CacheableOperationPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/CacheableOperationPlugin.test.ts index 90b10ea3084..60eec6b16d7 100644 --- a/libraries/rush-lib/src/logic/operations/test/CacheableOperationPlugin.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/CacheableOperationPlugin.test.ts @@ -139,6 +139,7 @@ interface ITestGraph { cacheDisabledReasons: Map; // The names of the operations whose reason that caching is disabled was computed, in order cacheDisabledReasonComputations: string[]; + projectConfigurations: Map; executions: string[]; cacheWrites: string[]; // The names of the operations that are restored from the build cache @@ -280,6 +281,7 @@ async function createTestGraphAsync( trackedFileHashes, cacheDisabledReasons, cacheDisabledReasonComputations, + projectConfigurations, executions, cacheWrites, cacheHits, @@ -982,6 +984,39 @@ describe(CacheableOperationPlugin.name, () => { describe('reason that caching is disabled', () => { const cacheDisabledReason: string = 'Caching has been disabled for this project.'; + it('allows an explicitly outputless operation without project configuration', async () => { + const testGraph: ITestGraph = await createTestGraphAsync(['a']); + const operation: Operation = testGraph.operations.get('a')!; + operation.settings = { + operationName: mockPhase.name, + outputFolderNames: [] + }; + testGraph.projectConfigurations.delete(operation.associatedProject); + + const result: IExecutionResult = await testGraph.executeAsync(); + + expect(result.status).toBe(OperationStatus.Success); + expect(testGraph.executions).toEqual(['a']); + expect(testGraph.cacheWrites).toEqual(['a']); + }); + + it('requires project configuration for operation-specific cache inputs', async () => { + const testGraph: ITestGraph = await createTestGraphAsync(['a']); + const operation: Operation = testGraph.operations.get('a')!; + operation.settings = { + operationName: mockPhase.name, + outputFolderNames: [], + dependsOnEnvVars: ['TEST_ENV_VAR'] + }; + testGraph.projectConfigurations.delete(operation.associatedProject); + + const result: IExecutionResult = await testGraph.executeAsync(); + + expect(result.status).toBe(OperationStatus.Success); + expect(testGraph.executions).toEqual(['a']); + expect(testGraph.cacheWrites).toEqual([]); + }); + it('is only computed for the operations that execute', async () => { const testGraph: ITestGraph = await createTestGraphAsync(['a', 'b', 'c']); await testGraph.executeAsync();