From 8c6b04953e3cfe081df2f358df2c8932c32d9952 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 02:52:30 +0000 Subject: [PATCH 01/34] 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 02/34] 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 03/34] 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 04/34] 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 05/34] 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 06/34] 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 07/34] 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 08/34] 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 09/34] 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 10/34] 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 79b1e6e292955066a6decc31e3e6d965106c383f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:32:11 +0000 Subject: [PATCH 11/34] Add reporter bootstrap handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 7 + apps/rush/src/RushCommandSelector.ts | 88 ++- apps/rush/src/RushFrontend.ts | 4 +- apps/rush/src/RushReporterHost.ts | 74 ++- apps/rush/src/RushVersionSelector.ts | 29 +- .../rush/src/test/RushCommandSelector.test.ts | 173 ++++++ apps/rush/src/test/RushFrontend.test.ts | 2 + apps/rush/src/test/RushReporterHost.test.ts | 95 ++- ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + common/reviews/api/rush-reporter.api.md | 7 + .../src/bootstrap/BootstrapProtocol.ts | 4 +- .../reporter/src/frontend/ReporterHost.ts | 63 +- libraries/reporter/src/index.ts | 6 +- .../reporter/src/test/ReporterHost.test.ts | 7 +- .../src/scripts/InstallRunRushBootstrap.ts | 572 ++++++++++++++++++ .../scripts/generated/BootstrapProtocol.ts | 45 ++ .../rush-lib/src/scripts/install-run-rush.ts | 73 ++- libraries/rush-lib/src/scripts/install-run.ts | 209 ++++++- .../test/InstallRunRushBootstrap.test.ts | 261 ++++++++ libraries/rush-lib/webpack.config.js | 57 +- 21 files changed, 1704 insertions(+), 94 deletions(-) create mode 100644 apps/rush/src/test/RushCommandSelector.test.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts create mode 100644 libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 920ae96235f..d14c1858da6 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -14,4 +14,11 @@ import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rus export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; + readonly reporterEnabled: boolean; + readonly reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 8d29eac6afa..0453811b4de 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -2,6 +2,14 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +import { + OldEngineOutputAdapter, + REPORTER_PROTOCOL_VERSION, + resolveReporterCompatibility, + type IReporterCompatibilityDecision +} from '@rushstack/rush-reporter'; import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; @@ -37,6 +45,37 @@ export class RushCommandSelector { } const commandName: CommandName = _getCommandName(); + const engineProtocolMajor: number | undefined = ( + Rush as typeof Rush & { readonly _reporterProtocolMajor?: number } + )._reporterProtocolMajor; + const compatibility: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: REPORTER_PROTOCOL_VERSION.major, hasManager: true }, + { + supportsStructuredSink: engineProtocolMajor !== undefined, + protocolMajor: engineProtocolMajor + } + ); + let effectiveOptions: IRushFrontendLaunchOptions = options; + if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); + } else if ( + compatibility.mode === 'old-frontend-new-engine' && + engineProtocolMajor !== undefined && + options.reporterEnabled + ) { + if (options.reporterSelectionReason === 'explicit --reporter') { + throw new Error( + `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + + `frontend supports major ${REPORTER_PROTOCOL_VERSION.major}. Update global Rush or use ` + + '--reporter=legacy.' + ); + } + effectiveOptions = { + ...options, + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }; + } if (commandName === 'rush-pnpm') { if (!Rush.launchRushPnpm) { @@ -56,13 +95,58 @@ export class RushCommandSelector { ` which does not support the "rushx" command` ); } - Rush.launchRushX(launcherVersion, options); + Rush.launchRushX(launcherVersion, effectiveOptions); } else { - Rush.launch(launcherVersion, options); + Rush.launch(launcherVersion, effectiveOptions); } } } +function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): void { + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: options.reporterEventSink, + sessionId: `rush_old_engine_${process.pid}`, + source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } + }); + const legacyWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); + _observeStream(process.stdout, 'stdout', adapter, legacyWrite); + _observeStream(process.stderr, 'stderr', adapter, legacyWrite); +} + +function _observeStream( + stream: NodeJS.WriteStream, + streamName: 'stdout' | 'stderr', + adapter: OldEngineOutputAdapter, + legacyWrite: typeof process.stderr.write +): void { + const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); + const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + if (markedStream[marker]) { + return; + } + markedStream[marker] = true; + + const decoder: StringDecoder = new StringDecoder('utf8'); + stream.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ): boolean => { + const text: string = + typeof chunk === 'string' + ? chunk + : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + if (text) { + adapter.capture(streamName, text); + } + if (typeof encodingOrCallback === 'function') { + return legacyWrite(chunk, encodingOrCallback); + } + return legacyWrite(chunk, encodingOrCallback, callback); + }) as typeof stream.write; +} + function _failWithError(message: string): never { throw new Error(message); } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 044a060d6b9..4c9dc4618dd 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -163,7 +163,9 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr eventSink: reporterHost.sink, sessionId }, - reporterCloseAsync + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterSelectionReason: reporterHost.selection.reason }; try { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index dfa9b84a7e4..0163b3b16aa 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,8 +23,12 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type IBootstrapReplayResult, type ReporterLogLevel, - type ReporterName + type ReporterName, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '@rushstack/rush-reporter'; export interface IRushReporterOutputStream { @@ -38,11 +42,15 @@ export interface IRushReporterHostOptions { readonly env?: Record; readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; } export interface IRushReporterSelection { @@ -57,7 +65,8 @@ export interface IRushReporterSelection { | 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' - | 'pre-major legacy default'; + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } export interface IInitializedRushReporterHost { @@ -65,6 +74,8 @@ export interface IInitializedRushReporterHost { readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -603,9 +614,23 @@ 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 }); + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + columns: process.stdout.columns, + write: process.stdout.write.bind(process.stdout) + }; + const stderr: IRushReporterOutputStream = options.stderr ?? { + isTTY: process.stderr.isTTY, + columns: process.stderr.columns, + write: process.stderr.write.bind(process.stderr) + }; + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ + env, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); @@ -640,11 +665,48 @@ export async function initializeRushReporterHostAsync( } await host.manager.initializeAsync(); + let bootstrapReplay: IBootstrapReplayResult; + try { + bootstrapReplay = await host.replayBootstrapHandoffAsync(); + } finally { + delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + } + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); + if (bootstrapReplay.skipReason === 'incompatible-protocol') { + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + throw new Error( + 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); + } + let closePromise: Promise | undefined; return { host, - sink: host.getSink(), + sink, selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, closeAsync: (timeoutMs?: number) => { closePromise ??= host.manager.closeAsync(timeoutMs); return closePromise; diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 6e450e7aca0..077152d5444 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -39,16 +39,19 @@ export class RushVersionSelector { let installIsValid: boolean = await installMarker.isValidAsync(); if (!installIsValid) { // Need to install Rush - console.log(`Rush version ${version} is not currently installed. Installing...`); + this._reportStartupMessage( + executeOptions, + `Rush version ${version} is not currently installed. Installing...` + ); const resourceName: string = `rush-${version}`; - console.log(`Trying to acquire lock for ${resourceName}`); + this._reportStartupMessage(executeOptions, `Trying to acquire lock for ${resourceName}`); const lock: LockFile = await LockFile.acquireAsync(expectedRushPath, resourceName); installIsValid = await installMarker.isValidAsync(); if (installIsValid) { - console.log('Another process performed the installation.'); + this._reportStartupMessage(executeOptions, 'Another process performed the installation.'); } else { await Utilities.installPackageInDirectoryAsync({ directory: expectedRushPath, @@ -69,7 +72,10 @@ export class RushVersionSelector { filterNpmIncompatibleProperties: true }); - console.log(`Successfully installed Rush version ${version} in ${expectedRushPath}.`); + this._reportStartupMessage( + executeOptions, + `Successfully installed Rush version ${version} in ${expectedRushPath}.` + ); // If we've made it here without exception, write the flag file await installMarker.createAsync(); @@ -101,4 +107,19 @@ export class RushVersionSelector { RushCommandSelector.execute(this._currentPackageVersion, rushCliEntrypoint, executeOptions); } } + + private _reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { + if (options.reporterEnabled) { + options.reporterEventSink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: `rush_frontend_${process.pid}`, + source: { packageName: '@microsoft/rush', packageVersion: this._currentPackageVersion }, + privacy: 'public', + type: 'activityChanged', + payload: { kind: 'version-selection', text } + }); + } else { + console.log(text); + } + } } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 00000000000..3ec4e45375a --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; + +import { RushCommandSelector } from '../RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly events: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise {} + + public report(event: IReporterEventEnvelope): void { + this.events.push(event); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +describe(RushCommandSelector.name, () => { + it('keeps old-engine legacy output visible while bridging it to the frontend host', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + let visibleOutput: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stderr.write = ((text: string): boolean => { + visibleOutput += text; + return true; + }) as typeof process.stderr.write; + + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy engine output\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, options); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(visibleOutput).toBe('legacy engine output\n'); + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0]).toMatchObject({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'legacy engine output\n' } + }); + }); + + it('preserves a UTF-8 code point split across old-engine buffer writes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write(Buffer.from([0xe2])); + process.stdout.write(Buffer.from([0x82, 0xac])); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€' }); + }); + + it('fails an explicit reporter request for an incompatible new engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 2/ + ); + }); + + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + }); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 233b3d7e2d1..1ee58d32b16 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -40,6 +40,8 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'legacy', logLevel: 'normal', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fc3e630773e..77b3bf1a6a1 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -6,6 +6,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import { + BootstrapEventBuffer, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -68,9 +74,11 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'explicit --reporter' }); - expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( - /cannot enable the pre-major reporter path/ - ); + expect(resolve(['build'], { RUSH_REPORTER: 'json' })).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'pre-major legacy default' + }); }); it('uses deterministic non-agent selection for the repository experiment', () => { @@ -458,4 +466,85 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('replays and deletes a bootstrap handoff before returning the authoritative host', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: { rushVersion: '5.178.1' } }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.host.manager.flushAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(JSON.parse(stdoutText).type).toBe('sessionStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'activityChanged', payload: { text: 'installing Rush' } }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace(/"major":1/g, '"major":2')); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('installing Rush\nnpm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..0405adb2302 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a bounded nonce-protected install-run-rush handoff, replay it before version selection, and bridge cross-version reporter compatibility.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..aa1ab9990ea --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Preserve ordered legacy fallback output when a bootstrap handoff protocol is incompatible.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..342b21b8754 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -330,11 +330,18 @@ export interface IBootstrapHandoffWriteResult { readonly nonce: string; } +// @beta +export interface IBootstrapLegacyOutput { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; diff --git a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts index 84b72c09b78..7edcf861f82 100644 --- a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts +++ b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts @@ -74,8 +74,6 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string }); } -// END GENERATED BOOTSTRAP PROTOCOL - /** * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). * @@ -120,3 +118,5 @@ export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NO */ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = 'rush.reporter.buffer-truncated'; + +// END GENERATED BOOTSTRAP PROTOCOL diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 0b6acdd3787..770986282d0 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 @@ -101,7 +98,35 @@ 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'; + + /** + * Ordered raw output that a legacy fallback can render when the handoff + * protocol is incompatible. + */ + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; +} + +/** + * A raw bootstrap write retained for legacy-visible fallback. + * + * @beta + */ +export interface IBootstrapLegacyOutput { + /** + * The original output stream. + */ + readonly stream: 'stdout' | 'stderr'; + + /** + * The unmodified output text. + */ + readonly text: string; } function isRecord(value: unknown): value is Record { @@ -143,6 +168,25 @@ function isReporterEventEnvelope(value: unknown): value is IReporterEventEnvelop ); } +function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOutput[] { + const output: IBootstrapLegacyOutput[] = []; + for (const event of events) { + if (!isRecord(event) || !isRecord(event.payload)) { + continue; + } + if ( + event.type === 'externalOutput' && + (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && + typeof event.payload.text === 'string' + ) { + output.push({ stream: event.payload.stream, text: event.payload.text }); + } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { + output.push({ stream: 'stdout', text: `${event.payload.text}\n` }); + } + } + return output; +} + /** * Hosts the authoritative {@link ReporterManager} in the frontend, before Rush * version selection. @@ -247,17 +291,16 @@ 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(REPORTER_PROTOCOL_VERSION, protocolVersion)) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'incompatible-protocol' + skipReason: 'incompatible-protocol', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } if (!isReporterEventEnvelope(event)) { diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f8..7cba1719773 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -138,7 +138,11 @@ export { export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; -export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; +export type { + IReporterHostOptions, + IBootstrapReplayResult, + IBootstrapLegacyOutput +} from './frontend/ReporterHost'; export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; export type { diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 208c458b761..4ebca154427 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -188,6 +188,7 @@ describe('ReporterHost handoff replay', () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); buffer.emit({ type: 'sessionStarted', payload: {} }); + buffer.addExternalOutput('stderr', 'legacy bootstrap output\n'); const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); @@ -204,6 +205,7 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toEqual([{ stream: 'stderr', text: 'legacy bootstrap output\n' }]); }); }); @@ -267,10 +269,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/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts new file mode 100644 index 00000000000..fc86bc729e1 --- /dev/null +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -0,0 +1,572 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// IMPORTANT: This file is bundled into install-run-rush.js and must use only Node.js built-ins. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILogger } from '../utilities/npmrcUtilities'; +import { + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + BOOTSTRAP_PROTOCOL_MAJOR, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + encodeBootstrapEnvelope +} from './generated/BootstrapProtocol'; + +const TRUNCATION_NOTICE_RESERVE_BYTES: number = 512; +const BOOTSTRAP_HANDOFF_FILE_PREFIX: string = 'rush-reporter-bootstrap-'; +const BOOTSTRAP_HANDOFF_FILE_SUFFIX: string = '.ndjson'; +const SUPPORTED_REPORTERS: ReadonlySet = new Set([ + 'default', + 'ai', + 'json', + 'plaintext', + 'file', + 'legacy' +]); +const SUPPORTED_LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +type BootstrapStream = 'stdout' | 'stderr'; + +interface IBootstrapEventInput { + readonly type: string; + readonly privacy: 'public' | 'local-sensitive'; + readonly payload: unknown; +} + +interface IBufferedBootstrapEntry { + readonly line: string; + readonly bytes: number; + readonly required: boolean; + readonly fallbackWrite?: IFallbackWrite; +} + +interface IFallbackWrite { + readonly stream: BootstrapStream; + readonly text: string; +} + +export interface IInstallRunRushBootstrapOptions { + readonly argv: readonly string[]; + readonly env: Record; + readonly rushJsonFolder: string; + readonly rushVersion: string; + readonly bootstrapVersion: string; + readonly commandName: 'rush' | 'rush-pnpm' | 'rushx'; + readonly quiet: boolean; + readonly stdout?: (text: string) => void; + readonly stderr?: (text: string) => void; + readonly handoffDirectory?: string; + readonly maxBytes?: number; + readonly now?: () => string; + readonly randomUUID?: () => string; +} + +export interface IInstallRunRushBootstrap { + readonly enabled: boolean; + readonly logger: ILogger; + readonly externalOutputCaptureMaxBytes: number | undefined; + readonly externalOutputHandler: ((stream: BootstrapStream, text: string) => void) | undefined; + readonly externalOutputOverflowHandler: (() => void) | undefined; + readonly prepareToRun: (() => void) | undefined; +} + +function readSingleFlagValue(argv: readonly string[], flag: string): string | undefined { + let result: string | undefined; + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + let value: string | undefined; + if (argument.startsWith(prefix)) { + value = argument.slice(prefix.length); + } else if (argument === flag) { + value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + index++; + } + + if (value !== undefined) { + if (!value) { + throw new Error(`${flag} requires a value.`); + } + if (result !== undefined) { + throw new Error(`${flag} may be specified only once.`); + } + result = value; + } + } + return result; +} + +function repositoryUsesRushReporter(rushJsonFolder: string): boolean { + const experimentsPath: string = path.join(rushJsonFolder, 'common', 'config', 'rush', 'experiments.json'); + let contents: string; + try { + contents = fs.readFileSync(experimentsPath, 'utf8'); + } catch (error) { + const code: unknown = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return false; + } + throw error; + } + + const matches: RegExpMatchArray[] = [ + ...stripJsonComments(contents).matchAll(/"useRushReporter"\s*:\s*(true|false)/g) + ]; + return matches.length > 0 && matches[matches.length - 1][1] === 'true'; +} + +function stripJsonComments(text: string): string { + let result: string = ''; + let inString: boolean = false; + let escaped: boolean = false; + let lineComment: boolean = false; + let blockComment: boolean = false; + + for (let index: number = 0; index < text.length; index++) { + const character: string = text[index]; + const nextCharacter: string | undefined = text[index + 1]; + if (lineComment) { + if (character === '\n' || character === '\r') { + lineComment = false; + result += character; + } + continue; + } + if (blockComment) { + if (character === '*' && nextCharacter === '/') { + blockComment = false; + index++; + } else if (character === '\n' || character === '\r') { + result += character; + } + continue; + } + if (inString) { + result += character; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (character === '/' && nextCharacter === '/') { + lineComment = true; + index++; + } else if (character === '/' && nextCharacter === '*') { + blockComment = true; + index++; + } else { + result += character; + } + } + return result; +} + +interface IParsedVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[] | undefined; +} + +function parseVersion(version: string): IParsedVersion | undefined { + const match: RegExpMatchArray | null = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split('.') + }; +} + +function comparePrerelease( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): number { + if (!left) { + return right ? 1 : 0; + } + if (!right) { + return -1; + } + const length: number = Math.max(left.length, right.length); + for (let index: number = 0; index < length; index++) { + const leftPart: string | undefined = left[index]; + const rightPart: string | undefined = right[index]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + const leftNumeric: boolean = /^\d+$/.test(leftPart); + const rightNumeric: boolean = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) { + return Number(leftPart) - Number(rightPart); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function supportsBootstrapHandoff(rushVersion: string, bootstrapVersion: string): boolean { + const rush: IParsedVersion | undefined = parseVersion(rushVersion); + const bootstrap: IParsedVersion | undefined = parseVersion(bootstrapVersion); + if (!rush || !bootstrap) { + return false; + } + for (let index: number = 0; index < rush.core.length; index++) { + if (rush.core[index] !== bootstrap.core[index]) { + return rush.core[index] > bootstrap.core[index]; + } + } + return comparePrerelease(rush.prerelease, bootstrap.prerelease) >= 0; +} + +function* chunkUtf8Text(text: string, maxChunkBytes: number): Iterable { + let chunkStart: number = 0; + let chunkBytes: number = 0; + let offset: number = 0; + + while (offset < text.length) { + const codePoint: number = text.codePointAt(offset)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (chunkBytes > 0 && chunkBytes + codePointBytes > maxChunkBytes) { + yield text.slice(chunkStart, offset); + chunkStart = offset; + chunkBytes = 0; + } + chunkBytes += codePointBytes; + offset += codeUnits; + } + + if (chunkStart < text.length) { + yield text.slice(chunkStart); + } +} + +class InstallRunRushBootstrap implements IInstallRunRushBootstrap { + public readonly enabled: boolean = true; + public readonly logger: ILogger; + public readonly externalOutputCaptureMaxBytes: number; + public readonly externalOutputHandler: (stream: BootstrapStream, text: string) => void; + public readonly externalOutputOverflowHandler: () => void; + public readonly prepareToRun: () => void; + + private readonly _entries: IBufferedBootstrapEntry[]; + private readonly _env: Record; + private readonly _stdout: (text: string) => void; + private readonly _stderr: (text: string) => void; + private readonly _handoffDirectory: string; + private readonly _maxBytes: number; + private readonly _now: () => string; + private readonly _randomUUID: () => string; + private readonly _sessionId: string; + private readonly _sourceVersion: string; + private readonly _entryLimit: number; + private _usedBytes: number; + private _nextSequence: number; + private _nextEventNumber: number; + private _droppedReplaceable: number; + private _droppedRequired: number; + private _failureFlushed: boolean; + + public constructor(options: IInstallRunRushBootstrapOptions) { + this._entries = []; + this._env = options.env; + this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + this._stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); + this._maxBytes = options.maxBytes ?? BOOTSTRAP_BUFFER_MAX_BYTES; + this._now = options.now ?? (() => new Date().toISOString()); + this._randomUUID = options.randomUUID ?? (() => crypto.randomUUID()); + this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; + this._sourceVersion = options.bootstrapVersion; + this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + if (this._entryLimit <= 0) { + throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); + } + this._usedBytes = 0; + this._nextSequence = 1; + this._nextEventNumber = 1; + this._droppedReplaceable = 0; + this._droppedRequired = 0; + this._failureFlushed = false; + this.externalOutputCaptureMaxBytes = this._maxBytes; + this._addEvent({ + type: 'sessionStarted', + privacy: 'public', + payload: { rushVersion: options.rushVersion, cwd: process.cwd() } + }); + this._addEvent({ + type: 'commandStarted', + privacy: 'public', + payload: { commandName: options.argv[0] ?? 'unknown', argv: options.argv } + }); + + this.logger = { + info: (text: string) => { + this._addEvent( + { + type: 'activityChanged', + privacy: 'public', + payload: { kind: 'bootstrap', text } + }, + { stream: 'stdout', text: `${text}\n` } + ); + }, + error: (text: string) => { + const droppedRequiredBefore: number = this._droppedRequired; + this._addExternalOutput('stderr', `${text}\n`); + this._flushFailureOutput(); + if (this._droppedRequired > droppedRequiredBefore) { + this._stderr(`${text}\n`); + } + } + }; + this.externalOutputHandler = (stream: BootstrapStream, text: string) => { + this._addExternalOutput(stream, text); + }; + this.externalOutputOverflowHandler = () => { + this._droppedRequired++; + }; + this.prepareToRun = () => { + this._writeHandoff(); + }; + } + + private _addEvent(event: IBootstrapEventInput, fallbackWrite?: IFallbackWrite): void { + const required: boolean = event.type !== 'activityChanged'; + const line: string = encodeBootstrapEnvelope({ + eventId: `boot_${this._nextEventNumber++}`, + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: event.privacy, + required, + type: event.type, + payload: event.payload + }); + const bytes: number = Buffer.byteLength(line, 'utf8') + 1; + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + return; + } + + if (!required) { + this._droppedReplaceable++; + return; + } + + for ( + let index: number = 0; + this._usedBytes + bytes > this._entryLimit && index < this._entries.length; + + ) { + const entry: IBufferedBootstrapEntry = this._entries[index]; + if (entry.required) { + index++; + } else { + this._entries.splice(index, 1); + this._usedBytes -= entry.bytes; + this._droppedReplaceable++; + } + } + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + } else { + this._droppedRequired++; + } + } + + private _addExternalOutput(stream: BootstrapStream, text: string): void { + if (!text) { + return; + } + for (const chunk of chunkUtf8Text(text, BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES)) { + this._addEvent( + { + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream, text: chunk } + }, + { stream, text: chunk } + ); + } + } + + private _flushFailureOutput(): void { + if (this._failureFlushed) { + return; + } + this._failureFlushed = true; + for (const entry of this._entries) { + const write: IFallbackWrite | undefined = entry.fallbackWrite; + if (write) { + (write.stream === 'stdout' ? this._stdout : this._stderr)(write.text); + } + } + } + + private _writeHandoff(): void { + const serialized: string = this._serializeEvents(); + const nonce: string = this._randomUUID(); + const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${process.pid}-${nonce}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`; + const handoffPath: string = path.join(this._handoffDirectory, fileName); + fs.mkdirSync(this._handoffDirectory, { recursive: true }); + fs.writeFileSync(handoffPath, `${JSON.stringify({ kind: 'bootstrapHandoff', nonce })}\n${serialized}`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }); + if (process.platform !== 'win32') { + fs.chmodSync(handoffPath, 0o600); + } + this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + } + + private _serializeEvents(): string { + const truncated: boolean = this._droppedReplaceable + this._droppedRequired > 0; + if (truncated) { + const notice: string = encodeBootstrapEnvelope({ + eventId: 'boot_bufferTruncated', + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: 'public', + required: true, + type: 'extension', + payload: { + name: BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + droppedReplaceable: this._droppedReplaceable, + droppedOther: 0, + droppedRequired: this._droppedRequired, + failed: this._droppedRequired > 0 + } + }); + if (Buffer.byteLength(notice, 'utf8') + 1 > TRUNCATION_NOTICE_RESERVE_BYTES) { + throw new Error('The bootstrap truncation notice exceeded its reserved capacity.'); + } + this._entries.push({ + line: notice, + bytes: Buffer.byteLength(notice, 'utf8') + 1, + required: true + }); + } + + if (this._droppedRequired > 0) { + throw new Error( + `The Rush reporter bootstrap buffer exceeded ${this._maxBytes} bytes and could not preserve ` + + `${this._droppedRequired} required event(s).` + ); + } + + return this._entries.length > 0 + ? `${this._entries.map((entry: IBufferedBootstrapEntry) => entry.line).join('\n')}\n` + : ''; + } +} + +function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { + const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + return { + enabled: false, + logger: options.quiet + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`) } + : { + info: (text: string) => stdout(`${text}\n`), + error: (text: string) => stderr(`${text}\n`) + }, + externalOutputHandler: undefined, + externalOutputCaptureMaxBytes: undefined, + externalOutputOverflowHandler: undefined, + prepareToRun: undefined + }; +} + +export function createInstallRunRushBootstrap( + options: IInstallRunRushBootstrapOptions +): IInstallRunRushBootstrap { + if (BOOTSTRAP_PROTOCOL_MAJOR < 1) { + throw new Error('The generated Rush reporter bootstrap protocol is invalid.'); + } + delete options.env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete options.env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + + if (options.commandName !== 'rush') { + return createLegacyBootstrap(options); + } + + const environmentReporter: string | undefined = options.env.RUSH_REPORTER?.trim().toLowerCase(); + if (environmentReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const explicitReporter: string | undefined = readSingleFlagValue(options.argv, '--reporter'); + const explicitLogLevel: string | undefined = readSingleFlagValue(options.argv, '--log-level'); + if (explicitReporter !== undefined && !SUPPORTED_REPORTERS.has(explicitReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(explicitReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + if (explicitLogLevel !== undefined && !SUPPORTED_LOG_LEVELS.has(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + + if (explicitReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const repositoryOptIn: boolean = repositoryUsesRushReporter(options.rushJsonFolder); + const explicitOptIn: boolean = explicitReporter !== undefined; + if (!explicitOptIn && !repositoryOptIn) { + return createLegacyBootstrap(options); + } + + if (!supportsBootstrapHandoff(options.rushVersion, options.bootstrapVersion)) { + if (explicitOptIn) { + throw new Error( + `Rush version ${options.rushVersion} does not support the reporter bootstrap requested by ` + + `${JSON.stringify(`--reporter=${explicitReporter}`)}. Update the repository Rush version or ` + + 'use --reporter=legacy.' + ); + } + return createLegacyBootstrap(options); + } + + return new InstallRunRushBootstrap(options); +} diff --git a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts index 97bfe28545b..82c6a588674 100644 --- a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts +++ b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts @@ -70,3 +70,48 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string payload: input.payload === undefined ? {} : input.payload }); } + +/** + * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). + * + * @beta + */ +export const BOOTSTRAP_BUFFER_MAX_BYTES: number = 1024 * 1024; + +/** + * The maximum size of a single raw external-output chunk, in bytes (64 KiB). + * + * @beta + */ +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number = 64 * 1024; + +/** + * The private environment variable used to hand the bootstrap NDJSON file path + * to the installed frontend. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF' = + '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + +/** + * The private environment variable carrying the one-time nonce that must match + * the handoff file's header line. + * + * @remarks + * The nonce proves the handoff file was written by the same bootstrap process + * that set the environment variable: a stale or foreign handoff file (same + * temp directory, different invocation) is rejected rather than replayed. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE' = + '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + +/** + * The namespaced extension event name that describes bootstrap buffer truncation. + * + * @beta + */ +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = + 'rush.reporter.buffer-truncated'; diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index 1bb7b29d0c5..d0fd5eb5edb 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -7,14 +7,13 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import type { ILogger } from '../utilities/npmrcUtilities'; +import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap } from './InstallRunRushBootstrap'; import { BOOTSTRAP_PROTOCOL_MAJOR, encodeBootstrapEnvelope } from './generated/BootstrapProtocol'; -const { - installAndRun, - findRushJsonFolder, - RUSH_JSON_FILENAME, - runWithErrorAndStatusCode -}: typeof import('./install-run') = __non_webpack_require__('./install-run'); +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME }: typeof import('./install-run') = + __non_webpack_require__('./install-run'); + +declare const RUSH_LIB_VERSION_FOR_BOOTSTRAP: string; const PACKAGE_NAME: string = '@microsoft/rush'; const RUSH_PREVIEW_VERSION: string = 'RUSH_PREVIEW_VERSION'; @@ -28,11 +27,13 @@ function _validateBundledBootstrapProtocol(): void { } } -function _getRushVersion(logger: ILogger): string { +function _getRushVersion(): { readonly version: string; readonly sourceMessage?: string } { const rushPreviewVersion: string | undefined = process.env[RUSH_PREVIEW_VERSION]; if (rushPreviewVersion !== undefined) { - logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); - return rushPreviewVersion; + return { + version: rushPreviewVersion, + sourceMessage: `Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}` + }; } const rushJsonFolder: string = findRushJsonFolder(); @@ -44,7 +45,7 @@ function _getRushVersion(logger: ILogger): string { const rushJsonMatches: string[] = rushJsonContents.match( /\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/ )!; - return rushJsonMatches[1]; + return { version: rushJsonMatches[1] }; } catch (e) { throw new Error( `Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + @@ -54,7 +55,7 @@ function _getRushVersion(logger: ILogger): string { } } -function _getBin(scriptName: string): string { +function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { switch (scriptName.toLowerCase()) { case 'install-run-rush-pnpm.js': return 'rush-pnpm'; @@ -77,7 +78,7 @@ function _run(): void { // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the // appropriate binary inside the rush package to run const scriptName: string = path.basename(scriptPath); - const bin: string = _getBin(scriptName); + const bin: 'rush' | 'rush-pnpm' | 'rushx' = _getBin(scriptName); if (!nodePath || !scriptPath) { throw new Error('Unexpected exception: could not detect node path or script path'); } @@ -115,13 +116,25 @@ function _run(): void { process.exit(1); } - const logger: ILogger = quiet - ? { info: () => {}, error: console.error } - : { info: console.log, error: console.error }; - - runWithErrorAndStatusCode(logger, () => { - const version: string = _getRushVersion(logger); - logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); + let bootstrap: IInstallRunRushBootstrap | undefined; + process.exitCode = 1; + try { + bootstrap = createInstallRunRushBootstrap({ + argv: packageBinArgs, + env: process.env, + rushJsonFolder, + rushVersion: rushVersion.version, + bootstrapVersion: RUSH_LIB_VERSION_FOR_BOOTSTRAP, + commandName: bin, + quiet + }); + const logger: ILogger = bootstrap.logger; + if (rushVersion.sourceMessage) { + logger.info(rushVersion.sourceMessage); + } + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${rushVersion.version}`); const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { @@ -130,8 +143,26 @@ function _run(): void { ); } - return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); - }); + process.exitCode = installAndRun( + logger, + PACKAGE_NAME, + rushVersion.version, + bin, + packageBinArgs, + lockFilePath, + { + onExternalOutput: bootstrap.externalOutputHandler, + onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, + externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + prepareToRun: bootstrap.prepareToRun + } + ); + } catch (error) { + const logger: ILogger = + bootstrap?.logger ?? + (quiet ? { info: () => {}, error: console.error } : { info: console.log, error: console.error }); + logger.error(`\n\n${String(error)}\n`); + } } _run(); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 7f568566485..84e350938a0 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -7,6 +7,7 @@ import * as childProcess from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import type { IPackageJson } from '@rushstack/node-core-library'; @@ -20,6 +21,61 @@ const INSTALL_RUN_LOCKFILE_PATH_VARIABLE: 'INSTALL_RUN_LOCKFILE_PATH' = 'INSTALL const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; +let _externalOutputCaptureId: number = 0; +const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const { StringDecoder } = require('node:string_decoder'); +const [command, argsJson, capturePath, useShell, maxBytesText] = process.argv.slice(1); +const child = childProcess.spawn(command, JSON.parse(argsJson), { + cwd: process.cwd(), + env: process.env, + shell: useShell === '1', + windowsVerbatimArguments: false, + stdio: ['inherit', 'pipe', 'pipe'] +}); +const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }; +const maxBytes = Number(maxBytesText); +let capturedBytes = 0; +let overflowed = false; +function capture(stream, text) { + if (!text || overflowed) { + return; + } + const record = JSON.stringify({ stream, text }) + '\\n'; + const recordBytes = Buffer.byteLength(record); + if (capturedBytes + recordBytes <= maxBytes) { + fs.appendFileSync(capturePath, record); + capturedBytes += recordBytes; + } else { + overflowed = true; + fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); + } +} +child.stdout.on('data', (chunk) => capture('stdout', decoders.stdout.write(chunk))); +child.stderr.on('data', (chunk) => capture('stderr', decoders.stderr.write(chunk))); +child.on('error', (error) => { + process.stderr.write(String(error) + '\\n'); + process.exitCode = 1; +}); +child.on('close', (code, signal) => { + capture('stdout', decoders.stdout.end()); + capture('stderr', decoders.stderr.end()); + if (signal) { + process.stderr.write('npm was terminated by signal: ' + signal + '\\n'); + process.exitCode = 1; + } else { + process.exitCode = code === null ? 1 : code; + } +}); +`; + +export interface IInstallAndRunOptions { + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string) => void; + readonly onExternalOutputOverflow?: () => void; + readonly externalOutputCaptureMaxBytes?: number; + readonly prepareToRun?: () => void; +} /** * Parse a package specifier (in the form of name\@version) into name and version parts. @@ -352,22 +408,102 @@ function _installPackage( packageInstallFolder: string, name: string, version: string, - npmCommand: 'install' | 'ci' + npmCommand: 'install' | 'ci', + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined, + onExternalOutputOverflow: (() => void) | undefined, + externalOutputCaptureMaxBytes: number | undefined ): void { + let capturePath: string | undefined; try { logger.info(`Installing ${name}...`); - _runNpmConfirmSuccess( - [npmCommand], - { - stdio: 'inherit', - cwd: packageInstallFolder, - env: process.env - }, - `npm ${npmCommand}` - ); - logger.info(`Successfully installed ${name}@${version}`); + if (onExternalOutput) { + capturePath = path.join( + packageInstallFolder, + `.install-run-output-${process.pid}-${_externalOutputCaptureId++}.log` + ); + fs.closeSync(fs.openSync(capturePath, 'wx', 0o600)); + } + if (capturePath) { + _runNpmWithCaptureConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + capturePath, + externalOutputCaptureMaxBytes ?? 1024 * 1024, + `npm ${npmCommand}` + ); + } else { + _runNpmConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + `npm ${npmCommand}` + ); + } } catch (e) { throw new Error(`Unable to install package: ${e}`); + } finally { + if (capturePath !== undefined) { + try { + _readCapturedNpmOutput(capturePath, onExternalOutput!, onExternalOutputOverflow); + } finally { + _deleteFile(capturePath); + } + } + } + logger.info(`Successfully installed ${name}@${version}`); +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string) => void, + onExternalOutputOverflow: (() => void) | undefined +): void { + const fileDescriptor: number = fs.openSync(capturePath, 'r'); + const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); + const decoder: StringDecoder = new StringDecoder('utf8'); + let pending: string = ''; + try { + for (;;) { + const bytesRead: number = fs.readSync(fileDescriptor, buffer, 0, buffer.length, null); + if (bytesRead === 0) { + break; + } + pending += decoder.write(buffer.subarray(0, bytesRead)); + let newlineIndex: number; + while ((newlineIndex = pending.indexOf('\n')) >= 0) { + const line: string = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (line) { + const record: { stream?: unknown; text?: unknown; overflow?: unknown } = JSON.parse(line); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else if ( + (record.stream === 'stdout' || record.stream === 'stderr') && + typeof record.text === 'string' + ) { + onExternalOutput(record.stream, record.text); + } + } + } + } + pending += decoder.end(); + if (pending.trim()) { + const record: { overflow?: unknown } = JSON.parse(pending); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else { + throw new Error('The npm output capture ended with an incomplete record.'); + } + } + } finally { + fs.closeSync(fileDescriptor); } } @@ -417,7 +553,41 @@ function _runNpmConfirmSuccess( } else { result = childProcess.spawnSync(command, args, options); } + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} +function _runNpmWithCaptureConfirmSuccess( + args: string[], + options: childProcess.SpawnSyncOptions, + capturePath: string, + captureMaxBytes: number, + commandNameForLogging: string +): childProcess.SpawnSyncReturns { + const npmPath: string = getNpmPath(); + const command: string = IS_WINDOWS ? _buildShellCommand(npmPath, args) : npmPath; + const commandArgs: string[] = IS_WINDOWS ? [] : args; + const result: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + command, + JSON.stringify(commandArgs), + capturePath, + IS_WINDOWS ? '1' : '0', + String(captureMaxBytes) + ], + options + ); + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} + +function _throwIfSpawnFailed( + result: childProcess.SpawnSyncReturns, + commandNameForLogging: string +): void { if (result.status !== 0) { if (!result.status) { // Is status null or undefined? @@ -432,8 +602,6 @@ function _runNpmConfirmSuccess( throw new Error(`"${commandNameForLogging}" returned error code ${result.status}`); } } - - return result; } export function installAndRun( @@ -442,7 +610,8 @@ export function installAndRun( packageVersion: string, packageBinName: string, packageBinArgs: string[], - lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE] + lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE], + options: IInstallAndRunOptions = {} ): number { const rushJsonFolder: string = findRushJsonFolder(); const rushCommonFolder: string = path.join(rushJsonFolder, 'common'); @@ -470,13 +639,23 @@ export function installAndRun( _createPackageJson(packageInstallFolder, packageName, packageVersion); const installCommand: 'install' | 'ci' = lockFilePath ? 'ci' : 'install'; - _installPackage(logger, packageInstallFolder, packageName, packageVersion, installCommand); + _installPackage( + logger, + packageInstallFolder, + packageName, + packageVersion, + installCommand, + options.onExternalOutput, + options.onExternalOutputOverflow, + options.externalOutputCaptureMaxBytes + ); _writeFlagFile(packageInstallFolder); } const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + options.prepareToRun?.(); const binPath: string = _getBinPath(packageInstallFolder, packageBinName); const binFolderPath: string = path.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts new file mode 100644 index 00000000000..443d63a14c7 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -0,0 +1,261 @@ +// 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 { + createInstallRunRushBootstrap, + type IInstallRunRushBootstrap, + type IInstallRunRushBootstrapOptions +} from '../InstallRunRushBootstrap'; +import { + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR +} from '../generated/BootstrapProtocol'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +function makeOptions( + directory: string, + overrides: Partial = {} +): { + readonly options: IInstallRunRushBootstrapOptions; + readonly env: Record; + readonly stdout: string[]; + readonly stderr: string[]; +} { + const env: Record = {}; + const stdout: string[] = []; + const stderr: string[] = []; + return { + env, + stdout, + stderr, + options: { + argv: ['build'], + env, + rushJsonFolder: directory, + rushVersion: '5.178.1', + bootstrapVersion: '5.178.1', + commandName: 'rush', + quiet: false, + stdout: (text: string) => stdout.push(text), + stderr: (text: string) => stderr.push(text), + handoffDirectory: directory, + now: () => '2026-08-28T00:00:00.000Z', + randomUUID: (() => { + let index: number = 0; + return () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}`; + })(), + ...overrides + } + }; +} + +function readHandoff(env: Record): { + readonly path: string; + readonly records: Record[]; +} { + const handoffPath: string | undefined = env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + if (!handoffPath) { + throw new Error('Expected a bootstrap handoff path.'); + } + const records: Record[] = fs + .readFileSync(handoffPath, 'utf8') + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + return { path: handoffPath, records }; +} + +describe(createInstallRunRushBootstrap.name, () => { + it('preserves direct legacy bootstrap output without an opt-in', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory); + env.RUSH_REPORTER = 'unsupported-automatic-value'; + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('legacy startup'); + bootstrap.prepareToRun?.(); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['legacy startup\n']); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + }); + }); + + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory, { + argv: ['build', '--reporter=json', '--log-level=debug'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('resolving Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n'); + bootstrap.logger.info('invoking Rush'); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + expect(bootstrap.enabled).toBe(true); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( + (handoff.records[0] as { nonce?: string }).nonce + ); + expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'activityChanged', + 'externalOutput', + 'activityChanged' + ]); + expect((handoff.records[4].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + if (process.platform !== 'win32') { + expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); + } + }); + }); + + it('uses repository opt-in but safely falls back for an old frontend', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + '{ "useRushReporter": true }\n' + ); + const { options, stdout } = makeOptions(directory, { rushVersion: '5.177.0' }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('old frontend startup'); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['old frontend startup\n']); + }); + }); + + it('ignores a commented hypothetical repository opt-in', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + [ + '{', + ' // "useRushReporter": true,', + ' "exampleUrl": "https://example.test/*not-a-comment*/"', + '}' + ].join('\n') + ); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap( + makeOptions(directory).options + ); + + expect(bootstrap.enabled).toBe(false); + }); + }); + + it('does not treat an older prerelease of the bootstrap version as compatible', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.178.1-dev.1', + bootstrapVersion: '5.178.1-dev.10' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('fails unsupported explicit requests and explicit requests for an old frontend', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { argv: ['build', '--reporter=unknown'] }).options + ) + ).toThrow(/Unsupported reporter/); + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.177.0' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('honors the legacy emergency override before validating reporter controls', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=unknown', '--log-level=invalid'] + }); + env.RUSH_REPORTER = ' LEGACY '; + + expect(createInstallRunRushBootstrap(options).enabled).toBe(false); + }); + }); + + it('truncates replaceable startup status with a required marker', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 50; index++) { + bootstrap.logger.info(`status ${index} ${'x'.repeat(80)}`); + } + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const eventRecords: Record[] = handoff.records.slice(1); + const marker: Record = eventRecords[eventRecords.length - 1]; + expect(marker.type).toBe('extension'); + expect((marker.payload as { name: string }).name).toBe(BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME); + expect((marker.payload as { droppedReplaceable: number }).droppedReplaceable).toBeGreaterThan(0); + expect( + Buffer.byteLength(fs.readFileSync(handoff.path, 'utf8').split('\n').slice(1).join('\n'), 'utf8') + ).toBeLessThanOrEqual(1800); + }); + }); + + it('fails instead of dropping required external output', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000)); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + bootstrap.logger.error('bootstrap failed'); + + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(stderr.join('')).toContain('bootstrap failed'); + }); + }); + + it('fails when the npm capture reports overflow before replay', async () => { + await withTempDir(async (directory: string) => { + const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputOverflowHandler?.(); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + }); + }); +}); diff --git a/libraries/rush-lib/webpack.config.js b/libraries/rush-lib/webpack.config.js index 6952beba9dc..fd758205ace 100644 --- a/libraries/rush-lib/webpack.config.js +++ b/libraries/rush-lib/webpack.config.js @@ -116,32 +116,39 @@ module.exports = () => { } } ), - generateConfiguration({ - [PathConstants.pnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.subspacePnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushxScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, - ...SCRIPT_ENTRY_OPTIONS + generateConfiguration( + { + [PathConstants.pnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.subspacePnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushxScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushPnpmScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, + ...SCRIPT_ENTRY_OPTIONS + } }, - [PathConstants.installRunRushPnpmScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, - ...SCRIPT_ENTRY_OPTIONS - } - }) + [ + new webpack.DefinePlugin({ + RUSH_LIB_VERSION_FOR_BOOTSTRAP: JSON.stringify(packageJson.version) + }) + ] + ) ]; return configurations; From 5041c96162c8b91ff271323ec64f760d5e361ef1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:52:39 +0000 Subject: [PATCH 12/34] Fix reporter bootstrap compatibility failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushCommandSelector.ts | 12 +- apps/rush/src/RushReporterHost.ts | 154 ++++++++++-------- .../rush/src/test/RushCommandSelector.test.ts | 55 ++++++- apps/rush/src/test/RushReporterHost.test.ts | 127 +++++++++++++++ common/reviews/api/rush-reporter.api.md | 3 +- .../reporter/src/frontend/ReporterHost.ts | 33 +++- .../reporter/src/test/ReporterHost.test.ts | 40 ++++- 7 files changed, 342 insertions(+), 82 deletions(-) diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 0453811b4de..f486016b4da 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import { + LegacyFallbackSink, OldEngineOutputAdapter, REPORTER_PROTOCOL_VERSION, resolveReporterCompatibility, @@ -56,13 +57,7 @@ export class RushCommandSelector { } ); let effectiveOptions: IRushFrontendLaunchOptions = options; - if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { - _observeOldEngineOutput(options, Rush.version); - } else if ( - compatibility.mode === 'old-frontend-new-engine' && - engineProtocolMajor !== undefined && - options.reporterEnabled - ) { + if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { if (options.reporterSelectionReason === 'explicit --reporter') { throw new Error( `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + @@ -72,9 +67,12 @@ export class RushCommandSelector { } effectiveOptions = { ...options, + reporterEventSink: new LegacyFallbackSink(), reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }; + } else if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); } if (commandName === 'rush-pnpm') { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 0163b3b16aa..2944e364fb7 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -624,92 +624,104 @@ export async function initializeRushReporterHostAsync( columns: process.stderr.columns, write: process.stderr.write.bind(process.stderr) }; - let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); const host: ReporterHost = new ReporterHost({ env, handoffDirectory: options.handoffDirectory, retentionMs: options.handoffRetentionMs, nowMs: options.nowMs }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; - 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' - }); + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + + 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 + }); + } } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' ) { - 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 - }); + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + const incompatibility: string = + bootstrapReplay.skipReason === 'incompatible-protocol' + ? 'protocol is incompatible' + : 'contains an unsupported required event'; + throw new Error( + `The install-run-rush bootstrap reporter ${incompatibility} with this Rush frontend. ` + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); } - } - await host.manager.initializeAsync(); - let bootstrapReplay: IBootstrapReplayResult; - try { - bootstrapReplay = await host.replayBootstrapHandoffAsync(); + return { + host, + sink, + selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; } finally { + if (!handoffReplayAttempted) { + await host.discardBootstrapHandoffAsync(); + } delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; } - const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); - - let sink: IReporterEventSink = host.getSink(); - if (bootstrapReplay.skipReason === 'incompatible-protocol') { - for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { - const target: IRushReporterOutputStream = - selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; - target.write(output.text); - } - if (selection.reason === 'explicit --reporter') { - throw new Error( - 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + - 'Update the global Rush installation or use --reporter=legacy.' - ); - } - selection = { - reporter: 'legacy', - logLevel: 'normal', - outputs: [], - commandJson: selection.commandJson, - enabled: false, - reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, - reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, - reason: 'bootstrap compatibility fallback' - }; - sink = new LegacyFallbackSink(); - } - - let closePromise: Promise | undefined; - return { - host, - sink, - selection, - bootstrapReplay, - abandonedHandoffFilesDeleted, - closeAsync: (timeoutMs?: number) => { - closePromise ??= host.manager.closeAsync(timeoutMs); - return closePromise; - } - }; } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index 3ec4e45375a..1db9f261068 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; +import { + LegacyFallbackSink, + ReporterManager, + type IReporter, + type IReporterEventEnvelope +} from '@rushstack/rush-reporter'; import { RushCommandSelector } from '../RushCommandSelector'; import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; @@ -145,6 +150,26 @@ describe(RushCommandSelector.name, () => { ); }); + it('fails an explicit reporter request for an incompatible older engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 0/ + ); + }); + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { let receivedOptions: IRushFrontendLaunchOptions | undefined; const options: IRushFrontendLaunchOptions = { @@ -169,5 +194,33 @@ describe(RushCommandSelector.name, () => { reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); + }); + + it('falls back to legacy engine rendering for an implicit older protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 77b3bf1a6a1..fe9d32531cc 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -547,4 +547,131 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('falls back when repository opt-in meets an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('fails an explicit reporter request for an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stderrText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/unsupported required event/); + + expect(stderrText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('deletes an authenticated handoff when explicit reporter validation fails', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/requires an interactive TTY/); + + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 342b21b8754..3865d31aefc 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -344,7 +344,7 @@ export interface IBootstrapReplayResult { readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'unsupported-required-event' | 'incompatible-protocol'; } // @beta @@ -1373,6 +1373,7 @@ export type ReporterExtensionEventName = `${string}.${string}` & { export class ReporterHost { constructor(options?: IReporterHostOptions); cleanAbandonedHandoffFilesAsync(): Promise; + discardBootstrapHandoffAsync(): Promise; getSink(): IReporterEventSink; get manager(): ReporterManager; replayBootstrapHandoffAsync(): Promise; diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 770986282d0..7f1c4fa19c5 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -103,6 +103,7 @@ export interface IBootstrapReplayResult { | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' + | 'unsupported-required-event' | 'incompatible-protocol'; /** @@ -305,13 +306,15 @@ export class ReporterHost { } if (!isReporterEventEnvelope(event)) { if (isRecord(event) && event.required === true) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'invalid-event' + skipReason: 'unsupported-required-event', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } skippedEventCount++; @@ -348,6 +351,34 @@ export class ReporterHost { }; } + /** + * Deletes the current authenticated bootstrap handoff without replaying it. + * + * @remarks + * This is used when frontend initialization fails before replay can begin. + * Paths outside the configured handoff directory and nonce mismatches are + * rejected without deleting the referenced file. + * + */ + public async discardBootstrapHandoffAsync(): Promise { + const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + const expectedNonce: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + if (!handoffPath || !expectedNonce || !this._isOwnedHandoffPath(handoffPath)) { + return; + } + + try { + const { header } = await readBootstrapHandoffFileAsync(handoffPath); + if (header?.nonce !== expectedNonce) { + return; + } + } catch { + // Match replay behavior for an unreadable file at an authenticated private path. + } + + await deleteBootstrapHandoffFileAsync(handoffPath); + } + /** * Deletes abandoned handoff files older than the retention window. * diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 4ebca154427..f1a53954a5d 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -300,7 +300,45 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); - expect(result).toMatchObject({ replayed: false, skipReason: 'invalid-event' }); + expect(result).toMatchObject({ replayed: false, skipReason: 'unsupported-required-event' }); + }); + }); +}); + +describe('ReporterHost handoff discard', () => { + it('deletes only the current authenticated handoff', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + + it('does not delete a handoff with a mismatched nonce', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: 'wrong-nonce' + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(true); }); }); }); From 547aceb4d5dda6cce7156adc8ed5f6f5fb1d09e5 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 16:37:55 +0000 Subject: [PATCH 13/34] Fix reporter bootstrap review findings 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 | 107 +++++--- apps/rush/src/RushFrontend.ts | 2 + apps/rush/src/RushReporterHost.ts | 46 +++- .../rush/src/test/RushCommandSelector.test.ts | 233 ++++++++++++++++-- apps/rush/src/test/RushFrontend.test.ts | 4 + apps/rush/src/test/RushReporterHost.test.ts | 130 +++++++++- common/reviews/api/rush-reporter.api.md | 2 +- .../src/compat/OldEngineOutputAdapter.ts | 4 +- .../reporter/src/frontend/ReporterHost.ts | 3 +- .../reporter/src/manager/ReporterManager.ts | 28 ++- .../reporter/src/test/Compatibility.test.ts | 18 +- libraries/reporter/src/test/Manager.test.ts | 25 +- .../reporter/src/test/ReporterHost.test.ts | 30 +++ .../src/scripts/InstallRunRushBootstrap.ts | 46 ++-- .../rush-lib/src/scripts/install-run-rush.ts | 12 +- libraries/rush-lib/src/scripts/install-run.ts | 58 +++-- .../test/InstallRunRushBootstrap.test.ts | 73 +++++- .../scripts/test/InstallRunScripts.test.ts | 153 ++++++++++++ 19 files changed, 859 insertions(+), 116 deletions(-) create mode 100644 libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index d14c1858da6..34753e80af3 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -15,6 +15,7 @@ export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; readonly reporterEnabled: boolean; + readonly reporterStdoutIsMachineReadable?: boolean; readonly reporterSelectionReason: | 'explicit --reporter' | 'repository experiment' diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index f486016b4da..13c92a0df52 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -57,6 +57,7 @@ export class RushCommandSelector { } ); let effectiveOptions: IRushFrontendLaunchOptions = options; + let restoreOldEngineOutput: (() => void) | undefined; if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { if (options.reporterSelectionReason === 'explicit --reporter') { throw new Error( @@ -72,60 +73,92 @@ export class RushCommandSelector { reporterSelectionReason: 'bootstrap compatibility fallback' }; } else if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { - _observeOldEngineOutput(options, Rush.version); + restoreOldEngineOutput = _observeOldEngineOutput(options, Rush.version); } - if (commandName === 'rush-pnpm') { - if (!Rush.launchRushPnpm) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rush-pnpm" command` - ); - } - Rush.launchRushPnpm(launcherVersion, { - isManaged: options.isManaged, - alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError - }); - } else if (commandName === 'rushx') { - if (!Rush.launchRushX) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rushx" command` - ); + try { + if (commandName === 'rush-pnpm') { + if (!Rush.launchRushPnpm) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rush-pnpm" command` + ); + } + Rush.launchRushPnpm(launcherVersion, { + isManaged: options.isManaged, + alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError + }); + } else if (commandName === 'rushx') { + if (!Rush.launchRushX) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rushx" command` + ); + } + Rush.launchRushX(launcherVersion, effectiveOptions); + } else { + Rush.launch(launcherVersion, effectiveOptions); } - Rush.launchRushX(launcherVersion, effectiveOptions); - } else { - Rush.launch(launcherVersion, effectiveOptions); + } catch (error) { + restoreOldEngineOutput?.(); + throw error; } } } -function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): void { +function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): () => void { const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ sink: options.reporterEventSink, sessionId: `rush_old_engine_${process.pid}`, source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } }); - const legacyWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); - _observeStream(process.stdout, 'stdout', adapter, legacyWrite); - _observeStream(process.stderr, 'stderr', adapter, legacyWrite); + const restoreStdout: () => void = _observeStream( + process.stdout, + 'stdout', + adapter, + process.stdout.write.bind(process.stdout), + options.reporterStdoutIsMachineReadable !== true + ); + const restoreStderr: () => void = _observeStream( + process.stderr, + 'stderr', + adapter, + process.stderr.write.bind(process.stderr), + true + ); + let restored: boolean = false; + const restore: () => void = () => { + if (restored) { + return; + } + restored = true; + process.removeListener('beforeExit', restore); + process.removeListener('exit', restore); + restoreStdout(); + restoreStderr(); + }; + process.once('beforeExit', restore); + process.once('exit', restore); + return restore; } function _observeStream( stream: NodeJS.WriteStream, streamName: 'stdout' | 'stderr', adapter: OldEngineOutputAdapter, - legacyWrite: typeof process.stderr.write -): void { + legacyWrite: typeof process.stdout.write, + renderLive: boolean +): () => void { const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; if (markedStream[marker]) { - return; + return () => {}; } markedStream[marker] = true; const decoder: StringDecoder = new StringDecoder('utf8'); + const originalWrite: typeof stream.write = stream.write; stream.write = (( chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), @@ -136,13 +169,29 @@ function _observeStream( ? chunk : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); if (text) { - adapter.capture(streamName, text); + adapter.capture(streamName, text, renderLive); + } + if (!renderLive) { + const writeCallback: ((error?: Error | null) => void) | undefined = + typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + if (writeCallback) { + process.nextTick(writeCallback); + } + return true; } if (typeof encodingOrCallback === 'function') { return legacyWrite(chunk, encodingOrCallback); } return legacyWrite(chunk, encodingOrCallback, callback); }) as typeof stream.write; + return () => { + const remaining: string = decoder.end(); + if (remaining) { + adapter.capture(streamName, remaining, renderLive); + } + stream.write = originalWrite; + delete markedStream[marker]; + }; } function _failWithError(message: string): never { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 4c9dc4618dd..35268a1a7fc 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -165,6 +165,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr }, reporterCloseAsync, reporterEnabled: reporterHost.selection.enabled, + reporterStdoutIsMachineReadable: + reporterHost.selection.reporter === 'ai' || reporterHost.selection.reporter === 'json', reporterSelectionReason: reporterHost.selection.reason }; diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 2944e364fb7..64f1554e8a1 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -122,6 +122,40 @@ class LogLevelReporter implements IReporter { } } +class VisibleBootstrapOutputFilterReporter 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 { + const payload: { readonly wasRendered?: unknown } | undefined = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as { readonly wasRendered?: unknown }) + : undefined; + if (event.type === 'externalOutput' && payload?.wasRendered === true) { + return; + } + 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; @@ -639,9 +673,15 @@ 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), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' - }); + const filteredReporter: IReporter = new LogLevelReporter(primaryReporter, selection.logLevel); + host.manager.addReporter( + selection.reporter === 'default' || selection.reporter === 'plaintext' + ? new VisibleBootstrapOutputFilterReporter(filteredReporter) + : filteredReporter, + { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + } + ); } const hasExplicitFileOutput: boolean = selection.outputs.some( diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index 1db9f261068..aaecd41ced8 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -26,8 +26,29 @@ class RecordingReporter implements IReporter { public async closeAsync(): Promise {} } +type BeforeExitListener = (code: number) => void; + +function restoreObservedOutput( + previousBeforeExitListeners: readonly BeforeExitListener[], + required: boolean = true +): void { + const currentListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + const restoreListener: BeforeExitListener | undefined = currentListeners.find( + (listener: BeforeExitListener) => !previousBeforeExitListeners.includes(listener) + ); + if (!restoreListener) { + if (required) { + throw new Error('Expected an old-engine output restoration listener.'); + } + return; + } + restoreListener(0); +} + describe(RushCommandSelector.name, () => { - it('keeps old-engine legacy output visible while bridging it to the frontend host', async () => { + it('keeps ordered old-engine stdout and stderr on their original streams', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter(); manager.addReporter(reporter); @@ -36,19 +57,27 @@ describe(RushCommandSelector.name, () => { const originalArgv: string[] = process.argv; const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; const originalStderrWrite: typeof process.stderr.write = process.stderr.write; - const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); - const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = - process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; - let visibleOutput: string = ''; + let stdoutText: string = ''; + let stderrText: string = ''; process.argv = ['node', 'rush', 'build']; - process.stderr.write = ((text: string): boolean => { - visibleOutput += text; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = ((text: string): boolean => { + stderrText += text; return true; }) as typeof process.stderr.write; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: manager, + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -56,29 +85,147 @@ describe(RushCommandSelector.name, () => { Rush: { version: '5.177.0', launch: () => { - process.stdout.write('legacy engine output\n'); + process.stdout.write('stdout 1\n'); + process.stderr.write('stderr 1\n'); + process.stdout.write('stdout 2\n'); } } } as unknown as typeof import('@microsoft/rush-lib'); try { RushCommandSelector.execute('5.178.1', oldRushLib, options); + expect(process.stdout.write).not.toBe(stdoutWrite); + expect(process.stderr.write).not.toBe(stderrWrite); + restoreObservedOutput(previousBeforeExitListeners); await manager.flushAsync(); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); } finally { + restoreObservedOutput(previousBeforeExitListeners, false); process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; - delete markedStdout[marker]; - delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ - Symbol.for('rush.reporter.old-engine-output.stderr') - ]; process.argv = originalArgv; } - expect(visibleOutput).toBe('legacy engine output\n'); - expect(reporter.events).toHaveLength(1); - expect(reporter.events[0]).toMatchObject({ - type: 'externalOutput', - payload: { stream: 'stdout', text: 'legacy engine output\n' } + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + + it('captures asynchronous old-engine output until the process lifecycle completes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + setImmediate(() => { + process.stdout.write('async stdout\n'); + process.stderr.write('async stderr\n'); + }); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: manager, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + await new Promise((resolve) => setImmediate(resolve)); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'async stdout\n', wasRendered: true }, + { stream: 'stderr', text: 'async stderr\n', wasRendered: true } + ]); + }); + + it('keeps old-engine stdout structured for machine reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy stdout\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: manager, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterStdoutIsMachineReadable: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe(''); + expect(reporter.events[0].payload).toEqual({ + stream: 'stdout', + text: 'legacy stdout\n' }); }); @@ -97,6 +244,9 @@ describe(RushCommandSelector.name, () => { process.argv = ['node', 'rush', 'build']; process.stdout.write = (() => true) as typeof process.stdout.write; process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; const oldRushLib = { Rush: { @@ -112,11 +262,14 @@ describe(RushCommandSelector.name, () => { RushCommandSelector.execute('5.178.1', oldRushLib, { isManaged: true, reporterEventSink: manager, + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }); + restoreObservedOutput(previousBeforeExitListeners); await manager.flushAsync(); } finally { + restoreObservedOutput(previousBeforeExitListeners, false); process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; delete markedStdout[marker]; @@ -127,13 +280,54 @@ describe(RushCommandSelector.name, () => { } expect(reporter.events).toHaveLength(1); - expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€' }); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€', wasRendered: true }); + }); + + it('restores old-engine stream writers when launch throws', () => { + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + + try { + expect(() => + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + throw new Error('launch failed'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ) + ).toThrow('launch failed'); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } }); it('fails an explicit reporter request for an incompatible new engine protocol', () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -154,6 +348,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -175,6 +370,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' }; @@ -202,6 +398,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 1ee58d32b16..dd5aee4d218 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -70,6 +70,8 @@ async function createEnabledHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -107,6 +109,8 @@ async function createPhaseHangingHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fe9d32531cc..b90aac496dd 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -74,11 +74,9 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'explicit --reporter' }); - expect(resolve(['build'], { RUSH_REPORTER: 'json' })).toMatchObject({ - reporter: 'legacy', - enabled: false, - reason: 'pre-major legacy default' - }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); }); it('uses deterministic non-agent selection for the repository experiment', () => { @@ -282,6 +280,9 @@ describe(resolveRushReporterSelection.name, () => { enabled: false, reason: 'pre-major legacy default' }); + expect( + resolve(['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid']) + ).toMatchObject({ reporter: 'json', enabled: true }); }); it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { @@ -505,6 +506,125 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('does not replay live bootstrap output to the same visible destination', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: [ + 'build', + '--reporter=plaintext', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'old-engine-session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.0' }, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }); + await initialized.host.manager.closeAsync(); + + expect(stdoutText).toBe(''); + expect( + (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }), + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }) + ]); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('retains bootstrap stdout and stderr records in the primary JSON stream', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'captured stdout\n' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--log-level=debug'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect( + stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line).payload) + ).toEqual([ + { stream: 'stdout', text: 'captured stdout\n' }, + { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + ]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const env: Record = {}; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 3865d31aefc..14a1ec55f14 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1246,7 +1246,7 @@ export function normalizeAnsi(text: string): string; // @beta export class OldEngineOutputAdapter { constructor(options: IOldEngineOutputAdapterOptions); - capture(stream: 'stdout' | 'stderr', text: string): string[]; + capture(stream: 'stdout' | 'stderr', text: string, wasRendered?: boolean): string[]; } // @beta diff --git a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts index 06d6e3ecee1..fddfa856d51 100644 --- a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts +++ b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts @@ -74,7 +74,7 @@ export class OldEngineOutputAdapter { * @param stream - the originating stream * @param text - the raw output text */ - public capture(stream: 'stdout' | 'stderr', text: string): string[] { + public capture(stream: 'stdout' | 'stderr', text: string, wasRendered: boolean = true): string[] { const eventIds: string[] = []; for (const chunk of chunkUtf8Text(text, this._maxChunkBytes)) { eventIds.push( @@ -84,7 +84,7 @@ export class OldEngineOutputAdapter { source: this._source, privacy: 'local-sensitive', type: 'externalOutput', - payload: { stream, text: chunk } + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } }) ); } diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 7f1c4fa19c5..1c8c85faa7f 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -178,7 +178,8 @@ function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOu if ( event.type === 'externalOutput' && (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && - typeof event.payload.text === 'string' + typeof event.payload.text === 'string' && + event.payload.wasRendered !== true ) { output.push({ stream: event.payload.stream, text: event.payload.text }); } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 657e88c17a6..b9aba350aef 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -352,10 +352,14 @@ export class ReporterManager implements IReporterEventSink { entry.queue.push(envelope); } - if (!entry.draining) { - entry.draining = true; - entry.drainPromise = this._drainEntryAsync(entry); + if (entry.draining) { + if (!this._isCoalescibleStatusEvent(envelope)) { + this._drainQueuedEventsSynchronously(entry); + } + return; } + entry.draining = true; + entry.drainPromise = this._drainEntryAsync(entry); } private async _drainEntryAsync(entry: IReporterEntry): Promise { @@ -367,8 +371,11 @@ export class ReporterManager implements IReporterEventSink { entry.queue.length = 0; break; } - // Yield so producers and coalescing can interleave with delivery. - await Promise.resolve(); + // Only replaceable status updates need to yield for coalescing. Protected + // events are delivered synchronously so a hard process exit cannot strand them. + if (this._isCoalescibleStatusEvent(envelope)) { + await Promise.resolve(); + } } } finally { entry.draining = false; @@ -383,6 +390,17 @@ export class ReporterManager implements IReporterEventSink { } } + private _drainQueuedEventsSynchronously(entry: IReporterEntry): void { + while (entry.queue.length > 0) { + const envelope: IReporterEventEnvelope = entry.queue.shift()!; + this._deliverEnvelope(entry, envelope); + if (entry.disabled) { + entry.queue.length = 0; + break; + } + } + } + private _handleReporterFailure(entry: IReporterEntry, error: Error): void { if (entry.required) { if (!this._fatalError) { diff --git a/libraries/reporter/src/test/Compatibility.test.ts b/libraries/reporter/src/test/Compatibility.test.ts index 971e8dcffc2..cdb14abdb37 100644 --- a/libraries/reporter/src/test/Compatibility.test.ts +++ b/libraries/reporter/src/test/Compatibility.test.ts @@ -139,7 +139,8 @@ describe('OldEngineOutputAdapter', () => { expect(event.privacy).toBe('local-sensitive'); expect(event.payload).toEqual({ stream: 'stdout', - text: 'Building project-a...\nproject-a done.\n' + text: 'Building project-a...\nproject-a done.\n', + wasRendered: true }); }); @@ -175,14 +176,13 @@ describe('OldEngineOutputAdapter', () => { }); it('rejects a chunk limit smaller than one UTF-8 code point', () => { - expect( - () => - new OldEngineOutputAdapter({ - sink: new ReporterManager(), - sessionId: 'sess', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, - maxChunkBytes: 1 - }).capture('stdout', '😀') + expect(() => + new OldEngineOutputAdapter({ + sink: new ReporterManager(), + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, + maxChunkBytes: 1 + }).capture('stdout', '😀') ).toThrow(/at least 4/); }); }); diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 582f88a8c23..9da2ac3034f 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -108,6 +108,24 @@ describe('ReporterManager ordering and assignment', () => { expect(reporter.reported[0].timestamp).toBe('2026-01-01T00:00:00.000Z'); }); + it('delivers protected events synchronously so hard exits cannot strand output', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + manager.emit(makeInput('activityChanged', { text: 'status' })); + manager.emit(makeInput('externalOutput', { text: 'first' })); + manager.emit(makeInput('externalOutput', { text: 'second' })); + + expect(reporter.reported.map((event: IReporterEventEnvelope) => event.payload)).toEqual([ + { text: 'status' }, + { text: 'first' }, + { text: 'second' } + ]); + expect(manager.getPendingEventCount()).toBe(0); + }); + it('derives the required flag from the event type, ignoring producer input', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter('a'); @@ -152,9 +170,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/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index f1a53954a5d..ec2d8379ea3 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -209,6 +209,36 @@ describe('ReporterHost handoff replay', () => { }); }); + it('does not duplicate already-rendered output during legacy fallback', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'live output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); + + const manager: ReporterManager = new ReporterManager(); + await manager.initializeAsync(); + const host: ReporterHost = new ReporterHost({ + manager, + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + + expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toBeUndefined(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + it('replays a valid prefix before a malformed trailing record', async () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index fc86bc729e1..533d0f024e1 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -72,7 +72,10 @@ export interface IInstallRunRushBootstrap { readonly enabled: boolean; readonly logger: ILogger; readonly externalOutputCaptureMaxBytes: number | undefined; - readonly externalOutputHandler: ((stream: BootstrapStream, text: string) => void) | undefined; + readonly externalOutputHandler: + | ((stream: BootstrapStream, text: string, wasRendered: boolean) => void) + | undefined; + readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined; readonly externalOutputOverflowHandler: (() => void) | undefined; readonly prepareToRun: (() => void) | undefined; } @@ -82,6 +85,9 @@ function readSingleFlagValue(argv: readonly string[], flag: string): string | un const prefix: string = `${flag}=`; for (let index: number = 0; index < argv.length; index++) { const argument: string = argv[index]; + if (argument === '--') { + break; + } let value: string | undefined; if (argument.startsWith(prefix)) { value = argument.slice(prefix.length); @@ -273,7 +279,12 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { public readonly enabled: boolean = true; public readonly logger: ILogger; public readonly externalOutputCaptureMaxBytes: number; - public readonly externalOutputHandler: (stream: BootstrapStream, text: string) => void; + public readonly externalOutputHandler: ( + stream: BootstrapStream, + text: string, + wasRendered: boolean + ) => void; + public readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }>; public readonly externalOutputOverflowHandler: () => void; public readonly prepareToRun: () => void; @@ -288,6 +299,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { private readonly _sessionId: string; private readonly _sourceVersion: string; private readonly _entryLimit: number; + private readonly _fallbackStdoutStream: BootstrapStream; private _usedBytes: number; private _nextSequence: number; private _nextEventNumber: number; @@ -295,7 +307,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { private _droppedRequired: number; private _failureFlushed: boolean; - public constructor(options: IInstallRunRushBootstrapOptions) { + public constructor(options: IInstallRunRushBootstrapOptions, liveStdout: boolean) { this._entries = []; this._env = options.env; this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); @@ -307,6 +319,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; this._sourceVersion = options.bootstrapVersion; this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + this._fallbackStdoutStream = liveStdout ? 'stdout' : 'stderr'; if (this._entryLimit <= 0) { throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); } @@ -317,15 +330,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { this._droppedRequired = 0; this._failureFlushed = false; this.externalOutputCaptureMaxBytes = this._maxBytes; + this.externalOutputLiveStreams = { stdout: liveStdout, stderr: true }; this._addEvent({ type: 'sessionStarted', privacy: 'public', - payload: { rushVersion: options.rushVersion, cwd: process.cwd() } - }); - this._addEvent({ - type: 'commandStarted', - privacy: 'public', - payload: { commandName: options.argv[0] ?? 'unknown', argv: options.argv } + payload: { rushVersion: options.rushVersion } }); this.logger = { @@ -336,20 +345,20 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { privacy: 'public', payload: { kind: 'bootstrap', text } }, - { stream: 'stdout', text: `${text}\n` } + { stream: this._fallbackStdoutStream, text: `${text}\n` } ); }, error: (text: string) => { const droppedRequiredBefore: number = this._droppedRequired; - this._addExternalOutput('stderr', `${text}\n`); + this._addExternalOutput('stderr', `${text}\n`, false); this._flushFailureOutput(); if (this._droppedRequired > droppedRequiredBefore) { this._stderr(`${text}\n`); } } }; - this.externalOutputHandler = (stream: BootstrapStream, text: string) => { - this._addExternalOutput(stream, text); + this.externalOutputHandler = (stream: BootstrapStream, text: string, wasRendered: boolean) => { + this._addExternalOutput(stream, text, wasRendered); }; this.externalOutputOverflowHandler = () => { this._droppedRequired++; @@ -406,7 +415,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { } } - private _addExternalOutput(stream: BootstrapStream, text: string): void { + private _addExternalOutput(stream: BootstrapStream, text: string, wasRendered: boolean): void { if (!text) { return; } @@ -415,9 +424,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { { type: 'externalOutput', privacy: 'local-sensitive', - payload: { stream, text: chunk } + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } }, - { stream, text: chunk } + wasRendered + ? undefined + : { stream: stream === 'stdout' ? this._fallbackStdoutStream : stream, text: chunk } ); } } @@ -509,6 +520,7 @@ function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInsta }, externalOutputHandler: undefined, externalOutputCaptureMaxBytes: undefined, + externalOutputLiveStreams: undefined, externalOutputOverflowHandler: undefined, prepareToRun: undefined }; @@ -568,5 +580,5 @@ export function createInstallRunRushBootstrap( return createLegacyBootstrap(options); } - return new InstallRunRushBootstrap(options); + return new InstallRunRushBootstrap(options, explicitReporter !== 'json' && explicitReporter !== 'ai'); } diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index d0fd5eb5edb..ff6d7fbf09a 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -67,8 +67,6 @@ function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { } function _run(): void { - _validateBundledBootstrapProtocol(); - const [ nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, @@ -89,7 +87,9 @@ function _run(): void { let quiet: boolean = quietModeEnvValue === '1' || quietModeEnvValue === 'true'; for (const arg of packageBinArgs) { - if (arg === '-q' || arg === '--quiet') { + if (arg === '--') { + break; + } else if (arg === '-q' || arg === '--quiet') { // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress // any normal informational/diagnostic information printed during startup. // @@ -116,11 +116,12 @@ function _run(): void { process.exit(1); } - const rushJsonFolder: string = findRushJsonFolder(); - const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); let bootstrap: IInstallRunRushBootstrap | undefined; process.exitCode = 1; try { + _validateBundledBootstrapProtocol(); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); bootstrap = createInstallRunRushBootstrap({ argv: packageBinArgs, env: process.env, @@ -154,6 +155,7 @@ function _run(): void { onExternalOutput: bootstrap.externalOutputHandler, onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + externalOutputLiveStreams: bootstrap.externalOutputLiveStreams, prepareToRun: bootstrap.prepareToRun } ); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 84e350938a0..1b22990f11e 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -22,11 +22,19 @@ const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; let _externalOutputCaptureId: number = 0; -const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +export const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` const childProcess = require('node:child_process'); const fs = require('node:fs'); const { StringDecoder } = require('node:string_decoder'); -const [command, argsJson, capturePath, useShell, maxBytesText] = process.argv.slice(1); +const [ + command, + argsJson, + capturePath, + useShell, + maxBytesText, + renderStdoutText, + renderStderrText +] = process.argv.slice(1); const child = childProcess.spawn(command, JSON.parse(argsJson), { cwd: process.cwd(), env: process.env, @@ -38,11 +46,12 @@ const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder( const maxBytes = Number(maxBytesText); let capturedBytes = 0; let overflowed = false; +const renderedStreams = { stdout: renderStdoutText === '1', stderr: renderStderrText === '1' }; function capture(stream, text) { if (!text || overflowed) { return; } - const record = JSON.stringify({ stream, text }) + '\\n'; + const record = JSON.stringify({ stream, text, wasRendered: renderedStreams[stream] }) + '\\n'; const recordBytes = Buffer.byteLength(record); if (capturedBytes + recordBytes <= maxBytes) { fs.appendFileSync(capturePath, record); @@ -52,8 +61,14 @@ function capture(stream, text) { fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); } } -child.stdout.on('data', (chunk) => capture('stdout', decoders.stdout.write(chunk))); -child.stderr.on('data', (chunk) => capture('stderr', decoders.stderr.write(chunk))); +function forwardAndCapture(stream, chunk) { + if (renderedStreams[stream]) { + (stream === 'stdout' ? process.stdout : process.stderr).write(chunk); + } + capture(stream, decoders[stream].write(chunk)); +} +child.stdout.on('data', (chunk) => forwardAndCapture('stdout', chunk)); +child.stderr.on('data', (chunk) => forwardAndCapture('stderr', chunk)); child.on('error', (error) => { process.stderr.write(String(error) + '\\n'); process.exitCode = 1; @@ -71,9 +86,10 @@ child.on('close', (code, signal) => { `; export interface IInstallAndRunOptions { - readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string) => void; + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void; readonly onExternalOutputOverflow?: () => void; readonly externalOutputCaptureMaxBytes?: number; + readonly externalOutputLiveStreams?: Readonly<{ stdout: boolean; stderr: boolean }>; readonly prepareToRun?: () => void; } @@ -409,9 +425,10 @@ function _installPackage( name: string, version: string, npmCommand: 'install' | 'ci', - onExternalOutput: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined, + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void) | undefined, onExternalOutputOverflow: (() => void) | undefined, - externalOutputCaptureMaxBytes: number | undefined + externalOutputCaptureMaxBytes: number | undefined, + externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined ): void { let capturePath: string | undefined; try { @@ -433,6 +450,7 @@ function _installPackage( }, capturePath, externalOutputCaptureMaxBytes ?? 1024 * 1024, + externalOutputLiveStreams ?? { stdout: true, stderr: true }, `npm ${npmCommand}` ); } else { @@ -462,7 +480,7 @@ function _installPackage( function _readCapturedNpmOutput( capturePath: string, - onExternalOutput: (stream: 'stdout' | 'stderr', text: string) => void, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, onExternalOutputOverflow: (() => void) | undefined ): void { const fileDescriptor: number = fs.openSync(capturePath, 'r'); @@ -481,14 +499,19 @@ function _readCapturedNpmOutput( const line: string = pending.slice(0, newlineIndex); pending = pending.slice(newlineIndex + 1); if (line) { - const record: { stream?: unknown; text?: unknown; overflow?: unknown } = JSON.parse(line); + const record: { + stream?: unknown; + text?: unknown; + wasRendered?: unknown; + overflow?: unknown; + } = JSON.parse(line); if (record.overflow === true) { onExternalOutputOverflow?.(); } else if ( (record.stream === 'stdout' || record.stream === 'stderr') && typeof record.text === 'string' ) { - onExternalOutput(record.stream, record.text); + onExternalOutput(record.stream, record.text, record.wasRendered === true); } } } @@ -562,6 +585,7 @@ function _runNpmWithCaptureConfirmSuccess( options: childProcess.SpawnSyncOptions, capturePath: string, captureMaxBytes: number, + liveStreams: Readonly<{ stdout: boolean; stderr: boolean }>, commandNameForLogging: string ): childProcess.SpawnSyncReturns { const npmPath: string = getNpmPath(); @@ -576,7 +600,9 @@ function _runNpmWithCaptureConfirmSuccess( JSON.stringify(commandArgs), capturePath, IS_WINDOWS ? '1' : '0', - String(captureMaxBytes) + String(captureMaxBytes), + liveStreams.stdout ? '1' : '0', + liveStreams.stderr ? '1' : '0' ], options ); @@ -647,12 +673,16 @@ export function installAndRun( installCommand, options.onExternalOutput, options.onExternalOutputOverflow, - options.externalOutputCaptureMaxBytes + options.externalOutputCaptureMaxBytes, + options.externalOutputLiveStreams ); _writeFlagFile(packageInstallFolder); } - const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const invocation: string = options.onExternalOutput + ? packageBinName + : `${packageBinName} ${packageBinArgs.join(' ')}`; + const statusMessage: string = `Invoking "${invocation}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); options.prepareToRun?.(); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index 443d63a14c7..b4fa2d9b735 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -103,30 +103,79 @@ describe(createInstallRunRushBootstrap.name, () => { const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); bootstrap.logger.info('resolving Rush'); - bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n', false); bootstrap.logger.info('invoking Rush'); bootstrap.prepareToRun?.(); const handoff = readHandoff(env); expect(bootstrap.enabled).toBe(true); + expect(bootstrap.externalOutputLiveStreams).toEqual({ stdout: false, stderr: true }); expect(stdout).toEqual([]); expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( (handoff.records[0] as { nonce?: string }).nonce ); expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ 'sessionStarted', - 'commandStarted', 'activityChanged', 'externalOutput', 'activityChanged' ]); - expect((handoff.records[4].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + expect((handoff.records[3].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + expect((handoff.records[3].payload as { wasRendered?: boolean }).wasRendered).toBeUndefined(); if (process.platform !== 'win32') { expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); } }); }); + it('does not publish the working directory or full argv as public bootstrap data', async () => { + await withTempDir(async (directory: string) => { + const secretArgument: string = '--token=bootstrap-secret-value'; + const secretCwd: string = path.join(directory, 'secret-worktree-name'); + const cwdSpy: jest.SpyInstance = jest.spyOn(process, 'cwd').mockReturnValue(secretCwd); + try { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json', secretArgument] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const serialized: string = fs.readFileSync(handoff.path, 'utf8'); + expect(serialized).not.toContain(secretArgument); + expect(serialized).not.toContain(secretCwd); + expect(handoff.records[1]).toMatchObject({ + privacy: 'public', + type: 'sessionStarted', + payload: { rushVersion: '5.178.1' } + }); + expect(handoff.records).toHaveLength(2); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + + it('stops parsing reporter controls at the pass-through separator', async () => { + await withTempDir(async (directory: string) => { + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(false); + + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(true); + }); + }); + it('uses repository opt-in but safely falls back for an old frontend', async () => { await withTempDir(async (directory: string) => { const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); @@ -239,13 +288,29 @@ describe(createInstallRunRushBootstrap.name, () => { maxBytes: 800 }); const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); - bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000)); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000), true); expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); bootstrap.logger.error('bootstrap failed'); expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); expect(stderr.join('')).toContain('bootstrap failed'); + expect(stderr.join('')).not.toContain('xxx'); + }); + }); + + it('keeps machine-reporter failure fallback off stdout', async () => { + await withTempDir(async (directory: string) => { + const { options, stdout, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('installing Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm stdout\n', false); + bootstrap.logger.error('bootstrap failed'); + + expect(stdout).toEqual([]); + expect(stderr.join('')).toBe('installing Rush\nnpm stdout\nbootstrap failed\n'); }); }); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts new file mode 100644 index 00000000000..c3ea0071b48 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-script-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +describe('install-run script integration', () => { + it('tees npm output live to the matching streams while capturing ordered records once', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const childScript: string = [ + "process.stdout.write('stdout 1\\n');", + "setTimeout(() => process.stderr.write('stderr 1\\n'), 25);", + "setTimeout(() => process.stdout.write('stdout 2\\n'), 50);" + ].join(''); + const wrapper: childProcess.ChildProcessWithoutNullStreams = childProcess.spawn( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify(['-e', childScript]), + capturePath, + '0', + String(1024 * 1024), + '1', + '1' + ], + { cwd: directory } + ); + + let stdoutText: string = ''; + let stderrText: string = ''; + let sawLiveOutputBeforeClose: boolean = false; + let closed: boolean = false; + wrapper.stdout.on('data', (chunk: Buffer) => { + stdoutText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + wrapper.stderr.on('data', (chunk: Buffer) => { + stderrText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + wrapper.on('error', reject); + wrapper.on('close', (code: number | null) => { + closed = true; + resolve(code); + }); + }); + + expect(exitCode).toBe(0); + expect(sawLiveOutputBeforeClose).toBe(true); + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + }); + + it('keeps machine-reporter stdout structured while stderr remains live', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const wrapper: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify([ + '-e', + "process.stdout.write('stdout\\n'); setTimeout(() => process.stderr.write('stderr\\n'), 25);" + ]), + capturePath, + '0', + String(1024 * 1024), + '0', + '1' + ], + { cwd: directory, encoding: 'utf8' } + ); + + expect(wrapper.status).toBe(0); + expect(wrapper.stdout).toBe(''); + expect(wrapper.stderr).toBe('stderr\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout\n', wasRendered: false }, + { stream: 'stderr', text: 'stderr\n', wasRendered: true } + ]); + }); + }); + + it('reports missing and invalid rush.json errors without an unhandled stack', async () => { + await withTempDir(async (directory: string) => { + const builtScriptPath: string = path.resolve(__dirname, '../../../dist/scripts/install-run-rush.js'); + const scriptPath: string = path.join(directory, 'install-run-rush.js'); + await fs.promises.copyFile(builtScriptPath, scriptPath); + await fs.promises.copyFile( + path.resolve(__dirname, '../../../dist/scripts/install-run.js'), + path.join(directory, 'install-run.js') + ); + + const missingResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(missingResult.status).toBe(1); + expect(missingResult.stderr).toContain('Error: Unable to find rush.json.'); + expect(missingResult.stderr).not.toMatch(/\n\s+at /); + + await fs.promises.writeFile(path.join(directory, 'rush.json'), '{ "rushVersion": false }\n'); + const invalidResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(invalidResult.status).toBe(1); + expect(invalidResult.stderr).toContain( + 'Error: Unable to determine the required version of Rush from rush.json' + ); + expect(invalidResult.stderr).not.toMatch(/\n\s+at /); + }); + }); +}); From 2af714b489cdaaa012f79a605054d72c1e837e94 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 17:05:35 +0000 Subject: [PATCH 14/34] Fix bootstrap diagnostic privacy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/scripts/InstallRunRushBootstrap.ts | 9 +- .../rush-lib/src/scripts/install-run-rush.ts | 10 ++- libraries/rush-lib/src/scripts/install-run.ts | 80 ++++++++++++++---- .../test/InstallRunRushBootstrap.test.ts | 83 +++++++++++++++++++ .../scripts/test/InstallRunScripts.test.ts | 71 +++++++++++++++- .../rush-lib/src/utilities/npmrcUtilities.ts | 17 ++-- .../src/utilities/test/npmrcUtilities.test.ts | 33 ++++++++ 7 files changed, 275 insertions(+), 28 deletions(-) diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index 533d0f024e1..4eb2f832b9f 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { ILogger } from '../utilities/npmrcUtilities'; +import type { ILogger, LogPrivacyClassification } from '../utilities/npmrcUtilities'; import { BOOTSTRAP_BUFFER_MAX_BYTES, BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, @@ -338,11 +338,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { }); this.logger = { - info: (text: string) => { + info: (text: string, privacy: LogPrivacyClassification = 'public') => { this._addEvent( { type: 'activityChanged', - privacy: 'public', + privacy, payload: { kind: 'bootstrap', text } }, { stream: this._fallbackStdoutStream, text: `${text}\n` } @@ -355,6 +355,9 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { if (this._droppedRequired > droppedRequiredBefore) { this._stderr(`${text}\n`); } + }, + warning: (text: string) => { + this._stderr(`${text}\n`); } }; this.externalOutputHandler = (stream: BootstrapStream, text: string, wasRendered: boolean) => { diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index ff6d7fbf09a..3416cd583cc 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -140,7 +140,8 @@ function _run(): void { const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { logger.info( - `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.` + `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' ); } @@ -162,7 +163,12 @@ function _run(): void { } catch (error) { const logger: ILogger = bootstrap?.logger ?? - (quiet ? { info: () => {}, error: console.error } : { info: console.log, error: console.error }); + (quiet + ? { info: () => {}, error: (text: string) => console.error(text) } + : { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }); logger.error(`\n\n${String(error)}\n`); } } diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 1b22990f11e..47fb3c57568 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -468,20 +468,64 @@ function _installPackage( throw new Error(`Unable to install package: ${e}`); } finally { if (capturePath !== undefined) { - try { - _readCapturedNpmOutput(capturePath, onExternalOutput!, onExternalOutputOverflow); - } finally { - _deleteFile(capturePath); - } + finalizeCapturedNpmOutput(capturePath, logger, onExternalOutput!, onExternalOutputOverflow); } } logger.info(`Successfully installed ${name}@${version}`); } -function _readCapturedNpmOutput( +function _reportCaptureDamage(logger: ILogger, capturePath: string, detail: string): void { + const message: string = `Warning: npm output capture ${JSON.stringify(capturePath)} ${detail}`; + try { + if (logger.warning) { + logger.warning(message, 'local-sensitive'); + } else { + logger.error(message, 'local-sensitive'); + } + } catch { + try { + process.stderr.write(`${message}\n`); + } catch { + // Capture diagnostics are best-effort and must not change the install result. + } + } +} + +export function finalizeCapturedNpmOutput( capturePath: string, + logger: ILogger, onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, onExternalOutputOverflow: (() => void) | undefined +): void { + let firstDamageDetail: string | undefined; + let damageCount: number = 0; + try { + _readCapturedNpmOutput(capturePath, onExternalOutput, onExternalOutputOverflow, (detail: string) => { + firstDamageDetail ??= detail; + damageCount++; + }); + } catch (error) { + firstDamageDetail ??= `could not be read: ${String(error)}.`; + damageCount++; + } + if (firstDamageDetail) { + const additionalDamage: string = + damageCount > 1 ? ` ${damageCount - 1} additional capture issue(s) were discarded.` : ''; + _reportCaptureDamage(logger, capturePath, `${firstDamageDetail}${additionalDamage}`); + } + + try { + _deleteFile(capturePath); + } catch (error) { + _reportCaptureDamage(logger, capturePath, `could not be deleted: ${String(error)}.`); + } +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, + onExternalOutputOverflow: (() => void) | undefined, + onCaptureDamage: (detail: string) => void ): void { const fileDescriptor: number = fs.openSync(capturePath, 'r'); const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); @@ -499,12 +543,18 @@ function _readCapturedNpmOutput( const line: string = pending.slice(0, newlineIndex); pending = pending.slice(newlineIndex + 1); if (line) { - const record: { + let record: { stream?: unknown; text?: unknown; wasRendered?: unknown; overflow?: unknown; - } = JSON.parse(line); + }; + try { + record = JSON.parse(line); + } catch (error) { + onCaptureDamage(`contains a corrupt record that was discarded: ${String(error)}.`); + continue; + } if (record.overflow === true) { onExternalOutputOverflow?.(); } else if ( @@ -512,18 +562,15 @@ function _readCapturedNpmOutput( typeof record.text === 'string' ) { onExternalOutput(record.stream, record.text, record.wasRendered === true); + } else { + onCaptureDamage('contains an invalid record that was discarded.'); } } } } pending += decoder.end(); if (pending.trim()) { - const record: { overflow?: unknown } = JSON.parse(pending); - if (record.overflow === true) { - onExternalOutputOverflow?.(); - } else { - throw new Error('The npm output capture ended with an incomplete record.'); - } + onCaptureDamage('ended with a partial record that was discarded.'); } } finally { fs.closeSync(fileDescriptor); @@ -762,7 +809,10 @@ function _run(): void { process.exit(1); } - const logger: ILogger = { info: console.log, error: console.error }; + const logger: ILogger = { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }; runWithErrorAndStatusCode(logger, () => { const rushJsonFolder: string = findRushJsonFolder(); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index b4fa2d9b735..800fd0b8123 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { syncNpmrc } from '../../utilities/npmrcUtilities'; import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap, @@ -156,6 +157,68 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it('classifies path-bearing installation activity as local-sensitive', async () => { + await withTempDir(async (directory: string) => { + const sourceFolder: string = path.join(directory, 'sentinel-source-npmrc'); + const targetFolder: string = path.join(directory, 'sentinel-target-npmrc'); + const lockFilePath: string = path.join(directory, 'sentinel-lockfile', 'package-lock.json'); + await fs.promises.mkdir(sourceFolder, { recursive: true }); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('Installing @microsoft/rush...'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.logger.info( + `Found INSTALL_RUN_RUSH_LOCKFILE_PATH="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' + ); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.prepareToRun?.(); + + const events: Record[] = readHandoff(env).records.slice(1); + const activityEvents: Record[] = events.filter( + (event: Record) => event.type === 'activityChanged' + ); + expect( + activityEvents.find( + (event: Record) => + (event.payload as { text?: string }).text === 'Installing @microsoft/rush...' + ) + ).toMatchObject({ privacy: 'public' }); + + for (const sentinelPath of [sourceFolder, targetFolder, lockFilePath]) { + const matchingEvents: Record[] = activityEvents.filter( + (event: Record) => + (event.payload as { text?: string }).text?.includes(sentinelPath) === true + ); + expect(matchingEvents.length).toBeGreaterThan(0); + expect( + matchingEvents.every((event: Record) => event.privacy === 'local-sensitive') + ).toBe(true); + } + expect( + activityEvents + .filter((event: Record) => event.privacy === 'public') + .map((event: Record) => (event.payload as { text?: string }).text) + .join('\n') + ).not.toContain(directory); + }); + }); + it('stops parsing reporter controls at the pass-through separator', async () => { await withTempDir(async (directory: string) => { expect( @@ -314,6 +377,26 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it('keeps capture-damage warnings outside required handoff accounting', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1200 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 20; index++) { + bootstrap.logger.warning?.( + `Warning: npm output capture "${path.join(directory, `capture-${index}.ndjson`)}" was corrupt.`, + 'local-sensitive' + ); + } + + expect(() => bootstrap.prepareToRun?.()).not.toThrow(); + expect(readHandoff(env).records).toHaveLength(2); + expect(stderr).toHaveLength(20); + }); + }); + it('fails when the npm capture reports overflow before replay', async () => { await withTempDir(async (directory: string) => { const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts index c3ea0071b48..2b3e65e6995 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts @@ -6,7 +6,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; +import type { ILogger, LogPrivacyClassification } from '../../utilities/npmrcUtilities'; +import { finalizeCapturedNpmOutput, NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; async function withTempDir(action: (directory: string) => Promise): Promise { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-script-test-')); @@ -118,6 +119,74 @@ describe('install-run script integration', () => { }); }); + it('discards a partial capture record without failing a successful install', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'partial-capture.ndjson'); + await fs.promises.writeFile( + capturePath, + `${JSON.stringify({ stream: 'stdout', text: 'complete\n', wasRendered: true })}\n` + + '{"stream":"stderr","text":"partial' + ); + const output: Array<{ stream: 'stdout' | 'stderr'; text: string; wasRendered: boolean }> = []; + const warnings: Array<{ text: string; privacy: LogPrivacyClassification | undefined }> = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string, privacy?: LogPrivacyClassification) => { + warnings.push({ text, privacy }); + } + }; + const overflow: jest.Mock = jest.fn(); + + expect(() => + finalizeCapturedNpmOutput( + capturePath, + logger, + (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => { + output.push({ stream, text, wasRendered }); + }, + overflow + ) + ).not.toThrow(); + + expect(output).toEqual([{ stream: 'stdout', text: 'complete\n', wasRendered: true }]); + expect(overflow).not.toHaveBeenCalled(); + expect(warnings).toEqual([ + { + text: expect.stringContaining('ended with a partial record that was discarded'), + privacy: 'local-sensitive' + } + ]); + expect(fs.existsSync(capturePath)).toBe(false); + }); + }); + + it('never replaces the npm failure when capture finalization is damaged', async () => { + await withTempDir(async (directory: string) => { + const npmError: Error = new Error('npm install failed'); + const warnings: string[] = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string) => warnings.push(text) + }; + let caught: unknown; + try { + try { + throw npmError; + } finally { + finalizeCapturedNpmOutput(directory, logger, () => {}, undefined); + } + } catch (error) { + caught = error; + } + + expect(caught).toBe(npmError); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.join('\n')).toContain('could not be'); + }); + }); + it('reports missing and invalid rush.json errors without an unhandled stack', async () => { await withTempDir(async (directory: string) => { const builtScriptPath: string = path.resolve(__dirname, '../../../dist/scripts/install-run-rush.js'); diff --git a/libraries/rush-lib/src/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 6544dd7268f..e2d2dd9ba9e 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -6,9 +6,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +export type LogPrivacyClassification = 'public' | 'local-sensitive'; + export interface ILogger { - info: (string: string) => void; - error: (string: string) => void; + info: (text: string, privacy?: LogPrivacyClassification) => void; + error: (text: string, privacy?: LogPrivacyClassification) => void; + warning?: (text: string, privacy?: LogPrivacyClassification) => void; } /** @@ -590,8 +593,8 @@ interface INpmrcTrimOptions { function _copyAndTrimNpmrcFile(options: INpmrcTrimOptions): string { const { logger, sourceNpmrcPath, targetNpmrcPath } = options; - logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose - logger.info(` --> "${targetNpmrcPath}"`); + logger.info(`Transforming ${sourceNpmrcPath}`, 'local-sensitive'); // Verbose + logger.info(` --> "${targetNpmrcPath}"`, 'local-sensitive'); const combinedNpmrc: string = _trimNpmrcFile(options); @@ -643,9 +646,9 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { useNpmrcPublish, logger = { // eslint-disable-next-line no-console - info: console.log, + info: (text: string) => console.log(text), // eslint-disable-next-line no-console - error: console.error + error: (text: string) => console.error(text) }, createIfMissing = false } = options; @@ -669,7 +672,7 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { }); } else if (fs.existsSync(targetNpmrcPath)) { // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target - logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + logger.info(`Deleting ${targetNpmrcPath}`, 'local-sensitive'); // Verbose fs.unlinkSync(targetNpmrcPath); } } catch (e) { diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index 0ee88363c88..a3ca0697efa 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,10 +1,43 @@ // 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 { FileSystem } from '@rushstack/node-core-library'; + import { getNpmrcEnvironmentVariables, syncNpmrc, trimNpmrcFileLines } from '../npmrcUtilities'; describe('npmrcUtilities', () => { + it('does not print privacy metadata through the default console logger', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'npmrc-logger-test-')); + const sourceFolder: string = path.join(directory, 'source'); + const targetFolder: string = path.join(directory, 'target'); + const logSpy: jest.SpyInstance = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + await fs.promises.mkdir(sourceFolder); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + + expect(logSpy.mock.calls.every((call: unknown[]) => call.length === 1)).toBe(true); + expect(logSpy.mock.calls.flat().join('\n')).not.toContain('local-sensitive'); + } finally { + logSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + describe(trimNpmrcFileLines.name, () => { it('collects project settings with environment variables that PNPM ignores', () => { const environmentVariableSettingNames: Set = new Set(); From 2210b6f591d78576f41b91676ac2ed4af02affbd Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 17:18:33 +0000 Subject: [PATCH 15/34] Route legacy bootstrap warnings to stderr Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/scripts/InstallRunRushBootstrap.ts | 8 ++++-- .../test/InstallRunRushBootstrap.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index 4eb2f832b9f..c1dc3eb09e6 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -513,13 +513,17 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + const warning: (text: string) => void = (text: string) => stderr(`${text}\n`); return { enabled: false, + // Legacy mode cannot create npm captures because it exposes no external output handler. + // Keep warning routing available so future diagnostic finalization remains stderr-only. logger: options.quiet - ? { info: () => {}, error: (text: string) => stderr(`${text}\n`) } + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`), warning } : { info: (text: string) => stdout(`${text}\n`), - error: (text: string) => stderr(`${text}\n`) + error: (text: string) => stderr(`${text}\n`), + warning }, externalOutputHandler: undefined, externalOutputCaptureMaxBytes: undefined, diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index 800fd0b8123..3dfa051b0a9 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -16,6 +16,7 @@ import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '../generated/BootstrapProtocol'; +import { finalizeCapturedNpmOutput } from '../install-run'; async function withTempDir(action: (directory: string) => Promise): Promise { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); @@ -96,6 +97,31 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it.each([false, true])( + 'routes future capture warnings to stderr in legacy mode when quiet is %s', + async (quiet: boolean) => { + await withTempDir(async (directory: string) => { + const { options, env, stdout, stderr } = makeOptions(directory, { quiet }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + const capturePath: string = path.join(directory, 'legacy-partial-capture.ndjson'); + await fs.promises.writeFile(capturePath, '{"stream":"stdout","text":"partial'); + + expect(bootstrap.enabled).toBe(false); + expect(bootstrap.externalOutputHandler).toBeUndefined(); + expect(() => + finalizeCapturedNpmOutput(capturePath, bootstrap.logger, () => {}, undefined) + ).not.toThrow(); + + expect(stderr).toHaveLength(1); + expect(stderr[0]).toContain('ended with a partial record that was discarded'); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(fs.existsSync(capturePath)).toBe(false); + }); + } + ); + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { await withTempDir(async (directory: string) => { const { options, env, stdout } = makeOptions(directory, { From aa4fa987c9b4fa2953ca2b834b241fb1aa15e648 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:27:27 +0000 Subject: [PATCH 16/34] Fix reporter engine compatibility routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushCommandSelector.ts | 19 ++- apps/rush/src/RushVersionSelector.ts | 7 +- .../rush/src/test/RushCommandSelector.test.ts | 125 ++++++++++++++++-- .../src/reporters/PlaintextReporter.ts | 9 +- .../src/test/PlaintextReporter.test.ts | 9 ++ libraries/rush-lib/src/api/Rush.ts | 5 + 6 files changed, 154 insertions(+), 20 deletions(-) diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 13c92a0df52..8ecee361a30 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -68,7 +68,10 @@ export class RushCommandSelector { } effectiveOptions = { ...options, - reporterEventSink: new LegacyFallbackSink(), + reporter: { + ...options.reporter, + eventSink: new LegacyFallbackSink() + }, reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }; @@ -108,8 +111,8 @@ export class RushCommandSelector { function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): () => void { const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ - sink: options.reporterEventSink, - sessionId: `rush_old_engine_${process.pid}`, + sink: options.reporter.eventSink, + sessionId: options.reporter.sessionId, source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } }); const restoreStdout: () => void = _observeStream( @@ -157,6 +160,7 @@ function _observeStream( } markedStream[marker] = true; + let captureInProgress: boolean = false; const decoder: StringDecoder = new StringDecoder('utf8'); const originalWrite: typeof stream.write = stream.write; stream.write = (( @@ -168,8 +172,13 @@ function _observeStream( typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - if (text) { - adapter.capture(streamName, text, renderLive); + if (text && !captureInProgress) { + captureInProgress = true; + try { + adapter.capture(streamName, text, renderLive); + } finally { + captureInProgress = false; + } } if (!renderLive) { const writeCallback: ((error?: Error | null) => void) | undefined = diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 077152d5444..258b4a5c111 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; +import { REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; @@ -110,9 +111,9 @@ export class RushVersionSelector { private _reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { if (options.reporterEnabled) { - options.reporterEventSink.emit({ - protocolVersion: { major: 1, minor: 0 }, - sessionId: `rush_frontend_${process.pid}`, + options.reporter.eventSink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId: options.reporter.sessionId, source: { packageName: '@microsoft/rush', packageVersion: this._currentPackageVersion }, privacy: 'public', type: 'activityChanged', diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index aaecd41ced8..9d33bb842be 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -4,9 +4,11 @@ import { LegacyFallbackSink, ReporterManager, + REPORTER_PROTOCOL_VERSION, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; +import { Rush } from '@microsoft/rush-lib'; import { RushCommandSelector } from '../RushCommandSelector'; import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; @@ -26,6 +28,22 @@ class RecordingReporter implements IReporter { public async closeAsync(): Promise {} } +class WritingReporter implements IReporter { + public readonly name: string = 'writing'; + public reportCount: number = 0; + + public async initializeAsync(): Promise {} + + public report(): void { + this.reportCount++; + process.stdout.write('reporter output\n'); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + type BeforeExitListener = (code: number) => void; function restoreObservedOutput( @@ -48,6 +66,91 @@ function restoreObservedOutput( } describe(RushCommandSelector.name, () => { + it('publishes the current engine reporter protocol major', () => { + expect((Rush as typeof Rush & { readonly _reporterProtocolMajor?: number })._reporterProtocolMajor).toBe( + REPORTER_PROTOCOL_VERSION.major + ); + }); + + it('does not observe output from a matching structured engine', () => { + const manager: ReporterManager = new ReporterManager(); + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const currentRushLib = { + Rush: { + version: '5.178.1', + _reporterProtocolMajor: REPORTER_PROTOCOL_VERSION.major, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', currentRushLib, options); + + expect(process.stdout.write).toBe(originalStdoutWrite); + expect(receivedOptions?.reporter).toBe(options.reporter); + }); + + it('does not recapture reporter output while observing an old engine', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: WritingReporter = new WritingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => process.stdout.write('legacy output\n') + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.reportCount).toBe(1); + expect(stdoutText.match(/reporter output/g)).toHaveLength(1); + expect(stdoutText.match(/legacy output/g)).toHaveLength(1); + }); + it('keeps ordered old-engine stdout and stderr on their original streams', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter(); @@ -76,7 +179,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, - reporterEventSink: manager, + reporter: { eventSink: manager, sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -150,7 +253,7 @@ describe(RushCommandSelector.name, () => { } as unknown as typeof import('@microsoft/rush-lib'), { isManaged: true, - reporterEventSink: manager, + reporter: { eventSink: manager, sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -206,7 +309,7 @@ describe(RushCommandSelector.name, () => { } as unknown as typeof import('@microsoft/rush-lib'), { isManaged: true, - reporterEventSink: manager, + reporter: { eventSink: manager, sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterStdoutIsMachineReadable: true, @@ -261,7 +364,7 @@ describe(RushCommandSelector.name, () => { try { RushCommandSelector.execute('5.178.1', oldRushLib, { isManaged: true, - reporterEventSink: manager, + reporter: { eventSink: manager, sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -307,7 +410,7 @@ describe(RushCommandSelector.name, () => { } as unknown as typeof import('@microsoft/rush-lib'), { isManaged: true, - reporterEventSink: new ReporterManager(), + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -326,7 +429,7 @@ describe(RushCommandSelector.name, () => { it('fails an explicit reporter request for an incompatible new engine protocol', () => { const options: IRushFrontendLaunchOptions = { isManaged: true, - reporterEventSink: new ReporterManager(), + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -347,7 +450,7 @@ describe(RushCommandSelector.name, () => { it('fails an explicit reporter request for an incompatible older engine protocol', () => { const options: IRushFrontendLaunchOptions = { isManaged: true, - reporterEventSink: new ReporterManager(), + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' @@ -369,7 +472,7 @@ describe(RushCommandSelector.name, () => { let receivedOptions: IRushFrontendLaunchOptions | undefined; const options: IRushFrontendLaunchOptions = { isManaged: true, - reporterEventSink: new ReporterManager(), + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' @@ -390,14 +493,14 @@ describe(RushCommandSelector.name, () => { reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }); - expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); }); it('falls back to legacy engine rendering for an implicit older protocol', () => { let receivedOptions: IRushFrontendLaunchOptions | undefined; const options: IRushFrontendLaunchOptions = { isManaged: true, - reporterEventSink: new ReporterManager(), + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' @@ -418,6 +521,6 @@ describe(RushCommandSelector.name, () => { reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }); - expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); }); }); diff --git a/libraries/reporter/src/reporters/PlaintextReporter.ts b/libraries/reporter/src/reporters/PlaintextReporter.ts index 0508f15bcce..e3cae1183f0 100644 --- a/libraries/reporter/src/reporters/PlaintextReporter.ts +++ b/libraries/reporter/src/reporters/PlaintextReporter.ts @@ -217,7 +217,14 @@ export class PlaintextReporter implements IReporter { return; } const operationId: string | undefined = event.scope?.operationId; - const text: string = (event.payload as { text?: string }).text ?? ''; + const payload: { text?: string; wasRendered?: boolean } = event.payload as { + text?: string; + wasRendered?: boolean; + }; + if (payload.wasRendered === true) { + return; + } + const text: string = payload.text ?? ''; const record: IOperationRecord | undefined = operationId !== undefined ? this._operations.get(operationId) : undefined; if (record) { diff --git a/libraries/reporter/src/test/PlaintextReporter.test.ts b/libraries/reporter/src/test/PlaintextReporter.test.ts index aa97c7e474a..62e43c795bb 100644 --- a/libraries/reporter/src/test/PlaintextReporter.test.ts +++ b/libraries/reporter/src/test/PlaintextReporter.test.ts @@ -83,6 +83,15 @@ describe('PlaintextReporter', () => { expect(capture.getOutput()).toMatchSnapshot(); }); + it('does not replay old-engine output that was already rendered', () => { + const capture: ICapture = makeDetailed(); + capture.reporter.report( + ev('externalOutput', { stream: 'stdout', text: 'already rendered\n', wasRendered: true }) + ); + + expect(capture.getOutput()).toBe(''); + }); + it('preserves partial-line chunks within grouped output', () => { const capture: ICapture = makeDetailed(); capture.reporter.report( diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index e75815484d6..cdd58fd4937 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { InternalError, type IPackageJson, PackageJsonLookup } from '@rushstack/node-core-library'; +import { REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import '../utilities/SetRushLibPath'; @@ -174,6 +175,10 @@ export class Rush { */ } +Object.defineProperty(Rush, '_reporterProtocolMajor', { + value: REPORTER_PROTOCOL_VERSION.major +}); + function _ensureOwnPackageJsonIsLoaded(): void { if (!_rushLibPackageJsonCache) { const packageJsonFilePath: string | undefined = From 3b005ee1581124740a76e6df2bdb233fba51be0b Mon Sep 17 00:00:00 2001 From: selarkin Date: Mon, 7 Sep 2026 01:18:53 +0000 Subject: [PATCH 17/34] Isolate reporter configuration test fixtures from shared cleanup Follow up #5987 without changing configuration behavior; concurrent flag-file tests empty api/test/temp. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../rush-lib/src/api/test/ExperimentsConfiguration.test.ts | 2 +- .../rush-lib/src/api/test/RushConfigurationReporting.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts index dfe9526cab8..7768eafa26f 100644 --- a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts @@ -7,7 +7,7 @@ import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { ExperimentsConfiguration } from '../ExperimentsConfiguration'; -const TEMP_FOLDER: string = path.join(__dirname, 'temp', ExperimentsConfiguration.name); +const TEMP_FOLDER: string = path.join(__dirname, `temp-${ExperimentsConfiguration.name}`); const EXPERIMENTS_JSON_PATH: string = path.join(TEMP_FOLDER, 'experiments.json'); describe(ExperimentsConfiguration.name, () => { diff --git a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts index a188189023c..1509baec266 100644 --- a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts @@ -8,7 +8,7 @@ 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 TEMP_FOLDER: string = path.join(__dirname, 'temp-RushConfigurationReporting'); const RUSH_JSON_PATH: string = path.join(TEMP_FOLDER, 'rush.json'); function writeRushJson(reporting?: unknown): void { From 7e9a1cd996c4edbb5593b06a5e95d6e2b16414f9 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:10:57 +0000 Subject: [PATCH 18/34] 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 43908fec59a542c375ed60cf0ca8bf578ae8551a Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:44:04 +0000 Subject: [PATCH 19/34] 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 b0056eedac4778ecb796b43e4218a8a6bb0046c6 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 14:57:22 +0000 Subject: [PATCH 20/34] 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 c3dc2c1ac9e16f7c92e4417c24991293eefeb64d Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:19:26 +0000 Subject: [PATCH 21/34] 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 352aa37e494733d0d9cfc43b0a01305481bac88f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:39:13 +0000 Subject: [PATCH 22/34] 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 034f22dde86e51e78c9f3fe83f5973e1f1efb48d Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:48:23 +0000 Subject: [PATCH 23/34] 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 2c1d78645c212b4f4c1c4128395b6e6db720e04b Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:06:05 +0000 Subject: [PATCH 24/34] 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 c8637e5e981cf4857b669d2ccedc2f6220fc0e61 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:06:20 +0000 Subject: [PATCH 25/34] 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 52c50f8faa236d664a1bbafc3049d2fc15ffa295 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 19:50:18 +0000 Subject: [PATCH 26/34] 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 5bba19cf627472a11c5c766ca75531f5f3bd1293 Mon Sep 17 00:00:00 2001 From: selarkin Date: Mon, 7 Sep 2026 18:33:49 +0000 Subject: [PATCH 27/34] Add the missing reporter change note for R2B JSON controls The newly rerun CI for #5989 exposed the existing reporter-package change without its release note. Describe the already-implemented pass-through separator behavior; no runtime code or gate changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../reporter-r2b-json-controls_2026-09-07.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json diff --git a/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json new file mode 100644 index 00000000000..5336c4b756f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Honor the pass-through separator when distinguishing command JSON from reporter JSON controls.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} From e3bf1e724f18e834be3b7bfb064247bfc1290e27 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:06:20 +0000 Subject: [PATCH 28/34] 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 d57e66b96f00a6cdc72e46c128593503b02cdcdb Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:06:20 +0000 Subject: [PATCH 29/34] 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 34402c38024..928ffa7350d 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 0f503ea1577..a8b0af670ab 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": "36a63ea0a120d7f9fd7bba3e57f734059b5177e2", + "pnpmShrinkwrapHash": "50a1f3c8d2270f840d49426b54c028e26de05189", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "ee803d13f0fb0ae994024d4dc646d2def4cc1f0f" + "packageJsonInjectedDependenciesHash": "af9e972a5d86601391889a0ff0ae8349679a6a10" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 36fecb4bcb5..a9c01ddb734 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 f365b9870b2..47357a51c24 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 3b8b2c9f758a223b1e5260b5ef51a85a14aa7f50 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 15:34:53 +0000 Subject: [PATCH 30/34] Adapt reporter bootstrap cleanup onto the corrected R6 parent Replay a48ce82416c7e3e6711763feffaf3efa8f1c6d4b while preserving corrected foundation stream ownership tests and limiting README changes to the six-line R6 cleanup paragraph. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/RushReporterHost.ts | 11 +++ apps/rush/src/test/RushReporterHost.test.ts | 99 +++++++++++++++++++ .../review-r6-cleanup_2026-09-09-13-00.json | 10 ++ .../review-r6-cleanup_2026-09-09-13-00.json | 10 ++ common/reviews/api/rush-reporter.api.md | 2 + libraries/reporter/README.md | 6 ++ .../reporter/src/frontend/ReporterHost.ts | 69 +++++++++++-- .../reporter/src/manager/ReporterManager.ts | 39 ++++++++ libraries/reporter/src/test/Manager.test.ts | 51 ++++++++++ .../reporter/src/test/ReporterHost.test.ts | 95 +++++++++++++++++- 10 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json create mode 100644 common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 9450d20f2de..e11cee9548b 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -781,6 +781,17 @@ export async function initializeRushReporterHostAsync( return closePromise; } }; + } catch (error) { + const [disposal]: PromiseSettledResult[] = await Promise.allSettled([ + host.manager._disposeInitializedReportersAsync() + ]); + if (disposal.status === 'rejected') { + // Even a failed emergency write must not replace the original startup failure. + await Promise.allSettled([ + Promise.resolve().then(() => stderr.write(`[reporter] ${String(disposal.reason)}\n`)) + ]); + } + throw error; } finally { if (!handoffReplayAttempted) { await host.discardBootstrapHandoffAsync(); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 77de2c0f60f..63f7da97dd6 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -537,6 +537,73 @@ describe(initializeRushReporterHostAsync.name, () => { } ); + it.each(['incompatible-protocol', 'unsupported-required-event'])( + 'closes initialized output descriptors when rejecting %s', + async (skipReason: string) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-host-disposal-')); + const outputPath: string = path.join(directory, 'output.ndjson'); + let outputDescriptor: number | undefined; + const filesystem: typeof fs = jest.requireActual('node:fs'); + const originalOpen: typeof fs.openSync = filesystem.openSync; + const openSpy: jest.SpyInstance = jest + .spyOn(filesystem, 'openSync') + .mockImplementation((filePath, flags, mode) => { + const descriptor: number = originalOpen(filePath, flags, mode); + if (filePath === outputPath) { + outputDescriptor = descriptor; + } + return descriptor; + }); + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const event: Record = JSON.parse(lines[1]); + if (skipReason === 'incompatible-protocol') { + event.protocolVersion = { major: 99, minor: 0 }; + } else { + event.type = 'futureRequiredEvent'; + event.required = true; + } + lines[1] = JSON.stringify(event); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + const env: Record = { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + env, + handoffDirectory: directory, + includeDefaultFileReporter: false, + stdout: { isTTY: false, write: () => undefined }, + stderr: { write: () => undefined } + }) + ).rejects.toThrow(/bootstrap reporter/); + expect(outputDescriptor).toBeDefined(); + expect(() => fs.fstatSync(outputDescriptor!)).toThrow(expect.objectContaining({ code: 'EBADF' })); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env).toEqual({}); + } finally { + openSpy.mockRestore(); + if (outputDescriptor !== undefined) { + try { + fs.closeSync(outputDescriptor); + } catch (error) { + expect(error).toMatchObject({ code: 'EBADF' }); + } + } + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + it('writes ./stdout to a file without conflicting with the primary stdout reporter', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-path-')); let stdoutText: string = ''; @@ -558,6 +625,38 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('preserves the initialization error after cleanup and emergency reporting fail', async () => { + const originalError: Error = new Error('original initialization failure'); + const manager: ReporterManager = new ReporterManager(); + const close: jest.Mock = jest.fn(async () => { + throw new Error('cleanup failure'); + }); + manager.addReporter({ + name: 'partially-initialized', + initializeAsync: async () => { + throw originalError; + }, + report: () => undefined, + flushAsync: async () => undefined, + closeAsync: close + }); + + await expect( + initializeRushReporterHostAsync({ + argv: [], + env: {}, + manager, + includeDefaultFileReporter: false, + stderr: { + write: () => { + throw new Error('emergency output failed'); + } + } + }) + ).rejects.toBe(originalError); + expect(close).toHaveBeenCalledTimes(1); + }); + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { let output: string = ''; const stdout: IRushReporterOutputStream = { diff --git a/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json b/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json new file mode 100644 index 00000000000..526946c83cc --- /dev/null +++ b/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Close initialized reporter destinations when bootstrap host creation fails without replacing the original startup error.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json b/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json new file mode 100644 index 00000000000..0b741f37338 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Dispose attempted reporter initialization safely and bound owned abandoned bootstrap handoffs by age and session count.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 14a1ec55f14..c8113a3489c 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1395,6 +1395,8 @@ export class ReporterManager implements IReporterEventSink { constructor(options?: IReporterManagerOptions); addReporter(reporter: IReporter, options?: IReporterRegistrationOptions): void; closeAsync(timeoutMs?: number): Promise; + // @internal + _disposeInitializedReportersAsync(): Promise; emit(event: IReporterEmitEventInput): string; flushAsync(timeoutMs?: number): Promise; getPendingEventCount(): number; diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index b326abb9f19..ac82d7db3c6 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -4,6 +4,12 @@ Canonical event protocol, reporter manager, and built-in reporters for Rush. This package is released as a public beta. Exported contracts may change before the stable release. +Bootstrap initialization failures close every destination whose initialization was attempted, including +partially initialized reporters, before propagating the original failure. Abandoned handoff cleanup applies +the 14-day retention window and a 20-session cap to files verifiably owned by the current user whose producer +process has exited. Live/current handoffs, foreign files, and entries without verifiable ownership are not +removed; timestamp ties are resolved by filename. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 1c8c85faa7f..61edd08cc3d 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -30,6 +30,17 @@ import { */ export const DEFAULT_HANDOFF_RETENTION_MS: number = 14 * 24 * 60 * 60 * 1000; +const MAX_ABANDONED_HANDOFF_SESSIONS: number = 20; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + /** * Options for constructing a {@link ReporterHost}. * @@ -381,7 +392,8 @@ export class ReporterHost { } /** - * Deletes abandoned handoff files older than the retention window. + * Deletes expired abandoned handoffs and retains at most 20 recent abandoned sessions per user. + * Live processes, the current handoff, and files without verifiable ownership are protected. * * @returns the paths of the deleted files */ @@ -394,22 +406,67 @@ export class ReporterHost { return deleted; } + const uid: number = process.getuid?.() ?? os.userInfo().uid; + if (uid < 0) { + return deleted; + } + const currentHandoff: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; const cutoff: number = this._nowMs() - this._retentionMs; + const abandoned: Array<{ path: string; pid: number; stats: fs.Stats }> = []; for (const fileName of fileNames) { - if (!isBootstrapHandoffFileName(fileName)) { + const match: RegExpExecArray | null = /^rush-reporter-bootstrap-([1-9]\d*)-.+\.ndjson$/.exec(fileName); + if (!match) { continue; } + const pid: number = Number(match[1]); const filePath: string = path.join(this._handoffDirectory, fileName); + if ( + !Number.isSafeInteger(pid) || + pid > 0x7fffffff || + pid === process.pid || + (currentHandoff !== undefined && path.resolve(filePath) === path.resolve(currentHandoff)) + ) { + continue; + } try { - const stats: fs.Stats = await fs.promises.stat(filePath); - if (stats.mtimeMs < cutoff) { - await fs.promises.rm(filePath, { force: true }); - deleted.push(filePath); + const stats: fs.Stats = await fs.promises.lstat(filePath); + if (stats.isFile() && stats.uid === uid && !isProcessAlive(pid)) { + abandoned.push({ path: filePath, pid, stats }); } } catch { // Ignore files that vanish or cannot be inspected. } } + abandoned.sort( + (left, right) => + right.stats.mtimeMs - left.stats.mtimeMs || + (left.path < right.path ? -1 : left.path > right.path ? 1 : 0) + ); + for (const [index, candidate] of abandoned.entries()) { + if (index < MAX_ABANDONED_HANDOFF_SESSIONS && candidate.stats.mtimeMs >= cutoff) { + continue; + } + try { + const stats: fs.Stats = await fs.promises.lstat(candidate.path); + if ( + stats.isFile() && + stats.uid === uid && + stats.dev === candidate.stats.dev && + stats.ino === candidate.stats.ino && + stats.mtimeMs === candidate.stats.mtimeMs && + !isProcessAlive(candidate.pid) + ) { + await fs.promises.unlink(candidate.path); + deleted.push(candidate.path); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + process.stderr.write( + `[reporter] Unable to remove an abandoned bootstrap handoff: ${String(error)}\n` + ); + } + } + } return deleted; } diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index b9aba350aef..8e1538d1a71 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -89,6 +89,7 @@ interface IReporterEntry { readonly reporter: IReporter; readonly destination: string | undefined; readonly required: boolean; + initializationStarted: boolean; disabled: boolean; failureNotified: boolean; readonly queue: IReporterEventEnvelope[]; @@ -121,6 +122,7 @@ export class ReporterManager implements IReporterEventSink { private _nextEventId: number; private _initialized: boolean; private _fatalError: Error | undefined; + private _disposalPromise: Promise | undefined; public constructor(options: IReporterManagerOptions = {}) { const { @@ -169,6 +171,7 @@ export class ReporterManager implements IReporterEventSink { reporter, destination, required: options.required ?? false, + initializationStarted: false, disabled: false, failureNotified: false, queue: [], @@ -191,11 +194,47 @@ export class ReporterManager implements IReporterEventSink { protocolVersion: this._protocolVersion, destination: entry.destination }; + entry.initializationStarted = true; await entry.reporter.initializeAsync(context); } this._initialized = true; } + /** + * Joins cleanup of every attempted initialization, including a partially initialized reporter. + * + * @internal + */ + public _disposeInitializedReportersAsync(): Promise { + this._disposalPromise ??= (async () => { + const results: PromiseSettledResult[] = await Promise.allSettled( + this._entries + .filter((entry: IReporterEntry) => entry.initializationStarted) + .map(async (entry: IReporterEntry): Promise => { + await entry.lifecyclePromise; + await entry.drainPromise; + try { + if (this._initialized && !entry.disabled) { + await entry.reporter.flushAsync(); + } + } finally { + await entry.reporter.closeAsync(); + } + }) + ); + const errors: unknown[] = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result: PromiseRejectedResult) => result.reason); + if (errors.length > 0) { + throw new AggregateError( + errors, + `Reporter initialization cleanup failed: ${errors.map((error: unknown) => String(error)).join('; ')}` + ); + } + })(); + return this._disposalPromise; + } + /** * Publishes an in-process event, assigning its `eventId`, `sequence`, and * `timestamp`, and returns the assigned `eventId`. diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 9da2ac3034f..eea98086673 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -66,6 +66,57 @@ function makeInput( } describe('ReporterManager ordering and assignment', () => { + it('disposes every attempted initialization once without closing unstarted reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const first: RecordingReporter = new RecordingReporter('first'); + const failed: RecordingReporter = new RecordingReporter('failed'); + const unstarted: RecordingReporter = new RecordingReporter('unstarted'); + failed.throwOnInit = true; + first.throwOnClose = true; + manager.addReporter(first); + manager.addReporter(failed); + manager.addReporter(unstarted); + + await expect(manager.initializeAsync()).rejects.toThrow('init failed failed'); + const disposal: Promise = manager._disposeInitializedReportersAsync(); + expect(manager._disposeInitializedReportersAsync()).toBe(disposal); + await expect(disposal).rejects.toThrow('close failed first'); + expect([first.closeCount, failed.closeCount, unstarted.closeCount]).toEqual([1, 1, 0]); + expect([first.flushCount, failed.flushCount, unstarted.flushCount]).toEqual([0, 0, 0]); + }); + + it('joins other destination cleanup after one close rejects', async () => { + const manager: ReporterManager = new ReporterManager(); + const first: RecordingReporter = new RecordingReporter('first'); + first.throwOnClose = true; + const second: RecordingReporter = new RecordingReporter('second'); + let releaseClose!: () => void; + let notifyCloseStarted!: () => void; + const closeStarted: Promise = new Promise((resolve) => (notifyCloseStarted = resolve)); + const closeFinished: Promise = new Promise((resolve) => (releaseClose = resolve)); + second.closeAsync = async () => { + notifyCloseStarted(); + await closeFinished; + second.closeCount++; + }; + manager.addReporter(first); + manager.addReporter(second); + await manager.initializeAsync(); + + let settled: boolean = false; + const disposal: Promise = manager._disposeInitializedReportersAsync(); + const assertion: Promise = expect(disposal).rejects.toThrow('close failed first'); + void disposal.then( + () => (settled = true), + () => (settled = true) + ); + await closeStarted; + expect(settled).toBe(false); + releaseClose(); + await assertion; + expect(second.closeCount).toBe(1); + }); + it('rejects in-process events before reporters are initialized', () => { const manager: ReporterManager = new ReporterManager(); manager.addReporter(new RecordingReporter('a')); diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index ec2d8379ea3..f05beaa4811 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -398,10 +398,26 @@ describe('ReporterHost sink', () => { }); describe('ReporterHost abandoned file cleanup', () => { + const deadPid: number = 99999999; + + beforeEach(() => { + const userInfo: os.UserInfo = os.userInfo(); + jest + .spyOn(jest.requireActual('node:os'), 'userInfo') + .mockReturnValue({ ...userInfo, uid: fs.statSync(os.tmpdir()).uid }); + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('deletes only stale handoff files and leaves other files untouched', async () => { await withTempDir(async (directory: string) => { - const oldFile: string = path.join(directory, 'rush-reporter-bootstrap-1-1000.ndjson'); - const newFile: string = path.join(directory, 'rush-reporter-bootstrap-2-2000.ndjson'); + const oldFile: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-1000.ndjson`); + const newFile: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-2000.ndjson`); const otherFile: string = path.join(directory, 'unrelated.txt'); await fs.promises.writeFile(oldFile, '{}\n'); await fs.promises.writeFile(newFile, '{}\n'); @@ -419,4 +435,79 @@ describe('ReporterHost abandoned file cleanup', () => { expect(fs.existsSync(otherFile)).toBe(true); }); }); + + it('retains 20 recent abandoned sessions with deterministic timestamp ties', async () => { + await withTempDir(async (directory: string) => { + const files: string[] = []; + const timestamp: Date = new Date('2026-09-01T00:00:00Z'); + for (let index: number = 20; index >= 0; index--) { + const filePath: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-${String(index).padStart(3, '0')}.ndjson` + ); + await fs.promises.writeFile(filePath, '{}\n', { mode: 0o600 }); + await fs.promises.utimes(filePath, timestamp, timestamp); + files.push(filePath); + } + const host: ReporterHost = new ReporterHost({ + env: {}, + handoffDirectory: directory, + nowMs: () => Date.parse('2026-09-09T00:00:00Z') + }); + + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([files[0]]); + expect((await fs.promises.readdir(directory)).length).toBe(20); + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([]); + }); + }); + + it('protects live, current, foreign-owned and non-file entries regardless of age', async () => { + await withTempDir(async (directory: string) => { + const livePid: number = 88888888; + const currentHandoff: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-current.ndjson` + ); + const foreign: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-foreign.ndjson`); + const protectedPaths: string[] = [ + currentHandoff, + foreign, + path.join(directory, `rush-reporter-bootstrap-${process.pid}-self.ndjson`), + path.join(directory, `rush-reporter-bootstrap-${livePid}-live.ndjson`) + ]; + const old: Date = new Date('2000-01-01T00:00:00Z'); + for (const filePath of protectedPaths) { + await fs.promises.writeFile(filePath, '{}\n'); + await fs.promises.utimes(filePath, old, old); + } + const directoryEntry: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-directory.ndjson` + ); + await fs.promises.mkdir(directoryEntry); + const originalLstat: typeof fs.promises.lstat = fs.promises.lstat; + jest.spyOn(fs.promises, 'lstat').mockImplementation(async (filePath) => { + const stats: fs.Stats = await originalLstat(filePath); + if (filePath === foreign) { + stats.uid++; + } + return stats; + }); + jest.mocked(process.kill).mockImplementation((pid) => { + if (pid === livePid) { + throw Object.assign(new Error('Not permitted'), { code: 'EPERM' }); + } + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + const host: ReporterHost = new ReporterHost({ + env: { [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: currentHandoff }, + handoffDirectory: directory + }); + + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([]); + for (const filePath of [...protectedPaths, directoryEntry]) { + expect(fs.existsSync(filePath)).toBe(true); + } + }); + }); }); From 01c2d040d584c6a3f7bd62ca368beae7248055d2 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 13:52:21 +0000 Subject: [PATCH 31/34] Keep R6 cleanup regression independent of later host injection options Inject the partial-initialization fixture through ReporterManager's existing initializer so the test replays onto the original R6 slice without importing a later host manager option. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/test/RushReporterHost.test.ts | 43 +++++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 63f7da97dd6..10c741b4a6d 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -627,11 +627,10 @@ describe(initializeRushReporterHostAsync.name, () => { it('preserves the initialization error after cleanup and emergency reporting fail', async () => { const originalError: Error = new Error('original initialization failure'); - const manager: ReporterManager = new ReporterManager(); const close: jest.Mock = jest.fn(async () => { throw new Error('cleanup failure'); }); - manager.addReporter({ + const reporter: IReporter = { name: 'partially-initialized', initializeAsync: async () => { throw originalError; @@ -639,22 +638,32 @@ describe(initializeRushReporterHostAsync.name, () => { report: () => undefined, flushAsync: async () => undefined, closeAsync: close - }); - - await expect( - initializeRushReporterHostAsync({ - argv: [], - env: {}, - manager, - includeDefaultFileReporter: false, - stderr: { - write: () => { - throw new Error('emergency output failed'); + }; + const initialize: typeof ReporterManager.prototype.initializeAsync = + ReporterManager.prototype.initializeAsync; + const initializeSpy: jest.SpiedFunction = jest + .spyOn(ReporterManager.prototype, 'initializeAsync') + .mockImplementation(async function (this: ReporterManager): Promise { + this.addReporter(reporter); + await initialize.call(this); + }); + try { + await expect( + initializeRushReporterHostAsync({ + argv: [], + env: {}, + includeDefaultFileReporter: false, + stderr: { + write: () => { + throw new Error('emergency output failed'); + } } - } - }) - ).rejects.toBe(originalError); - expect(close).toHaveBeenCalledTimes(1); + }) + ).rejects.toBe(originalError); + expect(close).toHaveBeenCalledTimes(1); + } finally { + initializeSpy.mockRestore(); + } }); it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { From 3c1a8e504e8b83f47d8ec9aab13cb67cfb469720 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 15:36:15 +0000 Subject: [PATCH 32/34] Adapt shared reporter close state to the original R6 API Replay cad7bfdd3a4296f647718844d72b8583413c1313 without introducing the later _flushAndConfirmAsync API or expanding the six-line R6 README addition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...view-r6-shared-close_2026-09-09-14-10.json | 10 ++++ .../reporter/src/manager/ReporterManager.ts | 30 +++++++--- libraries/reporter/src/test/Manager.test.ts | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json diff --git a/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json new file mode 100644 index 00000000000..be5c9af4f28 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Share once-only reporter close operations across manager shutdown and initialization disposal, including failed closes and lifecycle errors.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 8e1538d1a71..7d5771cfd27 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -96,6 +96,7 @@ interface IReporterEntry { draining: boolean; drainPromise: Promise; lifecyclePromise: Promise; + closePromise: Promise | undefined; } /** @@ -177,7 +178,8 @@ export class ReporterManager implements IReporterEventSink { queue: [], draining: false, drainPromise: Promise.resolve(), - lifecyclePromise: Promise.resolve() + lifecyclePromise: Promise.resolve(), + closePromise: undefined }); } @@ -211,14 +213,14 @@ export class ReporterManager implements IReporterEventSink { this._entries .filter((entry: IReporterEntry) => entry.initializationStarted) .map(async (entry: IReporterEntry): Promise => { - await entry.lifecyclePromise; - await entry.drainPromise; try { - if (this._initialized && !entry.disabled) { + await entry.lifecyclePromise; + await entry.drainPromise; + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } } finally { - await entry.reporter.closeAsync(); + await this._closeEntryAsync(entry); } }) ); @@ -305,7 +307,7 @@ export class ReporterManager implements IReporterEventSink { public async flushAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { await this._settleAsync(async (entry: IReporterEntry): Promise => { await entry.drainPromise; - if (!entry.disabled) { + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } }, timeoutMs); @@ -324,7 +326,7 @@ export class ReporterManager implements IReporterEventSink { public async signalFlushAsync(timeoutMs: number = DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS): Promise { await this._settleAsync(async (entry: IReporterEntry): Promise => { await entry.drainPromise; - if (!entry.disabled) { + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } }, timeoutMs); @@ -343,7 +345,7 @@ export class ReporterManager implements IReporterEventSink { flushError = error as Error; } await this._settleAsync(async (entry: IReporterEntry): Promise => { - await entry.reporter.closeAsync(); + await this._closeEntryAsync(entry); }, timeoutMs); if (flushError) { throw flushError; @@ -353,6 +355,18 @@ export class ReporterManager implements IReporterEventSink { } } + private _canFlushEntry(entry: IReporterEntry): boolean { + return this._initialized && !entry.disabled && entry.closePromise === undefined; + } + + private _closeEntryAsync(entry: IReporterEntry): Promise { + if (!entry.initializationStarted) { + return Promise.resolve(); + } + entry.closePromise ??= Promise.resolve().then(() => entry.reporter.closeAsync()); + return entry.closePromise; + } + private _fanOut(envelope: IReporterEventEnvelope): void { for (const entry of this._entries) { if (!entry.disabled) { diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index eea98086673..73178c45ef4 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -66,6 +66,65 @@ function makeInput( } describe('ReporterManager ordering and assignment', () => { + it('shares one close operation between concurrent shutdown and initialization disposal', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('shared-close'); + let notifyCloseStarted!: () => void; + let finishClose!: () => void; + const closeStarted: Promise = new Promise((resolve) => (notifyCloseStarted = resolve)); + const closeFinished: Promise = new Promise((resolve) => (finishClose = resolve)); + jest.spyOn(reporter, 'closeAsync').mockImplementation(async () => { + reporter.closeCount++; + notifyCloseStarted(); + await closeFinished; + }); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const closing: Promise = manager.closeAsync(); + const disposing: Promise = manager._disposeInitializedReportersAsync(); + await closeStarted; + expect(reporter.closeCount).toBe(1); + finishClose(); + await Promise.all([closing, disposing]); + const flushCount: number = reporter.flushCount; + await manager.flushAsync(); + await manager.closeAsync(); + expect(reporter.closeCount).toBe(1); + expect(reporter.flushCount).toBe(flushCount); + }); + + it('caches a rejected close across normal shutdown and disposal without retrying it', async () => { + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const reporter: RecordingReporter = new RecordingReporter('failed-close'); + reporter.throwOnClose = true; + manager.addReporter(reporter, { required: true }); + await manager.initializeAsync(); + + await expect(manager.closeAsync()).rejects.toThrow('close failed failed-close'); + await expect(manager._disposeInitializedReportersAsync()).rejects.toThrow('close failed failed-close'); + await expect(manager.closeAsync()).rejects.toThrow('close failed failed-close'); + expect(reporter.closeCount).toBe(1); + }); + + it('closes attempted initializations even when a prior lifecycle error reporter rejected', async () => { + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: () => { + throw new Error('emergency writer failed'); + } + }); + const reporter: RecordingReporter = new RecordingReporter('lifecycle-failure'); + reporter.flushAsync = async () => { + throw new Error('flush failed'); + }; + manager.addReporter(reporter); + await manager.initializeAsync(); + + await expect(manager.flushAsync()).rejects.toThrow('emergency writer failed'); + await expect(manager._disposeInitializedReportersAsync()).rejects.toThrow('emergency writer failed'); + expect(reporter.closeCount).toBe(1); + }); + it('disposes every attempted initialization once without closing unstarted reporters', async () => { const manager: ReporterManager = new ReporterManager(); const first: RecordingReporter = new RecordingReporter('first'); From 8527f8f8a0720dcfdb0f8f5b6f73412de4a16d51 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 14:39:47 +0000 Subject: [PATCH 33/34] Serialize initialization disposal on each reporter lifecycle lane Reserve the existing per-entry lifecycle tail for disposal while retaining raw failures for AggregateError reporting. Concurrent normal shutdown now waits behind a blocked disposal flush before sharing the cached close operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...view-r6-shared-close_2026-09-09-14-10.json | 2 +- .../reporter/src/manager/ReporterManager.ts | 24 ++++++++------ libraries/reporter/src/test/Manager.test.ts | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json index be5c9af4f28..b26bd1297c2 100644 --- a/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json +++ b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/rush-reporter", - "comment": "Share once-only reporter close operations across manager shutdown and initialization disposal, including failed closes and lifecycle errors.", + "comment": "Share once-only reporter close operations and serialized lifecycle ordering across manager shutdown and initialization disposal, including failed closes and lifecycle errors.", "type": "patch" } ], diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 7d5771cfd27..d5b56a98dab 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -212,16 +212,22 @@ export class ReporterManager implements IReporterEventSink { const results: PromiseSettledResult[] = await Promise.allSettled( this._entries .filter((entry: IReporterEntry) => entry.initializationStarted) - .map(async (entry: IReporterEntry): Promise => { - try { - await entry.lifecyclePromise; - await entry.drainPromise; - if (this._canFlushEntry(entry)) { - await entry.reporter.flushAsync(); + .map((entry: IReporterEntry): Promise => { + const previousLifecycle: Promise = entry.lifecyclePromise; + const disposal: Promise = (async () => { + try { + await previousLifecycle; + await entry.drainPromise; + if (this._canFlushEntry(entry)) { + await entry.reporter.flushAsync(); + } + } finally { + await this._closeEntryAsync(entry); } - } finally { - await this._closeEntryAsync(entry); - } + })(); + // Reserve the lifecycle lane without swallowing failures needed by the aggregate. + entry.lifecyclePromise = disposal; + return disposal; }) ); const errors: unknown[] = results diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 73178c45ef4..bac94c010d2 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -66,6 +66,38 @@ function makeInput( } describe('ReporterManager ordering and assignment', () => { + it('reserves the disposal lifecycle lane before concurrent shutdown can flush or close', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('blocked-disposal-flush'); + let notifyFlushStarted!: () => void; + let finishFlush!: () => void; + const flushStarted: Promise = new Promise((resolve) => (notifyFlushStarted = resolve)); + const flushFinished: Promise = new Promise((resolve) => (finishFlush = resolve)); + jest.spyOn(reporter, 'flushAsync').mockImplementation(async () => { + reporter.flushCount++; + if (reporter.flushCount === 1) { + notifyFlushStarted(); + await flushFinished; + } + }); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const disposing: Promise = manager._disposeInitializedReportersAsync(); + await flushStarted; + const closing: Promise = manager.closeAsync(); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(reporter.flushCount).toBe(1); + expect(reporter.closeCount).toBe(0); + } finally { + finishFlush(); + await Promise.all([disposing, closing]); + } + expect(reporter.flushCount).toBe(1); + expect(reporter.closeCount).toBe(1); + }); + it('shares one close operation between concurrent shutdown and initialization disposal', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter('shared-close'); From adfc6f8dc7f941575c3626f52bb5f420dfb75d01 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 15:43:52 +0000 Subject: [PATCH 34/34] Complete original R6 cleanup test imports Import the existing reporter types and manager explicitly on the owning R6 slice; no later host API is introduced. Validated frontend, manager, bootstrap, capture and retention suites on the corrected R6 parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/test/RushReporterHost.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 10c741b4a6d..ebbbac3d3bd 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -5,9 +5,10 @@ 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 type { IReporter, IReporterEventSink } from '@rushstack/rush-reporter'; import { BootstrapEventBuffer, + ReporterManager, RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, writeBootstrapHandoffFileAsync