From 4684f6ecb722f73d7f0068c4ca558ec52f4d0d61 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 02:52:30 +0000 Subject: [PATCH 01/22] Add Rush reporter repository configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...2a-experiment-config_2026-08-28-02-38.json | 11 +++ common/reviews/api/rush-lib.api.md | 8 ++ .../common/config/rush/experiments.json | 8 +- libraries/rush-lib/assets/rush-init/rush.json | 13 ++++ .../src/api/ExperimentsConfiguration.ts | 6 ++ .../rush-lib/src/api/RushConfiguration.ts | 25 +++++++ .../api/test/ExperimentsConfiguration.test.ts | 55 ++++++++++++++ .../test/RushConfigurationReporting.test.ts | 74 +++++++++++++++++++ libraries/rush-lib/src/index.ts | 6 +- .../src/schemas/experiments.schema.json | 4 + .../rush-lib/src/schemas/rush.schema.json | 16 ++++ 11 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts create mode 100644 libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json new file mode 100644 index 00000000000..2130da2c580 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add repository configuration for opting into and configuring the experimental Rush reporter.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e1839f13f69..37f1ea3ef31 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -499,6 +499,7 @@ export interface IExperimentsJson { usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; usePnpmPreferFrozenLockfileForRushUpdate?: boolean; usePnpmSyncForInjectedDependencies?: boolean; + useRushReporter?: boolean; } // @beta @@ -973,6 +974,11 @@ export interface _IRushProjectJson { operationSettings?: IOperationSettings[]; } +// @beta +export interface IRushReportingConfiguration { + readonly agentEnvironmentVariables: readonly string[]; +} + // @beta (undocumented) export interface IRushSessionOptions { // (undocumented) @@ -1473,6 +1479,8 @@ export class RushConfiguration { get projectsByName(): ReadonlyMap; // @beta get projectsByTag(): ReadonlyMap>; + // @beta + readonly reportingConfiguration: IRushReportingConfiguration; readonly repositoryDefaultBranch: string; get repositoryDefaultFullyQualifiedRemoteBranch(): string; readonly repositoryDefaultRemote: string; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index a8c4c01cb4e..afaf6c9a2d5 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -165,5 +165,11 @@ * registry and proxy settings must likewise be supplied through trusted user, global, CLI, or * environment configuration rather than a project .npmrc. */ - /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true + /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true, + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + /*[LINE "HYPOTHETICAL"]*/ "useRushReporter": true } diff --git a/libraries/rush-lib/assets/rush-init/rush.json b/libraries/rush-lib/assets/rush-init/rush.json index a972877f6d0..4cf8209cc28 100644 --- a/libraries/rush-lib/assets/rush-init/rush.json +++ b/libraries/rush-lib/assets/rush-init/rush.json @@ -316,6 +316,19 @@ */ /*[LINE "HYPOTHETICAL"]*/ "telemetryEnabled": false, + /** + * Configures repository settings used by the experimental Rush reporter system. + */ + /*[BEGIN "HYPOTHETICAL"]*/ + "reporting": { + /** + * Additional environment variable names that identify an agent environment. + * The built-in COPILOT_CLI variable does not need to be listed here. + */ + "agentEnvironmentVariables": ["MY_AGENT_CLI", "ANOTHER_AGENT"] + }, + /*[END "HYPOTHETICAL"]*/ + /** * Allows creation of hotfix changes. This feature is experimental so it is disabled by default. * If this is set, 'rush change' only allows a 'hotfix' change type to be specified. This change type diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 658671e14c9..4c179069759 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -177,6 +177,12 @@ export interface IExperimentsJson { * through trusted user, global, CLI, or environment configuration rather than a project `.npmrc`. */ provideNpmrcCredentialsViaEnvironment?: boolean; + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + useRushReporter?: boolean; } const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 86ae18f6778..210fdd01510 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -154,6 +154,21 @@ export interface IRushVariantOptionsJson { description: string; } +interface IRushReportingConfigurationJson { + agentEnvironmentVariables?: string[]; +} + +/** + * Repository settings used by the Rush reporter system. + * @beta + */ +export interface IRushReportingConfiguration { + /** + * Additional environment variable names that identify an agent environment. + */ + readonly agentEnvironmentVariables: readonly string[]; +} + /** * This represents the JSON data structure for the "rush.json" configuration file. * See rush.schema.json for documentation. @@ -184,6 +199,7 @@ export interface IRushConfigurationJson { yarnOptions?: IYarnOptionsJson; ensureConsistentVersions?: boolean; variants?: IRushVariantOptionsJson[]; + reporting?: IRushReportingConfigurationJson; } /** @@ -523,6 +539,12 @@ export class RushConfiguration { */ public readonly telemetryEnabled: boolean; + /** + * Repository settings used by the Rush reporter system. + * @beta + */ + public readonly reportingConfiguration: IRushReportingConfiguration; + /** * {@inheritDoc NpmOptionsConfiguration} */ @@ -853,6 +875,9 @@ export class RushConfiguration { } this.telemetryEnabled = !!rushConfigurationJson.telemetryEnabled; + this.reportingConfiguration = { + agentEnvironmentVariables: rushConfigurationJson.reporting?.agentEnvironmentVariables || [] + }; this.eventHooks = new EventHooks(rushConfigurationJson.eventHooks || {}); this.versionPolicyConfigurationFilePath = path.join( diff --git a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts new file mode 100644 index 00000000000..dfe9526cab8 --- /dev/null +++ b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { ExperimentsConfiguration } from '../ExperimentsConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', ExperimentsConfiguration.name); +const EXPERIMENTS_JSON_PATH: string = path.join(TEMP_FOLDER, 'experiments.json'); + +describe(ExperimentsConfiguration.name, () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('preserves legacy reporting behavior when the experiment file is absent', () => { + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBeUndefined(); + }); + + it('loads the Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: true }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(true); + }); + + it('keeps an explicit false value disabled', () => { + JsonFile.save({ useRushReporter: false }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(false); + }); + + it('rejects a non-boolean Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: 'yes' }, EXPERIMENTS_JSON_PATH); + + expect(() => new ExperimentsConfiguration(EXPERIMENTS_JSON_PATH)).toThrow(/useRushReporter/); + }); +}); diff --git a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts new file mode 100644 index 00000000000..a188189023c --- /dev/null +++ b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { Rush } from '../Rush'; +import { RushConfiguration } from '../RushConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', 'RushConfigurationReporting'); +const RUSH_JSON_PATH: string = path.join(TEMP_FOLDER, 'rush.json'); + +function writeRushJson(reporting?: unknown): void { + JsonFile.save( + { + rushVersion: Rush.version, + pnpmVersion: '10.0.0', + projects: [], + ...(reporting === undefined ? {} : { reporting }) + }, + RUSH_JSON_PATH + ); +} + +describe('RushConfiguration reporting configuration', () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('defaults agent environment variables to an empty array', () => { + writeRushJson(); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([]); + }); + + it('loads configured agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 'ANOTHER_AGENT'] + }); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([ + 'MY_AGENT_CLI', + 'ANOTHER_AGENT' + ]); + }); + + it('rejects invalid agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 123] + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow( + /agentEnvironmentVariables/ + ); + }); + + it('rejects unsupported reporting settings', () => { + writeRushJson({ + agentEnvironmentVariables: [], + defaultReporter: 'ai' + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow(/defaultReporter/); + }); +}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index f2df5c851a1..0fdd200e775 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -20,7 +20,11 @@ export { export { ApprovedPackagesPolicy } from './api/ApprovedPackagesPolicy'; -export { RushConfiguration, type ITryFindRushJsonLocationOptions } from './api/RushConfiguration'; +export { + RushConfiguration, + type IRushReportingConfiguration, + type ITryFindRushJsonLocationOptions +} from './api/RushConfiguration'; export { Subspace } from './api/Subspace'; export { SubspacesConfiguration } from './api/SubspacesConfiguration'; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index fa4d2ee1308..de8925a12ce 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -101,6 +101,10 @@ "provideNpmrcCredentialsViaEnvironment": { "description": "If true, when using PNPM 10.34.2 through 10.x or PNPM 11.5.3 through versions earlier than 11.6.0, Rush resolves the \"${VAR}\" tokens that appear in credentials and registry URLs in the .npmrc file, instead of relying on PNPM to expand them. Credentials are passed to PNPM using \"npm_config_*\" environment variables and are not written to the generated .npmrc file. PNPM 11.6.0 and newer support URL-scoped \"pnpm_config_//...\" environment variables, which should instead be supplied directly by CI so the trusted environment binds each credential to its registry. Dynamic registry and proxy settings must likewise come from trusted user, global, CLI, or environment configuration.", "type": "boolean" + }, + "useRushReporter": { + "description": "If true, Rush may use the experimental Rush reporter system. If omitted or false, Rush preserves the legacy reporting behavior.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/schemas/rush.schema.json b/libraries/rush-lib/src/schemas/rush.schema.json index dce5fcaae37..9593b014ee0 100644 --- a/libraries/rush-lib/src/schemas/rush.schema.json +++ b/libraries/rush-lib/src/schemas/rush.schema.json @@ -248,6 +248,22 @@ "description": "Indicates whether telemetry data should be collected and stored in the Rush temp folder during Rush runs.", "type": "boolean" }, + "reporting": { + "description": "Configures repository settings used by the Rush reporter system.", + "type": "object", + "properties": { + "agentEnvironmentVariables": { + "description": "Additional environment variable names that identify an agent environment.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, "allowedProjectTags": { "description": "This is an optional, but recommended, list of allowed tags that can be applied to Rush projects using the \"tags\" setting in this file. This list is useful for preventing mistakes such as misspelling, and it also provides a centralized place to document your tags. If \"allowedProjectTags\" list is not specified, then any valid tag is allowed. A tag name must be one or more words separated by hyphens or slashes, where a word may contain lowercase ASCII letters, digits, \".\", and \"@\" characters.", "type": "array", From 991a4812dec873a125f14151095cf5c85733347c Mon Sep 17 00:00:00 2001 From: selarkin Date: Mon, 7 Sep 2026 01:18:53 +0000 Subject: [PATCH 02/22] Isolate reporter configuration test fixtures from shared cleanup Follow up #5987 without changing configuration behavior; concurrent flag-file tests empty api/test/temp. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../rush-lib/src/api/test/ExperimentsConfiguration.test.ts | 2 +- .../rush-lib/src/api/test/RushConfigurationReporting.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts index dfe9526cab8..7768eafa26f 100644 --- a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts @@ -7,7 +7,7 @@ import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { ExperimentsConfiguration } from '../ExperimentsConfiguration'; -const TEMP_FOLDER: string = path.join(__dirname, 'temp', ExperimentsConfiguration.name); +const TEMP_FOLDER: string = path.join(__dirname, `temp-${ExperimentsConfiguration.name}`); const EXPERIMENTS_JSON_PATH: string = path.join(TEMP_FOLDER, 'experiments.json'); describe(ExperimentsConfiguration.name, () => { diff --git a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts index a188189023c..1509baec266 100644 --- a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts +++ b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts @@ -8,7 +8,7 @@ import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { Rush } from '../Rush'; import { RushConfiguration } from '../RushConfiguration'; -const TEMP_FOLDER: string = path.join(__dirname, 'temp', 'RushConfigurationReporting'); +const TEMP_FOLDER: string = path.join(__dirname, 'temp-RushConfigurationReporting'); const RUSH_JSON_PATH: string = path.join(TEMP_FOLDER, 'rush.json'); function writeRushJson(reporting?: unknown): void { From ebbb4773cca998424dc43c8d35e5998e3c6d6df2 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 23:52:47 +0000 Subject: [PATCH 03/22] Refresh R2B reporter controls onto native-private trunk Preserve the exact published R2B slice and review corrections while reconciling native private members and replacing unbranded parser test objects with real execution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 18 + apps/rush/src/MinimalRushConfiguration.ts | 41 +- apps/rush/src/RushCommandSelector.ts | 8 +- apps/rush/src/RushFrontend.ts | 203 ++++ apps/rush/src/RushReporterHost.ts | 679 +++++++++++ apps/rush/src/RushVersionSelector.ts | 5 +- apps/rush/src/start-dev.ts | 19 +- apps/rush/src/start.ts | 26 +- .../src/test/MinimalRushConfiguration.test.ts | 2 + apps/rush/src/test/RushFrontend.test.ts | 1046 +++++++++++++++++ apps/rush/src/test/RushReporterHost.test.ts | 603 ++++++++++ .../repo/common/config/rush/experiments.json | 3 + ...ontend-host-controls_2026-08-28-03-00.json | 11 + ...porter-foundation-controls_2026-09-09.json | 11 + ...reporter-r2b-json-controls_2026-09-07.json | 11 + 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 | 100 +- .../cli/test/RushCommandLineParser.test.ts | 44 + ...RushCommandLineParserReporterClose.test.ts | 147 +++ .../common/config/rush/command-line.json | 39 + .../custom-output.js | 10 + .../common/config/rush/command-line.json | 18 + .../custom-reporter-flag.js | 10 + specs/2026-07-12-rush-reporter-overhaul.md | 8 + 26 files changed, 3033 insertions(+), 49 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 apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json create mode 100644 common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json create mode 100644 common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts 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 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/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..4b3bf391a67 --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,18 @@ +// 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; + readonly reporterCloseAsync: () => Promise; +} diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 1f6923f97eb..46d58acb45b 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 { #rushVersion: string; #commonRushConfigFolder: string; + #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/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..8d29eac6afa 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,8 +3,7 @@ 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 +27,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; @@ -65,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 new file mode 100644 index 00000000000..0fc42146f09 --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,203 @@ +// 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 { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IRushReporterHostOptions, + 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?: ( + options: IRushReporterHostOptions + ) => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => 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._closeForSignalAsync(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(); + } + } + + 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 { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute, + processLifecycle = createProcessLifecycle() + } = options; + + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter, + forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, + selectedRushVersion: rushVersionToLoad + }); + const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled + ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) + : undefined; + reporterLifecycle?.start(); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { + 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 + }; + + 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 reporterCloseAsync(); + } catch (closeError) { + processLifecycle.reportCloseError(closeError as Error); + processLifecycle.setExitCode(1); + } + 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 new file mode 100644 index 00000000000..8ca6b07f9b0 --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,679 @@ +// 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 stderr?: IRushReporterOutputStream; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; + readonly forceLegacy?: boolean; + readonly selectedRushVersion?: string; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reporterValueFlagsToStrip: readonly string[]; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default'; +} + +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']); +const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; +const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; + +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 readonly _outputStream: IRushReporterOutputStream | undefined; + private _fileDescriptor: number | undefined; + + public constructor( + reporterName: string, + outputPath: string, + logLevel: ReporterLogLevel, + outputStream?: IRushReporterOutputStream + ) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._outputStream = outputStream; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._outputStream) { + this._outputStream.write(text); + return; + } + 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 { + if (!this._outputStream) { + 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[], + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS +): 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 (!valueFlagsToStrip.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + index++; + } + } + return result; +} + +function parseReporterControls( + argv: readonly string[], + includeOutputAndLogLevelControls: boolean, + tolerateMissingReporterValue: boolean = false +): 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]; + 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, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.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'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + 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 (includeOutputAndLogLevelControls && controls.logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } +} + +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( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean, + useLegacyAliasPrecedence: boolean = false +): 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 (useLegacyAliasPrecedence && explicitLogLevel === undefined) { + if (controls.debug) { + return 'debug'; + } + if (controls.verbose) { + return 'verbose'; + } + if (controls.quiet) { + return 'quiet'; + } + } + 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 isReporterStreamTarget(target: string): target is 'stdout' | 'stderr' { + return target === 'stdout' || target === 'stderr'; +} + +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: isReporterStreamTarget(output.target) ? 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, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); + if (isLegacyEmergencyFallbackRequested(env)) { + const reporterValueFlagsToStrip: readonly string[] = reporterProbe.reporters.some( + (reporter) => isSupportedReporterName(reporter) && reporter !== 'legacy' + ) + ? ALL_REPORTER_VALUE_FLAGS + : reporterProbe.reporters.includes('legacy') + ? REPORTER_SELECTION_FLAG + : []; + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: reporterValueFlagsToStrip.length > 0, + reporterValueFlagsToStrip, + reason: 'RUSH_REPORTER=legacy' + }; + } + + const reporterOwnershipEstablished: boolean = + options.repositoryOptIn === true || + hasReporterOutputControl(argv) || + reporterProbe.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + const selectionControls: IParsedReporterControls = reporterOwnershipEstablished + ? parseReporterControls(argv, false) + : reporterProbe; + 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(reporterValue)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + const requestedReporter: ReporterName | undefined = reporterValue; + + if (options.forceLegacy) { + if (requestedReporter !== undefined && requestedReporter !== 'legacy') { + throw new Error( + `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + + `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 { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + 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 (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + return { + reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(selectionControls, env, true, true), + outputs: [], + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'repository experiment' + }; + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + if (requestedReporter === 'legacy') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: REPORTER_SELECTION_FLAG, + reason: 'explicit --reporter' + }; + } + + const controls: IParsedReporterControls = parseReporterControls(argv, true); + validateReporterControlMultiplicity(controls, true); + 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, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + 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 stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; + 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; + const outputStream: IRushReporterOutputStream | undefined = isReporterStreamTarget(output.target) + ? output.target === 'stdout' + ? stdout + : stderr + : undefined; + host.manager.addReporter( + new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel, outputStream), + { destination: output.target } + ); + } + } + + await host.manager.initializeAsync(); + 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/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 10e391fad3d..20c3d01dadc 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/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 new file mode 100644 index 00000000000..6920848d5fd --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,1046 @@ +// 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 { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; +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 { + initializeRushReporterHostAsync, + type IInitializedRushReporterHost, + type IRushReporterSelection +} from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +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(); + let closePromise: Promise | undefined; + const hasExplicitReporter: boolean = reason === 'explicit --reporter'; + return { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reporterControlsOwnedByFrontend: hasExplicitReporter, + reporterValueFlagsToStrip: hasExplicitReporter ? ['--reporter'] : [], + reason + }, + closeAsync: (timeoutMs?: number) => { + if (!closePromise) { + order.push('close'); + closePromise = host.manager.closeAsync(timeoutMs); + } + return closePromise; + } + }; +} + +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>; + 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']; + + 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; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(order).toEqual(['host', 'engine', 'close']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporterEventSink).toEqual( + expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink + ); + expect(receivedOptions).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; + } + }); + + 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 cannot safely use --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 = new RushVersionSelector('5.178.1'); + 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', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + 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; + }, + createVersionSelector: () => versionSelector, + processLifecycle + }); + + 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.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 = new RushVersionSelector('5.178.1'); + 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: {}, + repositoryOptIn: false, + expectedArguments: [ + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + expectedEnabled: false + }, + { + name: 'supported reporter as frontend ownership', + reporter: 'json', + env: {}, + repositoryOptIn: false, + expectedArguments: ['--verbose'], + expectedEnabled: true + }, + { + name: 'explicit legacy under the emergency override', + reporter: 'legacy', + env: { RUSH_REPORTER: 'legacy' }, + repositoryOptIn: false, + expectedArguments: ['--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + expectedEnabled: false + }, + { + name: 'custom reporter under repository emergency rollback', + reporter: 'junit', + env: { RUSH_REPORTER: 'legacy' }, + repositoryOptIn: true, + expectedArguments: [ + '--reporter', + 'junit', + '--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: { useRushReporter: testCase.repositoryOptIn } as MinimalRushConfiguration, + 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.each([false, true])( + 'runs a value-less custom reporter flag with repository rollback %s', + async (rollback) => { + 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: { useRushReporter: rollback } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: rollback ? { RUSH_REPORTER: 'legacy' } : {}, + 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'); + 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('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); + 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); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('parser failed'); + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: directory, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + void parser.executeAsync(); + }); + }, + 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[] = [ + '--', + '--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('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 closeAsync: jest.Mock, [number?]> = jest.fn(async () => undefined); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + + 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(closeAsync).toHaveBeenCalledTimes(1); + }); + + 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('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', + '--reporter', + 'junit', + '--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 initialized: IInitializedRushReporterHost = await createEnabledHostAsync(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(); + }); + + 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 new file mode 100644 index 00000000000..d67e4da3656 --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,603 @@ +// 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, + repositoryOptIn: boolean = false, + forceLegacy: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn, + forceLegacy, + selectedRushVersion: forceLegacy ? '5.177.0' : 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, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + 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('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' + }); + expect(resolve(['build', '--quiet', '--verbose', '--debug'], {}, false, true).logLevel).toBe('debug'); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve( + ['build', '--reporter=plaintext', '--log-level=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + + 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({ + 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', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ + reporter: 'legacy', + enabled: false, + 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.each([['--reporter=junit'], ['--reporter'], ['--reporter', '--verbose']])( + 'preserves custom reporter controls during repository rollback: %j', + (...argv: string[]) => { + const selection: IRushReporterSelection = resolve( + ['custom', ...argv], + { RUSH_REPORTER: 'legacy' }, + false, + true + ); + + expect(selection).toMatchObject({ + enabled: false, + reporter: 'legacy', + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect( + stripReporterValueControls(['custom', ...argv], new Set(selection.reporterValueFlagsToStrip)) + ).toEqual(['custom', ...argv]); + } + ); + + it('rolls back contradictory reporter selections without consuming pass-through arguments', () => { + const argv: string[] = [ + 'build', + '--reporter=legacy', + '--reporter=json', + '--reporter=ai', + '--log-level=invalid', + '--quiet', + '--debug', + '--', + '--reporter=junit', + '--output=child-output' + ]; + const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); + + expect(selection.enabled).toBe(false); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--quiet', + '--debug', + '--', + '--reporter=junit', + '--output=child-output' + ]); + }); + + 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('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 + ).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('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter'] + }); + }); + + 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(['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', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + }); + + 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/ + ); + expect(resolve(['custom', '--reporter=junit', '--verbose'], {}, false, false, 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', () => { + 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=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:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); + + it('distinguishes reserved stream targets from explicit relative file paths', () => { + expect( + resolve([ + 'build', + '--reporter=json', + '--output=json://stdout', + '--output=json://stderr', + '--output=json://./stdout', + '--output=json://./stderr' + ]).outputs.map(({ target }) => target) + ).toEqual(['stdout', 'stderr', path.resolve('/repo', 'stdout'), path.resolve('/repo', 'stderr')]); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it.each([ + { target: 'stdout', outputs: ['json://stdout'] }, + { target: 'stderr', outputs: ['json://stderr', 'file://stderr'] } + ])('rejects conflicting $target ownership before opening files', async ({ target, outputs }) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-conflict-')); + try { + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', ...outputs.map((output) => `--output=${output}`)], + env: {}, + cwd: directory, + stdout: { write: () => undefined }, + includeDefaultFileReporter: false + }).then(async (initialized) => { + await initialized.closeAsync(); + return initialized; + }) + ).rejects.toThrow(`The destination "${target}" is already owned by another reporter.`); + expect(await fs.promises.readdir(directory)).toEqual([]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it.each(['stdout', 'stderr'] as const)( + 'writes reserved %s output to the stream without creating a same-named file', + async (target) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-output-')); + const osModule: typeof os = jest.requireActual('node:os'); + const tmpdirSpy: jest.SpyInstance = jest.spyOn(osModule, 'tmpdir').mockReturnValue(directory); + const stdout = { write: jest.fn(), end: jest.fn() }; + const stderr = { write: jest.fn(), end: jest.fn() }; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=file', `--output=json://${target}`], + env: {}, + cwd: directory, + stdout, + stderr, + includeDefaultFileReporter: false + }); + emitCommandStarted(initialized.sink); + await initialized.closeAsync(); + + const stream = target === 'stdout' ? stdout : stderr; + expect(JSON.parse(stream.write.mock.calls.map(([text]) => text).join('')).type).toBe( + 'commandStarted' + ); + expect(stdout.end).not.toHaveBeenCalled(); + expect(stderr.end).not.toHaveBeenCalled(); + await expect(fs.promises.stat(path.join(directory, target))).rejects.toMatchObject({ + code: 'ENOENT' + }); + } finally { + tmpdirSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + + it('writes ./stdout to a file without conflicting with the primary stdout reporter', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-path-')); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--output=json://./stdout'], + env: {}, + cwd: directory, + stdout: { write: (text: string) => (stdoutText += text) }, + includeDefaultFileReporter: false + }); + emitCommandStarted(initialized.sink); + await initialized.closeAsync(); + + expect(await fs.promises.readFile(path.join(directory, 'stdout'), 'utf8')).toBe(stdoutText); + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + 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.closeAsync(); + + 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); + 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'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); 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 +} 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..919daad035d --- /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 pre-major frontend reporter controls with legacy command compatibility, selected-engine gating, and deterministic reporter finalization.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json b/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json new file mode 100644 index 00000000000..c6e32b8b796 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve custom reporter controls during emergency legacy rollback and honor reserved stdout/stderr output destinations.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json new file mode 100644 index 00000000000..5336c4b756f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Honor the pass-through separator when distinguishing command JSON from reporter JSON controls.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} 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 1546b9cce3e..1f18bab1e6e 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporterCloseAsync?: () => Promise; } export class RushCommandLineParser extends CommandLineParser { @@ -88,6 +89,8 @@ export class RushCommandLineParser extends CommandLineParser { readonly #terminalProvider: ConsoleTerminalProvider; readonly #terminal: Terminal; readonly #autocreateBuildCommand: boolean; + #initializationFailed: boolean = false; + #reporterClosePromise: Promise | undefined; /** * The current working directory that was used to find the Rush configuration. @@ -143,7 +146,7 @@ export class RushCommandLineParser extends CommandLineParser { this.rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFilePath); } } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } NodeJsCompatibility.warnAboutCompatibilityIssues({ @@ -166,6 +169,10 @@ export class RushCommandLineParser extends CommandLineParser { restrictConsoleOutput: this.#restrictConsoleOutput, rushGlobalFolder: this.rushGlobalFolder }); + if (this.#initializationFailed) { + this.#autocreateBuildCommand = true; + return; + } const pluginCommandLineConfigurations: ICustomCommandLineConfigurationInfo[] = this.pluginManager.tryGetCustomCommandLineConfigurationInfos(); @@ -178,18 +185,22 @@ export class RushCommandLineParser extends CommandLineParser { this.#autocreateBuildCommand = !hasBuildCommandInPlugin; this.#populateActions(); + if (this.#initializationFailed) { + return; + } for (const { commandLineConfiguration, pluginLoader } of pluginCommandLineConfigurations) { try { this.#addCommandLineConfigActions(commandLineConfiguration); } catch (e) { - this._reportErrorAndSetExitCode( + this._reportInitializationErrorAndSetExitCode( new Error( `Error from plugin ${pluginLoader.pluginName} by ${pluginLoader.packageName}: ${( e as Error ).toString()}` ) ); + return; } } } @@ -216,6 +227,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; } @@ -234,15 +248,29 @@ export class RushCommandLineParser extends CommandLineParser { } public override async executeAsync(args?: string[]): Promise { + if (this.#initializationFailed) { + await this._closeReporterAsync(); + return false; + } + // debugParameter will be correctly parsed during super.executeAsync(), so manually parse here. + const passThroughSeparatorIndex: number = process.argv.indexOf('--', 2); + const rushArgv: string[] = + 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 { @@ -309,7 +337,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 }; } @@ -359,7 +388,7 @@ export class RushCommandLineParser extends CommandLineParser { this.#populateScriptActions(); } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } } @@ -391,10 +420,7 @@ export class RushCommandLineParser extends CommandLineParser { } } - #addCommandLineConfigAction( - commandLineConfiguration: CommandLineConfiguration, - command: Command - ): void { + #addCommandLineConfigAction(commandLineConfiguration: CommandLineConfiguration, command: Command): void { if (this.tryGetAction(command.name)) { throw new Error( `${RushConstants.commandLineFilename} defines a command "${command.name}"` + @@ -534,6 +560,13 @@ export class RushCommandLineParser extends CommandLineParser { this.flushTelemetry(); + const configuredExitCode: string | number | undefined = process.exitCode; + const numericExitCode: number = Number(configuredExitCode); + const exitCode: number = + configuredExitCode !== undefined && Number.isInteger(numericExitCode) && numericExitCode !== 0 + ? numericExitCode + : 1; + process.exitCode = exitCode; const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. @@ -541,17 +574,44 @@ export class RushCommandLineParser extends CommandLineParser { // performs nontrivial work that can throw an exception. Either the Rush class would need // to handle reporting for those exceptions, or else _populateActions() should be moved // to a RushCommandLineParser lifecycle stage that can handle it. - if (process.exitCode !== undefined) { - process.exit(process.exitCode); - } else { - process.exit(1); - } + process.exit(exitCode); }; - if (this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed()) { - this.telemetry.ensureFlushedAsync().then(handleExit).catch(handleExit); + const telemetryFlushAsync: Promise | undefined = + this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed() + ? this.telemetry.ensureFlushedAsync() + : undefined; + + if (this.#rushOptions.reporterCloseAsync || telemetryFlushAsync) { + const pendingFlushes: Promise[] = []; + if (this.#rushOptions.reporterCloseAsync) { + pendingFlushes.push(this._closeReporterAsync()); + } + if (telemetryFlushAsync) { + pendingFlushes.push(telemetryFlushAsync); + } + void Promise.allSettled(pendingFlushes).then(handleExit); } else { handleExit(); } } + + private _reportInitializationErrorAndSetExitCode(error: Error): void { + this.#initializationFailed = true; + this._reportErrorAndSetExitCode(error); + } + + private _closeReporterAsync(): Promise { + if (!this.#reporterClosePromise) { + this.#reporterClosePromise = (async (): Promise => { + try { + await this.#rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + } + })(); + } + return this.#reporterClosePromise; + } } diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index dcdbca339ff..64d47c1cfdf 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -114,6 +114,36 @@ 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( + '--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', + 'custom-level', + '--verbose' + ]); + }); + }); + describe("'rebuild' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunRebuildActionRepo'; @@ -140,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/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts new file mode 100644 index 00000000000..5ca85182753 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -0,0 +1,147 @@ +// 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'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import { ConsoleTerminalProvider } from '@rushstack/terminal'; + +describe('RushCommandLineParser reporter close', () => { + let originalExitCode: string | number | undefined; + const originalArgv: string[] = process.argv; + + beforeEach(() => { + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + 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 = parser.rushSession.terminalProvider; + if (!(terminalProvider instanceof ConsoleTerminalProvider)) { + throw new Error('Expected the native console terminal provider.'); + } + 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.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( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + process.exitCode = 0; + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('parser failed'); + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const execution: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + process.exitCode = 0; + + resolveClose!(); + await expect(execution).resolves.toBe(false); + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('does not execute after an initialization failure', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('configuration failed'); + }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const executePromise: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(executePromise).resolves.toBe(false); + await new Promise((resolve: () => void) => setImmediate(resolve)); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('reports close failure without rejecting from parser finalization', async () => { + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => { + throw new Error('close failed'); + } + }); + const errorSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + process.exitCode = 0; + + await expect(parser.executeAsync(['--help'])).resolves.toBe(true); + + 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/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..c7d4e88c76b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,39 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-output", + "summary": "Exercises custom parameters that overlap reporter controls.", + "shellCommand": "node custom-output.js" + } + ], + "parameters": [ + { + "parameterKind": "string", + "longName": "--reporter", + "argumentName": "REPORTER", + "description": "Custom reporter value.", + "associatedCommands": ["custom-output"] + }, + { + "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` +); 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` +); diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index b32a790e46d..ed7bf36bcca 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -417,6 +417,10 @@ rush build --reporter=json --output=file://./rush-debug.log?logLevel=debug rush build --output=json://./rush-events.jsonl ``` +Literal `stdout` and `stderr` output targets reserve the corresponding stream; +they are not file paths. Conflicting stream owners are rejected before reporters +initialize. Use `./stdout` or `./stderr` to name an ordinary file instead. + Environment controls: - `RUSH_REPORTER`; @@ -444,6 +448,10 @@ Precedence: 5. Interactive TTY. 6. Generic non-TTY plaintext. +During pre-major opt-in, `RUSH_REPORTER=legacy` is an emergency override of both +explicit selection and the repository experiment. It is applied before strict +reporter validation, preserving custom command controls that Rush does not own. + Legacy flags remain permanent compatibility aliases for the primary reporter: - `--quiet` maps to `quiet`; From 9c40269bed3b0a3586f5f67cccfe96579a6d9487 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 23:57:49 +0000 Subject: [PATCH 04/22] Refresh R3A scoped producers onto native-private trunk Retain the exact scoped producer API and WeakMap-backed plugin facades while preserving native-private parser/plugin members and real launch-boundary coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +- apps/rush/src/RushFrontend.ts | 10 +- apps/rush/src/test/RushFrontend.test.ts | 57 ++++- ...ter-r3a-session-sink_2026-08-28-02-38.json | 11 + .../build-tests-subspace/pnpm-lock.yaml | 1 + .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 3 + common/reviews/api/rush-lib.api.md | 47 +++++ libraries/rush-lib/src/api/Rush.ts | 13 ++ .../rush-lib/src/cli/RushCommandLineParser.ts | 9 +- .../src/cli/actions/BaseRushAction.ts | 5 +- libraries/rush-lib/src/index.ts | 16 ++ .../PluginLoader/PluginLoaderBase.ts | 17 ++ .../src/pluginFramework/PluginManager.ts | 14 +- .../src/pluginFramework/RushSession.test.ts | 152 ++++++++++++++ .../src/pluginFramework/RushSession.ts | 198 ++++++++++++++++-- libraries/rush-sdk/package.json | 1 + .../test/__snapshots__/script.test.ts.snap | 4 +- 18 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/pluginFramework/RushSession.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a67..920ae96235f 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ILaunchOptions } from '@microsoft/rush-lib'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; /** * The cross-version launch contract owned by the Rush frontend. @@ -13,6 +12,6 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; * options, so an older engine can safely ignore the new property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { - readonly reporterEventSink: IReporterEventSink; + readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..044a060d6b9 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { randomUUID } from 'node:crypto'; + import type { ILaunchOptions } from '@microsoft/rush-lib'; import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; @@ -30,6 +32,7 @@ export interface IRushFrontendOptions { currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions ) => void | Promise; + readonly createSessionId?: () => string; readonly processLifecycle?: IRushFrontendProcessLifecycle; } @@ -132,6 +135,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, processLifecycle = createProcessLifecycle() } = options; @@ -152,9 +156,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink, + reporter: { + eventSink: reporterHost.sink, + sessionId + }, reporterCloseAsync }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 6920848d5fd..ddf0e046056 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; @@ -19,6 +20,7 @@ import { } from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, type IInitializedRushReporterHost, @@ -179,7 +181,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; - let receivedOptions: Record | undefined; + let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -196,7 +198,7 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; order.push('engine'); - receivedOptions = launchOptions as unknown as Record; + receivedOptions = launchOptions; return launchOptions.reporterCloseAsync(); }, processLifecycle @@ -204,9 +206,10 @@ describe(launchRushFrontendAsync.name, () => { expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); - expect(receivedOptions?.reporterEventSink).toEqual( - expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink - ); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String) + }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); @@ -217,6 +220,46 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend' + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -630,7 +673,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); return launchOptions.reporterCloseAsync(); }, processLifecycle: createTestProcessLifecycle() @@ -671,7 +714,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); process.exitCode = 1; return new Promise((resolve: () => void) => { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json new file mode 100644 index 00000000000..fa12adb823f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose an optional scoped reporter producer API to Rush actions and plugins while preserving legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 34402c38024..928ffa7350d 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -4990,6 +4990,7 @@ snapshots: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-reporter': file:../../../libraries/reporter(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 0f503ea1577..a8b0af670ab 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "36a63ea0a120d7f9fd7bba3e57f734059b5177e2", + "pnpmShrinkwrapHash": "50a1f3c8d2270f840d49426b54c028e26de05189", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "ee803d13f0fb0ae994024d4dc646d2def4cc1f0f" + "packageJsonInjectedDependenciesHash": "af9e972a5d86601391889a0ff0ae8349679a6a10" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 36fecb4bcb5..a9c01ddb734 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4407,6 +4407,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-reporter': + specifier: workspace:* + version: link:../reporter '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 37f1ea3ef31..13d17d81750 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -13,14 +13,22 @@ import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICreateRushDiagnosticOptions } from '@rushstack/rush-reporter'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterEventScope } from '@rushstack/rush-reporter'; +import { IReporterEventSink } from '@rushstack/rush-reporter'; +import { IRushDiagnostic } from '@rushstack/rush-reporter'; +import { IScopedLogger } from '@rushstack/rush-reporter'; +import { IScopedMessageOptions } from '@rushstack/rush-reporter'; +import { IScopedReporter } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +36,11 @@ import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; +import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; import type { PerformanceEntry as PerformanceEntry_2 } from 'node:perf_hooks'; +import { ReporterExtensionEventName } from '@rushstack/rush-reporter'; +import { ReporterJsonValue } from '@rushstack/rush-reporter'; +import { ReporterPrivacyClassification } from '@rushstack/rush-reporter'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; @@ -148,6 +160,8 @@ export class CommonVersionsConfiguration { saveAsync(): Promise; } +export { createRushDiagnostic } + export { CredentialCache } // @beta @@ -439,6 +453,8 @@ export interface ICreateOperationsContext { readonly rushConfiguration: RushConfiguration; } +export { ICreateRushDiagnosticOptions } + export { ICredentialCacheEntry } export { ICredentialCacheOptions } @@ -557,6 +573,8 @@ export interface ILaunchOptions { // @internal builtInPluginConfigurations?: _IBuiltInPluginConfiguration[]; isManaged: boolean; + // @internal + reporter?: IRushSessionReporterOptions; terminalProvider?: ITerminalProvider; } @@ -911,6 +929,10 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +export { IReporterEventScope } + +export { IReporterEventSink } + // @beta export interface IRushCommand { readonly actionName: string; @@ -943,6 +965,8 @@ export interface IRushCommandLineSpec { // @beta (undocumented) export type IRushConfigurationProjectForSnapshot = Pick; +export { IRushDiagnostic } + // @alpha (undocumented) export interface IRushPhaseSharding { count: number; @@ -983,10 +1007,23 @@ export interface IRushReportingConfiguration { export interface IRushSessionOptions { // (undocumented) getIsDebugMode: () => boolean; + reporter?: IRushSessionReporterOptions; // (undocumented) terminalProvider: ITerminalProvider; } +// @beta +export interface IRushSessionReporterOptions { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; +} + +export { IScopedLogger } + +export { IScopedMessageOptions } + +export { IScopedReporter } + // @beta export interface IStopwatchResult { get duration(): number; @@ -1288,6 +1325,8 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @beta export type Parallelism = number | IParallelismScalar; +export { parseReporterExtensionEventName } + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1365,6 +1404,12 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +export { ReporterExtensionEventName } + +export { ReporterJsonValue } + +export { ReporterPrivacyClassification } + // @public export class RepoStateFile { readonly filePath: string; @@ -1702,6 +1747,8 @@ export class RushSession { getCobuildLockProviderFactory(cobuildLockProviderName: string): CobuildLockProviderFactory | undefined; // (undocumented) getLogger(name: string): ILogger; + getReporter(scope?: IReporterEventScope): IScopedReporter | undefined; + getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined; // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index a51af8b0930..e75815484d6 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -14,6 +14,7 @@ import { RushXCommandLine } from '../cli/RushXCommandLine'; import { CommandLineMigrationAdvisor } from '../cli/CommandLineMigrationAdvisor'; import { EnvironmentVariableNames } from './EnvironmentConfiguration'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { IRushSessionReporterOptions } from '../pluginFramework/RushSession'; import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; @@ -58,6 +59,17 @@ export interface ILaunchOptions { * @internal */ builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + + /** + * Supplies the structured event sink owned by the Rush frontend. + * + * @remarks + * This is an internal cross-version frontend-to-engine handoff. Reporter + * selection and concrete reporter instances remain owned by the frontend. + * + * @internal + */ + reporter?: IRushSessionReporterOptions; } let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; @@ -98,6 +110,7 @@ export class Rush { const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations, + reporter: options.reporter, reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 1f18bab1e6e..71331301bca 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -57,7 +57,7 @@ import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; import { type ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; -import { RushSession } from '../pluginFramework/RushSession'; +import { type IRushSessionReporterOptions, RushSession } from '../pluginFramework/RushSession'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; import { RushAlerts } from '../utilities/RushAlerts'; @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; } @@ -131,7 +132,7 @@ export class RushCommandLineParser extends CommandLineParser { const terminal: Terminal = new Terminal(this.#terminalProvider); this.#terminal = terminal; this.#rushOptions = this.#normalizeOptions(options || {}); - const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations } = this.#rushOptions; + const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this.#rushOptions; let rushJsonFilePath: string | undefined; try { @@ -159,7 +160,8 @@ export class RushCommandLineParser extends CommandLineParser { this.rushSession = new RushSession({ getIsDebugMode: () => this.isDebug, - terminalProvider + terminalProvider, + reporter }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, @@ -338,6 +340,7 @@ export class RushCommandLineParser extends CommandLineParser { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporter: options.reporter, reporterCloseAsync: options.reporterCloseAsync }; } diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index fc92b4ac237..da6e8bb9d9c 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { CommandLineAction, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; import { LockFile } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { IScopedReporter } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; @@ -44,6 +45,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme protected readonly rushConfiguration: RushConfiguration | undefined; protected readonly terminal: ITerminal; protected readonly rushSession: RushSession; + protected readonly reporter: IScopedReporter | undefined; protected readonly rushGlobalFolder: RushGlobalFolder; protected readonly parser: RushCommandLineParser; @@ -57,6 +59,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme this.rushConfiguration = rushConfiguration; this.terminal = terminal; this.rushSession = rushSession; + this.reporter = rushSession.getReporter({ commandName: this.actionName }); this.rushGlobalFolder = rushGlobalFolder; } @@ -115,7 +118,7 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { return this.#eventHooksManager; } - protected declare readonly rushConfiguration: RushConfiguration; + declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { if (!this.rushConfiguration) { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 0fdd200e775..6f0bb4c5e67 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -168,10 +168,26 @@ export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { RushSession, type IRushSessionOptions, + type IRushSessionReporterOptions, type CloudBuildCacheProviderFactory, type CobuildLockProviderFactory } from './pluginFramework/RushSession'; +export { + createRushDiagnostic, + parseReporterExtensionEventName, + type ICreateRushDiagnosticOptions, + type IReporterEventScope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedLogger, + type IScopedMessageOptions, + type IScopedReporter, + type ReporterExtensionEventName, + type ReporterJsonValue, + type ReporterPrivacyClassification +} from '@rushstack/rush-reporter'; + export { type IRushCommand, type IGlobalCommand, diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index be38a503d9d..013cce44e26 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -7,6 +7,8 @@ import { FileSystem, InternalError, JsonFile, + PackageJsonLookup, + type IPackageJson, type JsonObject, JsonSchema } from '@rushstack/node-core-library'; @@ -51,6 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; + private _packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -84,6 +87,20 @@ export abstract class PluginLoaderBase< return this.#getRushPluginManifest(); } + public get packageVersion(): string { + if (!this._packageVersionCache) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( + path.join(this.packageFolder, 'package.json') + ); + if (!packageJson.version) { + throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); + } + this._packageVersionCache = packageJson.version; + } + + return this._packageVersionCache; + } + public getCommandLineConfiguration(): CommandLineConfiguration | undefined { const commandLineJsonFilePath: string | undefined = this._getCommandLineJsonFilePath(); if (!commandLineJsonFilePath) { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 926aac1a5e8..d5fc01eccc9 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -9,7 +9,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import { BuiltInPluginLoader, type IBuiltInPluginConfiguration } from './PluginLoader/BuiltInPluginLoader'; import type { IRushPlugin } from './IRushPlugin'; import { AutoinstallerPluginLoader } from './PluginLoader/AutoinstallerPluginLoader'; -import type { RushSession } from './RushSession'; +import { _createRushSessionForPlugin, type RushSession } from './RushSession'; import type { PluginLoaderBase } from './PluginLoader/PluginLoaderBase'; import { Rush } from '../api/Rush'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; @@ -205,7 +205,7 @@ export class PluginManager { const plugin: IRushPlugin | undefined = pluginLoader.load(); this.#loadedPluginNames.add(pluginName); if (plugin) { - this.#applyPlugin(plugin, pluginName); + this.#applyPlugin(plugin, pluginLoader); } } } @@ -227,9 +227,15 @@ export class PluginManager { }); } - #applyPlugin(plugin: IRushPlugin, pluginName: string): void { + #applyPlugin(plugin: IRushPlugin, pluginLoader: PluginLoaderBase): void { + const { packageName, pluginName } = pluginLoader; try { - plugin.apply(this.#rushSession, this.#rushConfiguration); + const pluginSession: RushSession = _createRushSessionForPlugin(this.#rushSession, () => ({ + packageName, + packageVersion: pluginLoader.packageVersion, + component: pluginName + })); + plugin.apply(pluginSession, this.#rushConfiguration); } catch (e) { throw new InternalError(`Error applying "${pluginName}": ${e}`); } diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts new file mode 100644 index 00000000000..26a48160731 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { + IReporterEmitEventInput, + IReporterEventSource, + IReporterEventSink +} from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { Rush } from '../api/Rush'; +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + +function createSession(reporter?: IRushSessionReporterOptions): RushSession { + return new RushSession({ + getIsDebugMode: () => false, + terminalProvider: new StringBufferTerminalProvider(), + reporter + }); +} + +describe(RushSession.name, () => { + it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { + const session: RushSession = createSession(); + + expect(session.getReporter()).toBeUndefined(); + expect(session.getScopedLogger()).toBeUndefined(); + expect(session.getLogger('legacy')).toBeDefined(); + expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + }); + + it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-1' }); + const scope = { commandName: 'build', projectName: '@scope/project' }; + const reporter = session.getReporter(scope); + + expect(reporter).toBeDefined(); + expect(Object.keys(reporter!).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + expect('getSink' in reporter!).toBe(false); + expect('reporters' in reporter!).toBe(false); + expect(Object.keys(session)).not.toContain('reporter'); + + scope.commandName = 'spoofed'; + reporter!.emitMessage({ severity: 'info', text: 'hello' }); + + expect(sink.inputs).toHaveLength(1); + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-1', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { + commandName: 'build', + projectName: '@scope/project' + } + }); + expect(sink.inputs[0]).not.toHaveProperty('eventId'); + expect(sink.inputs[0]).not.toHaveProperty('sequence'); + expect(sink.inputs[0]).not.toHaveProperty('timestamp'); + expect(sink.inputs[0]).not.toHaveProperty('required'); + }); + + it('isolates plugin sources while sharing session state', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-2' }); + const pluginSource: IReporterEventSource = { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }; + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => pluginSource); + + expect(pluginSession.hooks).toBe(session.hooks); + (pluginSource as { packageName: string }).packageName = '@acme/spoofed'; + pluginSession.getReporter({ projectName: '@scope/a' })!.emitMessage({ + severity: 'info', + text: 'plugin' + }); + session.getReporter({ projectName: '@scope/b' })!.emitMessage({ + severity: 'info', + text: 'rush' + }); + + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }, + scope: { projectName: '@scope/a' } + }); + expect(sink.inputs[1]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { projectName: '@scope/b' } + }); + }); + + it('rejects invalid explicitly supplied reporter options', () => { + expect(() => + createSession({ + eventSink: {} as IReporterEventSink, + sessionId: 'session-3' + }) + ).toThrow(/eventSink/); + + expect(() => createSession({ eventSink: new CapturingSink(), sessionId: ' ' })).toThrow(/sessionId/); + }); + + it('does not resolve plugin identity when reporting is disabled', () => { + const session: RushSession = createSession(); + const getSource = jest.fn((): IReporterEventSource => { + throw new Error('should not resolve source'); + }); + + expect(_createRushSessionForPlugin(session, getSource)).toBe(session); + expect(getSource).not.toHaveBeenCalled(); + }); + + it('binds built-in action reporters to their command name', () => { + const sink: CapturingSink = new CapturingSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: os.tmpdir(), + reporter: { eventSink: sink, sessionId: 'session-4' } + }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; + + expect(action?.reporter).toBeDefined(); + action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); + expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 2d0e5585b35..e017a9a8cbc 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { + RushSessionReporting, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IScopedLogger, + type IScopedReporter +} from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import { type ILogger, type ILoggerOptions, Logger } from './logging/Logger'; @@ -11,12 +19,43 @@ import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCa import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +/** + * The reporter channel supplied by the Rush frontend for a single Rush session. + * + * @remarks + * The frontend owns reporter selection and the concrete reporter instances. Rush + * only receives this presentation-free sink and binds producer identities before + * exposing scoped reporters to actions and plugins. + * + * @beta + */ +export interface IRushSessionReporterOptions { + /** + * The typed event sink owned by the Rush frontend. + */ + readonly eventSink: IReporterEventSink; + + /** + * The identifier assigned to this Rush session by the frontend. + */ + readonly sessionId: string; +} + /** * @beta */ export interface IRushSessionOptions { terminalProvider: ITerminalProvider; getIsDebugMode: () => boolean; + + /** + * The optional structured reporter channel for this session. + * + * @remarks + * When omitted, scoped reporter APIs return `undefined` and legacy terminal + * behavior remains unchanged. + */ + reporter?: IRushSessionReporterOptions; } /** @@ -33,20 +72,85 @@ export type CobuildLockProviderFactory = ( cobuildJson: ICobuildJson ) => ICobuildLockProvider | Promise; +interface IRushSessionState { + readonly options: IRushSessionOptions; + readonly cloudBuildCacheProviderFactories: Map; + readonly cobuildLockProviderFactories: Map; + readonly hooks: RushLifecycleHooks; + readonly reporting: RushSessionReporting | undefined; +} + +let _rushLibSource: IReporterEventSource | undefined; +const _rushSessionStates: WeakMap = new WeakMap(); + +function _getRushLibSource(): IReporterEventSource { + if (!_rushLibSource) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for @microsoft/rush-lib'); + } + + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); + if (!packageJson.version) { + throw new InternalError('The @microsoft/rush-lib package.json file does not specify a version'); + } + + _rushLibSource = { + packageName: '@microsoft/rush-lib', + packageVersion: packageJson.version + }; + } + + return _rushLibSource; +} + +function _createReporting( + reporterOptions: IRushSessionReporterOptions | undefined, + source: IReporterEventSource +): RushSessionReporting | undefined { + if (!reporterOptions) { + return undefined; + } + + const { eventSink, sessionId } = reporterOptions; + if (!eventSink || typeof eventSink.emit !== 'function') { + throw new TypeError('RushSession reporter.eventSink must implement IReporterEventSink'); + } + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); + } + + return new RushSessionReporting({ + sink: eventSink, + sessionId, + source: { ...source } + }); +} + +function _getSessionState(rushSession: RushSession): IRushSessionState { + const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); + if (!state) { + throw new InternalError('RushSession state was not initialized'); + } + return state; +} + /** * @beta */ export class RushSession { - readonly #options: IRushSessionOptions; - readonly #cloudBuildCacheProviderFactories: Map = new Map(); - readonly #cobuildLockProviderFactories: Map = new Map(); - public readonly hooks: RushLifecycleHooks; public constructor(options: IRushSessionOptions) { - this.#options = options; - this.hooks = new RushLifecycleHooks(); + _rushSessionStates.set(this, { + options, + cloudBuildCacheProviderFactories: new Map(), + cobuildLockProviderFactories: new Map(), + hooks: this.hooks, + reporting: options.reporter ? _createReporting(options.reporter, _getRushLibSource()) : undefined + }); } public getLogger(name: string): ILogger { @@ -54,51 +158,113 @@ export class RushSession { throw new InternalError('RushSession.getLogger(name) called without a name'); } - const terminalProvider: ITerminalProvider = this.#options.terminalProvider; + const { options } = _getSessionState(this); + const terminalProvider: ITerminalProvider = options.terminalProvider; const loggerOptions: ILoggerOptions = { loggerName: name, - getShouldPrintStacks: () => this.#options.getIsDebugMode(), + getShouldPrintStacks: () => options.getIsDebugMode(), terminalProvider }; return new Logger(loggerOptions); } public get terminalProvider(): ITerminalProvider { - return this.#options.terminalProvider; + return _getSessionState(this).options.terminalProvider; + } + + /** + * Creates a structured reporter bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * The returned API cannot access concrete reporters or override the session and + * source identity bound by Rush. + */ + public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { + return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + } + + /** + * Creates a structured logger bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * This API is additive; {@link RushSession.getLogger} and terminal output remain + * available during the pre-major compatibility period. + */ + public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { + return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); } public registerCloudBuildCacheProviderFactory( cacheProviderName: string, factory: CloudBuildCacheProviderFactory ): void { - if (this.#cloudBuildCacheProviderFactories.has(cacheProviderName)) { + const { cloudBuildCacheProviderFactories } = _getSessionState(this); + if (cloudBuildCacheProviderFactories.has(cacheProviderName)) { throw new Error(`A build cache provider factory for ${cacheProviderName} has already been registered`); } - this.#cloudBuildCacheProviderFactories.set(cacheProviderName, factory); + cloudBuildCacheProviderFactories.set(cacheProviderName, factory); } public getCloudBuildCacheProviderFactory( cacheProviderName: string ): CloudBuildCacheProviderFactory | undefined { - return this.#cloudBuildCacheProviderFactories.get(cacheProviderName); + return _getSessionState(this).cloudBuildCacheProviderFactories.get(cacheProviderName); } public registerCobuildLockProviderFactory( cobuildLockProviderName: string, factory: CobuildLockProviderFactory ): void { - if (this.#cobuildLockProviderFactories.has(cobuildLockProviderName)) { + const { cobuildLockProviderFactories } = _getSessionState(this); + if (cobuildLockProviderFactories.has(cobuildLockProviderName)) { throw new Error( `A cobuild lock provider factory for ${cobuildLockProviderName} has already been registered` ); } - this.#cobuildLockProviderFactories.set(cobuildLockProviderName, factory); + cobuildLockProviderFactories.set(cobuildLockProviderName, factory); } public getCobuildLockProviderFactory( cobuildLockProviderName: string ): CobuildLockProviderFactory | undefined { - return this.#cobuildLockProviderFactories.get(cobuildLockProviderName); + return _getSessionState(this).cobuildLockProviderFactories.get(cobuildLockProviderName); + } +} + +/** + * Creates the RushSession facade passed to one plugin. + * + * @remarks + * This function is internal to rush-lib. PluginManager derives the source from + * trusted loader metadata so the plugin cannot choose another producer identity. + * + * @internal + */ +export function _createRushSessionForPlugin( + rushSession: RushSession, + getSource: () => IReporterEventSource +): RushSession { + const state: IRushSessionState = _getSessionState(rushSession); + if (!state.options.reporter) { + return rushSession; } + + const pluginSession: RushSession = Object.create(RushSession.prototype) as RushSession; + Object.defineProperty(pluginSession, 'hooks', { + configurable: false, + enumerable: true, + value: state.hooks, + writable: false + }); + _rushSessionStates.set(pluginSession, { + options: state.options, + cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, + cobuildLockProviderFactories: state.cobuildLockProviderFactories, + hooks: state.hooks, + reporting: _createReporting(state.options.reporter, getSource()) + }); + return pluginSession; } diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index f365b9870b2..47357a51c24 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -50,6 +50,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-reporter": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 573fa555e28..80fc60cee1e 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -63,7 +63,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', - '_rushSdk_loadInternalModule' + '_rushSdk_loadInternalModule', + 'createRushDiagnostic', + 'parseReporterExtensionEventName' ]" `; From a4c8126ad712288a9510b24dbee6dab77cf3a9fe Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 00:01:29 +0000 Subject: [PATCH 05/22] Refresh R3B shadow lifecycle onto native-private trunk Preserve published early-failure, late-telemetry and operation-callback corrections; reconcile native lifecycle fields and telemetry references, with real branded parser regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + ...orter-foundation-lifecycle_2026-09-09.json | 11 + ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + common/reviews/api/rush-lib.api.md | 4 +- common/reviews/api/rush-reporter.api.md | 6 + .../diagnostics/RushDiagnosticCodeRegistry.ts | 39 +- .../src/diagnostics/templates/operation.ts | 3 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 142 +++++++- .../cli/scriptActions/PhasedScriptAction.ts | 2 + .../cli/test/RushCommandLineParser.test.ts | 33 +- ...RushCommandLineParserReporterClose.test.ts | 43 +++ ...CommandLineParserReporterLifecycle.test.ts | 249 +++++++++++++ libraries/rush-lib/src/cli/test/TestUtils.ts | 6 +- libraries/rush-lib/src/logic/Telemetry.ts | 33 ++ .../logic/operations/OperationEventSink.ts | 7 +- .../src/logic/operations/OperationGraph.ts | 17 +- .../operations/ReporterOperationEventSink.ts | 336 ++++++++++++++++++ .../test/OperationGraphEventSink.test.ts | 281 ++++++++++++++- .../rush-lib/src/logic/test/Telemetry.test.ts | 44 ++- .../src/pluginFramework/RushSession.test.ts | 95 ++++- .../src/pluginFramework/RushSession.ts | 250 ++++++++++++- specs/2026-07-12-rush-reporter-overhaul.md | 6 + 22 files changed, 1566 insertions(+), 63 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts create mode 100644 libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..71b7d371662 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit shadow Rush lifecycle, phase-aware operation, diagnostic, telemetry, and command-result events without changing legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json new file mode 100644 index 00000000000..0603c92996b --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Report early initialization failures and defer successful reporter completion until telemetry finalization preserves the command's native outcome.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..5f23b51eea9 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add a stable structured diagnostic code for Rush command failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 13d17d81750..e9201654ba4 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import { IRushDiagnostic } from '@rushstack/rush-reporter'; import { IScopedLogger } from '@rushstack/rush-reporter'; import { IScopedMessageOptions } from '@rushstack/rush-reporter'; import { IScopedReporter } from '@rushstack/rush-reporter'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -684,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } @@ -1044,6 +1045,7 @@ export interface ITelemetryData { readonly operationResults?: Record; readonly performanceEntries?: readonly PerformanceEntry_2[]; readonly platform?: string; + readonly reporterData?: ITelemetryAggregate; readonly result: 'Succeeded' | 'Failed'; readonly rushVersion?: string; readonly timestampMs?: number; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..ecf09047dbd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1506,6 +1506,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; readonly detailKey: undefined; +}, { + readonly code: "RUSH_COMMAND_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_COMMAND_FAILED.summary"; + readonly detailKey: undefined; }]; // @beta diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 11f5c1d933a..0d697f893e6 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -111,16 +111,13 @@ type AreValidRushDiagnosticCodeSegments< ? IsValidRushDiagnosticCodeSegment : false; -type ValidateRushDiagnosticCode = - TCode extends `RUSH_${infer Segments}` - ? AreValidRushDiagnosticCodeSegments extends true - ? TCode - : never - : never; +type ValidateRushDiagnosticCode = TCode extends `RUSH_${infer Segments}` + ? AreValidRushDiagnosticCodeSegments extends true + ? TCode + : never + : never; -type ValidatedRushDiagnosticCodeDefinitions< - TDefinitions extends readonly IRushDiagnosticCodeDefinition[] -> = { +type ValidatedRushDiagnosticCodeDefinitions = { readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition ? TDefinitions[K] & { readonly code: ValidateRushDiagnosticCode; @@ -130,9 +127,7 @@ type ValidatedRushDiagnosticCodeDefinitions< function defineRushDiagnosticCodeDefinitions< const TDefinitions extends readonly IRushDiagnosticCodeDefinition[] ->( - definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions -): TDefinitions { +>(definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions): TDefinitions { return definitions; } @@ -233,6 +228,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', detailKey: undefined + }, + { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + detailKey: undefined } ]); @@ -257,12 +259,11 @@ export type RushDiagnosticTemplateKey = NonNullable< * * @beta */ -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = - new Map( - RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( - (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const - ) - ); +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const + ) +); export { isValidRushDiagnosticCode } from './RushDiagnosticCode'; -export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; \ No newline at end of file +export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 32107668384..456adc6c8eb 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -11,5 +11,6 @@ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', - 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}', + 'diagnostic.RUSH_COMMAND_FAILED.summary': 'The Rush command {commandName} failed.' } as const; diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 71331301bca..1415b217422 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -16,6 +16,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; +import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter } from '@rushstack/rush-reporter'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushConstants } from '../logic/RushConstants'; @@ -64,6 +65,13 @@ import { RushAlerts } from '../utilities/RushAlerts'; import { initializeDotEnv } from '../logic/dotenv'; import { measureAsyncFn } from '../utilities/performance'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { + _correlateRushSessionError, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionReporterSourceVersion, + _isRushSessionErrorRepresented +} from '../pluginFramework/RushSession'; /** * Options for `RushCommandLineParser`. @@ -91,6 +99,11 @@ export class RushCommandLineParser extends CommandLineParser { readonly #terminal: Terminal; readonly #autocreateBuildCommand: boolean; #initializationFailed: boolean = false; + #sessionLifecycleEmitter: LifecycleEmitter | undefined; + #commandLifecycleEmitter: LifecycleEmitter | undefined; + #sessionStartTimeMs: number | undefined; + #commandStartTimeMs: number | undefined; + #reporterCompletionEmitted: boolean = false; #reporterClosePromise: Promise | undefined; /** @@ -134,6 +147,13 @@ export class RushCommandLineParser extends CommandLineParser { this.#rushOptions = this.#normalizeOptions(options || {}); const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this.#rushOptions; + this.rushSession = new RushSession({ + getIsDebugMode: () => this.isDebug, + terminalProvider, + reporter + }); + this.#sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + let rushJsonFilePath: string | undefined; try { rushJsonFilePath = RushConfiguration.tryFindRushJsonLocation({ @@ -158,11 +178,6 @@ export class RushCommandLineParser extends CommandLineParser { this.rushGlobalFolder = new RushGlobalFolder(); - this.rushSession = new RushSession({ - getIsDebugMode: () => this.isDebug, - terminalProvider, - reporter - }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, rushConfiguration: this.rushConfiguration, @@ -264,12 +279,24 @@ export class RushCommandLineParser extends CommandLineParser { this.#terminalProvider.verboseEnabled = this.#terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); + this._startReporterSession(); + try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => this.pluginManager.tryInitializeUnassociatedPluginsAsync() ); - return await super.executeAsync(args); + const succeeded: boolean = await super.executeAsync(args); + if (!this.#reporterCompletionEmitted) { + this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + } + return succeeded; + } catch (error) { + if (!process.exitCode) { + process.exitCode = 1; + } + this._reportErrorAndSetExitCode(error as Error); + return false; } finally { await this._closeReporterAsync(); } @@ -287,6 +314,17 @@ export class RushCommandLineParser extends CommandLineParser { InternalError.breakInDebugger = true; } + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName) { + this.#commandLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession, { + commandName + }); + if (this.#commandLifecycleEmitter) { + this.#commandStartTimeMs = performance.now(); + this.#commandLifecycleEmitter.emitCommandStarted({ commandName }); + } + } + try { await this.#wrapOnExecuteAsync(); @@ -332,7 +370,12 @@ export class RushCommandLineParser extends CommandLineParser { } // This only gets hit if the wrapped execution completes successfully - await this.telemetry?.ensureFlushedAsync(); + try { + await this.telemetry?.ensureFlushedAsync(); + } catch (error) { + this._emitReporterFailureDiagnostic(error as Error); + throw error; + } } #normalizeOptions(options: Partial): IRushCommandLineParserOptions { @@ -540,7 +583,37 @@ export class RushCommandLineParser extends CommandLineParser { ); } + private _startReporterSession(): void { + if (this.#sessionLifecycleEmitter && this.#sessionStartTimeMs === undefined) { + this.#sessionStartTimeMs = performance.now(); + this.#sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + } + + private _emitReporterFailureDiagnostic(error: Error): void { + this._startReporterSession(); + const emitter: LifecycleEmitter | undefined = + this.#commandLifecycleEmitter ?? this.#sessionLifecycleEmitter; + const rushSession: RushSession | undefined = this.rushSession; + if (emitter && rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { + parameters: { + commandName: { + value: this.selectedAction?.actionName ?? 'unknown', + privacy: 'public' + } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); + } + } + private _reportErrorAndSetExitCode(error: Error): void { + this._emitReporterFailureDiagnostic(error); + if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -561,8 +634,6 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } - this.flushTelemetry(); - const configuredExitCode: string | number | undefined = process.exitCode; const numericExitCode: number = Number(configuredExitCode); const exitCode: number = @@ -570,6 +641,9 @@ export class RushCommandLineParser extends CommandLineParser { ? numericExitCode : 1; process.exitCode = exitCode; + this._emitReporterCompletion(exitCode); + this.flushTelemetry(); + const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. @@ -617,4 +691,54 @@ export class RushCommandLineParser extends CommandLineParser { } return this.#reporterClosePromise; } + + private _emitReporterCompletion(exitCode: number): void { + if (!this.#sessionLifecycleEmitter || this.#reporterCompletionEmitted) { + return; + } + this.#reporterCompletionEmitted = true; + + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName && this.#commandLifecycleEmitter) { + const durationMs: number | undefined = + this.#commandStartTimeMs === undefined ? undefined : performance.now() - this.#commandStartTimeMs; + this.#commandLifecycleEmitter.emitCommandResult({ + commandName, + succeeded: exitCode === 0, + exitCode + }); + this.#commandLifecycleEmitter.emitCommandCompleted({ + commandName, + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + if (this.#sessionLifecycleEmitter) { + const durationMs: number | undefined = + this.#sessionStartTimeMs === undefined ? undefined : performance.now() - this.#sessionStartTimeMs; + this.#sessionLifecycleEmitter.emitSessionCompleted({ + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + // Shadow derivation is deliberately observational. process.exitCode remains authoritative. + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession) { + _getRushSessionDerivedExitStatus(rushSession); + } + } +} + +function _getNumericProcessExitCode(fallback: number): number { + const { exitCode } = process; + if (typeof exitCode === 'number') { + return exitCode; + } + if (typeof exitCode === 'string') { + const parsed: number = Number(exitCode); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; } diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 4584ebd9811..df13ddad3f8 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -62,6 +62,7 @@ import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParameter import { TrimRushEnvironmentVariablesPlugin } from '../../logic/operations/TrimRushEnvironmentVariablesPlugin'; import { DebugHashesPlugin } from '../../logic/operations/DebugHashesPlugin'; import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import { attachReporterOperationEventSink } from '../../logic/operations/ReporterOperationEventSink'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; @@ -678,6 +679,7 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { await hooks.onGraphCreatedAsync.promise(graph, graphContext); }); + attachReporterOperationEventSink(graph, this.rushSession, this.actionName); const executeOptions: IExecuteOperationsOptions = { graph, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 64d47c1cfdf..6f1184c7dde 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -31,6 +31,7 @@ import './mockRushCommandLineParser'; import type { SpawnOptions } from 'node:child_process'; import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { Autoinstaller } from '../../logic/Autoinstaller'; import type { ITelemetryData } from '../../logic/Telemetry'; import { @@ -47,6 +48,15 @@ import { IS_WINDOWS } from '../../utilities/executionUtilities'; // we only reference the one that is common. const SPAWN_ARG_OPTIONS: number = 2; +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function spawnOptionEquals( spawnCall: SpawnMockCall, optionName: TOption, @@ -93,7 +103,11 @@ describe('RushCommandLineParser', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; - const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build'); + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build', { + eventSink: reporterSink, + sessionId: 'parser-shadow' + }); await expect(parser.executeAsync()).resolves.toEqual(true); @@ -111,6 +125,23 @@ describe('RushCommandLineParser', () => { const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1]; expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp); cwdOptionEquals(secondSpawn, `${repoPath}/b`); + + const eventTypes: string[] = reporterSink.inputs.map(({ type }) => type); + expect(eventTypes[0]).toBe('sessionStarted'); + expect(eventTypes[1]).toBe('commandStarted'); + expect(eventTypes).toContain('operationRegistered'); + expect(eventTypes).toContain('operationStatusChanged'); + expect(eventTypes.slice(-3)).toEqual(['commandResult', 'commandCompleted', 'sessionCompleted']); + expect(reporterSink.inputs.at(-3)?.payload).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationRegistered')) { + const scope = event.scope!; + expect(scope.operationId).toBe(`${scope.projectName}#${scope.phaseName}`); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 5ca85182753..6ddafceb845 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -5,6 +5,16 @@ import { RushCommandLineParser } from '../RushCommandLineParser'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import { RushConfiguration } from '../../api/RushConfiguration'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; + +class CapturingReporterSink implements IReporterEventSink { + public readonly events: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.events.push(event); + return `event-${this.events.length}`; + } +} describe('RushCommandLineParser reporter close', () => { let originalExitCode: string | number | undefined; @@ -74,6 +84,7 @@ describe('RushCommandLineParser reporter close', () => { resolveClose = resolve; }) ); + const sink: CapturingReporterSink = new CapturingReporterSink(); const exitSpy: jest.SpyInstance = jest .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -84,11 +95,13 @@ describe('RushCommandLineParser reporter close', () => { }); const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: `${__dirname}/repo`, + reporter: { eventSink: sink, sessionId: 'parser-exit-close' }, reporterCloseAsync: closeAsync }); const execution: Promise = parser.executeAsync(); expect(closeAsync).toHaveBeenCalledTimes(1); + expect(sink.events.at(-1)).toMatchObject({ type: 'sessionCompleted', payload: { exitCode: 1 } }); expect(exitSpy).not.toHaveBeenCalled(); process.exitCode = 0; @@ -144,4 +157,34 @@ describe('RushCommandLineParser reporter close', () => { expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); }); + + it('shares one reporter close operation across failure and finalization paths', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('configuration failed'); + }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const firstClose: Promise = parser.executeAsync(); + const secondClose: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([false, false]); + await new Promise((resolve) => setImmediate(resolve)); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts new file mode 100644 index 00000000000..774265bc22b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -0,0 +1,249 @@ +// 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 { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; + +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import type { IRushConfigurationJson } from '../../api/RushConfiguration'; +import { + _getRushSessionDerivedExitStatus, + _isRushSessionErrorRepresented +} from '../../pluginFramework/RushSession'; +import { RushCommandLineParser } from '../RushCommandLineParser'; + +class CapturingReporterSink implements IReporterEventSink { + public readonly events: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.events.push(event); + return `event-${this.events.length}`; + } +} + +function isCompletion(event: IReporterEmitEventInput): boolean { + return ( + event.type === 'commandResult' || event.type === 'commandCompleted' || event.type === 'sessionCompleted' + ); +} + +describe('RushCommandLineParser reporter lifecycle', () => { + const temporaryFolders: string[] = []; + let originalExitCode: string | number | undefined; + let originalArgv: string[]; + let stdoutSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + async function copyRepositoryAsync(): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-lifecycle-')); + temporaryFolders.push(directory); + const repoPath: string = path.join(directory, 'repo'); + await fs.promises.cp(path.join(__dirname, 'basicAndRunBuildActionRepo'), repoPath, { recursive: true }); + return repoPath; + } + + beforeEach(() => { + originalExitCode = process.exitCode; + originalArgv = process.argv; + process.exitCode = undefined; + process.argv = ['node', 'rush', 'custom-output']; + EnvironmentConfiguration.reset(); + stdoutSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(async () => { + await Promise.all( + temporaryFolders + .splice(0) + .map((directory) => fs.promises.rm(directory, { recursive: true, force: true })) + ); + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + }); + + it.each([ + { file: 'rush.json', withClose: false }, + { file: 'rush.json', withClose: true }, + { file: 'common/config/rush/command-line.json', withClose: false }, + { file: 'common/config/rush/command-line.json', withClose: true } + ])('reports invalid $file before fatal exit (close callback: $withClose)', async ({ file, withClose }) => { + const repoPath: string = await copyRepositoryAsync(); + await fs.promises.writeFile(path.join(repoPath, file), '{'); + const visibleOutput: unknown[] = []; + + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + let eventsAtExit: readonly IReporterEmitEventInput[] = []; + let eventsAtClose: readonly IReporterEmitEventInput[] = []; + const exitSpy: jest.SpyInstance = jest.spyOn(process, 'exit').mockImplementation(() => { + eventsAtExit = [...sink.events]; + return undefined as never; + }); + const closeAsync: jest.Mock, []> = jest.fn(async () => { + eventsAtClose = [...sink.events]; + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'initialization-failure' } : undefined, + reporterCloseAsync: withClose ? closeAsync : undefined + }); + + if (!withClose) { + expect(exitSpy).toHaveBeenCalledWith(1); + } + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(process.exitCode).toBe(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(closeAsync).toHaveBeenCalledTimes(withClose ? 1 : 0); + expect(sink.events.map(({ type }) => type)).toEqual( + reporting ? ['sessionStarted', 'diagnosticEmitted', 'sessionCompleted'] : [] + ); + expect(eventsAtExit).toEqual(sink.events); + if (withClose) { + expect(eventsAtClose).toEqual(sink.events); + } + if (reporting) { + expect(sink.events[1].payload).toMatchObject({ + code: 'RUSH_COMMAND_FAILED', + diagnosticId: expect.any(String) + }); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + } + visibleOutput.push({ + stdout: stdoutSpy.mock.calls.map((args) => [...args]), + stderr: stderrSpy.mock.calls.map((args) => [...args]) + }); + exitSpy.mockRestore(); + } + + expect(visibleOutput[1]).toEqual(visibleOutput[0]); + }); + + it('emits and correlates a session diagnostic when plugin initialization fails before action selection', async () => { + const repoPath: string = await copyRepositoryAsync(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: { eventSink: sink, sessionId: 'plugin-initialization-failure' }, + reporterCloseAsync: closeAsync + }); + const error: Error = new Error('plugin initialization failed'); + jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync').mockRejectedValue(error); + + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sink.events.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'diagnosticEmitted', + 'sessionCompleted' + ]); + expect(sink.events[1].scope?.commandName).toBeUndefined(); + expect(_isRushSessionErrorRepresented(parser.rushSession, error)).toBe(true); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it.each([false, true])('awaits a real delayed public telemetry hook (reject: %s)', async (reject) => { + const visibleErrors: unknown[] = []; + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const repoPath: string = await copyRepositoryAsync(); + const rushJsonPath: string = path.join(repoPath, 'rush.json'); + const rushJson: IRushConfigurationJson = JsonFile.load(rushJsonPath); + rushJson.telemetryEnabled = true; + JsonFile.save(rushJson, rushJsonPath); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'telemetry-finalization' } : undefined, + reporterCloseAsync: reporting ? closeAsync : undefined + }); + let markHookStarted: (() => void) | undefined; + const hookStarted: Promise = new Promise((resolve) => { + markHookStarted = resolve; + }); + let releaseHook: (() => void) | undefined; + const hookReleased: Promise = new Promise((resolve) => { + releaseHook = resolve; + }); + const failure: Error = new Error('delayed telemetry flush failed'); + const flushTelemetry: jest.Mock, []> = jest.fn(async () => { + markHookStarted!(); + await hookReleased; + if (reject) { + throw failure; + } + }); + parser.rushSession.hooks.flushTelemetry.tapPromise('DelayedTelemetry', flushTelemetry); + + const execution: Promise = parser.executeAsync(['custom-output', '--reporter=junit']); + await hookStarted; + await new Promise((resolve) => setImmediate(resolve)); + const prematureCompletions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + releaseHook!(); + const succeeded: boolean = await execution; + + expect(JsonFile.load(path.join(repoPath, 'custom-output-args.json'))).toEqual(['--reporter', 'junit']); + expect(prematureCompletions).toEqual([]); + expect(succeeded).toBe(!reject); + expect(process.exitCode).toBe(reject ? 1 : 0); + expect(exitSpy).not.toHaveBeenCalled(); + expect(flushTelemetry).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledTimes(reporting ? 1 : 0); + if (reporting) { + const completions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + expect(completions.map(({ type }) => type)).toEqual([ + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + for (const event of completions) { + expect(event.payload).toMatchObject({ exitCode: reject ? 1 : 0 }); + } + expect(completions[0].payload).toMatchObject({ succeeded: !reject }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: reject ? 1 : 0, + outcome: reject ? 'failed' : 'succeeded' + }); + expect(_isRushSessionErrorRepresented(parser.rushSession, failure)).toBe(reject); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toHaveLength(reject ? 1 : 0); + } else { + expect(sink.events).toEqual([]); + } + visibleErrors.push(stderrSpy.mock.calls.map((args) => [...args])); + exitSpy.mockRestore(); + } + + expect(visibleErrors[1]).toEqual(visibleErrors[0]); + }); +}); diff --git a/libraries/rush-lib/src/cli/test/TestUtils.ts b/libraries/rush-lib/src/cli/test/TestUtils.ts index c8191358c2c..29fa4482233 100644 --- a/libraries/rush-lib/src/cli/test/TestUtils.ts +++ b/libraries/rush-lib/src/cli/test/TestUtils.ts @@ -4,6 +4,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser'; +import type { IRushSessionReporterOptions } from '../../pluginFramework/RushSession'; import { FlagFile } from '../../api/FlagFile'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -76,7 +77,8 @@ export const TEST_REPO_FOLDER_PATH: string = `${PROJECT_ROOT}/temp/test/unit-tes */ export async function getCommandLineParserInstanceAsync( repoName: string, - taskName: string + taskName: string, + reporter?: IRushSessionReporterOptions ): Promise { // Copy the test repo to a sandbox folder const repoPath: string = `${TEST_REPO_FOLDER_PATH}/${repoName}-${performance.now()}`; @@ -100,7 +102,7 @@ export async function getCommandLineParserInstanceAsync( // to exit and clear the Rush file lock. So running multiple `it` or `describe` test blocks over the same test // repo will fail due to contention over the same lock which is kept until the test runner process // ends. - const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath }); + const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath, reporter }); // Bulk tasks are hard-coded to expect install to have been completed. So, ensure the last-link.flag // file exists and is valid diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 1915f266ffa..71c5d74f64a 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import type { PerformanceEntry } from 'node:perf_hooks'; import { FileSystem, type FileSystemStats, JsonFile } from '@rushstack/node-core-library'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../api/RushConfiguration'; import { Rush } from '../api/Rush'; import type { RushSession } from '../pluginFramework/RushSession'; +import { _getRushSessionTelemetryAggregate } from '../pluginFramework/RushSession'; import { collectPerformanceEntries } from '../utilities/performance'; /** @@ -138,6 +140,16 @@ export interface ITelemetryData { * This is an array of `PerformanceEntry` objects, which can include marks, measures, and function timings. */ readonly performanceEntries?: readonly PerformanceEntry[]; + + /** + * The allowlisted projection derived from shadow reporter events. + * + * @remarks + * This is present only when the Rush frontend supplied a reporter event sink. + * It never contains messages, paths, arguments, raw output, remediation + * parameters, stack traces, or non-public envelope metadata. + */ + readonly reporterData?: ITelemetryAggregate; } const MAX_FILE_COUNT: number = 100; @@ -166,9 +178,30 @@ export class Telemetry { if (!this.#enabled) { return; } + const reporterAggregate: ITelemetryAggregate | undefined = _getRushSessionTelemetryAggregate( + this.#rushSession + ); + const processExitCode: number = + typeof process.exitCode === 'number' ? process.exitCode : Number(process.exitCode); const cpus: os.CpuInfo[] = os.cpus(); const data: ITelemetryData = { ...telemetryData, + reporterData: reporterAggregate + ? { + ...reporterAggregate, + commandName: reporterAggregate.commandName ?? telemetryData.name, + result: + reporterAggregate.result ?? (telemetryData.result === 'Succeeded' ? 'succeeded' : 'failed'), + exitCode: + reporterAggregate.exitCode ?? + (telemetryData.result === 'Succeeded' + ? 0 + : Number.isFinite(processExitCode) + ? processExitCode + : 1), + durationMs: reporterAggregate.durationMs ?? telemetryData.durationInSeconds * 1000 + } + : telemetryData.reporterData, performanceEntries: telemetryData.performanceEntries || collectPerformanceEntries(this.#telemetryStartTime), machineInfo: telemetryData.machineInfo || { diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index cda9311ebb4..b5d90e0ad8a 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,16 +40,13 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; /** * Invoked synchronously on every operation status transition. The result's * `status`, `error`, and `stopwatch` reflect the new state. */ - onOperationStatusChanged?( - result: IOperationExecutionResult, - previousStatus: OperationStatus - ): void; + onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; /** * Invoked when an operation's collated output is about to be displayed, diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 7cd67d15d37..2890772a1d6 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -679,7 +679,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -1296,10 +1296,9 @@ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatef function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed successfully in ${stopwatch.toString()}.`, - { operationId: record.name } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed successfully in ${stopwatch.toString()}.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) ); @@ -1316,10 +1315,10 @@ function _handleOperationSuccessWithWarning( ): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed with warnings in ${stopwatch.toString()}.`, - { operationId: record.name, stderr: true } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`, { + operationId: record.name, + stderr: true + }); record.collatedWriter.terminal.writeStderrLine( Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) ); diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts new file mode 100644 index 00000000000..d3287c44270 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + type IRushDiagnostic, + type LifecycleEmitter, + type OperationStatus as ReporterOperationStatus +} from '@rushstack/rush-reporter'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { RushSession } from '../../pluginFramework/RushSession'; +import { + _correlateRushSessionError, + _getRushSessionLifecycleEmitter +} from '../../pluginFramework/RushSession'; +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import type { OperationGraph } from './OperationGraph'; + +interface IReporterOperation { + readonly emitter: LifecycleEmitter; + readonly legacyOperationIds: Set; + readonly operationId: string; + readonly phaseName: string; + readonly projectName: string; + registrationCycle: IReporterOperationCycle | undefined; +} + +interface IReporterOperationCycle { + readonly registeredOperationIds: Set; + readonly statuses: Map; + diagnosed: boolean; + lastEmittedStatus: ReporterOperationStatus | undefined; + silent: boolean; +} + +class ReporterOperationEventSink implements IOperationGraphEventSink { + private readonly _operationsByLegacyId: Map = new Map(); + private readonly _cyclesByResult: WeakMap = + new WeakMap(); + private readonly _rushSession: RushSession; + + public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { + this._rushSession = rushSession; + const operationsByReporterId: Map = new Map(); + + for (const operation of operations) { + const projectName: string = operation.associatedProject.packageName; + const phaseName: string = operation.associatedPhase.name; + const operationId: string = `${projectName}#${phaseName}`; + let reporterOperation: IReporterOperation | undefined = operationsByReporterId.get(operationId); + if (!reporterOperation) { + const emitter: LifecycleEmitter | undefined = _getRushSessionLifecycleEmitter(rushSession, { + commandName, + operationId, + projectName, + phaseName + }); + if (!emitter) { + continue; + } + reporterOperation = { + emitter, + legacyOperationIds: new Set(), + operationId, + phaseName, + projectName, + registrationCycle: undefined + }; + operationsByReporterId.set(operationId, reporterOperation); + } + reporterOperation.legacyOperationIds.add(operation.name); + this._operationsByLegacyId.set(operation.name, reporterOperation); + } + } + + public get isEnabled(): boolean { + return this._operationsByLegacyId.size > 0; + } + + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation || !result) { + return; + } + + let cycle: IReporterOperationCycle | undefined = operation.registrationCycle; + if (!cycle || cycle.registeredOperationIds.size === operation.legacyOperationIds.size) { + cycle = { + registeredOperationIds: new Set(), + statuses: new Map(), + diagnosed: false, + lastEmittedStatus: undefined, + silent: true + }; + operation.registrationCycle = cycle; + } + + this._cyclesByResult.set(result, cycle); + cycle.registeredOperationIds.add(operationId); + cycle.silent &&= silent; + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { + return; + } + + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } + + public onOperationStatusChanged(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation) { + return; + } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } + + if ( + result.status === OperationStatus.Ready && + cycle.registeredOperationIds.size === operation.legacyOperationIds.size + ) { + return; + } + + cycle.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !cycle.diagnosed) { + cycle.diagnosed = true; + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: operation.projectName, privacy: 'public' } + } + }); + operation.emitter.emitDiagnostic(diagnostic); + if (result.error) { + _correlateRushSessionError(this._rushSession, result.error, diagnostic.diagnosticId); + } + } + + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation, cycle); + if (status === undefined || status === cycle.lastEmittedStatus) { + return; + } + cycle.lastEmittedStatus = status; + if (!cycle.silent) { + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + operation.emitter.emitOperationStatusChanged({ + operationId: operation.operationId, + status, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + } +} + +class CompositeOperationGraphEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + + private readonly _first: IOperationGraphEventSink; + private readonly _second: IOperationGraphEventSink; + + public constructor(first: IOperationGraphEventSink, second: IOperationGraphEventSink) { + this._first = first; + this._second = second; + this.onOperationChunk = + first.onOperationChunk || second.onOperationChunk + ? (operationId, chunk) => { + first.onOperationChunk?.(operationId, chunk); + second.onOperationChunk?.(operationId, chunk); + } + : undefined; + this.onOperationStreamClosed = + first.onOperationStreamClosed || second.onOperationStreamClosed + ? (operationId) => { + first.onOperationStreamClosed?.(operationId); + second.onOperationStreamClosed?.(operationId); + } + : undefined; + } + + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + this._first.onOperationRegistered?.(operationId, silent, result); + this._second.onOperationRegistered?.(operationId, silent, result); + } + + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { + this._first.onOperationStatusChanged?.(result, previousStatus); + this._second.onOperationStatusChanged?.(result, previousStatus); + } + + public onOperationHeader(operationId: string, completedOperations: number, totalOperations: number): void { + this._first.onOperationHeader?.(operationId, completedOperations, totalOperations); + this._second.onOperationHeader?.(operationId, completedOperations, totalOperations); + } + + public onActivity(text: string, options?: IOperationActivityOptions): void { + this._first.onActivity?.(text, options); + this._second.onActivity?.(text, options); + } +} + +/** + * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * + * @internal + */ +export function attachReporterOperationEventSink( + graph: OperationGraph, + rushSession: RushSession, + commandName: string +): void { + const reporterSink: ReporterOperationEventSink = new ReporterOperationEventSink( + rushSession, + commandName, + graph.operations + ); + if (!reporterSink.isEnabled) { + return; + } + + graph.eventSink = graph.eventSink + ? new CompositeOperationGraphEventSink(graph.eventSink, reporterSink) + : reporterSink; +} + +function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { + switch (status) { + case OperationStatus.Ready: + return 'ready'; + case OperationStatus.Waiting: + return 'waiting'; + case OperationStatus.Queued: + return 'queued'; + case OperationStatus.Executing: + return 'executing'; + case OperationStatus.Success: + return 'success'; + case OperationStatus.SuccessWithWarning: + return 'successWithWarnings'; + case OperationStatus.Failure: + return 'failure'; + case OperationStatus.Blocked: + return 'blocked'; + case OperationStatus.Skipped: + return 'skipped'; + case OperationStatus.FromCache: + return 'fromCache'; + case OperationStatus.NoOp: + return 'noOp'; + case OperationStatus.Aborted: + return 'aborted'; + } +} + +function _getAggregateStatus( + operation: IReporterOperation, + cycle: IReporterOperationCycle +): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...cycle.statuses.values()]; + if ( + statuses.some((status) => status === OperationStatus.Executing) || + cycle.lastEmittedStatus === 'executing' + ) { + if ( + cycle.statuses.size !== operation.legacyOperationIds.size || + statuses.some((status) => !_isTerminalStatus(status)) + ) { + return 'executing'; + } + } + if ( + cycle.statuses.size === operation.legacyOperationIds.size && + statuses.every((status) => _isTerminalStatus(status)) + ) { + return _getAggregateTerminalStatus(statuses); + } + if (statuses.some((status) => status === OperationStatus.Queued)) { + return 'queued'; + } + if (statuses.some((status) => status === OperationStatus.Ready)) { + return 'ready'; + } + if (statuses.some((status) => status === OperationStatus.Waiting)) { + return 'waiting'; + } + return operation.legacyOperationIds.size === 1 + ? _toReporterStatus(statuses[0] ?? OperationStatus.Ready) + : undefined; +} + +function _getAggregateTerminalStatus(operationStatuses: Iterable): ReporterOperationStatus { + const statuses: Set = new Set(operationStatuses); + if (statuses.has(OperationStatus.Failure)) return 'failure'; + if (statuses.has(OperationStatus.Aborted)) return 'aborted'; + if (statuses.has(OperationStatus.Blocked)) return 'blocked'; + if (statuses.has(OperationStatus.SuccessWithWarning)) return 'successWithWarnings'; + if (statuses.has(OperationStatus.Success)) return 'success'; + if (statuses.has(OperationStatus.FromCache)) return 'fromCache'; + if (statuses.has(OperationStatus.Skipped)) return 'skipped'; + return 'noOp'; +} + +function _isTerminalStatus(status: OperationStatus): boolean { + switch (status) { + case OperationStatus.Success: + case OperationStatus.SuccessWithWarning: + case OperationStatus.Failure: + case OperationStatus.Blocked: + case OperationStatus.Skipped: + case OperationStatus.FromCache: + case OperationStatus.NoOp: + case OperationStatus.Aborted: + return true; + default: + return false; + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c16d6c91f32..9a9883dfdb7 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -34,7 +34,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); -import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -46,6 +47,13 @@ import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; +import { + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + RushSession +} from '../../../pluginFramework/RushSession'; +import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; const mockPhase: IPhase = { name: 'phase', @@ -57,12 +65,17 @@ const mockPhase: IPhase = { missingScriptBehavior: 'silent' }; -function createOperation(name: string, runner: IOperationRunner): Operation { +function createOperation( + name: string, + runner: IOperationRunner, + phase: IPhase = mockPhase, + projectName: string = name +): Operation { return new Operation({ runner, logFilenameIdentifier: name, - phase: mockPhase, - project: { packageName: name } as unknown as RushConfigurationProject + phase, + project: { packageName: projectName } as unknown as RushConfigurationProject }); } @@ -95,6 +108,15 @@ class RecordingSink implements IOperationGraphEventSink { } } +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { return { quietMode, @@ -207,4 +229,255 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + + it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + }); + const createFailingOperation = (): Operation => + createOperation( + '@scope/project', + new MockOperationRunner('@scope/project (phase)', async () => OperationStatus.Failure) + ); + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createFailingOperation()]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const operation: Operation = createFailingOperation(); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' || type === 'operationStatusChanged' + ); + expect(operationEvents.length).toBeGreaterThan(1); + for (const event of operationEvents) { + expect(event.scope).toMatchObject({ + commandName: 'build', + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase' + }); + } + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'diagnosticEmitted', + payload: expect.objectContaining({ code: 'RUSH_OPERATION_FAILED' }) + }) + ); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); + + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'sharded-operation-shadow' } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardOneRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 1/2`, + async () => OperationStatus.Success + ); + let shardTwoOutcome: OperationStatus = OperationStatus.Failure; + const shardTwoRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 2/2`, + async () => shardTwoOutcome + ); + const collatorRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - collate`, + async () => OperationStatus.Success + ); + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shardOne: Operation = createOperation('shard-one', shardOneRunner, mockPhase, projectName); + const shardTwo: Operation = createOperation('shard-two', shardTwoRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shardOne.addDependency(preShard); + shardTwo.addDependency(preShard); + collator.addDependency(shardOne); + collator.addDependency(shardTwo); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shardOne, shardTwo]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const reporterOperationId: string = `${projectName}#phase`; + const operationEvents = (): IReporterEmitEventInput[] => + reporterSink.inputs.filter(({ scope }) => scope?.operationId === reporterOperationId); + expect(operationEvents().filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'failure' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + failure: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + + shardTwoOutcome = OperationStatus.Success; + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + expect( + operationEvents() + .filter(({ type }) => type === 'operationRegistered') + .map(({ scope }) => scope?.operationId) + ).toEqual([reporterOperationId, reporterOperationId]); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'success' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + success: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + + const lifecycleEmitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + lifecycleEmitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + lifecycleEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + lifecycleEmitter.emitSessionCompleted({ exitCode: 0 }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); + + it('isolates diagnostics when the next watch iteration registers before abort completes', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'overlapping-operation-shadow' } + }); + let runCount: number = 0; + let resolveFirstRun: ((status: OperationStatus) => void) | undefined; + let markFirstRunStarted: (() => void) | undefined; + const firstRunStarted: Promise = new Promise((resolve: () => void) => { + markFirstRunStarted = resolve; + }); + const runner: MockOperationRunner = new MockOperationRunner('@scope/overlap (phase)', async () => { + runCount++; + if (runCount === 1) { + markFirstRunStarted!(); + return await new Promise((resolve: (status: OperationStatus) => void) => { + resolveFirstRun = resolve; + }); + } + return OperationStatus.Failure; + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('overlap', runner, mockPhase, '@scope/overlap')]), + { ...createGraphOptions(mockWritable, false), pauseNextIteration: true } + ); + attachReporterOperationEventSink(graph, rushSession, 'build'); + + await graph.scheduleIterationAsync({}); + const firstExecution: Promise = graph.executeScheduledIterationAsync(); + await firstRunStarted; + await graph.scheduleIterationAsync({}); + const abortPromise: Promise = graph.abortCurrentIterationAsync(); + resolveFirstRun!(OperationStatus.Failure); + await Promise.all([firstExecution, abortPromise]); + await graph.executeScheduledIterationAsync(); + + expect( + reporterSink.inputs.filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(2); + }); + + it('recomputes grouped silence for each watch-style iteration', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'grouped-silence-shadow' } + }); + const projectName: string = '@scope/silence'; + const first: Operation = createOperation( + 'first', + new MockOperationRunner(`${projectName} (phase) - first`), + mockPhase, + projectName + ); + const second: Operation = createOperation( + 'second', + new MockOperationRunner(`${projectName} (phase) - second`), + mockPhase, + projectName + ); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationId: string = `${projectName}#phase`; + const countEvents = (type: IReporterEmitEventInput['type']): number => + reporterSink.inputs.filter( + ({ type: eventType, scope }) => eventType === type && scope?.operationId === operationId + ).length; + const registrationCount: number = countEvents('operationRegistered'); + const statusCount: number = countEvents('operationStatusChanged'); + expect(registrationCount).toBe(1); + expect(statusCount).toBeGreaterThan(0); + + first.enabled = false; + second.enabled = false; + graph.invalidateOperations(undefined, 'disable group'); + await graph.executeAsync({}); + + expect(countEvents('operationRegistered')).toBe(registrationCount); + expect(countEvents('operationStatusChanged')).toBe(statusCount); + }); }); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index a1e4511b0e2..6bff8eab19e 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -2,12 +2,22 @@ // See LICENSE in the project root for license information. import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import { Rush } from '../../api/Rush'; import { Telemetry, type ITelemetryData, type ITelemetryMachineInfo } from '../Telemetry'; -import { RushSession } from '../../pluginFramework/RushSession'; +import { _getRushSessionLifecycleEmitter, RushSession } from '../../pluginFramework/RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} interface ITelemetryPrivateMembers extends Omit { _flushAsyncTasks: Set>; @@ -136,6 +146,38 @@ describe(Telemetry.name, () => { expect(result.timestampMs).toBeDefined(); }); + it('projects public shadow events into legacy telemetry without exposing command arguments', () => { + const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); + const sink: CapturingSink = new CapturingSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: sink, sessionId: 'telemetry-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=secret'] }); + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'success' }); + + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ + name: 'build', + durationInSeconds: 2, + result: 'Succeeded', + machineInfo: {} as ITelemetryMachineInfo, + performanceEntries: [] + }); + + expect(telemetry.store[0].reporterData).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + expect(JSON.stringify(telemetry.store[0].reporterData)).not.toContain('--auth-token=secret'); + }); + it('calls custom flush telemetry', async () => { const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 26a48160731..c4287f8d580 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -3,16 +3,27 @@ import * as os from 'node:os'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, IReporterEventSink } from '@rushstack/rush-reporter'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { Rush } from '../api/Rush'; import { RushCommandLineParser } from '../cli/RushCommandLineParser'; -import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; +import { + _correlateRushSessionError, + _createRushSessionForPlugin, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + _isRushSessionErrorRepresented, + type IRushSessionReporterOptions, + RushSession +} from './RushSession'; class CapturingSink implements IReporterEventSink { public readonly inputs: IReporterEmitEventInput[] = []; @@ -149,4 +160,86 @@ describe(RushSession.name, () => { action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); }); + + it('observes shadow lifecycle, diagnostics, telemetry, and legacy correlation without terminal output', () => { + const sink: CapturingSink = new CapturingSink(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const session: RushSession = new RushSession({ + getIsDebugMode: () => false, + terminalProvider, + reporter: { eventSink: sink, sessionId: 'session-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + const error: AlreadyReportedError = new AlreadyReportedError(); + + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationRegistered({ + operationId: '@scope/project#_phase:test', + projectName: '@scope/project', + phaseName: '_phase:test' + }); + emitter.emitOperationStatusChanged({ + operationId: '@scope/project#_phase:test', + status: 'failure' + }); + const diagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: '@scope/project', privacy: 'public' } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 1, durationMs: 25 }); + emitter.emitSessionCompleted({ exitCode: 1, durationMs: 30 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(_getRushSessionTelemetryAggregate(session)).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + operationStatusCounts: { failure: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 } + }); + expect(terminalProvider.getAllOutput(false)).toEqual({ + log: '', + warning: '', + error: '', + verbose: '', + debug: '' + }); + }); + + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@private/plugin', + packageVersion: '1.0.0' + })); + + pluginSession.getReporter()!.emitMessage({ + severity: 'info', + text: '/local/private/path' + }); + _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + + const aggregate = _getRushSessionTelemetryAggregate(session)!; + expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); + expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e017a9a8cbc..fa9771ccb08 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -3,10 +3,19 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; import { + LifecycleEmitter, + LegacyErrorBridge, RushSessionReporting, + TelemetrySubscriber, + isReporterEventRequired, + resolveExitStatus, + type IReporterEmitEventInput, + type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IRushExitStatus, + type ITelemetryAggregate, type IScopedLogger, type IScopedReporter } from '@rushstack/rush-reporter'; @@ -77,7 +86,23 @@ interface IRushSessionState { readonly cloudBuildCacheProviderFactories: Map; readonly cobuildLockProviderFactories: Map; readonly hooks: RushLifecycleHooks; - readonly reporting: RushSessionReporting | undefined; + readonly reporting: IRushSessionReportingState | undefined; +} + +interface IRushSessionReportingState { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sessionReporting: RushSessionReporting; + readonly observer: IRushSessionShadowEventObserver; +} + +interface IRushSessionShadowEventObserver { + ingest(event: IReporterEmitEventInput, eventId: string): void; + buildTelemetryAggregate(): ITelemetryAggregate; + resolveExitStatus(): IRushExitStatus; + correlateError(error: unknown, diagnosticId: string): void; + isErrorRepresented(error: unknown): boolean; } let _rushLibSource: IReporterEventSource | undefined; @@ -107,8 +132,9 @@ function _getRushLibSource(): IReporterEventSource { function _createReporting( reporterOptions: IRushSessionReporterOptions | undefined, - source: IReporterEventSource -): RushSessionReporting | undefined { + source: IReporterEventSource, + observer?: IRushSessionShadowEventObserver +): IRushSessionReportingState | undefined { if (!reporterOptions) { return undefined; } @@ -121,10 +147,148 @@ function _createReporting( throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); } - return new RushSessionReporting({ - sink: eventSink, + const shadowObserver: IRushSessionShadowEventObserver = observer ?? _createRushSessionShadowEventObserver(); + const observedEventSink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const eventId: string = eventSink.emit(event); + shadowObserver.ingest(event, eventId); + return eventId; + } + }; + const boundSource: IReporterEventSource = { ...source }; + + return { + eventSink: observedEventSink, sessionId, - source: { ...source } + source: boundSource, + observer: shadowObserver, + sessionReporting: new RushSessionReporting({ + sink: observedEventSink, + sessionId, + source: boundSource + }) + }; +} + +function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserver { + const legacyErrorBridge: LegacyErrorBridge = new LegacyErrorBridge(); + const telemetrySubscriber: TelemetrySubscriber = new TelemetrySubscriber(); + const operationStatuses: Map = new Map(); + let sequence: number = 0; + let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let hasUnscopedFailure: boolean = false; + + const updateDerivedOperationStatus = (): void => { + const hasOperationFailure: boolean = [...operationStatuses.values()].some( + (status) => status === 'failure' || status === 'aborted' + ); + derivedExitStatus = resolveExitStatus({ + hasFailures: hasUnscopedFailure || hasOperationFailure + }); + }; + + return { + ingest(event: IReporterEmitEventInput, eventId: string): void { + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: ++sequence, + timestamp: new Date().toISOString(), + required: isReporterEventRequired(event.type) + }; + legacyErrorBridge.ingest(envelope); + + if (envelope.parentSessionId === undefined) { + switch (envelope.type) { + case 'commandStarted': { + operationStatuses.clear(); + hasUnscopedFailure = false; + derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; + break; + } + case 'operationRegistered': { + const { operationId } = envelope.payload as { operationId: string }; + operationStatuses.set(operationId, 'ready'); + updateDerivedOperationStatus(); + break; + } + case 'operationStatusChanged': { + const { operationId, status } = envelope.payload as { + operationId: string; + status: string; + }; + operationStatuses.set(operationId, status); + updateDerivedOperationStatus(); + break; + } + case 'diagnosticEmitted': { + const { severity } = envelope.payload as { severity?: string }; + if (severity === 'error' && envelope.scope?.operationId === undefined) { + hasUnscopedFailure = true; + updateDerivedOperationStatus(); + } + break; + } + case 'commandResult': { + const { succeeded, exitCode } = envelope.payload as { + succeeded: boolean; + exitCode: number; + }; + derivedExitStatus = resolveExitStatus({ + hasFailures: !succeeded || exitCode !== 0 + }); + break; + } + case 'commandCompleted': + case 'sessionCompleted': { + const { exitCode } = envelope.payload as { exitCode: number }; + derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + break; + } + default: + break; + } + } + + // Match the privacy behavior from #5990 without duplicating its reporter-package changes: + // only public envelopes contribute source, protocol, lifecycle, or diagnostic telemetry. + // Remove this outer gate after #5990 reaches shared main and the hardened subscriber is in this ancestry. + if (envelope.privacy === 'public') { + telemetrySubscriber.ingest(envelope); + } + }, + + buildTelemetryAggregate(): ITelemetryAggregate { + return telemetrySubscriber.buildAggregate(); + }, + + resolveExitStatus(): IRushExitStatus { + return derivedExitStatus; + }, + + correlateError(error: unknown, diagnosticId: string): void { + legacyErrorBridge.correlate(error, diagnosticId); + }, + + isErrorRepresented(error: unknown): boolean { + return legacyErrorBridge.shouldSuppressRendering(error); + } + }; +} + +function _createLifecycleEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + if (!state) { + return undefined; + } + + return new LifecycleEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined }); } @@ -181,7 +345,9 @@ export class RushSession { * source identity bound by Rush. */ public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { - return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedReporter( + scope ? { ...scope } : undefined + ); } /** @@ -193,7 +359,9 @@ export class RushSession { * available during the pre-major compatibility period. */ public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { - return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedLogger( + scope ? { ...scope } : undefined + ); } public registerCloudBuildCacheProviderFactory( @@ -248,7 +416,8 @@ export function _createRushSessionForPlugin( getSource: () => IReporterEventSource ): RushSession { const state: IRushSessionState = _getSessionState(rushSession); - if (!state.options.reporter) { + const reporting: IRushSessionReportingState | undefined = state.reporting; + if (!state.options.reporter || !reporting) { return rushSession; } @@ -264,7 +433,68 @@ export function _createRushSessionForPlugin( cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, cobuildLockProviderFactories: state.cobuildLockProviderFactories, hooks: state.hooks, - reporting: _createReporting(state.options.reporter, getSource()) + reporting: _createReporting(state.options.reporter, getSource(), reporting.observer) }); return pluginSession; } + +/** + * Creates a Rush-owned lifecycle emitter for internal command and operation paths. + * + * @internal + */ +export function _getRushSessionLifecycleEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); +} + +/** + * Returns the current allowlisted reporter telemetry projection. + * + * @internal + */ +export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITelemetryAggregate | undefined { + return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); +} + +/** + * Derives the shadow exit status without changing the authoritative process exit code. + * + * @internal + */ +export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +} + +/** + * Returns the Rush version bound to structured events for this session. + * + * @internal + */ +export function _getRushSessionReporterSourceVersion(rushSession: RushSession): string | undefined { + return _getSessionState(rushSession).reporting?.source.packageVersion; +} + +/** + * Correlates a legacy failure sentinel with an emitted structured diagnostic. + * + * @internal + */ +export function _correlateRushSessionError( + rushSession: RushSession, + error: unknown, + diagnosticId: string +): void { + _getSessionState(rushSession).reporting?.observer.correlateError(error, diagnosticId); +} + +/** + * Returns whether a failure is already represented by an emitted diagnostic or legacy sentinel. + * + * @internal + */ +export function _isRushSessionErrorRepresented(rushSession: RushSession, error: unknown): boolean { + return _getSessionState(rushSession).reporting?.observer.isErrorRepresented(error) ?? false; +} diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index ed7bf36bcca..91ff006d264 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -335,6 +335,12 @@ required parent/wire reporter is fatal. Failure to create the full-detail file at both repository and OS-temp paths is nonfatal but emits an emergency warning and marks the artifact unavailable. +The engine's root reporting context is available before fallible repository +initialization. Failures before command selection emit a session-scoped +diagnostic and failure completion before reporter close. Successful command +completion is published only after command finalization, including the public +telemetry flush hooks, so reporter results retain the native exit outcome. + ### 5.5 Bootstrap and Wire Protocol `install-run-rush` performs a minimal prelude: From 9834e3074d7194b743249f1360bd6ee837e5c3e9 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 00:02:44 +0000 Subject: [PATCH 06/22] Refresh R3C parity coverage onto native-private trunk Retain the published cancellation, identity, output and shadow parity slice on the reconciled R3B parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...er-r3c-shadow-parity_2026-08-28-04-55.json | 11 ++ .../test/OperationGraphEventSink.test.ts | 86 ++++++++++++ .../src/pluginFramework/RushSession.test.ts | 123 +++++++++++++++++- .../src/pluginFramework/RushSession.ts | 22 ++-- 4 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json new file mode 100644 index 00000000000..a51b0ff8971 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Complete shadow reporter parity coverage for event identity, telemetry privacy, exit status, repeated operation phases, and unchanged legacy output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 9a9883dfdb7..e91029084c0 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -480,4 +480,90 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(countEvents('operationRegistered')).toBe(registrationCount); expect(countEvents('operationStatusChanged')).toBe(statusCount); }); + + it('keeps project x phase identities stable across repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + }); + const compilePhase: IPhase = { + ...mockPhase, + name: '_phase:compile', + logFilenameIdentifier: '_phase_compile' + }; + const testPhase: IPhase = { + ...mockPhase, + name: '_phase:test', + logFilenameIdentifier: '_phase_test' + }; + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation( + '@scope/project compile', + new MockOperationRunner('@scope/project (_phase:compile)'), + compilePhase, + '@scope/project' + ), + createOperation( + '@scope/project test', + new MockOperationRunner('@scope/project (_phase:test)'), + testPhase, + '@scope/project' + ) + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + const registrations: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' + ); + expect(registrations.map(({ scope }) => scope?.operationId)).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { + expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); + expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); + } + }); + + it('leaves stdout, stderr, and StreamCollator rendering byte-identical with shadow reporting', async () => { + const createOutputRunner = (): MockOperationRunner => + new MockOperationRunner('output', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('shadow parity stdout'); + terminal.writeStderrLine('shadow parity stderr'); + return OperationStatus.Success; + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + }); + const shadowWritable: MockWritable = new MockWritable(); + const shadowGraph: OperationGraph = new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(shadowWritable, false) + ); + attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + await shadowGraph.executeAsync({}); + expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index c4287f8d580..348663bf53d 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -7,7 +7,10 @@ import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, - IReporterEventSink + IReporterEventSink, + IResolveExitStatusFromEventsOptions, + IRushExitStatus, + LifecycleEmitter } from '@rushstack/rush-reporter'; import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; @@ -45,11 +48,16 @@ function createSession(reporter?: IRushSessionReporterOptions): RushSession { describe(RushSession.name, () => { it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { const session: RushSession = createSession(); + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: os.tmpdir() }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; expect(session.getReporter()).toBeUndefined(); expect(session.getScopedLogger()).toBeUndefined(); expect(session.getLogger('legacy')).toBeDefined(); expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + expect(action?.reporter).toBeUndefined(); }); it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { @@ -223,6 +231,100 @@ describe(RushSession.name, () => { }); }); + it('preserves event order, correlation, session identity, and trusted producer identity', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'ordered-session' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + })); + const sessionEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + const commandEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { + commandName: 'build' + })!; + const diagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED'); + const error: Error = new Error('represented'); + + sessionEmitter.emitSessionStarted({ rushVersion: Rush.version }); + commandEmitter.emitCommandStarted({ commandName: 'build' }); + pluginSession.getReporter({ commandName: 'build' })!.emitMessage({ + severity: 'info', + text: 'plugin message' + }); + commandEmitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + commandEmitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + commandEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 1 }); + sessionEmitter.emitSessionCompleted({ exitCode: 1 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'messageEmitted', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(new Set(sink.inputs.map(({ sessionId }) => sessionId))).toEqual(new Set(['ordered-session'])); + expect(sink.inputs[0].source).toMatchObject({ + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }); + expect(sink.inputs[2].source).toEqual({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }); + expect(sink.inputs[3].payload).toMatchObject({ diagnosticId: diagnostic.diagnosticId }); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + }); + + it('derives legacy-compatible exit status for success, warnings, failures, cancellation, and errors', () => { + const derive = ( + emitEvents: (emitter: LifecycleEmitter) => void, + options?: IResolveExitStatusFromEventsOptions + ): IRushExitStatus => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'exit-session' + }); + emitEvents(_getRushSessionLifecycleEmitter(session, { commandName: 'build' })!); + return _getRushSessionDerivedExitStatus(session, options)!; + }; + + expect( + derive((emitter) => { + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED', { severity: 'warning' })); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'failure' }); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + + expect(derive(() => {}, { cancelled: true })).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + for (const code of ['RUSH_CONFIG_INVALID_JSON', 'RUSH_INTERNAL_UNEXPECTED'] as const) { + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic(code)); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + } + }); + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { const sink: CapturingSink = new CapturingSink(); const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); @@ -235,11 +337,28 @@ describe(RushSession.name, () => { severity: 'info', text: '/local/private/path' }); - _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + pluginSession.getReporter()!.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: 'private-secret-token', privacy: 'secret' } + } + }) + ); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=public-envelope-secret'] }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); const aggregate = _getRushSessionTelemetryAggregate(session)!; expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(JSON.stringify(aggregate)).not.toContain('private-secret-token'); + expect(JSON.stringify(aggregate)).not.toContain('--auth-token=public-envelope-secret'); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0 + }); expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index fa9771ccb08..e52cbb0ad02 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -8,12 +8,13 @@ import { RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, - resolveExitStatus, + resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IResolveExitStatusFromEventsOptions, type IRushExitStatus, type ITelemetryAggregate, type IScopedLogger, @@ -100,7 +101,7 @@ interface IRushSessionReportingState { interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; - resolveExitStatus(): IRushExitStatus; + resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; correlateError(error: unknown, diagnosticId: string): void; isErrorRepresented(error: unknown): boolean; } @@ -182,7 +183,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve const hasOperationFailure: boolean = [...operationStatuses.values()].some( (status) => status === 'failure' || status === 'aborted' ); - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: hasUnscopedFailure || hasOperationFailure }); }; @@ -234,7 +235,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve succeeded: boolean; exitCode: number; }; - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: !succeeded || exitCode !== 0 }); break; @@ -242,7 +243,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandCompleted': case 'sessionCompleted': { const { exitCode } = envelope.payload as { exitCode: number }; - derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + derivedExitStatus = resolveRushExitStatus({ hasFailures: exitCode !== 0 }); break; } default: @@ -262,8 +263,8 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve return telemetrySubscriber.buildAggregate(); }, - resolveExitStatus(): IRushExitStatus { - return derivedExitStatus; + resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus { + return resolveRushExitStatus({ hasFailures: derivedExitStatus.exitCode !== 0, ...options }); }, correlateError(error: unknown, diagnosticId: string): void { @@ -464,8 +465,11 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe * * @internal */ -export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { - return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +export function _getRushSessionDerivedExitStatus( + rushSession: RushSession, + options?: IResolveExitStatusFromEventsOptions +): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(options); } /** From 0749f3852afcb8df3bbd33a1f1342dc6a3ae1c59 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 17:54:57 +0000 Subject: [PATCH 07/22] Clarify the frontend reporter channel identity contract Document both the typed event sink and the frontend-assigned sessionId in the cross-version handoff without changing its shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +++-- .../reporter-session-handoff-docs_2026-09-10.json | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 920ae96235f..603f1863bba 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -8,8 +8,9 @@ import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rus * * @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. + * receives a reporter channel containing the typed producer event sink and the + * frontend-assigned `sessionId`, in addition to its existing launch options. + * An older engine can safely ignore this additive reporter property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; diff --git a/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json b/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json new file mode 100644 index 00000000000..8169968fab3 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Clarify that the frontend reporter handoff contains both the typed event sink and the frontend-assigned session identity.", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} From 65ba96baaccab4d675ca89a978f84396474a56d3 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:11:28 +0000 Subject: [PATCH 08/22] Preserve rollback flags and primary file detail defaults Share separated-value recognition with stripping so valueless controls cannot consume legacy flags, and use debug only as the unrequested primary file log-level default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/RushReporterHost.ts | 17 +++-- apps/rush/src/test/RushReporterHost.test.ts | 70 +++++++++++++++++++ ...er-rollback-and-file-level_2026-09-10.json | 11 +++ 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 8ca6b07f9b0..09873be4945 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -176,6 +176,10 @@ class ExplicitOutputReporter implements IReporter { } } +function isSeparatedControlValue(value: string | undefined): value is string { + return value !== undefined && value.length > 0 && !value.startsWith('-'); +} + function readValue( argv: readonly string[], index: number, @@ -195,7 +199,7 @@ function readValue( } const value: string | undefined = argv[index + 1]; - if (!value || value.startsWith('-')) { + if (!isSeparatedControlValue(value)) { throw new Error(`${flag} requires a value.`); } return { value, consumedNext: true }; @@ -218,7 +222,7 @@ export function stripReporterValueControls( result.push(argument); continue; } - if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + if (equalsIndex < 0 && isSeparatedControlValue(argv[index + 1])) { index++; } } @@ -245,7 +249,7 @@ function parseReporterControls( if ( tolerateMissingReporterValue && argument === '--reporter' && - (!argv[index + 1] || argv[index + 1].startsWith('-')) + !isSeparatedControlValue(argv[index + 1]) ) { continue; } @@ -325,7 +329,8 @@ function resolveLogLevel( controls: IParsedReporterControls, env: Record, includeEnvironment: boolean, - useLegacyAliasPrecedence: boolean = false + useLegacyAliasPrecedence: boolean = false, + defaultLogLevel: ReporterLogLevel = 'normal' ): ReporterLogLevel { const requestedLevels: ReporterLogLevel[] = []; const explicitLogLevel: string | undefined = controls.logLevels[0]; @@ -382,7 +387,7 @@ function resolveLogLevel( return normalizedLogLevel; } - return 'normal'; + return defaultLogLevel; } function isReporterStreamTarget(target: string): target is 'stdout' | 'stderr' { @@ -574,7 +579,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return { reporter: requestedReporter, - logLevel: resolveLogLevel(controls, env, true), + logLevel: resolveLogLevel(controls, env, true, false, requestedReporter === 'file' ? 'debug' : 'normal'), outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index d67e4da3656..25acd8d89aa 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -243,6 +243,19 @@ describe(resolveRushReporterSelection.name, () => { ]); }); + it.each(['--reporter', '--output', '--log-level'])( + 'does not consume legacy flags after a value-less %s during rollback', + (flag) => { + const argv: string[] = ['build', '--reporter=json', flag, '--quiet', '--debug']; + const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--quiet', + '--debug' + ]); + } + ); + it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -334,6 +347,25 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('defaults only an unqualified primary file reporter to debug', () => { + expect(resolve(['build', '--reporter=file']).logLevel).toBe('debug'); + expect(resolve(['build', '--reporter=plaintext']).logLevel).toBe('normal'); + for (const level of ['quiet', 'normal', 'verbose', 'debug']) { + expect(resolve(['build', '--reporter=file', `--log-level=${level}`]).logLevel).toBe(level); + expect(resolve(['build', '--reporter=file'], { RUSH_LOG_LEVEL: level }).logLevel).toBe(level); + } + expect(resolve(['build', '--reporter=file', '--quiet'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe( + 'quiet' + ); + expect(resolve(['build', '--reporter=file', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel).toBe( + 'verbose' + ); + expect(resolve(['build', '--reporter=file', '--debug'], { RUSH_LOG_LEVEL: 'normal' }).logLevel).toBe( + 'debug' + ); + expect(resolve(['build', '--reporter=file'], { RUSH_REPORTER: 'legacy' }).enabled).toBe(false); + }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', @@ -468,6 +500,44 @@ describe(resolveRushReporterSelection.name, () => { }); describe(initializeRushReporterHostAsync.name, () => { + it.each([false, true])( + 'retains primary file debug details unless normal is explicit: %s', + async (normal) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-level-')); + const osModule: typeof os = jest.requireActual('node:os'); + const tmpdirSpy: jest.SpyInstance = jest.spyOn(osModule, 'tmpdir').mockReturnValue(directory); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=file', ...(normal ? ['--log-level=normal'] : [])], + env: {}, + stdout: { write: () => undefined }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'primary-file-level', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.179.0' }, + privacy: 'public', + type: 'messageEmitted', + payload: { severity: 'debug', text: 'retained-debug-detail' } + }); + await initialized.closeAsync(); + + const [logFolder]: string[] = await fs.promises.readdir(directory); + const names: string[] = await fs.promises.readdir(path.join(directory, logFolder)); + const logName: string | undefined = names.find( + (name) => name.endsWith('.log') && name !== 'latest.log' + ); + expect(logName).toBeDefined(); + const text: string = await fs.promises.readFile(path.join(directory, logFolder, logName!), 'utf8'); + expect(text.includes('retained-debug-detail')).toBe(!normal); + } finally { + tmpdirSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + it.each([ { target: 'stdout', outputs: ['json://stdout'] }, { target: 'stderr', outputs: ['json://stderr', 'file://stderr'] } diff --git a/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json b/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json new file mode 100644 index 00000000000..424f2c2bbd5 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve legacy flags after valueless reporter rollback controls and default an unqualified primary file reporter to debug detail.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} From 3fbddc0c8a9ac40b2ca89bbc6d77f73390cfe251 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:26:12 +0000 Subject: [PATCH 09/22] Connect watch cancellation to shadow parity observation Retain selected-action cancellation for subsequent shadow observations while preserving legacy process and telemetry results. Exercise a native watch session and compare raw terminal chunks and per-stream bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../copilot-review-parity_2026-09-10.json | 11 ++ libraries/reporter/README.md | 11 ++ .../rush-lib/src/cli/RushCommandLineParser.ts | 9 +- ...CommandLineParserReporterLifecycle.test.ts | 119 +++++++++++++++++- .../test/OperationGraphEventSink.test.ts | 38 ++++-- .../src/pluginFramework/RushSession.test.ts | 36 +++++- .../src/pluginFramework/RushSession.ts | 26 +++- 7 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json diff --git a/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json b/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json new file mode 100644 index 00000000000..6c34349ec54 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-review-parity_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Connect real watch cancellation to the persistent shadow exit-status observer without changing legacy process status, and verify raw stdout/stderr chunk parity.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index b326abb9f19..f71f7c7f4d3 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -4,6 +4,17 @@ Canonical event protocol, reporter manager, and built-in reporters for Rush. This package is released as a public beta. Exported contracts may change before the stable release. +## Shadow parity + +Rush's shadow session observer records the selected phased action's real cancellation state at completion. +A gracefully stopped watch command therefore derives the existing logical `cancelled` outcome on subsequent +observations, even when native Rush returns normally with process exit code 0. Legacy completion payloads and +binary telemetry results continue to describe that native exit; shadow reporting does not change process status. +The recorded cancellation state is reset when a new command starts. + +Operation output parity tests compare raw terminal chunks, including stream identity and unnormalized ANSI +text, as well as the actual bytes on each stdout/stderr stream. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 1415b217422..39f6e526385 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -70,7 +70,8 @@ import { _getRushSessionDerivedExitStatus, _getRushSessionLifecycleEmitter, _getRushSessionReporterSourceVersion, - _isRushSessionErrorRepresented + _isRushSessionErrorRepresented, + _setRushSessionExitStatusOptions } from '../pluginFramework/RushSession'; /** @@ -698,6 +699,12 @@ export class RushCommandLineParser extends CommandLineParser { } this.#reporterCompletionEmitted = true; + _setRushSessionExitStatusOptions(this.rushSession, { + cancelled: + this.selectedAction instanceof PhasedScriptAction && + this.selectedAction.sessionAbortController.signal.aborted + }); + const commandName: string | undefined = this.selectedAction?.actionName; if (commandName && this.#commandLifecycleEmitter) { const durationMs: number | undefined = diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 774265bc22b..b30b222a3ca 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -1,9 +1,11 @@ // 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 fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { once } from 'node:events'; import { JsonFile } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; @@ -12,9 +14,13 @@ import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { IRushConfigurationJson } from '../../api/RushConfiguration'; import { _getRushSessionDerivedExitStatus, + _getRushSessionTelemetryAggregate, _isRushSessionErrorRepresented } from '../../pluginFramework/RushSession'; import { RushCommandLineParser } from '../RushCommandLineParser'; +import { PhasedScriptAction } from '../scriptActions/PhasedScriptAction'; +import { FlagFile } from '../../api/FlagFile'; +import { RushConstants } from '../../logic/RushConstants'; class CapturingReporterSink implements IReporterEventSink { public readonly events: IReporterEmitEventInput[] = []; @@ -246,4 +252,115 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(visibleErrors[1]).toEqual(visibleErrors[0]); }); + + it.each([false, true])( + 'observes a real watch cancellation without changing legacy exit (shadow: %s)', + async (reporting) => { + const repoPath: string = await copyRepositoryAsync(); + JsonFile.save( + { + commands: [ + { + commandKind: 'bulk', + name: 'watch-test', + summary: 'Watch cancellation fixture', + watchForChanges: true, + enableParallelism: false, + disableBuildCache: true, + safeForSimultaneousRushProcesses: true + } + ] + }, + path.join(repoPath, 'common/config/rush/command-line.json') + ); + JsonFile.save({}, path.join(repoPath, 'common/config/rush/npm-shrinkwrap.json')); + for (const name of ['a', 'b']) { + JsonFile.save( + { name, version: '1.0.0', scripts: { 'watch-test': 'node watch-test.js' } }, + path.join(repoPath, name, 'package.json') + ); + await fs.promises.writeFile( + path.join(repoPath, name, 'watch-test.js'), + 'process.stdout.write("watch child output\\n");\n' + ); + } + execFileSync('git', ['init', '--quiet'], { cwd: repoPath }); + execFileSync('git', ['add', '.'], { cwd: repoPath }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Rush test', + '-c', + 'user.email=rush-test@example.com', + '-c', + 'commit.gpgSign=false', + 'commit', + '--quiet', + '-m', + 'Initialize watch fixture' + ], + { cwd: repoPath } + ); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const watchSpy: jest.SpyInstance = jest.spyOn(fs, 'watch'); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'real-watch-cancellation' } : undefined + }); + await new FlagFile( + parser.rushConfiguration.defaultSubspace.getSubspaceTempFolderPath(), + RushConstants.lastLinkFlagFilename, + {} + ).createAsync(); + const action = parser.getAction('watch-test'); + if (!(action instanceof PhasedScriptAction)) { + throw new Error('Expected the production phased watch action'); + } + let reachedWatchIdle: boolean = false; + let closedWatchers: Promise[] = []; + parser.rushSession.hooks.runPhasedCommand.for('watch-test').tap('CancelRealWatch', (command) => { + command.hooks.onGraphCreatedAsync.tap('CancelRealWatch', (graph) => { + graph.hooks.onIdle.tap({ name: 'CancelRealWatch', stage: Number.MAX_SAFE_INTEGER }, () => { + reachedWatchIdle = true; + closedWatchers = watchSpy.mock.results.map(({ value }) => once(value as fs.FSWatcher, 'close')); + action.sessionAbortController.abort(); + }); + }); + }); + const execution: Promise = parser.executeAsync(['watch-test', '--verbose']); + try { + await expect(execution).resolves.toBe(true); + await Promise.all(closedWatchers); + expect(reachedWatchIdle).toBe(true); + expect(watchSpy.mock.calls.length).toBeGreaterThan(0); + expect(action.sessionAbortController.signal.aborted).toBe(true); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual( + reporting ? { exitCode: 1, outcome: 'cancelled' } : undefined + ); + if (reporting) { + expect(sink.events.filter(isCompletion).map(({ payload }) => payload)).toEqual([ + expect.objectContaining({ succeeded: true, exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }) + ]); + expect(_getRushSessionTelemetryAggregate(parser.rushSession)).toMatchObject({ + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 2 } + }); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + } + } finally { + action.sessionAbortController.abort(); + await execution; + await Promise.all(closedWatchers); + } + } + ); }); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index e91029084c0..fcbe0d344dd 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -// Deterministic Stopwatch timing, matching OperationGraph.test.ts +// Exercise the color-preserving terminal pipeline on every test platform. jest.mock('@rushstack/terminal', () => { const originalModule = jest.requireActual('@rushstack/terminal'); return { @@ -35,7 +35,12 @@ jest.mock('../ProjectLogWritable', () => { }); import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; -import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; +import { + MockWritable, + StringBufferTerminalProvider, + TerminalChunkKind, + type ITerminalChunk +} from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -227,7 +232,7 @@ describe('OperationGraph event sink (dual-emit)', () => { tappedGraph.eventSink = new RecordingSink(); await tappedGraph.executeAsync({}); - expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(tappedWritable.chunks).toEqual(plainWritable.chunks); }); it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { @@ -276,7 +281,7 @@ describe('OperationGraph event sink (dual-emit)', () => { }) ); expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); - expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(mockWritable.chunks).toEqual(plainWritable.chunks); }); it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { @@ -539,8 +544,8 @@ describe('OperationGraph event sink (dual-emit)', () => { it('leaves stdout, stderr, and StreamCollator rendering byte-identical with shadow reporting', async () => { const createOutputRunner = (): MockOperationRunner => new MockOperationRunner('output', async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('shadow parity stdout'); - terminal.writeStderrLine('shadow parity stderr'); + terminal.writeStdoutLine('\u001b[32mshadow parity stdout\u001b[0m'); + terminal.writeStderrLine('\u001b[31mshadow parity stderr\u001b[0m'); return OperationStatus.Success; }); @@ -563,7 +568,26 @@ describe('OperationGraph event sink (dual-emit)', () => { ); attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); await shadowGraph.executeAsync({}); - expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(shadowWritable.chunks).toEqual(plainWritable.chunks); + for (const [kind, text] of [ + [TerminalChunkKind.Stdout, '\u001b[32mshadow parity stdout\u001b[0m'], + [TerminalChunkKind.Stderr, '\u001b[31mshadow parity stderr\u001b[0m'] + ] as const) { + const plainBytes: Buffer = Buffer.from( + plainWritable.chunks + .filter((chunk) => chunk.kind === kind) + .map((chunk) => chunk.text) + .join('') + ); + const shadowBytes: Buffer = Buffer.from( + shadowWritable.chunks + .filter((chunk) => chunk.kind === kind) + .map((chunk) => chunk.text) + .join('') + ); + expect(plainBytes.includes(Buffer.from(text))).toBe(true); + expect(shadowBytes).toEqual(plainBytes); + } expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 348663bf53d..f771fd1f5e7 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -24,6 +24,7 @@ import { _getRushSessionLifecycleEmitter, _getRushSessionTelemetryAggregate, _isRushSessionErrorRepresented, + _setRushSessionExitStatusOptions, type IRushSessionReporterOptions, RushSession } from './RushSession'; @@ -50,8 +51,7 @@ describe(RushSession.name, () => { const session: RushSession = createSession(); const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: os.tmpdir() }); const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as - | { reporter?: ReturnType } - | undefined; + { reporter?: ReturnType } | undefined; expect(session.getReporter()).toBeUndefined(); expect(session.getScopedLogger()).toBeUndefined(); @@ -161,8 +161,7 @@ describe(RushSession.name, () => { reporter: { eventSink: sink, sessionId: 'session-4' } }); const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as - | { reporter?: ReturnType } - | undefined; + { reporter?: ReturnType } | undefined; expect(action?.reporter).toBeDefined(); action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); @@ -325,6 +324,35 @@ describe(RushSession.name, () => { } }); + it('retains command cancellation through completion and scoped observations, but not the next command', () => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'cancelled-command' + }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/plugin', + packageVersion: '1.0.0' + })); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build' }); + _setRushSessionExitStatusOptions(session, { cancelled: true }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + expect(_getRushSessionDerivedExitStatus(pluginSession)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + expect(_getRushSessionDerivedExitStatus(session, { cancelled: false })).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(_getRushSessionDerivedExitStatus(session, { signal: 'SIGTERM' })?.outcome).toBe('signal'); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + emitter.emitCommandStarted({ commandName: 'build' }); + expect(_getRushSessionDerivedExitStatus(pluginSession)).toEqual({ exitCode: 0, outcome: 'succeeded' }); + }); + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { const sink: CapturingSink = new CapturingSink(); const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e52cbb0ad02..848e128059f 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -101,6 +101,7 @@ interface IRushSessionReportingState { interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; + setExitStatusOptions(options: IResolveExitStatusFromEventsOptions): void; resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; correlateError(error: unknown, diagnosticId: string): void; isErrorRepresented(error: unknown): boolean; @@ -177,6 +178,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve const operationStatuses: Map = new Map(); let sequence: number = 0; let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let commandExitStatusOptions: IResolveExitStatusFromEventsOptions = {}; let hasUnscopedFailure: boolean = false; const updateDerivedOperationStatus = (): void => { @@ -204,6 +206,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandStarted': { operationStatuses.clear(); hasUnscopedFailure = false; + commandExitStatusOptions = {}; derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; break; } @@ -263,8 +266,16 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve return telemetrySubscriber.buildAggregate(); }, + setExitStatusOptions(options: IResolveExitStatusFromEventsOptions): void { + commandExitStatusOptions = { ...options }; + }, + resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus { - return resolveRushExitStatus({ hasFailures: derivedExitStatus.exitCode !== 0, ...options }); + return resolveRushExitStatus({ + hasFailures: derivedExitStatus.exitCode !== 0, + ...commandExitStatusOptions, + ...options + }); }, correlateError(error: unknown, diagnosticId: string): void { @@ -460,6 +471,19 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); } +/** + * Records real command cancellation/signal state for subsequent shadow observations. + * Legacy completion payloads and telemetry retain their authoritative process exit code. + * + * @internal + */ +export function _setRushSessionExitStatusOptions( + rushSession: RushSession, + options: IResolveExitStatusFromEventsOptions +): void { + _getSessionState(rushSession).reporting?.observer.setExitStatusOptions(options); +} + /** * Derives the shadow exit status without changing the authoritative process exit code. * From 6d645ad3fc9e5b8330d3ec39cb96411756cf799a Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:26:12 +0000 Subject: [PATCH 10/22] Fix shadow lifecycle error correlation and final registration Keep immutable errors intact, capture original pre-execution parser failures without changing legacy rendering, and observe final configured operation silence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../copilot-review-lifecycle_2026-09-10.json | 11 +++ .../copilot-review-lifecycle_2026-09-10.json | 11 +++ libraries/reporter/README.md | 10 +++ .../reporter/src/compat/LegacyErrorBridge.ts | 8 +- .../src/test/LegacyErrorBridge.test.ts | 22 +++++ .../rush-lib/src/cli/RushCommandLineParser.ts | 22 ++++- ...CommandLineParserReporterLifecycle.test.ts | 81 ++++++++++++++++++- .../src/logic/operations/OperationGraph.ts | 2 +- .../test/OperationGraphEventSink.test.ts | 52 ++++++++++++ 9 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json diff --git a/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json b/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json new file mode 100644 index 00000000000..6e8a6c6dc5a --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve immutable errors, diagnose pre-execution parser failures once, and register shadow operations after final watch iteration configuration.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json b/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json new file mode 100644 index 00000000000..9f2705adb2d --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Correlate diagnostics using weak metadata instead of mutating potentially frozen or non-extensible errors.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index b326abb9f19..4205016018d 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -4,6 +4,16 @@ Canonical event protocol, reporter manager, and built-in reporters for Rush. This package is released as a public beta. Exported contracts may change before the stable release. +## Shadow lifecycle compatibility + +Error correlation uses external weak metadata, so frozen and non-extensible errors retain their original +identity, cause, and properties. Correlation remains visible across bridge instances without keeping errors alive. + +Rush command-line parse failures emit one session-scoped `RUSH_COMMAND_FAILED` diagnostic before completion. +The original parser message is retained in the diagnostic's local-sensitive `message` parameter; native error +rendering and exit codes remain unchanged. Operation registration observes the final iteration configuration, +so unchanged watch operations do not produce visible shadow registration or status events. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/reporter/src/compat/LegacyErrorBridge.ts b/libraries/reporter/src/compat/LegacyErrorBridge.ts index 69c84e6c189..5bc6fb8d7dd 100644 --- a/libraries/reporter/src/compat/LegacyErrorBridge.ts +++ b/libraries/reporter/src/compat/LegacyErrorBridge.ts @@ -11,7 +11,7 @@ import { RushError } from '../diagnostics/RushError'; */ export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError' = 'AlreadyReportedError'; -const CORRELATION_KEY: unique symbol = Symbol('rush-reporter-correlated-diagnostic-id'); +const correlatedDiagnosticIds: WeakMap = new WeakMap(); /** * The criteria that must be met before the legacy error bridge is removed. @@ -94,11 +94,11 @@ export class LegacyErrorBridge { } /** - * Correlates a legacy sentinel error with the diagnostic id it corresponds to. + * Correlates an error with its diagnostic id without modifying the supplied object. */ public correlate(error: unknown, diagnosticId: string): void { if (typeof error === 'object' && error !== null) { - (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY] = diagnosticId; + correlatedDiagnosticIds.set(error, diagnosticId); } } @@ -107,7 +107,7 @@ export class LegacyErrorBridge { */ public getCorrelatedDiagnosticId(error: unknown): string | undefined { if (typeof error === 'object' && error !== null) { - return (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY]; + return correlatedDiagnosticIds.get(error); } return undefined; } diff --git a/libraries/reporter/src/test/LegacyErrorBridge.test.ts b/libraries/reporter/src/test/LegacyErrorBridge.test.ts index d230cb60805..fff9951bce7 100644 --- a/libraries/reporter/src/test/LegacyErrorBridge.test.ts +++ b/libraries/reporter/src/test/LegacyErrorBridge.test.ts @@ -86,4 +86,26 @@ describe('LegacyErrorBridge', () => { bridge.recordEmittedDiagnostic('diag_2'); expect(bridge.shouldSuppressRendering(sentinel)).toBe(true); }); + + it.each([Object.freeze, Object.seal, Object.preventExtensions])( + 'correlates an immutable error without modifying its identity, cause, or properties (%p)', + (restrict) => { + const cause: Error = new Error('original cause'); + const error: Error = new Error('original failure', { cause }); + restrict(error); + const descriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(error); + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + const otherBridge: LegacyErrorBridge = new LegacyErrorBridge(); + + bridge.correlate(error, 'immutable-error'); + + expect(Object.getOwnPropertyDescriptors(error)).toEqual(descriptors); + expect(error.cause).toBe(cause); + expect(otherBridge.getCorrelatedDiagnosticId(error)).toBe('immutable-error'); + expect(otherBridge.shouldSuppressRendering(error)).toBe(false); + otherBridge.recordEmittedDiagnostic('immutable-error'); + expect(otherBridge.shouldSuppressRendering(error)).toBe(true); + expect(otherBridge.shouldSuppressRendering(new Error(error.message, { cause }))).toBe(false); + } + ); }); diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 1415b217422..2b0e40aec07 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -302,6 +302,16 @@ export class RushCommandLineParser extends CommandLineParser { } } + public override async executeWithoutErrorHandlingAsync(args?: string[]): Promise { + try { + await super.executeWithoutErrorHandlingAsync(args); + } catch (error) { + // Capture the original parse error before the base executeAsync renders it and returns false. + this._emitReporterFailureDiagnostic(error as Error, !this.#commandLifecycleEmitter); + throw error; + } + } + protected override async onExecuteAsync(): Promise { // Defensively set the exit code to 1 so if Rush crashes for whatever reason, we'll have a nonzero exit code. // For example, Node.js currently has the inexcusable design of terminating with zero exit code when @@ -592,7 +602,7 @@ export class RushCommandLineParser extends CommandLineParser { } } - private _emitReporterFailureDiagnostic(error: Error): void { + private _emitReporterFailureDiagnostic(error: Error, includeMessage: boolean = false): void { this._startReporterSession(); const emitter: LifecycleEmitter | undefined = this.#commandLifecycleEmitter ?? this.#sessionLifecycleEmitter; @@ -603,7 +613,15 @@ export class RushCommandLineParser extends CommandLineParser { commandName: { value: this.selectedAction?.actionName ?? 'unknown', privacy: 'public' - } + }, + ...(includeMessage + ? { + message: { + value: error instanceof Error ? error.message : String(error), + privacy: 'local-sensitive' as const + } + } + : {}) } }); emitter.emitDiagnostic(diagnostic); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 774265bc22b..27f3d7951dd 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { JsonFile } from '@rushstack/node-core-library'; -import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import type { IReporterEmitEventInput, IReporterEventSink, IRushDiagnostic } from '@rushstack/rush-reporter'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { IRushConfigurationJson } from '../../api/RushConfiguration'; @@ -136,7 +136,7 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(visibleOutput[1]).toEqual(visibleOutput[0]); }); - it('emits and correlates a session diagnostic when plugin initialization fails before action selection', async () => { + it.each([false, true])('correlates a plugin initialization failure (frozen: %s)', async (frozen) => { const repoPath: string = await copyRepositoryAsync(); const sink: CapturingReporterSink = new CapturingReporterSink(); const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); @@ -149,6 +149,9 @@ describe('RushCommandLineParser reporter lifecycle', () => { reporterCloseAsync: closeAsync }); const error: Error = new Error('plugin initialization failed'); + if (frozen) { + Object.freeze(error); + } jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync').mockRejectedValue(error); await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); @@ -165,6 +168,80 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(closeAsync).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledWith(1); + expect(stderrSpy.mock.calls.flat().join('\n')).toContain(error.message); + expect(stderrSpy.mock.calls.flat().join('\n')).not.toContain('TypeError'); + }); + + it.each([ + { args: ['not-a-rush-command'], message: 'not-a-rush-command' }, + { args: ['list', '--not-a-rush-option'], message: '--not-a-rush-option' } + ])('reports one real pre-execution parse diagnostic for $args', async ({ args, message }) => { + const repoPath: string = await copyRepositoryAsync(); + const visibleOutput: unknown[] = []; + const stdoutWriteSpy: jest.SpyInstance = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const stderrWriteSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'parse-failure' } : undefined, + reporterCloseAsync: closeAsync + }); + + await expect(parser.executeAsync(args)).resolves.toBe(false); + + expect(process.exitCode).toBe(2); + expect(exitSpy).not.toHaveBeenCalled(); + expect(closeAsync).toHaveBeenCalledTimes(1); + const stderr: string = stderrSpy.mock.calls.flat().join('\n'); + expect(stderr).toContain(message); + expect(sink.events.map(({ type }) => type)).toEqual( + reporting ? ['sessionStarted', 'diagnosticEmitted', 'sessionCompleted'] : [] + ); + if (reporting) { + const diagnosticEvent: IReporterEmitEventInput = sink.events[1]; + const diagnostic: IRushDiagnostic = diagnosticEvent.payload as IRushDiagnostic; + expect(diagnostic.code).toBe('RUSH_COMMAND_FAILED'); + expect(diagnosticEvent.scope?.commandName).toBeUndefined(); + expect(diagnostic.parameters?.message).toEqual({ + value: expect.stringContaining(message), + privacy: 'local-sensitive' + }); + expect(stderr).toContain(diagnostic.parameters?.message.value); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + } + visibleOutput.push({ + stdout: stdoutSpy.mock.calls.map((call) => [...call]), + stderr: stderrSpy.mock.calls.map((call) => [...call]), + stdoutWrites: stdoutWriteSpy.mock.calls.map(([chunk]) => chunk), + stderrWrites: stderrWriteSpy.mock.calls.map(([chunk]) => chunk) + }); + exitSpy.mockRestore(); + } + expect(visibleOutput[1]).toEqual(visibleOutput[0]); + }); + + it('does not diagnose a successful help request as a parse failure', async () => { + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: await copyRepositoryAsync(), + reporter: { eventSink: sink, sessionId: 'help' } + }); + + await expect(parser.executeAsync(['--help'])).resolves.toBe(true); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + expect(sink.events.at(-1)?.payload).toMatchObject({ exitCode: 0 }); }); it.each([false, true])('awaits a real delayed public telemetry hook (reject: %s)', async (reject) => { diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 2890772a1d6..f67bd2cc8a2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -679,7 +679,6 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -708,6 +707,7 @@ export class OperationGraph implements IOperationGraph { }); for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); if (!executionRecord.silent) { // Only count non-silent operations iterationContext.totalOperations++; diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 9a9883dfdb7..0d4a3fb2809 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -54,6 +54,9 @@ import { RushSession } from '../../../pluginFramework/RushSession'; import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; +import { PhasedOperationPlugin } from '../PhasedOperationPlugin'; +import { PhasedCommandHooks, type IOperationGraphContext } from '../../../pluginFramework/PhasedCommandHooks'; +import type { IInputsSnapshot } from '../../incremental/InputsSnapshot'; const mockPhase: IPhase = { name: 'phase', @@ -434,6 +437,55 @@ describe('OperationGraph event sink (dual-emit)', () => { ).toHaveLength(2); }); + it('registers final silence after the standard plugin disables unchanged watch operations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'unchanged-watch' } + }); + const execute: jest.Mock, []> = jest.fn(async () => OperationStatus.Success); + const operations: Set = new Set( + ['first', 'second'].map((name) => + createOperation(name, new MockOperationRunner(name, execute), mockPhase, '@scope/unchanged') + ) + ); + const graph: OperationGraph = new OperationGraph(operations, createGraphOptions(mockWritable, false)); + const hooks: PhasedCommandHooks = new PhasedCommandHooks(); + new PhasedOperationPlugin().apply(hooks); + // This plugin's graph-configuration callback does not consume the command context. + await hooks.onGraphCreatedAsync.promise(graph, {} as IOperationGraphContext); + const registrationSink: RecordingSink = new RecordingSink(); + graph.eventSink = registrationSink; + attachReporterOperationEventSink(graph, rushSession, 'build'); + const inputsSnapshot: IInputsSnapshot = { + hashes: new Map(), + rootDirectory: '/repo', + hasUncommittedChanges: false, + getTrackedFileHashesForOperation: () => new Map(), + getOperationOwnStateHash: () => 'unchanged' + }; + + await graph.executeAsync({ inputsSnapshot }); + const eventsAfterFirstRun: IReporterEmitEventInput[] = [...reporterSink.inputs]; + expect(execute).toHaveBeenCalledTimes(2); + expect(eventsAfterFirstRun.some(({ type }) => type === 'operationRegistered')).toBe(true); + expect(registrationSink.registered).toEqual([ + ['first', false], + ['second', false] + ]); + + await graph.executeAsync({ inputsSnapshot }); + + expect(execute).toHaveBeenCalledTimes(2); + expect([...operations].every((operation) => operation.enabled)).toBe(true); + expect(registrationSink.registered.slice(2)).toEqual([ + ['first', true], + ['second', true] + ]); + expect(reporterSink.inputs).toEqual(eventsAfterFirstRun); + }); + it('recomputes grouped silence for each watch-style iteration', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ From 68cab2ba3dc43038d750b281c0b7877bcb8e8fc3 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:27:05 +0000 Subject: [PATCH 11/22] Respect command ownership when consuming reporter controls Consume --verbose only for known actions that do not define it and parse repository opt-in value controls only when they are not command-owned. Preserve native aliases, declared custom values, unresolved plugin namespaces, and pass-through arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/RushFrontend.ts | 3 +- apps/rush/src/RushReporterCommandLine.ts | 90 ++++++ apps/rush/src/RushReporterHost.ts | 72 +++-- .../test/RushReporterControlOwnership.test.ts | 271 ++++++++++++++++++ ...-command-control-ownership_2026-09-10.json | 11 + specs/2026-07-12-rush-reporter-overhaul.md | 10 + 6 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 apps/rush/src/RushReporterCommandLine.ts create mode 100644 apps/rush/src/test/RushReporterControlOwnership.test.ts create mode 100644 common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..7617265c0bc 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -147,7 +147,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr if (reporterHost.selection.reporterControlsOwnedByFrontend) { process.argv = stripReporterValueControls( process.argv, - new Set(reporterHost.selection.reporterValueFlagsToStrip) + new Set(reporterHost.selection.reporterValueFlagsToStrip), + new Set(reporterHost.selection.reporterFlagsToStrip) ); } const reporterCloseAsync: () => Promise = () => diff --git a/apps/rush/src/RushReporterCommandLine.ts b/apps/rush/src/RushReporterCommandLine.ts new file mode 100644 index 00000000000..e93f46a8f2b --- /dev/null +++ b/apps/rush/src/RushReporterCommandLine.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { RushConfiguration } from '@microsoft/rush-lib'; +import { CommandLineConfiguration, type Command } from '@microsoft/rush-lib/lib/api/CommandLineConfiguration'; +import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; +import { RushPluginsConfiguration } from '@microsoft/rush-lib/lib/api/RushPluginsConfiguration'; + +export interface IReporterCommandLineOwnership { + readonly known: boolean; + readonly parameters: ReadonlySet; +} + +const NATIVE_COMMANDS: ReadonlySet = new Set([ + 'add', + 'alert', + 'bridge-package', + 'change', + 'check', + 'deploy', + 'init', + 'init-autoinstaller', + 'init-deploy', + 'init-subspace', + 'install', + 'install-autoinstaller', + 'link', + 'link-package', + 'list', + 'publish', + 'purge', + 'remove', + 'scan', + 'setup', + 'unlink', + 'update', + 'update-autoinstaller', + 'update-cloud-credentials', + 'upgrade-interactive', + 'version' +]); + +export function getReporterCommandLineOwnership( + actionName: string | undefined, + cwd: string +): IReporterCommandLineOwnership { + const parameters: Set = new Set(); + if (!actionName) { + return { known: false, parameters }; + } + if (NATIVE_COMMANDS.has(actionName)) { + if (actionName === 'check') { + parameters.add('--verbose'); + } + return { known: true, parameters }; + } + + const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ + startingFolder: cwd, + showVerbose: false + }); + const configFolder: string | undefined = rushJsonPath + ? path.join(path.dirname(rushJsonPath), RushConstants.commonFolderName, 'config', 'rush') + : undefined; + if ( + configFolder && + new RushPluginsConfiguration(path.join(configFolder, 'rush-plugins.json')).configuration.plugins.length > + 0 + ) { + // The selected engine resolves plugin command definitions; do not claim their parameters here. + return { known: false, parameters }; + } + + const configuration: CommandLineConfiguration = CommandLineConfiguration.loadFromFileOrDefault( + configFolder && path.join(configFolder, RushConstants.commandLineFilename) + ); + const command: Command | undefined = configuration.commands.get(actionName); + if (!command) { + return { known: false, parameters }; + } + for (const parameter of command.associatedParameters) { + parameters.add(parameter.longName); + } + if (command.commandKind === RushConstants.phasedCommandKind) { + parameters.add('--verbose'); + } + return { known: true, parameters }; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 09873be4945..c204edeba5b 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -27,6 +27,11 @@ import { type ReporterName } from '@rushstack/rush-reporter'; +import { + getReporterCommandLineOwnership, + type IReporterCommandLineOwnership +} from './RushReporterCommandLine'; + export interface IRushReporterOutputStream { readonly isTTY?: boolean; readonly columns?: number; @@ -54,6 +59,7 @@ export interface IRushReporterSelection { readonly enabled: boolean; readonly reporterControlsOwnedByFrontend: boolean; readonly reporterValueFlagsToStrip: readonly string[]; + readonly reporterFlagsToStrip?: readonly string[]; readonly reason: | 'explicit --reporter' | 'repository experiment' @@ -207,7 +213,8 @@ function readValue( export function stripReporterValueControls( argv: readonly string[], - valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS, + flagsToStrip: ReadonlySet = new Set() ): string[] { const result: string[] = []; for (let index: number = 0; index < argv.length; index++) { @@ -216,6 +223,9 @@ export function stripReporterValueControls( result.push(...argv.slice(index)); break; } + if (flagsToStrip.has(argument)) { + continue; + } const equalsIndex: number = argument.indexOf('='); const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); if (!valueFlagsToStrip.has(flagName)) { @@ -232,7 +242,8 @@ export function stripReporterValueControls( function parseReporterControls( argv: readonly string[], includeOutputAndLogLevelControls: boolean, - tolerateMissingReporterValue: boolean = false + tolerateMissingReporterValue: boolean = false, + valueFlagsToParse: ReadonlySet = REPORTER_VALUE_FLAGS ): IParsedReporterControls { const reporters: string[] = []; const logLevels: string[] = []; @@ -264,21 +275,15 @@ function parseReporterControls( continue; } if (includeOutputAndLogLevelControls) { - const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( - argv, - index, - '--log-level' - ); + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = + valueFlagsToParse.has('--log-level') ? readValue(argv, index, '--log-level') : undefined; 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' - ); + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = + valueFlagsToParse.has('--output') ? readValue(argv, index, '--output') : undefined; if (output) { outputs.push(output.value); index += output.consumedNext ? 1 : 0; @@ -522,6 +527,28 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return 'rush'; } + let commandOwnership: IReporterCommandLineOwnership | undefined; + function getCommandOwnership(): IReporterCommandLineOwnership { + if (!commandOwnership) { + const separator: number = argv.indexOf('--'); + const actionName: string | undefined = stripReporterValueControls( + separator < 0 ? argv : argv.slice(0, separator) + ).find((argument) => !argument.startsWith('-')); + commandOwnership = getReporterCommandLineOwnership(actionName, cwd); + } + return commandOwnership; + } + + function getFlagsToStrip(controls: IParsedReporterControls): readonly string[] { + if (controls.verbose) { + const ownership: IReporterCommandLineOwnership = getCommandOwnership(); + if (ownership.known && !ownership.parameters.has('--verbose')) { + return ['--verbose']; + } + } + return []; + } + if (requestedReporter === undefined) { const environmentReporter: string | undefined = env.RUSH_REPORTER; if (environmentReporter?.trim()) { @@ -532,14 +559,26 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } if (options.repositoryOptIn) { const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const ownership: IReporterCommandLineOwnership = getCommandOwnership(); + const valueFlagsToParse: Set = new Set( + ['--output', '--log-level'].filter((flag) => ownership.known && !ownership.parameters.has(flag)) + ); + const controls: IParsedReporterControls = parseReporterControls(argv, true, false, valueFlagsToParse); + validateReporterControlMultiplicity(controls, true); + const reporterValueFlagsToStrip: string[] = []; + if (controls.outputs.length > 0) reporterValueFlagsToStrip.push('--output'); + if (controls.logLevels.length > 0) reporterValueFlagsToStrip.push('--log-level'); + const reporterFlagsToStrip: readonly string[] = getFlagsToStrip(controls); return { reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', - logLevel: resolveLogLevel(selectionControls, env, true, true), - outputs: [], + logLevel: resolveLogLevel(controls, env, true, true), + outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, - reporterControlsOwnedByFrontend: false, - reporterValueFlagsToStrip: [], + reporterControlsOwnedByFrontend: + reporterValueFlagsToStrip.length > 0 || reporterFlagsToStrip.length > 0, + reporterValueFlagsToStrip, + ...(reporterFlagsToStrip.length > 0 ? { reporterFlagsToStrip } : {}), reason: 'repository experiment' }; } @@ -585,6 +624,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = enabled: true, reporterControlsOwnedByFrontend: true, reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + reporterFlagsToStrip: getFlagsToStrip(controls), reason: 'explicit --reporter' }; } diff --git a/apps/rush/src/test/RushReporterControlOwnership.test.ts b/apps/rush/src/test/RushReporterControlOwnership.test.ts new file mode 100644 index 00000000000..4bdb6846fcc --- /dev/null +++ b/apps/rush/src/test/RushReporterControlOwnership.test.ts @@ -0,0 +1,271 @@ +// 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 { LockFile } from '@rushstack/node-core-library'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; + +import { launchRushFrontendAsync } from '../RushFrontend'; +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IRushReporterSelection +} from '../RushReporterHost'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +describe('reporter command-line ownership', () => { + let folder: string; + let originalArgv: string[]; + let originalExitCode: typeof process.exitCode; + let locks: jest.SpiedFunction; + + beforeEach(async () => { + folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-ownership-')); + await fs.promises.cp( + path.resolve(__dirname, '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo'), + folder, + { recursive: true } + ); + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + locks = jest.spyOn(LockFile, 'tryAcquire'); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(async () => { + for (const result of locks.mock.results) { + if (result.type === 'return' && result.value && !result.value.isReleased) { + result.value.release(); + } + } + process.argv = originalArgv; + process.exitCode = originalExitCode; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + await fs.promises.rm(folder, { recursive: true, force: true }); + }); + + function select(argv: readonly string[], repositoryOptIn: boolean): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env: {}, + cwd: folder, + commandName: 'rush', + repositoryOptIn, + stdout: { isTTY: false, write: () => undefined } + }); + } + + async function executeAsync( + argv: readonly string[], + repositoryOptIn: boolean + ): Promise<{ selection: IRushReporterSelection; succeeded: boolean; forwarded: readonly string[] }> { + process.argv = ['node', 'rush', ...argv]; + let selection: IRushReporterSelection | undefined; + let succeeded: boolean | undefined; + let forwarded: readonly string[] = []; + await launchRushFrontendAsync({ + currentPackageVersion: rushLib.Rush.version, + rushVersionToLoad: undefined, + configuration: { useRushReporter: repositoryOptIn } as MinimalRushConfiguration, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + cwd: folder, + commandName: 'rush', + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + forwarded = process.argv.slice(2); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: folder, + reporterCloseAsync: options.reporterCloseAsync + }); + return parser.executeAsync().then((result) => { + succeeded = result; + }); + } + }); + if (!selection || succeeded === undefined) { + throw new Error('Expected the real frontend and parser to execute.'); + } + return { selection, succeeded, forwarded }; + } + + it.each([false, true])( + 'runs native list with reporter --verbose (repository opt-in: %s)', + async (implicit) => { + const result = await executeAsync( + ['list', ...(implicit ? [] : ['--reporter=json']), '--verbose'], + implicit + ); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(['list']); + expect(result.selection.logLevel).toBe('verbose'); + } + ); + + it('preserves action-owned --verbose and every -v meaning', () => { + for (const actionName of ['build', 'rebuild', 'check', 'custom-output']) { + const argv: string[] = [actionName, '--reporter=plaintext', '--verbose', '-v']; + const selection: IRushReporterSelection = select(argv, false); + expect( + stripReporterValueControls( + argv, + new Set(selection.reporterValueFlagsToStrip), + new Set(selection.reporterFlagsToStrip) + ) + ).toEqual([actionName, '--verbose', '-v']); + } + const argv: string[] = ['list', '--reporter=json', '-v', '--verbose', '--', '--verbose']; + const selection: IRushReporterSelection = select(argv, false); + expect( + stripReporterValueControls( + argv, + new Set(selection.reporterValueFlagsToStrip), + new Set(selection.reporterFlagsToStrip) + ) + ).toEqual(['list', '-v', '--', '--verbose']); + }); + + it('matches the native action parameter definitions instead of registering a global verbose option', () => { + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: folder }); + for (const action of parser.actions) { + if (action.actionName === 'tab-complete') continue; + const selection: IRushReporterSelection = select( + [action.actionName, '--reporter=json', '--verbose'], + false + ); + const actionOwnsVerbose: boolean = action.parameters.some( + (parameter) => parameter.longName === '--verbose' + ); + expect(selection.reporterFlagsToStrip ?? []).toEqual(actionOwnsVerbose ? [] : ['--verbose']); + const valueSelection: IRushReporterSelection = select( + [action.actionName, '--output=json://./events.jsonl', '--log-level=debug'], + true + ); + expect(valueSelection.reporterValueFlagsToStrip).toEqual( + ['--output', '--log-level'].filter( + (name) => !action.parameters.some((parameter) => parameter.longName === name) + ) + ); + } + expect(parser.parameters.some((parameter) => parameter.longName === '--verbose')).toBe(false); + }); + + it('parses and strips repository-level value controls before the native parser', async () => { + const result = await executeAsync(['list', '--output=json://./events.jsonl', '--log-level=debug'], true); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(['list']); + expect(result.selection).toMatchObject({ + logLevel: 'debug', + outputs: [{ reporter: 'json', target: path.join(folder, 'events.jsonl') }], + reporterValueFlagsToStrip: ['--output', '--log-level'] + }); + expect((await fs.promises.stat(path.join(folder, 'events.jsonl'))).isFile()).toBe(true); + }); + + it('preserves declared custom values even when they look like reporter controls', async () => { + const argv: string[] = [ + 'custom-output', + '--output=json://./custom.jsonl', + '--log-level=debug', + '--verbose' + ]; + const result = await executeAsync(argv, true); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(argv); + expect(result.selection.outputs).toEqual([]); + expect( + JSON.parse(await fs.promises.readFile(path.join(folder, 'custom-output-args.json'), 'utf8')) + ).toEqual(['--output', 'json://./custom.jsonl', '--log-level', 'debug', '--verbose']); + await expect(fs.promises.stat(path.join(folder, 'custom.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT' + }); + }); + + it('claims an unowned value control without consuming a different command-owned control', async () => { + const configPath: string = path.join(folder, 'common/config/rush/command-line.json'); + const config: { parameters: Array<{ longName: string }> } = JSON.parse( + await fs.promises.readFile(configPath, 'utf8') + ); + config.parameters = config.parameters.filter(({ longName }) => longName !== '--log-level'); + await fs.promises.writeFile(configPath, JSON.stringify(config)); + const result = await executeAsync( + ['custom-output', '--output=custom-artifact.zip', '--log-level=debug'], + true + ); + expect(result.succeeded).toBe(true); + expect(result.selection).toMatchObject({ + logLevel: 'debug', + outputs: [], + reporterValueFlagsToStrip: ['--log-level'] + }); + expect( + JSON.parse(await fs.promises.readFile(path.join(folder, 'custom-output-args.json'), 'utf8')) + ).toEqual(['--output', 'custom-artifact.zip']); + }); + + it('does not claim unknown or plugin-resolved command controls', async () => { + const argv: string[] = [ + 'hidden-tool', + '--output=json://./hidden.jsonl', + '--log-level=debug', + '--verbose' + ]; + expect(select(argv, true)).toMatchObject({ + outputs: [], + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + await fs.promises.writeFile( + path.join(folder, 'common/config/rush/rush-plugins.json'), + JSON.stringify({ + plugins: [{ packageName: '@example/plugin', pluginName: 'commands', autoinstallerName: 'plugins' }] + }) + ); + expect(select(['build', '--output=json://./plugin.jsonl', '--log-level=debug'], true)).toMatchObject({ + outputs: [], + reporterControlsOwnedByFrontend: false + }); + await fs.promises.writeFile( + path.join(folder, 'common/config/rush/rush-plugins.json'), + JSON.stringify({ plugins: [] }) + ); + expect(select(['build', '--log-level=debug'], true)).toMatchObject({ + logLevel: 'debug', + reporterValueFlagsToStrip: ['--log-level'] + }); + }); + + it('keeps legacy inputs unchanged and rejects malformed owned values', () => { + expect(select(['list', '--verbose', '--output=json://./events.jsonl'], false)).toMatchObject({ + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(() => select(['build', '--output'], true)).toThrow('--output requires a value'); + expect(() => select(['build', '--log-level=unsupported'], true)).toThrow('Unsupported log level'); + expect(() => select(['build', '--log-level=debug', '--quiet'], true)).toThrow( + 'Contradictory reporter verbosity' + ); + }); +}); diff --git a/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json b/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json new file mode 100644 index 00000000000..6317f9d5187 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Consume reporter verbose and repository opt-in value controls only when command ownership is known, preserving native aliases and declared custom parameters.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index ed7bf36bcca..5b534c59bf3 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -452,6 +452,12 @@ During pre-major opt-in, `RUSH_REPORTER=legacy` is an emergency override of both explicit selection and the repository experiment. It is applied before strict reporter validation, preserving custom command controls that Rush does not own. +Repository opt-in consumes `--output` and `--log-level` only when the frontend +can establish that the command does not declare them. Custom command parameters +remain command-owned even when their values look like reporter URLs or levels. +For unknown or plugin-resolved command namespaces, use an explicit non-legacy +`--reporter` request to claim reporter value controls. + Legacy flags remain permanent compatibility aliases for the primary reporter: - `--quiet` maps to `quiet`; @@ -459,6 +465,10 @@ Legacy flags remain permanent compatibility aliases for the primary reporter: - `--debug` maps to `debug`; - contradictory verbosity controls are rejected. +The frontend consumes reporter `--verbose` for known actions that do not define +it. Phased actions, `check`, and custom commands that define `--verbose` retain +their native option; `-v` always keeps its command-specific meaning. + Command-specific `--json` behavior remains unchanged and is not an alias for `--reporter=json`. From c857a9b0651aff437475073f71140359ab24f226 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 23:52:47 +0000 Subject: [PATCH 12/22] Refresh R2B reporter controls onto native-private trunk Preserve the exact published R2B slice and review corrections while reconciling native private members and replacing unbranded parser test objects with real execution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 18 + apps/rush/src/MinimalRushConfiguration.ts | 41 +- apps/rush/src/RushCommandSelector.ts | 8 +- apps/rush/src/RushFrontend.ts | 203 ++++ apps/rush/src/RushReporterHost.ts | 679 +++++++++++ apps/rush/src/RushVersionSelector.ts | 5 +- apps/rush/src/start-dev.ts | 19 +- apps/rush/src/start.ts | 26 +- .../src/test/MinimalRushConfiguration.test.ts | 2 + apps/rush/src/test/RushFrontend.test.ts | 1046 +++++++++++++++++ apps/rush/src/test/RushReporterHost.test.ts | 603 ++++++++++ .../repo/common/config/rush/experiments.json | 3 + ...ontend-host-controls_2026-08-28-03-00.json | 11 + ...porter-foundation-controls_2026-09-09.json | 11 + ...reporter-r2b-json-controls_2026-09-07.json | 11 + 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 | 100 +- .../cli/test/RushCommandLineParser.test.ts | 44 + ...RushCommandLineParserReporterClose.test.ts | 147 +++ .../common/config/rush/command-line.json | 39 + .../custom-output.js | 10 + .../common/config/rush/command-line.json | 18 + .../custom-reporter-flag.js | 10 + specs/2026-07-12-rush-reporter-overhaul.md | 8 + 26 files changed, 3033 insertions(+), 49 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 apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json create mode 100644 common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json create mode 100644 common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts 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 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/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..4b3bf391a67 --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,18 @@ +// 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; + readonly reporterCloseAsync: () => Promise; +} diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 1f6923f97eb..46d58acb45b 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 { #rushVersion: string; #commonRushConfigFolder: string; + #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/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..8d29eac6afa 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,8 +3,7 @@ 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 +27,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; @@ -65,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 new file mode 100644 index 00000000000..0fc42146f09 --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,203 @@ +// 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 { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IRushReporterHostOptions, + 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?: ( + options: IRushReporterHostOptions + ) => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => 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._closeForSignalAsync(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(); + } + } + + 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 { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute, + processLifecycle = createProcessLifecycle() + } = options; + + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter, + forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, + selectedRushVersion: rushVersionToLoad + }); + const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled + ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) + : undefined; + reporterLifecycle?.start(); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { + 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 + }; + + 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 reporterCloseAsync(); + } catch (closeError) { + processLifecycle.reportCloseError(closeError as Error); + processLifecycle.setExitCode(1); + } + 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 new file mode 100644 index 00000000000..8ca6b07f9b0 --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,679 @@ +// 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 stderr?: IRushReporterOutputStream; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; + readonly forceLegacy?: boolean; + readonly selectedRushVersion?: string; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reporterValueFlagsToStrip: readonly string[]; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default'; +} + +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']); +const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; +const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; + +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 readonly _outputStream: IRushReporterOutputStream | undefined; + private _fileDescriptor: number | undefined; + + public constructor( + reporterName: string, + outputPath: string, + logLevel: ReporterLogLevel, + outputStream?: IRushReporterOutputStream + ) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._outputStream = outputStream; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._outputStream) { + this._outputStream.write(text); + return; + } + 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 { + if (!this._outputStream) { + 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[], + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS +): 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 (!valueFlagsToStrip.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + index++; + } + } + return result; +} + +function parseReporterControls( + argv: readonly string[], + includeOutputAndLogLevelControls: boolean, + tolerateMissingReporterValue: boolean = false +): 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]; + 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, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.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'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + 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 (includeOutputAndLogLevelControls && controls.logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } +} + +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( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean, + useLegacyAliasPrecedence: boolean = false +): 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 (useLegacyAliasPrecedence && explicitLogLevel === undefined) { + if (controls.debug) { + return 'debug'; + } + if (controls.verbose) { + return 'verbose'; + } + if (controls.quiet) { + return 'quiet'; + } + } + 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 isReporterStreamTarget(target: string): target is 'stdout' | 'stderr' { + return target === 'stdout' || target === 'stderr'; +} + +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: isReporterStreamTarget(output.target) ? 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, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); + if (isLegacyEmergencyFallbackRequested(env)) { + const reporterValueFlagsToStrip: readonly string[] = reporterProbe.reporters.some( + (reporter) => isSupportedReporterName(reporter) && reporter !== 'legacy' + ) + ? ALL_REPORTER_VALUE_FLAGS + : reporterProbe.reporters.includes('legacy') + ? REPORTER_SELECTION_FLAG + : []; + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: reporterValueFlagsToStrip.length > 0, + reporterValueFlagsToStrip, + reason: 'RUSH_REPORTER=legacy' + }; + } + + const reporterOwnershipEstablished: boolean = + options.repositoryOptIn === true || + hasReporterOutputControl(argv) || + reporterProbe.reporters.some((reporter: string) => isSupportedReporterName(reporter)); + const selectionControls: IParsedReporterControls = reporterOwnershipEstablished + ? parseReporterControls(argv, false) + : reporterProbe; + 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(reporterValue)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + const requestedReporter: ReporterName | undefined = reporterValue; + + if (options.forceLegacy) { + if (requestedReporter !== undefined && requestedReporter !== 'legacy') { + throw new Error( + `The selected Rush engine${options.selectedRushVersion ? ` ${options.selectedRushVersion}` : ''} ` + + `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 { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + 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 (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + return { + reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(selectionControls, env, true, true), + outputs: [], + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'repository experiment' + }; + } + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + reason: 'pre-major legacy default' + }; + } + + if (requestedReporter === 'legacy') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: REPORTER_SELECTION_FLAG, + reason: 'explicit --reporter' + }; + } + + const controls: IParsedReporterControls = parseReporterControls(argv, true); + validateReporterControlMultiplicity(controls, true); + 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, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + 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 stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; + 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; + const outputStream: IRushReporterOutputStream | undefined = isReporterStreamTarget(output.target) + ? output.target === 'stdout' + ? stdout + : stderr + : undefined; + host.manager.addReporter( + new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel, outputStream), + { destination: output.target } + ); + } + } + + await host.manager.initializeAsync(); + 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/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 10e391fad3d..20c3d01dadc 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/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 new file mode 100644 index 00000000000..6920848d5fd --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,1046 @@ +// 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 { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; +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 { + initializeRushReporterHostAsync, + type IInitializedRushReporterHost, + type IRushReporterSelection +} from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +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(); + let closePromise: Promise | undefined; + const hasExplicitReporter: boolean = reason === 'explicit --reporter'; + return { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reporterControlsOwnedByFrontend: hasExplicitReporter, + reporterValueFlagsToStrip: hasExplicitReporter ? ['--reporter'] : [], + reason + }, + closeAsync: (timeoutMs?: number) => { + if (!closePromise) { + order.push('close'); + closePromise = host.manager.closeAsync(timeoutMs); + } + return closePromise; + } + }; +} + +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>; + 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']; + + 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; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle + }); + + expect(order).toEqual(['host', 'engine', 'close']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporterEventSink).toEqual( + expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink + ); + expect(receivedOptions).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; + } + }); + + 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 cannot safely use --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 = new RushVersionSelector('5.178.1'); + 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', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']; + let selection: IRushReporterSelection | undefined; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + 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; + }, + createVersionSelector: () => versionSelector, + processLifecycle + }); + + 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.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 = new RushVersionSelector('5.178.1'); + 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: {}, + repositoryOptIn: false, + expectedArguments: [ + '--reporter', + 'junit', + '--output', + 'custom.zip', + '--log-level', + 'custom', + '--verbose' + ], + expectedEnabled: false + }, + { + name: 'supported reporter as frontend ownership', + reporter: 'json', + env: {}, + repositoryOptIn: false, + expectedArguments: ['--verbose'], + expectedEnabled: true + }, + { + name: 'explicit legacy under the emergency override', + reporter: 'legacy', + env: { RUSH_REPORTER: 'legacy' }, + repositoryOptIn: false, + expectedArguments: ['--output', 'custom.zip', '--log-level', 'custom', '--verbose'], + expectedEnabled: false + }, + { + name: 'custom reporter under repository emergency rollback', + reporter: 'junit', + env: { RUSH_REPORTER: 'legacy' }, + repositoryOptIn: true, + expectedArguments: [ + '--reporter', + 'junit', + '--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: { useRushReporter: testCase.repositoryOptIn } as MinimalRushConfiguration, + 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.each([false, true])( + 'runs a value-less custom reporter flag with repository rollback %s', + async (rollback) => { + 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: { useRushReporter: rollback } as MinimalRushConfiguration, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized: IInitializedRushReporterHost = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + cwd: repoPath, + env: rollback ? { RUSH_REPORTER: 'legacy' } : {}, + 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'); + 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('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); + 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); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('parser failed'); + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: directory, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + void parser.executeAsync(); + }); + }, + 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[] = [ + '--', + '--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('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 closeAsync: jest.Mock, [number?]> = jest.fn(async () => undefined); + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(closeAsync); + + 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(closeAsync).toHaveBeenCalledTimes(1); + }); + + 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('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', + '--reporter', + 'junit', + '--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 initialized: IInitializedRushReporterHost = await createEnabledHostAsync(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(); + }); + + 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 new file mode 100644 index 00000000000..d67e4da3656 --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,603 @@ +// 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, + repositoryOptIn: boolean = false, + forceLegacy: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn, + forceLegacy, + selectedRushVersion: forceLegacy ? '5.177.0' : 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, + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [], + 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('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' + }); + expect(resolve(['build', '--quiet', '--verbose', '--debug'], {}, false, true).logLevel).toBe('debug'); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve( + ['build', '--reporter=plaintext', '--log-level=debug', '--output=json://./events.jsonl'], + {}, + false, + true + ) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + + 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({ + 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', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ + reporter: 'legacy', + enabled: false, + 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.each([['--reporter=junit'], ['--reporter'], ['--reporter', '--verbose']])( + 'preserves custom reporter controls during repository rollback: %j', + (...argv: string[]) => { + const selection: IRushReporterSelection = resolve( + ['custom', ...argv], + { RUSH_REPORTER: 'legacy' }, + false, + true + ); + + expect(selection).toMatchObject({ + enabled: false, + reporter: 'legacy', + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + expect( + stripReporterValueControls(['custom', ...argv], new Set(selection.reporterValueFlagsToStrip)) + ).toEqual(['custom', ...argv]); + } + ); + + it('rolls back contradictory reporter selections without consuming pass-through arguments', () => { + const argv: string[] = [ + 'build', + '--reporter=legacy', + '--reporter=json', + '--reporter=ai', + '--log-level=invalid', + '--quiet', + '--debug', + '--', + '--reporter=junit', + '--output=child-output' + ]; + const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); + + expect(selection.enabled).toBe(false); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--quiet', + '--debug', + '--', + '--reporter=junit', + '--output=child-output' + ]); + }); + + 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('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 + ).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('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false, + reporterControlsOwnedByFrontend: true, + reporterValueFlagsToStrip: ['--reporter'] + }); + }); + + 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(['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', + enabled: false, + reporterControlsOwnedByFrontend: false + }); + }); + + 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/ + ); + expect(resolve(['custom', '--reporter=junit', '--verbose'], {}, false, false, 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', () => { + 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=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:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); + + it('distinguishes reserved stream targets from explicit relative file paths', () => { + expect( + resolve([ + 'build', + '--reporter=json', + '--output=json://stdout', + '--output=json://stderr', + '--output=json://./stdout', + '--output=json://./stderr' + ]).outputs.map(({ target }) => target) + ).toEqual(['stdout', 'stderr', path.resolve('/repo', 'stdout'), path.resolve('/repo', 'stderr')]); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it.each([ + { target: 'stdout', outputs: ['json://stdout'] }, + { target: 'stderr', outputs: ['json://stderr', 'file://stderr'] } + ])('rejects conflicting $target ownership before opening files', async ({ target, outputs }) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-conflict-')); + try { + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', ...outputs.map((output) => `--output=${output}`)], + env: {}, + cwd: directory, + stdout: { write: () => undefined }, + includeDefaultFileReporter: false + }).then(async (initialized) => { + await initialized.closeAsync(); + return initialized; + }) + ).rejects.toThrow(`The destination "${target}" is already owned by another reporter.`); + expect(await fs.promises.readdir(directory)).toEqual([]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it.each(['stdout', 'stderr'] as const)( + 'writes reserved %s output to the stream without creating a same-named file', + async (target) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-output-')); + const osModule: typeof os = jest.requireActual('node:os'); + const tmpdirSpy: jest.SpyInstance = jest.spyOn(osModule, 'tmpdir').mockReturnValue(directory); + const stdout = { write: jest.fn(), end: jest.fn() }; + const stderr = { write: jest.fn(), end: jest.fn() }; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=file', `--output=json://${target}`], + env: {}, + cwd: directory, + stdout, + stderr, + includeDefaultFileReporter: false + }); + emitCommandStarted(initialized.sink); + await initialized.closeAsync(); + + const stream = target === 'stdout' ? stdout : stderr; + expect(JSON.parse(stream.write.mock.calls.map(([text]) => text).join('')).type).toBe( + 'commandStarted' + ); + expect(stdout.end).not.toHaveBeenCalled(); + expect(stderr.end).not.toHaveBeenCalled(); + await expect(fs.promises.stat(path.join(directory, target))).rejects.toMatchObject({ + code: 'ENOENT' + }); + } finally { + tmpdirSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + + it('writes ./stdout to a file without conflicting with the primary stdout reporter', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-path-')); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--output=json://./stdout'], + env: {}, + cwd: directory, + stdout: { write: (text: string) => (stdoutText += text) }, + includeDefaultFileReporter: false + }); + emitCommandStarted(initialized.sink); + await initialized.closeAsync(); + + expect(await fs.promises.readFile(path.join(directory, 'stdout'), 'utf8')).toBe(stdoutText); + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + 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.closeAsync(); + + 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); + 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'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); 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 +} 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..919daad035d --- /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 pre-major frontend reporter controls with legacy command compatibility, selected-engine gating, and deterministic reporter finalization.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json b/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json new file mode 100644 index 00000000000..c6e32b8b796 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-foundation-controls_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve custom reporter controls during emergency legacy rollback and honor reserved stdout/stderr output destinations.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json new file mode 100644 index 00000000000..5336c4b756f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/reporter-r2b-json-controls_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Honor the pass-through separator when distinguishing command JSON from reporter JSON controls.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} 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 1546b9cce3e..1f18bab1e6e 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporterCloseAsync?: () => Promise; } export class RushCommandLineParser extends CommandLineParser { @@ -88,6 +89,8 @@ export class RushCommandLineParser extends CommandLineParser { readonly #terminalProvider: ConsoleTerminalProvider; readonly #terminal: Terminal; readonly #autocreateBuildCommand: boolean; + #initializationFailed: boolean = false; + #reporterClosePromise: Promise | undefined; /** * The current working directory that was used to find the Rush configuration. @@ -143,7 +146,7 @@ export class RushCommandLineParser extends CommandLineParser { this.rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFilePath); } } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } NodeJsCompatibility.warnAboutCompatibilityIssues({ @@ -166,6 +169,10 @@ export class RushCommandLineParser extends CommandLineParser { restrictConsoleOutput: this.#restrictConsoleOutput, rushGlobalFolder: this.rushGlobalFolder }); + if (this.#initializationFailed) { + this.#autocreateBuildCommand = true; + return; + } const pluginCommandLineConfigurations: ICustomCommandLineConfigurationInfo[] = this.pluginManager.tryGetCustomCommandLineConfigurationInfos(); @@ -178,18 +185,22 @@ export class RushCommandLineParser extends CommandLineParser { this.#autocreateBuildCommand = !hasBuildCommandInPlugin; this.#populateActions(); + if (this.#initializationFailed) { + return; + } for (const { commandLineConfiguration, pluginLoader } of pluginCommandLineConfigurations) { try { this.#addCommandLineConfigActions(commandLineConfiguration); } catch (e) { - this._reportErrorAndSetExitCode( + this._reportInitializationErrorAndSetExitCode( new Error( `Error from plugin ${pluginLoader.pluginName} by ${pluginLoader.packageName}: ${( e as Error ).toString()}` ) ); + return; } } } @@ -216,6 +227,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; } @@ -234,15 +248,29 @@ export class RushCommandLineParser extends CommandLineParser { } public override async executeAsync(args?: string[]): Promise { + if (this.#initializationFailed) { + await this._closeReporterAsync(); + return false; + } + // debugParameter will be correctly parsed during super.executeAsync(), so manually parse here. + const passThroughSeparatorIndex: number = process.argv.indexOf('--', 2); + const rushArgv: string[] = + 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 { @@ -309,7 +337,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 }; } @@ -359,7 +388,7 @@ export class RushCommandLineParser extends CommandLineParser { this.#populateScriptActions(); } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this._reportInitializationErrorAndSetExitCode(error as Error); } } @@ -391,10 +420,7 @@ export class RushCommandLineParser extends CommandLineParser { } } - #addCommandLineConfigAction( - commandLineConfiguration: CommandLineConfiguration, - command: Command - ): void { + #addCommandLineConfigAction(commandLineConfiguration: CommandLineConfiguration, command: Command): void { if (this.tryGetAction(command.name)) { throw new Error( `${RushConstants.commandLineFilename} defines a command "${command.name}"` + @@ -534,6 +560,13 @@ export class RushCommandLineParser extends CommandLineParser { this.flushTelemetry(); + const configuredExitCode: string | number | undefined = process.exitCode; + const numericExitCode: number = Number(configuredExitCode); + const exitCode: number = + configuredExitCode !== undefined && Number.isInteger(numericExitCode) && numericExitCode !== 0 + ? numericExitCode + : 1; + process.exitCode = exitCode; const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. @@ -541,17 +574,44 @@ export class RushCommandLineParser extends CommandLineParser { // performs nontrivial work that can throw an exception. Either the Rush class would need // to handle reporting for those exceptions, or else _populateActions() should be moved // to a RushCommandLineParser lifecycle stage that can handle it. - if (process.exitCode !== undefined) { - process.exit(process.exitCode); - } else { - process.exit(1); - } + process.exit(exitCode); }; - if (this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed()) { - this.telemetry.ensureFlushedAsync().then(handleExit).catch(handleExit); + const telemetryFlushAsync: Promise | undefined = + this.telemetry && this.rushSession.hooks.flushTelemetry.isUsed() + ? this.telemetry.ensureFlushedAsync() + : undefined; + + if (this.#rushOptions.reporterCloseAsync || telemetryFlushAsync) { + const pendingFlushes: Promise[] = []; + if (this.#rushOptions.reporterCloseAsync) { + pendingFlushes.push(this._closeReporterAsync()); + } + if (telemetryFlushAsync) { + pendingFlushes.push(telemetryFlushAsync); + } + void Promise.allSettled(pendingFlushes).then(handleExit); } else { handleExit(); } } + + private _reportInitializationErrorAndSetExitCode(error: Error): void { + this.#initializationFailed = true; + this._reportErrorAndSetExitCode(error); + } + + private _closeReporterAsync(): Promise { + if (!this.#reporterClosePromise) { + this.#reporterClosePromise = (async (): Promise => { + try { + await this.#rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + } + })(); + } + return this.#reporterClosePromise; + } } diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index dcdbca339ff..64d47c1cfdf 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -114,6 +114,36 @@ 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( + '--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', + 'custom-level', + '--verbose' + ]); + }); + }); + describe("'rebuild' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunRebuildActionRepo'; @@ -140,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/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts new file mode 100644 index 00000000000..5ca85182753 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -0,0 +1,147 @@ +// 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'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import { ConsoleTerminalProvider } from '@rushstack/terminal'; + +describe('RushCommandLineParser reporter close', () => { + let originalExitCode: string | number | undefined; + const originalArgv: string[] = process.argv; + + beforeEach(() => { + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + 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 = parser.rushSession.terminalProvider; + if (!(terminalProvider instanceof ConsoleTerminalProvider)) { + throw new Error('Expected the native console terminal provider.'); + } + 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.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( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + process.exitCode = 0; + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('parser failed'); + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const execution: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + process.exitCode = 0; + + resolveClose!(); + await expect(execution).resolves.toBe(false); + await new Promise((resolve: () => void) => setImmediate(resolve)); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('does not execute after an initialization failure', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('configuration failed'); + }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const executePromise: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(executePromise).resolves.toBe(false); + await new Promise((resolve: () => void) => setImmediate(resolve)); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('reports close failure without rejecting from parser finalization', async () => { + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: async () => { + throw new Error('close failed'); + } + }); + const errorSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + process.exitCode = 0; + + await expect(parser.executeAsync(['--help'])).resolves.toBe(true); + + 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/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..c7d4e88c76b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo/common/config/rush/command-line.json @@ -0,0 +1,39 @@ +{ + "commands": [ + { + "commandKind": "global", + "name": "custom-output", + "summary": "Exercises custom parameters that overlap reporter controls.", + "shellCommand": "node custom-output.js" + } + ], + "parameters": [ + { + "parameterKind": "string", + "longName": "--reporter", + "argumentName": "REPORTER", + "description": "Custom reporter value.", + "associatedCommands": ["custom-output"] + }, + { + "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` +); 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` +); diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index b32a790e46d..ed7bf36bcca 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -417,6 +417,10 @@ rush build --reporter=json --output=file://./rush-debug.log?logLevel=debug rush build --output=json://./rush-events.jsonl ``` +Literal `stdout` and `stderr` output targets reserve the corresponding stream; +they are not file paths. Conflicting stream owners are rejected before reporters +initialize. Use `./stdout` or `./stderr` to name an ordinary file instead. + Environment controls: - `RUSH_REPORTER`; @@ -444,6 +448,10 @@ Precedence: 5. Interactive TTY. 6. Generic non-TTY plaintext. +During pre-major opt-in, `RUSH_REPORTER=legacy` is an emergency override of both +explicit selection and the repository experiment. It is applied before strict +reporter validation, preserving custom command controls that Rush does not own. + Legacy flags remain permanent compatibility aliases for the primary reporter: - `--quiet` maps to `quiet`; From c4c05af5f215bb5e162c764b761c653788b05322 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:11:28 +0000 Subject: [PATCH 13/22] Preserve rollback flags and primary file detail defaults Share separated-value recognition with stripping so valueless controls cannot consume legacy flags, and use debug only as the unrequested primary file log-level default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/RushReporterHost.ts | 17 +++-- apps/rush/src/test/RushReporterHost.test.ts | 70 +++++++++++++++++++ ...er-rollback-and-file-level_2026-09-10.json | 11 +++ 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 8ca6b07f9b0..09873be4945 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -176,6 +176,10 @@ class ExplicitOutputReporter implements IReporter { } } +function isSeparatedControlValue(value: string | undefined): value is string { + return value !== undefined && value.length > 0 && !value.startsWith('-'); +} + function readValue( argv: readonly string[], index: number, @@ -195,7 +199,7 @@ function readValue( } const value: string | undefined = argv[index + 1]; - if (!value || value.startsWith('-')) { + if (!isSeparatedControlValue(value)) { throw new Error(`${flag} requires a value.`); } return { value, consumedNext: true }; @@ -218,7 +222,7 @@ export function stripReporterValueControls( result.push(argument); continue; } - if (equalsIndex < 0 && index + 1 < argv.length && argv[index + 1] !== '--') { + if (equalsIndex < 0 && isSeparatedControlValue(argv[index + 1])) { index++; } } @@ -245,7 +249,7 @@ function parseReporterControls( if ( tolerateMissingReporterValue && argument === '--reporter' && - (!argv[index + 1] || argv[index + 1].startsWith('-')) + !isSeparatedControlValue(argv[index + 1]) ) { continue; } @@ -325,7 +329,8 @@ function resolveLogLevel( controls: IParsedReporterControls, env: Record, includeEnvironment: boolean, - useLegacyAliasPrecedence: boolean = false + useLegacyAliasPrecedence: boolean = false, + defaultLogLevel: ReporterLogLevel = 'normal' ): ReporterLogLevel { const requestedLevels: ReporterLogLevel[] = []; const explicitLogLevel: string | undefined = controls.logLevels[0]; @@ -382,7 +387,7 @@ function resolveLogLevel( return normalizedLogLevel; } - return 'normal'; + return defaultLogLevel; } function isReporterStreamTarget(target: string): target is 'stdout' | 'stderr' { @@ -574,7 +579,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return { reporter: requestedReporter, - logLevel: resolveLogLevel(controls, env, true), + logLevel: resolveLogLevel(controls, env, true, false, requestedReporter === 'file' ? 'debug' : 'normal'), outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index d67e4da3656..25acd8d89aa 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -243,6 +243,19 @@ describe(resolveRushReporterSelection.name, () => { ]); }); + it.each(['--reporter', '--output', '--log-level'])( + 'does not consume legacy flags after a value-less %s during rollback', + (flag) => { + const argv: string[] = ['build', '--reporter=json', flag, '--quiet', '--debug']; + const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--quiet', + '--debug' + ]); + } + ); + it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -334,6 +347,25 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('defaults only an unqualified primary file reporter to debug', () => { + expect(resolve(['build', '--reporter=file']).logLevel).toBe('debug'); + expect(resolve(['build', '--reporter=plaintext']).logLevel).toBe('normal'); + for (const level of ['quiet', 'normal', 'verbose', 'debug']) { + expect(resolve(['build', '--reporter=file', `--log-level=${level}`]).logLevel).toBe(level); + expect(resolve(['build', '--reporter=file'], { RUSH_LOG_LEVEL: level }).logLevel).toBe(level); + } + expect(resolve(['build', '--reporter=file', '--quiet'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe( + 'quiet' + ); + expect(resolve(['build', '--reporter=file', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel).toBe( + 'verbose' + ); + expect(resolve(['build', '--reporter=file', '--debug'], { RUSH_LOG_LEVEL: 'normal' }).logLevel).toBe( + 'debug' + ); + expect(resolve(['build', '--reporter=file'], { RUSH_REPORTER: 'legacy' }).enabled).toBe(false); + }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', @@ -468,6 +500,44 @@ describe(resolveRushReporterSelection.name, () => { }); describe(initializeRushReporterHostAsync.name, () => { + it.each([false, true])( + 'retains primary file debug details unless normal is explicit: %s', + async (normal) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-level-')); + const osModule: typeof os = jest.requireActual('node:os'); + const tmpdirSpy: jest.SpyInstance = jest.spyOn(osModule, 'tmpdir').mockReturnValue(directory); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=file', ...(normal ? ['--log-level=normal'] : [])], + env: {}, + stdout: { write: () => undefined }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'primary-file-level', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.179.0' }, + privacy: 'public', + type: 'messageEmitted', + payload: { severity: 'debug', text: 'retained-debug-detail' } + }); + await initialized.closeAsync(); + + const [logFolder]: string[] = await fs.promises.readdir(directory); + const names: string[] = await fs.promises.readdir(path.join(directory, logFolder)); + const logName: string | undefined = names.find( + (name) => name.endsWith('.log') && name !== 'latest.log' + ); + expect(logName).toBeDefined(); + const text: string = await fs.promises.readFile(path.join(directory, logFolder, logName!), 'utf8'); + expect(text.includes('retained-debug-detail')).toBe(!normal); + } finally { + tmpdirSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + it.each([ { target: 'stdout', outputs: ['json://stdout'] }, { target: 'stderr', outputs: ['json://stderr', 'file://stderr'] } diff --git a/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json b/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json new file mode 100644 index 00000000000..424f2c2bbd5 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-rollback-and-file-level_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve legacy flags after valueless reporter rollback controls and default an unqualified primary file reporter to debug detail.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} From 55e4328b6830bc6ff1202ccf67326bcc8fc97003 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:27:05 +0000 Subject: [PATCH 14/22] Respect command ownership when consuming reporter controls Consume --verbose only for known actions that do not define it and parse repository opt-in value controls only when they are not command-owned. Preserve native aliases, declared custom values, unresolved plugin namespaces, and pass-through arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/RushFrontend.ts | 3 +- apps/rush/src/RushReporterCommandLine.ts | 90 ++++++ apps/rush/src/RushReporterHost.ts | 72 +++-- .../test/RushReporterControlOwnership.test.ts | 271 ++++++++++++++++++ ...-command-control-ownership_2026-09-10.json | 11 + specs/2026-07-12-rush-reporter-overhaul.md | 10 + 6 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 apps/rush/src/RushReporterCommandLine.ts create mode 100644 apps/rush/src/test/RushReporterControlOwnership.test.ts create mode 100644 common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..7617265c0bc 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -147,7 +147,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr if (reporterHost.selection.reporterControlsOwnedByFrontend) { process.argv = stripReporterValueControls( process.argv, - new Set(reporterHost.selection.reporterValueFlagsToStrip) + new Set(reporterHost.selection.reporterValueFlagsToStrip), + new Set(reporterHost.selection.reporterFlagsToStrip) ); } const reporterCloseAsync: () => Promise = () => diff --git a/apps/rush/src/RushReporterCommandLine.ts b/apps/rush/src/RushReporterCommandLine.ts new file mode 100644 index 00000000000..e93f46a8f2b --- /dev/null +++ b/apps/rush/src/RushReporterCommandLine.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { RushConfiguration } from '@microsoft/rush-lib'; +import { CommandLineConfiguration, type Command } from '@microsoft/rush-lib/lib/api/CommandLineConfiguration'; +import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; +import { RushPluginsConfiguration } from '@microsoft/rush-lib/lib/api/RushPluginsConfiguration'; + +export interface IReporterCommandLineOwnership { + readonly known: boolean; + readonly parameters: ReadonlySet; +} + +const NATIVE_COMMANDS: ReadonlySet = new Set([ + 'add', + 'alert', + 'bridge-package', + 'change', + 'check', + 'deploy', + 'init', + 'init-autoinstaller', + 'init-deploy', + 'init-subspace', + 'install', + 'install-autoinstaller', + 'link', + 'link-package', + 'list', + 'publish', + 'purge', + 'remove', + 'scan', + 'setup', + 'unlink', + 'update', + 'update-autoinstaller', + 'update-cloud-credentials', + 'upgrade-interactive', + 'version' +]); + +export function getReporterCommandLineOwnership( + actionName: string | undefined, + cwd: string +): IReporterCommandLineOwnership { + const parameters: Set = new Set(); + if (!actionName) { + return { known: false, parameters }; + } + if (NATIVE_COMMANDS.has(actionName)) { + if (actionName === 'check') { + parameters.add('--verbose'); + } + return { known: true, parameters }; + } + + const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ + startingFolder: cwd, + showVerbose: false + }); + const configFolder: string | undefined = rushJsonPath + ? path.join(path.dirname(rushJsonPath), RushConstants.commonFolderName, 'config', 'rush') + : undefined; + if ( + configFolder && + new RushPluginsConfiguration(path.join(configFolder, 'rush-plugins.json')).configuration.plugins.length > + 0 + ) { + // The selected engine resolves plugin command definitions; do not claim their parameters here. + return { known: false, parameters }; + } + + const configuration: CommandLineConfiguration = CommandLineConfiguration.loadFromFileOrDefault( + configFolder && path.join(configFolder, RushConstants.commandLineFilename) + ); + const command: Command | undefined = configuration.commands.get(actionName); + if (!command) { + return { known: false, parameters }; + } + for (const parameter of command.associatedParameters) { + parameters.add(parameter.longName); + } + if (command.commandKind === RushConstants.phasedCommandKind) { + parameters.add('--verbose'); + } + return { known: true, parameters }; +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 09873be4945..c204edeba5b 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -27,6 +27,11 @@ import { type ReporterName } from '@rushstack/rush-reporter'; +import { + getReporterCommandLineOwnership, + type IReporterCommandLineOwnership +} from './RushReporterCommandLine'; + export interface IRushReporterOutputStream { readonly isTTY?: boolean; readonly columns?: number; @@ -54,6 +59,7 @@ export interface IRushReporterSelection { readonly enabled: boolean; readonly reporterControlsOwnedByFrontend: boolean; readonly reporterValueFlagsToStrip: readonly string[]; + readonly reporterFlagsToStrip?: readonly string[]; readonly reason: | 'explicit --reporter' | 'repository experiment' @@ -207,7 +213,8 @@ function readValue( export function stripReporterValueControls( argv: readonly string[], - valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS + valueFlagsToStrip: ReadonlySet = REPORTER_VALUE_FLAGS, + flagsToStrip: ReadonlySet = new Set() ): string[] { const result: string[] = []; for (let index: number = 0; index < argv.length; index++) { @@ -216,6 +223,9 @@ export function stripReporterValueControls( result.push(...argv.slice(index)); break; } + if (flagsToStrip.has(argument)) { + continue; + } const equalsIndex: number = argument.indexOf('='); const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); if (!valueFlagsToStrip.has(flagName)) { @@ -232,7 +242,8 @@ export function stripReporterValueControls( function parseReporterControls( argv: readonly string[], includeOutputAndLogLevelControls: boolean, - tolerateMissingReporterValue: boolean = false + tolerateMissingReporterValue: boolean = false, + valueFlagsToParse: ReadonlySet = REPORTER_VALUE_FLAGS ): IParsedReporterControls { const reporters: string[] = []; const logLevels: string[] = []; @@ -264,21 +275,15 @@ function parseReporterControls( continue; } if (includeOutputAndLogLevelControls) { - const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( - argv, - index, - '--log-level' - ); + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = + valueFlagsToParse.has('--log-level') ? readValue(argv, index, '--log-level') : undefined; 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' - ); + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = + valueFlagsToParse.has('--output') ? readValue(argv, index, '--output') : undefined; if (output) { outputs.push(output.value); index += output.consumedNext ? 1 : 0; @@ -522,6 +527,28 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = return 'rush'; } + let commandOwnership: IReporterCommandLineOwnership | undefined; + function getCommandOwnership(): IReporterCommandLineOwnership { + if (!commandOwnership) { + const separator: number = argv.indexOf('--'); + const actionName: string | undefined = stripReporterValueControls( + separator < 0 ? argv : argv.slice(0, separator) + ).find((argument) => !argument.startsWith('-')); + commandOwnership = getReporterCommandLineOwnership(actionName, cwd); + } + return commandOwnership; + } + + function getFlagsToStrip(controls: IParsedReporterControls): readonly string[] { + if (controls.verbose) { + const ownership: IReporterCommandLineOwnership = getCommandOwnership(); + if (ownership.known && !ownership.parameters.has('--verbose')) { + return ['--verbose']; + } + } + return []; + } + if (requestedReporter === undefined) { const environmentReporter: string | undefined = env.RUSH_REPORTER; if (environmentReporter?.trim()) { @@ -532,14 +559,26 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } if (options.repositoryOptIn) { const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const ownership: IReporterCommandLineOwnership = getCommandOwnership(); + const valueFlagsToParse: Set = new Set( + ['--output', '--log-level'].filter((flag) => ownership.known && !ownership.parameters.has(flag)) + ); + const controls: IParsedReporterControls = parseReporterControls(argv, true, false, valueFlagsToParse); + validateReporterControlMultiplicity(controls, true); + const reporterValueFlagsToStrip: string[] = []; + if (controls.outputs.length > 0) reporterValueFlagsToStrip.push('--output'); + if (controls.logLevels.length > 0) reporterValueFlagsToStrip.push('--log-level'); + const reporterFlagsToStrip: readonly string[] = getFlagsToStrip(controls); return { reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', - logLevel: resolveLogLevel(selectionControls, env, true, true), - outputs: [], + logLevel: resolveLogLevel(controls, env, true, true), + outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, - reporterControlsOwnedByFrontend: false, - reporterValueFlagsToStrip: [], + reporterControlsOwnedByFrontend: + reporterValueFlagsToStrip.length > 0 || reporterFlagsToStrip.length > 0, + reporterValueFlagsToStrip, + ...(reporterFlagsToStrip.length > 0 ? { reporterFlagsToStrip } : {}), reason: 'repository experiment' }; } @@ -585,6 +624,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = enabled: true, reporterControlsOwnedByFrontend: true, reporterValueFlagsToStrip: ALL_REPORTER_VALUE_FLAGS, + reporterFlagsToStrip: getFlagsToStrip(controls), reason: 'explicit --reporter' }; } diff --git a/apps/rush/src/test/RushReporterControlOwnership.test.ts b/apps/rush/src/test/RushReporterControlOwnership.test.ts new file mode 100644 index 00000000000..4bdb6846fcc --- /dev/null +++ b/apps/rush/src/test/RushReporterControlOwnership.test.ts @@ -0,0 +1,271 @@ +// 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 { LockFile } from '@rushstack/node-core-library'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; + +import { launchRushFrontendAsync } from '../RushFrontend'; +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IRushReporterSelection +} from '../RushReporterHost'; +import type { MinimalRushConfiguration } from '../MinimalRushConfiguration'; + +describe('reporter command-line ownership', () => { + let folder: string; + let originalArgv: string[]; + let originalExitCode: typeof process.exitCode; + let locks: jest.SpiedFunction; + + beforeEach(async () => { + folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-ownership-')); + await fs.promises.cp( + path.resolve(__dirname, '../../../../libraries/rush-lib/src/cli/test/basicAndRunBuildActionRepo'), + folder, + { recursive: true } + ); + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + locks = jest.spyOn(LockFile, 'tryAcquire'); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(async () => { + for (const result of locks.mock.results) { + if (result.type === 'return' && result.value && !result.value.isReleased) { + result.value.release(); + } + } + process.argv = originalArgv; + process.exitCode = originalExitCode; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + await fs.promises.rm(folder, { recursive: true, force: true }); + }); + + function select(argv: readonly string[], repositoryOptIn: boolean): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env: {}, + cwd: folder, + commandName: 'rush', + repositoryOptIn, + stdout: { isTTY: false, write: () => undefined } + }); + } + + async function executeAsync( + argv: readonly string[], + repositoryOptIn: boolean + ): Promise<{ selection: IRushReporterSelection; succeeded: boolean; forwarded: readonly string[] }> { + process.argv = ['node', 'rush', ...argv]; + let selection: IRushReporterSelection | undefined; + let succeeded: boolean | undefined; + let forwarded: readonly string[] = []; + await launchRushFrontendAsync({ + currentPackageVersion: rushLib.Rush.version, + rushVersionToLoad: undefined, + configuration: { useRushReporter: repositoryOptIn } as MinimalRushConfiguration, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => { + const initialized = await initializeRushReporterHostAsync({ + ...options, + argv: process.argv.slice(2), + env: {}, + cwd: folder, + commandName: 'rush', + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }); + selection = initialized.selection; + return initialized; + }, + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + forwarded = process.argv.slice(2); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: folder, + reporterCloseAsync: options.reporterCloseAsync + }); + return parser.executeAsync().then((result) => { + succeeded = result; + }); + } + }); + if (!selection || succeeded === undefined) { + throw new Error('Expected the real frontend and parser to execute.'); + } + return { selection, succeeded, forwarded }; + } + + it.each([false, true])( + 'runs native list with reporter --verbose (repository opt-in: %s)', + async (implicit) => { + const result = await executeAsync( + ['list', ...(implicit ? [] : ['--reporter=json']), '--verbose'], + implicit + ); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(['list']); + expect(result.selection.logLevel).toBe('verbose'); + } + ); + + it('preserves action-owned --verbose and every -v meaning', () => { + for (const actionName of ['build', 'rebuild', 'check', 'custom-output']) { + const argv: string[] = [actionName, '--reporter=plaintext', '--verbose', '-v']; + const selection: IRushReporterSelection = select(argv, false); + expect( + stripReporterValueControls( + argv, + new Set(selection.reporterValueFlagsToStrip), + new Set(selection.reporterFlagsToStrip) + ) + ).toEqual([actionName, '--verbose', '-v']); + } + const argv: string[] = ['list', '--reporter=json', '-v', '--verbose', '--', '--verbose']; + const selection: IRushReporterSelection = select(argv, false); + expect( + stripReporterValueControls( + argv, + new Set(selection.reporterValueFlagsToStrip), + new Set(selection.reporterFlagsToStrip) + ) + ).toEqual(['list', '-v', '--', '--verbose']); + }); + + it('matches the native action parameter definitions instead of registering a global verbose option', () => { + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: folder }); + for (const action of parser.actions) { + if (action.actionName === 'tab-complete') continue; + const selection: IRushReporterSelection = select( + [action.actionName, '--reporter=json', '--verbose'], + false + ); + const actionOwnsVerbose: boolean = action.parameters.some( + (parameter) => parameter.longName === '--verbose' + ); + expect(selection.reporterFlagsToStrip ?? []).toEqual(actionOwnsVerbose ? [] : ['--verbose']); + const valueSelection: IRushReporterSelection = select( + [action.actionName, '--output=json://./events.jsonl', '--log-level=debug'], + true + ); + expect(valueSelection.reporterValueFlagsToStrip).toEqual( + ['--output', '--log-level'].filter( + (name) => !action.parameters.some((parameter) => parameter.longName === name) + ) + ); + } + expect(parser.parameters.some((parameter) => parameter.longName === '--verbose')).toBe(false); + }); + + it('parses and strips repository-level value controls before the native parser', async () => { + const result = await executeAsync(['list', '--output=json://./events.jsonl', '--log-level=debug'], true); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(['list']); + expect(result.selection).toMatchObject({ + logLevel: 'debug', + outputs: [{ reporter: 'json', target: path.join(folder, 'events.jsonl') }], + reporterValueFlagsToStrip: ['--output', '--log-level'] + }); + expect((await fs.promises.stat(path.join(folder, 'events.jsonl'))).isFile()).toBe(true); + }); + + it('preserves declared custom values even when they look like reporter controls', async () => { + const argv: string[] = [ + 'custom-output', + '--output=json://./custom.jsonl', + '--log-level=debug', + '--verbose' + ]; + const result = await executeAsync(argv, true); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(argv); + expect(result.selection.outputs).toEqual([]); + expect( + JSON.parse(await fs.promises.readFile(path.join(folder, 'custom-output-args.json'), 'utf8')) + ).toEqual(['--output', 'json://./custom.jsonl', '--log-level', 'debug', '--verbose']); + await expect(fs.promises.stat(path.join(folder, 'custom.jsonl'))).rejects.toMatchObject({ + code: 'ENOENT' + }); + }); + + it('claims an unowned value control without consuming a different command-owned control', async () => { + const configPath: string = path.join(folder, 'common/config/rush/command-line.json'); + const config: { parameters: Array<{ longName: string }> } = JSON.parse( + await fs.promises.readFile(configPath, 'utf8') + ); + config.parameters = config.parameters.filter(({ longName }) => longName !== '--log-level'); + await fs.promises.writeFile(configPath, JSON.stringify(config)); + const result = await executeAsync( + ['custom-output', '--output=custom-artifact.zip', '--log-level=debug'], + true + ); + expect(result.succeeded).toBe(true); + expect(result.selection).toMatchObject({ + logLevel: 'debug', + outputs: [], + reporterValueFlagsToStrip: ['--log-level'] + }); + expect( + JSON.parse(await fs.promises.readFile(path.join(folder, 'custom-output-args.json'), 'utf8')) + ).toEqual(['--output', 'custom-artifact.zip']); + }); + + it('does not claim unknown or plugin-resolved command controls', async () => { + const argv: string[] = [ + 'hidden-tool', + '--output=json://./hidden.jsonl', + '--log-level=debug', + '--verbose' + ]; + expect(select(argv, true)).toMatchObject({ + outputs: [], + reporterControlsOwnedByFrontend: false, + reporterValueFlagsToStrip: [] + }); + await fs.promises.writeFile( + path.join(folder, 'common/config/rush/rush-plugins.json'), + JSON.stringify({ + plugins: [{ packageName: '@example/plugin', pluginName: 'commands', autoinstallerName: 'plugins' }] + }) + ); + expect(select(['build', '--output=json://./plugin.jsonl', '--log-level=debug'], true)).toMatchObject({ + outputs: [], + reporterControlsOwnedByFrontend: false + }); + await fs.promises.writeFile( + path.join(folder, 'common/config/rush/rush-plugins.json'), + JSON.stringify({ plugins: [] }) + ); + expect(select(['build', '--log-level=debug'], true)).toMatchObject({ + logLevel: 'debug', + reporterValueFlagsToStrip: ['--log-level'] + }); + }); + + it('keeps legacy inputs unchanged and rejects malformed owned values', () => { + expect(select(['list', '--verbose', '--output=json://./events.jsonl'], false)).toMatchObject({ + enabled: false, + reporterControlsOwnedByFrontend: false + }); + expect(() => select(['build', '--output'], true)).toThrow('--output requires a value'); + expect(() => select(['build', '--log-level=unsupported'], true)).toThrow('Unsupported log level'); + expect(() => select(['build', '--log-level=debug', '--quiet'], true)).toThrow( + 'Contradictory reporter verbosity' + ); + }); +}); diff --git a/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json b/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json new file mode 100644 index 00000000000..6317f9d5187 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-command-control-ownership_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Consume reporter verbose and repository opt-in value controls only when command ownership is known, preserving native aliases and declared custom parameters.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index ed7bf36bcca..5b534c59bf3 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -452,6 +452,12 @@ During pre-major opt-in, `RUSH_REPORTER=legacy` is an emergency override of both explicit selection and the repository experiment. It is applied before strict reporter validation, preserving custom command controls that Rush does not own. +Repository opt-in consumes `--output` and `--log-level` only when the frontend +can establish that the command does not declare them. Custom command parameters +remain command-owned even when their values look like reporter URLs or levels. +For unknown or plugin-resolved command namespaces, use an explicit non-legacy +`--reporter` request to claim reporter value controls. + Legacy flags remain permanent compatibility aliases for the primary reporter: - `--quiet` maps to `quiet`; @@ -459,6 +465,10 @@ Legacy flags remain permanent compatibility aliases for the primary reporter: - `--debug` maps to `debug`; - contradictory verbosity controls are rejected. +The frontend consumes reporter `--verbose` for known actions that do not define +it. Phased actions, `check`, and custom commands that define `--verbose` retain +their native option; `-v` always keeps its command-specific meaning. + Command-specific `--json` behavior remains unchanged and is not an alias for `--reporter=json`. From d71c2ff353c9011c193c90d717c3bb4993d5a83e Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 23:57:49 +0000 Subject: [PATCH 15/22] Refresh R3A scoped producers onto native-private trunk Retain the exact scoped producer API and WeakMap-backed plugin facades while preserving native-private parser/plugin members and real launch-boundary coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +- apps/rush/src/RushFrontend.ts | 10 +- apps/rush/src/test/RushFrontend.test.ts | 57 ++++- ...ter-r3a-session-sink_2026-08-28-02-38.json | 11 + .../build-tests-subspace/pnpm-lock.yaml | 1 + .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 3 + common/reviews/api/rush-lib.api.md | 47 +++++ libraries/rush-lib/src/api/Rush.ts | 13 ++ .../rush-lib/src/cli/RushCommandLineParser.ts | 9 +- .../src/cli/actions/BaseRushAction.ts | 5 +- libraries/rush-lib/src/index.ts | 16 ++ .../PluginLoader/PluginLoaderBase.ts | 17 ++ .../src/pluginFramework/PluginManager.ts | 14 +- .../src/pluginFramework/RushSession.test.ts | 152 ++++++++++++++ .../src/pluginFramework/RushSession.ts | 198 ++++++++++++++++-- libraries/rush-sdk/package.json | 1 + .../test/__snapshots__/script.test.ts.snap | 4 +- 18 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/pluginFramework/RushSession.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a67..920ae96235f 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ILaunchOptions } from '@microsoft/rush-lib'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; /** * The cross-version launch contract owned by the Rush frontend. @@ -13,6 +12,6 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; * options, so an older engine can safely ignore the new property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { - readonly reporterEventSink: IReporterEventSink; + readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 7617265c0bc..d598e03aae7 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { randomUUID } from 'node:crypto'; + import type { ILaunchOptions } from '@microsoft/rush-lib'; import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; @@ -30,6 +32,7 @@ export interface IRushFrontendOptions { currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions ) => void | Promise; + readonly createSessionId?: () => string; readonly processLifecycle?: IRushFrontendProcessLifecycle; } @@ -132,6 +135,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, processLifecycle = createProcessLifecycle() } = options; @@ -153,9 +157,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink, + reporter: { + eventSink: reporterHost.sink, + sessionId + }, reporterCloseAsync }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 6920848d5fd..ddf0e046056 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; @@ -19,6 +20,7 @@ import { } from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, type IInitializedRushReporterHost, @@ -179,7 +181,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; - let receivedOptions: Record | undefined; + let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -196,7 +198,7 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; order.push('engine'); - receivedOptions = launchOptions as unknown as Record; + receivedOptions = launchOptions; return launchOptions.reporterCloseAsync(); }, processLifecycle @@ -204,9 +206,10 @@ describe(launchRushFrontendAsync.name, () => { expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); - expect(receivedOptions?.reporterEventSink).toEqual( - expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink - ); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String) + }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); @@ -217,6 +220,46 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend' + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -630,7 +673,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); return launchOptions.reporterCloseAsync(); }, processLifecycle: createTestProcessLifecycle() @@ -671,7 +714,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); process.exitCode = 1; return new Promise((resolve: () => void) => { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json new file mode 100644 index 00000000000..fa12adb823f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose an optional scoped reporter producer API to Rush actions and plugins while preserving legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 34402c38024..928ffa7350d 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -4990,6 +4990,7 @@ snapshots: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-reporter': file:../../../libraries/reporter(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 0f503ea1577..a8b0af670ab 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "36a63ea0a120d7f9fd7bba3e57f734059b5177e2", + "pnpmShrinkwrapHash": "50a1f3c8d2270f840d49426b54c028e26de05189", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "ee803d13f0fb0ae994024d4dc646d2def4cc1f0f" + "packageJsonInjectedDependenciesHash": "af9e972a5d86601391889a0ff0ae8349679a6a10" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 36fecb4bcb5..a9c01ddb734 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4407,6 +4407,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-reporter': + specifier: workspace:* + version: link:../reporter '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 37f1ea3ef31..13d17d81750 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -13,14 +13,22 @@ import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICreateRushDiagnosticOptions } from '@rushstack/rush-reporter'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterEventScope } from '@rushstack/rush-reporter'; +import { IReporterEventSink } from '@rushstack/rush-reporter'; +import { IRushDiagnostic } from '@rushstack/rush-reporter'; +import { IScopedLogger } from '@rushstack/rush-reporter'; +import { IScopedMessageOptions } from '@rushstack/rush-reporter'; +import { IScopedReporter } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +36,11 @@ import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; +import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; import type { PerformanceEntry as PerformanceEntry_2 } from 'node:perf_hooks'; +import { ReporterExtensionEventName } from '@rushstack/rush-reporter'; +import { ReporterJsonValue } from '@rushstack/rush-reporter'; +import { ReporterPrivacyClassification } from '@rushstack/rush-reporter'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; @@ -148,6 +160,8 @@ export class CommonVersionsConfiguration { saveAsync(): Promise; } +export { createRushDiagnostic } + export { CredentialCache } // @beta @@ -439,6 +453,8 @@ export interface ICreateOperationsContext { readonly rushConfiguration: RushConfiguration; } +export { ICreateRushDiagnosticOptions } + export { ICredentialCacheEntry } export { ICredentialCacheOptions } @@ -557,6 +573,8 @@ export interface ILaunchOptions { // @internal builtInPluginConfigurations?: _IBuiltInPluginConfiguration[]; isManaged: boolean; + // @internal + reporter?: IRushSessionReporterOptions; terminalProvider?: ITerminalProvider; } @@ -911,6 +929,10 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +export { IReporterEventScope } + +export { IReporterEventSink } + // @beta export interface IRushCommand { readonly actionName: string; @@ -943,6 +965,8 @@ export interface IRushCommandLineSpec { // @beta (undocumented) export type IRushConfigurationProjectForSnapshot = Pick; +export { IRushDiagnostic } + // @alpha (undocumented) export interface IRushPhaseSharding { count: number; @@ -983,10 +1007,23 @@ export interface IRushReportingConfiguration { export interface IRushSessionOptions { // (undocumented) getIsDebugMode: () => boolean; + reporter?: IRushSessionReporterOptions; // (undocumented) terminalProvider: ITerminalProvider; } +// @beta +export interface IRushSessionReporterOptions { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; +} + +export { IScopedLogger } + +export { IScopedMessageOptions } + +export { IScopedReporter } + // @beta export interface IStopwatchResult { get duration(): number; @@ -1288,6 +1325,8 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @beta export type Parallelism = number | IParallelismScalar; +export { parseReporterExtensionEventName } + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1365,6 +1404,12 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +export { ReporterExtensionEventName } + +export { ReporterJsonValue } + +export { ReporterPrivacyClassification } + // @public export class RepoStateFile { readonly filePath: string; @@ -1702,6 +1747,8 @@ export class RushSession { getCobuildLockProviderFactory(cobuildLockProviderName: string): CobuildLockProviderFactory | undefined; // (undocumented) getLogger(name: string): ILogger; + getReporter(scope?: IReporterEventScope): IScopedReporter | undefined; + getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined; // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index a51af8b0930..e75815484d6 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -14,6 +14,7 @@ import { RushXCommandLine } from '../cli/RushXCommandLine'; import { CommandLineMigrationAdvisor } from '../cli/CommandLineMigrationAdvisor'; import { EnvironmentVariableNames } from './EnvironmentConfiguration'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { IRushSessionReporterOptions } from '../pluginFramework/RushSession'; import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; @@ -58,6 +59,17 @@ export interface ILaunchOptions { * @internal */ builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + + /** + * Supplies the structured event sink owned by the Rush frontend. + * + * @remarks + * This is an internal cross-version frontend-to-engine handoff. Reporter + * selection and concrete reporter instances remain owned by the frontend. + * + * @internal + */ + reporter?: IRushSessionReporterOptions; } let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; @@ -98,6 +110,7 @@ export class Rush { const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations, + reporter: options.reporter, reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 1f18bab1e6e..71331301bca 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -57,7 +57,7 @@ import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; import { type ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; -import { RushSession } from '../pluginFramework/RushSession'; +import { type IRushSessionReporterOptions, RushSession } from '../pluginFramework/RushSession'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; import { RushAlerts } from '../utilities/RushAlerts'; @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; } @@ -131,7 +132,7 @@ export class RushCommandLineParser extends CommandLineParser { const terminal: Terminal = new Terminal(this.#terminalProvider); this.#terminal = terminal; this.#rushOptions = this.#normalizeOptions(options || {}); - const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations } = this.#rushOptions; + const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this.#rushOptions; let rushJsonFilePath: string | undefined; try { @@ -159,7 +160,8 @@ export class RushCommandLineParser extends CommandLineParser { this.rushSession = new RushSession({ getIsDebugMode: () => this.isDebug, - terminalProvider + terminalProvider, + reporter }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, @@ -338,6 +340,7 @@ export class RushCommandLineParser extends CommandLineParser { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporter: options.reporter, reporterCloseAsync: options.reporterCloseAsync }; } diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index fc92b4ac237..da6e8bb9d9c 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { CommandLineAction, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; import { LockFile } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { IScopedReporter } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; @@ -44,6 +45,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme protected readonly rushConfiguration: RushConfiguration | undefined; protected readonly terminal: ITerminal; protected readonly rushSession: RushSession; + protected readonly reporter: IScopedReporter | undefined; protected readonly rushGlobalFolder: RushGlobalFolder; protected readonly parser: RushCommandLineParser; @@ -57,6 +59,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme this.rushConfiguration = rushConfiguration; this.terminal = terminal; this.rushSession = rushSession; + this.reporter = rushSession.getReporter({ commandName: this.actionName }); this.rushGlobalFolder = rushGlobalFolder; } @@ -115,7 +118,7 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { return this.#eventHooksManager; } - protected declare readonly rushConfiguration: RushConfiguration; + declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { if (!this.rushConfiguration) { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 0fdd200e775..6f0bb4c5e67 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -168,10 +168,26 @@ export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { RushSession, type IRushSessionOptions, + type IRushSessionReporterOptions, type CloudBuildCacheProviderFactory, type CobuildLockProviderFactory } from './pluginFramework/RushSession'; +export { + createRushDiagnostic, + parseReporterExtensionEventName, + type ICreateRushDiagnosticOptions, + type IReporterEventScope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedLogger, + type IScopedMessageOptions, + type IScopedReporter, + type ReporterExtensionEventName, + type ReporterJsonValue, + type ReporterPrivacyClassification +} from '@rushstack/rush-reporter'; + export { type IRushCommand, type IGlobalCommand, diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index be38a503d9d..013cce44e26 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -7,6 +7,8 @@ import { FileSystem, InternalError, JsonFile, + PackageJsonLookup, + type IPackageJson, type JsonObject, JsonSchema } from '@rushstack/node-core-library'; @@ -51,6 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; + private _packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -84,6 +87,20 @@ export abstract class PluginLoaderBase< return this.#getRushPluginManifest(); } + public get packageVersion(): string { + if (!this._packageVersionCache) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( + path.join(this.packageFolder, 'package.json') + ); + if (!packageJson.version) { + throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); + } + this._packageVersionCache = packageJson.version; + } + + return this._packageVersionCache; + } + public getCommandLineConfiguration(): CommandLineConfiguration | undefined { const commandLineJsonFilePath: string | undefined = this._getCommandLineJsonFilePath(); if (!commandLineJsonFilePath) { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 926aac1a5e8..d5fc01eccc9 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -9,7 +9,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import { BuiltInPluginLoader, type IBuiltInPluginConfiguration } from './PluginLoader/BuiltInPluginLoader'; import type { IRushPlugin } from './IRushPlugin'; import { AutoinstallerPluginLoader } from './PluginLoader/AutoinstallerPluginLoader'; -import type { RushSession } from './RushSession'; +import { _createRushSessionForPlugin, type RushSession } from './RushSession'; import type { PluginLoaderBase } from './PluginLoader/PluginLoaderBase'; import { Rush } from '../api/Rush'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; @@ -205,7 +205,7 @@ export class PluginManager { const plugin: IRushPlugin | undefined = pluginLoader.load(); this.#loadedPluginNames.add(pluginName); if (plugin) { - this.#applyPlugin(plugin, pluginName); + this.#applyPlugin(plugin, pluginLoader); } } } @@ -227,9 +227,15 @@ export class PluginManager { }); } - #applyPlugin(plugin: IRushPlugin, pluginName: string): void { + #applyPlugin(plugin: IRushPlugin, pluginLoader: PluginLoaderBase): void { + const { packageName, pluginName } = pluginLoader; try { - plugin.apply(this.#rushSession, this.#rushConfiguration); + const pluginSession: RushSession = _createRushSessionForPlugin(this.#rushSession, () => ({ + packageName, + packageVersion: pluginLoader.packageVersion, + component: pluginName + })); + plugin.apply(pluginSession, this.#rushConfiguration); } catch (e) { throw new InternalError(`Error applying "${pluginName}": ${e}`); } diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts new file mode 100644 index 00000000000..26a48160731 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { + IReporterEmitEventInput, + IReporterEventSource, + IReporterEventSink +} from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { Rush } from '../api/Rush'; +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + +function createSession(reporter?: IRushSessionReporterOptions): RushSession { + return new RushSession({ + getIsDebugMode: () => false, + terminalProvider: new StringBufferTerminalProvider(), + reporter + }); +} + +describe(RushSession.name, () => { + it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { + const session: RushSession = createSession(); + + expect(session.getReporter()).toBeUndefined(); + expect(session.getScopedLogger()).toBeUndefined(); + expect(session.getLogger('legacy')).toBeDefined(); + expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + }); + + it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-1' }); + const scope = { commandName: 'build', projectName: '@scope/project' }; + const reporter = session.getReporter(scope); + + expect(reporter).toBeDefined(); + expect(Object.keys(reporter!).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + expect('getSink' in reporter!).toBe(false); + expect('reporters' in reporter!).toBe(false); + expect(Object.keys(session)).not.toContain('reporter'); + + scope.commandName = 'spoofed'; + reporter!.emitMessage({ severity: 'info', text: 'hello' }); + + expect(sink.inputs).toHaveLength(1); + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-1', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { + commandName: 'build', + projectName: '@scope/project' + } + }); + expect(sink.inputs[0]).not.toHaveProperty('eventId'); + expect(sink.inputs[0]).not.toHaveProperty('sequence'); + expect(sink.inputs[0]).not.toHaveProperty('timestamp'); + expect(sink.inputs[0]).not.toHaveProperty('required'); + }); + + it('isolates plugin sources while sharing session state', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-2' }); + const pluginSource: IReporterEventSource = { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }; + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => pluginSource); + + expect(pluginSession.hooks).toBe(session.hooks); + (pluginSource as { packageName: string }).packageName = '@acme/spoofed'; + pluginSession.getReporter({ projectName: '@scope/a' })!.emitMessage({ + severity: 'info', + text: 'plugin' + }); + session.getReporter({ projectName: '@scope/b' })!.emitMessage({ + severity: 'info', + text: 'rush' + }); + + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }, + scope: { projectName: '@scope/a' } + }); + expect(sink.inputs[1]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { projectName: '@scope/b' } + }); + }); + + it('rejects invalid explicitly supplied reporter options', () => { + expect(() => + createSession({ + eventSink: {} as IReporterEventSink, + sessionId: 'session-3' + }) + ).toThrow(/eventSink/); + + expect(() => createSession({ eventSink: new CapturingSink(), sessionId: ' ' })).toThrow(/sessionId/); + }); + + it('does not resolve plugin identity when reporting is disabled', () => { + const session: RushSession = createSession(); + const getSource = jest.fn((): IReporterEventSource => { + throw new Error('should not resolve source'); + }); + + expect(_createRushSessionForPlugin(session, getSource)).toBe(session); + expect(getSource).not.toHaveBeenCalled(); + }); + + it('binds built-in action reporters to their command name', () => { + const sink: CapturingSink = new CapturingSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: os.tmpdir(), + reporter: { eventSink: sink, sessionId: 'session-4' } + }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; + + expect(action?.reporter).toBeDefined(); + action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); + expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 2d0e5585b35..e017a9a8cbc 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { + RushSessionReporting, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IScopedLogger, + type IScopedReporter +} from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import { type ILogger, type ILoggerOptions, Logger } from './logging/Logger'; @@ -11,12 +19,43 @@ import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCa import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +/** + * The reporter channel supplied by the Rush frontend for a single Rush session. + * + * @remarks + * The frontend owns reporter selection and the concrete reporter instances. Rush + * only receives this presentation-free sink and binds producer identities before + * exposing scoped reporters to actions and plugins. + * + * @beta + */ +export interface IRushSessionReporterOptions { + /** + * The typed event sink owned by the Rush frontend. + */ + readonly eventSink: IReporterEventSink; + + /** + * The identifier assigned to this Rush session by the frontend. + */ + readonly sessionId: string; +} + /** * @beta */ export interface IRushSessionOptions { terminalProvider: ITerminalProvider; getIsDebugMode: () => boolean; + + /** + * The optional structured reporter channel for this session. + * + * @remarks + * When omitted, scoped reporter APIs return `undefined` and legacy terminal + * behavior remains unchanged. + */ + reporter?: IRushSessionReporterOptions; } /** @@ -33,20 +72,85 @@ export type CobuildLockProviderFactory = ( cobuildJson: ICobuildJson ) => ICobuildLockProvider | Promise; +interface IRushSessionState { + readonly options: IRushSessionOptions; + readonly cloudBuildCacheProviderFactories: Map; + readonly cobuildLockProviderFactories: Map; + readonly hooks: RushLifecycleHooks; + readonly reporting: RushSessionReporting | undefined; +} + +let _rushLibSource: IReporterEventSource | undefined; +const _rushSessionStates: WeakMap = new WeakMap(); + +function _getRushLibSource(): IReporterEventSource { + if (!_rushLibSource) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for @microsoft/rush-lib'); + } + + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); + if (!packageJson.version) { + throw new InternalError('The @microsoft/rush-lib package.json file does not specify a version'); + } + + _rushLibSource = { + packageName: '@microsoft/rush-lib', + packageVersion: packageJson.version + }; + } + + return _rushLibSource; +} + +function _createReporting( + reporterOptions: IRushSessionReporterOptions | undefined, + source: IReporterEventSource +): RushSessionReporting | undefined { + if (!reporterOptions) { + return undefined; + } + + const { eventSink, sessionId } = reporterOptions; + if (!eventSink || typeof eventSink.emit !== 'function') { + throw new TypeError('RushSession reporter.eventSink must implement IReporterEventSink'); + } + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); + } + + return new RushSessionReporting({ + sink: eventSink, + sessionId, + source: { ...source } + }); +} + +function _getSessionState(rushSession: RushSession): IRushSessionState { + const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); + if (!state) { + throw new InternalError('RushSession state was not initialized'); + } + return state; +} + /** * @beta */ export class RushSession { - readonly #options: IRushSessionOptions; - readonly #cloudBuildCacheProviderFactories: Map = new Map(); - readonly #cobuildLockProviderFactories: Map = new Map(); - public readonly hooks: RushLifecycleHooks; public constructor(options: IRushSessionOptions) { - this.#options = options; - this.hooks = new RushLifecycleHooks(); + _rushSessionStates.set(this, { + options, + cloudBuildCacheProviderFactories: new Map(), + cobuildLockProviderFactories: new Map(), + hooks: this.hooks, + reporting: options.reporter ? _createReporting(options.reporter, _getRushLibSource()) : undefined + }); } public getLogger(name: string): ILogger { @@ -54,51 +158,113 @@ export class RushSession { throw new InternalError('RushSession.getLogger(name) called without a name'); } - const terminalProvider: ITerminalProvider = this.#options.terminalProvider; + const { options } = _getSessionState(this); + const terminalProvider: ITerminalProvider = options.terminalProvider; const loggerOptions: ILoggerOptions = { loggerName: name, - getShouldPrintStacks: () => this.#options.getIsDebugMode(), + getShouldPrintStacks: () => options.getIsDebugMode(), terminalProvider }; return new Logger(loggerOptions); } public get terminalProvider(): ITerminalProvider { - return this.#options.terminalProvider; + return _getSessionState(this).options.terminalProvider; + } + + /** + * Creates a structured reporter bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * The returned API cannot access concrete reporters or override the session and + * source identity bound by Rush. + */ + public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { + return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + } + + /** + * Creates a structured logger bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * This API is additive; {@link RushSession.getLogger} and terminal output remain + * available during the pre-major compatibility period. + */ + public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { + return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); } public registerCloudBuildCacheProviderFactory( cacheProviderName: string, factory: CloudBuildCacheProviderFactory ): void { - if (this.#cloudBuildCacheProviderFactories.has(cacheProviderName)) { + const { cloudBuildCacheProviderFactories } = _getSessionState(this); + if (cloudBuildCacheProviderFactories.has(cacheProviderName)) { throw new Error(`A build cache provider factory for ${cacheProviderName} has already been registered`); } - this.#cloudBuildCacheProviderFactories.set(cacheProviderName, factory); + cloudBuildCacheProviderFactories.set(cacheProviderName, factory); } public getCloudBuildCacheProviderFactory( cacheProviderName: string ): CloudBuildCacheProviderFactory | undefined { - return this.#cloudBuildCacheProviderFactories.get(cacheProviderName); + return _getSessionState(this).cloudBuildCacheProviderFactories.get(cacheProviderName); } public registerCobuildLockProviderFactory( cobuildLockProviderName: string, factory: CobuildLockProviderFactory ): void { - if (this.#cobuildLockProviderFactories.has(cobuildLockProviderName)) { + const { cobuildLockProviderFactories } = _getSessionState(this); + if (cobuildLockProviderFactories.has(cobuildLockProviderName)) { throw new Error( `A cobuild lock provider factory for ${cobuildLockProviderName} has already been registered` ); } - this.#cobuildLockProviderFactories.set(cobuildLockProviderName, factory); + cobuildLockProviderFactories.set(cobuildLockProviderName, factory); } public getCobuildLockProviderFactory( cobuildLockProviderName: string ): CobuildLockProviderFactory | undefined { - return this.#cobuildLockProviderFactories.get(cobuildLockProviderName); + return _getSessionState(this).cobuildLockProviderFactories.get(cobuildLockProviderName); + } +} + +/** + * Creates the RushSession facade passed to one plugin. + * + * @remarks + * This function is internal to rush-lib. PluginManager derives the source from + * trusted loader metadata so the plugin cannot choose another producer identity. + * + * @internal + */ +export function _createRushSessionForPlugin( + rushSession: RushSession, + getSource: () => IReporterEventSource +): RushSession { + const state: IRushSessionState = _getSessionState(rushSession); + if (!state.options.reporter) { + return rushSession; } + + const pluginSession: RushSession = Object.create(RushSession.prototype) as RushSession; + Object.defineProperty(pluginSession, 'hooks', { + configurable: false, + enumerable: true, + value: state.hooks, + writable: false + }); + _rushSessionStates.set(pluginSession, { + options: state.options, + cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, + cobuildLockProviderFactories: state.cobuildLockProviderFactories, + hooks: state.hooks, + reporting: _createReporting(state.options.reporter, getSource()) + }); + return pluginSession; } diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index deff0c2961c..7714b951925 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -51,6 +51,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-reporter": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 573fa555e28..80fc60cee1e 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -63,7 +63,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', - '_rushSdk_loadInternalModule' + '_rushSdk_loadInternalModule', + 'createRushDiagnostic', + 'parseReporterExtensionEventName' ]" `; From 47d4f36f5eb8e451442003249e2d24f7606e5cfd Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 17:54:57 +0000 Subject: [PATCH 16/22] Clarify the frontend reporter channel identity contract Document both the typed event sink and the frontend-assigned sessionId in the cross-version handoff without changing its shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +++-- .../reporter-session-handoff-docs_2026-09-10.json | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 920ae96235f..603f1863bba 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -8,8 +8,9 @@ import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rus * * @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. + * receives a reporter channel containing the typed producer event sink and the + * frontend-assigned `sessionId`, in addition to its existing launch options. + * An older engine can safely ignore this additive reporter property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; diff --git a/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json b/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json new file mode 100644 index 00000000000..8169968fab3 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-session-handoff-docs_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Clarify that the frontend reporter handoff contains both the typed event sink and the frontend-assigned session identity.", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} From 2fc47f5df71b7feb2a9b547ee92785c6d36ee448 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 00:01:29 +0000 Subject: [PATCH 17/22] Refresh R3B shadow lifecycle onto native-private trunk Preserve published early-failure, late-telemetry and operation-callback corrections; reconcile native lifecycle fields and telemetry references, with real branded parser regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + ...orter-foundation-lifecycle_2026-09-09.json | 11 + ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + common/reviews/api/rush-lib.api.md | 4 +- common/reviews/api/rush-reporter.api.md | 6 + .../diagnostics/RushDiagnosticCodeRegistry.ts | 39 +- .../src/diagnostics/templates/operation.ts | 3 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 142 +++++++- .../cli/scriptActions/PhasedScriptAction.ts | 2 + .../cli/test/RushCommandLineParser.test.ts | 33 +- ...RushCommandLineParserReporterClose.test.ts | 43 +++ ...CommandLineParserReporterLifecycle.test.ts | 249 +++++++++++++ libraries/rush-lib/src/cli/test/TestUtils.ts | 6 +- libraries/rush-lib/src/logic/Telemetry.ts | 33 ++ .../logic/operations/OperationEventSink.ts | 7 +- .../src/logic/operations/OperationGraph.ts | 17 +- .../operations/ReporterOperationEventSink.ts | 336 ++++++++++++++++++ .../test/OperationGraphEventSink.test.ts | 281 ++++++++++++++- .../rush-lib/src/logic/test/Telemetry.test.ts | 44 ++- .../src/pluginFramework/RushSession.test.ts | 95 ++++- .../src/pluginFramework/RushSession.ts | 250 ++++++++++++- specs/2026-07-12-rush-reporter-overhaul.md | 6 + 22 files changed, 1566 insertions(+), 63 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts create mode 100644 libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..71b7d371662 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit shadow Rush lifecycle, phase-aware operation, diagnostic, telemetry, and command-result events without changing legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json new file mode 100644 index 00000000000..0603c92996b --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Report early initialization failures and defer successful reporter completion until telemetry finalization preserves the command's native outcome.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..5f23b51eea9 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add a stable structured diagnostic code for Rush command failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 13d17d81750..e9201654ba4 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import { IRushDiagnostic } from '@rushstack/rush-reporter'; import { IScopedLogger } from '@rushstack/rush-reporter'; import { IScopedMessageOptions } from '@rushstack/rush-reporter'; import { IScopedReporter } from '@rushstack/rush-reporter'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -684,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } @@ -1044,6 +1045,7 @@ export interface ITelemetryData { readonly operationResults?: Record; readonly performanceEntries?: readonly PerformanceEntry_2[]; readonly platform?: string; + readonly reporterData?: ITelemetryAggregate; readonly result: 'Succeeded' | 'Failed'; readonly rushVersion?: string; readonly timestampMs?: number; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..ecf09047dbd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1506,6 +1506,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; readonly detailKey: undefined; +}, { + readonly code: "RUSH_COMMAND_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_COMMAND_FAILED.summary"; + readonly detailKey: undefined; }]; // @beta diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 11f5c1d933a..0d697f893e6 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -111,16 +111,13 @@ type AreValidRushDiagnosticCodeSegments< ? IsValidRushDiagnosticCodeSegment : false; -type ValidateRushDiagnosticCode = - TCode extends `RUSH_${infer Segments}` - ? AreValidRushDiagnosticCodeSegments extends true - ? TCode - : never - : never; +type ValidateRushDiagnosticCode = TCode extends `RUSH_${infer Segments}` + ? AreValidRushDiagnosticCodeSegments extends true + ? TCode + : never + : never; -type ValidatedRushDiagnosticCodeDefinitions< - TDefinitions extends readonly IRushDiagnosticCodeDefinition[] -> = { +type ValidatedRushDiagnosticCodeDefinitions = { readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition ? TDefinitions[K] & { readonly code: ValidateRushDiagnosticCode; @@ -130,9 +127,7 @@ type ValidatedRushDiagnosticCodeDefinitions< function defineRushDiagnosticCodeDefinitions< const TDefinitions extends readonly IRushDiagnosticCodeDefinition[] ->( - definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions -): TDefinitions { +>(definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions): TDefinitions { return definitions; } @@ -233,6 +228,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', detailKey: undefined + }, + { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + detailKey: undefined } ]); @@ -257,12 +259,11 @@ export type RushDiagnosticTemplateKey = NonNullable< * * @beta */ -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = - new Map( - RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( - (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const - ) - ); +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const + ) +); export { isValidRushDiagnosticCode } from './RushDiagnosticCode'; -export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; \ No newline at end of file +export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 32107668384..456adc6c8eb 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -11,5 +11,6 @@ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', - 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}', + 'diagnostic.RUSH_COMMAND_FAILED.summary': 'The Rush command {commandName} failed.' } as const; diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 71331301bca..1415b217422 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -16,6 +16,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; +import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter } from '@rushstack/rush-reporter'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushConstants } from '../logic/RushConstants'; @@ -64,6 +65,13 @@ import { RushAlerts } from '../utilities/RushAlerts'; import { initializeDotEnv } from '../logic/dotenv'; import { measureAsyncFn } from '../utilities/performance'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { + _correlateRushSessionError, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionReporterSourceVersion, + _isRushSessionErrorRepresented +} from '../pluginFramework/RushSession'; /** * Options for `RushCommandLineParser`. @@ -91,6 +99,11 @@ export class RushCommandLineParser extends CommandLineParser { readonly #terminal: Terminal; readonly #autocreateBuildCommand: boolean; #initializationFailed: boolean = false; + #sessionLifecycleEmitter: LifecycleEmitter | undefined; + #commandLifecycleEmitter: LifecycleEmitter | undefined; + #sessionStartTimeMs: number | undefined; + #commandStartTimeMs: number | undefined; + #reporterCompletionEmitted: boolean = false; #reporterClosePromise: Promise | undefined; /** @@ -134,6 +147,13 @@ export class RushCommandLineParser extends CommandLineParser { this.#rushOptions = this.#normalizeOptions(options || {}); const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this.#rushOptions; + this.rushSession = new RushSession({ + getIsDebugMode: () => this.isDebug, + terminalProvider, + reporter + }); + this.#sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + let rushJsonFilePath: string | undefined; try { rushJsonFilePath = RushConfiguration.tryFindRushJsonLocation({ @@ -158,11 +178,6 @@ export class RushCommandLineParser extends CommandLineParser { this.rushGlobalFolder = new RushGlobalFolder(); - this.rushSession = new RushSession({ - getIsDebugMode: () => this.isDebug, - terminalProvider, - reporter - }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, rushConfiguration: this.rushConfiguration, @@ -264,12 +279,24 @@ export class RushCommandLineParser extends CommandLineParser { this.#terminalProvider.verboseEnabled = this.#terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); + this._startReporterSession(); + try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => this.pluginManager.tryInitializeUnassociatedPluginsAsync() ); - return await super.executeAsync(args); + const succeeded: boolean = await super.executeAsync(args); + if (!this.#reporterCompletionEmitted) { + this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + } + return succeeded; + } catch (error) { + if (!process.exitCode) { + process.exitCode = 1; + } + this._reportErrorAndSetExitCode(error as Error); + return false; } finally { await this._closeReporterAsync(); } @@ -287,6 +314,17 @@ export class RushCommandLineParser extends CommandLineParser { InternalError.breakInDebugger = true; } + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName) { + this.#commandLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession, { + commandName + }); + if (this.#commandLifecycleEmitter) { + this.#commandStartTimeMs = performance.now(); + this.#commandLifecycleEmitter.emitCommandStarted({ commandName }); + } + } + try { await this.#wrapOnExecuteAsync(); @@ -332,7 +370,12 @@ export class RushCommandLineParser extends CommandLineParser { } // This only gets hit if the wrapped execution completes successfully - await this.telemetry?.ensureFlushedAsync(); + try { + await this.telemetry?.ensureFlushedAsync(); + } catch (error) { + this._emitReporterFailureDiagnostic(error as Error); + throw error; + } } #normalizeOptions(options: Partial): IRushCommandLineParserOptions { @@ -540,7 +583,37 @@ export class RushCommandLineParser extends CommandLineParser { ); } + private _startReporterSession(): void { + if (this.#sessionLifecycleEmitter && this.#sessionStartTimeMs === undefined) { + this.#sessionStartTimeMs = performance.now(); + this.#sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + } + + private _emitReporterFailureDiagnostic(error: Error): void { + this._startReporterSession(); + const emitter: LifecycleEmitter | undefined = + this.#commandLifecycleEmitter ?? this.#sessionLifecycleEmitter; + const rushSession: RushSession | undefined = this.rushSession; + if (emitter && rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { + parameters: { + commandName: { + value: this.selectedAction?.actionName ?? 'unknown', + privacy: 'public' + } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); + } + } + private _reportErrorAndSetExitCode(error: Error): void { + this._emitReporterFailureDiagnostic(error); + if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -561,8 +634,6 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } - this.flushTelemetry(); - const configuredExitCode: string | number | undefined = process.exitCode; const numericExitCode: number = Number(configuredExitCode); const exitCode: number = @@ -570,6 +641,9 @@ export class RushCommandLineParser extends CommandLineParser { ? numericExitCode : 1; process.exitCode = exitCode; + this._emitReporterCompletion(exitCode); + this.flushTelemetry(); + const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. @@ -617,4 +691,54 @@ export class RushCommandLineParser extends CommandLineParser { } return this.#reporterClosePromise; } + + private _emitReporterCompletion(exitCode: number): void { + if (!this.#sessionLifecycleEmitter || this.#reporterCompletionEmitted) { + return; + } + this.#reporterCompletionEmitted = true; + + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName && this.#commandLifecycleEmitter) { + const durationMs: number | undefined = + this.#commandStartTimeMs === undefined ? undefined : performance.now() - this.#commandStartTimeMs; + this.#commandLifecycleEmitter.emitCommandResult({ + commandName, + succeeded: exitCode === 0, + exitCode + }); + this.#commandLifecycleEmitter.emitCommandCompleted({ + commandName, + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + if (this.#sessionLifecycleEmitter) { + const durationMs: number | undefined = + this.#sessionStartTimeMs === undefined ? undefined : performance.now() - this.#sessionStartTimeMs; + this.#sessionLifecycleEmitter.emitSessionCompleted({ + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + // Shadow derivation is deliberately observational. process.exitCode remains authoritative. + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession) { + _getRushSessionDerivedExitStatus(rushSession); + } + } +} + +function _getNumericProcessExitCode(fallback: number): number { + const { exitCode } = process; + if (typeof exitCode === 'number') { + return exitCode; + } + if (typeof exitCode === 'string') { + const parsed: number = Number(exitCode); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; } diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 4584ebd9811..df13ddad3f8 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -62,6 +62,7 @@ import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParameter import { TrimRushEnvironmentVariablesPlugin } from '../../logic/operations/TrimRushEnvironmentVariablesPlugin'; import { DebugHashesPlugin } from '../../logic/operations/DebugHashesPlugin'; import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import { attachReporterOperationEventSink } from '../../logic/operations/ReporterOperationEventSink'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; @@ -678,6 +679,7 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { await hooks.onGraphCreatedAsync.promise(graph, graphContext); }); + attachReporterOperationEventSink(graph, this.rushSession, this.actionName); const executeOptions: IExecuteOperationsOptions = { graph, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 64d47c1cfdf..6f1184c7dde 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -31,6 +31,7 @@ import './mockRushCommandLineParser'; import type { SpawnOptions } from 'node:child_process'; import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { Autoinstaller } from '../../logic/Autoinstaller'; import type { ITelemetryData } from '../../logic/Telemetry'; import { @@ -47,6 +48,15 @@ import { IS_WINDOWS } from '../../utilities/executionUtilities'; // we only reference the one that is common. const SPAWN_ARG_OPTIONS: number = 2; +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function spawnOptionEquals( spawnCall: SpawnMockCall, optionName: TOption, @@ -93,7 +103,11 @@ describe('RushCommandLineParser', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; - const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build'); + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build', { + eventSink: reporterSink, + sessionId: 'parser-shadow' + }); await expect(parser.executeAsync()).resolves.toEqual(true); @@ -111,6 +125,23 @@ describe('RushCommandLineParser', () => { const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1]; expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp); cwdOptionEquals(secondSpawn, `${repoPath}/b`); + + const eventTypes: string[] = reporterSink.inputs.map(({ type }) => type); + expect(eventTypes[0]).toBe('sessionStarted'); + expect(eventTypes[1]).toBe('commandStarted'); + expect(eventTypes).toContain('operationRegistered'); + expect(eventTypes).toContain('operationStatusChanged'); + expect(eventTypes.slice(-3)).toEqual(['commandResult', 'commandCompleted', 'sessionCompleted']); + expect(reporterSink.inputs.at(-3)?.payload).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationRegistered')) { + const scope = event.scope!; + expect(scope.operationId).toBe(`${scope.projectName}#${scope.phaseName}`); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 5ca85182753..6ddafceb845 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -5,6 +5,16 @@ import { RushCommandLineParser } from '../RushCommandLineParser'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import { RushConfiguration } from '../../api/RushConfiguration'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; + +class CapturingReporterSink implements IReporterEventSink { + public readonly events: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.events.push(event); + return `event-${this.events.length}`; + } +} describe('RushCommandLineParser reporter close', () => { let originalExitCode: string | number | undefined; @@ -74,6 +84,7 @@ describe('RushCommandLineParser reporter close', () => { resolveClose = resolve; }) ); + const sink: CapturingReporterSink = new CapturingReporterSink(); const exitSpy: jest.SpyInstance = jest .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -84,11 +95,13 @@ describe('RushCommandLineParser reporter close', () => { }); const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: `${__dirname}/repo`, + reporter: { eventSink: sink, sessionId: 'parser-exit-close' }, reporterCloseAsync: closeAsync }); const execution: Promise = parser.executeAsync(); expect(closeAsync).toHaveBeenCalledTimes(1); + expect(sink.events.at(-1)).toMatchObject({ type: 'sessionCompleted', payload: { exitCode: 1 } }); expect(exitSpy).not.toHaveBeenCalled(); process.exitCode = 0; @@ -144,4 +157,34 @@ describe('RushCommandLineParser reporter close', () => { expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); }); + + it('shares one reporter close operation across failure and finalization paths', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { + throw new Error('configuration failed'); + }); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: `${__dirname}/repo`, + reporterCloseAsync: closeAsync + }); + const firstClose: Promise = parser.executeAsync(); + const secondClose: Promise = parser.executeAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([false, false]); + await new Promise((resolve) => setImmediate(resolve)); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts new file mode 100644 index 00000000000..774265bc22b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -0,0 +1,249 @@ +// 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 { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; + +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import type { IRushConfigurationJson } from '../../api/RushConfiguration'; +import { + _getRushSessionDerivedExitStatus, + _isRushSessionErrorRepresented +} from '../../pluginFramework/RushSession'; +import { RushCommandLineParser } from '../RushCommandLineParser'; + +class CapturingReporterSink implements IReporterEventSink { + public readonly events: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.events.push(event); + return `event-${this.events.length}`; + } +} + +function isCompletion(event: IReporterEmitEventInput): boolean { + return ( + event.type === 'commandResult' || event.type === 'commandCompleted' || event.type === 'sessionCompleted' + ); +} + +describe('RushCommandLineParser reporter lifecycle', () => { + const temporaryFolders: string[] = []; + let originalExitCode: string | number | undefined; + let originalArgv: string[]; + let stdoutSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + async function copyRepositoryAsync(): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-lifecycle-')); + temporaryFolders.push(directory); + const repoPath: string = path.join(directory, 'repo'); + await fs.promises.cp(path.join(__dirname, 'basicAndRunBuildActionRepo'), repoPath, { recursive: true }); + return repoPath; + } + + beforeEach(() => { + originalExitCode = process.exitCode; + originalArgv = process.argv; + process.exitCode = undefined; + process.argv = ['node', 'rush', 'custom-output']; + EnvironmentConfiguration.reset(); + stdoutSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(async () => { + await Promise.all( + temporaryFolders + .splice(0) + .map((directory) => fs.promises.rm(directory, { recursive: true, force: true })) + ); + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + }); + + it.each([ + { file: 'rush.json', withClose: false }, + { file: 'rush.json', withClose: true }, + { file: 'common/config/rush/command-line.json', withClose: false }, + { file: 'common/config/rush/command-line.json', withClose: true } + ])('reports invalid $file before fatal exit (close callback: $withClose)', async ({ file, withClose }) => { + const repoPath: string = await copyRepositoryAsync(); + await fs.promises.writeFile(path.join(repoPath, file), '{'); + const visibleOutput: unknown[] = []; + + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + let eventsAtExit: readonly IReporterEmitEventInput[] = []; + let eventsAtClose: readonly IReporterEmitEventInput[] = []; + const exitSpy: jest.SpyInstance = jest.spyOn(process, 'exit').mockImplementation(() => { + eventsAtExit = [...sink.events]; + return undefined as never; + }); + const closeAsync: jest.Mock, []> = jest.fn(async () => { + eventsAtClose = [...sink.events]; + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'initialization-failure' } : undefined, + reporterCloseAsync: withClose ? closeAsync : undefined + }); + + if (!withClose) { + expect(exitSpy).toHaveBeenCalledWith(1); + } + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(process.exitCode).toBe(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(closeAsync).toHaveBeenCalledTimes(withClose ? 1 : 0); + expect(sink.events.map(({ type }) => type)).toEqual( + reporting ? ['sessionStarted', 'diagnosticEmitted', 'sessionCompleted'] : [] + ); + expect(eventsAtExit).toEqual(sink.events); + if (withClose) { + expect(eventsAtClose).toEqual(sink.events); + } + if (reporting) { + expect(sink.events[1].payload).toMatchObject({ + code: 'RUSH_COMMAND_FAILED', + diagnosticId: expect.any(String) + }); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + } + visibleOutput.push({ + stdout: stdoutSpy.mock.calls.map((args) => [...args]), + stderr: stderrSpy.mock.calls.map((args) => [...args]) + }); + exitSpy.mockRestore(); + } + + expect(visibleOutput[1]).toEqual(visibleOutput[0]); + }); + + it('emits and correlates a session diagnostic when plugin initialization fails before action selection', async () => { + const repoPath: string = await copyRepositoryAsync(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: { eventSink: sink, sessionId: 'plugin-initialization-failure' }, + reporterCloseAsync: closeAsync + }); + const error: Error = new Error('plugin initialization failed'); + jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync').mockRejectedValue(error); + + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sink.events.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'diagnosticEmitted', + 'sessionCompleted' + ]); + expect(sink.events[1].scope?.commandName).toBeUndefined(); + expect(_isRushSessionErrorRepresented(parser.rushSession, error)).toBe(true); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it.each([false, true])('awaits a real delayed public telemetry hook (reject: %s)', async (reject) => { + const visibleErrors: unknown[] = []; + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const repoPath: string = await copyRepositoryAsync(); + const rushJsonPath: string = path.join(repoPath, 'rush.json'); + const rushJson: IRushConfigurationJson = JsonFile.load(rushJsonPath); + rushJson.telemetryEnabled = true; + JsonFile.save(rushJson, rushJsonPath); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'telemetry-finalization' } : undefined, + reporterCloseAsync: reporting ? closeAsync : undefined + }); + let markHookStarted: (() => void) | undefined; + const hookStarted: Promise = new Promise((resolve) => { + markHookStarted = resolve; + }); + let releaseHook: (() => void) | undefined; + const hookReleased: Promise = new Promise((resolve) => { + releaseHook = resolve; + }); + const failure: Error = new Error('delayed telemetry flush failed'); + const flushTelemetry: jest.Mock, []> = jest.fn(async () => { + markHookStarted!(); + await hookReleased; + if (reject) { + throw failure; + } + }); + parser.rushSession.hooks.flushTelemetry.tapPromise('DelayedTelemetry', flushTelemetry); + + const execution: Promise = parser.executeAsync(['custom-output', '--reporter=junit']); + await hookStarted; + await new Promise((resolve) => setImmediate(resolve)); + const prematureCompletions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + releaseHook!(); + const succeeded: boolean = await execution; + + expect(JsonFile.load(path.join(repoPath, 'custom-output-args.json'))).toEqual(['--reporter', 'junit']); + expect(prematureCompletions).toEqual([]); + expect(succeeded).toBe(!reject); + expect(process.exitCode).toBe(reject ? 1 : 0); + expect(exitSpy).not.toHaveBeenCalled(); + expect(flushTelemetry).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledTimes(reporting ? 1 : 0); + if (reporting) { + const completions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + expect(completions.map(({ type }) => type)).toEqual([ + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + for (const event of completions) { + expect(event.payload).toMatchObject({ exitCode: reject ? 1 : 0 }); + } + expect(completions[0].payload).toMatchObject({ succeeded: !reject }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: reject ? 1 : 0, + outcome: reject ? 'failed' : 'succeeded' + }); + expect(_isRushSessionErrorRepresented(parser.rushSession, failure)).toBe(reject); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toHaveLength(reject ? 1 : 0); + } else { + expect(sink.events).toEqual([]); + } + visibleErrors.push(stderrSpy.mock.calls.map((args) => [...args])); + exitSpy.mockRestore(); + } + + expect(visibleErrors[1]).toEqual(visibleErrors[0]); + }); +}); diff --git a/libraries/rush-lib/src/cli/test/TestUtils.ts b/libraries/rush-lib/src/cli/test/TestUtils.ts index c8191358c2c..29fa4482233 100644 --- a/libraries/rush-lib/src/cli/test/TestUtils.ts +++ b/libraries/rush-lib/src/cli/test/TestUtils.ts @@ -4,6 +4,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser'; +import type { IRushSessionReporterOptions } from '../../pluginFramework/RushSession'; import { FlagFile } from '../../api/FlagFile'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -76,7 +77,8 @@ export const TEST_REPO_FOLDER_PATH: string = `${PROJECT_ROOT}/temp/test/unit-tes */ export async function getCommandLineParserInstanceAsync( repoName: string, - taskName: string + taskName: string, + reporter?: IRushSessionReporterOptions ): Promise { // Copy the test repo to a sandbox folder const repoPath: string = `${TEST_REPO_FOLDER_PATH}/${repoName}-${performance.now()}`; @@ -100,7 +102,7 @@ export async function getCommandLineParserInstanceAsync( // to exit and clear the Rush file lock. So running multiple `it` or `describe` test blocks over the same test // repo will fail due to contention over the same lock which is kept until the test runner process // ends. - const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath }); + const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath, reporter }); // Bulk tasks are hard-coded to expect install to have been completed. So, ensure the last-link.flag // file exists and is valid diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 1915f266ffa..71c5d74f64a 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import type { PerformanceEntry } from 'node:perf_hooks'; import { FileSystem, type FileSystemStats, JsonFile } from '@rushstack/node-core-library'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../api/RushConfiguration'; import { Rush } from '../api/Rush'; import type { RushSession } from '../pluginFramework/RushSession'; +import { _getRushSessionTelemetryAggregate } from '../pluginFramework/RushSession'; import { collectPerformanceEntries } from '../utilities/performance'; /** @@ -138,6 +140,16 @@ export interface ITelemetryData { * This is an array of `PerformanceEntry` objects, which can include marks, measures, and function timings. */ readonly performanceEntries?: readonly PerformanceEntry[]; + + /** + * The allowlisted projection derived from shadow reporter events. + * + * @remarks + * This is present only when the Rush frontend supplied a reporter event sink. + * It never contains messages, paths, arguments, raw output, remediation + * parameters, stack traces, or non-public envelope metadata. + */ + readonly reporterData?: ITelemetryAggregate; } const MAX_FILE_COUNT: number = 100; @@ -166,9 +178,30 @@ export class Telemetry { if (!this.#enabled) { return; } + const reporterAggregate: ITelemetryAggregate | undefined = _getRushSessionTelemetryAggregate( + this.#rushSession + ); + const processExitCode: number = + typeof process.exitCode === 'number' ? process.exitCode : Number(process.exitCode); const cpus: os.CpuInfo[] = os.cpus(); const data: ITelemetryData = { ...telemetryData, + reporterData: reporterAggregate + ? { + ...reporterAggregate, + commandName: reporterAggregate.commandName ?? telemetryData.name, + result: + reporterAggregate.result ?? (telemetryData.result === 'Succeeded' ? 'succeeded' : 'failed'), + exitCode: + reporterAggregate.exitCode ?? + (telemetryData.result === 'Succeeded' + ? 0 + : Number.isFinite(processExitCode) + ? processExitCode + : 1), + durationMs: reporterAggregate.durationMs ?? telemetryData.durationInSeconds * 1000 + } + : telemetryData.reporterData, performanceEntries: telemetryData.performanceEntries || collectPerformanceEntries(this.#telemetryStartTime), machineInfo: telemetryData.machineInfo || { diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index cda9311ebb4..b5d90e0ad8a 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,16 +40,13 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; /** * Invoked synchronously on every operation status transition. The result's * `status`, `error`, and `stopwatch` reflect the new state. */ - onOperationStatusChanged?( - result: IOperationExecutionResult, - previousStatus: OperationStatus - ): void; + onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; /** * Invoked when an operation's collated output is about to be displayed, diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 7cd67d15d37..2890772a1d6 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -679,7 +679,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -1296,10 +1296,9 @@ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatef function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed successfully in ${stopwatch.toString()}.`, - { operationId: record.name } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed successfully in ${stopwatch.toString()}.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) ); @@ -1316,10 +1315,10 @@ function _handleOperationSuccessWithWarning( ): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed with warnings in ${stopwatch.toString()}.`, - { operationId: record.name, stderr: true } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`, { + operationId: record.name, + stderr: true + }); record.collatedWriter.terminal.writeStderrLine( Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) ); diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts new file mode 100644 index 00000000000..d3287c44270 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + type IRushDiagnostic, + type LifecycleEmitter, + type OperationStatus as ReporterOperationStatus +} from '@rushstack/rush-reporter'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { RushSession } from '../../pluginFramework/RushSession'; +import { + _correlateRushSessionError, + _getRushSessionLifecycleEmitter +} from '../../pluginFramework/RushSession'; +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import type { OperationGraph } from './OperationGraph'; + +interface IReporterOperation { + readonly emitter: LifecycleEmitter; + readonly legacyOperationIds: Set; + readonly operationId: string; + readonly phaseName: string; + readonly projectName: string; + registrationCycle: IReporterOperationCycle | undefined; +} + +interface IReporterOperationCycle { + readonly registeredOperationIds: Set; + readonly statuses: Map; + diagnosed: boolean; + lastEmittedStatus: ReporterOperationStatus | undefined; + silent: boolean; +} + +class ReporterOperationEventSink implements IOperationGraphEventSink { + private readonly _operationsByLegacyId: Map = new Map(); + private readonly _cyclesByResult: WeakMap = + new WeakMap(); + private readonly _rushSession: RushSession; + + public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { + this._rushSession = rushSession; + const operationsByReporterId: Map = new Map(); + + for (const operation of operations) { + const projectName: string = operation.associatedProject.packageName; + const phaseName: string = operation.associatedPhase.name; + const operationId: string = `${projectName}#${phaseName}`; + let reporterOperation: IReporterOperation | undefined = operationsByReporterId.get(operationId); + if (!reporterOperation) { + const emitter: LifecycleEmitter | undefined = _getRushSessionLifecycleEmitter(rushSession, { + commandName, + operationId, + projectName, + phaseName + }); + if (!emitter) { + continue; + } + reporterOperation = { + emitter, + legacyOperationIds: new Set(), + operationId, + phaseName, + projectName, + registrationCycle: undefined + }; + operationsByReporterId.set(operationId, reporterOperation); + } + reporterOperation.legacyOperationIds.add(operation.name); + this._operationsByLegacyId.set(operation.name, reporterOperation); + } + } + + public get isEnabled(): boolean { + return this._operationsByLegacyId.size > 0; + } + + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation || !result) { + return; + } + + let cycle: IReporterOperationCycle | undefined = operation.registrationCycle; + if (!cycle || cycle.registeredOperationIds.size === operation.legacyOperationIds.size) { + cycle = { + registeredOperationIds: new Set(), + statuses: new Map(), + diagnosed: false, + lastEmittedStatus: undefined, + silent: true + }; + operation.registrationCycle = cycle; + } + + this._cyclesByResult.set(result, cycle); + cycle.registeredOperationIds.add(operationId); + cycle.silent &&= silent; + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { + return; + } + + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } + + public onOperationStatusChanged(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation) { + return; + } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } + + if ( + result.status === OperationStatus.Ready && + cycle.registeredOperationIds.size === operation.legacyOperationIds.size + ) { + return; + } + + cycle.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !cycle.diagnosed) { + cycle.diagnosed = true; + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: operation.projectName, privacy: 'public' } + } + }); + operation.emitter.emitDiagnostic(diagnostic); + if (result.error) { + _correlateRushSessionError(this._rushSession, result.error, diagnostic.diagnosticId); + } + } + + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation, cycle); + if (status === undefined || status === cycle.lastEmittedStatus) { + return; + } + cycle.lastEmittedStatus = status; + if (!cycle.silent) { + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + operation.emitter.emitOperationStatusChanged({ + operationId: operation.operationId, + status, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + } +} + +class CompositeOperationGraphEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + + private readonly _first: IOperationGraphEventSink; + private readonly _second: IOperationGraphEventSink; + + public constructor(first: IOperationGraphEventSink, second: IOperationGraphEventSink) { + this._first = first; + this._second = second; + this.onOperationChunk = + first.onOperationChunk || second.onOperationChunk + ? (operationId, chunk) => { + first.onOperationChunk?.(operationId, chunk); + second.onOperationChunk?.(operationId, chunk); + } + : undefined; + this.onOperationStreamClosed = + first.onOperationStreamClosed || second.onOperationStreamClosed + ? (operationId) => { + first.onOperationStreamClosed?.(operationId); + second.onOperationStreamClosed?.(operationId); + } + : undefined; + } + + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + this._first.onOperationRegistered?.(operationId, silent, result); + this._second.onOperationRegistered?.(operationId, silent, result); + } + + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { + this._first.onOperationStatusChanged?.(result, previousStatus); + this._second.onOperationStatusChanged?.(result, previousStatus); + } + + public onOperationHeader(operationId: string, completedOperations: number, totalOperations: number): void { + this._first.onOperationHeader?.(operationId, completedOperations, totalOperations); + this._second.onOperationHeader?.(operationId, completedOperations, totalOperations); + } + + public onActivity(text: string, options?: IOperationActivityOptions): void { + this._first.onActivity?.(text, options); + this._second.onActivity?.(text, options); + } +} + +/** + * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * + * @internal + */ +export function attachReporterOperationEventSink( + graph: OperationGraph, + rushSession: RushSession, + commandName: string +): void { + const reporterSink: ReporterOperationEventSink = new ReporterOperationEventSink( + rushSession, + commandName, + graph.operations + ); + if (!reporterSink.isEnabled) { + return; + } + + graph.eventSink = graph.eventSink + ? new CompositeOperationGraphEventSink(graph.eventSink, reporterSink) + : reporterSink; +} + +function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { + switch (status) { + case OperationStatus.Ready: + return 'ready'; + case OperationStatus.Waiting: + return 'waiting'; + case OperationStatus.Queued: + return 'queued'; + case OperationStatus.Executing: + return 'executing'; + case OperationStatus.Success: + return 'success'; + case OperationStatus.SuccessWithWarning: + return 'successWithWarnings'; + case OperationStatus.Failure: + return 'failure'; + case OperationStatus.Blocked: + return 'blocked'; + case OperationStatus.Skipped: + return 'skipped'; + case OperationStatus.FromCache: + return 'fromCache'; + case OperationStatus.NoOp: + return 'noOp'; + case OperationStatus.Aborted: + return 'aborted'; + } +} + +function _getAggregateStatus( + operation: IReporterOperation, + cycle: IReporterOperationCycle +): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...cycle.statuses.values()]; + if ( + statuses.some((status) => status === OperationStatus.Executing) || + cycle.lastEmittedStatus === 'executing' + ) { + if ( + cycle.statuses.size !== operation.legacyOperationIds.size || + statuses.some((status) => !_isTerminalStatus(status)) + ) { + return 'executing'; + } + } + if ( + cycle.statuses.size === operation.legacyOperationIds.size && + statuses.every((status) => _isTerminalStatus(status)) + ) { + return _getAggregateTerminalStatus(statuses); + } + if (statuses.some((status) => status === OperationStatus.Queued)) { + return 'queued'; + } + if (statuses.some((status) => status === OperationStatus.Ready)) { + return 'ready'; + } + if (statuses.some((status) => status === OperationStatus.Waiting)) { + return 'waiting'; + } + return operation.legacyOperationIds.size === 1 + ? _toReporterStatus(statuses[0] ?? OperationStatus.Ready) + : undefined; +} + +function _getAggregateTerminalStatus(operationStatuses: Iterable): ReporterOperationStatus { + const statuses: Set = new Set(operationStatuses); + if (statuses.has(OperationStatus.Failure)) return 'failure'; + if (statuses.has(OperationStatus.Aborted)) return 'aborted'; + if (statuses.has(OperationStatus.Blocked)) return 'blocked'; + if (statuses.has(OperationStatus.SuccessWithWarning)) return 'successWithWarnings'; + if (statuses.has(OperationStatus.Success)) return 'success'; + if (statuses.has(OperationStatus.FromCache)) return 'fromCache'; + if (statuses.has(OperationStatus.Skipped)) return 'skipped'; + return 'noOp'; +} + +function _isTerminalStatus(status: OperationStatus): boolean { + switch (status) { + case OperationStatus.Success: + case OperationStatus.SuccessWithWarning: + case OperationStatus.Failure: + case OperationStatus.Blocked: + case OperationStatus.Skipped: + case OperationStatus.FromCache: + case OperationStatus.NoOp: + case OperationStatus.Aborted: + return true; + default: + return false; + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c16d6c91f32..9a9883dfdb7 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -34,7 +34,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); -import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -46,6 +47,13 @@ import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; +import { + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + RushSession +} from '../../../pluginFramework/RushSession'; +import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; const mockPhase: IPhase = { name: 'phase', @@ -57,12 +65,17 @@ const mockPhase: IPhase = { missingScriptBehavior: 'silent' }; -function createOperation(name: string, runner: IOperationRunner): Operation { +function createOperation( + name: string, + runner: IOperationRunner, + phase: IPhase = mockPhase, + projectName: string = name +): Operation { return new Operation({ runner, logFilenameIdentifier: name, - phase: mockPhase, - project: { packageName: name } as unknown as RushConfigurationProject + phase, + project: { packageName: projectName } as unknown as RushConfigurationProject }); } @@ -95,6 +108,15 @@ class RecordingSink implements IOperationGraphEventSink { } } +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { return { quietMode, @@ -207,4 +229,255 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + + it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + }); + const createFailingOperation = (): Operation => + createOperation( + '@scope/project', + new MockOperationRunner('@scope/project (phase)', async () => OperationStatus.Failure) + ); + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createFailingOperation()]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const operation: Operation = createFailingOperation(); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' || type === 'operationStatusChanged' + ); + expect(operationEvents.length).toBeGreaterThan(1); + for (const event of operationEvents) { + expect(event.scope).toMatchObject({ + commandName: 'build', + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase' + }); + } + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'diagnosticEmitted', + payload: expect.objectContaining({ code: 'RUSH_OPERATION_FAILED' }) + }) + ); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); + + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'sharded-operation-shadow' } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardOneRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 1/2`, + async () => OperationStatus.Success + ); + let shardTwoOutcome: OperationStatus = OperationStatus.Failure; + const shardTwoRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 2/2`, + async () => shardTwoOutcome + ); + const collatorRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - collate`, + async () => OperationStatus.Success + ); + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shardOne: Operation = createOperation('shard-one', shardOneRunner, mockPhase, projectName); + const shardTwo: Operation = createOperation('shard-two', shardTwoRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shardOne.addDependency(preShard); + shardTwo.addDependency(preShard); + collator.addDependency(shardOne); + collator.addDependency(shardTwo); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shardOne, shardTwo]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const reporterOperationId: string = `${projectName}#phase`; + const operationEvents = (): IReporterEmitEventInput[] => + reporterSink.inputs.filter(({ scope }) => scope?.operationId === reporterOperationId); + expect(operationEvents().filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'failure' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + failure: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + + shardTwoOutcome = OperationStatus.Success; + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + expect( + operationEvents() + .filter(({ type }) => type === 'operationRegistered') + .map(({ scope }) => scope?.operationId) + ).toEqual([reporterOperationId, reporterOperationId]); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'success' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + success: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + + const lifecycleEmitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + lifecycleEmitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + lifecycleEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + lifecycleEmitter.emitSessionCompleted({ exitCode: 0 }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); + + it('isolates diagnostics when the next watch iteration registers before abort completes', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'overlapping-operation-shadow' } + }); + let runCount: number = 0; + let resolveFirstRun: ((status: OperationStatus) => void) | undefined; + let markFirstRunStarted: (() => void) | undefined; + const firstRunStarted: Promise = new Promise((resolve: () => void) => { + markFirstRunStarted = resolve; + }); + const runner: MockOperationRunner = new MockOperationRunner('@scope/overlap (phase)', async () => { + runCount++; + if (runCount === 1) { + markFirstRunStarted!(); + return await new Promise((resolve: (status: OperationStatus) => void) => { + resolveFirstRun = resolve; + }); + } + return OperationStatus.Failure; + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('overlap', runner, mockPhase, '@scope/overlap')]), + { ...createGraphOptions(mockWritable, false), pauseNextIteration: true } + ); + attachReporterOperationEventSink(graph, rushSession, 'build'); + + await graph.scheduleIterationAsync({}); + const firstExecution: Promise = graph.executeScheduledIterationAsync(); + await firstRunStarted; + await graph.scheduleIterationAsync({}); + const abortPromise: Promise = graph.abortCurrentIterationAsync(); + resolveFirstRun!(OperationStatus.Failure); + await Promise.all([firstExecution, abortPromise]); + await graph.executeScheduledIterationAsync(); + + expect( + reporterSink.inputs.filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(2); + }); + + it('recomputes grouped silence for each watch-style iteration', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'grouped-silence-shadow' } + }); + const projectName: string = '@scope/silence'; + const first: Operation = createOperation( + 'first', + new MockOperationRunner(`${projectName} (phase) - first`), + mockPhase, + projectName + ); + const second: Operation = createOperation( + 'second', + new MockOperationRunner(`${projectName} (phase) - second`), + mockPhase, + projectName + ); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationId: string = `${projectName}#phase`; + const countEvents = (type: IReporterEmitEventInput['type']): number => + reporterSink.inputs.filter( + ({ type: eventType, scope }) => eventType === type && scope?.operationId === operationId + ).length; + const registrationCount: number = countEvents('operationRegistered'); + const statusCount: number = countEvents('operationStatusChanged'); + expect(registrationCount).toBe(1); + expect(statusCount).toBeGreaterThan(0); + + first.enabled = false; + second.enabled = false; + graph.invalidateOperations(undefined, 'disable group'); + await graph.executeAsync({}); + + expect(countEvents('operationRegistered')).toBe(registrationCount); + expect(countEvents('operationStatusChanged')).toBe(statusCount); + }); }); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index a1e4511b0e2..6bff8eab19e 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -2,12 +2,22 @@ // See LICENSE in the project root for license information. import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import { Rush } from '../../api/Rush'; import { Telemetry, type ITelemetryData, type ITelemetryMachineInfo } from '../Telemetry'; -import { RushSession } from '../../pluginFramework/RushSession'; +import { _getRushSessionLifecycleEmitter, RushSession } from '../../pluginFramework/RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} interface ITelemetryPrivateMembers extends Omit { _flushAsyncTasks: Set>; @@ -136,6 +146,38 @@ describe(Telemetry.name, () => { expect(result.timestampMs).toBeDefined(); }); + it('projects public shadow events into legacy telemetry without exposing command arguments', () => { + const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); + const sink: CapturingSink = new CapturingSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: sink, sessionId: 'telemetry-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=secret'] }); + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'success' }); + + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ + name: 'build', + durationInSeconds: 2, + result: 'Succeeded', + machineInfo: {} as ITelemetryMachineInfo, + performanceEntries: [] + }); + + expect(telemetry.store[0].reporterData).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + expect(JSON.stringify(telemetry.store[0].reporterData)).not.toContain('--auth-token=secret'); + }); + it('calls custom flush telemetry', async () => { const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 26a48160731..c4287f8d580 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -3,16 +3,27 @@ import * as os from 'node:os'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, IReporterEventSink } from '@rushstack/rush-reporter'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { Rush } from '../api/Rush'; import { RushCommandLineParser } from '../cli/RushCommandLineParser'; -import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; +import { + _correlateRushSessionError, + _createRushSessionForPlugin, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + _isRushSessionErrorRepresented, + type IRushSessionReporterOptions, + RushSession +} from './RushSession'; class CapturingSink implements IReporterEventSink { public readonly inputs: IReporterEmitEventInput[] = []; @@ -149,4 +160,86 @@ describe(RushSession.name, () => { action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); }); + + it('observes shadow lifecycle, diagnostics, telemetry, and legacy correlation without terminal output', () => { + const sink: CapturingSink = new CapturingSink(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const session: RushSession = new RushSession({ + getIsDebugMode: () => false, + terminalProvider, + reporter: { eventSink: sink, sessionId: 'session-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + const error: AlreadyReportedError = new AlreadyReportedError(); + + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationRegistered({ + operationId: '@scope/project#_phase:test', + projectName: '@scope/project', + phaseName: '_phase:test' + }); + emitter.emitOperationStatusChanged({ + operationId: '@scope/project#_phase:test', + status: 'failure' + }); + const diagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: '@scope/project', privacy: 'public' } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 1, durationMs: 25 }); + emitter.emitSessionCompleted({ exitCode: 1, durationMs: 30 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(_getRushSessionTelemetryAggregate(session)).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + operationStatusCounts: { failure: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 } + }); + expect(terminalProvider.getAllOutput(false)).toEqual({ + log: '', + warning: '', + error: '', + verbose: '', + debug: '' + }); + }); + + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@private/plugin', + packageVersion: '1.0.0' + })); + + pluginSession.getReporter()!.emitMessage({ + severity: 'info', + text: '/local/private/path' + }); + _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + + const aggregate = _getRushSessionTelemetryAggregate(session)!; + expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); + expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e017a9a8cbc..fa9771ccb08 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -3,10 +3,19 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; import { + LifecycleEmitter, + LegacyErrorBridge, RushSessionReporting, + TelemetrySubscriber, + isReporterEventRequired, + resolveExitStatus, + type IReporterEmitEventInput, + type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IRushExitStatus, + type ITelemetryAggregate, type IScopedLogger, type IScopedReporter } from '@rushstack/rush-reporter'; @@ -77,7 +86,23 @@ interface IRushSessionState { readonly cloudBuildCacheProviderFactories: Map; readonly cobuildLockProviderFactories: Map; readonly hooks: RushLifecycleHooks; - readonly reporting: RushSessionReporting | undefined; + readonly reporting: IRushSessionReportingState | undefined; +} + +interface IRushSessionReportingState { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sessionReporting: RushSessionReporting; + readonly observer: IRushSessionShadowEventObserver; +} + +interface IRushSessionShadowEventObserver { + ingest(event: IReporterEmitEventInput, eventId: string): void; + buildTelemetryAggregate(): ITelemetryAggregate; + resolveExitStatus(): IRushExitStatus; + correlateError(error: unknown, diagnosticId: string): void; + isErrorRepresented(error: unknown): boolean; } let _rushLibSource: IReporterEventSource | undefined; @@ -107,8 +132,9 @@ function _getRushLibSource(): IReporterEventSource { function _createReporting( reporterOptions: IRushSessionReporterOptions | undefined, - source: IReporterEventSource -): RushSessionReporting | undefined { + source: IReporterEventSource, + observer?: IRushSessionShadowEventObserver +): IRushSessionReportingState | undefined { if (!reporterOptions) { return undefined; } @@ -121,10 +147,148 @@ function _createReporting( throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); } - return new RushSessionReporting({ - sink: eventSink, + const shadowObserver: IRushSessionShadowEventObserver = observer ?? _createRushSessionShadowEventObserver(); + const observedEventSink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const eventId: string = eventSink.emit(event); + shadowObserver.ingest(event, eventId); + return eventId; + } + }; + const boundSource: IReporterEventSource = { ...source }; + + return { + eventSink: observedEventSink, sessionId, - source: { ...source } + source: boundSource, + observer: shadowObserver, + sessionReporting: new RushSessionReporting({ + sink: observedEventSink, + sessionId, + source: boundSource + }) + }; +} + +function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserver { + const legacyErrorBridge: LegacyErrorBridge = new LegacyErrorBridge(); + const telemetrySubscriber: TelemetrySubscriber = new TelemetrySubscriber(); + const operationStatuses: Map = new Map(); + let sequence: number = 0; + let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let hasUnscopedFailure: boolean = false; + + const updateDerivedOperationStatus = (): void => { + const hasOperationFailure: boolean = [...operationStatuses.values()].some( + (status) => status === 'failure' || status === 'aborted' + ); + derivedExitStatus = resolveExitStatus({ + hasFailures: hasUnscopedFailure || hasOperationFailure + }); + }; + + return { + ingest(event: IReporterEmitEventInput, eventId: string): void { + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: ++sequence, + timestamp: new Date().toISOString(), + required: isReporterEventRequired(event.type) + }; + legacyErrorBridge.ingest(envelope); + + if (envelope.parentSessionId === undefined) { + switch (envelope.type) { + case 'commandStarted': { + operationStatuses.clear(); + hasUnscopedFailure = false; + derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; + break; + } + case 'operationRegistered': { + const { operationId } = envelope.payload as { operationId: string }; + operationStatuses.set(operationId, 'ready'); + updateDerivedOperationStatus(); + break; + } + case 'operationStatusChanged': { + const { operationId, status } = envelope.payload as { + operationId: string; + status: string; + }; + operationStatuses.set(operationId, status); + updateDerivedOperationStatus(); + break; + } + case 'diagnosticEmitted': { + const { severity } = envelope.payload as { severity?: string }; + if (severity === 'error' && envelope.scope?.operationId === undefined) { + hasUnscopedFailure = true; + updateDerivedOperationStatus(); + } + break; + } + case 'commandResult': { + const { succeeded, exitCode } = envelope.payload as { + succeeded: boolean; + exitCode: number; + }; + derivedExitStatus = resolveExitStatus({ + hasFailures: !succeeded || exitCode !== 0 + }); + break; + } + case 'commandCompleted': + case 'sessionCompleted': { + const { exitCode } = envelope.payload as { exitCode: number }; + derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + break; + } + default: + break; + } + } + + // Match the privacy behavior from #5990 without duplicating its reporter-package changes: + // only public envelopes contribute source, protocol, lifecycle, or diagnostic telemetry. + // Remove this outer gate after #5990 reaches shared main and the hardened subscriber is in this ancestry. + if (envelope.privacy === 'public') { + telemetrySubscriber.ingest(envelope); + } + }, + + buildTelemetryAggregate(): ITelemetryAggregate { + return telemetrySubscriber.buildAggregate(); + }, + + resolveExitStatus(): IRushExitStatus { + return derivedExitStatus; + }, + + correlateError(error: unknown, diagnosticId: string): void { + legacyErrorBridge.correlate(error, diagnosticId); + }, + + isErrorRepresented(error: unknown): boolean { + return legacyErrorBridge.shouldSuppressRendering(error); + } + }; +} + +function _createLifecycleEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + if (!state) { + return undefined; + } + + return new LifecycleEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined }); } @@ -181,7 +345,9 @@ export class RushSession { * source identity bound by Rush. */ public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { - return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedReporter( + scope ? { ...scope } : undefined + ); } /** @@ -193,7 +359,9 @@ export class RushSession { * available during the pre-major compatibility period. */ public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { - return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedLogger( + scope ? { ...scope } : undefined + ); } public registerCloudBuildCacheProviderFactory( @@ -248,7 +416,8 @@ export function _createRushSessionForPlugin( getSource: () => IReporterEventSource ): RushSession { const state: IRushSessionState = _getSessionState(rushSession); - if (!state.options.reporter) { + const reporting: IRushSessionReportingState | undefined = state.reporting; + if (!state.options.reporter || !reporting) { return rushSession; } @@ -264,7 +433,68 @@ export function _createRushSessionForPlugin( cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, cobuildLockProviderFactories: state.cobuildLockProviderFactories, hooks: state.hooks, - reporting: _createReporting(state.options.reporter, getSource()) + reporting: _createReporting(state.options.reporter, getSource(), reporting.observer) }); return pluginSession; } + +/** + * Creates a Rush-owned lifecycle emitter for internal command and operation paths. + * + * @internal + */ +export function _getRushSessionLifecycleEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); +} + +/** + * Returns the current allowlisted reporter telemetry projection. + * + * @internal + */ +export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITelemetryAggregate | undefined { + return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); +} + +/** + * Derives the shadow exit status without changing the authoritative process exit code. + * + * @internal + */ +export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +} + +/** + * Returns the Rush version bound to structured events for this session. + * + * @internal + */ +export function _getRushSessionReporterSourceVersion(rushSession: RushSession): string | undefined { + return _getSessionState(rushSession).reporting?.source.packageVersion; +} + +/** + * Correlates a legacy failure sentinel with an emitted structured diagnostic. + * + * @internal + */ +export function _correlateRushSessionError( + rushSession: RushSession, + error: unknown, + diagnosticId: string +): void { + _getSessionState(rushSession).reporting?.observer.correlateError(error, diagnosticId); +} + +/** + * Returns whether a failure is already represented by an emitted diagnostic or legacy sentinel. + * + * @internal + */ +export function _isRushSessionErrorRepresented(rushSession: RushSession, error: unknown): boolean { + return _getSessionState(rushSession).reporting?.observer.isErrorRepresented(error) ?? false; +} diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index 5b534c59bf3..bbc5d9e0848 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -335,6 +335,12 @@ required parent/wire reporter is fatal. Failure to create the full-detail file at both repository and OS-temp paths is nonfatal but emits an emergency warning and marks the artifact unavailable. +The engine's root reporting context is available before fallible repository +initialization. Failures before command selection emit a session-scoped +diagnostic and failure completion before reporter close. Successful command +completion is published only after command finalization, including the public +telemetry flush hooks, so reporter results retain the native exit outcome. + ### 5.5 Bootstrap and Wire Protocol `install-run-rush` performs a minimal prelude: From 852b04d2f3fca733ece1c279025de05824b87c28 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 10 Sep 2026 18:26:12 +0000 Subject: [PATCH 18/22] Fix shadow lifecycle error correlation and final registration Keep immutable errors intact, capture original pre-execution parser failures without changing legacy rendering, and observe final configured operation silence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../copilot-review-lifecycle_2026-09-10.json | 11 +++ .../copilot-review-lifecycle_2026-09-10.json | 11 +++ libraries/reporter/README.md | 10 +++ .../reporter/src/compat/LegacyErrorBridge.ts | 8 +- .../src/test/LegacyErrorBridge.test.ts | 22 +++++ .../rush-lib/src/cli/RushCommandLineParser.ts | 22 ++++- ...CommandLineParserReporterLifecycle.test.ts | 81 ++++++++++++++++++- .../src/logic/operations/OperationGraph.ts | 2 +- .../test/OperationGraphEventSink.test.ts | 52 ++++++++++++ 9 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json diff --git a/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json b/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json new file mode 100644 index 00000000000..6e8a6c6dc5a --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-review-lifecycle_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve immutable errors, diagnose pre-execution parser failures once, and register shadow operations after final watch iteration configuration.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json b/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json new file mode 100644 index 00000000000..9f2705adb2d --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-review-lifecycle_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Correlate diagnostics using weak metadata instead of mutating potentially frozen or non-extensible errors.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index b326abb9f19..4205016018d 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -4,6 +4,16 @@ Canonical event protocol, reporter manager, and built-in reporters for Rush. This package is released as a public beta. Exported contracts may change before the stable release. +## Shadow lifecycle compatibility + +Error correlation uses external weak metadata, so frozen and non-extensible errors retain their original +identity, cause, and properties. Correlation remains visible across bridge instances without keeping errors alive. + +Rush command-line parse failures emit one session-scoped `RUSH_COMMAND_FAILED` diagnostic before completion. +The original parser message is retained in the diagnostic's local-sensitive `message` parameter; native error +rendering and exit codes remain unchanged. Operation registration observes the final iteration configuration, +so unchanged watch operations do not produce visible shadow registration or status events. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/reporter/src/compat/LegacyErrorBridge.ts b/libraries/reporter/src/compat/LegacyErrorBridge.ts index 69c84e6c189..5bc6fb8d7dd 100644 --- a/libraries/reporter/src/compat/LegacyErrorBridge.ts +++ b/libraries/reporter/src/compat/LegacyErrorBridge.ts @@ -11,7 +11,7 @@ import { RushError } from '../diagnostics/RushError'; */ export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError' = 'AlreadyReportedError'; -const CORRELATION_KEY: unique symbol = Symbol('rush-reporter-correlated-diagnostic-id'); +const correlatedDiagnosticIds: WeakMap = new WeakMap(); /** * The criteria that must be met before the legacy error bridge is removed. @@ -94,11 +94,11 @@ export class LegacyErrorBridge { } /** - * Correlates a legacy sentinel error with the diagnostic id it corresponds to. + * Correlates an error with its diagnostic id without modifying the supplied object. */ public correlate(error: unknown, diagnosticId: string): void { if (typeof error === 'object' && error !== null) { - (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY] = diagnosticId; + correlatedDiagnosticIds.set(error, diagnosticId); } } @@ -107,7 +107,7 @@ export class LegacyErrorBridge { */ public getCorrelatedDiagnosticId(error: unknown): string | undefined { if (typeof error === 'object' && error !== null) { - return (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY]; + return correlatedDiagnosticIds.get(error); } return undefined; } diff --git a/libraries/reporter/src/test/LegacyErrorBridge.test.ts b/libraries/reporter/src/test/LegacyErrorBridge.test.ts index d230cb60805..fff9951bce7 100644 --- a/libraries/reporter/src/test/LegacyErrorBridge.test.ts +++ b/libraries/reporter/src/test/LegacyErrorBridge.test.ts @@ -86,4 +86,26 @@ describe('LegacyErrorBridge', () => { bridge.recordEmittedDiagnostic('diag_2'); expect(bridge.shouldSuppressRendering(sentinel)).toBe(true); }); + + it.each([Object.freeze, Object.seal, Object.preventExtensions])( + 'correlates an immutable error without modifying its identity, cause, or properties (%p)', + (restrict) => { + const cause: Error = new Error('original cause'); + const error: Error = new Error('original failure', { cause }); + restrict(error); + const descriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(error); + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + const otherBridge: LegacyErrorBridge = new LegacyErrorBridge(); + + bridge.correlate(error, 'immutable-error'); + + expect(Object.getOwnPropertyDescriptors(error)).toEqual(descriptors); + expect(error.cause).toBe(cause); + expect(otherBridge.getCorrelatedDiagnosticId(error)).toBe('immutable-error'); + expect(otherBridge.shouldSuppressRendering(error)).toBe(false); + otherBridge.recordEmittedDiagnostic('immutable-error'); + expect(otherBridge.shouldSuppressRendering(error)).toBe(true); + expect(otherBridge.shouldSuppressRendering(new Error(error.message, { cause }))).toBe(false); + } + ); }); diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 1415b217422..2b0e40aec07 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -302,6 +302,16 @@ export class RushCommandLineParser extends CommandLineParser { } } + public override async executeWithoutErrorHandlingAsync(args?: string[]): Promise { + try { + await super.executeWithoutErrorHandlingAsync(args); + } catch (error) { + // Capture the original parse error before the base executeAsync renders it and returns false. + this._emitReporterFailureDiagnostic(error as Error, !this.#commandLifecycleEmitter); + throw error; + } + } + protected override async onExecuteAsync(): Promise { // Defensively set the exit code to 1 so if Rush crashes for whatever reason, we'll have a nonzero exit code. // For example, Node.js currently has the inexcusable design of terminating with zero exit code when @@ -592,7 +602,7 @@ export class RushCommandLineParser extends CommandLineParser { } } - private _emitReporterFailureDiagnostic(error: Error): void { + private _emitReporterFailureDiagnostic(error: Error, includeMessage: boolean = false): void { this._startReporterSession(); const emitter: LifecycleEmitter | undefined = this.#commandLifecycleEmitter ?? this.#sessionLifecycleEmitter; @@ -603,7 +613,15 @@ export class RushCommandLineParser extends CommandLineParser { commandName: { value: this.selectedAction?.actionName ?? 'unknown', privacy: 'public' - } + }, + ...(includeMessage + ? { + message: { + value: error instanceof Error ? error.message : String(error), + privacy: 'local-sensitive' as const + } + } + : {}) } }); emitter.emitDiagnostic(diagnostic); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 774265bc22b..27f3d7951dd 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { JsonFile } from '@rushstack/node-core-library'; -import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import type { IReporterEmitEventInput, IReporterEventSink, IRushDiagnostic } from '@rushstack/rush-reporter'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { IRushConfigurationJson } from '../../api/RushConfiguration'; @@ -136,7 +136,7 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(visibleOutput[1]).toEqual(visibleOutput[0]); }); - it('emits and correlates a session diagnostic when plugin initialization fails before action selection', async () => { + it.each([false, true])('correlates a plugin initialization failure (frozen: %s)', async (frozen) => { const repoPath: string = await copyRepositoryAsync(); const sink: CapturingReporterSink = new CapturingReporterSink(); const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); @@ -149,6 +149,9 @@ describe('RushCommandLineParser reporter lifecycle', () => { reporterCloseAsync: closeAsync }); const error: Error = new Error('plugin initialization failed'); + if (frozen) { + Object.freeze(error); + } jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync').mockRejectedValue(error); await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); @@ -165,6 +168,80 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(closeAsync).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledWith(1); + expect(stderrSpy.mock.calls.flat().join('\n')).toContain(error.message); + expect(stderrSpy.mock.calls.flat().join('\n')).not.toContain('TypeError'); + }); + + it.each([ + { args: ['not-a-rush-command'], message: 'not-a-rush-command' }, + { args: ['list', '--not-a-rush-option'], message: '--not-a-rush-option' } + ])('reports one real pre-execution parse diagnostic for $args', async ({ args, message }) => { + const repoPath: string = await copyRepositoryAsync(); + const visibleOutput: unknown[] = []; + const stdoutWriteSpy: jest.SpyInstance = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const stderrWriteSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'parse-failure' } : undefined, + reporterCloseAsync: closeAsync + }); + + await expect(parser.executeAsync(args)).resolves.toBe(false); + + expect(process.exitCode).toBe(2); + expect(exitSpy).not.toHaveBeenCalled(); + expect(closeAsync).toHaveBeenCalledTimes(1); + const stderr: string = stderrSpy.mock.calls.flat().join('\n'); + expect(stderr).toContain(message); + expect(sink.events.map(({ type }) => type)).toEqual( + reporting ? ['sessionStarted', 'diagnosticEmitted', 'sessionCompleted'] : [] + ); + if (reporting) { + const diagnosticEvent: IReporterEmitEventInput = sink.events[1]; + const diagnostic: IRushDiagnostic = diagnosticEvent.payload as IRushDiagnostic; + expect(diagnostic.code).toBe('RUSH_COMMAND_FAILED'); + expect(diagnosticEvent.scope?.commandName).toBeUndefined(); + expect(diagnostic.parameters?.message).toEqual({ + value: expect.stringContaining(message), + privacy: 'local-sensitive' + }); + expect(stderr).toContain(diagnostic.parameters?.message.value); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + } + visibleOutput.push({ + stdout: stdoutSpy.mock.calls.map((call) => [...call]), + stderr: stderrSpy.mock.calls.map((call) => [...call]), + stdoutWrites: stdoutWriteSpy.mock.calls.map(([chunk]) => chunk), + stderrWrites: stderrWriteSpy.mock.calls.map(([chunk]) => chunk) + }); + exitSpy.mockRestore(); + } + expect(visibleOutput[1]).toEqual(visibleOutput[0]); + }); + + it('does not diagnose a successful help request as a parse failure', async () => { + jest.spyOn(process.stdout, 'write').mockReturnValue(true); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: await copyRepositoryAsync(), + reporter: { eventSink: sink, sessionId: 'help' } + }); + + await expect(parser.executeAsync(['--help'])).resolves.toBe(true); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + expect(sink.events.at(-1)?.payload).toMatchObject({ exitCode: 0 }); }); it.each([false, true])('awaits a real delayed public telemetry hook (reject: %s)', async (reject) => { diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 2890772a1d6..f67bd2cc8a2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -679,7 +679,6 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -708,6 +707,7 @@ export class OperationGraph implements IOperationGraph { }); for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); if (!executionRecord.silent) { // Only count non-silent operations iterationContext.totalOperations++; diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 9a9883dfdb7..0d4a3fb2809 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -54,6 +54,9 @@ import { RushSession } from '../../../pluginFramework/RushSession'; import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; +import { PhasedOperationPlugin } from '../PhasedOperationPlugin'; +import { PhasedCommandHooks, type IOperationGraphContext } from '../../../pluginFramework/PhasedCommandHooks'; +import type { IInputsSnapshot } from '../../incremental/InputsSnapshot'; const mockPhase: IPhase = { name: 'phase', @@ -434,6 +437,55 @@ describe('OperationGraph event sink (dual-emit)', () => { ).toHaveLength(2); }); + it('registers final silence after the standard plugin disables unchanged watch operations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'unchanged-watch' } + }); + const execute: jest.Mock, []> = jest.fn(async () => OperationStatus.Success); + const operations: Set = new Set( + ['first', 'second'].map((name) => + createOperation(name, new MockOperationRunner(name, execute), mockPhase, '@scope/unchanged') + ) + ); + const graph: OperationGraph = new OperationGraph(operations, createGraphOptions(mockWritable, false)); + const hooks: PhasedCommandHooks = new PhasedCommandHooks(); + new PhasedOperationPlugin().apply(hooks); + // This plugin's graph-configuration callback does not consume the command context. + await hooks.onGraphCreatedAsync.promise(graph, {} as IOperationGraphContext); + const registrationSink: RecordingSink = new RecordingSink(); + graph.eventSink = registrationSink; + attachReporterOperationEventSink(graph, rushSession, 'build'); + const inputsSnapshot: IInputsSnapshot = { + hashes: new Map(), + rootDirectory: '/repo', + hasUncommittedChanges: false, + getTrackedFileHashesForOperation: () => new Map(), + getOperationOwnStateHash: () => 'unchanged' + }; + + await graph.executeAsync({ inputsSnapshot }); + const eventsAfterFirstRun: IReporterEmitEventInput[] = [...reporterSink.inputs]; + expect(execute).toHaveBeenCalledTimes(2); + expect(eventsAfterFirstRun.some(({ type }) => type === 'operationRegistered')).toBe(true); + expect(registrationSink.registered).toEqual([ + ['first', false], + ['second', false] + ]); + + await graph.executeAsync({ inputsSnapshot }); + + expect(execute).toHaveBeenCalledTimes(2); + expect([...operations].every((operation) => operation.enabled)).toBe(true); + expect(registrationSink.registered.slice(2)).toEqual([ + ['first', true], + ['second', true] + ]); + expect(reporterSink.inputs).toEqual(eventsAfterFirstRun); + }); + it('recomputes grouped silence for each watch-style iteration', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ From 1a13d64edd6e0db665fe9f105eb66df7b9f98fc6 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 10 Sep 2026 21:39:47 +0000 Subject: [PATCH 19/22] Normalize Rush cwd before analyzing repository inputs Resolve physical cwd at parser entry so native Windows short names and directory aliases match Git repository paths. Keep real watch cancellation coverage and add symlink/junction regressions without mocking input analysis or watcher behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...eporter-watch-physical-cwd_2026-09-10.json | 11 ++++++++++ .../rush-lib/src/cli/RushCommandLineParser.ts | 5 +++-- ...CommandLineParserReporterLifecycle.test.ts | 22 +++++++++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json diff --git a/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json b/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json new file mode 100644 index 00000000000..371011e5a7b --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-watch-physical-cwd_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Resolve the command working directory to its physical path so watch input snapshots work with Windows short names and directory aliases. Preserve real watch cancellation and watcher cleanup coverage for both legacy and shadow reporting.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index b4d9738591e..75648ab68d4 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -8,7 +8,7 @@ import { type CommandLineFlagParameter, CommandLineHelper } from '@rushstack/ts-command-line'; -import { InternalError, AlreadyReportedError, Text } from '@rushstack/node-core-library'; +import { InternalError, AlreadyReportedError, FileSystem, Text } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, Terminal, @@ -391,7 +391,8 @@ export class RushCommandLineParser extends CommandLineParser { #normalizeOptions(options: Partial): IRushCommandLineParserOptions { return { - cwd: options.cwd || process.cwd(), + // Git reports physical paths, including when cwd contains a Windows short name or directory alias. + cwd: FileSystem.getRealPath(options.cwd || process.cwd()), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], reporter: options.reporter, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 81a2b94b4bf..e525b1f9ac2 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -330,9 +330,14 @@ describe('RushCommandLineParser reporter lifecycle', () => { expect(visibleErrors[1]).toEqual(visibleErrors[0]); }); - it.each([false, true])( - 'observes a real watch cancellation without changing legacy exit (shadow: %s)', - async (reporting) => { + it.each([ + { reporting: false, useAlias: false }, + { reporting: true, useAlias: false }, + { reporting: false, useAlias: true }, + { reporting: true, useAlias: true } + ])( + 'observes a real watch cancellation without changing legacy exit (shadow: $reporting, alias: $useAlias)', + async ({ reporting, useAlias }) => { const repoPath: string = await copyRepositoryAsync(); JsonFile.save( { @@ -384,8 +389,16 @@ describe('RushCommandLineParser reporter lifecycle', () => { .spyOn(process, 'exit') .mockImplementation(() => undefined as never); const watchSpy: jest.SpyInstance = jest.spyOn(fs, 'watch'); + const cwd: string = useAlias ? path.join(path.dirname(repoPath), 'repo-alias') : repoPath; + if (useAlias) { + await fs.promises.symlink( + await fs.promises.realpath(repoPath), + cwd, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } const parser: RushCommandLineParser = new RushCommandLineParser({ - cwd: repoPath, + cwd, reporter: reporting ? { eventSink: sink, sessionId: 'real-watch-cancellation' } : undefined }); await new FlagFile( @@ -413,6 +426,7 @@ describe('RushCommandLineParser reporter lifecycle', () => { await expect(execution).resolves.toBe(true); await Promise.all(closedWatchers); expect(reachedWatchIdle).toBe(true); + expect(parser.cwd).toBe(await fs.promises.realpath(repoPath)); expect(watchSpy.mock.calls.length).toBeGreaterThan(0); expect(action.sessionAbortController.signal.aborted).toBe(true); expect(exitSpy).not.toHaveBeenCalled(); From 1feb3ba36336ab0513b9f4f42c7f86de6d87000b Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 10 Sep 2026 21:43:10 +0000 Subject: [PATCH 20/22] Use native realpath to expand Windows short directory names Native Node 24 and 26 validation showed that generic realpathSync and FileSystem.getRealPath retain 8.3 names. Use the existing native-realpath pattern to resolve the physical directory before configuration discovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- libraries/rush-lib/src/cli/RushCommandLineParser.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 75648ab68d4..d4d92a5ef42 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -1,6 +1,7 @@ // 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 { @@ -8,7 +9,7 @@ import { type CommandLineFlagParameter, CommandLineHelper } from '@rushstack/ts-command-line'; -import { InternalError, AlreadyReportedError, FileSystem, Text } from '@rushstack/node-core-library'; +import { InternalError, AlreadyReportedError, Text } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, Terminal, @@ -392,7 +393,7 @@ export class RushCommandLineParser extends CommandLineParser { #normalizeOptions(options: Partial): IRushCommandLineParserOptions { return { // Git reports physical paths, including when cwd contains a Windows short name or directory alias. - cwd: FileSystem.getRealPath(options.cwd || process.cwd()), + cwd: fs.realpathSync.native(options.cwd || process.cwd()), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], reporter: options.reporter, From d672867e27c08a29c7845219dd6aedee0ad08a3b Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 10 Sep 2026 22:36:38 +0000 Subject: [PATCH 21/22] Capture successful Git setup diagnostics in watch regression tests Explicit pipes prevent Git line-ending notices from being mirrored onto the parent test stderr. Real setup failures still throw with the original captured error text. Reproduced the actual Rush production gate with process-local core.autocrlf=true and core.safecrlf=warn: unchanged tests exited with warnings before the fix and passed cleanly after it, without changing CI warning policy or watch assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../reporter-watch-fixture-git-output_2026-09-10.json | 11 +++++++++++ .../RushCommandLineParserReporterLifecycle.test.ts | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json diff --git a/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json b/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json new file mode 100644 index 00000000000..427d87a9dd8 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-watch-fixture-git-output_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Capture successful Git setup output in the real watch regression fixture so Windows line-ending notices do not mark the surrounding test operation as warned. Preserve nonzero Git failures and all native watch assertions.", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index e525b1f9ac2..bb21a685549 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -366,8 +366,9 @@ describe('RushCommandLineParser reporter lifecycle', () => { 'process.stdout.write("watch child output\\n");\n' ); } - execFileSync('git', ['init', '--quiet'], { cwd: repoPath }); - execFileSync('git', ['add', '.'], { cwd: repoPath }); + // Capture successful fixture setup diagnostics; failed Git commands still throw with their stderr. + execFileSync('git', ['init', '--quiet'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); execFileSync( 'git', [ @@ -382,7 +383,7 @@ describe('RushCommandLineParser reporter lifecycle', () => { '-m', 'Initialize watch fixture' ], - { cwd: repoPath } + { cwd: repoPath, stdio: 'pipe' } ); const sink: CapturingReporterSink = new CapturingReporterSink(); const exitSpy: jest.SpyInstance = jest From 08cd910307095b38560a00543fb078ebcf61d9c2 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 10 Sep 2026 23:20:09 +0000 Subject: [PATCH 22/22] Compare physical dependency targets in package manager tests Resolve both expected and actual link locations using native-backed realpath before comparing them. Add a real directory-alias regression that still rejects wrong and missing targets, and run it alongside the unchanged npm and Yarn integration workflows. Reproduced the previous lexical mismatch before the fix; the regression and complete suite pass under an invocation-owned aliased temporary root. No production code, dependency versions, or CI gates changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- .../README.md | 5 ++ .../src/TestHelper.ts | 5 +- .../src/runTests.ts | 11 ++++ .../src/testLinkIdentity.ts | 54 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts diff --git a/build-tests/rush-package-manager-integration-test/README.md b/build-tests/rush-package-manager-integration-test/README.md index bb5e2c7b53a..131026ce7eb 100644 --- a/build-tests/rush-package-manager-integration-test/README.md +++ b/build-tests/rush-package-manager-integration-test/README.md @@ -14,6 +14,11 @@ These tests ensure the tar 7.x upgrade works correctly with these workflows. The test suite is written in TypeScript using `@rushstack/node-core-library` for cross-platform compatibility. +### testLinkIdentity.ts +Verifies local dependency links through physical and aliased repository paths, while rejecting wrong +and missing targets. Both sides of the target comparison use native-backed realpath resolution so +Windows short-name aliases do not cause false failures. + ### testNpmMode.ts Tests Rush npm mode by: - Initializing a Rush repo with `npmVersion` configured diff --git a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts index 2ef48061152..322187ca2dc 100644 --- a/build-tests/rush-package-manager-integration-test/src/TestHelper.ts +++ b/build-tests/rush-package-manager-integration-test/src/TestHelper.ts @@ -1,6 +1,7 @@ // 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/promises'; import * as path from 'node:path'; import type * as child_process from 'node:child_process'; @@ -150,8 +151,8 @@ export class TestHelper { // Verify symlinks resolve correctly for local dependencies if (dep.startsWith('test-project-')) { - const depRealPath: string = await FileSystem.getRealPathAsync(depPath); - const expectedRealPath: string = path.join(testRepoPath, 'projects', dep); + const depRealPath: string = await fs.realpath(depPath); + const expectedRealPath: string = await fs.realpath(path.join(testRepoPath, 'projects', dep)); if (depRealPath !== expectedRealPath) { throw new Error( `ERROR: Symlink for ${dep} does not resolve correctly!\n` + diff --git a/build-tests/rush-package-manager-integration-test/src/runTests.ts b/build-tests/rush-package-manager-integration-test/src/runTests.ts index e531c6251bb..62c7379f342 100644 --- a/build-tests/rush-package-manager-integration-test/src/runTests.ts +++ b/build-tests/rush-package-manager-integration-test/src/runTests.ts @@ -5,6 +5,7 @@ import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal'; import { testNpmModeAsync } from './testNpmMode'; import { testYarnModeAsync } from './testYarnMode'; +import { testLinkIdentityAsync } from './testLinkIdentity'; /** * Main test runner that executes all package manager integration tests @@ -31,6 +32,16 @@ async function runTestsAsync(): Promise { let testsFailed: number = 0; const failedTests: string[] = []; + try { + await testLinkIdentityAsync(terminal); + testsPassed++; + } catch (error) { + testsFailed++; + failedTests.push('Local dependency link identity'); + terminal.writeErrorLine('Local dependency link identity checks FAILED'); + terminal.writeErrorLine(String(error)); + } + // Run npm mode test terminal.writeLine('=========================================='); terminal.writeLine('Running NPM mode test...'); diff --git a/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts b/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts new file mode 100644 index 00000000000..ee3c4a5ab80 --- /dev/null +++ b/build-tests/rush-package-manager-integration-test/src/testLinkIdentity.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ITerminal } from '@rushstack/terminal'; + +import { TestHelper } from './TestHelper'; + +export async function testLinkIdentityAsync(terminal: ITerminal): Promise { + const folder: string = await fs.mkdtemp(path.join(os.tmpdir(), 'rush-link-identity-')); + try { + const repoPath: string = path.join(folder, 'repo'); + await fs.mkdir(path.join(repoPath, 'projects', 'test-project-a'), { recursive: true }); + await fs.mkdir(path.join(repoPath, 'projects', 'wrong-target'), { recursive: true }); + await fs.mkdir(path.join(repoPath, 'projects', 'test-project-b', 'node_modules'), { recursive: true }); + + const physicalRepoPath: string = await fs.realpath(repoPath); + const aliasPath: string = path.join(folder, 'repo-alias'); + const dependencyPath: string = path.join( + physicalRepoPath, + 'projects', + 'test-project-b', + 'node_modules', + 'test-project-a' + ); + const linkType: 'junction' | 'dir' = process.platform === 'win32' ? 'junction' : 'dir'; + await fs.symlink(physicalRepoPath, aliasPath, linkType); + await fs.symlink(path.join(physicalRepoPath, 'projects', 'test-project-a'), dependencyPath, linkType); + + const helper: TestHelper = new TestHelper(terminal); + await helper.verifyDependenciesAsync(physicalRepoPath, 'test-project-b', ['test-project-a']); + await helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']); + + await fs.rm(dependencyPath, { recursive: true, force: true }); + await fs.symlink(path.join(physicalRepoPath, 'projects', 'wrong-target'), dependencyPath, linkType); + await assert.rejects( + helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']), + /does not resolve correctly/ + ); + + await fs.rm(dependencyPath, { recursive: true, force: true }); + await assert.rejects( + helper.verifyDependenciesAsync(aliasPath, 'test-project-b', ['test-project-a']), + /not found/ + ); + terminal.writeLine('Physical and aliased dependency links verified; wrong and missing targets rejected.'); + } finally { + await fs.rm(folder, { recursive: true, force: true }); + } +}