From 5b30e7bf38c5221c81dfb3421002dcb797dce6b2 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:10:57 +0000 Subject: [PATCH 1/6] 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 7b79e36e081..7ca2cdb9c5e 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -147,7 +147,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; @@ -233,10 +233,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({ @@ -446,7 +446,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 732e7048f7f4432a372521eb6866c54f023649fc Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:44:04 +0000 Subject: [PATCH 2/6] 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 709357fced25e118b852f09d6d4864884f780821 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 14:57:22 +0000 Subject: [PATCH 3/6] 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 5f8ef5e9483206df38b421cbc5f5f932a278b4b7 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:19:26 +0000 Subject: [PATCH 4/6] 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 7ca2cdb9c5e..7b79e36e081 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -147,7 +147,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; @@ -233,10 +233,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({ @@ -446,7 +446,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 c732e195b5561840208fa0656731071b7137c5d3 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:39:13 +0000 Subject: [PATCH 5/6] 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 62d9e7af04ca0ae9d176ba07b745322288f1be72 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 15:48:23 +0000 Subject: [PATCH 6/6] 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` +);