From 8c6b04953e3cfe081df2f358df2c8932c32d9952 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 02:52:30 +0000 Subject: [PATCH 001/164] Add Rush reporter repository configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...2a-experiment-config_2026-08-28-02-38.json | 11 +++ common/reviews/api/rush-lib.api.md | 8 ++ .../common/config/rush/experiments.json | 8 +- libraries/rush-lib/assets/rush-init/rush.json | 13 ++++ .../src/api/ExperimentsConfiguration.ts | 6 ++ .../rush-lib/src/api/RushConfiguration.ts | 25 +++++++ .../api/test/ExperimentsConfiguration.test.ts | 55 ++++++++++++++ .../test/RushConfigurationReporting.test.ts | 74 +++++++++++++++++++ libraries/rush-lib/src/index.ts | 6 +- .../src/schemas/experiments.schema.json | 4 + .../rush-lib/src/schemas/rush.schema.json | 16 ++++ 11 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts create mode 100644 libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json new file mode 100644 index 00000000000..2130da2c580 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add repository configuration for opting into and configuring the experimental Rush reporter.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e1839f13f69..37f1ea3ef31 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -499,6 +499,7 @@ export interface IExperimentsJson { usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; usePnpmPreferFrozenLockfileForRushUpdate?: boolean; usePnpmSyncForInjectedDependencies?: boolean; + useRushReporter?: boolean; } // @beta @@ -973,6 +974,11 @@ export interface _IRushProjectJson { operationSettings?: IOperationSettings[]; } +// @beta +export interface IRushReportingConfiguration { + readonly agentEnvironmentVariables: readonly string[]; +} + // @beta (undocumented) export interface IRushSessionOptions { // (undocumented) @@ -1473,6 +1479,8 @@ export class RushConfiguration { get projectsByName(): ReadonlyMap; // @beta get projectsByTag(): ReadonlyMap>; + // @beta + readonly reportingConfiguration: IRushReportingConfiguration; readonly repositoryDefaultBranch: string; get repositoryDefaultFullyQualifiedRemoteBranch(): string; readonly repositoryDefaultRemote: string; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index a8c4c01cb4e..afaf6c9a2d5 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -165,5 +165,11 @@ * registry and proxy settings must likewise be supplied through trusted user, global, CLI, or * environment configuration rather than a project .npmrc. */ - /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true + /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true, + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + /*[LINE "HYPOTHETICAL"]*/ "useRushReporter": true } diff --git a/libraries/rush-lib/assets/rush-init/rush.json b/libraries/rush-lib/assets/rush-init/rush.json index a972877f6d0..4cf8209cc28 100644 --- a/libraries/rush-lib/assets/rush-init/rush.json +++ b/libraries/rush-lib/assets/rush-init/rush.json @@ -316,6 +316,19 @@ */ /*[LINE "HYPOTHETICAL"]*/ "telemetryEnabled": false, + /** + * Configures repository settings used by the experimental Rush reporter system. + */ + /*[BEGIN "HYPOTHETICAL"]*/ + "reporting": { + /** + * Additional environment variable names that identify an agent environment. + * The built-in COPILOT_CLI variable does not need to be listed here. + */ + "agentEnvironmentVariables": ["MY_AGENT_CLI", "ANOTHER_AGENT"] + }, + /*[END "HYPOTHETICAL"]*/ + /** * Allows creation of hotfix changes. This feature is experimental so it is disabled by default. * If this is set, 'rush change' only allows a 'hotfix' change type to be specified. This change type diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 658671e14c9..4c179069759 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -177,6 +177,12 @@ export interface IExperimentsJson { * through trusted user, global, CLI, or environment configuration rather than a project `.npmrc`. */ provideNpmrcCredentialsViaEnvironment?: boolean; + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + useRushReporter?: boolean; } const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 527ec92be93..f75dffc795b 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -154,6 +154,21 @@ export interface IRushVariantOptionsJson { description: string; } +interface IRushReportingConfigurationJson { + agentEnvironmentVariables?: string[]; +} + +/** + * Repository settings used by the Rush reporter system. + * @beta + */ +export interface IRushReportingConfiguration { + /** + * Additional environment variable names that identify an agent environment. + */ + readonly agentEnvironmentVariables: readonly string[]; +} + /** * This represents the JSON data structure for the "rush.json" configuration file. * See rush.schema.json for documentation. @@ -184,6 +199,7 @@ export interface IRushConfigurationJson { yarnOptions?: IYarnOptionsJson; ensureConsistentVersions?: boolean; variants?: IRushVariantOptionsJson[]; + reporting?: IRushReportingConfigurationJson; } /** @@ -523,6 +539,12 @@ export class RushConfiguration { */ public readonly telemetryEnabled: boolean; + /** + * Repository settings used by the Rush reporter system. + * @beta + */ + public readonly reportingConfiguration: IRushReportingConfiguration; + /** * {@inheritDoc NpmOptionsConfiguration} */ @@ -853,6 +875,9 @@ export class RushConfiguration { } this.telemetryEnabled = !!rushConfigurationJson.telemetryEnabled; + this.reportingConfiguration = { + agentEnvironmentVariables: rushConfigurationJson.reporting?.agentEnvironmentVariables || [] + }; this.eventHooks = new EventHooks(rushConfigurationJson.eventHooks || {}); this.versionPolicyConfigurationFilePath = path.join( diff --git a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts new file mode 100644 index 00000000000..dfe9526cab8 --- /dev/null +++ b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { ExperimentsConfiguration } from '../ExperimentsConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', ExperimentsConfiguration.name); +const EXPERIMENTS_JSON_PATH: string = path.join(TEMP_FOLDER, 'experiments.json'); + +describe(ExperimentsConfiguration.name, () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('preserves legacy reporting behavior when the experiment file is absent', () => { + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBeUndefined(); + }); + + it('loads the Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: true }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(true); + }); + + it('keeps an explicit false value disabled', () => { + JsonFile.save({ useRushReporter: false }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(false); + }); + + it('rejects a non-boolean Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: 'yes' }, EXPERIMENTS_JSON_PATH); + + expect(() => new ExperimentsConfiguration(EXPERIMENTS_JSON_PATH)).toThrow(/useRushReporter/); + }); +}); diff --git a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts new file mode 100644 index 00000000000..a188189023c --- /dev/null +++ b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.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 * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { Rush } from '../Rush'; +import { RushConfiguration } from '../RushConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', 'RushConfigurationReporting'); +const RUSH_JSON_PATH: string = path.join(TEMP_FOLDER, 'rush.json'); + +function writeRushJson(reporting?: unknown): void { + JsonFile.save( + { + rushVersion: Rush.version, + pnpmVersion: '10.0.0', + projects: [], + ...(reporting === undefined ? {} : { reporting }) + }, + RUSH_JSON_PATH + ); +} + +describe('RushConfiguration reporting configuration', () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('defaults agent environment variables to an empty array', () => { + writeRushJson(); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([]); + }); + + it('loads configured agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 'ANOTHER_AGENT'] + }); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([ + 'MY_AGENT_CLI', + 'ANOTHER_AGENT' + ]); + }); + + it('rejects invalid agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 123] + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow( + /agentEnvironmentVariables/ + ); + }); + + it('rejects unsupported reporting settings', () => { + writeRushJson({ + agentEnvironmentVariables: [], + defaultReporter: 'ai' + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow(/defaultReporter/); + }); +}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index f2df5c851a1..0fdd200e775 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -20,7 +20,11 @@ export { export { ApprovedPackagesPolicy } from './api/ApprovedPackagesPolicy'; -export { RushConfiguration, type ITryFindRushJsonLocationOptions } from './api/RushConfiguration'; +export { + RushConfiguration, + type IRushReportingConfiguration, + type ITryFindRushJsonLocationOptions +} from './api/RushConfiguration'; export { Subspace } from './api/Subspace'; export { SubspacesConfiguration } from './api/SubspacesConfiguration'; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index fa4d2ee1308..de8925a12ce 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -101,6 +101,10 @@ "provideNpmrcCredentialsViaEnvironment": { "description": "If true, when using PNPM 10.34.2 through 10.x or PNPM 11.5.3 through versions earlier than 11.6.0, Rush resolves the \"${VAR}\" tokens that appear in credentials and registry URLs in the .npmrc file, instead of relying on PNPM to expand them. Credentials are passed to PNPM using \"npm_config_*\" environment variables and are not written to the generated .npmrc file. PNPM 11.6.0 and newer support URL-scoped \"pnpm_config_//...\" environment variables, which should instead be supplied directly by CI so the trusted environment binds each credential to its registry. Dynamic registry and proxy settings must likewise come from trusted user, global, CLI, or environment configuration.", "type": "boolean" + }, + "useRushReporter": { + "description": "If true, Rush may use the experimental Rush reporter system. If omitted or false, Rush preserves the legacy reporting behavior.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/schemas/rush.schema.json b/libraries/rush-lib/src/schemas/rush.schema.json index dce5fcaae37..9593b014ee0 100644 --- a/libraries/rush-lib/src/schemas/rush.schema.json +++ b/libraries/rush-lib/src/schemas/rush.schema.json @@ -248,6 +248,22 @@ "description": "Indicates whether telemetry data should be collected and stored in the Rush temp folder during Rush runs.", "type": "boolean" }, + "reporting": { + "description": "Configures repository settings used by the Rush reporter system.", + "type": "object", + "properties": { + "agentEnvironmentVariables": { + "description": "Additional environment variable names that identify an agent environment.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, "allowedProjectTags": { "description": "This is an optional, but recommended, list of allowed tags that can be applied to Rush projects using the \"tags\" setting in this file. This list is useful for preventing mistakes such as misspelling, and it also provides a centralized place to document your tags. If \"allowedProjectTags\" list is not specified, then any valid tag is allowed. A tag name must be one or more words separated by hyphens or slashes, where a word may contain lowercase ASCII letters, digits, \".\", and \"@\" characters.", "type": "array", From b4f0fc1573ea776a65879e3f62203d1ed1a669a8 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:10:57 +0000 Subject: [PATCH 002/164] Add Rush reporter frontend controls Create the authoritative frontend reporter host before version selection, register global reporter controls, and preserve legacy output unless a non-legacy reporter is explicitly selected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 17 + apps/rush/src/RushCommandSelector.ts | 5 +- apps/rush/src/RushFrontend.ts | 62 +++ apps/rush/src/RushReporterHost.ts | 514 ++++++++++++++++++ apps/rush/src/RushVersionSelector.ts | 5 +- apps/rush/src/start-dev.ts | 19 +- apps/rush/src/start.ts | 26 +- apps/rush/src/test/RushFrontend.test.ts | 93 ++++ apps/rush/src/test/RushReporterHost.test.ts | 227 ++++++++ ...ontend-host-controls_2026-08-28-03-00.json | 11 + .../RushCommandLine.test.ts.snap | 20 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 29 + .../rush-lib/src/cli/actions/CheckAction.ts | 9 +- .../cli/scriptActions/PhasedScriptAction.ts | 10 +- .../CommandLineHelp.test.ts.snap | 31 +- 15 files changed, 1014 insertions(+), 64 deletions(-) create mode 100644 apps/rush/src/IRushFrontendLaunchOptions.ts create mode 100644 apps/rush/src/RushFrontend.ts create mode 100644 apps/rush/src/RushReporterHost.ts create mode 100644 apps/rush/src/test/RushFrontend.test.ts create mode 100644 apps/rush/src/test/RushReporterHost.test.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..828b03ed3d6 --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +/** + * The cross-version launch contract owned by the Rush frontend. + * + * @remarks + * Reporter selection remains in `@microsoft/rush`. The selected `rush-lib` + * receives only the typed producer sink in addition to its existing launch + * options, so an older engine can safely ignore the new property. + */ +export interface IRushFrontendLaunchOptions extends ILaunchOptions { + readonly reporterEventSink: IReporterEventSink; +} diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..46728020622 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,9 +3,10 @@ import * as path from 'node:path'; -import type { ILaunchOptions } from '@microsoft/rush-lib/lib/index'; import { Colorize } from '@rushstack/terminal'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; /** @@ -28,7 +29,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts new file mode 100644 index 00000000000..c60d1265081 --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IInitializedRushReporterHost +} from './RushReporterHost'; +import { RushCommandSelector } from './RushCommandSelector'; +import { RushVersionSelector } from './RushVersionSelector'; +import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + +export interface IRushFrontendOptions { + readonly currentPackageVersion: string; + readonly rushVersionToLoad: string | undefined; + readonly configuration: MinimalRushConfiguration | undefined; + readonly launchOptions: ILaunchOptions; + readonly currentRushLib: typeof import('@microsoft/rush-lib'); + readonly initializeReporterHostAsync?: () => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => void; +} + +export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute + } = options; + + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync(); + if (!reporterHost.selection.enabled && reporterHost.selection.reason !== 'pre-major legacy default') { + process.argv = stripReporterValueControls(process.argv); + } + const reporterLaunchOptions: IRushFrontendLaunchOptions = { + ...launchOptions, + reporterEventSink: reporterHost.sink + }; + + if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { + const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); + await versionSelector.ensureRushVersionInstalledAsync( + rushVersionToLoad, + configuration, + reporterLaunchOptions + ); + } else { + executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + } +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts new file mode 100644 index 00000000000..cde7f83592c --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + AiReporter, + DefaultInteractiveReporter, + FileReporter, + JsonReporter, + PlaintextReporter, + ReporterHost, + isCiDetected, + isLegacyEmergencyFallbackRequested, + isSupportedLogLevel, + isSupportedReporterName, + parseOutputControl, + separateJsonControls, + shouldRenderAtLogLevel, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterOutputTarget, + type ReporterLogLevel, + type ReporterName +} from '@rushstack/rush-reporter'; + +export interface IRushReporterOutputStream { + readonly isTTY?: boolean; + readonly columns?: number; + write(text: string): unknown; +} + +export interface IRushReporterHostOptions { + readonly argv?: readonly string[]; + readonly env?: Record; + readonly cwd?: string; + readonly stdout?: IRushReporterOutputStream; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reason: 'explicit --reporter' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; +} + +export interface IInitializedRushReporterHost { + readonly host: ReporterHost; + readonly sink: IReporterEventSink; + readonly selection: IRushReporterSelection; +} + +const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); + +interface IParsedReporterControls { + readonly reporters: readonly string[]; + readonly logLevels: readonly string[]; + readonly outputs: readonly string[]; + readonly quiet: boolean; + readonly verbose: boolean; + readonly debug: boolean; +} + +class LogLevelReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + private readonly _logLevel: ReporterLogLevel; + + public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + this._reporter = reporter; + this._logLevel = logLevel; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if (shouldRenderAtLogLevel(this._logLevel, event)) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + +class ExplicitOutputReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: JsonReporter; + private readonly _filteredReporter: LogLevelReporter; + private readonly _outputPath: string; + private _fileDescriptor: number | undefined; + + public constructor(reporterName: string, outputPath: string, logLevel: ReporterLogLevel) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._fileDescriptor === undefined) { + throw new Error(`Reporter output ${JSON.stringify(this._outputPath)} is not initialized.`); + } + fs.writeSync(this._fileDescriptor, text); + } + }); + this._filteredReporter = new LogLevelReporter(this._reporter, logLevel); + } + + public async initializeAsync(context: IReporterContext): Promise { + await fs.promises.mkdir(path.dirname(this._outputPath), { recursive: true }); + this._fileDescriptor = fs.openSync(this._outputPath, 'w', 0o600); + await this._filteredReporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + this._filteredReporter.report(event); + } + + public async flushAsync(): Promise { + await this._filteredReporter.flushAsync(); + if (this._fileDescriptor !== undefined) { + fs.fsyncSync(this._fileDescriptor); + } + } + + public async closeAsync(): Promise { + try { + await this._filteredReporter.closeAsync(); + } finally { + if (this._fileDescriptor !== undefined) { + fs.closeSync(this._fileDescriptor); + this._fileDescriptor = undefined; + } + } + } +} + +function readValue( + argv: readonly string[], + index: number, + flag: string +): { readonly value: string; readonly consumedNext: boolean } | undefined { + const argument: string = argv[index]; + const prefix: string = `${flag}=`; + if (argument.startsWith(prefix)) { + const value: string = argument.slice(prefix.length); + if (!value) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: false }; + } + if (argument !== flag) { + return undefined; + } + + const value: string | undefined = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: true }; +} + +export function stripReporterValueControls(argv: readonly string[]): string[] { + const result: string[] = []; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + const equalsIndex: number = argument.indexOf('='); + const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); + if (!REPORTER_VALUE_FLAGS.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length) { + index++; + } + } + return result; +} + +function parseReporterControls(argv: readonly string[]): IParsedReporterControls { + const reporters: string[] = []; + const logLevels: string[] = []; + const outputs: string[] = []; + let quiet: boolean = false; + let verbose: boolean = false; + let debug: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.consumedNext ? 1 : 0; + continue; + } + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--log-level' + ); + if (logLevel) { + logLevels.push(logLevel.value); + index += logLevel.consumedNext ? 1 : 0; + continue; + } + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--output' + ); + if (output) { + outputs.push(output.value); + index += output.consumedNext ? 1 : 0; + continue; + } + + quiet ||= argument === '--quiet' || argument === '-q'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + if (reporters.length > 1) { + throw new Error('--reporter may be specified only once.'); + } + if (logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } + + return { reporters, logLevels, outputs, quiet, verbose, debug }; +} + +function resolveLogLevel( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean +): ReporterLogLevel { + const requestedLevels: ReporterLogLevel[] = []; + const explicitLogLevel: string | undefined = controls.logLevels[0]; + if (explicitLogLevel !== undefined) { + if (!isSupportedLogLevel(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + requestedLevels.push(explicitLogLevel); + } + if (controls.quiet) { + requestedLevels.push('quiet'); + } + if (controls.verbose) { + requestedLevels.push('verbose'); + } + if (controls.debug) { + requestedLevels.push('debug'); + } + + const distinctLevels: Set = new Set(requestedLevels); + if (distinctLevels.size > 1) { + throw new Error( + `Contradictory reporter verbosity controls were specified: ${[...distinctLevels].sort().join(', ')}. ` + + 'Specify only one of --log-level, --quiet, --verbose, or --debug.' + ); + } + if (requestedLevels.length > 0) { + return requestedLevels[0]; + } + + const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + if (environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (!isSupportedLogLevel(normalizedLogLevel)) { + throw new Error( + `Unsupported RUSH_LOG_LEVEL value ${JSON.stringify(environmentLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return normalizedLogLevel; + } + + return 'normal'; +} + +function resolveOutputs(outputValues: readonly string[], cwd: string): readonly IReporterOutputTarget[] { + return outputValues.map((value: string) => { + const output: IReporterOutputTarget = parseOutputControl(value); + if (output.reporter !== 'file' && output.reporter !== 'json') { + throw new Error( + `Unsupported --output reporter ${JSON.stringify(output.reporter)}. ` + + 'This rollout stage supports file:// and json:// output targets.' + ); + } + if (!output.target) { + throw new Error(`The --output target must not be empty: ${JSON.stringify(value)}.`); + } + for (const parameterName of Object.keys(output.params)) { + if (parameterName !== 'logLevel') { + throw new Error( + `Unsupported --output query parameter ${JSON.stringify(parameterName)}. ` + + 'The only supported query parameter is logLevel.' + ); + } + } + const outputLogLevel: string | undefined = output.params.logLevel; + if (outputLogLevel !== undefined && !isSupportedLogLevel(outputLogLevel)) { + throw new Error( + `Unsupported --output logLevel ${JSON.stringify(outputLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return { + ...output, + target: path.resolve(cwd, output.target) + }; + }); +} + +export function resolveRushReporterSelection(options: IRushReporterHostOptions = {}): IRushReporterSelection { + const argv: readonly string[] = options.argv ?? process.argv.slice(2); + const env: Record = options.env ?? process.env; + const commandName: 'rush' | 'rush-pnpm' | 'rushx' = options.commandName ?? getCommandName(); + if (commandName !== 'rush') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: separateJsonControls(argv).commandJson, + enabled: false, + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const controls: IParsedReporterControls = parseReporterControls(argv); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + if (isLegacyEmergencyFallbackRequested(env)) { + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'RUSH_REPORTER=legacy' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + const requestedReporter: string | undefined = controls.reporters[0]; + if (requestedReporter === undefined) { + const environmentReporter: string | undefined = env.RUSH_REPORTER; + if (environmentReporter?.trim()) { + throw new Error( + `RUSH_REPORTER=${JSON.stringify(environmentReporter)} cannot enable the pre-major reporter path. ` + + 'Use an explicit --reporter option, or set RUSH_REPORTER=legacy for the emergency fallback.' + ); + } + if (controls.outputs.length > 0 || controls.logLevels.length > 0) { + throw new Error('--output and --log-level require an explicit non-legacy --reporter selection.'); + } + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'pre-major legacy default' + }; + } + + if (!isSupportedReporterName(requestedReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(requestedReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + + if (requestedReporter === 'legacy') { + if (controls.outputs.length > 0 || controls.logLevels.length > 0) { + throw new Error('--output and --log-level are not supported with --reporter=legacy.'); + } + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'explicit --reporter' + }; + } + + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + if (requestedReporter === 'default' && !stdout.isTTY) { + throw new Error( + '--reporter=default requires an interactive TTY. Use --reporter=plaintext for CI or redirected output.' + ); + } + + return { + reporter: requestedReporter, + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reason: 'explicit --reporter' + }; +} + +function createPrimaryReporter( + selection: IRushReporterSelection, + stdout: IRushReporterOutputStream, + env: Record +): IReporter | undefined { + switch (selection.reporter) { + case 'default': + return new DefaultInteractiveReporter({ + terminal: { + columns: stdout.columns ?? 80, + isTTY: stdout.isTTY === true, + write: (text: string) => { + stdout.write(text); + } + }, + env + }); + case 'ai': + return new AiReporter({ write: (text: string) => stdout.write(text) }); + case 'json': + return new JsonReporter({ write: (text: string) => stdout.write(text) }); + case 'plaintext': + return new PlaintextReporter({ + write: (text: string) => stdout.write(text), + variant: isCiDetected(env) ? 'detailed' : 'concise', + color: false + }); + case 'file': + return new FileReporter(); + case 'legacy': + return undefined; + } +} + +export async function initializeRushReporterHostAsync( + options: IRushReporterHostOptions = {} +): Promise { + const env: Record = options.env ?? process.env; + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ env }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + }); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } + } + + await host.manager.initializeAsync(); + return { host, sink: host.getSink(), selection }; +} diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 615aaa0e356..6e450e7aca0 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -7,9 +7,10 @@ import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; -import { _FlagFile, _RushGlobalFolder, type ILaunchOptions } from '@microsoft/rush-lib'; +import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; import { RushCommandSelector } from './RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; const MAX_INSTALL_ATTEMPTS: number = 3; @@ -26,7 +27,7 @@ export class RushVersionSelector { public async ensureRushVersionInstalledAsync( version: string, configuration: MinimalRushConfiguration | undefined, - executeOptions: ILaunchOptions + executeOptions: IRushFrontendLaunchOptions ): Promise { const isLegacyRushVersion: boolean = semver.lt(version, '4.0.0'); const expectedRushPath: string = path.join(this._rushGlobalFolder.nodeSpecificPath, `rush-${version}`); diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index bba3469421f..eda177e33c3 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -7,7 +7,7 @@ import * as rushLib from '@microsoft/rush-lib'; import { PackageJsonLookup, Import } from '@rushstack/node-core-library'; -import { RushCommandSelector } from './RushCommandSelector'; +import { launchRushFrontendAsync } from './RushFrontend'; const builtInPluginConfigurations: rushLib._IBuiltInPluginConfiguration[] = []; @@ -34,8 +34,17 @@ includePlugin('rush-serve-plugin'); includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; -RushCommandSelector.execute(currentPackageVersion, rushLib, { - isManaged: false, - alreadyReportedNodeTooNewError: false, - builtInPluginConfigurations +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { + isManaged: false, + alreadyReportedNodeTooNewError: false, + builtInPluginConfigurations + }, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(error); }); diff --git a/apps/rush/src/start.ts b/apps/rush/src/start.ts index bf8d5927230..ff4db06b442 100644 --- a/apps/rush/src/start.ts +++ b/apps/rush/src/start.ts @@ -29,9 +29,8 @@ import { EnvironmentVariableNames } from '@microsoft/rush-lib'; import type { ILaunchOptions } from '@microsoft/rush-lib'; import * as rushLib from '@microsoft/rush-lib'; -import { RushCommandSelector } from './RushCommandSelector'; -import { RushVersionSelector } from './RushVersionSelector'; import { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import { launchRushFrontendAsync } from './RushFrontend'; // Load the configuration const configuration: MinimalRushConfiguration | undefined = @@ -90,16 +89,13 @@ const terminalProvider: ITerminalProvider = new ConsoleTerminalProvider(); const launchOptions: ILaunchOptions = { isManaged, alreadyReportedNodeTooNewError, terminalProvider }; -// If we're inside a repo folder, and it's requesting a different version, then use the RushVersionManager to -// install it -if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { - const versionSelector: RushVersionSelector = new RushVersionSelector(currentPackageVersion); - versionSelector - .ensureRushVersionInstalledAsync(rushVersionToLoad, configuration, launchOptions) - .catch((error: Error) => { - console.log(Colorize.red('Error: ' + error.message)); - }); -} else { - // Otherwise invoke the rush-lib that came with this rush package - RushCommandSelector.execute(currentPackageVersion, rushLib, launchOptions); -} +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(Colorize.red(`Error: ${error.message}`)); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts new file mode 100644 index 00000000000..cd0c2ca6618 --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as rushLib from '@microsoft/rush-lib'; +import { ReporterHost, type IReporterEventSink } from '@rushstack/rush-reporter'; + +import { launchRushFrontendAsync } from '../RushFrontend'; +import type { IInitializedRushReporterHost } from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; + +async function createInitializedHostAsync( + order: string[], + reason: IInitializedRushReporterHost['selection']['reason'] = 'pre-major legacy default' +): Promise { + order.push('host'); + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + return { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reason + } + }; +} + +describe(launchRushFrontendAsync.name, () => { + it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { + const order: string[] = []; + let receivedOptions: Record | undefined; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order, 'explicit --reporter'), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + order.push('engine'); + receivedOptions = launchOptions as unknown as Record; + } + }); + + expect(order).toEqual(['host', 'engine']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporterEventSink).toEqual( + expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink + ); + expect(receivedOptions).not.toHaveProperty('selection'); + expect(receivedOptions).not.toHaveProperty('host'); + expect(receivedOptions).not.toHaveProperty('manager'); + } finally { + process.argv = originalArgv; + } + }); + + it('creates the host before selecting and installing a repository Rush version', async () => { + const order: string[] = []; + let receivedSink: IReporterEventSink | undefined; + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + order.push('version-selection'); + receivedSink = (launchOptions as unknown as { reporterEventSink?: IReporterEventSink }) + .reporterEventSink; + }; + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order), + createVersionSelector: () => versionSelector + }); + + expect(order).toEqual(['host', 'version-selection']); + expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + }); +}); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts new file mode 100644 index 00000000000..11ca6276dc0 --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IRushReporterOutputStream, + type IRushReporterSelection +} from '../RushReporterHost'; + +function resolve( + argv: readonly string[], + env: Record = {}, + isTTY: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined } + }); +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +describe(resolveRushReporterSelection.name, () => { + it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { + for (const testCase of [ + { env: {}, isTTY: true }, + { env: {}, isTTY: false }, + { env: { CI: 'true' }, isTTY: false }, + { env: { COPILOT_CLI: '1' }, isTTY: true } + ]) { + expect(resolve(['build'], testCase.env, testCase.isTTY)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'pre-major legacy default' + }); + } + }); + + it('requires an explicit non-legacy --reporter to opt in', () => { + expect(resolve(['build', '--reporter=json'], { CI: 'true' }, false)).toMatchObject({ + reporter: 'json', + enabled: true, + reason: 'explicit --reporter' + }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); + }); + + it('does not consume rush-pnpm or rushx reporter arguments', () => { + expect( + resolveRushReporterSelection({ + argv: ['install', '--reporter=append-only'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rush-pnpm' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + expect( + resolveRushReporterSelection({ + argv: ['build', '--reporter=custom-script-value'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rushx' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + }); + + it('keeps RUSH_REPORTER=legacy as an emergency override', () => { + expect(resolve(['build', '--reporter=json'], { RUSH_REPORTER: ' LEGACY ' })).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'RUSH_REPORTER=legacy' + }); + }); + + it('removes reporter-only value controls before invoking a legacy engine', () => { + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'list', + '--json', + '--reporter=json', + '--output', + 'file://./rush.log', + '--log-level=debug', + '--quiet' + ]) + ).toEqual(['node', 'rush', 'list', '--json', '--quiet']); + }); + + it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { + expect( + resolve(['build', '--reporter=plaintext', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel + ).toBe('verbose'); + expect(resolve(['build', '--reporter=plaintext'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe('debug'); + expect(() => resolve(['build', '--reporter=plaintext', '--quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + }); + + it('ignores reporter environment selection before the gate but validates explicit controls', () => { + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); + expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); + expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); + expect(() => resolve(['build', '--output=json:\/\/events.jsonl'])).toThrow( + /require an explicit non-legacy --reporter/ + ); + }); + + it('rejects an interactive reporter on non-TTY output', () => { + expect(() => resolve(['build', '--reporter=default'], {}, false)).toThrow(/requires an interactive TTY/); + expect(resolve(['build', '--reporter=default'], {}, true).reporter).toBe('default'); + }); + + it('parses output targets and preserves command-specific --json independently', () => { + const selection: IRushReporterSelection = resolve( + [ + 'list', + '--json', + '--reporter=json', + '--output=file://./rush.log?logLevel=debug', + '--output=json://./events.jsonl' + ], + {}, + false + ); + + expect(selection.commandJson).toBe(true); + expect(selection.reporter).toBe('json'); + expect(selection.outputs).toEqual([ + { + reporter: 'file', + target: path.resolve('/repo', 'rush.log'), + params: { logLevel: 'debug' } + }, + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl'), + params: {} + } + ]); + }); + + it('surfaces unsupported and incomplete controls with actionable errors', () => { + expect(() => resolve(['build', '--reporter'])).toThrow(/--reporter requires a value/); + expect(() => resolve(['build', '--reporter=json', '--reporter=ai'])).toThrow( + /may be specified only once/ + ); + expect(() => resolve(['build', '--reporter=json', '--output=plaintext://./output.txt'])).toThrow( + /supports file:\/\/ and json:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { + let output: string = ''; + const stdout: IRushReporterOutputStream = { + isTTY: false, + write: (text: string) => { + output += text; + } + }; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env: { CI: 'true', COPILOT_CLI: '1' }, + stdout, + includeDefaultFileReporter: false + }); + + const sink: IReporterEventSink = initialized.sink; + emitCommandStarted(sink); + await initialized.host.manager.flushAsync(); + + expect(initialized.selection.enabled).toBe(false); + expect(output).toBe(''); + }); + + it('initializes the explicitly selected reporter and output destinations', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(initialized.sink); + await initialized.host.manager.closeAsync(); + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json new file mode 100644 index 00000000000..0abc06b9dc2 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add the pre-major ReporterHost and explicit global reporter controls while preserving legacy output by default.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@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 d913bb774e3..c6f5880848b 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 @@ -184,14 +184,6 @@ Object { "required": false, "shortName": undefined, }, - Object { - "description": "If this flag is specified, long lists of package names will not be truncated. This has no effect if the --json flag is also specified.", - "environmentVariable": undefined, - "kind": "Flag", - "longName": "--verbose", - "required": false, - "shortName": undefined, - }, Object { "description": "(EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only within that subspace (ignoring other subspaces). This parameter is required when the \\"subspacesEnabled\\" setting is set to true in subspaces.json.", "environmentVariable": undefined, @@ -1287,10 +1279,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, @@ -1441,10 +1433,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, @@ -1598,10 +1590,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 47f2b3a640b..d9af1151214 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -8,6 +8,7 @@ import { type CommandLineFlagParameter, CommandLineHelper } from '@rushstack/ts-command-line'; +import { SUPPORTED_LOG_LEVELS, SUPPORTED_REPORTER_NAMES } from '@rushstack/rush-reporter'; import { InternalError, AlreadyReportedError, Text } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, @@ -83,6 +84,7 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _debugParameter: CommandLineFlagParameter; private readonly _quietParameter: CommandLineFlagParameter; + private readonly _verboseParameter: CommandLineFlagParameter; private readonly _restrictConsoleOutput: boolean = RushCommandLineParser.shouldRestrictConsoleOutput(); private readonly _rushOptions: IRushCommandLineParserOptions; private readonly _terminalProvider: ConsoleTerminalProvider; @@ -123,6 +125,29 @@ export class RushCommandLineParser extends CommandLineParser { description: 'Hide rush startup information' }); + this._verboseParameter = this.defineFlagParameter({ + parameterLongName: '--verbose', + description: 'Show detailed command and reporter output' + }); + + this.defineChoiceParameter({ + parameterLongName: '--reporter', + alternatives: [...SUPPORTED_REPORTER_NAMES], + description: 'Select the Rush output reporter' + }); + + this.defineStringListParameter({ + parameterLongName: '--output', + argumentName: 'DESTINATION', + description: 'Add a reporter output destination such as file://./rush.log' + }); + + this.defineChoiceParameter({ + parameterLongName: '--log-level', + alternatives: [...SUPPORTED_LOG_LEVELS], + description: 'Set the reporter log level' + }); + const terminalProvider: ConsoleTerminalProvider = new ConsoleTerminalProvider(); this._terminalProvider = terminalProvider; const terminal: Terminal = new Terminal(this._terminalProvider); @@ -202,6 +227,10 @@ export class RushCommandLineParser extends CommandLineParser { return this._quietParameter.value; } + public get isVerbose(): boolean { + return this._verboseParameter.value; + } + public get terminal(): ITerminal { return this._terminal; } diff --git a/libraries/rush-lib/src/cli/actions/CheckAction.ts b/libraries/rush-lib/src/cli/actions/CheckAction.ts index fcf752b0657..4a1cda2f8ec 100644 --- a/libraries/rush-lib/src/cli/actions/CheckAction.ts +++ b/libraries/rush-lib/src/cli/actions/CheckAction.ts @@ -11,7 +11,6 @@ import { getVariantAsync, VARIANT_PARAMETER } from '../../api/Variants'; export class CheckAction extends BaseRushAction { private readonly _jsonFlag: CommandLineFlagParameter; - private readonly _verboseFlag: CommandLineFlagParameter; private readonly _subspaceParameter: CommandLineStringParameter | undefined; private readonly _variantParameter: CommandLineStringParameter; @@ -32,12 +31,6 @@ export class CheckAction extends BaseRushAction { parameterLongName: '--json', description: 'If this flag is specified, output will be in JSON format.' }); - this._verboseFlag = this.defineFlagParameter({ - parameterLongName: '--verbose', - description: - 'If this flag is specified, long lists of package names will not be truncated. ' + - `This has no effect if the ${this._jsonFlag.longName} flag is also specified.` - }); this._subspaceParameter = this.defineStringParameter({ parameterLongName: '--subspace', argumentName: 'SUBSPACE_NAME', @@ -75,7 +68,7 @@ export class CheckAction extends BaseRushAction { VersionMismatchFinder.rushCheck(this.rushConfiguration, this.terminal, { variant, printAsJson: this._jsonFlag.value, - truncateLongPackageNameLists: !this._verboseFlag.value, + truncateLongPackageNameLists: !this.parser.isVerbose, subspace: this._subspaceParameter?.value ? this.rushConfiguration.getSubspace(this._subspaceParameter.value) : this.rushConfiguration.defaultSubspace diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1b2b7aa5812..4361afa6645 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -148,7 +148,7 @@ export class PhasedScriptAction extends BaseScriptAction i private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; - private readonly _verboseParameter: CommandLineFlagParameter; + private readonly _legacyVerboseParameter: CommandLineFlagParameter; private readonly _parallelismParameter: CommandLineStringParameter | undefined; private readonly _ignoreHooksParameter: CommandLineFlagParameter; private readonly _watchParameter: CommandLineFlagParameter | undefined; @@ -234,10 +234,10 @@ export class PhasedScriptAction extends BaseScriptAction i cwd: this.parser.cwd }); - this._verboseParameter = this.defineFlagParameter({ - parameterLongName: '--verbose', + this._legacyVerboseParameter = this.defineFlagParameter({ + parameterLongName: '--verbose-build-output', parameterShortName: '-v', - description: 'Display the logs during the build, rather than just displaying the build status summary' + description: 'Display build logs instead of only status' }); this._includePhaseDeps = this.defineFlagParameter({ @@ -455,7 +455,7 @@ export class PhasedScriptAction extends BaseScriptAction i }); } - const isQuietMode: boolean = !this._verboseParameter.value; + const isQuietMode: boolean = !(this.parser.isVerbose || this._legacyVerboseParameter.value); const changedProjectsOnly: boolean = !!this._changedProjectsOnlyParameter?.value; 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 efe3e717b7d..30c901116f6 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 @@ -1,7 +1,10 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`CommandLineHelp prints the global help 1`] = ` -"usage: rush [-h] [-d] [-q] ... +"usage: rush [-h] [-d] [-q] [--verbose] + [--reporter {default,ai,json,plaintext,file,legacy}] + [--output DESTINATION] [--log-level {quiet,normal,verbose,debug}] + ... Rush makes life easier for JavaScript developers who develop, build, and publish many packages from a central Git repo. It is designed to handle very @@ -81,6 +84,13 @@ Optional arguments: -d, --debug Show the full call stack if an error occurs while executing the tool -q, --quiet Hide rush startup information + --verbose Show detailed command and reporter output + --reporter {default,ai,json,plaintext,file,legacy} + Select the Rush output reporter + --output DESTINATION Add a reporter output destination such as file://. + /rush.log + --log-level {quiet,normal,verbose,debug} + Set the reporter log level [bold]For detailed help about a specific command, use: rush -h[normal] " @@ -304,8 +314,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -409,9 +419,7 @@ Optional arguments: `; exports[`CommandLineHelp prints the help for each action: check 1`] = ` -"usage: rush check [-h] [--json] [--verbose] [--subspace SUBSPACE_NAME] - [--variant VARIANT] - +"usage: rush check [-h] [--json] [--subspace SUBSPACE_NAME] [--variant VARIANT] Checks each project's package.json files and ensures that all dependencies are of the same version throughout the repository. @@ -420,9 +428,6 @@ Optional arguments: -h, --help Show this help message and exit. --json If this flag is specified, output will be in JSON format. - --verbose If this flag is specified, long lists of package - names will not be truncated. This has no effect if - the --json flag is also specified. --subspace SUBSPACE_NAME (EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only @@ -598,8 +603,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -1245,8 +1250,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might From 2450d5f3c473c45909a2fc85956a18d19b29fbb5 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:44:04 +0000 Subject: [PATCH 003/164] Fix reporter frontend integration Consume the repository experiment before Rush version selection, keep agent detection out of pre-major defaults, strip frontend-only controls before engine handoff, and preserve legacy verbosity compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/MinimalRushConfiguration.ts | 41 +++++++++++- apps/rush/src/RushFrontend.ts | 11 +++- apps/rush/src/RushReporterHost.ts | 41 ++++++++++-- .../src/test/MinimalRushConfiguration.test.ts | 2 + apps/rush/src/test/RushFrontend.test.ts | 43 ++++++++---- apps/rush/src/test/RushReporterHost.test.ts | 66 ++++++++++++++++++- .../repo/common/config/rush/experiments.json | 3 + 7 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 0cc4436b964..62aef01d11d 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; -import { JsonFile } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { RushConfiguration } from '@microsoft/rush-lib'; import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; @@ -13,6 +13,10 @@ interface IMinimalRushConfigurationJson { rushVersion?: string; } +interface IMinimalExperimentsConfigurationJson { + useRushReporter?: boolean; +} + /** * Represents a minimal subset of the rush.json configuration file. It provides the information necessary to * decide which version of Rush should be installed/used. @@ -20,6 +24,7 @@ interface IMinimalRushConfigurationJson { export class MinimalRushConfiguration { private _rushVersion: string; private _commonRushConfigFolder: string; + private _useRushReporter: boolean; private constructor(minimalRushConfigurationJson: IMinimalRushConfigurationJson, rushJsonFilename: string) { this._rushVersion = @@ -30,6 +35,20 @@ export class MinimalRushConfiguration { 'config', 'rush' ); + + const experimentsJsonFilename: string = path.join( + this._commonRushConfigFolder, + RushConstants.experimentsFilename + ); + const experimentsConfiguration: IMinimalExperimentsConfigurationJson | undefined = + _loadExperimentsConfigurationJson(experimentsJsonFilename); + if ( + experimentsConfiguration?.useRushReporter !== undefined && + typeof experimentsConfiguration.useRushReporter !== 'boolean' + ) { + throw new Error(`The "useRushReporter" setting in "${experimentsJsonFilename}" must be true or false.`); + } + this._useRushReporter = experimentsConfiguration?.useRushReporter === true; } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { @@ -68,6 +87,13 @@ export class MinimalRushConfiguration { public get commonRushConfigFolder(): string { return this._commonRushConfigFolder; } + + /** + * Whether the repository explicitly opted in to the experimental Rush reporter frontend. + */ + public get useRushReporter(): boolean { + return this._useRushReporter; + } } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { @@ -77,3 +103,16 @@ function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigura return undefined; } } + +function _loadExperimentsConfigurationJson( + experimentsJsonFilename: string +): IMinimalExperimentsConfigurationJson | undefined { + try { + return JsonFile.load(experimentsJsonFilename); + } catch (e) { + if (FileSystem.isNotExistError(e)) { + return undefined; + } + throw e; + } +} diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index c60d1265081..05e522a8149 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -6,6 +6,7 @@ import type { ILaunchOptions } from '@microsoft/rush-lib'; import { initializeRushReporterHostAsync, stripReporterValueControls, + type IRushReporterHostOptions, type IInitializedRushReporterHost } from './RushReporterHost'; import { RushCommandSelector } from './RushCommandSelector'; @@ -19,7 +20,9 @@ export interface IRushFrontendOptions { readonly configuration: MinimalRushConfiguration | undefined; readonly launchOptions: ILaunchOptions; readonly currentRushLib: typeof import('@microsoft/rush-lib'); - readonly initializeReporterHostAsync?: () => Promise; + readonly initializeReporterHostAsync?: ( + options: IRushReporterHostOptions + ) => Promise; readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; readonly executeCurrentRush?: ( currentPackageVersion: string, @@ -40,8 +43,10 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr executeCurrentRush = RushCommandSelector.execute } = options; - const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync(); - if (!reporterHost.selection.enabled && reporterHost.selection.reason !== 'pre-major legacy default') { + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter + }); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { process.argv = stripReporterValueControls(process.argv); } const reporterLaunchOptions: IRushFrontendLaunchOptions = { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index cde7f83592c..d81a2df9711 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -40,6 +40,7 @@ export interface IRushReporterHostOptions { readonly stdout?: IRushReporterOutputStream; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; } export interface IRushReporterSelection { @@ -48,7 +49,12 @@ export interface IRushReporterSelection { readonly outputs: readonly IReporterOutputTarget[]; readonly commandJson: boolean; readonly enabled: boolean; - readonly reason: 'explicit --reporter' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default'; } export interface IInitializedRushReporterHost { @@ -345,25 +351,28 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson: separateJsonControls(argv).commandJson, enabled: false, + reporterControlsOwnedByFrontend: false, reason: 'pre-major legacy default' }; } const cwd: string = options.cwd ?? process.cwd(); - const controls: IParsedReporterControls = parseReporterControls(argv); const commandJson: boolean = separateJsonControls(argv).commandJson; if (isLegacyEmergencyFallbackRequested(env)) { return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'RUSH_REPORTER=legacy' }; } + const controls: IParsedReporterControls = parseReporterControls(argv); + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); if (executableName === 'rush-pnpm') { @@ -385,14 +394,32 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = ); } if (controls.outputs.length > 0 || controls.logLevels.length > 0) { - throw new Error('--output and --log-level require an explicit non-legacy --reporter selection.'); + if (!options.repositoryOptIn) { + throw new Error( + '--output and --log-level require an explicit non-legacy --reporter selection or the ' + + 'useRushReporter repository experiment.' + ); + } + } + if (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + return { + reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: true, + reason: 'repository experiment' + }; } return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'pre-major legacy default' }; } @@ -410,10 +437,11 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'explicit --reporter' }; } @@ -431,6 +459,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, + reporterControlsOwnedByFrontend: true, reason: 'explicit --reporter' }; } diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 391c9feeeb2..80b95dbd6aa 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -19,6 +19,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('2.5.0'); + expect(config.useRushReporter).toBe(false); }); }); @@ -31,6 +32,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); + expect(config.useRushReporter).toBe(true); }); }); }); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index cd0c2ca6618..765b4d9a1dc 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -24,6 +24,7 @@ async function createInitializedHostAsync( outputs: [], commandJson: false, enabled: false, + reporterControlsOwnedByFrontend: true, reason } }; @@ -77,17 +78,37 @@ describe(launchRushFrontendAsync.name, () => { .reporterEventSink; }; - await launchRushFrontendAsync({ - currentPackageVersion: '5.178.1', - rushVersionToLoad: '5.177.0', - configuration: undefined, - launchOptions: { isManaged: true }, - currentRushLib: rushLib, - initializeReporterHostAsync: () => createInitializedHostAsync(order), - createVersionSelector: () => versionSelector - }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=json', '--log-level=debug']; - expect(order).toEqual(['host', 'version-selection']); - expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => { + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + return { + ...initialized, + selection: { + ...initialized.selection, + reporter: 'json', + logLevel: 'debug', + enabled: true, + reason: 'explicit --reporter' + } + }; + }, + createVersionSelector: () => versionSelector + }); + + expect(order).toEqual(['host', 'version-selection']); + expect(process.argv).toEqual(['node', 'rush', 'build']); + expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + } finally { + process.argv = originalArgv; + } }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 11ca6276dc0..fa6f8bcf077 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -18,13 +18,15 @@ import { function resolve( argv: readonly string[], env: Record = {}, - isTTY: boolean = false + isTTY: boolean = false, + repositoryOptIn: boolean = false ): IRushReporterSelection { return resolveRushReporterSelection({ argv, env, cwd: '/repo', - stdout: { isTTY, columns: 100, write: () => undefined } + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn }); } @@ -66,6 +68,44 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('uses deterministic non-agent selection for the repository experiment', () => { + expect(resolve(['build'], {}, true, true)).toMatchObject({ + reporter: 'default', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { CI: 'true' }, true, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { COPILOT_CLI: '1' }, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve(['build', '--log-level=debug', '--output=json://./events.jsonl'], {}, false, true) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + it('does not consume rush-pnpm or rushx reporter arguments', () => { expect( resolveRushReporterSelection({ @@ -84,7 +124,14 @@ describe(resolveRushReporterSelection.name, () => { }); it('keeps RUSH_REPORTER=legacy as an emergency override', () => { - expect(resolve(['build', '--reporter=json'], { RUSH_REPORTER: ' LEGACY ' })).toMatchObject({ + expect( + resolve( + ['build', '--reporter=json', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ reporter: 'legacy', enabled: false, reason: 'RUSH_REPORTER=legacy' @@ -117,6 +164,19 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false + }); + }); + it('ignores reporter environment selection before the gate but validates explicit controls', () => { expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); diff --git a/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json new file mode 100644 index 00000000000..596ca68ca76 --- /dev/null +++ b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "useRushReporter": true +} From 0ad2e30cecc47180cfdb0531be1b79f4bfeb88f9 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 14:57:22 +0000 Subject: [PATCH 004/164] Fix reporter frontend argument and close lifecycle Stop reporter control scans at the pass-through separator and add an exactly-once frontend close contract across success, failure, and termination paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 1 + apps/rush/src/RushCommandSelector.ts | 5 +- apps/rush/src/RushFrontend.ts | 134 +++++++- apps/rush/src/RushReporterHost.ts | 21 +- apps/rush/src/test/RushFrontend.test.ts | 324 +++++++++++++++++- apps/rush/src/test/RushReporterHost.test.ts | 71 +++- ...ontend-host-controls_2026-08-28-03-00.json | 2 +- libraries/reporter/src/exit/CommandJson.ts | 3 + .../reporter/src/test/ExitStatus.test.ts | 9 + libraries/rush-lib/src/api/Rush.ts | 8 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 52 ++- ...RushCommandLineParserReporterClose.test.ts | 85 +++++ 12 files changed, 679 insertions(+), 36 deletions(-) create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 828b03ed3d6..4b3bf391a67 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -14,4 +14,5 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporterEventSink: IReporterEventSink; + readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 46728020622..8d29eac6afa 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,8 +3,6 @@ import * as path from 'node:path'; -import { Colorize } from '@rushstack/terminal'; - import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; @@ -66,8 +64,7 @@ export class RushCommandSelector { } function _failWithError(message: string): never { - console.log(Colorize.red(message)); - return process.exit(1); + throw new Error(message); } function _getCommandName(): CommandName { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 05e522a8149..9446c42d9bd 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type { ILaunchOptions } from '@microsoft/rush-lib'; +import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -28,7 +29,78 @@ export interface IRushFrontendOptions { currentPackageVersion: string, currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions - ) => void; + ) => void | Promise; + readonly processLifecycle?: IRushFrontendProcessLifecycle; +} + +type RushTerminationSignal = 'SIGINT' | 'SIGTERM'; + +export interface IRushFrontendProcessLifecycle { + registerBeforeExit(listener: () => void): () => void; + registerSignal(signal: RushTerminationSignal, listener: () => void): () => void; + terminate(signal: RushTerminationSignal): void; + setExitCode(exitCode: number): void; + reportCloseError(error: Error): void; +} + +class RushFrontendReporterLifecycle { + private readonly _reporterHost: IInitializedRushReporterHost; + private readonly _processLifecycle: IRushFrontendProcessLifecycle; + private _disposeBeforeExit: (() => void) | undefined; + private readonly _disposeSignalHandlers: Array<() => void> = []; + private _closePromise: Promise | undefined; + + public constructor( + reporterHost: IInitializedRushReporterHost, + processLifecycle: IRushFrontendProcessLifecycle + ) { + this._reporterHost = reporterHost; + this._processLifecycle = processLifecycle; + } + + public start(): void { + this._disposeBeforeExit = this._processLifecycle.registerBeforeExit(() => { + void this.closeAsync().catch((error: Error) => { + this._processLifecycle.reportCloseError(error); + this._processLifecycle.setExitCode(1); + }); + }); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + this._disposeSignalHandlers.push( + this._processLifecycle.registerSignal(signal, () => { + this._disposeSignals(); + void this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS) + .catch((error: Error) => { + this._processLifecycle.reportCloseError(error); + }) + .finally(() => { + this._processLifecycle.terminate(signal); + }); + }) + ); + } + } + + public closeAsync(timeoutMs?: number): Promise { + if (!this._closePromise) { + this._closePromise = Promise.resolve() + .then(() => this._reporterHost.closeAsync(timeoutMs)) + .finally(() => this._dispose()); + } + return this._closePromise; + } + + private _dispose(): void { + this._disposeBeforeExit?.(); + this._disposeBeforeExit = undefined; + this._disposeSignals(); + } + + private _disposeSignals(): void { + for (const dispose of this._disposeSignalHandlers.splice(0)) { + dispose(); + } + } } export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { @@ -40,28 +112,66 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr currentRushLib, initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), - executeCurrentRush = RushCommandSelector.execute + executeCurrentRush = RushCommandSelector.execute, + processLifecycle = createProcessLifecycle() } = options; const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ repositoryOptIn: configuration?.useRushReporter }); + const reporterLifecycle: RushFrontendReporterLifecycle = new RushFrontendReporterLifecycle( + reporterHost, + processLifecycle + ); + reporterLifecycle.start(); if (reporterHost.selection.reporterControlsOwnedByFrontend) { process.argv = stripReporterValueControls(process.argv); } const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink + reporterEventSink: reporterHost.sink, + reporterCloseAsync: () => reporterLifecycle.closeAsync() }; - if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { - const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); - await versionSelector.ensureRushVersionInstalledAsync( - rushVersionToLoad, - configuration, - reporterLaunchOptions - ); - } else { - executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + try { + if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { + const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); + await versionSelector.ensureRushVersionInstalledAsync( + rushVersionToLoad, + configuration, + reporterLaunchOptions + ); + } else { + await executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + } + } catch (error) { + try { + await reporterLifecycle.closeAsync(); + } catch (closeError) { + throw new AggregateError([error, closeError], 'Rush failed and the reporter host could not close.'); + } + throw error; } } + +function createProcessLifecycle(): IRushFrontendProcessLifecycle { + return { + registerBeforeExit: (listener: () => void) => { + process.once('beforeExit', listener); + return () => process.off('beforeExit', listener); + }, + registerSignal: (signal: RushTerminationSignal, listener: () => void) => { + process.once(signal, listener); + return () => process.off(signal, listener); + }, + terminate: (signal: RushTerminationSignal) => { + process.kill(process.pid, signal); + }, + setExitCode: (exitCode: number) => { + process.exitCode = exitCode; + }, + reportCloseError: (error: Error) => { + process.stderr.write(`[reporter] Unable to finalize reporters: ${error.message}\n`); + } + }; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index d81a2df9711..6142f6dcd30 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -61,6 +61,7 @@ export interface IInitializedRushReporterHost { readonly host: ReporterHost; readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; + closeAsync(timeoutMs?: number): Promise; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -185,13 +186,17 @@ export function stripReporterValueControls(argv: readonly string[]): string[] { const result: string[] = []; for (let index: number = 0; index < argv.length; index++) { const argument: string = argv[index]; + if (argument === '--') { + result.push(...argv.slice(index)); + break; + } const equalsIndex: number = argument.indexOf('='); const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); if (!REPORTER_VALUE_FLAGS.has(flagName)) { result.push(argument); continue; } - if (equalsIndex < 0 && index + 1 < argv.length) { + if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { index++; } } @@ -208,6 +213,9 @@ function parseReporterControls(argv: readonly string[]): IParsedReporterControls for (let index: number = 0; index < argv.length; index++) { const argument: string = argv[index]; + if (argument === '--') { + break; + } const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( argv, index, @@ -539,5 +547,14 @@ export async function initializeRushReporterHostAsync( } await host.manager.initializeAsync(); - return { host, sink: host.getSink(), selection }; + let closePromise: Promise | undefined; + return { + host, + sink: host.getSink(), + selection, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; } diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 765b4d9a1dc..49839c5d639 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -1,11 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + import * as rushLib from '@microsoft/rush-lib'; import { ReporterHost, type IReporterEventSink } from '@rushstack/rush-reporter'; -import { launchRushFrontendAsync } from '../RushFrontend'; -import type { IInitializedRushReporterHost } from '../RushReporterHost'; +import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import { + initializeRushReporterHostAsync, + type IInitializedRushReporterHost, + type IRushReporterSelection +} from '../RushReporterHost'; import { RushVersionSelector } from '../RushVersionSelector'; async function createInitializedHostAsync( @@ -15,6 +23,7 @@ async function createInitializedHostAsync( order.push('host'); const host: ReporterHost = new ReporterHost({ env: {} }); await host.manager.initializeAsync(); + let closePromise: Promise | undefined; return { host, sink: host.getSink(), @@ -26,14 +35,77 @@ async function createInitializedHostAsync( enabled: false, reporterControlsOwnedByFrontend: true, reason + }, + closeAsync: (timeoutMs?: number) => { + if (!closePromise) { + order.push('close'); + closePromise = host.manager.closeAsync(timeoutMs); + } + return closePromise; + } + }; +} + +interface ITestProcessLifecycle extends IRushFrontendProcessLifecycle { + beforeExitListener: (() => void) | undefined; + readonly signalListeners: Map<'SIGINT' | 'SIGTERM', () => void>; + readonly terminatedSignals: Array<'SIGINT' | 'SIGTERM'>; + readonly exitCodes: number[]; + readonly closeErrors: Error[]; +} + +function createTestProcessLifecycle(): ITestProcessLifecycle { + const lifecycle: ITestProcessLifecycle = { + beforeExitListener: undefined, + signalListeners: new Map(), + terminatedSignals: [], + exitCodes: [], + closeErrors: [], + registerBeforeExit: (listener: () => void) => { + lifecycle.beforeExitListener = listener; + return () => { + if (lifecycle.beforeExitListener === listener) { + lifecycle.beforeExitListener = undefined; + } + }; + }, + registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => { + lifecycle.signalListeners.set(signal, listener); + return () => { + if (lifecycle.signalListeners.get(signal) === listener) { + lifecycle.signalListeners.delete(signal); + } + }; + }, + terminate: (signal: 'SIGINT' | 'SIGTERM') => { + lifecycle.terminatedSignals.push(signal); + }, + setExitCode: (exitCode: number) => { + lifecycle.exitCodes.push(exitCode); + }, + reportCloseError: (error: Error) => { + lifecycle.closeErrors.push(error); } }; + return lifecycle; +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); } describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; let receivedOptions: Record | undefined; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -50,10 +122,12 @@ describe(launchRushFrontendAsync.name, () => { void selectedRushLib; order.push('engine'); receivedOptions = launchOptions as unknown as Record; - } + return launchOptions.reporterCloseAsync(); + }, + processLifecycle }); - expect(order).toEqual(['host', 'engine']); + expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); expect(receivedOptions?.reporterEventSink).toEqual( expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink @@ -61,6 +135,8 @@ describe(launchRushFrontendAsync.name, () => { expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); } finally { process.argv = originalArgv; } @@ -69,6 +145,7 @@ describe(launchRushFrontendAsync.name, () => { it('creates the host before selecting and installing a repository Rush version', async () => { const order: string[] = []; let receivedSink: IReporterEventSink | undefined; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { void version; @@ -76,6 +153,7 @@ describe(launchRushFrontendAsync.name, () => { order.push('version-selection'); receivedSink = (launchOptions as unknown as { reporterEventSink?: IReporterEventSink }) .reporterEventSink; + await launchOptions.reporterCloseAsync(); }; const originalArgv: string[] = process.argv; @@ -101,14 +179,248 @@ describe(launchRushFrontendAsync.name, () => { } }; }, - createVersionSelector: () => versionSelector + createVersionSelector: () => versionSelector, + processLifecycle }); - expect(order).toEqual(['host', 'version-selection']); + expect(order).toEqual(['host', 'version-selection', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build']); expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); } finally { process.argv = originalArgv; } }); + + it('uses beforeExit to close when an older engine ignores the optional close callback', async () => { + const order: string[] = []; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + versionSelector.ensureRushVersionInstalledAsync = async () => { + order.push('legacy-engine'); + }; + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order), + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(order).toEqual(['host', 'legacy-engine']); + processLifecycle.beforeExitListener!(); + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(order).toEqual(['host', 'legacy-engine', 'close']); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + }); + + it('flushes and closes an explicit output through the real frontend boundary on success', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporterEventSink); + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('preserves pass-through arguments byte-for-byte through the real frontend boundary', async () => { + const originalArgv: string[] = process.argv; + const passThroughArguments: string[] = [ + '--', + '--reporter=unknown', + '--reporter', + 'tool-reporter', + '--output=not-a-url', + '--output', + 'tool-output', + '--log-level=loud', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + process.argv = ['node', 'rush', 'build', '--reporter=json', ...passThroughArguments]; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'json', + logLevel: 'normal', + commandJson: false, + enabled: true + }); + expect(receivedArgv).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + } finally { + process.argv = originalArgv; + } + }); + + it('closes exactly once when the engine rejects', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('engine rejected')), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('engine rejected'); + + expect(order).toEqual(['host', 'close']); + }); + + it('closes exactly once when command selection fails', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: {} as typeof import('@microsoft/rush-lib'), + initializeReporterHostAsync: async () => initialized, + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unable to find the "Rush" entry point'); + + expect(order).toEqual(['host', 'close']); + } finally { + process.argv = originalArgv; + } + }); + + it('uses a bounded close before preserving signal termination', async () => { + let resolveClose: (() => void) | undefined; + const closePromise: Promise = new Promise((resolve: () => void) => { + resolveClose = resolve; + }); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => closePromise); + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + const initialized: IInitializedRushReporterHost = { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reporterControlsOwnedByFrontend: true, + reason: 'pre-major legacy default' + }, + closeAsync + }; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledWith(2000); + expect(processLifecycle.terminatedSignals).toEqual([]); + + resolveClose!(); + await closePromise; + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.signalListeners.size).toBe(0); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fa6f8bcf077..691bfaa0396 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -154,6 +154,71 @@ describe(resolveRushReporterSelection.name, () => { ).toEqual(['node', 'rush', 'list', '--json', '--quiet']); }); + it('preserves every argument at and after the pass-through separator', () => { + const passThroughArguments: string[] = [ + '--', + '--reporter=tool-reporter', + '--reporter', + 'tool-reporter', + '--output=tool-output', + '--output', + 'tool-output', + '--log-level=tool-level', + '--log-level', + 'tool-level', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary', + 'value with spaces' + ]; + + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'build', + '--reporter=json', + '--output', + 'json://./events.jsonl', + '--log-level=debug', + ...passThroughArguments + ]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + expect( + stripReporterValueControls(['node', 'rush', 'build', '--reporter', ...passThroughArguments]) + ).toEqual(['node', 'rush', 'build', ...passThroughArguments]); + }); + + it('ignores reporter controls and aliases after the pass-through separator', () => { + expect( + resolve([ + 'build', + '--', + '--reporter=unknown', + '--output=not-a-url', + '--log-level=loud', + '--quiet', + '-q', + '--verbose', + '--debug', + '-d', + '--json', + 'ordinary' + ]) + ).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reason: 'pre-major legacy default' + }); + }); + it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { expect( resolve(['build', '--reporter=plaintext', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel @@ -252,7 +317,7 @@ describe(initializeRushReporterHostAsync.name, () => { const sink: IReporterEventSink = initialized.sink; emitCommandStarted(sink); - await initialized.host.manager.flushAsync(); + await initialized.closeAsync(); expect(initialized.selection.enabled).toBe(false); expect(output).toBe(''); @@ -276,7 +341,9 @@ describe(initializeRushReporterHostAsync.name, () => { }); emitCommandStarted(initialized.sink); - await initialized.host.manager.closeAsync(); + const firstClose: Promise = initialized.closeAsync(); + expect(initialized.closeAsync()).toBe(firstClose); + await firstClose; expect(JSON.parse(stdoutText).type).toBe('commandStarted'); expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json index 0abc06b9dc2..708080a190c 100644 --- a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json +++ b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Add the pre-major ReporterHost and explicit global reporter controls while preserving legacy output by default.", + "comment": "Add the pre-major ReporterHost, separator-safe global controls, and deterministic reporter finalization while preserving legacy output by default.", "type": "patch" } ], diff --git a/libraries/reporter/src/exit/CommandJson.ts b/libraries/reporter/src/exit/CommandJson.ts index 2e3d14cbb94..83f4cd715d0 100644 --- a/libraries/reporter/src/exit/CommandJson.ts +++ b/libraries/reporter/src/exit/CommandJson.ts @@ -37,6 +37,9 @@ export function separateJsonControls(argv: readonly string[]): IJsonControls { for (let index: number = 0; index < argv.length; index++) { const arg: string = argv[index]; + if (arg === '--') { + break; + } if (arg === '--json') { commandJson = true; } else if (arg === '--reporter=json') { diff --git a/libraries/reporter/src/test/ExitStatus.test.ts b/libraries/reporter/src/test/ExitStatus.test.ts index d8424effed1..ffd90440418 100644 --- a/libraries/reporter/src/test/ExitStatus.test.ts +++ b/libraries/reporter/src/test/ExitStatus.test.ts @@ -149,4 +149,13 @@ describe('separateJsonControls', () => { reporterJson: false }); }); + + it('stops scanning at the pass-through separator', () => { + expect( + separateJsonControls(['build', '--json', '--', '--json', '--reporter=json', '--reporter', 'json']) + ).toEqual({ + commandJson: true, + reporterJson: false + }); + }); }); diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index 64e06354047..a51af8b0930 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -17,6 +17,10 @@ import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoade import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; +interface IRushFrontendLaunchOptions extends ILaunchOptions { + reporterCloseAsync?: () => Promise; +} + /** * Options to pass to the rush "launch" functions. * @@ -78,6 +82,7 @@ export class Rush { */ public static launch(launcherVersion: string, options: ILaunchOptions): void { options = _normalizeLaunchOptions(options); + const frontendOptions: IRushFrontendLaunchOptions = options; if (!RushCommandLineParser.shouldRestrictConsoleOutput()) { RushStartupBanner.logBanner(Rush.version, options.isManaged); @@ -92,7 +97,8 @@ export class Rush { _assignRushInvokedFolder(); const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, - builtInPluginConfigurations: options.builtInPluginConfigurations + builtInPluginConfigurations: options.builtInPluginConfigurations, + reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise // eslint-disable-next-line no-console diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index d9af1151214..6f40ae88f65 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -73,6 +73,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporterCloseAsync?: () => Promise; } export class RushCommandLineParser extends CommandLineParser { @@ -245,6 +246,9 @@ export class RushCommandLineParser extends CommandLineParser { for (let i: number = 2; i < process.argv.length; i++) { const arg: string = process.argv[i]; + if (arg === '--') { + break; + } if (arg === '-q' || arg === '--quiet' || arg === '--json') { return true; } @@ -264,14 +268,23 @@ export class RushCommandLineParser extends CommandLineParser { public override async executeAsync(args?: string[]): Promise { // debugParameter will be correctly parsed during super.executeAsync(), so manually parse here. + const passThroughSeparatorIndex: number = process.argv.indexOf('--', 2); + const rushArgv: string[] = + passThroughSeparatorIndex < 0 + ? process.argv.slice(2) + : process.argv.slice(2, passThroughSeparatorIndex); this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = - process.argv.indexOf('--debug') >= 0; + rushArgv.includes('--debug') || rushArgv.includes('-d'); - await measureAsyncFn('rush:initializeUnassociatedPlugins', () => - this.pluginManager.tryInitializeUnassociatedPluginsAsync() - ); + try { + await measureAsyncFn('rush:initializeUnassociatedPlugins', () => + this.pluginManager.tryInitializeUnassociatedPluginsAsync() + ); - return await super.executeAsync(args); + return await super.executeAsync(args); + } finally { + await this._closeReporterAsync(); + } } protected override async onExecuteAsync(): Promise { @@ -338,7 +351,8 @@ export class RushCommandLineParser extends CommandLineParser { return { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, - builtInPluginConfigurations: options.builtInPluginConfigurations || [] + builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporterCloseAsync: options.reporterCloseAsync }; } @@ -577,10 +591,32 @@ export class RushCommandLineParser extends CommandLineParser { } }; - if (this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed()) { - this.telemetry.ensureFlushedAsync().then(handleExit).catch(handleExit); + const reporterCloseAsync: (() => Promise) | undefined = this._rushOptions.reporterCloseAsync; + const telemetryFlushAsync: Promise | undefined = + this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed() + ? this.telemetry.ensureFlushedAsync() + : undefined; + + if (reporterCloseAsync || telemetryFlushAsync) { + const pendingFlushes: Promise[] = []; + if (reporterCloseAsync) { + pendingFlushes.push(reporterCloseAsync()); + } + if (telemetryFlushAsync) { + pendingFlushes.push(telemetryFlushAsync); + } + void Promise.allSettled(pendingFlushes).then(handleExit); } else { handleExit(); } } + + private async _closeReporterAsync(): Promise { + try { + await this._rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + throw error; + } + } } diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts new file mode 100644 index 00000000000..6be98150c90 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushCommandLineParser } from '../RushCommandLineParser'; +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; + +describe('RushCommandLineParser reporter close', () => { + const originalExitCode: string | number | null | undefined = process.exitCode; + const originalArgv: string[] = process.argv; + + afterEach(() => { + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + }); + + it('does not treat pass-through quiet, debug, or json arguments as Rush controls', async () => { + process.argv = ['node', 'rush', 'build', '--', '--quiet', '-q', '--debug', '-d', '--json']; + + expect(RushCommandLineParser.shouldRestrictConsoleOutput()).toBe(false); + + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => undefined + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + await parser.executeAsync(['not-a-rush-command']); + + const terminalProvider: { debugEnabled: boolean; verboseEnabled: boolean } = ( + parser as unknown as { + _terminalProvider: { debugEnabled: boolean; verboseEnabled: boolean }; + } + )._terminalProvider; + expect(terminalProvider.debugEnabled).toBe(false); + expect(terminalProvider.verboseEnabled).toBe(false); + }); + + it('closes after command-line parser rejection', async () => { + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(parser.executeAsync(['not-a-rush-command'])).resolves.toBe(false); + + expect(closeAsync).toHaveBeenCalledTimes(1); + }); + + it('waits for reporter close before an explicit parser exit', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); + Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + process.exitCode = 1; + + const reportErrorAndSetExitCode: (error: Error) => void = ( + parser as unknown as { + _reportErrorAndSetExitCode(error: Error): void; + } + )._reportErrorAndSetExitCode.bind(parser); + reportErrorAndSetExitCode(new Error('parser failed')); + + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + + resolveClose!(); + await Promise.resolve(); + await Promise.resolve(); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); From 4cb24481affe3c8ccdd993d3a96795d83d4faba9 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:19:26 +0000 Subject: [PATCH 005/164] Preserve Rush CLI reporter compatibility Keep reporter controls out of ts-command-line globals, gate incompatible engines before initialization, and enforce bounded signal and close-error behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushFrontend.ts | 60 ++- apps/rush/src/RushReporterHost.ts | 122 +++-- apps/rush/src/test/RushFrontend.test.ts | 471 +++++++++++++++--- apps/rush/src/test/RushReporterHost.test.ts | 64 ++- ...ontend-host-controls_2026-08-28-03-00.json | 2 +- .../RushCommandLine.test.ts.snap | 20 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 38 +- .../rush-lib/src/cli/actions/CheckAction.ts | 9 +- .../cli/scriptActions/PhasedScriptAction.ts | 10 +- .../cli/test/RushCommandLineParser.test.ts | 20 + ...RushCommandLineParserReporterClose.test.ts | 33 +- .../CommandLineHelp.test.ts.snap | 31 +- .../common/config/rush/command-line.json | 32 ++ .../custom-output.js | 10 + 14 files changed, 710 insertions(+), 212 deletions(-) create mode 100644 libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json create mode 100644 libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 9446c42d9bd..0fc42146f09 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -69,13 +69,7 @@ class RushFrontendReporterLifecycle { this._disposeSignalHandlers.push( this._processLifecycle.registerSignal(signal, () => { this._disposeSignals(); - void this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS) - .catch((error: Error) => { - this._processLifecycle.reportCloseError(error); - }) - .finally(() => { - this._processLifecycle.terminate(signal); - }); + void this._closeForSignalAsync(signal); }) ); } @@ -101,6 +95,31 @@ class RushFrontendReporterLifecycle { dispose(); } } + + private async _closeForSignalAsync(signal: RushTerminationSignal): Promise { + const closeResult: Promise = this.closeAsync(DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS).then( + () => undefined, + (error: Error) => error + ); + let timeout: ReturnType | undefined; + const deadline: Promise<'deadline'> = new Promise((resolve: (value: 'deadline') => void) => { + timeout = setTimeout(() => resolve('deadline'), DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS); + }); + + const result: Error | 'deadline' | undefined = await Promise.race([closeResult, deadline]); + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (result === 'deadline') { + this._processLifecycle.reportCloseError( + new Error(`Reporter close exceeded the ${DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS}ms signal deadline.`) + ); + } else if (result) { + this._processLifecycle.reportCloseError(result); + } + this._dispose(); + this._processLifecycle.terminate(signal); + } } export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { @@ -117,20 +136,26 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } = options; const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ - repositoryOptIn: configuration?.useRushReporter + repositoryOptIn: configuration?.useRushReporter, + forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, + selectedRushVersion: rushVersionToLoad }); - const reporterLifecycle: RushFrontendReporterLifecycle = new RushFrontendReporterLifecycle( - reporterHost, - processLifecycle - ); - reporterLifecycle.start(); + const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled + ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) + : undefined; + reporterLifecycle?.start(); if (reporterHost.selection.reporterControlsOwnedByFrontend) { - process.argv = stripReporterValueControls(process.argv); + process.argv = stripReporterValueControls( + process.argv, + new Set(reporterHost.selection.reporterValueFlagsToStrip) + ); } + const reporterCloseAsync: () => Promise = () => + reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporterEventSink: reporterHost.sink, - reporterCloseAsync: () => reporterLifecycle.closeAsync() + reporterCloseAsync }; try { @@ -146,9 +171,10 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } } catch (error) { try { - await reporterLifecycle.closeAsync(); + await reporterCloseAsync(); } catch (closeError) { - throw new AggregateError([error, closeError], 'Rush failed and the reporter host could not close.'); + processLifecycle.reportCloseError(closeError as Error); + processLifecycle.setExitCode(1); } throw error; } diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 6142f6dcd30..333dd7726c9 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -41,6 +41,8 @@ export interface IRushReporterHostOptions { readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; + readonly forceLegacy?: boolean; + readonly selectedRushVersion?: string; } export interface IRushReporterSelection { @@ -50,6 +52,7 @@ export interface IRushReporterSelection { readonly commandJson: boolean; readonly enabled: boolean; readonly reporterControlsOwnedByFrontend: boolean; + readonly reporterValueFlagsToStrip: readonly string[]; readonly reason: | 'explicit --reporter' | 'repository experiment' @@ -65,6 +68,8 @@ export interface IInitializedRushReporterHost { } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); +const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; +const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; interface IParsedReporterControls { readonly reporters: readonly string[]; @@ -182,7 +187,10 @@ function readValue( return { value, consumedNext: true }; } -export function stripReporterValueControls(argv: readonly string[]): string[] { +export function stripReporterValueControls( + argv: readonly string[], + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS +): string[] { const result: string[] = []; for (let index: number = 0; index < argv.length; index++) { const argument: string = argv[index]; @@ -192,7 +200,7 @@ export function stripReporterValueControls(argv: readonly string[]): string[] { } const equalsIndex: number = argument.indexOf('='); const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); - if (!REPORTER_VALUE_FLAGS.has(flagName)) { + if (!valueFlagsToStrip.has(flagName)) { result.push(argument); continue; } @@ -203,7 +211,10 @@ export function stripReporterValueControls(argv: readonly string[]): string[] { return result; } -function parseReporterControls(argv: readonly string[]): IParsedReporterControls { +function parseReporterControls( + argv: readonly string[], + includeOutputAndLogLevelControls: boolean +): IParsedReporterControls { const reporters: string[] = []; const logLevels: string[] = []; const outputs: string[] = []; @@ -226,25 +237,27 @@ function parseReporterControls(argv: readonly string[]): IParsedReporterControls index += reporter.consumedNext ? 1 : 0; continue; } - const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( - argv, - index, - '--log-level' - ); - if (logLevel) { - logLevels.push(logLevel.value); - index += logLevel.consumedNext ? 1 : 0; - continue; - } - const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( - argv, - index, - '--output' - ); - if (output) { - outputs.push(output.value); - index += output.consumedNext ? 1 : 0; - continue; + if (includeOutputAndLogLevelControls) { + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--log-level' + ); + if (logLevel) { + logLevels.push(logLevel.value); + index += logLevel.consumedNext ? 1 : 0; + continue; + } + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--output' + ); + if (output) { + outputs.push(output.value); + index += output.consumedNext ? 1 : 0; + continue; + } } quiet ||= argument === '--quiet' || argument === '-q'; @@ -360,6 +373,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = commandJson: separateJsonControls(argv).commandJson, enabled: false, reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], reason: 'pre-major legacy default' }; } @@ -367,6 +381,15 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const cwd: string = options.cwd ?? process.cwd(); const commandJson: boolean = separateJsonControls(argv).commandJson; + const selectionControls: IParsedReporterControls = parseReporterControls(argv, false); + const requestedReporter: string | undefined = selectionControls.reporters[0]; + if (requestedReporter !== undefined && !isSupportedReporterName(requestedReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(requestedReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + if (isLegacyEmergencyFallbackRequested(env)) { return { reporter: 'legacy', @@ -374,12 +397,31 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson, enabled: false, - reporterControlsOwnedByFrontend: true, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : ALL_REPORTER_VALUE_FLAGS, reason: 'RUSH_REPORTER=legacy' }; } - const controls: IParsedReporterControls = parseReporterControls(argv); + if (options.forceLegacy) { + if (requestedReporter !== undefined && requestedReporter !== 'legacy') { + throw new Error( + `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + + `does not support --reporter=${requestedReporter}. Remove the explicit reporter request or use ` + + 'the Rush version bundled with this frontend.' + ); + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); @@ -392,7 +434,6 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return 'rush'; } - const requestedReporter: string | undefined = controls.reporters[0]; if (requestedReporter === undefined) { const environmentReporter: string | undefined = env.RUSH_REPORTER; if (environmentReporter?.trim()) { @@ -401,23 +442,16 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = 'Use an explicit --reporter option, or set RUSH_REPORTER=legacy for the emergency fallback.' ); } - if (controls.outputs.length > 0 || controls.logLevels.length > 0) { - if (!options.repositoryOptIn) { - throw new Error( - '--output and --log-level require an explicit non-legacy --reporter selection or the ' + - 'useRushReporter repository experiment.' - ); - } - } if (options.repositoryOptIn) { const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; return { reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', - logLevel: resolveLogLevel(controls, env, true), - outputs: resolveOutputs(controls.outputs, cwd), + logLevel: resolveLogLevel(selectionControls, env, true), + outputs: [], commandJson, enabled: true, - reporterControlsOwnedByFrontend: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], reason: 'repository experiment' }; } @@ -427,22 +461,13 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson, enabled: false, - reporterControlsOwnedByFrontend: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], reason: 'pre-major legacy default' }; } - if (!isSupportedReporterName(requestedReporter)) { - throw new Error( - `Unsupported reporter ${JSON.stringify(requestedReporter)}. ` + - 'Supported values are default, ai, json, plaintext, file, and legacy.' - ); - } - if (requestedReporter === 'legacy') { - if (controls.outputs.length > 0 || controls.logLevels.length > 0) { - throw new Error('--output and --log-level are not supported with --reporter=legacy.'); - } return { reporter: 'legacy', logLevel: 'normal', @@ -450,10 +475,12 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = commandJson, enabled: false, reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: REPORTER_SELECTION_FLAG, reason: 'explicit --reporter' }; } + const controls: IParsedReporterControls = parseReporterControls(argv, true); const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; if (requestedReporter === 'default' && !stdout.isTTY) { throw new Error( @@ -468,6 +495,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = commandJson, enabled: true, reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, reason: 'explicit --reporter' }; } diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 49839c5d639..87cf7224f56 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,7 +6,15 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; -import { ReporterHost, type IReporterEventSink } from '@rushstack/rush-reporter'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { + ReporterHost, + ReporterManager, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink +} from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; import { @@ -15,6 +23,7 @@ import { type IRushReporterSelection } from '../RushReporterHost'; import { RushVersionSelector } from '../RushVersionSelector'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; async function createInitializedHostAsync( order: string[], @@ -24,6 +33,7 @@ async function createInitializedHostAsync( const host: ReporterHost = new ReporterHost({ env: {} }); await host.manager.initializeAsync(); let closePromise: Promise | undefined; + const hasExplicitReporter: boolean = reason === 'explicit --reporter'; return { host, sink: host.getSink(), @@ -33,7 +43,8 @@ async function createInitializedHostAsync( outputs: [], commandJson: false, enabled: false, - reporterControlsOwnedByFrontend: true, + reporterControlsOwnedByFrontend: hasExplicitReporter, + reporterValueFlagsToStrip: hasExplicitReporter ? ['--reporter'] : [], reason }, closeAsync: (timeoutMs?: number) => { @@ -46,6 +57,68 @@ async function createInitializedHostAsync( }; } +async function createEnabledHostAsync( + closeAsync?: (timeoutMs?: number) => Promise +): Promise { + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + return { + host, + sink: host.getSink(), + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: closeAsync ?? ((timeoutMs?: number) => host.manager.closeAsync(timeoutMs)) + }; +} + +async function createPhaseHangingHostAsync( + hangingPhase: 'flush' | 'close' +): Promise { + const never: Promise = new Promise(() => undefined); + const reporter: IReporter = { + name: `hang-${hangingPhase}`, + initializeAsync: async (context: IReporterContext) => { + void context; + }, + report: (event: IReporterEventEnvelope) => { + void event; + }, + flushAsync: () => (hangingPhase === 'flush' ? never : Promise.resolve()), + closeAsync: () => (hangingPhase === 'close' ? never : Promise.resolve()) + }; + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(reporter); + const host: ReporterHost = new ReporterHost({ env: {}, manager }); + await manager.initializeAsync(); + let closePromise: Promise | undefined; + return { + host, + sink: host.getSink(), + selection: { + reporter: 'json', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: true, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], + reason: 'explicit --reporter' + }, + closeAsync: (timeoutMs?: number) => { + closePromise ??= manager.closeAsync(timeoutMs); + return closePromise; + } + }; +} + interface ITestProcessLifecycle extends IRushFrontendProcessLifecycle { beforeExitListener: (() => void) | undefined; readonly signalListeners: Map<'SIGINT' | 'SIGTERM', () => void>; @@ -142,83 +215,95 @@ describe(launchRushFrontendAsync.name, () => { } }); - it('creates the host before selecting and installing a repository Rush version', async () => { - const order: string[] = []; - let receivedSink: IReporterEventSink | undefined; + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + const createVersionSelector: jest.Mock = jest.fn(); + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined } + }), + createVersionSelector, + processLifecycle + }) + ).rejects.toThrow(/selected Rush engine 5\.177\.0 does not support --reporter=json/); + + expect(createVersionSelector).not.toHaveBeenCalled(); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + await expect(fs.promises.stat(outputPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + process.argv = originalArgv; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('keeps an implicit repository opt-in on the legacy path for an incompatible engine', async () => { const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { void version; void configuration; - order.push('version-selection'); - receivedSink = (launchOptions as unknown as { reporterEventSink?: IReporterEventSink }) - .reporterEventSink; + receivedArgv = [...process.argv]; await launchOptions.reporterCloseAsync(); }; - const originalArgv: string[] = process.argv; - process.argv = ['node', 'rush', 'build', '--reporter=json', '--log-level=debug']; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let selection: IRushReporterSelection | undefined; try { await launchRushFrontendAsync({ currentPackageVersion: '5.178.1', rushVersionToLoad: '5.177.0', - configuration: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, launchOptions: { isManaged: true }, currentRushLib: rushLib, - initializeReporterHostAsync: async () => { - const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); - return { - ...initialized, - selection: { - ...initialized.selection, - reporter: 'json', - logLevel: 'debug', - enabled: true, - reason: 'explicit --reporter' - } - }; + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; }, createVersionSelector: () => versionSelector, processLifecycle }); - expect(order).toEqual(['host', 'version-selection', 'close']); - expect(process.argv).toEqual(['node', 'rush', 'build']); - expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); } finally { process.argv = originalArgv; } }); - it('uses beforeExit to close when an older engine ignores the optional close callback', async () => { - const order: string[] = []; - const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); - const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); - versionSelector.ensureRushVersionInstalledAsync = async () => { - order.push('legacy-engine'); - }; - - await launchRushFrontendAsync({ - currentPackageVersion: '5.178.1', - rushVersionToLoad: '5.177.0', - configuration: undefined, - launchOptions: { isManaged: true }, - currentRushLib: rushLib, - initializeReporterHostAsync: () => createInitializedHostAsync(order), - createVersionSelector: () => versionSelector, - processLifecycle - }); - - expect(order).toEqual(['host', 'legacy-engine']); - processLifecycle.beforeExitListener!(); - await new Promise((resolve: () => void) => setImmediate(resolve)); - - expect(order).toEqual(['host', 'legacy-engine', 'close']); - expect(processLifecycle.beforeExitListener).toBeUndefined(); - expect(processLifecycle.signalListeners.size).toBe(0); - }); - it('flushes and closes an explicit output through the real frontend boundary on success', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -264,6 +349,67 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('flushes an explicit output before the parser process.exit backstop', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-parser-exit-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'build', '--reporter=json', `--output=json://${outputPath}`]; + let outputAtExit: string | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: directory, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + emitCommandStarted(launchOptions.reporterEventSink); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); + Object.defineProperty(parser, '_rushOptions', { + value: { reporterCloseAsync: launchOptions.reporterCloseAsync } + }); + process.exitCode = 1; + + return new Promise((resolve: () => void) => { + jest.spyOn(process, 'exit').mockImplementation(() => { + outputAtExit = fs.readFileSync(outputPath, 'utf8'); + resolve(); + return undefined as never; + }); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + ( + parser as unknown as { + _reportErrorAndSetExitCode(error: Error): void; + } + )._reportErrorAndSetExitCode(new Error('parser failed')); + }); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(JSON.parse(outputAtExit!).type).toBe('commandStarted'); + } finally { + jest.restoreAllMocks(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + it('preserves pass-through arguments byte-for-byte through the real frontend boundary', async () => { const originalArgv: string[] = process.argv; const passThroughArguments: string[] = [ @@ -329,9 +475,56 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('preserves custom value parameters when repository opt-in enables reporting', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let receivedArgv: string[] | undefined; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(receivedArgv).toEqual(process.argv); + } finally { + process.argv = originalArgv; + } + }); + it('closes exactly once when the engine rejects', async () => { - const order: string[] = []; - const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const closeAsync: jest.Mock, [number?]> = jest.fn(async () => undefined); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); await expect( launchRushFrontendAsync({ @@ -346,7 +539,7 @@ describe(launchRushFrontendAsync.name, () => { }) ).rejects.toThrow('engine rejected'); - expect(order).toEqual(['host', 'close']); + expect(closeAsync).toHaveBeenCalledTimes(1); }); it('closes exactly once when command selection fails', async () => { @@ -374,28 +567,87 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('preserves the command failure when reporter close also fails', async () => { + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(async () => { + throw new Error('close failed'); + }); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => Promise.reject(new Error('command failed')), + processLifecycle + }) + ).rejects.toThrow('command failed'); + + expect(processLifecycle.exitCodes).toEqual([1]); + expect(processLifecycle.closeErrors).toEqual([expect.objectContaining({ message: 'close failed' })]); + }); + + it.each(['rush', 'rushx', 'rush-pnpm'])( + 'does not install lifecycle listeners for the disabled %s path', + async (commandName) => { + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + commandName, + 'custom', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let receivedArgv: string[] | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + commandName: commandName as 'rush' | 'rushx' | 'rush-pnpm', + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedArgv = [...process.argv]; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(receivedArgv).toEqual(process.argv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + } + ); + it('uses a bounded close before preserving signal termination', async () => { let resolveClose: (() => void) | undefined; const closePromise: Promise = new Promise((resolve: () => void) => { resolveClose = resolve; }); const closeAsync: jest.Mock, [number?]> = jest.fn(() => closePromise); - const host: ReporterHost = new ReporterHost({ env: {} }); - await host.manager.initializeAsync(); - const initialized: IInitializedRushReporterHost = { - host, - sink: host.getSink(), - selection: { - reporter: 'legacy', - logLevel: 'normal', - outputs: [], - commandJson: false, - enabled: false, - reporterControlsOwnedByFrontend: true, - reason: 'pre-major legacy default' - }, - closeAsync - }; + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); await launchRushFrontendAsync({ @@ -423,4 +675,77 @@ describe(launchRushFrontendAsync.name, () => { expect(processLifecycle.signalListeners.size).toBe(0); expect(processLifecycle.beforeExitListener).toBeUndefined(); }); + + it('enforces the signal deadline when a longer close is already in flight', async () => { + jest.useFakeTimers(); + const closeAsync: jest.Mock, [number?]> = jest.fn(() => new Promise(() => undefined)); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + void launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + await Promise.resolve(); + expect(closeAsync).toHaveBeenCalledWith(undefined); + + processLifecycle.signalListeners.get('SIGTERM')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGTERM']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + expect(closeAsync).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + + it.each(['flush', 'close'] as const)( + 'uses one signal deadline when the reporter %s phase hangs', + async (hangingPhase) => { + jest.useFakeTimers(); + const initialized: IInitializedRushReporterHost = await createPhaseHangingHostAsync(hangingPhase); + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + executeCurrentRush: () => undefined, + processLifecycle + }); + + processLifecycle.signalListeners.get('SIGINT')!(); + await jest.advanceTimersByTimeAsync(1999); + expect(processLifecycle.terminatedSignals).toEqual([]); + await jest.advanceTimersByTimeAsync(1); + + expect(processLifecycle.terminatedSignals).toEqual(['SIGINT']); + expect(processLifecycle.closeErrors).toEqual([ + expect.objectContaining({ message: 'Reporter close exceeded the 2000ms signal deadline.' }) + ]); + } finally { + jest.useRealTimers(); + } + } + ); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 691bfaa0396..75c9f81112c 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -19,14 +19,17 @@ function resolve( argv: readonly string[], env: Record = {}, isTTY: boolean = false, - repositoryOptIn: boolean = false + repositoryOptIn: boolean = false, + forceLegacy: boolean = false ): IRushReporterSelection { return resolveRushReporterSelection({ argv, env, cwd: '/repo', stdout: { isTTY, columns: 100, write: () => undefined }, - repositoryOptIn + repositoryOptIn, + forceLegacy, + selectedRushVersion: forceLegacy ? '5.177.0' : undefined }); } @@ -52,6 +55,8 @@ describe(resolveRushReporterSelection.name, () => { expect(resolve(['build'], testCase.env, testCase.isTTY)).toMatchObject({ reporter: 'legacy', enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], reason: 'pre-major legacy default' }); } @@ -93,7 +98,12 @@ describe(resolveRushReporterSelection.name, () => { it('allows reporter controls with the repository experiment', () => { expect( - resolve(['build', '--log-level=debug', '--output=json://./events.jsonl'], {}, false, true) + resolve( + ['build', '--reporter=plaintext', '--log-level=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ) ).toMatchObject({ reporter: 'plaintext', logLevel: 'debug', @@ -106,6 +116,24 @@ describe(resolveRushReporterSelection.name, () => { }); }); + it('preserves custom value parameters when the repository experiment selects the reporter implicitly', () => { + expect( + resolve( + ['custom', '--output', 'artifact.zip', '--log-level', 'custom-level', '--verbose'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'verbose', + outputs: [], + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + }); + it('does not consume rush-pnpm or rushx reporter arguments', () => { expect( resolveRushReporterSelection({ @@ -134,6 +162,7 @@ describe(resolveRushReporterSelection.name, () => { ).toMatchObject({ reporter: 'legacy', enabled: false, + reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], reason: 'RUSH_REPORTER=legacy' }); }); @@ -233,22 +262,41 @@ describe(resolveRushReporterSelection.name, () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', logLevel: 'normal', - enabled: false + enabled: false, + reporterControlsOwnedByFrontend: false }); expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', logLevel: 'normal', - enabled: false + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter'] }); }); - it('ignores reporter environment selection before the gate but validates explicit controls', () => { + it('ignores reporter environment selection before the gate and preserves custom value controls', () => { expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); - expect(() => resolve(['build', '--output=json:\/\/events.jsonl'])).toThrow( - /require an explicit non-legacy --reporter/ + expect(resolve(['custom', '--output=json://events.jsonl', '--log-level=custom'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + }); + + it('rejects explicit non-legacy reporters for incompatible selected engines', () => { + expect(() => resolve(['build', '--reporter=json'], {}, false, true, true)).toThrow( + /selected Rush engine 5\.177\.0 does not support --reporter=json/ ); + expect(resolve(['build', '--verbose'], {}, false, true, true)).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }); }); it('rejects an interactive reporter on non-TTY output', () => { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json index 708080a190c..919daad035d 100644 --- a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json +++ b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Add the pre-major ReporterHost, separator-safe global controls, and deterministic reporter finalization while preserving legacy output by default.", + "comment": "Add pre-major frontend reporter controls with legacy command compatibility, selected-engine gating, and deterministic reporter finalization.", "type": "patch" } ], 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 c6f5880848b..d913bb774e3 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 @@ -184,6 +184,14 @@ Object { "required": false, "shortName": undefined, }, + Object { + "description": "If this flag is specified, long lists of package names will not be truncated. This has no effect if the --json flag is also specified.", + "environmentVariable": undefined, + "kind": "Flag", + "longName": "--verbose", + "required": false, + "shortName": undefined, + }, Object { "description": "(EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only within that subspace (ignoring other subspaces). This parameter is required when the \\"subspacesEnabled\\" setting is set to true in subspaces.json.", "environmentVariable": undefined, @@ -1279,10 +1287,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display build logs instead of only status", + "description": "Display the logs during the build, rather than just displaying the build status summary", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose-build-output", + "longName": "--verbose", "required": false, "shortName": "-v", }, @@ -1433,10 +1441,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display build logs instead of only status", + "description": "Display the logs during the build, rather than just displaying the build status summary", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose-build-output", + "longName": "--verbose", "required": false, "shortName": "-v", }, @@ -1590,10 +1598,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display build logs instead of only status", + "description": "Display the logs during the build, rather than just displaying the build status summary", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose-build-output", + "longName": "--verbose", "required": false, "shortName": "-v", }, diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 6f40ae88f65..c293ca5fa0f 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -8,7 +8,6 @@ import { type CommandLineFlagParameter, CommandLineHelper } from '@rushstack/ts-command-line'; -import { SUPPORTED_LOG_LEVELS, SUPPORTED_REPORTER_NAMES } from '@rushstack/rush-reporter'; import { InternalError, AlreadyReportedError, Text } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, @@ -85,7 +84,6 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _debugParameter: CommandLineFlagParameter; private readonly _quietParameter: CommandLineFlagParameter; - private readonly _verboseParameter: CommandLineFlagParameter; private readonly _restrictConsoleOutput: boolean = RushCommandLineParser.shouldRestrictConsoleOutput(); private readonly _rushOptions: IRushCommandLineParserOptions; private readonly _terminalProvider: ConsoleTerminalProvider; @@ -126,29 +124,6 @@ export class RushCommandLineParser extends CommandLineParser { description: 'Hide rush startup information' }); - this._verboseParameter = this.defineFlagParameter({ - parameterLongName: '--verbose', - description: 'Show detailed command and reporter output' - }); - - this.defineChoiceParameter({ - parameterLongName: '--reporter', - alternatives: [...SUPPORTED_REPORTER_NAMES], - description: 'Select the Rush output reporter' - }); - - this.defineStringListParameter({ - parameterLongName: '--output', - argumentName: 'DESTINATION', - description: 'Add a reporter output destination such as file://./rush.log' - }); - - this.defineChoiceParameter({ - parameterLongName: '--log-level', - alternatives: [...SUPPORTED_LOG_LEVELS], - description: 'Set the reporter log level' - }); - const terminalProvider: ConsoleTerminalProvider = new ConsoleTerminalProvider(); this._terminalProvider = terminalProvider; const terminal: Terminal = new Terminal(this._terminalProvider); @@ -228,10 +203,6 @@ export class RushCommandLineParser extends CommandLineParser { return this._quietParameter.value; } - public get isVerbose(): boolean { - return this._verboseParameter.value; - } - public get terminal(): ITerminal { return this._terminal; } @@ -591,16 +562,15 @@ export class RushCommandLineParser extends CommandLineParser { } }; - const reporterCloseAsync: (() => Promise) | undefined = this._rushOptions.reporterCloseAsync; const telemetryFlushAsync: Promise | undefined = this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed() ? this.telemetry.ensureFlushedAsync() : undefined; - if (reporterCloseAsync || telemetryFlushAsync) { + if (this._rushOptions.reporterCloseAsync || telemetryFlushAsync) { const pendingFlushes: Promise[] = []; - if (reporterCloseAsync) { - pendingFlushes.push(reporterCloseAsync()); + if (this._rushOptions.reporterCloseAsync) { + pendingFlushes.push(this._closeReporterAsync()); } if (telemetryFlushAsync) { pendingFlushes.push(telemetryFlushAsync); @@ -616,7 +586,7 @@ export class RushCommandLineParser extends CommandLineParser { await this._rushOptions.reporterCloseAsync?.(); } catch (error) { process.exitCode = 1; - throw error; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); } } } diff --git a/libraries/rush-lib/src/cli/actions/CheckAction.ts b/libraries/rush-lib/src/cli/actions/CheckAction.ts index 4a1cda2f8ec..fcf752b0657 100644 --- a/libraries/rush-lib/src/cli/actions/CheckAction.ts +++ b/libraries/rush-lib/src/cli/actions/CheckAction.ts @@ -11,6 +11,7 @@ import { getVariantAsync, VARIANT_PARAMETER } from '../../api/Variants'; export class CheckAction extends BaseRushAction { private readonly _jsonFlag: CommandLineFlagParameter; + private readonly _verboseFlag: CommandLineFlagParameter; private readonly _subspaceParameter: CommandLineStringParameter | undefined; private readonly _variantParameter: CommandLineStringParameter; @@ -31,6 +32,12 @@ export class CheckAction extends BaseRushAction { parameterLongName: '--json', description: 'If this flag is specified, output will be in JSON format.' }); + this._verboseFlag = this.defineFlagParameter({ + parameterLongName: '--verbose', + description: + 'If this flag is specified, long lists of package names will not be truncated. ' + + `This has no effect if the ${this._jsonFlag.longName} flag is also specified.` + }); this._subspaceParameter = this.defineStringParameter({ parameterLongName: '--subspace', argumentName: 'SUBSPACE_NAME', @@ -68,7 +75,7 @@ export class CheckAction extends BaseRushAction { VersionMismatchFinder.rushCheck(this.rushConfiguration, this.terminal, { variant, printAsJson: this._jsonFlag.value, - truncateLongPackageNameLists: !this.parser.isVerbose, + truncateLongPackageNameLists: !this._verboseFlag.value, subspace: this._subspaceParameter?.value ? this.rushConfiguration.getSubspace(this._subspaceParameter.value) : this.rushConfiguration.defaultSubspace diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 4361afa6645..1b2b7aa5812 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -148,7 +148,7 @@ export class PhasedScriptAction extends BaseScriptAction i private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; - private readonly _legacyVerboseParameter: CommandLineFlagParameter; + private readonly _verboseParameter: CommandLineFlagParameter; private readonly _parallelismParameter: CommandLineStringParameter | undefined; private readonly _ignoreHooksParameter: CommandLineFlagParameter; private readonly _watchParameter: CommandLineFlagParameter | undefined; @@ -234,10 +234,10 @@ export class PhasedScriptAction extends BaseScriptAction i cwd: this.parser.cwd }); - this._legacyVerboseParameter = this.defineFlagParameter({ - parameterLongName: '--verbose-build-output', + this._verboseParameter = this.defineFlagParameter({ + parameterLongName: '--verbose', parameterShortName: '-v', - description: 'Display build logs instead of only status' + description: 'Display the logs during the build, rather than just displaying the build status summary' }); this._includePhaseDeps = this.defineFlagParameter({ @@ -455,7 +455,7 @@ export class PhasedScriptAction extends BaseScriptAction i }); } - const isQuietMode: boolean = !(this.parser.isVerbose || this._legacyVerboseParameter.value); + const isQuietMode: boolean = !this._verboseParameter.value; const changedProjectsOnly: boolean = !!this._changedProjectsOnlyParameter?.value; diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index dcdbca339ff..42b32b78d2c 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -114,6 +114,26 @@ describe('RushCommandLineParser', () => { }); }); + describe("'custom-output' action", () => { + it('preserves custom parameters that overlap reporter controls', async () => { + const { parser, repoPath } = await getCommandLineParserInstanceAsync( + 'basicAndRunBuildActionRepo', + 'custom-output' + ); + process.argv.push('--output', 'custom-artifact.zip', '--log-level', 'custom-level', '--verbose'); + + await expect(parser.executeAsync()).resolves.toEqual(true); + + expect(JsonFile.load(`${repoPath}/custom-output-args.json`)).toEqual([ + '--output', + 'custom-artifact.zip', + '--log-level', + 'custom-level', + '--verbose' + ]); + }); + }); + describe("'rebuild' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunRebuildActionRepo'; diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 6be98150c90..b8113aad056 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -49,6 +49,17 @@ describe('RushCommandLineParser reporter close', () => { expect(closeAsync).toHaveBeenCalledTimes(1); }); + it.each(['build', 'rebuild', 'check'])('accepts post-command --verbose for %s', async (commandName) => { + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => undefined + }); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(parser.executeAsync([commandName, '--verbose', '--help'])).resolves.toBe(true); + }); + it('waits for reporter close before an explicit parser exit', async () => { let resolveClose: (() => void) | undefined; const closeAsync: jest.Mock, []> = jest.fn( @@ -77,9 +88,27 @@ describe('RushCommandLineParser reporter close', () => { expect(exitSpy).not.toHaveBeenCalled(); resolveClose!(); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve: () => void) => setImmediate(resolve)); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it('reports close failure without rejecting from parser finalization', async () => { + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_rushOptions', { + value: { reporterCloseAsync: async () => Promise.reject(new Error('close failed')) } + }); + const errorSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + process.exitCode = 0; + + const closeReporterAsync: () => Promise = ( + parser as unknown as { + _closeReporterAsync(): Promise; + } + )._closeReporterAsync.bind(parser); + await expect(closeReporterAsync()).resolves.toBeUndefined(); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); + }); }); 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 30c901116f6..efe3e717b7d 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 @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`CommandLineHelp prints the global help 1`] = ` -"usage: rush [-h] [-d] [-q] [--verbose] - [--reporter {default,ai,json,plaintext,file,legacy}] - [--output DESTINATION] [--log-level {quiet,normal,verbose,debug}] - ... +"usage: rush [-h] [-d] [-q] ... Rush makes life easier for JavaScript developers who develop, build, and publish many packages from a central Git repo. It is designed to handle very @@ -84,13 +81,6 @@ Optional arguments: -d, --debug Show the full call stack if an error occurs while executing the tool -q, --quiet Hide rush startup information - --verbose Show detailed command and reporter output - --reporter {default,ai,json,plaintext,file,legacy} - Select the Rush output reporter - --output DESTINATION Add a reporter output destination such as file://. - /rush.log - --log-level {quiet,normal,verbose,debug} - Set the reporter log level [bold]For detailed help about a specific command, use: rush -h[normal] " @@ -314,8 +304,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose-build-output - Display build logs instead of only status + -v, --verbose Display the logs during the build, rather than just + displaying the build status summary --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -419,7 +409,9 @@ Optional arguments: `; exports[`CommandLineHelp prints the help for each action: check 1`] = ` -"usage: rush check [-h] [--json] [--subspace SUBSPACE_NAME] [--variant VARIANT] +"usage: rush check [-h] [--json] [--verbose] [--subspace SUBSPACE_NAME] + [--variant VARIANT] + Checks each project's package.json files and ensures that all dependencies are of the same version throughout the repository. @@ -428,6 +420,9 @@ Optional arguments: -h, --help Show this help message and exit. --json If this flag is specified, output will be in JSON format. + --verbose If this flag is specified, long lists of package + names will not be truncated. This has no effect if + the --json flag is also specified. --subspace SUBSPACE_NAME (EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only @@ -603,8 +598,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose-build-output - Display build logs instead of only status + -v, --verbose Display the logs during the build, rather than just + displaying the build status summary --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -1250,8 +1245,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose-build-output - Display build logs instead of only status + -v, --verbose Display the logs during the build, rather than just + displaying the build status summary --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might diff --git a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json new file mode 100644 index 00000000000..e153ab726a7 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,32 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-output", + "summary": "Exercises custom parameters that overlap reporter controls.", + "shellCommand": "node custom-output.js" + } + ], + "parameters": [ + { + "parameterKind": "string", + "longName": "--output", + "argumentName": "OUTPUT", + "description": "Custom output value.", + "associatedCommands": ["custom-output"] + }, + { + "parameterKind": "string", + "longName": "--log-level", + "argumentName": "LEVEL", + "description": "Custom log level.", + "associatedCommands": ["custom-output"] + }, + { + "parameterKind": "flag", + "longName": "--verbose", + "description": "Custom verbose flag.", + "associatedCommands": ["custom-output"] + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js new file mode 100644 index 00000000000..378b29c86a2 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/custom-output.js @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const fs = require('node:fs'); +const path = require('node:path'); + +fs.writeFileSync( + path.join(process.cwd(), 'custom-output-args.json'), + `${JSON.stringify(process.argv.slice(2), undefined, 2)}\n` +); From 00f16e146981574ea925ca8ce03f0c3f9472093e Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:39:13 +0000 Subject: [PATCH 006/164] Refine reporter flag ownership Preserve unsupported custom reporter values until frontend ownership is unambiguous, and narrow emergency legacy stripping to the reporter selection flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushReporterHost.ts | 60 ++++- apps/rush/src/test/RushFrontend.test.ts | 208 +++++++++++++++++- apps/rush/src/test/RushReporterHost.test.ts | 44 +++- .../cli/test/RushCommandLineParser.test.ts | 12 +- .../common/config/rush/command-line.json | 7 + 5 files changed, 317 insertions(+), 14 deletions(-) diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 333dd7726c9..e7aec54de1c 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -265,14 +265,38 @@ function parseReporterControls( debug ||= argument === '--debug' || argument === '-d'; } - if (reporters.length > 1) { + return { reporters, logLevels, outputs, quiet, verbose, debug }; +} + +function validateReporterControlMultiplicity( + controls: IParsedReporterControls, + includeOutputAndLogLevelControls: boolean +): void { + if (controls.reporters.length > 1) { throw new Error('--reporter may be specified only once.'); } - if (logLevels.length > 1) { + if (includeOutputAndLogLevelControls && controls.logLevels.length > 1) { throw new Error('--log-level may be specified only once.'); } +} - return { reporters, logLevels, outputs, quiet, verbose, debug }; +function hasReporterOutputControl(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + const prefix: string = '--output='; + const value: string | undefined = argument.startsWith(prefix) + ? argument.slice(prefix.length) + : argument === '--output' && argv[index + 1] && !argv[index + 1].startsWith('-') + ? argv[index + 1] + : undefined; + if (value && /^(?:file|json):\/\//.test(value)) { + return true; + } + } + return false; } function resolveLogLevel( @@ -382,15 +406,31 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const commandJson: boolean = separateJsonControls(argv).commandJson; const selectionControls: IParsedReporterControls = parseReporterControls(argv, false); - const requestedReporter: string | undefined = selectionControls.reporters[0]; - if (requestedReporter !== undefined && !isSupportedReporterName(requestedReporter)) { + const reporterOwnershipEstablished: boolean = + options.repositoryOptIn === true || + hasReporterOutputControl(argv) || + selectionControls.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + if (reporterOwnershipEstablished) { + validateReporterControlMultiplicity(selectionControls, false); + } + const reporterValue: string | undefined = reporterOwnershipEstablished + ? selectionControls.reporters[0] + : undefined; + if (reporterValue !== undefined && !isSupportedReporterName(reporterValue)) { throw new Error( - `Unsupported reporter ${JSON.stringify(requestedReporter)}. ` + + `Unsupported reporter ${JSON.stringify(reporterValue)}. ` + 'Supported values are default, ai, json, plaintext, file, and legacy.' ); } + const requestedReporter: ReporterName | undefined = reporterValue; if (isLegacyEmergencyFallbackRequested(env)) { + const reporterValueFlagsToStrip: readonly string[] = + requestedReporter === 'legacy' + ? REPORTER_SELECTION_FLAG + : requestedReporter === undefined + ? [] + : ALL_REPORTER_VALUE_FLAGS; return { reporter: 'legacy', logLevel: 'normal', @@ -398,7 +438,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = commandJson, enabled: false, reporterControlsOwnedByFrontend: requestedReporter !== undefined, - reporterValueFlagsToStrip: requestedReporter === undefined ? [] : ALL_REPORTER_VALUE_FLAGS, + reporterValueFlagsToStrip, reason: 'RUSH_REPORTER=legacy' }; } @@ -407,8 +447,9 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = if (requestedReporter !== undefined && requestedReporter !== 'legacy') { throw new Error( `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + - `does not support --reporter=${requestedReporter}. Remove the explicit reporter request or use ` + - 'the Rush version bundled with this frontend.' + `cannot safely use --reporter=${requestedReporter} because this frontend cannot verify its ` + + 'reporter close contract. Remove the explicit reporter request or use the Rush version bundled ' + + 'with this frontend.' ); } return { @@ -481,6 +522,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } const controls: IParsedReporterControls = parseReporterControls(argv, true); + validateReporterControlMultiplicity(controls, true); const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; if (requestedReporter === 'default' && !stdout.isTTY) { throw new Error( diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 87cf7224f56..7319fed6271 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; import { ReporterHost, @@ -242,7 +243,7 @@ describe(launchRushFrontendAsync.name, () => { createVersionSelector, processLifecycle }) - ).rejects.toThrow(/selected Rush engine 5\.177\.0 does not support --reporter=json/); + ).rejects.toThrow(/selected Rush engine 5\.177\.0 cannot safely use --reporter=json/); expect(createVersionSelector).not.toHaveBeenCalled(); expect(processLifecycle.beforeExitListener).toBeUndefined(); @@ -304,6 +305,209 @@ describe(launchRushFrontendAsync.name, () => { } }); + it.each([ + { + name: 'unsupported custom reporter', + reporter: 'junit', + expectedArgv: [ + 'node', + 'rush', + 'custom', + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ] + }, + { + name: 'explicit legacy reporter', + reporter: 'legacy', + expectedArgv: ['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'] + } + ])('preserves the old-engine $name escape path', async ({ reporter, expectedArgv }) => { + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + let receivedArgv: string[] | undefined; + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + receivedArgv = [...process.argv]; + await launchOptions.reporterCloseAsync(); + }; + const originalArgv: string[] = process.argv; + process.argv = [ + 'node', + 'rush', + 'custom', + '--reporter', + reporter, + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ]; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + createVersionSelector: () => versionSelector, + processLifecycle + }); + + expect(receivedArgv).toEqual(expectedArgv); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + { + name: 'unsupported reporter as a custom value', + reporter: 'junit', + env: {}, + expectedArguments: [ + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + expectedEnabled: false + }, + { + name: 'supported reporter as frontend ownership', + reporter: 'json', + env: {}, + expectedArguments: ['--verbose'], + expectedEnabled: true + }, + { + name: 'explicit legacy under the emergency override', + reporter: 'legacy', + env: { RUSH_REPORTER: 'legacy' }, + expectedArguments: ['--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + expectedEnabled: false + } + ])('runs the real custom command fixture with $name', async (testCase) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-command-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const reporterOutputPath: string = path.join(directory, 'reporter.jsonl'); + const outputValue: string = testCase.reporter === 'json' ? `json://${reporterOutputPath}` : 'custom.zip'; + const logLevelValue: string = testCase.reporter === 'json' ? 'debug' : 'custom'; + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = [ + 'node', + 'rush', + 'custom-output', + '--reporter', + testCase.reporter, + '--output', + outputValue + ]; + if (testCase.reporter !== 'json') { + process.argv.push('--log-level', logLevelValue); + } + process.argv.push('--verbose'); + let selection: IRushReporterSelection | undefined; + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: testCase.env, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(selection?.enabled).toBe(testCase.expectedEnabled); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-output-args.json'), 'utf8')) + ).toEqual(testCase.expectedArguments); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects an unsupported reporter typo when repository opt-in establishes ownership', async () => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'custom-output', '--reporter=junit']; + + try { + await expect( + launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: { useRushReporter: true } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: (options) => + initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }), + processLifecycle: createTestProcessLifecycle() + }) + ).rejects.toThrow('Unsupported reporter "junit"'); + } finally { + process.argv = originalArgv; + } + }); + it('flushes and closes an explicit output through the real frontend boundary on success', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -598,6 +802,8 @@ describe(launchRushFrontendAsync.name, () => { 'node', commandName, 'custom', + '--reporter', + 'junit', '--output', 'custom.zip', '--log-level', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 75c9f81112c..21fb4b9d05a 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -165,6 +165,33 @@ describe(resolveRushReporterSelection.name, () => { reporterValueFlagsToStrip: ['--reporter', '--output', '--log-level'], reason: 'RUSH_REPORTER=legacy' }); + + const legacySelection: IRushReporterSelection = resolve( + ['custom', '--reporter=legacy', '--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + { RUSH_REPORTER: 'legacy' } + ); + expect(legacySelection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterValueFlagsToStrip: ['--reporter'], + reason: 'RUSH_REPORTER=legacy' + }); + expect( + stripReporterValueControls( + [ + 'node', + 'rush', + 'custom', + '--reporter=legacy', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + new Set(legacySelection.reporterValueFlagsToStrip) + ) + ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); }); it('removes reporter-only value controls before invoking a legacy engine', () => { @@ -276,7 +303,18 @@ describe(resolveRushReporterSelection.name, () => { it('ignores reporter environment selection before the gate and preserves custom value controls', () => { expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); - expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); + expect(resolve(['custom', '--reporter=junit'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect(() => resolve(['custom', '--reporter=junit'], {}, false, true)).toThrow( + /Unsupported reporter "junit"/ + ); + expect(() => resolve(['custom', '--reporter=junit', '--output=json://./events.jsonl'])).toThrow( + /Unsupported reporter "junit"/ + ); expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); expect(resolve(['custom', '--output=json://events.jsonl', '--log-level=custom'])).toMatchObject({ reporter: 'legacy', @@ -287,9 +325,9 @@ describe(resolveRushReporterSelection.name, () => { it('rejects explicit non-legacy reporters for incompatible selected engines', () => { expect(() => resolve(['build', '--reporter=json'], {}, false, true, true)).toThrow( - /selected Rush engine 5\.177\.0 does not support --reporter=json/ + /selected Rush engine 5\.177\.0 cannot safely use --reporter=json/ ); - expect(resolve(['build', '--verbose'], {}, false, true, true)).toMatchObject({ + expect(resolve(['custom', '--reporter=junit', '--verbose'], {}, false, false, true)).toMatchObject({ reporter: 'legacy', logLevel: 'normal', enabled: false, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 42b32b78d2c..2f8cc85d331 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -120,11 +120,21 @@ describe('RushCommandLineParser', () => { 'basicAndRunBuildActionRepo', 'custom-output' ); - process.argv.push('--output', 'custom-artifact.zip', '--log-level', 'custom-level', '--verbose'); + process.argv.push( + '--reporter', + 'junit', + '--output', + 'custom-artifact.zip', + '--log-level', + 'custom-level', + '--verbose' + ); await expect(parser.executeAsync()).resolves.toEqual(true); expect(JsonFile.load(`${repoPath}/custom-output-args.json`)).toEqual([ + '--reporter', + 'junit', '--output', 'custom-artifact.zip', '--log-level', diff --git a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json index e153ab726a7..c7d4e88c76b 100644 --- a/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json @@ -8,6 +8,13 @@ } ], "parameters": [ + { + "parameterKind": "string", + "longName": "--reporter", + "argumentName": "REPORTER", + "description": "Custom reporter value.", + "associatedCommands": ["custom-output"] + }, { "parameterKind": "string", "longName": "--output", From a5f1f7c8b58fb43d6bd1a0516c0646cdc9566b23 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:48:23 +0000 Subject: [PATCH 007/164] Tolerate value-less custom reporter flags Probe reporter ownership without requiring a value, then enforce strict reporter parsing only after frontend ownership is established. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushReporterHost.ts | 33 ++++++++-- apps/rush/src/test/RushFrontend.test.ts | 64 +++++++++++++++++++ apps/rush/src/test/RushReporterHost.test.ts | 23 ++++++- .../cli/test/RushCommandLineParser.test.ts | 14 ++++ .../common/config/rush/command-line.json | 18 ++++++ .../custom-reporter-flag.js | 10 +++ 6 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json create mode 100644 libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index e7aec54de1c..dfa9b84a7e4 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -213,7 +213,8 @@ export function stripReporterValueControls( function parseReporterControls( argv: readonly string[], - includeOutputAndLogLevelControls: boolean + includeOutputAndLogLevelControls: boolean, + tolerateMissingReporterValue: boolean = false ): IParsedReporterControls { const reporters: string[] = []; const logLevels: string[] = []; @@ -227,6 +228,13 @@ function parseReporterControls( if (argument === '--') { break; } + if ( + tolerateMissingReporterValue && + argument === '--reporter' && + (!argv[index + 1] || argv[index + 1].startsWith('-')) + ) { + continue; + } const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( argv, index, @@ -302,7 +310,8 @@ function hasReporterOutputControl(argv: readonly string[]): boolean { function resolveLogLevel( controls: IParsedReporterControls, env: Record, - includeEnvironment: boolean + includeEnvironment: boolean, + useLegacyAliasPrecedence: boolean = false ): ReporterLogLevel { const requestedLevels: ReporterLogLevel[] = []; const explicitLogLevel: string | undefined = controls.logLevels[0]; @@ -315,6 +324,17 @@ function resolveLogLevel( } requestedLevels.push(explicitLogLevel); } + if (useLegacyAliasPrecedence && explicitLogLevel === undefined) { + if (controls.debug) { + return 'debug'; + } + if (controls.verbose) { + return 'verbose'; + } + if (controls.quiet) { + return 'quiet'; + } + } if (controls.quiet) { requestedLevels.push('quiet'); } @@ -405,11 +425,14 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const cwd: string = options.cwd ?? process.cwd(); const commandJson: boolean = separateJsonControls(argv).commandJson; - const selectionControls: IParsedReporterControls = parseReporterControls(argv, false); + const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); const reporterOwnershipEstablished: boolean = options.repositoryOptIn === true || hasReporterOutputControl(argv) || - selectionControls.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + reporterProbe.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + const selectionControls: IParsedReporterControls = reporterOwnershipEstablished + ? parseReporterControls(argv, false) + : reporterProbe; if (reporterOwnershipEstablished) { validateReporterControlMultiplicity(selectionControls, false); } @@ -487,7 +510,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; return { reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', - logLevel: resolveLogLevel(selectionControls, env, true), + logLevel: resolveLogLevel(selectionControls, env, true, true), outputs: [], commandJson, enabled: true, diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 7319fed6271..30b4081f405 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -508,6 +508,70 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('runs a value-less custom reporter flag through the real frontend and parser boundary', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-custom-reporter-flag-')); + const repoPath: string = path.join(directory, 'repo'); + const fixturePath: string = path.resolve( + __dirname, + '../../../../libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo' + ); + await fs.promises.cp(fixturePath, repoPath, { recursive: true }); + const originalArgv: string[] = process.argv; + const originalExitCode: string | number | null | undefined = process.exitCode; + process.argv = ['node', 'rush', 'custom-reporter-flag', '--reporter']; + const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); + let selection: IRushReporterSelection | undefined; + + try { + EnvironmentConfiguration.reset(); + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: {}, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + return parser.executeAsync().then(() => undefined); + }, + processLifecycle + }); + + expect(selection).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect( + JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-reporter-flag-args.json'), 'utf8')) + ).toEqual(['--reporter']); + expect(processLifecycle.beforeExitListener).toBeUndefined(); + expect(processLifecycle.signalListeners.size).toBe(0); + } finally { + EnvironmentConfiguration.reset(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + it('flushes and closes an explicit output through the real frontend boundary on success', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const outputPath: string = path.join(directory, 'events.jsonl'); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 21fb4b9d05a..fc3e630773e 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -94,6 +94,7 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'repository experiment' }); + expect(resolve(['build', '--quiet', '--verbose', '--debug'], {}, false, true).logLevel).toBe('debug'); }); it('allows reporter controls with the repository experiment', () => { @@ -323,6 +324,24 @@ describe(resolveRushReporterSelection.name, () => { }); }); + it('probes value-less custom reporter flags without claiming ownership', () => { + expect(resolve(['custom', '--reporter'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['custom', '--reporter', '--verbose'])).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(() => resolve(['custom', '--reporter'], {}, false, true)).toThrow(/--reporter requires a value/); + expect(() => resolve(['custom', '--reporter', '--output=json://./events.jsonl'])).toThrow( + /--reporter requires a value/ + ); + expect(() => resolve(['custom', '--reporter=json', '--reporter'])).toThrow(/--reporter requires a value/); + }); + it('rejects explicit non-legacy reporters for incompatible selected engines', () => { expect(() => resolve(['build', '--reporter=json'], {}, false, true, true)).toThrow( /selected Rush engine 5\.177\.0 cannot safely use --reporter=json/ @@ -372,10 +391,12 @@ describe(resolveRushReporterSelection.name, () => { }); it('surfaces unsupported and incomplete controls with actionable errors', () => { - expect(() => resolve(['build', '--reporter'])).toThrow(/--reporter requires a value/); expect(() => resolve(['build', '--reporter=json', '--reporter=ai'])).toThrow( /may be specified only once/ ); + expect(() => resolve(['build', '--reporter=json', '--log-level=quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); expect(() => resolve(['build', '--reporter=json', '--output=plaintext://./output.txt'])).toThrow( /supports file:\/\/ and json:\/\// ); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 2f8cc85d331..64d47c1cfdf 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -170,6 +170,20 @@ describe('RushCommandLineParser', () => { cwdOptionEquals(secondSpawn, `${repoPath}/b`); }); }); + + describe("'custom-reporter-flag' action", () => { + it('preserves a value-less custom reporter flag', async () => { + const { parser, repoPath } = await getCommandLineParserInstanceAsync( + 'basicAndRunRebuildActionRepo', + 'custom-reporter-flag' + ); + process.argv.push('--reporter'); + + await expect(parser.executeAsync()).resolves.toEqual(true); + + expect(JsonFile.load(`${repoPath}/custom-reporter-flag-args.json`)).toEqual(['--reporter']); + }); + }); }); describe("in repo with 'rebuild' command overridden", () => { diff --git a/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json new file mode 100644 index 00000000000..dbd2433e3db --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,18 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-reporter-flag", + "summary": "Exercises a value-less custom reporter flag.", + "shellCommand": "node custom-reporter-flag.js" + } + ], + "parameters": [ + { + "parameterKind": "flag", + "longName": "--reporter", + "description": "Custom reporter flag.", + "associatedCommands": ["custom-reporter-flag"] + } + ] +} diff --git a/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js new file mode 100644 index 00000000000..0e0f0a9db49 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunRebuildActionRepo/custom-reporter-flag.js @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const fs = require('node:fs'); +const path = require('node:path'); + +fs.writeFileSync( + path.join(process.cwd(), 'custom-reporter-flag-args.json'), + `${JSON.stringify(process.argv.slice(2), undefined, 2)}\n` +); From f093fbff2143bfda63417aae0b8949b297e909b8 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:06:05 +0000 Subject: [PATCH 008/164] Stop execution after parser initialization failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../rush-lib/src/cli/RushCommandLineParser.ts | 50 +++++++++++++------ ...RushCommandLineParserReporterClose.test.ts | 31 ++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index c293ca5fa0f..715089901dd 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -89,6 +89,8 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminalProvider: ConsoleTerminalProvider; private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; + private _initializationFailed: boolean = false; + private _reporterClosePromise: Promise | undefined; /** * The current working directory that was used to find the Rush configuration. @@ -144,7 +146,7 @@ export class RushCommandLineParser extends CommandLineParser { this.rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFilePath); } } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } NodeJsCompatibility.warnAboutCompatibilityIssues({ @@ -167,6 +169,10 @@ export class RushCommandLineParser extends CommandLineParser { restrictConsoleOutput: this._restrictConsoleOutput, rushGlobalFolder: this.rushGlobalFolder }); + if (this._initializationFailed) { + this._autocreateBuildCommand = true; + return; + } const pluginCommandLineConfigurations: ICustomCommandLineConfigurationInfo[] = this.pluginManager.tryGetCustomCommandLineConfigurationInfos(); @@ -179,18 +185,22 @@ export class RushCommandLineParser extends CommandLineParser { this._autocreateBuildCommand = !hasBuildCommandInPlugin; this._populateActions(); + if (this._initializationFailed) { + return; + } for (const { commandLineConfiguration, pluginLoader } of pluginCommandLineConfigurations) { try { this._addCommandLineConfigActions(commandLineConfiguration); } catch (e) { - this._reportErrorAndSetExitCode( + this._reportInitializationErrorAndSetExitCode( new Error( `Error from plugin ${pluginLoader.pluginName} by ${pluginLoader.packageName}: ${( e as Error ).toString()}` ) ); + return; } } } @@ -238,6 +248,11 @@ export class RushCommandLineParser extends CommandLineParser { } public override async executeAsync(args?: string[]): Promise { + if (this._initializationFailed) { + await this._closeReporterAsync(); + return false; + } + // debugParameter will be correctly parsed during super.executeAsync(), so manually parse here. const passThroughSeparatorIndex: number = process.argv.indexOf('--', 2); const rushArgv: string[] = @@ -373,7 +388,7 @@ export class RushCommandLineParser extends CommandLineParser { this._populateScriptActions(); } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } } @@ -548,6 +563,7 @@ export class RushCommandLineParser extends CommandLineParser { this.flushTelemetry(); + const exitCode: string | number = process.exitCode ?? 1; const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. @@ -555,11 +571,7 @@ export class RushCommandLineParser extends CommandLineParser { // performs nontrivial work that can throw an exception. Either the Rush class would need // to handle reporting for those exceptions, or else _populateActions() should be moved // to a RushCommandLineParser lifecycle stage that can handle it. - if (process.exitCode !== undefined) { - process.exit(process.exitCode); - } else { - process.exit(1); - } + process.exit(exitCode); }; const telemetryFlushAsync: Promise | undefined = @@ -581,12 +593,22 @@ export class RushCommandLineParser extends CommandLineParser { } } - private async _closeReporterAsync(): Promise { - try { - await this._rushOptions.reporterCloseAsync?.(); - } catch (error) { - process.exitCode = 1; - process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + private _reportInitializationErrorAndSetExitCode(error: Error): void { + this._initializationFailed = true; + this._reportErrorAndSetExitCode(error); + } + + private _closeReporterAsync(): Promise { + if (!this._reporterClosePromise) { + this._reporterClosePromise = (async (): Promise => { + try { + await this._rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + } + })(); } + return this._reporterClosePromise; } } diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index b8113aad056..a5fc7186214 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -3,6 +3,7 @@ import { RushCommandLineParser } from '../RushCommandLineParser'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import { RushConfiguration } from '../../api/RushConfiguration'; describe('RushCommandLineParser reporter close', () => { const originalExitCode: string | number | null | undefined = process.exitCode; @@ -86,6 +87,7 @@ describe('RushCommandLineParser reporter close', () => { expect(closeAsync).toHaveBeenCalledTimes(1); expect(exitSpy).not.toHaveBeenCalled(); + process.exitCode = 0; resolveClose!(); await new Promise((resolve: () => void) => setImmediate(resolve)); @@ -93,6 +95,35 @@ describe('RushCommandLineParser reporter close', () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it('does not execute after an initialization failure', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('configuration failed'); + }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const executePromise: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(executePromise).resolves.toBe(false); + await new Promise((resolve: () => void) => setImmediate(resolve)); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it('reports close failure without rejecting from parser finalization', async () => { const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_rushOptions', { From 61a3e54c73dcabf4417b032de2a933faa0ad6c95 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 19:50:18 +0000 Subject: [PATCH 009/164] Force nonzero parser failure exits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- libraries/rush-lib/src/cli/RushCommandLineParser.ts | 8 +++++++- .../cli/test/RushCommandLineParserReporterClose.test.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 715089901dd..b91d3ad8a6b 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -563,7 +563,13 @@ export class RushCommandLineParser extends CommandLineParser { this.flushTelemetry(); - const exitCode: string | number = process.exitCode ?? 1; + const configuredExitCode: string | number | undefined = process.exitCode; + const numericExitCode: number = Number(configuredExitCode); + const exitCode: number = + configuredExitCode !== undefined && Number.isInteger(numericExitCode) && numericExitCode !== 0 + ? numericExitCode + : 1; + process.exitCode = exitCode; const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index a5fc7186214..601bb70185d 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -6,9 +6,14 @@ import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import { RushConfiguration } from '../../api/RushConfiguration'; describe('RushCommandLineParser reporter close', () => { - const originalExitCode: string | number | null | undefined = process.exitCode; + let originalExitCode: string | number | undefined; const originalArgv: string[] = process.argv; + beforeEach(() => { + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + afterEach(() => { process.exitCode = originalExitCode; process.argv = originalArgv; @@ -76,7 +81,7 @@ describe('RushCommandLineParser reporter close', () => { .spyOn(process, 'exit') .mockImplementation(() => undefined as never); jest.spyOn(console, 'error').mockImplementation(() => undefined); - process.exitCode = 1; + process.exitCode = 0; const reportErrorAndSetExitCode: (error: Error) => void = ( parser as unknown as { From 23de51af4ecfb45e4ee729d13719b6acbac09a60 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:06:20 +0000 Subject: [PATCH 010/164] Expose scoped RushSession reporter producers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +- apps/rush/src/RushFrontend.ts | 10 +- apps/rush/src/test/RushFrontend.test.ts | 57 ++++- ...ter-r3a-session-sink_2026-08-28-02-38.json | 11 + .../build-tests-subspace/pnpm-lock.yaml | 1 + .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 3 + common/reviews/api/rush-lib.api.md | 47 +++++ libraries/rush-lib/src/api/Rush.ts | 13 ++ .../rush-lib/src/cli/RushCommandLineParser.ts | 9 +- .../src/cli/actions/BaseRushAction.ts | 5 +- libraries/rush-lib/src/index.ts | 16 ++ .../PluginLoader/PluginLoaderBase.ts | 17 ++ .../src/pluginFramework/PluginManager.ts | 14 +- .../src/pluginFramework/RushSession.test.ts | 152 ++++++++++++++ .../src/pluginFramework/RushSession.ts | 198 ++++++++++++++++-- libraries/rush-sdk/package.json | 1 + .../test/__snapshots__/script.test.ts.snap | 4 +- 18 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/pluginFramework/RushSession.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a67..920ae96235f 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ILaunchOptions } from '@microsoft/rush-lib'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; /** * The cross-version launch contract owned by the Rush frontend. @@ -13,6 +12,6 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; * options, so an older engine can safely ignore the new property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { - readonly reporterEventSink: IReporterEventSink; + readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..044a060d6b9 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { randomUUID } from 'node:crypto'; + import type { ILaunchOptions } from '@microsoft/rush-lib'; import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; @@ -30,6 +32,7 @@ export interface IRushFrontendOptions { currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions ) => void | Promise; + readonly createSessionId?: () => string; readonly processLifecycle?: IRushFrontendProcessLifecycle; } @@ -132,6 +135,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, processLifecycle = createProcessLifecycle() } = options; @@ -152,9 +156,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink, + reporter: { + eventSink: reporterHost.sink, + sessionId + }, reporterCloseAsync }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 30b4081f405..233b3d7e2d1 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; import { @@ -18,6 +19,7 @@ import { } from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, type IInitializedRushReporterHost, @@ -178,7 +180,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; - let receivedOptions: Record | undefined; + let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -195,7 +197,7 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; order.push('engine'); - receivedOptions = launchOptions as unknown as Record; + receivedOptions = launchOptions; return launchOptions.reporterCloseAsync(); }, processLifecycle @@ -203,9 +205,10 @@ describe(launchRushFrontendAsync.name, () => { expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); - expect(receivedOptions?.reporterEventSink).toEqual( - expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink - ); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String) + }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); @@ -216,6 +219,46 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend' + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -603,7 +646,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); return launchOptions.reporterCloseAsync(); }, processLifecycle: createTestProcessLifecycle() @@ -644,7 +687,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); Object.defineProperty(parser, '_rushOptions', { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json new file mode 100644 index 00000000000..fa12adb823f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose an optional scoped reporter producer API to Rush actions and plugins while preserving legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 5a14a3f4f84..52b4949bd4d 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -4990,6 +4990,7 @@ snapshots: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-reporter': file:../../../libraries/reporter(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 4555e68e184..c12a6241868 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "2f7908424d103b2f677e95bcd5d85a385b75eda2", + "pnpmShrinkwrapHash": "e3fd56b3094928b8856da3821af80ef4deee0529", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "e8fe4109038ad6e9b1e97cbb83e63d9094d37fe4" + "packageJsonInjectedDependenciesHash": "b0634100322878d7a992fa589326473bc3965ab6" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 76e3feb4d1d..b4eb9477abb 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4407,6 +4407,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-reporter': + specifier: workspace:* + version: link:../reporter '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 37f1ea3ef31..13d17d81750 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -13,14 +13,22 @@ import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICreateRushDiagnosticOptions } from '@rushstack/rush-reporter'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterEventScope } from '@rushstack/rush-reporter'; +import { IReporterEventSink } from '@rushstack/rush-reporter'; +import { IRushDiagnostic } from '@rushstack/rush-reporter'; +import { IScopedLogger } from '@rushstack/rush-reporter'; +import { IScopedMessageOptions } from '@rushstack/rush-reporter'; +import { IScopedReporter } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +36,11 @@ import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; +import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; import type { PerformanceEntry as PerformanceEntry_2 } from 'node:perf_hooks'; +import { ReporterExtensionEventName } from '@rushstack/rush-reporter'; +import { ReporterJsonValue } from '@rushstack/rush-reporter'; +import { ReporterPrivacyClassification } from '@rushstack/rush-reporter'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; @@ -148,6 +160,8 @@ export class CommonVersionsConfiguration { saveAsync(): Promise; } +export { createRushDiagnostic } + export { CredentialCache } // @beta @@ -439,6 +453,8 @@ export interface ICreateOperationsContext { readonly rushConfiguration: RushConfiguration; } +export { ICreateRushDiagnosticOptions } + export { ICredentialCacheEntry } export { ICredentialCacheOptions } @@ -557,6 +573,8 @@ export interface ILaunchOptions { // @internal builtInPluginConfigurations?: _IBuiltInPluginConfiguration[]; isManaged: boolean; + // @internal + reporter?: IRushSessionReporterOptions; terminalProvider?: ITerminalProvider; } @@ -911,6 +929,10 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +export { IReporterEventScope } + +export { IReporterEventSink } + // @beta export interface IRushCommand { readonly actionName: string; @@ -943,6 +965,8 @@ export interface IRushCommandLineSpec { // @beta (undocumented) export type IRushConfigurationProjectForSnapshot = Pick; +export { IRushDiagnostic } + // @alpha (undocumented) export interface IRushPhaseSharding { count: number; @@ -983,10 +1007,23 @@ export interface IRushReportingConfiguration { export interface IRushSessionOptions { // (undocumented) getIsDebugMode: () => boolean; + reporter?: IRushSessionReporterOptions; // (undocumented) terminalProvider: ITerminalProvider; } +// @beta +export interface IRushSessionReporterOptions { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; +} + +export { IScopedLogger } + +export { IScopedMessageOptions } + +export { IScopedReporter } + // @beta export interface IStopwatchResult { get duration(): number; @@ -1288,6 +1325,8 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @beta export type Parallelism = number | IParallelismScalar; +export { parseReporterExtensionEventName } + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1365,6 +1404,12 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +export { ReporterExtensionEventName } + +export { ReporterJsonValue } + +export { ReporterPrivacyClassification } + // @public export class RepoStateFile { readonly filePath: string; @@ -1702,6 +1747,8 @@ export class RushSession { getCobuildLockProviderFactory(cobuildLockProviderName: string): CobuildLockProviderFactory | undefined; // (undocumented) getLogger(name: string): ILogger; + getReporter(scope?: IReporterEventScope): IScopedReporter | undefined; + getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined; // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index a51af8b0930..e75815484d6 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -14,6 +14,7 @@ import { RushXCommandLine } from '../cli/RushXCommandLine'; import { CommandLineMigrationAdvisor } from '../cli/CommandLineMigrationAdvisor'; import { EnvironmentVariableNames } from './EnvironmentConfiguration'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { IRushSessionReporterOptions } from '../pluginFramework/RushSession'; import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; @@ -58,6 +59,17 @@ export interface ILaunchOptions { * @internal */ builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + + /** + * Supplies the structured event sink owned by the Rush frontend. + * + * @remarks + * This is an internal cross-version frontend-to-engine handoff. Reporter + * selection and concrete reporter instances remain owned by the frontend. + * + * @internal + */ + reporter?: IRushSessionReporterOptions; } let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; @@ -98,6 +110,7 @@ export class Rush { const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations, + reporter: options.reporter, reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index b91d3ad8a6b..ba043788d88 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -57,7 +57,7 @@ import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; import { type ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; -import { RushSession } from '../pluginFramework/RushSession'; +import { type IRushSessionReporterOptions, RushSession } from '../pluginFramework/RushSession'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; import { RushAlerts } from '../utilities/RushAlerts'; @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; } @@ -131,7 +132,7 @@ export class RushCommandLineParser extends CommandLineParser { const terminal: Terminal = new Terminal(this._terminalProvider); this._terminal = terminal; this._rushOptions = this._normalizeOptions(options || {}); - const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations } = this._rushOptions; + const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this._rushOptions; let rushJsonFilePath: string | undefined; try { @@ -159,7 +160,8 @@ export class RushCommandLineParser extends CommandLineParser { this.rushSession = new RushSession({ getIsDebugMode: () => this.isDebug, - terminalProvider + terminalProvider, + reporter }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, @@ -338,6 +340,7 @@ export class RushCommandLineParser extends CommandLineParser { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporter: options.reporter, reporterCloseAsync: options.reporterCloseAsync }; } diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index 256224d10f4..62222ba7d76 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { CommandLineAction, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; import { LockFile } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { IScopedReporter } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; @@ -44,6 +45,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme protected readonly rushConfiguration: RushConfiguration | undefined; protected readonly terminal: ITerminal; protected readonly rushSession: RushSession; + protected readonly reporter: IScopedReporter | undefined; protected readonly rushGlobalFolder: RushGlobalFolder; protected readonly parser: RushCommandLineParser; @@ -57,6 +59,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme this.rushConfiguration = rushConfiguration; this.terminal = terminal; this.rushSession = rushSession; + this.reporter = rushSession.getReporter({ commandName: this.actionName }); this.rushGlobalFolder = rushGlobalFolder; } @@ -115,7 +118,7 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { return this._eventHooksManager; } - protected declare readonly rushConfiguration: RushConfiguration; + declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { if (!this.rushConfiguration) { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 0fdd200e775..6f0bb4c5e67 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -168,10 +168,26 @@ export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { RushSession, type IRushSessionOptions, + type IRushSessionReporterOptions, type CloudBuildCacheProviderFactory, type CobuildLockProviderFactory } from './pluginFramework/RushSession'; +export { + createRushDiagnostic, + parseReporterExtensionEventName, + type ICreateRushDiagnosticOptions, + type IReporterEventScope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedLogger, + type IScopedMessageOptions, + type IScopedReporter, + type ReporterExtensionEventName, + type ReporterJsonValue, + type ReporterPrivacyClassification +} from '@rushstack/rush-reporter'; + export { type IRushCommand, type IGlobalCommand, diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index e2b235d113f..adbc3ab26d4 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -7,6 +7,8 @@ import { FileSystem, InternalError, JsonFile, + PackageJsonLookup, + type IPackageJson, type JsonObject, JsonSchema } from '@rushstack/node-core-library'; @@ -51,6 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; + private _packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -84,6 +87,20 @@ export abstract class PluginLoaderBase< return this._getRushPluginManifest(); } + public get packageVersion(): string { + if (!this._packageVersionCache) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( + path.join(this.packageFolder, 'package.json') + ); + if (!packageJson.version) { + throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); + } + this._packageVersionCache = packageJson.version; + } + + return this._packageVersionCache; + } + public getCommandLineConfiguration(): CommandLineConfiguration | undefined { const commandLineJsonFilePath: string | undefined = this._getCommandLineJsonFilePath(); if (!commandLineJsonFilePath) { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 9a5181e078c..0e353f3a574 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -9,7 +9,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import { BuiltInPluginLoader, type IBuiltInPluginConfiguration } from './PluginLoader/BuiltInPluginLoader'; import type { IRushPlugin } from './IRushPlugin'; import { AutoinstallerPluginLoader } from './PluginLoader/AutoinstallerPluginLoader'; -import type { RushSession } from './RushSession'; +import { _createRushSessionForPlugin, type RushSession } from './RushSession'; import type { PluginLoaderBase } from './PluginLoader/PluginLoaderBase'; import { Rush } from '../api/Rush'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; @@ -205,7 +205,7 @@ export class PluginManager { const plugin: IRushPlugin | undefined = pluginLoader.load(); this._loadedPluginNames.add(pluginName); if (plugin) { - this._applyPlugin(plugin, pluginName); + this._applyPlugin(plugin, pluginLoader); } } } @@ -227,9 +227,15 @@ export class PluginManager { }); } - private _applyPlugin(plugin: IRushPlugin, pluginName: string): void { + private _applyPlugin(plugin: IRushPlugin, pluginLoader: PluginLoaderBase): void { + const { packageName, pluginName } = pluginLoader; try { - plugin.apply(this._rushSession, this._rushConfiguration); + const pluginSession: RushSession = _createRushSessionForPlugin(this._rushSession, () => ({ + packageName, + packageVersion: pluginLoader.packageVersion, + component: pluginName + })); + plugin.apply(pluginSession, this._rushConfiguration); } catch (e) { throw new InternalError(`Error applying "${pluginName}": ${e}`); } diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts new file mode 100644 index 00000000000..26a48160731 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { + IReporterEmitEventInput, + IReporterEventSource, + IReporterEventSink +} from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { Rush } from '../api/Rush'; +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + +function createSession(reporter?: IRushSessionReporterOptions): RushSession { + return new RushSession({ + getIsDebugMode: () => false, + terminalProvider: new StringBufferTerminalProvider(), + reporter + }); +} + +describe(RushSession.name, () => { + it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { + const session: RushSession = createSession(); + + expect(session.getReporter()).toBeUndefined(); + expect(session.getScopedLogger()).toBeUndefined(); + expect(session.getLogger('legacy')).toBeDefined(); + expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + }); + + it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-1' }); + const scope = { commandName: 'build', projectName: '@scope/project' }; + const reporter = session.getReporter(scope); + + expect(reporter).toBeDefined(); + expect(Object.keys(reporter!).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + expect('getSink' in reporter!).toBe(false); + expect('reporters' in reporter!).toBe(false); + expect(Object.keys(session)).not.toContain('reporter'); + + scope.commandName = 'spoofed'; + reporter!.emitMessage({ severity: 'info', text: 'hello' }); + + expect(sink.inputs).toHaveLength(1); + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-1', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { + commandName: 'build', + projectName: '@scope/project' + } + }); + expect(sink.inputs[0]).not.toHaveProperty('eventId'); + expect(sink.inputs[0]).not.toHaveProperty('sequence'); + expect(sink.inputs[0]).not.toHaveProperty('timestamp'); + expect(sink.inputs[0]).not.toHaveProperty('required'); + }); + + it('isolates plugin sources while sharing session state', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-2' }); + const pluginSource: IReporterEventSource = { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }; + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => pluginSource); + + expect(pluginSession.hooks).toBe(session.hooks); + (pluginSource as { packageName: string }).packageName = '@acme/spoofed'; + pluginSession.getReporter({ projectName: '@scope/a' })!.emitMessage({ + severity: 'info', + text: 'plugin' + }); + session.getReporter({ projectName: '@scope/b' })!.emitMessage({ + severity: 'info', + text: 'rush' + }); + + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }, + scope: { projectName: '@scope/a' } + }); + expect(sink.inputs[1]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { projectName: '@scope/b' } + }); + }); + + it('rejects invalid explicitly supplied reporter options', () => { + expect(() => + createSession({ + eventSink: {} as IReporterEventSink, + sessionId: 'session-3' + }) + ).toThrow(/eventSink/); + + expect(() => createSession({ eventSink: new CapturingSink(), sessionId: ' ' })).toThrow(/sessionId/); + }); + + it('does not resolve plugin identity when reporting is disabled', () => { + const session: RushSession = createSession(); + const getSource = jest.fn((): IReporterEventSource => { + throw new Error('should not resolve source'); + }); + + expect(_createRushSessionForPlugin(session, getSource)).toBe(session); + expect(getSource).not.toHaveBeenCalled(); + }); + + it('binds built-in action reporters to their command name', () => { + const sink: CapturingSink = new CapturingSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: os.tmpdir(), + reporter: { eventSink: sink, sessionId: 'session-4' } + }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; + + expect(action?.reporter).toBeDefined(); + action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); + expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 0e512764438..e017a9a8cbc 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { + RushSessionReporting, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IScopedLogger, + type IScopedReporter +} from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import { type ILogger, type ILoggerOptions, Logger } from './logging/Logger'; @@ -11,12 +19,43 @@ import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCa import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +/** + * The reporter channel supplied by the Rush frontend for a single Rush session. + * + * @remarks + * The frontend owns reporter selection and the concrete reporter instances. Rush + * only receives this presentation-free sink and binds producer identities before + * exposing scoped reporters to actions and plugins. + * + * @beta + */ +export interface IRushSessionReporterOptions { + /** + * The typed event sink owned by the Rush frontend. + */ + readonly eventSink: IReporterEventSink; + + /** + * The identifier assigned to this Rush session by the frontend. + */ + readonly sessionId: string; +} + /** * @beta */ export interface IRushSessionOptions { terminalProvider: ITerminalProvider; getIsDebugMode: () => boolean; + + /** + * The optional structured reporter channel for this session. + * + * @remarks + * When omitted, scoped reporter APIs return `undefined` and legacy terminal + * behavior remains unchanged. + */ + reporter?: IRushSessionReporterOptions; } /** @@ -33,20 +72,85 @@ export type CobuildLockProviderFactory = ( cobuildJson: ICobuildJson ) => ICobuildLockProvider | Promise; +interface IRushSessionState { + readonly options: IRushSessionOptions; + readonly cloudBuildCacheProviderFactories: Map; + readonly cobuildLockProviderFactories: Map; + readonly hooks: RushLifecycleHooks; + readonly reporting: RushSessionReporting | undefined; +} + +let _rushLibSource: IReporterEventSource | undefined; +const _rushSessionStates: WeakMap = new WeakMap(); + +function _getRushLibSource(): IReporterEventSource { + if (!_rushLibSource) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for @microsoft/rush-lib'); + } + + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); + if (!packageJson.version) { + throw new InternalError('The @microsoft/rush-lib package.json file does not specify a version'); + } + + _rushLibSource = { + packageName: '@microsoft/rush-lib', + packageVersion: packageJson.version + }; + } + + return _rushLibSource; +} + +function _createReporting( + reporterOptions: IRushSessionReporterOptions | undefined, + source: IReporterEventSource +): RushSessionReporting | undefined { + if (!reporterOptions) { + return undefined; + } + + const { eventSink, sessionId } = reporterOptions; + if (!eventSink || typeof eventSink.emit !== 'function') { + throw new TypeError('RushSession reporter.eventSink must implement IReporterEventSink'); + } + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); + } + + return new RushSessionReporting({ + sink: eventSink, + sessionId, + source: { ...source } + }); +} + +function _getSessionState(rushSession: RushSession): IRushSessionState { + const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); + if (!state) { + throw new InternalError('RushSession state was not initialized'); + } + return state; +} + /** * @beta */ export class RushSession { - private readonly _options: IRushSessionOptions; - private readonly _cloudBuildCacheProviderFactories: Map = new Map(); - private readonly _cobuildLockProviderFactories: Map = new Map(); - public readonly hooks: RushLifecycleHooks; public constructor(options: IRushSessionOptions) { - this._options = options; - this.hooks = new RushLifecycleHooks(); + _rushSessionStates.set(this, { + options, + cloudBuildCacheProviderFactories: new Map(), + cobuildLockProviderFactories: new Map(), + hooks: this.hooks, + reporting: options.reporter ? _createReporting(options.reporter, _getRushLibSource()) : undefined + }); } public getLogger(name: string): ILogger { @@ -54,51 +158,113 @@ export class RushSession { throw new InternalError('RushSession.getLogger(name) called without a name'); } - const terminalProvider: ITerminalProvider = this._options.terminalProvider; + const { options } = _getSessionState(this); + const terminalProvider: ITerminalProvider = options.terminalProvider; const loggerOptions: ILoggerOptions = { loggerName: name, - getShouldPrintStacks: () => this._options.getIsDebugMode(), + getShouldPrintStacks: () => options.getIsDebugMode(), terminalProvider }; return new Logger(loggerOptions); } public get terminalProvider(): ITerminalProvider { - return this._options.terminalProvider; + return _getSessionState(this).options.terminalProvider; + } + + /** + * Creates a structured reporter bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * The returned API cannot access concrete reporters or override the session and + * source identity bound by Rush. + */ + public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { + return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + } + + /** + * Creates a structured logger bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * This API is additive; {@link RushSession.getLogger} and terminal output remain + * available during the pre-major compatibility period. + */ + public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { + return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); } public registerCloudBuildCacheProviderFactory( cacheProviderName: string, factory: CloudBuildCacheProviderFactory ): void { - if (this._cloudBuildCacheProviderFactories.has(cacheProviderName)) { + const { cloudBuildCacheProviderFactories } = _getSessionState(this); + if (cloudBuildCacheProviderFactories.has(cacheProviderName)) { throw new Error(`A build cache provider factory for ${cacheProviderName} has already been registered`); } - this._cloudBuildCacheProviderFactories.set(cacheProviderName, factory); + cloudBuildCacheProviderFactories.set(cacheProviderName, factory); } public getCloudBuildCacheProviderFactory( cacheProviderName: string ): CloudBuildCacheProviderFactory | undefined { - return this._cloudBuildCacheProviderFactories.get(cacheProviderName); + return _getSessionState(this).cloudBuildCacheProviderFactories.get(cacheProviderName); } public registerCobuildLockProviderFactory( cobuildLockProviderName: string, factory: CobuildLockProviderFactory ): void { - if (this._cobuildLockProviderFactories.has(cobuildLockProviderName)) { + const { cobuildLockProviderFactories } = _getSessionState(this); + if (cobuildLockProviderFactories.has(cobuildLockProviderName)) { throw new Error( `A cobuild lock provider factory for ${cobuildLockProviderName} has already been registered` ); } - this._cobuildLockProviderFactories.set(cobuildLockProviderName, factory); + cobuildLockProviderFactories.set(cobuildLockProviderName, factory); } public getCobuildLockProviderFactory( cobuildLockProviderName: string ): CobuildLockProviderFactory | undefined { - return this._cobuildLockProviderFactories.get(cobuildLockProviderName); + return _getSessionState(this).cobuildLockProviderFactories.get(cobuildLockProviderName); + } +} + +/** + * Creates the RushSession facade passed to one plugin. + * + * @remarks + * This function is internal to rush-lib. PluginManager derives the source from + * trusted loader metadata so the plugin cannot choose another producer identity. + * + * @internal + */ +export function _createRushSessionForPlugin( + rushSession: RushSession, + getSource: () => IReporterEventSource +): RushSession { + const state: IRushSessionState = _getSessionState(rushSession); + if (!state.options.reporter) { + return rushSession; } + + const pluginSession: RushSession = Object.create(RushSession.prototype) as RushSession; + Object.defineProperty(pluginSession, 'hooks', { + configurable: false, + enumerable: true, + value: state.hooks, + writable: false + }); + _rushSessionStates.set(pluginSession, { + options: state.options, + cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, + cobuildLockProviderFactories: state.cobuildLockProviderFactories, + hooks: state.hooks, + reporting: _createReporting(state.options.reporter, getSource()) + }); + return pluginSession; } diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index 801c66f20fa..f42e357738a 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -50,6 +50,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-reporter": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 573fa555e28..80fc60cee1e 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -63,7 +63,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', - '_rushSdk_loadInternalModule' + '_rushSdk_loadInternalModule', + 'createRushDiagnostic', + 'parseReporterExtensionEventName' ]" `; From c6b0349a11d9c57c61146bca955ab25cc801ed64 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:15:43 +0000 Subject: [PATCH 011/164] Emit shadow Rush lifecycle events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + common/reviews/api/rush-lib.api.md | 2 + common/reviews/api/rush-reporter.api.md | 6 + .../diagnostics/RushDiagnosticCodeRegistry.ts | 39 +-- .../src/diagnostics/templates/operation.ts | 3 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 110 +++++- .../cli/scriptActions/PhasedScriptAction.ts | 2 + .../cli/test/RushCommandLineParser.test.ts | 33 +- ...RushCommandLineParserReporterClose.test.ts | 25 ++ libraries/rush-lib/src/cli/test/TestUtils.ts | 6 +- libraries/rush-lib/src/logic/Telemetry.ts | 33 ++ .../operations/ReporterOperationEventSink.ts | 314 ++++++++++++++++++ .../test/OperationGraphEventSink.test.ts | 235 ++++++++++++- .../rush-lib/src/logic/test/Telemetry.test.ts | 44 ++- .../src/pluginFramework/RushSession.test.ts | 95 +++++- .../src/pluginFramework/RushSession.ts | 250 +++++++++++++- 17 files changed, 1179 insertions(+), 40 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..71b7d371662 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit shadow Rush lifecycle, phase-aware operation, diagnostic, telemetry, and command-result events without changing legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..5f23b51eea9 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add a stable structured diagnostic code for Rush command failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 13d17d81750..bf214916f0b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import { IRushDiagnostic } from '@rushstack/rush-reporter'; import { IScopedLogger } from '@rushstack/rush-reporter'; import { IScopedMessageOptions } from '@rushstack/rush-reporter'; import { IScopedReporter } from '@rushstack/rush-reporter'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -1044,6 +1045,7 @@ export interface ITelemetryData { readonly operationResults?: Record; readonly performanceEntries?: readonly PerformanceEntry_2[]; readonly platform?: string; + readonly reporterData?: ITelemetryAggregate; readonly result: 'Succeeded' | 'Failed'; readonly rushVersion?: string; readonly timestampMs?: number; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..ecf09047dbd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1506,6 +1506,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; readonly detailKey: undefined; +}, { + readonly code: "RUSH_COMMAND_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_COMMAND_FAILED.summary"; + readonly detailKey: undefined; }]; // @beta diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 11f5c1d933a..0d697f893e6 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -111,16 +111,13 @@ type AreValidRushDiagnosticCodeSegments< ? IsValidRushDiagnosticCodeSegment : false; -type ValidateRushDiagnosticCode = - TCode extends `RUSH_${infer Segments}` - ? AreValidRushDiagnosticCodeSegments extends true - ? TCode - : never - : never; +type ValidateRushDiagnosticCode = TCode extends `RUSH_${infer Segments}` + ? AreValidRushDiagnosticCodeSegments extends true + ? TCode + : never + : never; -type ValidatedRushDiagnosticCodeDefinitions< - TDefinitions extends readonly IRushDiagnosticCodeDefinition[] -> = { +type ValidatedRushDiagnosticCodeDefinitions = { readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition ? TDefinitions[K] & { readonly code: ValidateRushDiagnosticCode; @@ -130,9 +127,7 @@ type ValidatedRushDiagnosticCodeDefinitions< function defineRushDiagnosticCodeDefinitions< const TDefinitions extends readonly IRushDiagnosticCodeDefinition[] ->( - definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions -): TDefinitions { +>(definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions): TDefinitions { return definitions; } @@ -233,6 +228,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', detailKey: undefined + }, + { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + detailKey: undefined } ]); @@ -257,12 +259,11 @@ export type RushDiagnosticTemplateKey = NonNullable< * * @beta */ -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = - new Map( - RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( - (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const - ) - ); +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const + ) +); export { isValidRushDiagnosticCode } from './RushDiagnosticCode'; -export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; \ No newline at end of file +export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 32107668384..456adc6c8eb 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -11,5 +11,6 @@ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', - 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}', + 'diagnostic.RUSH_COMMAND_FAILED.summary': 'The Rush command {commandName} failed.' } as const; diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index ba043788d88..7a149ca5744 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -16,6 +16,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; +import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter } from '@rushstack/rush-reporter'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushConstants } from '../logic/RushConstants'; @@ -64,6 +65,13 @@ import { RushAlerts } from '../utilities/RushAlerts'; import { initializeDotEnv } from '../logic/dotenv'; import { measureAsyncFn } from '../utilities/performance'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { + _correlateRushSessionError, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionReporterSourceVersion, + _isRushSessionErrorRepresented +} from '../pluginFramework/RushSession'; /** * Options for `RushCommandLineParser`. @@ -91,6 +99,11 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; private _initializationFailed: boolean = false; + private _sessionLifecycleEmitter: LifecycleEmitter | undefined; + private _commandLifecycleEmitter: LifecycleEmitter | undefined; + private _sessionStartTimeMs: number | undefined; + private _commandStartTimeMs: number | undefined; + private _reporterCompletionEmitted: boolean = false; private _reporterClosePromise: Promise | undefined; /** @@ -264,12 +277,30 @@ export class RushCommandLineParser extends CommandLineParser { this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); + this._sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + if (this._sessionLifecycleEmitter) { + this._sessionStartTimeMs = performance.now(); + this._sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => this.pluginManager.tryInitializeUnassociatedPluginsAsync() ); - return await super.executeAsync(args); + const succeeded: boolean = await super.executeAsync(args); + if (!this._reporterCompletionEmitted) { + this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + } + return succeeded; + } catch (error) { + if (!process.exitCode) { + process.exitCode = 1; + } + this._reportErrorAndSetExitCode(error as Error); + return false; } finally { await this._closeReporterAsync(); } @@ -287,6 +318,17 @@ export class RushCommandLineParser extends CommandLineParser { InternalError.breakInDebugger = true; } + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName) { + this._commandLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession, { + commandName + }); + if (this._commandLifecycleEmitter) { + this._commandStartTimeMs = performance.now(); + this._commandLifecycleEmitter.emitCommandStarted({ commandName }); + } + } + try { await this._wrapOnExecuteAsync(); @@ -327,6 +369,7 @@ export class RushCommandLineParser extends CommandLineParser { // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; + this._emitReporterCompletion(0); } catch (error) { this._reportErrorAndSetExitCode(error as Error); } @@ -544,6 +587,20 @@ export class RushCommandLineParser extends CommandLineParser { } private _reportErrorAndSetExitCode(error: Error): void { + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { + parameters: { + commandName: { + value: this.selectedAction?.actionName ?? 'unknown', + privacy: 'public' + } + } + }); + this._commandLifecycleEmitter?.emitDiagnostic(diagnostic); + _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); + } + if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -564,6 +621,7 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } + this._emitReporterCompletion(_getNumericProcessExitCode(1)); this.flushTelemetry(); const configuredExitCode: string | number | undefined = process.exitCode; @@ -620,4 +678,54 @@ export class RushCommandLineParser extends CommandLineParser { } return this._reporterClosePromise; } + + private _emitReporterCompletion(exitCode: number): void { + if (this._reporterCompletionEmitted) { + return; + } + this._reporterCompletionEmitted = true; + + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName && this._commandLifecycleEmitter) { + const durationMs: number | undefined = + this._commandStartTimeMs === undefined ? undefined : performance.now() - this._commandStartTimeMs; + this._commandLifecycleEmitter.emitCommandResult({ + commandName, + succeeded: exitCode === 0, + exitCode + }); + this._commandLifecycleEmitter.emitCommandCompleted({ + commandName, + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + if (this._sessionLifecycleEmitter) { + const durationMs: number | undefined = + this._sessionStartTimeMs === undefined ? undefined : performance.now() - this._sessionStartTimeMs; + this._sessionLifecycleEmitter.emitSessionCompleted({ + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + // Shadow derivation is deliberately observational. process.exitCode remains authoritative. + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession) { + _getRushSessionDerivedExitStatus(rushSession); + } + } +} + +function _getNumericProcessExitCode(fallback: number): number { + const { exitCode } = process; + if (typeof exitCode === 'number') { + return exitCode; + } + if (typeof exitCode === 'string') { + const parsed: number = Number(exitCode); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; } diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1b2b7aa5812..e310a7c32d1 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -62,6 +62,7 @@ import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParameter import { TrimRushEnvironmentVariablesPlugin } from '../../logic/operations/TrimRushEnvironmentVariablesPlugin'; import { DebugHashesPlugin } from '../../logic/operations/DebugHashesPlugin'; import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import { attachReporterOperationEventSink } from '../../logic/operations/ReporterOperationEventSink'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; @@ -677,6 +678,7 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { await hooks.onGraphCreatedAsync.promise(graph, graphContext); }); + attachReporterOperationEventSink(graph, this.rushSession, this.actionName); const executeOptions: IExecuteOperationsOptions = { graph, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 64d47c1cfdf..6f1184c7dde 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -31,6 +31,7 @@ import './mockRushCommandLineParser'; import type { SpawnOptions } from 'node:child_process'; import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { Autoinstaller } from '../../logic/Autoinstaller'; import type { ITelemetryData } from '../../logic/Telemetry'; import { @@ -47,6 +48,15 @@ import { IS_WINDOWS } from '../../utilities/executionUtilities'; // we only reference the one that is common. const SPAWN_ARG_OPTIONS: number = 2; +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function spawnOptionEquals( spawnCall: SpawnMockCall, optionName: TOption, @@ -93,7 +103,11 @@ describe('RushCommandLineParser', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; - const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build'); + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build', { + eventSink: reporterSink, + sessionId: 'parser-shadow' + }); await expect(parser.executeAsync()).resolves.toEqual(true); @@ -111,6 +125,23 @@ describe('RushCommandLineParser', () => { const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1]; expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp); cwdOptionEquals(secondSpawn, `${repoPath}/b`); + + const eventTypes: string[] = reporterSink.inputs.map(({ type }) => type); + expect(eventTypes[0]).toBe('sessionStarted'); + expect(eventTypes[1]).toBe('commandStarted'); + expect(eventTypes).toContain('operationRegistered'); + expect(eventTypes).toContain('operationStatusChanged'); + expect(eventTypes.slice(-3)).toEqual(['commandResult', 'commandCompleted', 'sessionCompleted']); + expect(reporterSink.inputs.at(-3)?.payload).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationRegistered')) { + const scope = event.scope!; + expect(scope.operationId).toBe(`${scope.projectName}#${scope.phaseName}`); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 601bb70185d..1b46d180f4c 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -147,4 +147,29 @@ describe('RushCommandLineParser reporter close', () => { expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); }); + + it('shares one reporter close operation across failure and finalization paths', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + + const closeReporterAsync: () => Promise = ( + parser as unknown as { + _closeReporterAsync(): Promise; + } + )._closeReporterAsync.bind(parser); + const firstClose: Promise = closeReporterAsync(); + const secondClose: Promise = closeReporterAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([undefined, undefined]); + expect(closeAsync).toHaveBeenCalledTimes(1); + }); }); diff --git a/libraries/rush-lib/src/cli/test/TestUtils.ts b/libraries/rush-lib/src/cli/test/TestUtils.ts index c8191358c2c..29fa4482233 100644 --- a/libraries/rush-lib/src/cli/test/TestUtils.ts +++ b/libraries/rush-lib/src/cli/test/TestUtils.ts @@ -4,6 +4,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser'; +import type { IRushSessionReporterOptions } from '../../pluginFramework/RushSession'; import { FlagFile } from '../../api/FlagFile'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -76,7 +77,8 @@ export const TEST_REPO_FOLDER_PATH: string = `${PROJECT_ROOT}/temp/test/unit-tes */ export async function getCommandLineParserInstanceAsync( repoName: string, - taskName: string + taskName: string, + reporter?: IRushSessionReporterOptions ): Promise { // Copy the test repo to a sandbox folder const repoPath: string = `${TEST_REPO_FOLDER_PATH}/${repoName}-${performance.now()}`; @@ -100,7 +102,7 @@ export async function getCommandLineParserInstanceAsync( // to exit and clear the Rush file lock. So running multiple `it` or `describe` test blocks over the same test // repo will fail due to contention over the same lock which is kept until the test runner process // ends. - const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath }); + const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath, reporter }); // Bulk tasks are hard-coded to expect install to have been completed. So, ensure the last-link.flag // file exists and is valid diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 8d855cd46a0..e9d0f54ad95 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import type { PerformanceEntry } from 'node:perf_hooks'; import { FileSystem, type FileSystemStats, JsonFile } from '@rushstack/node-core-library'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../api/RushConfiguration'; import { Rush } from '../api/Rush'; import type { RushSession } from '../pluginFramework/RushSession'; +import { _getRushSessionTelemetryAggregate } from '../pluginFramework/RushSession'; import { collectPerformanceEntries } from '../utilities/performance'; /** @@ -138,6 +140,16 @@ export interface ITelemetryData { * This is an array of `PerformanceEntry` objects, which can include marks, measures, and function timings. */ readonly performanceEntries?: readonly PerformanceEntry[]; + + /** + * The allowlisted projection derived from shadow reporter events. + * + * @remarks + * This is present only when the Rush frontend supplied a reporter event sink. + * It never contains messages, paths, arguments, raw output, remediation + * parameters, stack traces, or non-public envelope metadata. + */ + readonly reporterData?: ITelemetryAggregate; } const MAX_FILE_COUNT: number = 100; @@ -166,9 +178,30 @@ export class Telemetry { if (!this._enabled) { return; } + const reporterAggregate: ITelemetryAggregate | undefined = _getRushSessionTelemetryAggregate( + this._rushSession + ); + const processExitCode: number = + typeof process.exitCode === 'number' ? process.exitCode : Number(process.exitCode); const cpus: os.CpuInfo[] = os.cpus(); const data: ITelemetryData = { ...telemetryData, + reporterData: reporterAggregate + ? { + ...reporterAggregate, + commandName: reporterAggregate.commandName ?? telemetryData.name, + result: + reporterAggregate.result ?? (telemetryData.result === 'Succeeded' ? 'succeeded' : 'failed'), + exitCode: + reporterAggregate.exitCode ?? + (telemetryData.result === 'Succeeded' + ? 0 + : Number.isFinite(processExitCode) + ? processExitCode + : 1), + durationMs: reporterAggregate.durationMs ?? telemetryData.durationInSeconds * 1000 + } + : telemetryData.reporterData, performanceEntries: telemetryData.performanceEntries || collectPerformanceEntries(this._telemetryStartTime), machineInfo: telemetryData.machineInfo || { diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts new file mode 100644 index 00000000000..11a100e68bd --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + type IRushDiagnostic, + type LifecycleEmitter, + type OperationStatus as ReporterOperationStatus +} from '@rushstack/rush-reporter'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { RushSession } from '../../pluginFramework/RushSession'; +import { + _correlateRushSessionError, + _getRushSessionLifecycleEmitter +} from '../../pluginFramework/RushSession'; +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import type { OperationGraph } from './OperationGraph'; + +interface IReporterOperation { + readonly emitter: LifecycleEmitter; + readonly legacyOperationIds: Set; + readonly operationId: string; + readonly phaseName: string; + readonly projectName: string; + readonly registeredOperationIds: Set; + readonly statuses: Map; + lastEmittedStatus: ReporterOperationStatus | undefined; + silent: boolean; +} + +class ReporterOperationEventSink implements IOperationGraphEventSink { + private readonly _operationsByLegacyId: Map = new Map(); + private readonly _diagnosedOperations: Set = new Set(); + private readonly _rushSession: RushSession; + + public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { + this._rushSession = rushSession; + const operationsByReporterId: Map = new Map(); + + for (const operation of operations) { + const projectName: string = operation.associatedProject.packageName; + const phaseName: string = operation.associatedPhase.name; + const operationId: string = `${projectName}#${phaseName}`; + let reporterOperation: IReporterOperation | undefined = operationsByReporterId.get(operationId); + if (!reporterOperation) { + const emitter: LifecycleEmitter | undefined = _getRushSessionLifecycleEmitter(rushSession, { + commandName, + operationId, + projectName, + phaseName + }); + if (!emitter) { + continue; + } + reporterOperation = { + emitter, + legacyOperationIds: new Set(), + operationId, + phaseName, + projectName, + registeredOperationIds: new Set(), + statuses: new Map(), + lastEmittedStatus: undefined, + silent: true + }; + operationsByReporterId.set(operationId, reporterOperation); + } + reporterOperation.legacyOperationIds.add(operation.name); + reporterOperation.silent &&= !operation.enabled || operation.runner?.silent === true; + this._operationsByLegacyId.set(operation.name, reporterOperation); + } + } + + public get isEnabled(): boolean { + return this._operationsByLegacyId.size > 0; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation) { + return; + } + + if (operation.registeredOperationIds.size === operation.legacyOperationIds.size) { + operation.registeredOperationIds.clear(); + operation.statuses.clear(); + operation.lastEmittedStatus = undefined; + operation.silent = true; + this._diagnosedOperations.delete(operation.operationId); + } + + operation.registeredOperationIds.add(operationId); + operation.silent &&= silent; + if (operation.registeredOperationIds.size !== operation.legacyOperationIds.size || operation.silent) { + return; + } + + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } + + public onOperationStatusChanged(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation) { + return; + } + + if ( + result.status === OperationStatus.Ready && + operation.registeredOperationIds.size === operation.legacyOperationIds.size + ) { + return; + } + + operation.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !this._diagnosedOperations.has(operation.operationId)) { + this._diagnosedOperations.add(operation.operationId); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: operation.projectName, privacy: 'public' } + } + }); + operation.emitter.emitDiagnostic(diagnostic); + if (result.error) { + _correlateRushSessionError(this._rushSession, result.error, diagnostic.diagnosticId); + } + } + + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation); + if (status === undefined || status === operation.lastEmittedStatus) { + return; + } + operation.lastEmittedStatus = status; + if (!operation.silent) { + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + operation.emitter.emitOperationStatusChanged({ + operationId: operation.operationId, + status, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + } +} + +class CompositeOperationGraphEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + + private readonly _first: IOperationGraphEventSink; + private readonly _second: IOperationGraphEventSink; + + public constructor(first: IOperationGraphEventSink, second: IOperationGraphEventSink) { + this._first = first; + this._second = second; + this.onOperationChunk = + first.onOperationChunk || second.onOperationChunk + ? (operationId, chunk) => { + first.onOperationChunk?.(operationId, chunk); + second.onOperationChunk?.(operationId, chunk); + } + : undefined; + this.onOperationStreamClosed = + first.onOperationStreamClosed || second.onOperationStreamClosed + ? (operationId) => { + first.onOperationStreamClosed?.(operationId); + second.onOperationStreamClosed?.(operationId); + } + : undefined; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + this._first.onOperationRegistered?.(operationId, silent); + this._second.onOperationRegistered?.(operationId, silent); + } + + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { + this._first.onOperationStatusChanged?.(result, previousStatus); + this._second.onOperationStatusChanged?.(result, previousStatus); + } + + public onOperationHeader(operationId: string, completedOperations: number, totalOperations: number): void { + this._first.onOperationHeader?.(operationId, completedOperations, totalOperations); + this._second.onOperationHeader?.(operationId, completedOperations, totalOperations); + } + + public onActivity(text: string, options?: IOperationActivityOptions): void { + this._first.onActivity?.(text, options); + this._second.onActivity?.(text, options); + } +} + +/** + * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * + * @internal + */ +export function attachReporterOperationEventSink( + graph: OperationGraph, + rushSession: RushSession, + commandName: string +): void { + const reporterSink: ReporterOperationEventSink = new ReporterOperationEventSink( + rushSession, + commandName, + graph.operations + ); + if (!reporterSink.isEnabled) { + return; + } + + graph.eventSink = graph.eventSink + ? new CompositeOperationGraphEventSink(graph.eventSink, reporterSink) + : reporterSink; +} + +function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { + switch (status) { + case OperationStatus.Ready: + return 'ready'; + case OperationStatus.Waiting: + return 'waiting'; + case OperationStatus.Queued: + return 'queued'; + case OperationStatus.Executing: + return 'executing'; + case OperationStatus.Success: + return 'success'; + case OperationStatus.SuccessWithWarning: + return 'successWithWarnings'; + case OperationStatus.Failure: + return 'failure'; + case OperationStatus.Blocked: + return 'blocked'; + case OperationStatus.Skipped: + return 'skipped'; + case OperationStatus.FromCache: + return 'fromCache'; + case OperationStatus.NoOp: + return 'noOp'; + case OperationStatus.Aborted: + return 'aborted'; + } +} + +function _getAggregateStatus(operation: IReporterOperation): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...operation.statuses.values()]; + if ( + statuses.some((status) => status === OperationStatus.Executing) || + operation.lastEmittedStatus === 'executing' + ) { + if ( + operation.statuses.size !== operation.legacyOperationIds.size || + statuses.some((status) => !_isTerminalStatus(status)) + ) { + return 'executing'; + } + } + if ( + operation.statuses.size === operation.legacyOperationIds.size && + statuses.every((status) => _isTerminalStatus(status)) + ) { + return _getAggregateTerminalStatus(statuses); + } + if (statuses.some((status) => status === OperationStatus.Queued)) { + return 'queued'; + } + if (statuses.some((status) => status === OperationStatus.Ready)) { + return 'ready'; + } + if (statuses.some((status) => status === OperationStatus.Waiting)) { + return 'waiting'; + } + return operation.legacyOperationIds.size === 1 + ? _toReporterStatus(statuses[0] ?? OperationStatus.Ready) + : undefined; +} + +function _getAggregateTerminalStatus(operationStatuses: Iterable): ReporterOperationStatus { + const statuses: Set = new Set(operationStatuses); + if (statuses.has(OperationStatus.Failure)) return 'failure'; + if (statuses.has(OperationStatus.Aborted)) return 'aborted'; + if (statuses.has(OperationStatus.Blocked)) return 'blocked'; + if (statuses.has(OperationStatus.SuccessWithWarning)) return 'successWithWarnings'; + if (statuses.has(OperationStatus.Success)) return 'success'; + if (statuses.has(OperationStatus.FromCache)) return 'fromCache'; + if (statuses.has(OperationStatus.Skipped)) return 'skipped'; + return 'noOp'; +} + +function _isTerminalStatus(status: OperationStatus): boolean { + switch (status) { + case OperationStatus.Success: + case OperationStatus.SuccessWithWarning: + case OperationStatus.Failure: + case OperationStatus.Blocked: + case OperationStatus.Skipped: + case OperationStatus.FromCache: + case OperationStatus.NoOp: + case OperationStatus.Aborted: + return true; + default: + return false; + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c16d6c91f32..fc6e58fe38a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -34,7 +34,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); -import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -46,6 +47,13 @@ import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; +import { + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + RushSession +} from '../../../pluginFramework/RushSession'; +import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; const mockPhase: IPhase = { name: 'phase', @@ -57,12 +65,17 @@ const mockPhase: IPhase = { missingScriptBehavior: 'silent' }; -function createOperation(name: string, runner: IOperationRunner): Operation { +function createOperation( + name: string, + runner: IOperationRunner, + phase: IPhase = mockPhase, + projectName: string = name +): Operation { return new Operation({ runner, logFilenameIdentifier: name, - phase: mockPhase, - project: { packageName: name } as unknown as RushConfigurationProject + phase, + project: { packageName: projectName } as unknown as RushConfigurationProject }); } @@ -95,6 +108,15 @@ class RecordingSink implements IOperationGraphEventSink { } } +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { return { quietMode, @@ -207,4 +229,209 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + + it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + }); + const createFailingOperation = (): Operation => + createOperation( + '@scope/project', + new MockOperationRunner('@scope/project (phase)', async () => OperationStatus.Failure) + ); + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createFailingOperation()]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const operation: Operation = createFailingOperation(); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' || type === 'operationStatusChanged' + ); + expect(operationEvents.length).toBeGreaterThan(1); + for (const event of operationEvents) { + expect(event.scope).toMatchObject({ + commandName: 'build', + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase' + }); + } + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'diagnosticEmitted', + payload: expect.objectContaining({ code: 'RUSH_OPERATION_FAILED' }) + }) + ); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); + + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'sharded-operation-shadow' } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardOneRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 1/2`, + async () => OperationStatus.Success + ); + let shardTwoOutcome: OperationStatus = OperationStatus.Failure; + const shardTwoRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 2/2`, + async () => shardTwoOutcome + ); + const collatorRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - collate`, + async () => OperationStatus.Success + ); + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shardOne: Operation = createOperation('shard-one', shardOneRunner, mockPhase, projectName); + const shardTwo: Operation = createOperation('shard-two', shardTwoRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shardOne.addDependency(preShard); + shardTwo.addDependency(preShard); + collator.addDependency(shardOne); + collator.addDependency(shardTwo); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shardOne, shardTwo]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const reporterOperationId: string = `${projectName}#phase`; + const operationEvents = (): IReporterEmitEventInput[] => + reporterSink.inputs.filter(({ scope }) => scope?.operationId === reporterOperationId); + expect(operationEvents().filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'failure' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + failure: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + + shardTwoOutcome = OperationStatus.Success; + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + expect( + operationEvents() + .filter(({ type }) => type === 'operationRegistered') + .map(({ scope }) => scope?.operationId) + ).toEqual([reporterOperationId, reporterOperationId]); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'success' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + success: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + + const lifecycleEmitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + lifecycleEmitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + lifecycleEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + lifecycleEmitter.emitSessionCompleted({ exitCode: 0 }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); + + it('recomputes grouped silence for each watch-style iteration', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'grouped-silence-shadow' } + }); + const projectName: string = '@scope/silence'; + const first: Operation = createOperation( + 'first', + new MockOperationRunner(`${projectName} (phase) - first`), + mockPhase, + projectName + ); + const second: Operation = createOperation( + 'second', + new MockOperationRunner(`${projectName} (phase) - second`), + mockPhase, + projectName + ); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationId: string = `${projectName}#phase`; + const countEvents = (type: IReporterEmitEventInput['type']): number => + reporterSink.inputs.filter( + ({ type: eventType, scope }) => eventType === type && scope?.operationId === operationId + ).length; + const registrationCount: number = countEvents('operationRegistered'); + const statusCount: number = countEvents('operationStatusChanged'); + expect(registrationCount).toBe(1); + expect(statusCount).toBeGreaterThan(0); + + first.enabled = false; + second.enabled = false; + graph.invalidateOperations(undefined, 'disable group'); + await graph.executeAsync({}); + + expect(countEvents('operationRegistered')).toBe(registrationCount); + expect(countEvents('operationStatusChanged')).toBe(statusCount); + }); }); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index aebc60ef4a9..4afccd7e86e 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -2,12 +2,22 @@ // See LICENSE in the project root for license information. import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import { Rush } from '../../api/Rush'; import { Telemetry, type ITelemetryData, type ITelemetryMachineInfo } from '../Telemetry'; -import { RushSession } from '../../pluginFramework/RushSession'; +import { _getRushSessionLifecycleEmitter, RushSession } from '../../pluginFramework/RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} interface ITelemetryPrivateMembers extends Omit { _flushAsyncTasks: Map>; @@ -136,6 +146,38 @@ describe(Telemetry.name, () => { expect(result.timestampMs).toBeDefined(); }); + it('projects public shadow events into legacy telemetry without exposing command arguments', () => { + const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); + const sink: CapturingSink = new CapturingSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: sink, sessionId: 'telemetry-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=secret'] }); + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'success' }); + + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ + name: 'build', + durationInSeconds: 2, + result: 'Succeeded', + machineInfo: {} as ITelemetryMachineInfo, + performanceEntries: [] + }); + + expect(telemetry.store[0].reporterData).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + expect(JSON.stringify(telemetry.store[0].reporterData)).not.toContain('--auth-token=secret'); + }); + it('calls custom flush telemetry', async () => { const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 26a48160731..c4287f8d580 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -3,16 +3,27 @@ import * as os from 'node:os'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, IReporterEventSink } from '@rushstack/rush-reporter'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { Rush } from '../api/Rush'; import { RushCommandLineParser } from '../cli/RushCommandLineParser'; -import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; +import { + _correlateRushSessionError, + _createRushSessionForPlugin, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + _isRushSessionErrorRepresented, + type IRushSessionReporterOptions, + RushSession +} from './RushSession'; class CapturingSink implements IReporterEventSink { public readonly inputs: IReporterEmitEventInput[] = []; @@ -149,4 +160,86 @@ describe(RushSession.name, () => { action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); }); + + it('observes shadow lifecycle, diagnostics, telemetry, and legacy correlation without terminal output', () => { + const sink: CapturingSink = new CapturingSink(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const session: RushSession = new RushSession({ + getIsDebugMode: () => false, + terminalProvider, + reporter: { eventSink: sink, sessionId: 'session-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + const error: AlreadyReportedError = new AlreadyReportedError(); + + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationRegistered({ + operationId: '@scope/project#_phase:test', + projectName: '@scope/project', + phaseName: '_phase:test' + }); + emitter.emitOperationStatusChanged({ + operationId: '@scope/project#_phase:test', + status: 'failure' + }); + const diagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: '@scope/project', privacy: 'public' } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 1, durationMs: 25 }); + emitter.emitSessionCompleted({ exitCode: 1, durationMs: 30 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(_getRushSessionTelemetryAggregate(session)).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + operationStatusCounts: { failure: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 } + }); + expect(terminalProvider.getAllOutput(false)).toEqual({ + log: '', + warning: '', + error: '', + verbose: '', + debug: '' + }); + }); + + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@private/plugin', + packageVersion: '1.0.0' + })); + + pluginSession.getReporter()!.emitMessage({ + severity: 'info', + text: '/local/private/path' + }); + _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + + const aggregate = _getRushSessionTelemetryAggregate(session)!; + expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); + expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e017a9a8cbc..fa9771ccb08 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -3,10 +3,19 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; import { + LifecycleEmitter, + LegacyErrorBridge, RushSessionReporting, + TelemetrySubscriber, + isReporterEventRequired, + resolveExitStatus, + type IReporterEmitEventInput, + type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IRushExitStatus, + type ITelemetryAggregate, type IScopedLogger, type IScopedReporter } from '@rushstack/rush-reporter'; @@ -77,7 +86,23 @@ interface IRushSessionState { readonly cloudBuildCacheProviderFactories: Map; readonly cobuildLockProviderFactories: Map; readonly hooks: RushLifecycleHooks; - readonly reporting: RushSessionReporting | undefined; + readonly reporting: IRushSessionReportingState | undefined; +} + +interface IRushSessionReportingState { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sessionReporting: RushSessionReporting; + readonly observer: IRushSessionShadowEventObserver; +} + +interface IRushSessionShadowEventObserver { + ingest(event: IReporterEmitEventInput, eventId: string): void; + buildTelemetryAggregate(): ITelemetryAggregate; + resolveExitStatus(): IRushExitStatus; + correlateError(error: unknown, diagnosticId: string): void; + isErrorRepresented(error: unknown): boolean; } let _rushLibSource: IReporterEventSource | undefined; @@ -107,8 +132,9 @@ function _getRushLibSource(): IReporterEventSource { function _createReporting( reporterOptions: IRushSessionReporterOptions | undefined, - source: IReporterEventSource -): RushSessionReporting | undefined { + source: IReporterEventSource, + observer?: IRushSessionShadowEventObserver +): IRushSessionReportingState | undefined { if (!reporterOptions) { return undefined; } @@ -121,10 +147,148 @@ function _createReporting( throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); } - return new RushSessionReporting({ - sink: eventSink, + const shadowObserver: IRushSessionShadowEventObserver = observer ?? _createRushSessionShadowEventObserver(); + const observedEventSink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const eventId: string = eventSink.emit(event); + shadowObserver.ingest(event, eventId); + return eventId; + } + }; + const boundSource: IReporterEventSource = { ...source }; + + return { + eventSink: observedEventSink, sessionId, - source: { ...source } + source: boundSource, + observer: shadowObserver, + sessionReporting: new RushSessionReporting({ + sink: observedEventSink, + sessionId, + source: boundSource + }) + }; +} + +function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserver { + const legacyErrorBridge: LegacyErrorBridge = new LegacyErrorBridge(); + const telemetrySubscriber: TelemetrySubscriber = new TelemetrySubscriber(); + const operationStatuses: Map = new Map(); + let sequence: number = 0; + let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let hasUnscopedFailure: boolean = false; + + const updateDerivedOperationStatus = (): void => { + const hasOperationFailure: boolean = [...operationStatuses.values()].some( + (status) => status === 'failure' || status === 'aborted' + ); + derivedExitStatus = resolveExitStatus({ + hasFailures: hasUnscopedFailure || hasOperationFailure + }); + }; + + return { + ingest(event: IReporterEmitEventInput, eventId: string): void { + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: ++sequence, + timestamp: new Date().toISOString(), + required: isReporterEventRequired(event.type) + }; + legacyErrorBridge.ingest(envelope); + + if (envelope.parentSessionId === undefined) { + switch (envelope.type) { + case 'commandStarted': { + operationStatuses.clear(); + hasUnscopedFailure = false; + derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; + break; + } + case 'operationRegistered': { + const { operationId } = envelope.payload as { operationId: string }; + operationStatuses.set(operationId, 'ready'); + updateDerivedOperationStatus(); + break; + } + case 'operationStatusChanged': { + const { operationId, status } = envelope.payload as { + operationId: string; + status: string; + }; + operationStatuses.set(operationId, status); + updateDerivedOperationStatus(); + break; + } + case 'diagnosticEmitted': { + const { severity } = envelope.payload as { severity?: string }; + if (severity === 'error' && envelope.scope?.operationId === undefined) { + hasUnscopedFailure = true; + updateDerivedOperationStatus(); + } + break; + } + case 'commandResult': { + const { succeeded, exitCode } = envelope.payload as { + succeeded: boolean; + exitCode: number; + }; + derivedExitStatus = resolveExitStatus({ + hasFailures: !succeeded || exitCode !== 0 + }); + break; + } + case 'commandCompleted': + case 'sessionCompleted': { + const { exitCode } = envelope.payload as { exitCode: number }; + derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + break; + } + default: + break; + } + } + + // Match the privacy behavior from #5990 without duplicating its reporter-package changes: + // only public envelopes contribute source, protocol, lifecycle, or diagnostic telemetry. + // Remove this outer gate after #5990 reaches shared main and the hardened subscriber is in this ancestry. + if (envelope.privacy === 'public') { + telemetrySubscriber.ingest(envelope); + } + }, + + buildTelemetryAggregate(): ITelemetryAggregate { + return telemetrySubscriber.buildAggregate(); + }, + + resolveExitStatus(): IRushExitStatus { + return derivedExitStatus; + }, + + correlateError(error: unknown, diagnosticId: string): void { + legacyErrorBridge.correlate(error, diagnosticId); + }, + + isErrorRepresented(error: unknown): boolean { + return legacyErrorBridge.shouldSuppressRendering(error); + } + }; +} + +function _createLifecycleEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + if (!state) { + return undefined; + } + + return new LifecycleEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined }); } @@ -181,7 +345,9 @@ export class RushSession { * source identity bound by Rush. */ public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { - return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedReporter( + scope ? { ...scope } : undefined + ); } /** @@ -193,7 +359,9 @@ export class RushSession { * available during the pre-major compatibility period. */ public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { - return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedLogger( + scope ? { ...scope } : undefined + ); } public registerCloudBuildCacheProviderFactory( @@ -248,7 +416,8 @@ export function _createRushSessionForPlugin( getSource: () => IReporterEventSource ): RushSession { const state: IRushSessionState = _getSessionState(rushSession); - if (!state.options.reporter) { + const reporting: IRushSessionReportingState | undefined = state.reporting; + if (!state.options.reporter || !reporting) { return rushSession; } @@ -264,7 +433,68 @@ export function _createRushSessionForPlugin( cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, cobuildLockProviderFactories: state.cobuildLockProviderFactories, hooks: state.hooks, - reporting: _createReporting(state.options.reporter, getSource()) + reporting: _createReporting(state.options.reporter, getSource(), reporting.observer) }); return pluginSession; } + +/** + * Creates a Rush-owned lifecycle emitter for internal command and operation paths. + * + * @internal + */ +export function _getRushSessionLifecycleEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); +} + +/** + * Returns the current allowlisted reporter telemetry projection. + * + * @internal + */ +export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITelemetryAggregate | undefined { + return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); +} + +/** + * Derives the shadow exit status without changing the authoritative process exit code. + * + * @internal + */ +export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +} + +/** + * Returns the Rush version bound to structured events for this session. + * + * @internal + */ +export function _getRushSessionReporterSourceVersion(rushSession: RushSession): string | undefined { + return _getSessionState(rushSession).reporting?.source.packageVersion; +} + +/** + * Correlates a legacy failure sentinel with an emitted structured diagnostic. + * + * @internal + */ +export function _correlateRushSessionError( + rushSession: RushSession, + error: unknown, + diagnosticId: string +): void { + _getSessionState(rushSession).reporting?.observer.correlateError(error, diagnosticId); +} + +/** + * Returns whether a failure is already represented by an emitted diagnostic or legacy sentinel. + * + * @internal + */ +export function _isRushSessionErrorRepresented(rushSession: RushSession, error: unknown): boolean { + return _getSessionState(rushSession).reporting?.observer.isErrorRepresented(error) ?? false; +} From 7f4718196cc7e564885236635efdea14be802aa9 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:11:04 +0000 Subject: [PATCH 012/164] Isolate overlapping reporter watch cycles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 2 +- .../logic/operations/OperationEventSink.ts | 7 +- .../src/logic/operations/OperationGraph.ts | 17 ++-- .../operations/ReporterOperationEventSink.ts | 79 +++++++++++-------- .../test/OperationGraphEventSink.test.ts | 50 +++++++++++- 5 files changed, 106 insertions(+), 49 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index bf214916f0b..01e0608c75f 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -685,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index cda9311ebb4..74fe3be2ea7 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,16 +40,13 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; /** * Invoked synchronously on every operation status transition. The result's * `status`, `error`, and `stopwatch` reflect the new state. */ - onOperationStatusChanged?( - result: IOperationExecutionResult, - previousStatus: OperationStatus - ): void; + onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; /** * Invoked when an operation's collated output is about to be displayed, diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 58e6c692aad..5f479b03565 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -678,7 +678,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); } for (const [operation, record] of executionRecords) { @@ -1295,10 +1295,9 @@ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatef function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed successfully in ${stopwatch.toString()}.`, - { operationId: record.name } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed successfully in ${stopwatch.toString()}.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) ); @@ -1315,10 +1314,10 @@ function _handleOperationSuccessWithWarning( ): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed with warnings in ${stopwatch.toString()}.`, - { operationId: record.name, stderr: true } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`, { + operationId: record.name, + stderr: true + }); record.collatedWriter.terminal.writeStderrLine( Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) ); diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 11a100e68bd..e9613aaa075 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -26,15 +26,21 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + registrationCycle: IReporterOperationCycle | undefined; +} + +interface IReporterOperationCycle { readonly registeredOperationIds: Set; readonly statuses: Map; + diagnosed: boolean; lastEmittedStatus: ReporterOperationStatus | undefined; silent: boolean; } class ReporterOperationEventSink implements IOperationGraphEventSink { private readonly _operationsByLegacyId: Map = new Map(); - private readonly _diagnosedOperations: Set = new Set(); + private readonly _cyclesByResult: WeakMap = + new WeakMap(); private readonly _rushSession: RushSession; public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { @@ -62,15 +68,11 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { operationId, phaseName, projectName, - registeredOperationIds: new Set(), - statuses: new Map(), - lastEmittedStatus: undefined, - silent: true + registrationCycle: undefined }; operationsByReporterId.set(operationId, reporterOperation); } reporterOperation.legacyOperationIds.add(operation.name); - reporterOperation.silent &&= !operation.enabled || operation.runner?.silent === true; this._operationsByLegacyId.set(operation.name, reporterOperation); } } @@ -79,23 +81,29 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { return this._operationsByLegacyId.size > 0; } - public onOperationRegistered(operationId: string, silent: boolean): void { + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + const operationId: string = result.operation.name; const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); if (!operation) { return; } - if (operation.registeredOperationIds.size === operation.legacyOperationIds.size) { - operation.registeredOperationIds.clear(); - operation.statuses.clear(); - operation.lastEmittedStatus = undefined; - operation.silent = true; - this._diagnosedOperations.delete(operation.operationId); + let cycle: IReporterOperationCycle | undefined = operation.registrationCycle; + if (!cycle || cycle.registeredOperationIds.size === operation.legacyOperationIds.size) { + cycle = { + registeredOperationIds: new Set(), + statuses: new Map(), + diagnosed: false, + lastEmittedStatus: undefined, + silent: true + }; + operation.registrationCycle = cycle; } - operation.registeredOperationIds.add(operationId); - operation.silent &&= silent; - if (operation.registeredOperationIds.size !== operation.legacyOperationIds.size || operation.silent) { + this._cyclesByResult.set(result, cycle); + cycle.registeredOperationIds.add(operationId); + cycle.silent &&= silent; + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { return; } @@ -111,17 +119,21 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!operation) { return; } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } if ( result.status === OperationStatus.Ready && - operation.registeredOperationIds.size === operation.legacyOperationIds.size + cycle.registeredOperationIds.size === operation.legacyOperationIds.size ) { return; } - operation.statuses.set(result.operation.name, result.status); - if (result.status === OperationStatus.Failure && !this._diagnosedOperations.has(operation.operationId)) { - this._diagnosedOperations.add(operation.operationId); + cycle.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !cycle.diagnosed) { + cycle.diagnosed = true; const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { parameters: { projectName: { value: operation.projectName, privacy: 'public' } @@ -133,12 +145,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } } - const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation); - if (status === undefined || status === operation.lastEmittedStatus) { + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation, cycle); + if (status === undefined || status === cycle.lastEmittedStatus) { return; } - operation.lastEmittedStatus = status; - if (!operation.silent) { + cycle.lastEmittedStatus = status; + if (!cycle.silent) { const durationMs: number | undefined = operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined ? result.stopwatch.duration * 1000 @@ -178,9 +190,9 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { : undefined; } - public onOperationRegistered(operationId: string, silent: boolean): void { - this._first.onOperationRegistered?.(operationId, silent); - this._second.onOperationRegistered?.(operationId, silent); + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + this._first.onOperationRegistered?.(result, silent); + this._second.onOperationRegistered?.(result, silent); } public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { @@ -252,21 +264,24 @@ function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { } } -function _getAggregateStatus(operation: IReporterOperation): ReporterOperationStatus | undefined { - const statuses: readonly OperationStatus[] = [...operation.statuses.values()]; +function _getAggregateStatus( + operation: IReporterOperation, + cycle: IReporterOperationCycle +): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...cycle.statuses.values()]; if ( statuses.some((status) => status === OperationStatus.Executing) || - operation.lastEmittedStatus === 'executing' + cycle.lastEmittedStatus === 'executing' ) { if ( - operation.statuses.size !== operation.legacyOperationIds.size || + cycle.statuses.size !== operation.legacyOperationIds.size || statuses.some((status) => !_isTerminalStatus(status)) ) { return 'executing'; } } if ( - operation.statuses.size === operation.legacyOperationIds.size && + cycle.statuses.size === operation.legacyOperationIds.size && statuses.every((status) => _isTerminalStatus(status)) ) { return _getAggregateTerminalStatus(statuses); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index fc6e58fe38a..8f89c77a1f2 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -86,8 +86,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly activities: string[] = []; public readonly chunks: Map = new Map(); - public onOperationRegistered(operationId: string, silent: boolean): void { - this.registered.push([operationId, silent]); + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + this.registered.push([result.operation.name, silent]); } public onOperationStatusChanged(result: IOperationExecutionResult): void { this.transitions.push([result.operation.name, result.status]); @@ -388,6 +388,52 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); + it('isolates diagnostics when the next watch iteration registers before abort completes', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'overlapping-operation-shadow' } + }); + let runCount: number = 0; + let resolveFirstRun: ((status: OperationStatus) => void) | undefined; + let markFirstRunStarted: (() => void) | undefined; + const firstRunStarted: Promise = new Promise((resolve: () => void) => { + markFirstRunStarted = resolve; + }); + const runner: MockOperationRunner = new MockOperationRunner('@scope/overlap (phase)', async () => { + runCount++; + if (runCount === 1) { + markFirstRunStarted!(); + return await new Promise((resolve: (status: OperationStatus) => void) => { + resolveFirstRun = resolve; + }); + } + return OperationStatus.Failure; + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('overlap', runner, mockPhase, '@scope/overlap')]), + { ...createGraphOptions(mockWritable, false), pauseNextIteration: true } + ); + attachReporterOperationEventSink(graph, rushSession, 'build'); + + await graph.scheduleIterationAsync({}); + const firstExecution: Promise = graph.executeScheduledIterationAsync(); + await firstRunStarted; + await graph.scheduleIterationAsync({}); + const abortPromise: Promise = graph.abortCurrentIterationAsync(); + resolveFirstRun!(OperationStatus.Failure); + await Promise.all([firstExecution, abortPromise]); + await graph.executeScheduledIterationAsync(); + + expect( + reporterSink.inputs.filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(2); + }); + it('recomputes grouped silence for each watch-style iteration', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ From 04d04cc852900ae8258e32c83e57f71d2a307b4a Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 19:55:50 +0000 Subject: [PATCH 013/164] Keep operation sink callbacks compatible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 2 +- .../logic/operations/OperationEventSink.ts | 2 +- .../src/logic/operations/OperationGraph.ts | 2 +- .../operations/ReporterOperationEventSink.ts | 19 +++++++++++++------ .../test/OperationGraphEventSink.test.ts | 4 ++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 01e0608c75f..e9201654ba4 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -685,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 74fe3be2ea7..b5d90e0ad8a 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,7 +40,7 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; /** * Invoked synchronously on every operation status transition. The result's diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 5f479b03565..b2c1362b6a2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -678,7 +678,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index e9613aaa075..d3287c44270 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -81,10 +81,13 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { return this._operationsByLegacyId.size > 0; } - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - const operationId: string = result.operation.name; + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); - if (!operation) { + if (!operation || !result) { return; } @@ -190,9 +193,13 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { : undefined; } - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - this._first.onOperationRegistered?.(result, silent); - this._second.onOperationRegistered?.(result, silent); + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + this._first.onOperationRegistered?.(operationId, silent, result); + this._second.onOperationRegistered?.(operationId, silent, result); } public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 8f89c77a1f2..9a9883dfdb7 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -86,8 +86,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly activities: string[] = []; public readonly chunks: Map = new Map(); - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - this.registered.push([result.operation.name, silent]); + public onOperationRegistered(operationId: string, silent: boolean): void { + this.registered.push([operationId, silent]); } public onOperationStatusChanged(result: IOperationExecutionResult): void { this.transitions.push([result.operation.name, result.status]); From 745843b4079533e7dc86898b3b7a728f00cc0578 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 20:53:13 +0000 Subject: [PATCH 014/164] Align reporter completion with failure exit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- libraries/rush-lib/src/cli/RushCommandLineParser.ts | 6 +++--- .../src/cli/test/RushCommandLineParserReporterClose.test.ts | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 7a149ca5744..772ee63935a 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -621,9 +621,6 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } - this._emitReporterCompletion(_getNumericProcessExitCode(1)); - this.flushTelemetry(); - const configuredExitCode: string | number | undefined = process.exitCode; const numericExitCode: number = Number(configuredExitCode); const exitCode: number = @@ -631,6 +628,9 @@ export class RushCommandLineParser extends CommandLineParser { ? numericExitCode : 1; process.exitCode = exitCode; + this._emitReporterCompletion(exitCode); + this.flushTelemetry(); + const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 1b46d180f4c..7bebb337268 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -77,6 +77,8 @@ describe('RushCommandLineParser reporter close', () => { const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + const emitReporterCompletion: jest.Mock = jest.fn(); + Object.defineProperty(parser, '_emitReporterCompletion', { value: emitReporterCompletion }); const exitSpy: jest.SpyInstance = jest .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -91,6 +93,7 @@ describe('RushCommandLineParser reporter close', () => { reportErrorAndSetExitCode(new Error('parser failed')); expect(closeAsync).toHaveBeenCalledTimes(1); + expect(emitReporterCompletion).toHaveBeenCalledWith(1); expect(exitSpy).not.toHaveBeenCalled(); process.exitCode = 0; From 853470e64fa95ed8b3b4a803762e8dd99bd7be9f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:29:53 +0000 Subject: [PATCH 015/164] Complete shadow reporter parity coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...er-r3c-shadow-parity_2026-08-28-04-55.json | 11 ++ .../test/OperationGraphEventSink.test.ts | 86 ++++++++++++ .../src/pluginFramework/RushSession.test.ts | 123 +++++++++++++++++- .../src/pluginFramework/RushSession.ts | 22 ++-- 4 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json new file mode 100644 index 00000000000..a51b0ff8971 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Complete shadow reporter parity coverage for event identity, telemetry privacy, exit status, repeated operation phases, and unchanged legacy output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 9a9883dfdb7..e91029084c0 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -480,4 +480,90 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(countEvents('operationRegistered')).toBe(registrationCount); expect(countEvents('operationStatusChanged')).toBe(statusCount); }); + + it('keeps project x phase identities stable across repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + }); + const compilePhase: IPhase = { + ...mockPhase, + name: '_phase:compile', + logFilenameIdentifier: '_phase_compile' + }; + const testPhase: IPhase = { + ...mockPhase, + name: '_phase:test', + logFilenameIdentifier: '_phase_test' + }; + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation( + '@scope/project compile', + new MockOperationRunner('@scope/project (_phase:compile)'), + compilePhase, + '@scope/project' + ), + createOperation( + '@scope/project test', + new MockOperationRunner('@scope/project (_phase:test)'), + testPhase, + '@scope/project' + ) + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + const registrations: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' + ); + expect(registrations.map(({ scope }) => scope?.operationId)).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { + expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); + expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); + } + }); + + it('leaves stdout, stderr, and StreamCollator rendering byte-identical with shadow reporting', async () => { + const createOutputRunner = (): MockOperationRunner => + new MockOperationRunner('output', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('shadow parity stdout'); + terminal.writeStderrLine('shadow parity stderr'); + return OperationStatus.Success; + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + }); + const shadowWritable: MockWritable = new MockWritable(); + const shadowGraph: OperationGraph = new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(shadowWritable, false) + ); + attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + await shadowGraph.executeAsync({}); + expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index c4287f8d580..348663bf53d 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -7,7 +7,10 @@ import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, - IReporterEventSink + IReporterEventSink, + IResolveExitStatusFromEventsOptions, + IRushExitStatus, + LifecycleEmitter } from '@rushstack/rush-reporter'; import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; @@ -45,11 +48,16 @@ function createSession(reporter?: IRushSessionReporterOptions): RushSession { describe(RushSession.name, () => { it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { const session: RushSession = createSession(); + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: os.tmpdir() }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; expect(session.getReporter()).toBeUndefined(); expect(session.getScopedLogger()).toBeUndefined(); expect(session.getLogger('legacy')).toBeDefined(); expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + expect(action?.reporter).toBeUndefined(); }); it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { @@ -223,6 +231,100 @@ describe(RushSession.name, () => { }); }); + it('preserves event order, correlation, session identity, and trusted producer identity', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'ordered-session' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + })); + const sessionEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + const commandEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { + commandName: 'build' + })!; + const diagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED'); + const error: Error = new Error('represented'); + + sessionEmitter.emitSessionStarted({ rushVersion: Rush.version }); + commandEmitter.emitCommandStarted({ commandName: 'build' }); + pluginSession.getReporter({ commandName: 'build' })!.emitMessage({ + severity: 'info', + text: 'plugin message' + }); + commandEmitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + commandEmitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + commandEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 1 }); + sessionEmitter.emitSessionCompleted({ exitCode: 1 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'messageEmitted', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(new Set(sink.inputs.map(({ sessionId }) => sessionId))).toEqual(new Set(['ordered-session'])); + expect(sink.inputs[0].source).toMatchObject({ + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }); + expect(sink.inputs[2].source).toEqual({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }); + expect(sink.inputs[3].payload).toMatchObject({ diagnosticId: diagnostic.diagnosticId }); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + }); + + it('derives legacy-compatible exit status for success, warnings, failures, cancellation, and errors', () => { + const derive = ( + emitEvents: (emitter: LifecycleEmitter) => void, + options?: IResolveExitStatusFromEventsOptions + ): IRushExitStatus => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'exit-session' + }); + emitEvents(_getRushSessionLifecycleEmitter(session, { commandName: 'build' })!); + return _getRushSessionDerivedExitStatus(session, options)!; + }; + + expect( + derive((emitter) => { + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED', { severity: 'warning' })); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'failure' }); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + + expect(derive(() => {}, { cancelled: true })).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + for (const code of ['RUSH_CONFIG_INVALID_JSON', 'RUSH_INTERNAL_UNEXPECTED'] as const) { + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic(code)); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + } + }); + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { const sink: CapturingSink = new CapturingSink(); const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); @@ -235,11 +337,28 @@ describe(RushSession.name, () => { severity: 'info', text: '/local/private/path' }); - _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + pluginSession.getReporter()!.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: 'private-secret-token', privacy: 'secret' } + } + }) + ); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=public-envelope-secret'] }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); const aggregate = _getRushSessionTelemetryAggregate(session)!; expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(JSON.stringify(aggregate)).not.toContain('private-secret-token'); + expect(JSON.stringify(aggregate)).not.toContain('--auth-token=public-envelope-secret'); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0 + }); expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index fa9771ccb08..e52cbb0ad02 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -8,12 +8,13 @@ import { RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, - resolveExitStatus, + resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IResolveExitStatusFromEventsOptions, type IRushExitStatus, type ITelemetryAggregate, type IScopedLogger, @@ -100,7 +101,7 @@ interface IRushSessionReportingState { interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; - resolveExitStatus(): IRushExitStatus; + resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; correlateError(error: unknown, diagnosticId: string): void; isErrorRepresented(error: unknown): boolean; } @@ -182,7 +183,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve const hasOperationFailure: boolean = [...operationStatuses.values()].some( (status) => status === 'failure' || status === 'aborted' ); - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: hasUnscopedFailure || hasOperationFailure }); }; @@ -234,7 +235,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve succeeded: boolean; exitCode: number; }; - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: !succeeded || exitCode !== 0 }); break; @@ -242,7 +243,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandCompleted': case 'sessionCompleted': { const { exitCode } = envelope.payload as { exitCode: number }; - derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + derivedExitStatus = resolveRushExitStatus({ hasFailures: exitCode !== 0 }); break; } default: @@ -262,8 +263,8 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve return telemetrySubscriber.buildAggregate(); }, - resolveExitStatus(): IRushExitStatus { - return derivedExitStatus; + resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus { + return resolveRushExitStatus({ hasFailures: derivedExitStatus.exitCode !== 0, ...options }); }, correlateError(error: unknown, diagnosticId: string): void { @@ -464,8 +465,11 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe * * @internal */ -export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { - return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +export function _getRushSessionDerivedExitStatus( + rushSession: RushSession, + options?: IResolveExitStatusFromEventsOptions +): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(options); } /** From b80a277340b7895055fb5b4f8b6ccefad947e7c2 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 06:41:03 +0000 Subject: [PATCH 016/164] Add feature-flagged operation event adapter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushFrontend.ts | 3 +- apps/rush/src/RushReporterHost.ts | 46 +++- apps/rush/src/test/RushFrontend.test.ts | 8 +- apps/rush/src/test/RushReporterHost.test.ts | 66 +++++- ...5a-operation-adapter_2026-08-28-06-35.json | 11 + ...5a-operation-adapter_2026-08-28-06-35.json | 11 + common/reviews/api/rush-lib.api.md | 3 + common/reviews/api/rush-reporter.api.md | 22 +- .../reporter/src/config/LogLevelFilter.ts | 2 + .../reporter/src/events/ReporterEventType.ts | 8 +- libraries/reporter/src/index.ts | 2 + .../reporter/src/lifecycle/LifecycleEvents.ts | 44 ++++ .../reporter/src/protocol/ReporterProtocol.ts | 2 +- .../src/scheduler/OperationStreamEmitter.ts | 68 +++++- .../src/test/IReporterEventEnvelope.test.ts | 4 +- .../reporter/src/test/LogLevelFilter.test.ts | 2 + .../src/test/OperationStreamEmitter.test.ts | 40 +++- libraries/reporter/src/test/Protocol.test.ts | 6 +- libraries/reporter/src/test/Telemetry.test.ts | 2 +- .../test/__snapshots__/Goldens.test.ts.snap | 2 +- .../logic/operations/OperationEventSink.ts | 9 +- .../operations/OperationExecutionRecord.ts | 24 +- .../src/logic/operations/OperationGraph.ts | 8 +- .../operations/ReporterOperationEventSink.ts | 187 +++++++++++++-- .../test/OperationGraphEventSink.test.ts | 217 +++++++++++++++++- .../src/pluginFramework/RushSession.ts | 43 ++++ 26 files changed, 765 insertions(+), 75 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 044a060d6b9..00caadebf4b 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -161,7 +161,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr ...launchOptions, reporter: { eventSink: reporterHost.sink, - sessionId + sessionId, + operationStreamEnabled: reporterHost.selection.enabled }, reporterCloseAsync }; diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index dfa9b84a7e4..82dc2d4f902 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,6 +23,7 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type ReporterEventType, type ReporterLogLevel, type ReporterName } from '@rushstack/rush-reporter'; @@ -70,6 +71,13 @@ export interface IInitializedRushReporterHost { const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; +const DEFERRED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ + 'operationRegistered', + 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted', + 'externalOutput' +]); interface IParsedReporterControls { readonly reporters: readonly string[]; @@ -111,6 +119,38 @@ class LogLevelReporter implements IReporter { } } +/** + * Keeps operation presentation on the legacy collator until R5B transfers terminal ownership. + */ +class DeferredOperationPresentationReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + + public constructor(reporter: IReporter) { + this._reporter = reporter; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if (!DEFERRED_OPERATION_EVENT_TYPES.has(event.type)) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + class ExplicitOutputReporter implements IReporter { public readonly name: string; @@ -610,7 +650,11 @@ export async function initializeRushReporterHostAsync( if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + const presentationReporter: IReporter = + selection.reporter === 'file' + ? primaryReporter + : new DeferredOperationPresentationReporter(primaryReporter); + host.manager.addReporter(new LogLevelReporter(presentationReporter, selection.logLevel), { destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' }); } diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 233b3d7e2d1..4a59142d851 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -178,7 +178,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { } describe(launchRushFrontendAsync.name, () => { - it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { + it('creates the authoritative host before invoking the bundled rush-lib and passes only its channel', async () => { const order: string[] = []; let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); @@ -207,7 +207,8 @@ describe(launchRushFrontendAsync.name, () => { expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); expect(receivedOptions?.reporter).toEqual({ eventSink: expect.objectContaining({ emit: expect.any(Function) }), - sessionId: expect.any(String) + sessionId: expect.any(String), + operationStreamEnabled: false }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); @@ -249,7 +250,8 @@ describe(launchRushFrontendAsync.name, () => { expect(createSessionId).toHaveBeenCalledTimes(1); expect(receivedOptions?.reporter).toEqual({ eventSink: initialized.sink, - sessionId: 'session-from-frontend' + sessionId: 'session-from-frontend', + operationStreamEnabled: false }); await initialized.closeAsync(); expect(order).toEqual(['host', 'close']); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fc3e630773e..7846cb673c6 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -44,6 +44,45 @@ function emitCommandStarted(sink: IReporterEventSink): void { }); } +function emitOperationEvents(sink: IReporterEventSink): void { + const base = { + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + scope: { commandName: 'build', operationId: 'project#phase' } + } as const; + sink.emit({ + ...base, + privacy: 'public', + type: 'operationRegistered', + payload: { operationId: 'project#phase', projectName: 'project', phaseName: 'phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStatusChanged', + payload: { operationId: 'project#phase', previousStatus: 'queued', status: 'executing' } + }); + sink.emit({ + ...base, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stdout', text: 'raw operation output\n' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStreamClosed', + payload: { operationId: 'project#phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationCompleted', + payload: { operationId: 'project#phase', status: 'success' } + }); +} + describe(resolveRushReporterSelection.name, () => { it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { for (const testCase of [ @@ -436,7 +475,12 @@ describe(initializeRushReporterHostAsync.name, () => { let stdoutText: string = ''; try { const initialized = await initializeRushReporterHostAsync({ - argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + argv: [ + 'build', + '--reporter=json', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], env: {}, stdout: { isTTY: false, @@ -448,12 +492,28 @@ describe(initializeRushReporterHostAsync.name, () => { }); emitCommandStarted(initialized.sink); + emitOperationEvents(initialized.sink); const firstClose: Promise = initialized.closeAsync(); expect(initialized.closeAsync()).toBe(firstClose); await firstClose; - expect(JSON.parse(stdoutText).type).toBe('commandStarted'); - expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + const stdoutEvents: Record[] = stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + const fileEvents: Record[] = (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(stdoutEvents.map(({ type }) => type)).toEqual(['commandStarted']); + expect(fileEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); } finally { await fs.promises.rm(directory, { recursive: true, force: true }); } diff --git a/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json b/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json new file mode 100644 index 00000000000..2e0fd43f62f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit feature-flagged phase-aware operation registration, status, raw output, stream-close, and completion events while preserving the legacy StreamCollator output path.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json new file mode 100644 index 00000000000..fe11e3b0e35 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Extend OperationStreamEmitter with silent registration metadata, previous status, stream-close, and operation-completion events.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e9201654ba4..5ae22050de6 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -684,6 +684,7 @@ export interface IOperationGraphContext extends ICreateOperationsContext { export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationCompleted?(result: IOperationExecutionResult): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; @@ -1016,6 +1017,8 @@ export interface IRushSessionOptions { // @beta export interface IRushSessionReporterOptions { readonly eventSink: IReporterEventSink; + // @internal + readonly operationStreamEnabled?: boolean; readonly sessionId: string; } diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index ecf09047dbd..029dea99d87 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -604,20 +604,34 @@ export interface IOldEngineOutputAdapterOptions { readonly source: IReporterEventSource; } +// @beta +export interface IOperationCompletedPayload { + readonly durationMs?: number; + readonly operationId: string; + readonly status: OperationStatus; +} + // @beta export interface IOperationRegisteredPayload { readonly operationId: string; readonly phaseName?: string; readonly projectName?: string; + readonly silent?: boolean; } // @beta export interface IOperationStatusChangedPayload { readonly durationMs?: number; readonly operationId: string; + readonly previousStatus?: OperationStatus; readonly status: OperationStatus; } +// @beta +export interface IOperationStreamClosedPayload { + readonly operationId: string; +} + // @beta export interface IOperationStreamEmitterOptions { readonly maxChunkBytes?: number; @@ -1251,11 +1265,13 @@ export type OperationStatus = 'ready' | 'waiting' | 'queued' | 'executing' | 'su // @beta export class OperationStreamEmitter { constructor(options: IOperationStreamEmitterOptions); - changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string; + changeStatus(operationId: string, status: OperationStatus, durationMs?: number, previousStatus?: OperationStatus): string; + closeOperationStream(operationId: string): string; completeCommand(commandName: string, succeeded: boolean, exitCode: number, operationCounts?: { readonly [status: string]: number; }): string; - registerOperation(operationId: string, projectName?: string, phaseName?: string): string; + completeOperation(operationId: string, status: OperationStatus, durationMs?: number): string; + registerOperation(operationId: string, projectName?: string, phaseName?: string, silent?: boolean): string; writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[]; } @@ -1328,7 +1344,7 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; +export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension", "operationStreamClosed", "operationCompleted"]; // @beta export const REPORTER_KNOWN_CAPABILITIES: readonly []; diff --git a/libraries/reporter/src/config/LogLevelFilter.ts b/libraries/reporter/src/config/LogLevelFilter.ts index f644a0edda2..6ea09cdf5f3 100644 --- a/libraries/reporter/src/config/LogLevelFilter.ts +++ b/libraries/reporter/src/config/LogLevelFilter.ts @@ -59,6 +59,7 @@ export function getEventMinimumLogLevel(event: IReporterEventEnvelope): case 'sessionStarted': case 'commandStarted': case 'operationStatusChanged': + case 'operationCompleted': case 'watchCycleCompleted': case 'artifactAvailable': return 'normal'; @@ -79,6 +80,7 @@ export function getEventMinimumLogLevel(event: IReporterEventEnvelope): case 'externalProcessCompleted': return 'verbose'; case 'externalOutput': + case 'operationStreamClosed': return 'debug'; case 'extension': return 'normal'; diff --git a/libraries/reporter/src/events/ReporterEventType.ts b/libraries/reporter/src/events/ReporterEventType.ts index f0c99004fc3..7e7010b9534 100644 --- a/libraries/reporter/src/events/ReporterEventType.ts +++ b/libraries/reporter/src/events/ReporterEventType.ts @@ -30,6 +30,8 @@ * | `artifactAvailable` | yes | `normal` | * | `commandResult` | yes | `quiet` | * | `extension` | yes | `normal` | + * | `operationStreamClosed` | yes | `debug` | + * | `operationCompleted` | yes | `normal` | * * Coalescing a replaceable `activityChanged` event under queue pressure leaves * gaps in the delivered `sequence` values; gaps are legal and are not a @@ -57,7 +59,9 @@ export const REPORTER_EVENT_TYPES = [ 'externalProcessCompleted', 'artifactAvailable', 'commandResult', - 'extension' + 'extension', + 'operationStreamClosed', + 'operationCompleted' ] as const; /** @@ -83,4 +87,4 @@ export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; */ export function isReporterEventRequired(type: ReporterEventType): boolean { return type !== 'activityChanged'; -} \ No newline at end of file +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f8..21672628495 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -177,6 +177,8 @@ export type { ICommandCompletedPayload, IOperationRegisteredPayload, IOperationStatusChangedPayload, + IOperationStreamClosedPayload, + IOperationCompletedPayload, ICommandResultPayload, IWatchCycleCompletedPayload } from './lifecycle/LifecycleEvents'; diff --git a/libraries/reporter/src/lifecycle/LifecycleEvents.ts b/libraries/reporter/src/lifecycle/LifecycleEvents.ts index e142e9ef323..81b27752713 100644 --- a/libraries/reporter/src/lifecycle/LifecycleEvents.ts +++ b/libraries/reporter/src/lifecycle/LifecycleEvents.ts @@ -113,6 +113,11 @@ export interface IOperationRegisteredPayload { * The phase the operation belongs to. */ readonly phaseName?: string; + + /** + * Whether the operation is architectural and normally omitted from visible summaries. + */ + readonly silent?: boolean; } /** @@ -131,12 +136,51 @@ export interface IOperationStatusChangedPayload { */ readonly status: OperationStatus; + /** + * The status immediately preceding this transition. + */ + readonly previousStatus?: OperationStatus; + /** * The operation duration in milliseconds when known. */ readonly durationMs?: number; } +/** + * The payload of an `operationStreamClosed` event. + * + * @beta + */ +export interface IOperationStreamClosedPayload { + /** + * The operation whose output stream has closed. + */ + readonly operationId: string; +} + +/** + * The payload of an `operationCompleted` event. + * + * @beta + */ +export interface IOperationCompletedPayload { + /** + * The completed operation. + */ + readonly operationId: string; + + /** + * The terminal operation status. + */ + readonly status: OperationStatus; + + /** + * The final operation duration in milliseconds when known. + */ + readonly durationMs?: number; +} + /** * The payload of a `commandResult` event. * diff --git a/libraries/reporter/src/protocol/ReporterProtocol.ts b/libraries/reporter/src/protocol/ReporterProtocol.ts index 40119adea8b..15dc630563d 100644 --- a/libraries/reporter/src/protocol/ReporterProtocol.ts +++ b/libraries/reporter/src/protocol/ReporterProtocol.ts @@ -15,7 +15,7 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion */ export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion = { major: 1, - minor: 0 + minor: 1 }; /** diff --git a/libraries/reporter/src/scheduler/OperationStreamEmitter.ts b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts index 259c935a1ea..241217781e2 100644 --- a/libraries/reporter/src/scheduler/OperationStreamEmitter.ts +++ b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts @@ -49,11 +49,11 @@ export interface IOperationStreamEmitterOptions { * * @remarks * The operation scheduler uses this to publish operation registration, status - * transitions, raw output chunks, and the aggregate command result. Output - * chunks are emitted immediately in call order and are never collated, so the - * concise reporter can derive activity without buffering, the detailed and file - * reporters can own grouping, and problem matchers can consume the same - * uncollated source stream. + * transitions, raw output chunks, stream close, operation completion, and the + * aggregate command result. Output chunks are emitted immediately in call order + * and are never collated, so the concise reporter can derive activity without + * buffering, the detailed and file reporters can own grouping, and problem + * matchers can consume the same uncollated source stream. * * @beta */ @@ -71,8 +71,7 @@ export class OperationStreamEmitter { this._source = options.source; this._scope = options.scope; this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; - const maxChunkBytes: number = - options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; + const maxChunkBytes: number = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; if ( !Number.isInteger(maxChunkBytes) || maxChunkBytes < 4 || @@ -88,10 +87,20 @@ export class OperationStreamEmitter { /** * Emits an operation registration event. */ - public registerOperation(operationId: string, projectName?: string, phaseName?: string): string { + public registerOperation( + operationId: string, + projectName?: string, + phaseName?: string, + silent?: boolean + ): string { return this._emit( 'operationRegistered', - { operationId, projectName, phaseName }, + { + operationId, + projectName, + phaseName, + ...(silent === undefined ? {} : { silent }) + }, { operationId, projectName, phaseName }, 'public' ); @@ -100,10 +109,20 @@ export class OperationStreamEmitter { /** * Emits an operation status transition. */ - public changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string { + public changeStatus( + operationId: string, + status: OperationStatus, + durationMs?: number, + previousStatus?: OperationStatus + ): string { return this._emit( 'operationStatusChanged', - { operationId, status, durationMs }, + { + operationId, + status, + ...(previousStatus === undefined ? {} : { previousStatus }), + ...(durationMs === undefined ? {} : { durationMs }) + }, { operationId }, 'public' ); @@ -146,6 +165,25 @@ export class OperationStreamEmitter { return eventIds; } + /** + * Emits the authoritative signal that no more output will be emitted for an operation. + */ + public closeOperationStream(operationId: string): string { + return this._emit('operationStreamClosed', { operationId }, { operationId }, 'public'); + } + + /** + * Emits the final outcome of an operation. + */ + public completeOperation(operationId: string, status: OperationStatus, durationMs?: number): string { + return this._emit( + 'operationCompleted', + { operationId, status, ...(durationMs === undefined ? {} : { durationMs }) }, + { operationId }, + 'public' + ); + } + /** * Emits the aggregate command result. */ @@ -164,7 +202,13 @@ export class OperationStreamEmitter { } private _emit( - type: 'operationRegistered' | 'operationStatusChanged' | 'externalOutput' | 'commandResult', + type: + | 'operationRegistered' + | 'operationStatusChanged' + | 'operationStreamClosed' + | 'operationCompleted' + | 'externalOutput' + | 'commandResult', payload: unknown, scopeOverride: IReporterEventScope, privacy: 'public' | 'local-sensitive' | 'secret' diff --git a/libraries/reporter/src/test/IReporterEventEnvelope.test.ts b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts index 949f38df083..0786651183b 100644 --- a/libraries/reporter/src/test/IReporterEventEnvelope.test.ts +++ b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts @@ -27,7 +27,9 @@ describe('ReporterEventType', () => { 'externalProcessCompleted', 'artifactAvailable', 'commandResult', - 'extension' + 'extension', + 'operationStreamClosed', + 'operationCompleted' ]); }); diff --git a/libraries/reporter/src/test/LogLevelFilter.test.ts b/libraries/reporter/src/test/LogLevelFilter.test.ts index b1c6569ffe5..a50fa6d3dfa 100644 --- a/libraries/reporter/src/test/LogLevelFilter.test.ts +++ b/libraries/reporter/src/test/LogLevelFilter.test.ts @@ -40,6 +40,7 @@ describe('getEventMinimumLogLevel', () => { it('classifies standard lifecycle and non-required warnings as normal', () => { expect(getEventMinimumLogLevel(ev('commandStarted', { commandName: 'build' }))).toBe('normal'); expect(getEventMinimumLogLevel(ev('operationStatusChanged', { status: 'success' }))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('operationCompleted', { status: 'success' }))).toBe('normal'); expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe('normal'); }); @@ -47,6 +48,7 @@ describe('getEventMinimumLogLevel', () => { expect(getEventMinimumLogLevel(ev('operationRegistered', {}))).toBe('normal'); expect(getEventMinimumLogLevel(ev('externalProcessStarted', {}))).toBe('verbose'); expect(getEventMinimumLogLevel(ev('externalOutput', { stream: 'stdout', text: 'x' }))).toBe('debug'); + expect(getEventMinimumLogLevel(ev('operationStreamClosed', {}))).toBe('debug'); expect(getEventMinimumLogLevel(ev('messageEmitted', { severity: 'debug', text: 'd' }))).toBe('debug'); expect(getEventMinimumLogLevel(ev('extension', { name: 'a.b' }, false))).toBe('normal'); }); diff --git a/libraries/reporter/src/test/OperationStreamEmitter.test.ts b/libraries/reporter/src/test/OperationStreamEmitter.test.ts index a6b47eaf746..680cdb49e17 100644 --- a/libraries/reporter/src/test/OperationStreamEmitter.test.ts +++ b/libraries/reporter/src/test/OperationStreamEmitter.test.ts @@ -61,10 +61,12 @@ describe('OperationStreamEmitter', () => { it('emits registration, status, output, and result with operation scope', () => { const sink: CapturingSink = new CapturingSink(); const emitter: OperationStreamEmitter = makeEmitter(sink); - emitter.registerOperation('op1', 'project-a', 'build'); - emitter.changeStatus('op1', 'executing'); + emitter.registerOperation('op1', 'project-a', 'build', false); + emitter.changeStatus('op1', 'executing', 0, 'queued'); emitter.writeOutput('op1', 'stdout', 'hello\n'); - emitter.changeStatus('op1', 'success', 100); + emitter.changeStatus('op1', 'success', 100, 'executing'); + emitter.closeOperationStream('op1'); + emitter.completeOperation('op1', 'success', 100); emitter.completeCommand('build', true, 0, { success: 1 }); expect(sink.inputs.map((i) => i.type)).toEqual([ @@ -72,15 +74,45 @@ describe('OperationStreamEmitter', () => { 'operationStatusChanged', 'externalOutput', 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted', 'commandResult' ]); expect(sink.inputs[2].scope).toEqual({ commandName: 'build', operationId: 'op1' }); expect(sink.inputs[2].privacy).toBe('local-sensitive'); - expect(sink.inputs[3].payload).toMatchObject({ operationId: 'op1', status: 'success', durationMs: 100 }); + expect(sink.inputs[0].payload).toMatchObject({ operationId: 'op1', silent: false }); + expect(sink.inputs[3].payload).toMatchObject({ + operationId: 'op1', + previousStatus: 'executing', + status: 'success', + durationMs: 100 + }); // externalOutput is protected (never coalesced/dropped); the manager derives `required`. expect(isReporterEventRequired('externalOutput')).toBe(true); }); + it('records silent metadata and orders close before completion', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.registerOperation('silent-op', 'project-a', '_phase:synthetic', true); + emitter.changeStatus('silent-op', 'noOp', 0, 'ready'); + emitter.closeOperationStream('silent-op'); + emitter.completeOperation('silent-op', 'noOp', 0); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'operationRegistered', + 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted' + ]); + expect(sink.inputs[0].payload).toEqual({ + operationId: 'silent-op', + projectName: 'project-a', + phaseName: '_phase:synthetic', + silent: true + }); + }); + it('splits raw output into uncollated chunks', () => { const sink: CapturingSink = new CapturingSink(); const emitter: OperationStreamEmitter = makeEmitter(sink, 4); diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts index b602d9d5cc1..8efe5138ba0 100644 --- a/libraries/reporter/src/test/Protocol.test.ts +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -18,6 +18,7 @@ import { describe('ReporterProtocol', () => { it('advertises protocol major 1 and the specified byte limits', () => { expect(REPORTER_PROTOCOL_VERSION.major).toBe(1); + expect(REPORTER_PROTOCOL_VERSION.minor).toBe(1); expect(REPORTER_PROTOCOL_LIMITS.bootstrapBufferBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes).toBe(64 * 1024); @@ -175,10 +176,7 @@ describe('negotiateReporterHello', () => { it('rejects a malformed wire hello with a predictable validation error', () => { expect(() => - negotiateReporterHello( - { kind: 'hello' }, - { supportedProtocolVersion: { major: 1, minor: 0 } } - ) + negotiateReporterHello({ kind: 'hello' }, { supportedProtocolVersion: { major: 1, minor: 0 } }) ).toThrow(InvalidReporterHelloError); expect(() => negotiateReporterHello( diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 423b8a78cf1..fed71783a89 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -85,7 +85,7 @@ describe('TelemetrySubscriber', () => { expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); expect(aggregate.reporterMode).toBe('default'); - expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 0 }); + expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 1 }); expect(aggregate.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); // The subscriber runs alongside a rendering reporter and does not consume events from it. diff --git a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap index 76b6b15a1c5..e46cd3c517c 100644 --- a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap +++ b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap @@ -3,7 +3,7 @@ exports[`compatibility goldens advertises the current protocol version as the negotiation baseline 1`] = ` Object { "major": 1, - "minor": 0, + "minor": 1, } `; diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index b5d90e0ad8a..9e42aadf489 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -60,14 +60,19 @@ export interface IOperationGraphEventSink { * quiet-mode filtering. Concatenated chunks for one operation exactly match * what the collated sink receives for that operation. */ - onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; /** * Invoked when an operation's collated output stream is closed at the end of * its execution, after all status lines and output have been written. This * is the authoritative "no more output for this operation" signal. */ - onOperationStreamClosed?(operationId: string): void; + onOperationStreamClosed?(result: IOperationExecutionResult): void; + + /** + * Invoked after the operation stream is closed and the final outcome is authoritative. + */ + onOperationCompleted?(result: IOperationExecutionResult): void; /** * Invoked for each human-oriented status line written to the terminal, diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 47317ecfac8..031a48b5482 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -173,6 +173,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera private _status: OperationStatus; private _stateHash: string | undefined; private _stateHashComponents: IOperationStateHashComponents | undefined; + private _operationStreamClosed: boolean = false; public constructor(operation: Operation, context: IOperationExecutionRecordContext) { const { runner, associatedPhase, associatedProject, enabled } = operation; @@ -283,6 +284,18 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return !this.enabled || this.runner.silent; } + /** + * Notifies observers that this iteration cannot emit more output for the operation. + * + * @internal + */ + public closeOperationStream(): void { + if (!this._operationStreamClosed) { + this._operationStreamClosed = true; + this._context.eventSink?.onOperationStreamClosed?.(this); + } + } + public getStateHash(): string { if (this._stateHash === undefined) { const { dependencies, local, config } = this.getStateHashComponents(); @@ -402,9 +415,11 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // Tap the stream upstream of the quiet-mode discard so the sink observes // the exact bytes the collated writer would receive, regardless of verbosity. chunkTapDestinations.push( - new OperationChunkTap(this.name, (operationId, chunk) => - eventSink.onOperationChunk?.(operationId, chunk) - ) + new OperationChunkTap(this.name, (operationId, chunk) => { + if (operationId === this.name) { + eventSink.onOperationChunk?.(this, chunk); + } + }) ); } @@ -486,9 +501,6 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } finally { if (this.isTerminal) { this._collatedWriter?.close(); - if (this._collatedWriter) { - this._context.eventSink?.onOperationStreamClosed?.(this.name); - } this.stdioSummarizer.close(); this.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index b2c1362b6a2..a135e796cd3 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -676,9 +676,7 @@ export class OperationGraph implements IOperationGraph { operation, iterationContext ); - executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -717,6 +715,10 @@ export class OperationGraph implements IOperationGraph { return; } + for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + } + this._setScheduledIteration(iterationContext); // Notify listeners that an iteration has been scheduled with the planned operation records try { @@ -961,6 +963,8 @@ export class OperationGraph implements IOperationGraph { } } for (const record of executionRecords.values()) { + record.closeOperationStream(); + eventSink?.onOperationCompleted?.(record); record.stdioSummarizer.close(); record.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index d3287c44270..3c82a943cea 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -5,14 +5,16 @@ import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter, + type OperationStreamEmitter, type OperationStatus as ReporterOperationStatus } from '@rushstack/rush-reporter'; -import type { ITerminalChunk } from '@rushstack/terminal'; +import { TerminalChunkKind, type ITerminalChunk } from '@rushstack/terminal'; import type { RushSession } from '../../pluginFramework/RushSession'; import { _correlateRushSessionError, - _getRushSessionLifecycleEmitter + _getRushSessionLifecycleEmitter, + _getRushSessionOperationStreamEmitter } from '../../pluginFramework/RushSession'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; @@ -26,18 +28,26 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + readonly streamEmitter: OperationStreamEmitter | undefined; registrationCycle: IReporterOperationCycle | undefined; } interface IReporterOperationCycle { readonly registeredOperationIds: Set; readonly statuses: Map; + readonly closedOperationIds: Set; + readonly completedResults: Map; diagnosed: boolean; lastEmittedStatus: ReporterOperationStatus | undefined; silent: boolean; + streamClosed: boolean; } class ReporterOperationEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; + private readonly _operationsByLegacyId: Map = new Map(); private readonly _cyclesByResult: WeakMap = new WeakMap(); @@ -62,12 +72,22 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!emitter) { continue; } + const streamEmitter: OperationStreamEmitter | undefined = _getRushSessionOperationStreamEmitter( + rushSession, + { + commandName, + operationId, + projectName, + phaseName + } + ); reporterOperation = { emitter, legacyOperationIds: new Set(), operationId, phaseName, projectName, + streamEmitter, registrationCycle: undefined }; operationsByReporterId.set(operationId, reporterOperation); @@ -75,6 +95,16 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { reporterOperation.legacyOperationIds.add(operation.name); this._operationsByLegacyId.set(operation.name, reporterOperation); } + + if (Array.from(this._operationsByLegacyId.values()).some(({ streamEmitter }) => !!streamEmitter)) { + this.onOperationChunk = (operationId, chunk) => this._onOperationChunk(operationId, chunk); + this.onOperationStreamClosed = (operationId) => this._onOperationStreamClosed(operationId); + this.onOperationCompleted = (result) => this._onOperationCompleted(result); + } else { + this.onOperationChunk = undefined; + this.onOperationStreamClosed = undefined; + this.onOperationCompleted = undefined; + } } public get isEnabled(): boolean { @@ -96,9 +126,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { cycle = { registeredOperationIds: new Set(), statuses: new Map(), + closedOperationIds: new Set(), + completedResults: new Map(), diagnosed: false, lastEmittedStatus: undefined, - silent: true + silent: true, + streamClosed: false }; operation.registrationCycle = cycle; } @@ -106,18 +139,27 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { this._cyclesByResult.set(result, cycle); cycle.registeredOperationIds.add(operationId); cycle.silent &&= silent; - if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size) { return; } - operation.emitter.emitOperationRegistered({ - operationId: operation.operationId, - projectName: operation.projectName, - phaseName: operation.phaseName - }); + if (operation.streamEmitter) { + operation.streamEmitter.registerOperation( + operation.operationId, + operation.projectName, + operation.phaseName, + cycle.silent + ); + } else if (!cycle.silent) { + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } } - public onOperationStatusChanged(result: IOperationExecutionResult): void { + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); if (!operation) { return; @@ -152,12 +194,22 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (status === undefined || status === cycle.lastEmittedStatus) { return; } + const aggregatePreviousStatus: ReporterOperationStatus | undefined = + cycle.lastEmittedStatus ?? + (operation.legacyOperationIds.size === 1 ? _toReporterStatus(previousStatus) : undefined); cycle.lastEmittedStatus = status; - if (!cycle.silent) { - const durationMs: number | undefined = - operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined - ? result.stopwatch.duration * 1000 - : undefined; + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + if (operation.streamEmitter) { + operation.streamEmitter.changeStatus( + operation.operationId, + status, + durationMs, + aggregatePreviousStatus + ); + } else if (!cycle.silent) { operation.emitter.emitOperationStatusChanged({ operationId: operation.operationId, status, @@ -165,11 +217,64 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { }); } } + + private _onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get( + result.operation.name + ); + if (!operation?.streamEmitter || !this._cyclesByResult.has(result)) { + return; + } + + if (chunk.kind === TerminalChunkKind.Stdout) { + operation.streamEmitter.writeOutput(operation.operationId, 'stdout', chunk.text); + } else if (chunk.kind === TerminalChunkKind.Stderr) { + operation.streamEmitter.writeOutput(operation.operationId, 'stderr', chunk.text); + } + } + + private _onOperationStreamClosed(result: IOperationExecutionResult): void { + const operationId: string = result.operation.name; + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!operation?.streamEmitter || !cycle || cycle.streamClosed) { + return; + } + cycle.closedOperationIds.add(operationId); + if (cycle.closedOperationIds.size === operation.legacyOperationIds.size) { + cycle.streamClosed = true; + operation.streamEmitter.closeOperationStream(operation.operationId); + } + } + + private _onOperationCompleted(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation?.streamEmitter) { + return; + } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } + + cycle.completedResults.set(result.operation.name, result); + if (cycle.completedResults.size !== operation.legacyOperationIds.size) { + return; + } + const status: ReporterOperationStatus = _getAggregateTerminalStatus( + Array.from(cycle.completedResults.values(), ({ status: resultStatus }) => resultStatus) + ); + const durationMs: number | undefined = _getAggregateDurationMs(cycle.completedResults); + operation.streamEmitter.completeOperation(operation.operationId, status, durationMs); + } } class CompositeOperationGraphEventSink implements IOperationGraphEventSink { - public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; - public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + public readonly onOperationChunk: + | ((result: IOperationExecutionResult, chunk: ITerminalChunk) => void) + | undefined; + public readonly onOperationStreamClosed: ((result: IOperationExecutionResult) => void) | undefined; + public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; private readonly _first: IOperationGraphEventSink; private readonly _second: IOperationGraphEventSink; @@ -179,16 +284,23 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { this._second = second; this.onOperationChunk = first.onOperationChunk || second.onOperationChunk - ? (operationId, chunk) => { - first.onOperationChunk?.(operationId, chunk); - second.onOperationChunk?.(operationId, chunk); + ? (result, chunk) => { + first.onOperationChunk?.(result, chunk); + second.onOperationChunk?.(result, chunk); } : undefined; this.onOperationStreamClosed = first.onOperationStreamClosed || second.onOperationStreamClosed - ? (operationId) => { - first.onOperationStreamClosed?.(operationId); - second.onOperationStreamClosed?.(operationId); + ? (result) => { + first.onOperationStreamClosed?.(result); + second.onOperationStreamClosed?.(result); + } + : undefined; + this.onOperationCompleted = + first.onOperationCompleted || second.onOperationCompleted + ? (result) => { + first.onOperationCompleted?.(result); + second.onOperationCompleted?.(result); } : undefined; } @@ -219,7 +331,7 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { } /** - * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * Adds reporter emission without changing the graph's visible output or collator routing. * * @internal */ @@ -334,3 +446,30 @@ function _isTerminalStatus(status: OperationStatus): boolean { return false; } } + +function _getAggregateDurationMs( + results: ReadonlyMap +): number | undefined { + let startTime: number | undefined; + let endTime: number | undefined; + for (const result of results.values()) { + if (result.stopwatch.startTime !== undefined) { + startTime = + startTime === undefined + ? result.stopwatch.startTime + : Math.min(startTime, result.stopwatch.startTime); + } + if (result.stopwatch.endTime !== undefined) { + endTime = + endTime === undefined ? result.stopwatch.endTime : Math.max(endTime, result.stopwatch.endTime); + } + } + if (startTime !== undefined && endTime !== undefined) { + return Math.max(0, endTime - startTime); + } + if (results.size === 1) { + const result: IOperationExecutionResult | undefined = results.values().next().value; + return result?.stopwatch.startTime === undefined ? undefined : result.stopwatch.duration * 1000; + } + return undefined; +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index e91029084c0..c93400e7165 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -35,7 +35,12 @@ jest.mock('../ProjectLogWritable', () => { }); import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; -import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; +import { + MockWritable, + StringBufferTerminalProvider, + TerminalProviderSeverity, + type ITerminalChunk +} from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -85,6 +90,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly headers: [string, number, number][] = []; public readonly activities: string[] = []; public readonly chunks: Map = new Map(); + public readonly closed: string[] = []; + public readonly completed: [string, string][] = []; public onOperationRegistered(operationId: string, silent: boolean): void { this.registered.push([operationId, silent]); @@ -106,6 +113,12 @@ class RecordingSink implements IOperationGraphEventSink { } chunks.push(chunk.text); } + public onOperationStreamClosed(operationId: string): void { + this.closed.push(operationId); + } + public onOperationCompleted(result: IOperationExecutionResult): void { + this.completed.push([result.operation.name, result.status]); + } } class CapturingReporterSink implements IReporterEventSink { @@ -167,6 +180,11 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(sink.activities.some((line: string) => line.includes('"alpha" completed successfully'))).toBe( true ); + expect([...sink.closed].sort()).toEqual(['alpha', 'beta']); + expect([...sink.completed].sort()).toEqual([ + ['alpha', OperationStatus.Success], + ['beta', OperationStatus.Success] + ]); }); it('emits raw per-operation chunks even in quiet mode, matching the collated stream', async () => { @@ -235,7 +253,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + reporter: { + eventSink: reporterSink, + sessionId: 'operation-shadow', + operationStreamEnabled: false + } }); const createFailingOperation = (): Operation => createOperation( @@ -279,6 +301,174 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + it('emits the opted-in canonical stream without duplicating or losing operation chunks', async () => { + const stdoutText: string = `${'a'.repeat(64 * 1024 + 7)}\n`; + const stderrText: string = 'stderr detail\n'; + const createOutputRunner = (): IOperationRunner => ({ + name: '@scope/project (_phase:build)', + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: true, + isNoOp: false, + executeAsync: async (context: IOperationRunnerContext) => + await context.runWithTerminalAsync( + async (terminal, terminalProvider) => { + void terminal; + terminalProvider.write(stdoutText, TerminalProviderSeverity.log); + terminalProvider.write(stderrText, TerminalProviderSeverity.error); + return OperationStatus.SuccessWithWarning; + }, + { createLogFile: false, logFileSuffix: '' } + ), + getConfigHash: () => 'mock' + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('@scope/project', createOutputRunner(), mockPhase, '@scope/project')]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'operation-stream', + operationStreamEnabled: true + } + }); + const streamedWritable: MockWritable = new MockWritable(); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('@scope/project', createOutputRunner(), mockPhase, '@scope/project')]), + createGraphOptions(streamedWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + expect(streamedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ scope }) => scope?.operationId === '@scope/project#phase' + ); + expect(operationEvents[0]).toMatchObject({ + type: 'operationRegistered', + payload: { + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase', + silent: false + } + }); + + const statusEvents: IReporterEmitEventInput[] = operationEvents.filter( + ({ type }) => type === 'operationStatusChanged' + ); + expect(statusEvents.map(({ payload }) => payload)).toEqual([ + expect.objectContaining({ previousStatus: 'ready', status: 'queued' }), + expect.objectContaining({ previousStatus: 'queued', status: 'executing' }), + expect.objectContaining({ previousStatus: 'executing', status: 'successWithWarnings' }) + ]); + + const outputEvents: IReporterEmitEventInput[] = operationEvents.filter( + ({ type }) => type === 'externalOutput' + ); + expect( + outputEvents.every( + ({ payload }) => Buffer.byteLength((payload as { text: string }).text, 'utf8') <= 64 * 1024 + ) + ).toBe(true); + const stdoutChunks: string = outputEvents + .filter(({ payload }) => (payload as { stream: string }).stream === 'stdout') + .map(({ payload }) => (payload as { text: string }).text) + .join(''); + const stderrChunks: string = outputEvents + .filter(({ payload }) => (payload as { stream: string }).stream === 'stderr') + .map(({ payload }) => (payload as { text: string }).text) + .join(''); + expect(stdoutChunks).toBe(stdoutText); + expect(stderrChunks).toBe(stderrText); + + const closedIndex: number = operationEvents.findIndex(({ type }) => type === 'operationStreamClosed'); + const completedIndex: number = operationEvents.findIndex(({ type }) => type === 'operationCompleted'); + expect(closedIndex).toBeGreaterThan(operationEvents.lastIndexOf(outputEvents.at(-1)!)); + expect(completedIndex).toBeGreaterThan(closedIndex); + expect(operationEvents[completedIndex].payload).toMatchObject({ + operationId: '@scope/project#phase', + status: 'successWithWarnings' + }); + }); + + it('reports silent operation metadata and outcomes on the opted-in stream', async () => { + const silentRunner: IOperationRunner = { + name: 'silent synthetic', + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async () => OperationStatus.Success, + getConfigHash: () => 'silent' + }; + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'silent-operation', + operationStreamEnabled: true + } + }); + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation('visible', new MockOperationRunner('visible'), mockPhase, '@scope/visible'), + createOperation('silent synthetic', silentRunner, mockPhase, '@scope/project') + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'operationRegistered', + payload: expect.objectContaining({ + operationId: '@scope/project#phase', + silent: true + }) + }) + ); + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'operationCompleted', + payload: expect.objectContaining({ + operationId: '@scope/project#phase', + status: 'success' + }) + }) + ); + }); + + it('does not attach an operation adapter when the session has no reporter sink', () => { + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('no sink', new MockOperationRunner('no sink'))]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + + expect(graph.eventSink).toBeUndefined(); + }); + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ @@ -486,7 +676,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + reporter: { + eventSink: reporterSink, + sessionId: 'operation-retries', + operationStreamEnabled: true + } }); const compilePhase: IPhase = { ...mockPhase, @@ -530,6 +724,16 @@ describe('OperationGraph event sink (dual-emit)', () => { '@scope/project#_phase:compile', '@scope/project#_phase:test' ]); + expect( + reporterSink.inputs + .filter(({ type }) => type === 'operationCompleted') + .map(({ scope }) => scope?.operationId) + ).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); @@ -554,7 +758,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + reporter: { + eventSink: reporterSink, + sessionId: 'output-parity', + operationStreamEnabled: false + } }); const shadowWritable: MockWritable = new MockWritable(); const shadowGraph: OperationGraph = new OperationGraph( @@ -562,6 +770,7 @@ describe('OperationGraph event sink (dual-emit)', () => { createGraphOptions(shadowWritable, false) ); attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + expect(shadowGraph.eventSink?.onOperationChunk).toBeUndefined(); await shadowGraph.executeAsync({}); expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e52cbb0ad02..9525b92dc43 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -5,6 +5,7 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/ import { LifecycleEmitter, LegacyErrorBridge, + OperationStreamEmitter, RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, @@ -49,6 +50,17 @@ export interface IRushSessionReporterOptions { * The identifier assigned to this Rush session by the frontend. */ readonly sessionId: string; + + /** + * Enables raw semantic operation events for the pre-major reporter opt-in path. + * + * @remarks + * When false or omitted, Rush retains the shadow lifecycle-only behavior and + * does not tap operation output. + * + * @internal + */ + readonly operationStreamEnabled?: boolean; } /** @@ -293,6 +305,22 @@ function _createLifecycleEmitter( }); } +function _createOperationStreamEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): OperationStreamEmitter | undefined { + if (!state) { + return undefined; + } + + return new OperationStreamEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined + }); +} + function _getSessionState(rushSession: RushSession): IRushSessionState { const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); if (!state) { @@ -451,6 +479,21 @@ export function _getRushSessionLifecycleEmitter( return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); } +/** + * Creates the raw operation stream emitter only for the pre-major opt-in path. + * + * @internal + */ +export function _getRushSessionOperationStreamEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): OperationStreamEmitter | undefined { + const state: IRushSessionState = _getSessionState(rushSession); + return state.options.reporter?.operationStreamEnabled + ? _createOperationStreamEmitter(state.reporting, scope) + : undefined; +} + /** * Returns the current allowlisted reporter telemetry projection. * From 481b4f582732669d6931e137380271e2a8d06c09 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 08:10:04 +0000 Subject: [PATCH 017/164] Fix operation reporter stream edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../operations/CacheableOperationPlugin.ts | 10 +- .../logic/operations/OperationEventSink.ts | 3 +- .../operations/OperationExecutionRecord.ts | 44 ++++--- .../src/logic/operations/OperationGraph.ts | 33 +++-- .../test/OperationGraphEventSink.test.ts | 124 +++++++++++++++++- 5 files changed, 179 insertions(+), 35 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts index 6f254f52fc6..de28b6a4995 100644 --- a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts @@ -762,15 +762,17 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin { cacheConsoleWritable = collatedWriter; } - let cacheCollatedTerminal: CollatedTerminal; + let cacheDestination: TerminalWritable; if (cacheProjectLogWritable) { - const cacheSplitterTransform: SplitterTransform = new SplitterTransform({ + cacheDestination = new SplitterTransform({ destinations: [cacheConsoleWritable, cacheProjectLogWritable] }); - cacheCollatedTerminal = new CollatedTerminal(cacheSplitterTransform); } else { - cacheCollatedTerminal = new CollatedTerminal(cacheConsoleWritable); + cacheDestination = cacheConsoleWritable; } + const cacheCollatedTerminal: CollatedTerminal = new CollatedTerminal( + record.addOperationChunkTap(cacheDestination) + ); const buildCacheTerminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider( cacheCollatedTerminal, diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 9e42aadf489..79c10e3300e 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -57,8 +57,7 @@ export interface IOperationGraphEventSink { /** * Invoked for each chunk of an operation's raw output, upstream of any - * quiet-mode filtering. Concatenated chunks for one operation exactly match - * what the collated sink receives for that operation. + * newline normalization or quiet-mode filtering. */ onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 031a48b5482..bb0d4c2524d 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -296,6 +296,29 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } } + /** + * Adds the reporter's lossless operation-output tap ahead of any legacy presentation transforms. + * + * @internal + */ + public addOperationChunkTap(destination: TerminalWritable): TerminalWritable { + const eventSink: IOperationGraphEventSink | undefined = this._context.eventSink; + if (!eventSink?.onOperationChunk) { + return destination; + } + + return new SplitterTransform({ + destinations: [ + destination, + new OperationChunkTap(this.name, (operationId, chunk) => { + if (operationId === this.name) { + eventSink.onOperationChunk?.(this, chunk); + } + }) + ] + }); + } + public getStateHash(): string { if (this._stateHash === undefined) { const { dependencies, local, config } = this.getStateHashComponents(); @@ -409,27 +432,12 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera newlineKind: NewlineKind.Lf // for StdioSummarizer }); - const chunkTapDestinations: TerminalWritable[] = []; - const eventSink: IOperationGraphEventSink | undefined = this._context.eventSink; - if (eventSink?.onOperationChunk) { - // Tap the stream upstream of the quiet-mode discard so the sink observes - // the exact bytes the collated writer would receive, regardless of verbosity. - chunkTapDestinations.push( - new OperationChunkTap(this.name, (operationId, chunk) => { - if (operationId === this.name) { - eventSink.onOperationChunk?.(this, chunk); - } - }) - ); - } - const splitterTransform1: SplitterTransform = new SplitterTransform({ destinations: [ this.quietMode ? new DiscardStdoutTransform({ destination: this.collatedWriter }) : this.collatedWriter, - stderrLineTransform, - ...chunkTapDestinations + stderrLineTransform ] }); @@ -439,7 +447,9 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera ensureNewlineAtEnd: true }); - const collatedTerminal: CollatedTerminal = new CollatedTerminal(normalizeNewlineTransform); + const collatedTerminal: CollatedTerminal = new CollatedTerminal( + this.addOperationChunkTap(normalizeNewlineTransform) + ); const terminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider(collatedTerminal, { debugEnabled: this.debugMode }); diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index a135e796cd3..a6e6eeda5c3 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -715,10 +715,6 @@ export class OperationGraph implements IOperationGraph { return; } - for (const executionRecord of executionRecords.values()) { - eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); - } - this._setScheduledIteration(iterationContext); // Notify listeners that an iteration has been scheduled with the planned operation records try { @@ -730,6 +726,9 @@ export class OperationGraph implements IOperationGraph { terminal.writeStderrLine(Colorize.red(errorMessage)); throw e; } + for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + } if (!this._currentIteration) { this._setIdleTimeout(); } else if (!this.pauseNextIteration) { @@ -877,12 +876,26 @@ export class OperationGraph implements IOperationGraph { terminal.writeStdoutLine(parallelismLine); eventSink?.onActivity?.(parallelismLine); - const bailStatus: OperationStatus | undefined | void = abortSignal.aborted - ? OperationStatus.Aborted - : await measureAsyncFn( - `${PERF_PREFIX}:beforeExecuteIterationAsync`, - async () => await hooks.beforeExecuteIterationAsync.promise(executionRecords, iterationOptions) - ); + let bailStatus: OperationStatus | undefined | void; + try { + bailStatus = abortSignal.aborted + ? OperationStatus.Aborted + : await measureAsyncFn( + `${PERF_PREFIX}:beforeExecuteIterationAsync`, + async () => await hooks.beforeExecuteIterationAsync.promise(executionRecords, iterationOptions) + ); + } catch (error) { + for (const record of executionRecords.values()) { + if (!record.isTerminal) { + record.status = OperationStatus.Aborted; + } + record.closeOperationStream(); + eventSink?.onOperationCompleted?.(record); + record.stdioSummarizer.close(); + record.problemCollector.close(); + } + throw error; + } if (bailStatus) { // A tap short-circuited the iteration. If it bailed with a successful status (e.g. the diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c93400e7165..2f392a4bcde 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -302,8 +302,8 @@ describe('OperationGraph event sink (dual-emit)', () => { }); it('emits the opted-in canonical stream without duplicating or losing operation chunks', async () => { - const stdoutText: string = `${'a'.repeat(64 * 1024 + 7)}\n`; - const stderrText: string = 'stderr detail\n'; + const stdoutText: string = `${'a'.repeat(64 * 1024 + 7)}\rprogress`; + const stderrText: string = 'stderr detail\r'; const createOutputRunner = (): IOperationRunner => ({ name: '@scope/project (_phase:build)', reportTiming: true, @@ -402,6 +402,126 @@ describe('OperationGraph event sink (dual-emit)', () => { }); }); + it('combines sharded implementation records into one project x phase stream', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'sharded-operation-stream', + operationStreamEnabled: true + } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardRunner: IOperationRunner = { + name: `${projectName} (phase) - shard 1/1`, + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async (context: IOperationRunnerContext) => + await context.runWithTerminalAsync( + async (terminal) => { + terminal.write('shard output without newline'); + return OperationStatus.Failure; + }, + { createLogFile: false, logFileSuffix: '' } + ), + getConfigHash: () => 'shard' + }; + const collatorRunner: IOperationRunner = { + name: `${projectName} (phase) - collate`, + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async () => OperationStatus.Success, + getConfigHash: () => 'collate' + }; + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shard: Operation = createOperation('shard', shardRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shard.addDependency(preShard); + collator.addDependency(shard); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shard]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ scope }) => scope?.operationId === `${projectName}#phase` + ); + expect(operationEvents.filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents + .filter(({ type }) => type === 'externalOutput') + .map(({ payload }) => (payload as { text: string }).text) + .join('') + ).toBe('shard output without newline'); + expect(operationEvents.filter(({ type }) => type === 'operationStreamClosed')).toHaveLength(1); + expect(operationEvents.filter(({ type }) => type === 'operationCompleted')).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ operationId: `${projectName}#phase`, status: 'failure' }) + }) + ]); + expect( + operationEvents.filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + }); + + it('does not register operations when scheduling hooks reject the iteration', async () => { + const sink: RecordingSink = new RecordingSink(); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('hook failure', new MockOperationRunner('hook failure'))]), + createGraphOptions(mockWritable, false) + ); + graph.eventSink = sink; + graph.hooks.onIterationScheduled.tap('test', () => { + throw new Error('schedule rejected'); + }); + + await expect(graph.executeAsync({})).rejects.toThrow('schedule rejected'); + expect(sink.registered).toEqual([]); + expect(sink.closed).toEqual([]); + expect(sink.completed).toEqual([]); + }); + + it('finalizes registered operations when the pre-execution hook rejects', async () => { + const sink: RecordingSink = new RecordingSink(); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('hook failure', new MockOperationRunner('hook failure'))]), + createGraphOptions(mockWritable, false) + ); + graph.eventSink = sink; + graph.hooks.beforeExecuteIterationAsync.tapPromise('test', async () => { + throw new Error('pre-execution rejected'); + }); + + await expect(graph.executeAsync({})).rejects.toThrow('pre-execution rejected'); + expect(sink.registered).toEqual([['hook failure', false]]); + expect(sink.closed).toEqual(['hook failure']); + expect(sink.completed).toEqual([['hook failure', OperationStatus.Aborted]]); + }); + it('reports silent operation metadata and outcomes on the opted-in stream', async () => { const silentRunner: IOperationRunner = { name: 'silent synthetic', From e83fa59d2f95c0a7126b0c79575d1799aa13403d Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 17:30:45 +0000 Subject: [PATCH 018/164] Fix operation stream review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-reporter.api.md | 1 + .../src/bootstrap/BootstrapEventBuffer.ts | 4 +- .../src/events/IReporterEventEnvelope.ts | 3 +- .../reporter/src/events/ReporterEventType.ts | 31 +++- .../reporter/src/frontend/ReporterHost.ts | 24 ++- .../reporter/src/test/HeftIntegration.test.ts | 51 ++++++ libraries/reporter/src/test/Manager.test.ts | 13 +- .../src/test/OperationStreamEmitter.test.ts | 2 + libraries/reporter/src/test/Protocol.test.ts | 7 + .../reporter/src/test/ReporterHost.test.ts | 12 +- .../operations/OperationExecutionRecord.ts | 24 +++ .../src/logic/operations/OperationGraph.ts | 37 +++-- .../test/OperationGraphEventSink.test.ts | 156 ++++++++---------- 13 files changed, 238 insertions(+), 127 deletions(-) diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 029dea99d87..087a4ca45fd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -819,6 +819,7 @@ export interface IReporterHostOptions { readonly manager?: ReporterManager; readonly nowMs?: () => number; readonly retentionMs?: number; + readonly supportedProtocolVersion?: IReporterProtocolVersion; } // @beta diff --git a/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts b/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts index c3789cf8789..6ef91a2aa1c 100644 --- a/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts +++ b/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts @@ -7,7 +7,7 @@ import { BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, encodeBootstrapEnvelope } from './BootstrapProtocol'; -import type { ReporterEventType } from '../events/ReporterEventType'; +import { isReporterEventRequired, type ReporterEventType } from '../events/ReporterEventType'; import { chunkUtf8Text } from '../utilities/chunkUtf8Text'; const TRUNCATION_NOTICE_RESERVE_BYTES: number = 512; @@ -191,7 +191,7 @@ export class BootstrapEventBuffer { */ public emit(input: IBootstrapEventInput): string { const eventId: string = `boot_${this._nextEventId++}`; - const required: boolean = input.type !== 'activityChanged'; + const required: boolean = isReporterEventRequired(input.type); const line: string = encodeBootstrapEnvelope({ eventId, sessionId: this._sessionId, diff --git a/libraries/reporter/src/events/IReporterEventEnvelope.ts b/libraries/reporter/src/events/IReporterEventEnvelope.ts index b9a964fca4c..f0cd5495f7b 100644 --- a/libraries/reporter/src/events/IReporterEventEnvelope.ts +++ b/libraries/reporter/src/events/IReporterEventEnvelope.ts @@ -132,7 +132,8 @@ export interface IReporterEventEnvelope { readonly privacy: ReporterPrivacyClassification; /** - * Whether this event is correctness-critical and must never be dropped. + * Whether an older same-major consumer must reject the stream if it does not + * recognize this event. Event types added in a minor version are optional. */ readonly required: boolean; diff --git a/libraries/reporter/src/events/ReporterEventType.ts b/libraries/reporter/src/events/ReporterEventType.ts index 7e7010b9534..c9e28830c2f 100644 --- a/libraries/reporter/src/events/ReporterEventType.ts +++ b/libraries/reporter/src/events/ReporterEventType.ts @@ -12,7 +12,7 @@ * Per-type policy (the contract the manager, log-level filters, and reporters * implement): * - * | type | never dropped | minimum log level | + * | type | required on wire | minimum log level | * | --- | --- | --- | * | `sessionStarted` | yes | `normal` | * | `sessionCompleted` | yes | `quiet` | @@ -30,8 +30,8 @@ * | `artifactAvailable` | yes | `normal` | * | `commandResult` | yes | `quiet` | * | `extension` | yes | `normal` | - * | `operationStreamClosed` | yes | `debug` | - * | `operationCompleted` | yes | `normal` | + * | `operationStreamClosed` | additive optional | `debug` | + * | `operationCompleted` | additive optional | `normal` | * * Coalescing a replaceable `activityChanged` event under queue pressure leaves * gaps in the delivered `sequence` values; gaps are legal and are not a @@ -72,19 +72,38 @@ export const REPORTER_EVENT_TYPES = [ */ export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; +const REQUIRED_REPORTER_EVENT_TYPES: ReadonlySet = new Set([ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'messageEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' +]); + /** * Returns `true` if events of this type are correctness-critical and must * never be dropped or coalesced. * * @remarks * The manager derives the envelope `required` flag from this policy; - * producers never set it. Only `activityChanged` is replaceable — every other - * type, including `extension`, must be delivered. + * producers never set it. The required set is frozen to the protocol 1.0 event + * types so a same-major older peer can skip event types introduced by a newer + * minor version without discarding the stream. * * @param type - the event type to check * * @beta */ export function isReporterEventRequired(type: ReporterEventType): boolean { - return type !== 'activityChanged'; + return REQUIRED_REPORTER_EVENT_TYPES.has(type); } diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 0b6acdd3787..be139470bf9 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -11,10 +11,7 @@ import type { ReporterEventType } from '../events/ReporterEventType'; import type { IReporterEventSink } from '../producers/IReporterEventSink'; import { REPORTER_EVENT_TYPES } from '../events/ReporterEventType'; import { ReporterManager } from '../manager/ReporterManager'; -import { - REPORTER_PROTOCOL_VERSION, - isReporterProtocolCompatible -} from '../protocol/ReporterProtocol'; +import { REPORTER_PROTOCOL_VERSION, isReporterProtocolCompatible } from '../protocol/ReporterProtocol'; import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR @@ -64,6 +61,11 @@ export interface IReporterHostOptions { * Returns the current time in milliseconds. Injectable for testing. */ readonly nowMs?: () => number; + + /** + * The protocol version supported by this host. Defaults to the current version. + */ + readonly supportedProtocolVersion?: IReporterProtocolVersion; } /** @@ -101,7 +103,12 @@ export interface IBootstrapReplayResult { * The reason no events were replayed, when a handoff path was present. * `nonce-mismatch` means the file failed authentication and was rejected. */ - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: + | 'unreadable' + | 'invalid-path' + | 'nonce-mismatch' + | 'invalid-event' + | 'incompatible-protocol'; } function isRecord(value: unknown): value is Record { @@ -161,6 +168,7 @@ export class ReporterHost { private readonly _handoffDirectory: string; private readonly _retentionMs: number; private readonly _nowMs: () => number; + private readonly _supportedProtocolVersion: IReporterProtocolVersion; public constructor(options: IReporterHostOptions = {}) { this._manager = options.manager ?? new ReporterManager(); @@ -168,6 +176,7 @@ export class ReporterHost { this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); this._retentionMs = options.retentionMs ?? DEFAULT_HANDOFF_RETENTION_MS; this._nowMs = options.nowMs ?? (() => Date.now()); + this._supportedProtocolVersion = options.supportedProtocolVersion ?? REPORTER_PROTOCOL_VERSION; } /** @@ -247,10 +256,7 @@ export class ReporterHost { let skippedEventCount: number = discardedRecordCount; for (const event of events) { const protocolVersion: IReporterProtocolVersion | undefined = getProtocolVersion(event); - if ( - protocolVersion && - !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion) - ) { + if (protocolVersion && !isReporterProtocolCompatible(this._supportedProtocolVersion, protocolVersion)) { await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index 5d2990e33a6..bd1f0d0eed6 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -269,6 +269,57 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_UPDATE_REQUIRED'); }); + it('lets a 1.0 consumer skip an unknown optional 1.1 event and continue the stream', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => forwarded.push(envelope) + }); + + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 1 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: [], + requiredFeatures: [] + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 1 }, + eventId: 'future_optional', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + required: false, + type: 'futureMinorEvent', + payload: {} + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 1 }, + eventId: 'known_after_future', + sessionId: 'child-sess', + sequence: 2, + timestamp: '2026-01-01T00:00:00.001Z', + source: SOURCE, + privacy: 'public', + required: true, + type: 'commandCompleted', + payload: { commandName: 'build', exitCode: 0 } + }) + ).toBe(true); + + const result: IHeftChildResult = host.processChildRecords([]); + expect(result).toMatchObject({ accepted: true, eventCount: 1 }); + expect(forwarded.map(({ eventId }) => eventId)).toEqual(['known_after_future']); + }); + it('rejects malformed records without throwing from the streaming drain', () => { const negotiationResults: boolean[] = []; const host: HeftDescriptorHost = new HeftDescriptorHost({ diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 582f88a8c23..cd77f28ecda 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -117,12 +117,16 @@ describe('ReporterManager ordering and assignment', () => { manager.emit(makeInput('activityChanged')); manager.emit(makeInput('messageEmitted')); manager.emit(makeInput('commandStarted')); + manager.emit(makeInput('operationStreamClosed')); + manager.emit(makeInput('operationCompleted')); await manager.flushAsync(); expect(reporter.reported.map((e: IReporterEventEnvelope) => e.required)).toEqual([ false, true, - true + true, + false, + false ]); }); @@ -152,9 +156,10 @@ describe('ReporterManager ordering and assignment', () => { manager.ingestForeignEnvelope(foreign); await manager.flushAsync(); - const byIdentity: [string, string][] = reporter.reported.map( - (e: IReporterEventEnvelope) => [e.sessionId, e.eventId] - ); + const byIdentity: [string, string][] = reporter.reported.map((e: IReporterEventEnvelope) => [ + e.sessionId, + e.eventId + ]); expect(byIdentity).toEqual([ ['sess', 'evt_1'], ['child', 'evt_1'] diff --git a/libraries/reporter/src/test/OperationStreamEmitter.test.ts b/libraries/reporter/src/test/OperationStreamEmitter.test.ts index 680cdb49e17..adb5e6c6a93 100644 --- a/libraries/reporter/src/test/OperationStreamEmitter.test.ts +++ b/libraries/reporter/src/test/OperationStreamEmitter.test.ts @@ -89,6 +89,8 @@ describe('OperationStreamEmitter', () => { }); // externalOutput is protected (never coalesced/dropped); the manager derives `required`. expect(isReporterEventRequired('externalOutput')).toBe(true); + expect(isReporterEventRequired('operationStreamClosed')).toBe(false); + expect(isReporterEventRequired('operationCompleted')).toBe(false); }); it('records silent metadata and orders close before completion', () => { diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts index 8efe5138ba0..587de7ae8bc 100644 --- a/libraries/reporter/src/test/Protocol.test.ts +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -5,6 +5,7 @@ import { REPORTER_PROTOCOL_VERSION, REPORTER_PROTOCOL_LIMITS, isReporterProtocolCompatible, + isReporterEventRequired, encodeNdjsonRecord, NdjsonDecoder, NdjsonInvalidRecordError, @@ -28,6 +29,12 @@ describe('ReporterProtocol', () => { expect(isReporterProtocolCompatible({ major: 1, minor: 0 }, { major: 1, minor: 9 })).toBe(true); expect(isReporterProtocolCompatible({ major: 1, minor: 0 }, { major: 2, minor: 0 })).toBe(false); }); + + it('marks event types added in protocol 1.1 as optional for protocol 1.0 consumers', () => { + expect(isReporterEventRequired('operationStreamClosed')).toBe(false); + expect(isReporterEventRequired('operationCompleted')).toBe(false); + expect(isReporterEventRequired('commandResult')).toBe(true); + }); }); describe('NDJSON encode/decode', () => { diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 208c458b761..2472ef5793e 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -234,7 +234,7 @@ describe('ReporterHost handoff replay', () => { }); }); - it('skips an unknown additive event and replays known events', async () => { + it('lets a 1.0 consumer skip an unknown optional 1.1 event and replay the remaining stream', async () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); buffer.emit({ type: 'sessionStarted', payload: {} }); @@ -251,12 +251,15 @@ describe('ReporterHost handoff replay', () => { lines.splice(2, 0, JSON.stringify(unknownEvent)); await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); - const manager: ReporterManager = new ReporterManager(); + const manager: ReporterManager = new ReporterManager({ + protocolVersion: { major: 1, minor: 0 } + }); const reporter: RecordingReporter = new RecordingReporter(); manager.addReporter(reporter); await manager.initializeAsync(); const host: ReporterHost = new ReporterHost({ manager, + supportedProtocolVersion: { major: 1, minor: 0 }, env: { [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce @@ -267,10 +270,7 @@ describe('ReporterHost handoff replay', () => { const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); await manager.flushAsync(); expect(result).toMatchObject({ replayed: true, eventCount: 2, skippedEventCount: 1 }); - expect(reporter.reported.map((event) => event.type)).toEqual([ - 'sessionStarted', - 'diagnosticEmitted' - ]); + expect(reporter.reported.map((event) => event.type)).toEqual(['sessionStarted', 'diagnosticEmitted']); }); }); diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index bb0d4c2524d..d235a70cf23 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -174,6 +174,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera private _stateHash: string | undefined; private _stateHashComponents: IOperationStateHashComponents | undefined; private _operationStreamClosed: boolean = false; + private _operationCompleted: boolean = false; public constructor(operation: Operation, context: IOperationExecutionRecordContext) { const { runner, associatedPhase, associatedProject, enabled } = operation; @@ -296,6 +297,28 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } } + /** + * Emits the ordered terminal stream events exactly once. + * + * @internal + */ + public finalizeOperation(): void { + this.closeOperationStream(); + if (!this._operationCompleted) { + this._operationCompleted = true; + this._context.eventSink?.onOperationCompleted?.(this); + } + } + + /** + * Whether this record has emitted its terminal completion event. + * + * @internal + */ + public get isOperationCompleted(): boolean { + return this._operationCompleted; + } + /** * Adds the reporter's lossless operation-output tap ahead of any legacy presentation transforms. * @@ -511,6 +534,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } finally { if (this.isTerminal) { this._collatedWriter?.close(); + this.finalizeOperation(); this.stdioSummarizer.close(); this.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index a6e6eeda5c3..90bea9b0990 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -799,6 +799,7 @@ export class OperationGraph implements IOperationGraph { this._setStatus(OperationStatus.Executing); const { hooks } = this; + const graph: OperationGraph = this; const { abortController, records: executionRecords, terminal, totalOperations } = iterationContext; @@ -885,12 +886,14 @@ export class OperationGraph implements IOperationGraph { async () => await hooks.beforeExecuteIterationAsync.promise(executionRecords, iterationOptions) ); } catch (error) { + await closeRunnersAndReportFailuresAsync( + [...executionRecords.values()].filter((record) => !record.shouldRunnerPersist) + ); for (const record of executionRecords.values()) { if (!record.isTerminal) { record.status = OperationStatus.Aborted; } - record.closeOperationStream(); - eventSink?.onOperationCompleted?.(record); + record.finalizeOperation(); record.stdioSummarizer.close(); record.problemCollector.close(); } @@ -943,21 +946,20 @@ export class OperationGraph implements IOperationGraph { }); } - const recordsToClose: OperationExecutionRecord[] = []; - for (const record of executionRecords.values()) { - if (!record.shouldRunnerPersist) { - recordsToClose.push(record); - } - } function reportRunnerCleanupFailure(record: OperationExecutionRecord, error: Error): void { record.error = error; record.status = OperationStatus.Failure; _reportOperationErrorIfAny(record); state.hasAnyFailures = true; } - if (recordsToClose.length > 0) { + async function closeRunnersAndReportFailuresAsync( + recordsToClose: readonly OperationExecutionRecord[] + ): Promise { + if (recordsToClose.length === 0) { + return; + } try { - await this.closeRunnersAsync(recordsToClose.map((record) => record.operation)); + await graph.closeRunnersAsync(recordsToClose.map((record) => record.operation)); } catch (e) { if (e instanceof AggregateError) { for (const error of e.errors) { @@ -975,9 +977,17 @@ export class OperationGraph implements IOperationGraph { } } } + const incompleteRecordsToClose: OperationExecutionRecord[] = []; for (const record of executionRecords.values()) { - record.closeOperationStream(); - eventSink?.onOperationCompleted?.(record); + if (!record.shouldRunnerPersist && !record.isOperationCompleted) { + incompleteRecordsToClose.push(record); + } + } + await closeRunnersAndReportFailuresAsync(incompleteRecordsToClose); + for (const record of executionRecords.values()) { + if (!record.isOperationCompleted) { + record.finalizeOperation(); + } record.stdioSummarizer.close(); record.problemCollector.close(); } @@ -1136,6 +1146,9 @@ export class OperationGraph implements IOperationGraph { record.error = e; record.status = OperationStatus.Failure; } + if (!record.shouldRunnerPersist) { + await closeRunnersAndReportFailuresAsync([record]); + } _onOperationComplete(record, state); } } diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 2f392a4bcde..54be137742a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -224,6 +224,73 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(mockWritable.getAllOutput()).not.toContain('quiet-hidden-stdout'); }); + it('emits terminal events before a dependent operation starts', async () => { + const sink: RecordingSink = new RecordingSink(); + const first: Operation = createOperation( + 'first', + new MockOperationRunner('first', async () => OperationStatus.Success) + ); + let firstWasFinalized: boolean = false; + const second: Operation = createOperation( + 'second', + new MockOperationRunner('second', async () => { + firstWasFinalized = + sink.closed.filter((name) => name === 'first').length === 1 && + sink.completed.filter(([name]) => name === 'first').length === 1; + return OperationStatus.Success; + }) + ); + second.addDependency(first); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + graph.eventSink = sink; + + await graph.executeAsync({}); + + expect(firstWasFinalized).toBe(true); + expect(sink.closed).toEqual(['first', 'second']); + expect(sink.completed).toEqual([ + ['first', OperationStatus.Success], + ['second', OperationStatus.Success] + ]); + }); + + it('emits the runner cleanup failure as the single authoritative completion', async () => { + const sink: RecordingSink = new RecordingSink(); + const runner: IOperationRunner = { + name: 'cleanup failure', + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async () => OperationStatus.Success, + closeAsync: async () => { + throw new Error('cleanup failed'); + }, + getConfigHash: () => 'cleanup-failure' + }; + const operation: Operation = createOperation('cleanup failure', runner); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + graph.eventSink = sink; + graph.hooks.configureIteration.tap('test', (records) => { + for (const record of records.values()) { + record.shouldRunnerPersist = false; + } + }); + + const result = await graph.executeAsync({}); + + expect(result.status).toBe(OperationStatus.Failure); + expect(sink.closed).toEqual(['cleanup failure']); + expect(sink.completed).toEqual([['cleanup failure', OperationStatus.Failure]]); + }); + it('leaves terminal output byte-identical whether or not a sink is attached', async () => { const makeRunner: () => MockOperationRunner = () => new MockOperationRunner('echo', async (terminal: CollatedTerminal) => { @@ -402,92 +469,6 @@ describe('OperationGraph event sink (dual-emit)', () => { }); }); - it('combines sharded implementation records into one project x phase stream', async () => { - const reporterSink: CapturingReporterSink = new CapturingReporterSink(); - const rushSession: RushSession = new RushSession({ - terminalProvider: new StringBufferTerminalProvider(), - getIsDebugMode: () => false, - reporter: { - eventSink: reporterSink, - sessionId: 'sharded-operation-stream', - operationStreamEnabled: true - } - }); - const projectName: string = '@scope/sharded'; - const preShardRunner: IOperationRunner = { - name: `${projectName} (phase) - pre-shard`, - reportTiming: false, - silent: true, - cacheable: false, - warningsAreAllowed: false, - isNoOp: true, - executeAsync: async () => OperationStatus.NoOp, - getConfigHash: () => 'pre-shard' - }; - const shardRunner: IOperationRunner = { - name: `${projectName} (phase) - shard 1/1`, - reportTiming: true, - silent: false, - cacheable: false, - warningsAreAllowed: false, - isNoOp: false, - executeAsync: async (context: IOperationRunnerContext) => - await context.runWithTerminalAsync( - async (terminal) => { - terminal.write('shard output without newline'); - return OperationStatus.Failure; - }, - { createLogFile: false, logFileSuffix: '' } - ), - getConfigHash: () => 'shard' - }; - const collatorRunner: IOperationRunner = { - name: `${projectName} (phase) - collate`, - reportTiming: true, - silent: false, - cacheable: false, - warningsAreAllowed: false, - isNoOp: false, - executeAsync: async () => OperationStatus.Success, - getConfigHash: () => 'collate' - }; - const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); - const shard: Operation = createOperation('shard', shardRunner, mockPhase, projectName); - const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); - shard.addDependency(preShard); - collator.addDependency(shard); - const graph: OperationGraph = new OperationGraph( - new Set([collator, preShard, shard]), - createGraphOptions(mockWritable, false) - ); - - attachReporterOperationEventSink(graph, rushSession, 'build'); - await graph.executeAsync({}); - - const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( - ({ scope }) => scope?.operationId === `${projectName}#phase` - ); - expect(operationEvents.filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); - expect( - operationEvents - .filter(({ type }) => type === 'externalOutput') - .map(({ payload }) => (payload as { text: string }).text) - .join('') - ).toBe('shard output without newline'); - expect(operationEvents.filter(({ type }) => type === 'operationStreamClosed')).toHaveLength(1); - expect(operationEvents.filter(({ type }) => type === 'operationCompleted')).toEqual([ - expect.objectContaining({ - payload: expect.objectContaining({ operationId: `${projectName}#phase`, status: 'failure' }) - }) - ]); - expect( - operationEvents.filter( - ({ type, payload }) => - type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' - ) - ).toHaveLength(1); - }); - it('does not register operations when scheduling hooks reject the iteration', async () => { const sink: RecordingSink = new RecordingSink(); const graph: OperationGraph = new OperationGraph( @@ -848,10 +829,11 @@ describe('OperationGraph event sink (dual-emit)', () => { reporterSink.inputs .filter(({ type }) => type === 'operationCompleted') .map(({ scope }) => scope?.operationId) + .sort() ).toEqual([ '@scope/project#_phase:compile', - '@scope/project#_phase:test', '@scope/project#_phase:compile', + '@scope/project#_phase:test', '@scope/project#_phase:test' ]); for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { From a87994b6a554b1a1615914c5f8658d9078dc116c Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:15:44 +0000 Subject: [PATCH 019/164] Preserve reporter cycles across stream callbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 4 ++-- .../logic/operations/ReporterOperationEventSink.ts | 14 +++++++------- .../test/OperationGraphEventSink.test.ts | 7 ++++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 5ae22050de6..c993a28e263 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -683,12 +683,12 @@ export interface IOperationGraphContext extends ICreateOperationsContext { // @internal export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; - onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; onOperationCompleted?(result: IOperationExecutionResult): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; - onOperationStreamClosed?(operationId: string): void; + onOperationStreamClosed?(result: IOperationExecutionResult): void; } // @alpha diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 3c82a943cea..66fc17e7355 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -44,8 +44,10 @@ interface IReporterOperationCycle { } class ReporterOperationEventSink implements IOperationGraphEventSink { - public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; - public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + public readonly onOperationChunk: + | ((result: IOperationExecutionResult, chunk: ITerminalChunk) => void) + | undefined; + public readonly onOperationStreamClosed: ((result: IOperationExecutionResult) => void) | undefined; public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; private readonly _operationsByLegacyId: Map = new Map(); @@ -97,8 +99,8 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } if (Array.from(this._operationsByLegacyId.values()).some(({ streamEmitter }) => !!streamEmitter)) { - this.onOperationChunk = (operationId, chunk) => this._onOperationChunk(operationId, chunk); - this.onOperationStreamClosed = (operationId) => this._onOperationStreamClosed(operationId); + this.onOperationChunk = (result, chunk) => this._onOperationChunk(result, chunk); + this.onOperationStreamClosed = (result) => this._onOperationStreamClosed(result); this.onOperationCompleted = (result) => this._onOperationCompleted(result); } else { this.onOperationChunk = undefined; @@ -219,9 +221,7 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } private _onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { - const operation: IReporterOperation | undefined = this._operationsByLegacyId.get( - result.operation.name - ); + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); if (!operation?.streamEmitter || !this._cyclesByResult.has(result)) { return; } diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 54be137742a..789756297dc 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -105,7 +105,8 @@ class RecordingSink implements IOperationGraphEventSink { public onActivity(text: string): void { this.activities.push(text); } - public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { + public onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { + const operationId: string = result.operation.name; let chunks: string[] | undefined = this.chunks.get(operationId); if (!chunks) { chunks = []; @@ -113,8 +114,8 @@ class RecordingSink implements IOperationGraphEventSink { } chunks.push(chunk.text); } - public onOperationStreamClosed(operationId: string): void { - this.closed.push(operationId); + public onOperationStreamClosed(result: IOperationExecutionResult): void { + this.closed.push(result.operation.name); } public onOperationCompleted(result: IOperationExecutionResult): void { this.completed.push([result.operation.name, result.status]); From 023cf7e8a0cd93f6f204258defb4db7aa183179d Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 19:53:00 +0000 Subject: [PATCH 020/164] Emit registrations for collapsed iterations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../rush-lib/src/logic/operations/OperationGraph.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 90bea9b0990..17a041772e9 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -712,6 +712,7 @@ export class OperationGraph implements IOperationGraph { } if (iterationContext.totalOperations === 0) { + registerOperations(); return; } @@ -726,9 +727,7 @@ export class OperationGraph implements IOperationGraph { terminal.writeStderrLine(Colorize.red(errorMessage)); throw e; } - for (const executionRecord of executionRecords.values()) { - eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); - } + registerOperations(); if (!this._currentIteration) { this._setIdleTimeout(); } else if (!this.pauseNextIteration) { @@ -736,6 +735,12 @@ export class OperationGraph implements IOperationGraph { } return iterationContext; + function registerOperations(): void { + for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + } + } + function onWriterActive(writer: CollatedWriter | undefined): void { if (writer) { iterationContext.completedOperations++; From 2139b32e6e15b10f4d011b8cbc02b5e2f2fed309 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 20:06:32 +0000 Subject: [PATCH 021/164] Keep operation stream callbacks compatible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 4 +- .../logic/operations/OperationEventSink.ts | 4 +- .../operations/OperationExecutionRecord.ts | 4 +- .../src/logic/operations/OperationGraph.ts | 2 +- .../operations/ReporterOperationEventSink.ts | 45 +++++++++++-------- .../test/OperationGraphEventSink.test.ts | 7 ++- 6 files changed, 37 insertions(+), 29 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index c993a28e263..3073cabee0b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -683,12 +683,12 @@ export interface IOperationGraphContext extends ICreateOperationsContext { // @internal export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; - onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; + onOperationChunk?(operationId: string, chunk: ITerminalChunk, result?: IOperationExecutionResult): void; onOperationCompleted?(result: IOperationExecutionResult): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; - onOperationStreamClosed?(result: IOperationExecutionResult): void; + onOperationStreamClosed?(operationId: string, result?: IOperationExecutionResult): void; } // @alpha diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 79c10e3300e..8e12ffd4218 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -59,14 +59,14 @@ export interface IOperationGraphEventSink { * Invoked for each chunk of an operation's raw output, upstream of any * newline normalization or quiet-mode filtering. */ - onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; + onOperationChunk?(operationId: string, chunk: ITerminalChunk, result?: IOperationExecutionResult): void; /** * Invoked when an operation's collated output stream is closed at the end of * its execution, after all status lines and output have been written. This * is the authoritative "no more output for this operation" signal. */ - onOperationStreamClosed?(result: IOperationExecutionResult): void; + onOperationStreamClosed?(operationId: string, result?: IOperationExecutionResult): void; /** * Invoked after the operation stream is closed and the final outcome is authoritative. diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index d235a70cf23..077372b65fc 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -293,7 +293,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera public closeOperationStream(): void { if (!this._operationStreamClosed) { this._operationStreamClosed = true; - this._context.eventSink?.onOperationStreamClosed?.(this); + this._context.eventSink?.onOperationStreamClosed?.(this.name, this); } } @@ -335,7 +335,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera destination, new OperationChunkTap(this.name, (operationId, chunk) => { if (operationId === this.name) { - eventSink.onOperationChunk?.(this, chunk); + eventSink.onOperationChunk?.(operationId, chunk, this); } }) ] diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 17a041772e9..51de34c2f34 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -737,7 +737,7 @@ export class OperationGraph implements IOperationGraph { function registerOperations(): void { for (const executionRecord of executionRecords.values()) { - eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } } diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 66fc17e7355..d55276aa167 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -45,9 +45,11 @@ interface IReporterOperationCycle { class ReporterOperationEventSink implements IOperationGraphEventSink { public readonly onOperationChunk: - | ((result: IOperationExecutionResult, chunk: ITerminalChunk) => void) + | ((operationId: string, chunk: ITerminalChunk, result?: IOperationExecutionResult) => void) + | undefined; + public readonly onOperationStreamClosed: + | ((operationId: string, result?: IOperationExecutionResult) => void) | undefined; - public readonly onOperationStreamClosed: ((result: IOperationExecutionResult) => void) | undefined; public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; private readonly _operationsByLegacyId: Map = new Map(); @@ -99,8 +101,10 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } if (Array.from(this._operationsByLegacyId.values()).some(({ streamEmitter }) => !!streamEmitter)) { - this.onOperationChunk = (result, chunk) => this._onOperationChunk(result, chunk); - this.onOperationStreamClosed = (result) => this._onOperationStreamClosed(result); + this.onOperationChunk = (operationId, chunk, result) => + this._onOperationChunk(operationId, chunk, result); + this.onOperationStreamClosed = (operationId, result) => + this._onOperationStreamClosed(operationId, result); this.onOperationCompleted = (result) => this._onOperationCompleted(result); } else { this.onOperationChunk = undefined; @@ -220,9 +224,13 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } } - private _onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { - const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); - if (!operation?.streamEmitter || !this._cyclesByResult.has(result)) { + private _onOperationChunk( + operationId: string, + chunk: ITerminalChunk, + result?: IOperationExecutionResult + ): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation?.streamEmitter || !result || !this._cyclesByResult.has(result)) { return; } @@ -233,10 +241,9 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } } - private _onOperationStreamClosed(result: IOperationExecutionResult): void { - const operationId: string = result.operation.name; + private _onOperationStreamClosed(operationId: string, result?: IOperationExecutionResult): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); - const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + const cycle: IReporterOperationCycle | undefined = result ? this._cyclesByResult.get(result) : undefined; if (!operation?.streamEmitter || !cycle || cycle.streamClosed) { return; } @@ -271,9 +278,11 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { class CompositeOperationGraphEventSink implements IOperationGraphEventSink { public readonly onOperationChunk: - | ((result: IOperationExecutionResult, chunk: ITerminalChunk) => void) + | ((operationId: string, chunk: ITerminalChunk, result?: IOperationExecutionResult) => void) + | undefined; + public readonly onOperationStreamClosed: + | ((operationId: string, result?: IOperationExecutionResult) => void) | undefined; - public readonly onOperationStreamClosed: ((result: IOperationExecutionResult) => void) | undefined; public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; private readonly _first: IOperationGraphEventSink; @@ -284,16 +293,16 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { this._second = second; this.onOperationChunk = first.onOperationChunk || second.onOperationChunk - ? (result, chunk) => { - first.onOperationChunk?.(result, chunk); - second.onOperationChunk?.(result, chunk); + ? (operationId, chunk, result) => { + first.onOperationChunk?.(operationId, chunk, result); + second.onOperationChunk?.(operationId, chunk, result); } : undefined; this.onOperationStreamClosed = first.onOperationStreamClosed || second.onOperationStreamClosed - ? (result) => { - first.onOperationStreamClosed?.(result); - second.onOperationStreamClosed?.(result); + ? (operationId, result) => { + first.onOperationStreamClosed?.(operationId, result); + second.onOperationStreamClosed?.(operationId, result); } : undefined; this.onOperationCompleted = diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 789756297dc..54be137742a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -105,8 +105,7 @@ class RecordingSink implements IOperationGraphEventSink { public onActivity(text: string): void { this.activities.push(text); } - public onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { - const operationId: string = result.operation.name; + public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { let chunks: string[] | undefined = this.chunks.get(operationId); if (!chunks) { chunks = []; @@ -114,8 +113,8 @@ class RecordingSink implements IOperationGraphEventSink { } chunks.push(chunk.text); } - public onOperationStreamClosed(result: IOperationExecutionResult): void { - this.closed.push(result.operation.name); + public onOperationStreamClosed(operationId: string): void { + this.closed.push(operationId); } public onOperationCompleted(result: IOperationExecutionResult): void { this.completed.push([result.operation.name, result.status]); From ccd34f3c82f941a5bbf9beac198a9156ec87d813 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 20:57:37 +0000 Subject: [PATCH 022/164] Forward daemon operation completion events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/PhasedRequestEventMultiplexer.ts | 12 ++++--- .../PhasedRequestEventMultiplexer.test.ts | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts diff --git a/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts b/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts index 3ac90ca137e..d917ea7f03f 100644 --- a/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts +++ b/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts @@ -46,10 +46,7 @@ export class PhasedRequestEventMultiplexer implements _IOperationGraphEventSink } } - public onOperationStatusChanged( - result: IOperationExecutionResult, - previousStatus: OperationStatus - ): void { + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { this.#workspaceSink?.onOperationStatusChanged?.(result, previousStatus); for (const requestSink of this.#requestSinks) { requestSink.onOperationStatusChanged?.(result, previousStatus); @@ -77,6 +74,13 @@ export class PhasedRequestEventMultiplexer implements _IOperationGraphEventSink } } + public onOperationCompleted(result: IOperationExecutionResult): void { + this.#workspaceSink?.onOperationCompleted?.(result); + for (const requestSink of this.#requestSinks) { + requestSink.onOperationCompleted?.(result); + } + } + public onActivity(text: string, options?: _IOperationActivityOptions): void { this.#workspaceSink?.onActivity?.(text, options); for (const requestSink of this.#requestSinks) { diff --git a/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts b/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts new file mode 100644 index 00000000000..f19a3975795 --- /dev/null +++ b/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IOperationExecutionResult, _IOperationGraphEventSink } from '@microsoft/rush-lib'; + +import { PhasedRequestEventMultiplexer } from '../PhasedRequestEventMultiplexer'; + +describe(PhasedRequestEventMultiplexer.name, () => { + it('forwards stream closure before completion to workspace and request sinks', () => { + const events: string[] = []; + const workspaceSink: _IOperationGraphEventSink = { + onOperationStreamClosed: () => events.push('workspace-closed'), + onOperationCompleted: () => events.push('workspace-completed') + }; + const requestSink: _IOperationGraphEventSink & { + onIterationScheduled(records: Iterable): void; + } = { + onIterationScheduled: () => {}, + onOperationStreamClosed: () => events.push('request-closed'), + onOperationCompleted: () => events.push('request-completed') + }; + const multiplexer: PhasedRequestEventMultiplexer = new PhasedRequestEventMultiplexer(workspaceSink); + multiplexer.subscribe(requestSink); + const result: IOperationExecutionResult = {} as IOperationExecutionResult; + + multiplexer.onOperationStreamClosed('operation'); + multiplexer.onOperationCompleted(result); + + expect(events).toEqual([ + 'workspace-closed', + 'request-closed', + 'workspace-completed', + 'request-completed' + ]); + }); +}); From 0debf23dbee71273077b5ee4afc5000d48236a9f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 21:44:21 +0000 Subject: [PATCH 023/164] Complete collapsed daemon operation cycles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/PhasedRequestEventMultiplexer.ts | 26 +++++++---- .../PhasedRequestEventMultiplexer.test.ts | 21 +++++++-- .../src/logic/operations/OperationGraph.ts | 6 +++ .../test/OperationGraphEventSink.test.ts | 43 +++++++++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts b/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts index d917ea7f03f..bf4b3e19217 100644 --- a/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts +++ b/libraries/rush-daemon/src/PhasedRequestEventMultiplexer.ts @@ -39,10 +39,14 @@ export class PhasedRequestEventMultiplexer implements _IOperationGraphEventSink } } - public onOperationRegistered(operationId: string, silent: boolean): void { - this.#workspaceSink?.onOperationRegistered?.(operationId, silent); + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + this.#workspaceSink?.onOperationRegistered?.(operationId, silent, result); for (const requestSink of this.#requestSinks) { - requestSink.onOperationRegistered?.(operationId, silent); + requestSink.onOperationRegistered?.(operationId, silent, result); } } @@ -60,17 +64,21 @@ export class PhasedRequestEventMultiplexer implements _IOperationGraphEventSink } } - public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { - this.#workspaceSink?.onOperationChunk?.(operationId, chunk); + public onOperationChunk( + operationId: string, + chunk: ITerminalChunk, + result?: IOperationExecutionResult + ): void { + this.#workspaceSink?.onOperationChunk?.(operationId, chunk, result); for (const requestSink of this.#requestSinks) { - requestSink.onOperationChunk?.(operationId, chunk); + requestSink.onOperationChunk?.(operationId, chunk, result); } } - public onOperationStreamClosed(operationId: string): void { - this.#workspaceSink?.onOperationStreamClosed?.(operationId); + public onOperationStreamClosed(operationId: string, result?: IOperationExecutionResult): void { + this.#workspaceSink?.onOperationStreamClosed?.(operationId, result); for (const requestSink of this.#requestSinks) { - requestSink.onOperationStreamClosed?.(operationId); + requestSink.onOperationStreamClosed?.(operationId, result); } } diff --git a/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts b/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts index f19a3975795..78bf089aa0a 100644 --- a/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts +++ b/libraries/rush-daemon/src/test/PhasedRequestEventMultiplexer.test.ts @@ -6,9 +6,16 @@ import type { IOperationExecutionResult, _IOperationGraphEventSink } from '@micr import { PhasedRequestEventMultiplexer } from '../PhasedRequestEventMultiplexer'; describe(PhasedRequestEventMultiplexer.name, () => { - it('forwards stream closure before completion to workspace and request sinks', () => { + it('forwards execution identity, stream closure, and completion to every sink', () => { const events: string[] = []; + const result: IOperationExecutionResult = {} as IOperationExecutionResult; const workspaceSink: _IOperationGraphEventSink = { + onOperationRegistered: (operationId, silent, forwardedResult) => { + expect(operationId).toBe('operation'); + expect(silent).toBe(false); + expect(forwardedResult).toBe(result); + events.push('workspace-registered'); + }, onOperationStreamClosed: () => events.push('workspace-closed'), onOperationCompleted: () => events.push('workspace-completed') }; @@ -16,17 +23,25 @@ describe(PhasedRequestEventMultiplexer.name, () => { onIterationScheduled(records: Iterable): void; } = { onIterationScheduled: () => {}, + onOperationRegistered: (operationId, silent, forwardedResult) => { + expect(operationId).toBe('operation'); + expect(silent).toBe(false); + expect(forwardedResult).toBe(result); + events.push('request-registered'); + }, onOperationStreamClosed: () => events.push('request-closed'), onOperationCompleted: () => events.push('request-completed') }; const multiplexer: PhasedRequestEventMultiplexer = new PhasedRequestEventMultiplexer(workspaceSink); multiplexer.subscribe(requestSink); - const result: IOperationExecutionResult = {} as IOperationExecutionResult; - multiplexer.onOperationStreamClosed('operation'); + multiplexer.onOperationRegistered('operation', false, result); + multiplexer.onOperationStreamClosed('operation', result); multiplexer.onOperationCompleted(result); expect(events).toEqual([ + 'workspace-registered', + 'request-registered', 'workspace-closed', 'request-closed', 'workspace-completed', diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 51de34c2f34..c96815ea0bd 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -713,6 +713,12 @@ export class OperationGraph implements IOperationGraph { if (iterationContext.totalOperations === 0) { registerOperations(); + for (const executionRecord of executionRecords.values()) { + executionRecord.status = OperationStatus.NoOp; + executionRecord.finalizeOperation(); + executionRecord.stdioSummarizer.close(); + executionRecord.problemCollector.close(); + } return; } diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 54be137742a..0669c9b3b2a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -555,6 +555,49 @@ describe('OperationGraph event sink (dual-emit)', () => { ); }); + it('finalizes every registered operation for an all-silent iteration', async () => { + const silentRunner: IOperationRunner = { + name: 'silent only', + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async () => OperationStatus.Success, + getConfigHash: () => 'silent-only' + }; + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'silent-only', + operationStreamEnabled: true + } + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('silent only', silentRunner, mockPhase, '@scope/silent')]), + createGraphOptions(mockWritable, false) + ); + attachReporterOperationEventSink(graph, rushSession, 'build'); + + await graph.executeAsync({}); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ scope }) => scope?.operationId === '@scope/silent#phase' + ); + expect(operationEvents.filter(({ type }) => type === 'operationRegistered')).toHaveLength(2); + expect(operationEvents.filter(({ type }) => type === 'operationStreamClosed')).toHaveLength(2); + expect(operationEvents.filter(({ type }) => type === 'operationCompleted')).toHaveLength(2); + expect( + operationEvents + .filter(({ type }) => type === 'operationCompleted') + .map(({ payload }) => (payload as { status: string }).status) + ).toEqual(['noOp', 'noOp']); + }); + it('does not attach an operation adapter when the session has no reporter sink', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), From 6af34ca1c946f3fcee7ca55ea4e77b9eb2f79ad1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 07:52:04 +0000 Subject: [PATCH 024/164] Add direct Rush reporter demo path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/MinimalRushConfiguration.ts | 42 +++- apps/rush/src/RushFrontend.ts | 25 +- apps/rush/src/RushReporterHost.ts | 168 +++++++++---- .../src/test/MinimalRushConfiguration.test.ts | 1 + apps/rush/src/test/RushReporterHost.test.ts | 76 +++++- .../src/test/sandbox/reporter-demo/README.md | 25 ++ .../src/test/sandbox/reporter-demo/run.mjs | 51 ++++ ...r-r5b-demo-reporters_2026-08-28-07-10.json | 11 + ...r-r5b-demo-reporters_2026-08-28-07-10.json | 11 + common/reviews/api/rush-lib.api.md | 2 + common/reviews/api/rush-reporter.api.md | 1 + .../reporter/src/reporters/AiReporter.ts | 25 +- .../reporters/DefaultInteractiveReporter.ts | 79 ++++-- .../reporter/src/reporters/FileReporter.ts | 170 ++++++++++++- .../reporter/src/reporters/LegacyReporter.ts | 4 +- .../src/reporters/PlaintextReporter.ts | 71 +++++- .../test/DefaultInteractiveReporter.test.ts | 32 ++- .../reporter/src/test/FileReporter.test.ts | 64 ++++- .../reporter/src/test/JsonAiReporter.test.ts | 26 ++ .../src/test/OperationStreamEmitter.test.ts | 3 + .../src/test/PlaintextReporter.test.ts | 35 +++ libraries/rush-lib/src/api/Rush.ts | 2 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 144 ++++++++--- .../src/cli/actions/BaseRushAction.ts | 11 +- .../cli/scriptActions/PhasedScriptAction.ts | 38 ++- .../cli/test/RushCommandLineParser.test.ts | 30 +++ .../rush-lib/src/logic/ProjectWatcher.ts | 14 +- .../operations/ReporterOperationEventSink.ts | 212 ++++++++++------ .../test/OperationGraphEventSink.test.ts | 231 ++++++++++++++---- .../src/pluginFramework/RushSession.ts | 25 ++ 30 files changed, 1343 insertions(+), 286 deletions(-) create mode 100644 apps/rush/src/test/sandbox/reporter-demo/README.md create mode 100644 apps/rush/src/test/sandbox/reporter-demo/run.mjs create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r5b-demo-reporters_2026-08-28-07-10.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r5b-demo-reporters_2026-08-28-07-10.json diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 62aef01d11d..8a7ffa0d3b7 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -52,14 +52,29 @@ export class MinimalRushConfiguration { } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { + const showVerbose: boolean = !RushCommandLineParser.shouldRestrictConsoleOutput(); const rushJsonLocation: string | undefined = RushConfiguration.tryFindRushJsonLocation({ - showVerbose: !RushCommandLineParser.shouldRestrictConsoleOutput() + showVerbose: false }); if (rushJsonLocation) { const minimalRushConfigurationJson: IMinimalRushConfigurationJson | undefined = _loadConfigurationJson(rushJsonLocation); if (minimalRushConfigurationJson) { - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + const configuration: MinimalRushConfiguration = new MinimalRushConfiguration( + minimalRushConfigurationJson, + rushJsonLocation + ); + if ( + showVerbose && + !configuration.useRushReporter && + !_hasExplicitNonLegacyReporter(process.argv.slice(2)) && + path.dirname(rushJsonLocation) !== process.cwd() + ) { + // Preserve the legacy discovery message exactly when the reporter path is not taking ownership. + console.log('Found configuration in ' + rushJsonLocation); + console.log(''); + } + return configuration; } return undefined; } else { @@ -94,6 +109,29 @@ export class MinimalRushConfiguration { public get useRushReporter(): boolean { return this._useRushReporter; } + + /** + * The repository's common temp folder, used for invocation-scoped reporter logs. + */ + public get commonTempFolder(): string { + return path.resolve(this._commonRushConfigFolder, '..', '..', 'temp'); + } +} + +function _hasExplicitNonLegacyReporter(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + let value: string | undefined; + if (argument === '--reporter') { + value = argv[index + 1]; + } else if (argument.startsWith('--reporter=')) { + value = argument.slice('--reporter='.length); + } + if (value !== undefined) { + return value.trim().toLowerCase() !== 'legacy'; + } + } + return false; } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 00caadebf4b..c6088eb195d 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto'; import type { ILaunchOptions } from '@microsoft/rush-lib'; -import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; +import { + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, + REPORTER_PROTOCOL_VERSION +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -139,10 +142,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr processLifecycle = createProcessLifecycle() } = options; + const engineArgv: string[] = stripReporterValueControls(process.argv.slice(2)); const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ repositoryOptIn: configuration?.useRushReporter, forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, - selectedRushVersion: rushVersionToLoad + selectedRushVersion: rushVersionToLoad, + commonTempFolder: configuration?.commonTempFolder, + actionName: engineArgv.find((argument: string) => !argument.startsWith('-')) }); const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) @@ -157,6 +163,21 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); + if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { + reporterHost.sink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId, + source: { packageName: '@microsoft/rush', packageVersion: currentPackageVersion }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: reporterHost.logArtifact.path, + format: 'plaintext', + complete: false + } + }); + } const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporter: { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 82dc2d4f902..fcfc53232cf 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -20,8 +20,10 @@ import { shouldRenderAtLogLevel, type IReporter, type IReporterContext, + type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventSink, + type IFileReporterArtifact, type IReporterOutputTarget, type ReporterEventType, type ReporterLogLevel, @@ -39,6 +41,9 @@ export interface IRushReporterHostOptions { readonly env?: Record; readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; + readonly commonTempFolder?: string; + readonly actionName?: string; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; @@ -65,13 +70,14 @@ export interface IInitializedRushReporterHost { readonly host: ReporterHost; readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; + readonly logArtifact: IFileReporterArtifact | undefined; closeAsync(timeoutMs?: number): Promise; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; -const DEFERRED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ +const GROUPED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ 'operationRegistered', 'operationStatusChanged', 'operationStreamClosed', @@ -93,10 +99,16 @@ class LogLevelReporter implements IReporter { private readonly _reporter: IReporter; private readonly _logLevel: ReporterLogLevel; + private readonly _preserveOperationStream: boolean; - public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + public constructor( + reporter: IReporter, + logLevel: ReporterLogLevel, + preserveOperationStream: boolean = false + ) { this._reporter = reporter; this._logLevel = logLevel; + this._preserveOperationStream = preserveOperationStream; this.name = reporter.name; } @@ -105,39 +117,18 @@ class LogLevelReporter implements IReporter { } public report(event: IReporterEventEnvelope): void { - if (shouldRenderAtLogLevel(this._logLevel, event)) { - this._reporter.report(event); - } - } - - public flushAsync(): Promise { - return this._reporter.flushAsync(); - } - - public closeAsync(): Promise { - return this._reporter.closeAsync(); - } -} - -/** - * Keeps operation presentation on the legacy collator until R5B transfers terminal ownership. - */ -class DeferredOperationPresentationReporter implements IReporter { - public readonly name: string; - - private readonly _reporter: IReporter; - - public constructor(reporter: IReporter) { - this._reporter = reporter; - this.name = reporter.name; - } - - public initializeAsync(context: IReporterContext): Promise { - return this._reporter.initializeAsync(context); - } - - public report(event: IReporterEventEnvelope): void { - if (!DEFERRED_OPERATION_EVENT_TYPES.has(event.type)) { + const verboseTerminalMessage: boolean = + this._logLevel === 'verbose' && + event.type === 'messageEmitted' && + (event.payload as { severity?: string }).severity === 'debug'; + if ( + shouldRenderAtLogLevel(this._logLevel, event) || + verboseTerminalMessage || + event.type === 'artifactAvailable' || + (this._preserveOperationStream && + GROUPED_OPERATION_EVENT_TYPES.has(event.type) && + !(this._logLevel === 'quiet' && event.type === 'externalOutput')) + ) { this._reporter.report(event); } } @@ -202,6 +193,43 @@ class ExplicitOutputReporter implements IReporter { } } +class FilePathReporter implements IReporter { + public readonly name: string = 'file-path'; + + private readonly _write: (text: string) => unknown; + private _path: string | undefined; + + public constructor(write: (text: string) => unknown) { + this._write = write; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string } = event.payload as { + role?: string; + path?: string; + }; + if (payload.role === 'log') { + this._path = payload.path; + } + } else if (event.type === 'commandResult' && this._path) { + this._write(`Rush full log: ${this._path}\n`); + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + function readValue( argv: readonly string[], index: number, @@ -397,6 +425,19 @@ function resolveLogLevel( } const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + const environmentQuiet: boolean = + includeEnvironment && (env.RUSH_QUIET_MODE === '1' || env.RUSH_QUIET_MODE?.toLowerCase() === 'true'); + if (environmentQuiet && environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (normalizedLogLevel !== 'quiet') { + throw new Error( + 'RUSH_QUIET_MODE contradicts RUSH_LOG_LEVEL. Remove one of these environment controls.' + ); + } + } + if (environmentQuiet) { + return 'quiet'; + } if (environmentLogLevel) { const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); if (!isSupportedLogLevel(normalizedLogLevel)) { @@ -527,6 +568,19 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + if (argv.includes('--help') || argv.includes('-h')) { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); if (executableName === 'rush-pnpm') { @@ -629,11 +683,12 @@ function createPrimaryReporter( case 'plaintext': return new PlaintextReporter({ write: (text: string) => stdout.write(text), - variant: isCiDetected(env) ? 'detailed' : 'concise', - color: false + variant: selection.reason === 'explicit --reporter' || isCiDetected(env) ? 'detailed' : 'concise', + color: false, + logLevel: selection.logLevel }); case 'file': - return new FileReporter(); + return undefined; case 'legacy': return undefined; } @@ -644,30 +699,36 @@ export async function initializeRushReporterHostAsync( ): Promise { const env: Record = options.env ?? process.env; const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); const host: ReporterHost = new ReporterHost({ env }); + let fullDetailReporter: FileReporter | undefined; if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); if (primaryReporter) { - const presentationReporter: IReporter = - selection.reporter === 'file' - ? primaryReporter - : new DeferredOperationPresentationReporter(primaryReporter); - host.manager.addReporter(new LogLevelReporter(presentationReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + host.manager.addReporter( + new LogLevelReporter(primaryReporter, selection.logLevel, selection.reporter === 'plaintext'), + { + destination: 'stdout' + } + ); + } + + if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { + fullDetailReporter = new FileReporter({ + commonTempFolder: options.commonTempFolder, + actionName: options.actionName + }); + host.manager.addReporter(fullDetailReporter, { + destination: 'file:auto' }); } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); - if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput - ) { - host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + if (selection.reporter === 'file') { + host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { + destination: 'stderr' + }); } for (const output of selection.outputs) { @@ -689,6 +750,7 @@ export async function initializeRushReporterHostAsync( host, sink: host.getSink(), selection, + logArtifact: fullDetailReporter?.getArtifact(), closeAsync: (timeoutMs?: number) => { closePromise ??= host.manager.closeAsync(timeoutMs); return closePromise; diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 80b95dbd6aa..3f01ee36a9b 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -33,6 +33,7 @@ describe(MinimalRushConfiguration.name, () => { MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); expect(config.useRushReporter).toBe(true); + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'common', 'temp')); }); }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 7846cb673c6..abd6884bbc2 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -234,6 +234,14 @@ describe(resolveRushReporterSelection.name, () => { ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); }); + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -325,6 +333,13 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('preserves RUSH_QUIET_MODE as a quiet reporter alias', () => { + expect(resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: 'true' }).logLevel).toBe('quiet'); + expect(() => + resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: '1', RUSH_LOG_LEVEL: 'debug' }) + ).toThrow(/contradicts RUSH_LOG_LEVEL/); + }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', @@ -466,9 +481,61 @@ describe(initializeRushReporterHostAsync.name, () => { await initialized.closeAsync(); expect(initialized.selection.enabled).toBe(false); + expect(initialized.logArtifact).toBeUndefined(); expect(output).toBe(''); }); + it('always creates a repository full-detail log on the enabled path', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-full-log-')); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined } + }); + + expect(initialized.logArtifact).toMatchObject({ available: true }); + expect(initialized.logArtifact?.path).toMatch( + new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + await initialized.closeAsync(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not render operation output at quiet plaintext log level', async () => { + let output: string = ''; + const quietHost = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext', '--log-level=quiet'], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + output += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(quietHost.sink); + emitOperationEvents(quietHost.sink); + quietHost.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await quietHost.closeAsync(); + + expect(output).not.toContain('raw operation output'); + expect(output).toContain('rush build succeeded'); + }); + it('initializes the explicitly selected reporter and output destinations', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -505,7 +572,14 @@ describe(initializeRushReporterHostAsync.name, () => { .trim() .split('\n') .map((line: string) => JSON.parse(line) as Record); - expect(stdoutEvents.map(({ type }) => type)).toEqual(['commandStarted']); + expect(stdoutEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); expect(fileEvents.map(({ type }) => type)).toEqual([ 'commandStarted', 'operationRegistered', diff --git a/apps/rush/src/test/sandbox/reporter-demo/README.md b/apps/rush/src/test/sandbox/reporter-demo/README.md new file mode 100644 index 00000000000..39eeccdd4fb --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -0,0 +1,25 @@ +# Direct Rush reporter demo + +Build the three reporter projects, then run the self-checking direct invocation demo: + +```sh +rush build --to @microsoft/rush +node apps/rush/src/test/sandbox/reporter-demo/run.mjs +``` + +The script runs the same `rush build --only @rushstack/rush-reporter` operation stream through legacy, +plaintext, JSON, and AI modes. It verifies JSON/AI payload-only stdout, confirms the plaintext result +contains an existing absolute full-log path, and writes captured stdout/stderr files to a temporary folder. + +For an individual invocation: + +```sh +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json --log-level=debug +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=ai +RUSH_REPORTER=legacy node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json +``` + +Repositories can opt in without a command-line flag by setting `"useRushReporter": true` in +`common/config/rush/experiments.json`. Remove that setting or use `RUSH_REPORTER=legacy` for immediate +rollback. diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs new file mode 100644 index 00000000000..0cf345a6314 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptFolder = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptFolder, '..', '..', '..', '..', '..', '..'); +const rushBin = path.join(repoRoot, 'apps', 'rush', 'bin', 'rush'); +const outputFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-reporter-demo-')); +const commonArgs = ['build', '--only', '@rushstack/rush-reporter']; + +function run(name, args, env = {}) { + const result = spawnSync(process.execPath, [rushBin, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8' + }); + fs.writeFileSync(path.join(outputFolder, `${name}.stdout`), result.stdout); + fs.writeFileSync(path.join(outputFolder, `${name}.stderr`), result.stderr); + if (result.status !== 0) { + throw new Error(`${name} failed with exit code ${result.status}\n${result.stderr}`); + } + return result.stdout; +} + +run('warmup', commonArgs); +const legacy = run('legacy', commonArgs); +const rollback = run('rollback', [...commonArgs, '--reporter=json'], { RUSH_REPORTER: 'legacy' }); +const normalizeDurations = (text) => text.replace(/\d+\.\d+ seconds/g, '