diff --git a/common/changes/@microsoft/rush/copilot-add-show-existing-failure-logs_2025-11-19-22-25.json b/common/changes/@microsoft/rush/copilot-add-show-existing-failure-logs_2025-11-19-22-25.json new file mode 100644 index 0000000000..24727dfeb4 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-add-show-existing-failure-logs_2025-11-19-22-25.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add \"--show-existing-failure-logs\" parameter to phased commands that skips execution and replays existing failure logs from previous runs. This is useful when performing sweeping changes (e.g., TypeScript upgrades) to quickly review which projects failed without re-running expensive builds.", + "type": "minor", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "198982749+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index c47899ac29..8dae3aa089 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -1318,6 +1318,14 @@ Object { "required": false, "shortName": undefined, }, + Object { + "description": "Skips execution of operations and instead displays any existing failure logs for the selected projects. This is useful for reviewing failures from a previous run without re-executing the operations. Operations without existing failure logs will be silenced.", + "environmentVariable": undefined, + "kind": "Flag", + "longName": "--show-existing-failure-logs", + "required": false, + "shortName": undefined, + }, Object { "description": "Selects a single instead of the default locale (en-us) for non-ship builds or all locales for ship builds.", "environmentVariable": undefined, @@ -1480,6 +1488,14 @@ Object { "required": false, "shortName": undefined, }, + Object { + "description": "Skips execution of operations and instead displays any existing failure logs for the selected projects. This is useful for reviewing failures from a previous run without re-executing the operations. Operations without existing failure logs will be silenced.", + "environmentVariable": undefined, + "kind": "Flag", + "longName": "--show-existing-failure-logs", + "required": false, + "shortName": undefined, + }, Object { "description": "Perform a production build, including minification and localization steps", "environmentVariable": undefined, @@ -1629,6 +1645,14 @@ Object { "required": false, "shortName": undefined, }, + Object { + "description": "Skips execution of operations and instead displays any existing failure logs for the selected projects. This is useful for reviewing failures from a previous run without re-executing the operations. Operations without existing failure logs will be silenced.", + "environmentVariable": undefined, + "kind": "Flag", + "longName": "--show-existing-failure-logs", + "required": false, + "shortName": undefined, + }, Object { "description": "Perform a production build, including minification and localization steps", "environmentVariable": undefined, diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index c5f19629af..8a89c65005 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -170,6 +170,7 @@ export class PhasedScriptAction extends BaseScriptAction i private readonly _nodeDiagnosticDirParameter: CommandLineStringParameter; private readonly _debugBuildCacheIdsParameter: CommandLineFlagParameter; private readonly _includePhaseDeps: CommandLineFlagParameter | undefined; + private readonly _showExistingFailureLogsParameter: CommandLineFlagParameter; public constructor(options: IPhasedScriptActionOptions) { super(options); @@ -326,6 +327,14 @@ export class PhasedScriptAction extends BaseScriptAction i 'Logs information about the components of the build cache ids for individual operations. This is useful for debugging the incremental build logic.' }); + this._showExistingFailureLogsParameter = this.defineFlagParameter({ + parameterLongName: '--show-existing-failure-logs', + description: + 'Skips execution of operations and instead displays any existing failure logs for the selected projects. ' + + 'This is useful for reviewing failures from a previous run without re-executing the operations. ' + + 'Operations without existing failure logs will be silenced.' + }); + this.defineScriptParameters(); // Associate parameters with their respective phases @@ -485,9 +494,25 @@ export class PhasedScriptAction extends BaseScriptAction i } const isWatch: boolean = this._watchParameter?.value || this._alwaysWatch; + const showExistingFailureLogs: boolean = this._showExistingFailureLogsParameter.value; + + // If showing existing failure logs, we don't want to enable watch mode + if (showExistingFailureLogs && isWatch) { + throw new Error('The --show-existing-failure-logs parameter cannot be used with --watch.'); + } await measureAsyncFn(`${PERF_PREFIX}:applySituationalPlugins`, async () => { - if (isWatch && this._noIPCParameter?.value === false) { + if (showExistingFailureLogs) { + // Apply the plugin that replays existing failure logs without executing operations + terminal.writeVerboseLine(`Mode: showing existing failure logs`); + const { ShowExistingFailureLogsPlugin } = await import( + /* webpackChunkName: 'ShowExistingFailureLogsPlugin' */ + '../../logic/operations/ShowExistingFailureLogsPlugin' + ); + new ShowExistingFailureLogsPlugin({ + terminal + }).apply(this.hooks); + } else if (isWatch && this._noIPCParameter?.value === false) { new ( await import( /* webpackChunkName: 'IPCOperationRunnerPlugin' */ '../../logic/operations/IPCOperationRunnerPlugin' @@ -495,33 +520,36 @@ export class PhasedScriptAction extends BaseScriptAction i ).IPCOperationRunnerPlugin().apply(this.hooks); } - if (buildCacheConfiguration?.buildCacheEnabled) { - terminal.writeVerboseLine(`Incremental strategy: cache restoration`); - new CacheableOperationPlugin({ - allowWarningsInSuccessfulBuild: - !!this.rushConfiguration.experimentsConfiguration.configuration - .buildCacheWithAllowWarningsInSuccessfulBuild, - buildCacheConfiguration, - cobuildConfiguration, - terminal - }).apply(this.hooks); - - if (this._debugBuildCacheIdsParameter.value) { - new DebugHashesPlugin(terminal).apply(this.hooks); + // Skip build cache and legacy skip plugins when showing existing failure logs + if (!showExistingFailureLogs) { + if (buildCacheConfiguration?.buildCacheEnabled) { + terminal.writeVerboseLine(`Incremental strategy: cache restoration`); + new CacheableOperationPlugin({ + allowWarningsInSuccessfulBuild: + !!this.rushConfiguration.experimentsConfiguration.configuration + .buildCacheWithAllowWarningsInSuccessfulBuild, + buildCacheConfiguration, + cobuildConfiguration, + terminal + }).apply(this.hooks); + + if (this._debugBuildCacheIdsParameter.value) { + new DebugHashesPlugin(terminal).apply(this.hooks); + } + } else if (!this._disableBuildCache) { + terminal.writeVerboseLine(`Incremental strategy: output preservation`); + // Explicitly disabling the build cache also disables legacy skip detection. + new LegacySkipPlugin({ + allowWarningsInSuccessfulBuild: + this.rushConfiguration.experimentsConfiguration.configuration + .buildSkipWithAllowWarningsInSuccessfulBuild, + terminal, + changedProjectsOnly, + isIncrementalBuildAllowed: this._isIncrementalBuildAllowed + }).apply(this.hooks); + } else { + terminal.writeVerboseLine(`Incremental strategy: none (full rebuild)`); } - } else if (!this._disableBuildCache) { - terminal.writeVerboseLine(`Incremental strategy: output preservation`); - // Explicitly disabling the build cache also disables legacy skip detection. - new LegacySkipPlugin({ - allowWarningsInSuccessfulBuild: - this.rushConfiguration.experimentsConfiguration.configuration - .buildSkipWithAllowWarningsInSuccessfulBuild, - terminal, - changedProjectsOnly, - isIncrementalBuildAllowed: this._isIncrementalBuildAllowed - }).apply(this.hooks); - } else { - terminal.writeVerboseLine(`Incremental strategy: none (full rebuild)`); } const showBuildPlan: boolean = this._cobuildPlanParameter?.value ?? false; diff --git a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index df9d2c0a67..3e4868be3d 100644 --- a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -192,7 +192,7 @@ exports[`CommandLineHelp prints the help for each action: build 1`] = ` [--from-version-policy VERSION_POLICY_NAME] [-v] [--include-phase-deps] [-c] [--ignore-hooks] [--node-diagnostic-dir DIRECTORY] [--debug-build-cache-ids] - [-s] [-m] + [--show-existing-failure-logs] [-s] [-m] This command is similar to \\"rush rebuild\\", except that \\"rush build\\" performs @@ -334,6 +334,12 @@ Optional arguments: Logs information about the components of the build cache ids for individual operations. This is useful for debugging the incremental build logic. + --show-existing-failure-logs + Skips execution of operations and instead displays + any existing failure logs for the selected projects. + This is useful for reviewing failures from a previous + run without re-executing the operations. Operations + without existing failure logs will be silenced. -s, --ship Perform a production build, including minification and localization steps -m, --minimal Perform a fast build, which disables certain tasks @@ -488,6 +494,7 @@ exports[`CommandLineHelp prints the help for each action: import-strings 1`] = ` [--include-phase-deps] [--ignore-hooks] [--node-diagnostic-dir DIRECTORY] [--debug-build-cache-ids] + [--show-existing-failure-logs] [--locale {en-us,fr-fr,es-es,zh-cn}] @@ -612,6 +619,12 @@ Optional arguments: Logs information about the components of the build cache ids for individual operations. This is useful for debugging the incremental build logic. + --show-existing-failure-logs + Skips execution of operations and instead displays + any existing failure logs for the selected projects. + This is useful for reviewing failures from a previous + run without re-executing the operations. Operations + without existing failure logs will be silenced. --locale {en-us,fr-fr,es-es,zh-cn} Selects a single instead of the default locale (en-us) for non-ship builds or all locales for ship @@ -1129,7 +1142,8 @@ exports[`CommandLineHelp prints the help for each action: rebuild 1`] = ` [--from-version-policy VERSION_POLICY_NAME] [-v] [--include-phase-deps] [--ignore-hooks] [--node-diagnostic-dir DIRECTORY] - [--debug-build-cache-ids] [-s] [-m] + [--debug-build-cache-ids] [--show-existing-failure-logs] + [-s] [-m] This command assumes that the package.json file for each project contains a @@ -1259,6 +1273,12 @@ Optional arguments: Logs information about the components of the build cache ids for individual operations. This is useful for debugging the incremental build logic. + --show-existing-failure-logs + Skips execution of operations and instead displays + any existing failure logs for the selected projects. + This is useful for reviewing failures from a previous + run without re-executing the operations. Operations + without existing failure logs will be silenced. -s, --ship Perform a production build, including minification and localization steps -m, --minimal Perform a fast build, which disables certain tasks diff --git a/libraries/rush-lib/src/logic/operations/ShowExistingFailureLogsPlugin.ts b/libraries/rush-lib/src/logic/operations/ShowExistingFailureLogsPlugin.ts new file mode 100644 index 0000000000..34ddd860d1 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ShowExistingFailureLogsPlugin.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { FileSystem } from '@rushstack/node-core-library'; +import type { ITerminal } from '@rushstack/terminal'; + +import { OperationStatus } from './OperationStatus'; +import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; +import type { IOperationRunnerContext } from './IOperationRunner'; +import type { OperationExecutionRecord } from './OperationExecutionRecord'; +import { getProjectLogFilePaths } from './ProjectLogWritable'; + +const PLUGIN_NAME: 'ShowExistingFailureLogsPlugin' = 'ShowExistingFailureLogsPlugin'; + +export interface IShowExistingFailureLogsPluginOptions { + terminal: ITerminal; +} + +/** + * Plugin that replays existing failure logs without executing operations. + * This is useful for reviewing failures from a previous run. + */ +export class ShowExistingFailureLogsPlugin implements IPhasedCommandPlugin { + private readonly _options: IShowExistingFailureLogsPluginOptions; + + public constructor(options: IShowExistingFailureLogsPluginOptions) { + this._options = options; + } + + public apply(hooks: PhasedCommandHooks): void { + hooks.beforeExecuteOperation.tapPromise( + PLUGIN_NAME, + async (runnerContext: IOperationRunnerContext): Promise => { + const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; + const { operation, _operationMetadataManager: operationMetadataManager } = record; + + const { associatedProject: project } = operation; + + // Get the path to the error log file + const { error: errorLogPath } = getProjectLogFilePaths({ + project, + logFilenameIdentifier: operation.logFilenameIdentifier + }); + + // Check if an error log exists from a previous run + const errorLogExists: boolean = await FileSystem.existsAsync(errorLogPath); + + if (errorLogExists) { + // Replay the failure log + await runnerContext.runWithTerminalAsync( + async (taskTerminal, terminalProvider) => { + // Restore the operation logs + await operationMetadataManager?.tryRestoreAsync({ + terminalProvider, + terminal: taskTerminal, + errorLogPath, + cobuildContextId: undefined, + cobuildRunnerId: undefined + }); + }, + { createLogFile: false, logFileSuffix: '' } + ); + + // Return Failure status to indicate this operation had previously failed + return OperationStatus.Failure; + } else { + // No error log exists, so this operation either succeeded or wasn't run + // Return Skipped to silence it + return OperationStatus.Skipped; + } + } + ); + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/ShowExistingFailureLogsPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/ShowExistingFailureLogsPlugin.test.ts new file mode 100644 index 0000000000..ba6e781cbe --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/ShowExistingFailureLogsPlugin.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@rushstack/node-core-library'); + +import { FileSystem } from '@rushstack/node-core-library'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { ShowExistingFailureLogsPlugin } from '../ShowExistingFailureLogsPlugin'; +import { PhasedCommandHooks } from '../../../pluginFramework/PhasedCommandHooks'; +import { OperationStatus } from '../OperationStatus'; + +describe(ShowExistingFailureLogsPlugin.name, () => { + let mockTerminalProvider: StringBufferTerminalProvider; + let mockTerminal: Terminal; + let hooks: PhasedCommandHooks; + let plugin: ShowExistingFailureLogsPlugin; + + beforeEach(() => { + mockTerminalProvider = new StringBufferTerminalProvider(false); + mockTerminal = new Terminal(mockTerminalProvider); + hooks = new PhasedCommandHooks(); + plugin = new ShowExistingFailureLogsPlugin({ terminal: mockTerminal }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should apply the plugin to hooks', () => { + plugin.apply(hooks); + expect(hooks.beforeExecuteOperation.isUsed()).toBe(true); + }); + + it('should handle operations with existing error logs', async () => { + // Mock FileSystem.existsAsync to return true (error log exists) + jest.spyOn(FileSystem, 'existsAsync').mockResolvedValue(true); + + // Apply the plugin + plugin.apply(hooks); + + // Create a minimal mock context + const mockRunnerContext = { + operation: { + logFilenameIdentifier: 'test-operation', + associatedProject: { + projectFolder: '/test/project', + packageName: 'test-package' + } + }, + _operationMetadataManager: { + tryRestoreAsync: jest.fn() + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runWithTerminalAsync: jest.fn(async (callback: any) => { + await callback(mockTerminal, mockTerminalProvider); + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + // Trigger the beforeExecuteOperation hook + const result = await hooks.beforeExecuteOperation.promise(mockRunnerContext); + + expect(result).toBe(OperationStatus.Failure); + expect(FileSystem.existsAsync).toHaveBeenCalled(); + }); + + it('should handle operations without error logs', async () => { + // Mock FileSystem.existsAsync to return false (no error log) + jest.spyOn(FileSystem, 'existsAsync').mockResolvedValue(false); + + // Apply the plugin + plugin.apply(hooks); + + // Create a minimal mock context + const mockRunnerContext = { + operation: { + logFilenameIdentifier: 'test-operation', + associatedProject: { + projectFolder: '/test/project' + } + }, + _operationMetadataManager: { + tryRestoreAsync: jest.fn() + }, + runWithTerminalAsync: jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + // Trigger the beforeExecuteOperation hook + const result = await hooks.beforeExecuteOperation.promise(mockRunnerContext); + + expect(result).toBe(OperationStatus.Skipped); + expect(FileSystem.existsAsync).toHaveBeenCalled(); + expect(mockRunnerContext.runWithTerminalAsync).not.toHaveBeenCalled(); + }); +});