From 8c6b04953e3cfe081df2f358df2c8932c32d9952 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 02:52:30 +0000 Subject: [PATCH 001/133] Add Rush reporter repository configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...2a-experiment-config_2026-08-28-02-38.json | 11 +++ common/reviews/api/rush-lib.api.md | 8 ++ .../common/config/rush/experiments.json | 8 +- libraries/rush-lib/assets/rush-init/rush.json | 13 ++++ .../src/api/ExperimentsConfiguration.ts | 6 ++ .../rush-lib/src/api/RushConfiguration.ts | 25 +++++++ .../api/test/ExperimentsConfiguration.test.ts | 55 ++++++++++++++ .../test/RushConfigurationReporting.test.ts | 74 +++++++++++++++++++ libraries/rush-lib/src/index.ts | 6 +- .../src/schemas/experiments.schema.json | 4 + .../rush-lib/src/schemas/rush.schema.json | 16 ++++ 11 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts create mode 100644 libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json new file mode 100644 index 00000000000..2130da2c580 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2a-experiment-config_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add repository configuration for opting into and configuring the experimental Rush reporter.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e1839f13f69..37f1ea3ef31 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -499,6 +499,7 @@ export interface IExperimentsJson { usePnpmLockfileOnlyThenFrozenLockfileForRushUpdate?: boolean; usePnpmPreferFrozenLockfileForRushUpdate?: boolean; usePnpmSyncForInjectedDependencies?: boolean; + useRushReporter?: boolean; } // @beta @@ -973,6 +974,11 @@ export interface _IRushProjectJson { operationSettings?: IOperationSettings[]; } +// @beta +export interface IRushReportingConfiguration { + readonly agentEnvironmentVariables: readonly string[]; +} + // @beta (undocumented) export interface IRushSessionOptions { // (undocumented) @@ -1473,6 +1479,8 @@ export class RushConfiguration { get projectsByName(): ReadonlyMap; // @beta get projectsByTag(): ReadonlyMap>; + // @beta + readonly reportingConfiguration: IRushReportingConfiguration; readonly repositoryDefaultBranch: string; get repositoryDefaultFullyQualifiedRemoteBranch(): string; readonly repositoryDefaultRemote: string; diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json index a8c4c01cb4e..afaf6c9a2d5 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -165,5 +165,11 @@ * registry and proxy settings must likewise be supplied through trusted user, global, CLI, or * environment configuration rather than a project .npmrc. */ - /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true + /*[LINE "HYPOTHETICAL"]*/ "provideNpmrcCredentialsViaEnvironment": true, + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + /*[LINE "HYPOTHETICAL"]*/ "useRushReporter": true } diff --git a/libraries/rush-lib/assets/rush-init/rush.json b/libraries/rush-lib/assets/rush-init/rush.json index a972877f6d0..4cf8209cc28 100644 --- a/libraries/rush-lib/assets/rush-init/rush.json +++ b/libraries/rush-lib/assets/rush-init/rush.json @@ -316,6 +316,19 @@ */ /*[LINE "HYPOTHETICAL"]*/ "telemetryEnabled": false, + /** + * Configures repository settings used by the experimental Rush reporter system. + */ + /*[BEGIN "HYPOTHETICAL"]*/ + "reporting": { + /** + * Additional environment variable names that identify an agent environment. + * The built-in COPILOT_CLI variable does not need to be listed here. + */ + "agentEnvironmentVariables": ["MY_AGENT_CLI", "ANOTHER_AGENT"] + }, + /*[END "HYPOTHETICAL"]*/ + /** * Allows creation of hotfix changes. This feature is experimental so it is disabled by default. * If this is set, 'rush change' only allows a 'hotfix' change type to be specified. This change type diff --git a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts index 658671e14c9..4c179069759 100644 --- a/libraries/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/libraries/rush-lib/src/api/ExperimentsConfiguration.ts @@ -177,6 +177,12 @@ export interface IExperimentsJson { * through trusted user, global, CLI, or environment configuration rather than a project `.npmrc`. */ provideNpmrcCredentialsViaEnvironment?: boolean; + + /** + * If true, Rush may use the experimental Rush reporter system. If omitted or false, + * Rush preserves the legacy reporting behavior. + */ + useRushReporter?: boolean; } const _EXPERIMENTS_JSON_SCHEMA: JsonSchema = JsonSchema.fromLoadedObject(schemaJson); diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 527ec92be93..f75dffc795b 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -154,6 +154,21 @@ export interface IRushVariantOptionsJson { description: string; } +interface IRushReportingConfigurationJson { + agentEnvironmentVariables?: string[]; +} + +/** + * Repository settings used by the Rush reporter system. + * @beta + */ +export interface IRushReportingConfiguration { + /** + * Additional environment variable names that identify an agent environment. + */ + readonly agentEnvironmentVariables: readonly string[]; +} + /** * This represents the JSON data structure for the "rush.json" configuration file. * See rush.schema.json for documentation. @@ -184,6 +199,7 @@ export interface IRushConfigurationJson { yarnOptions?: IYarnOptionsJson; ensureConsistentVersions?: boolean; variants?: IRushVariantOptionsJson[]; + reporting?: IRushReportingConfigurationJson; } /** @@ -523,6 +539,12 @@ export class RushConfiguration { */ public readonly telemetryEnabled: boolean; + /** + * Repository settings used by the Rush reporter system. + * @beta + */ + public readonly reportingConfiguration: IRushReportingConfiguration; + /** * {@inheritDoc NpmOptionsConfiguration} */ @@ -853,6 +875,9 @@ export class RushConfiguration { } this.telemetryEnabled = !!rushConfigurationJson.telemetryEnabled; + this.reportingConfiguration = { + agentEnvironmentVariables: rushConfigurationJson.reporting?.agentEnvironmentVariables || [] + }; this.eventHooks = new EventHooks(rushConfigurationJson.eventHooks || {}); this.versionPolicyConfigurationFilePath = path.join( diff --git a/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts new file mode 100644 index 00000000000..dfe9526cab8 --- /dev/null +++ b/libraries/rush-lib/src/api/test/ExperimentsConfiguration.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { ExperimentsConfiguration } from '../ExperimentsConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', ExperimentsConfiguration.name); +const EXPERIMENTS_JSON_PATH: string = path.join(TEMP_FOLDER, 'experiments.json'); + +describe(ExperimentsConfiguration.name, () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('preserves legacy reporting behavior when the experiment file is absent', () => { + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBeUndefined(); + }); + + it('loads the Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: true }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(true); + }); + + it('keeps an explicit false value disabled', () => { + JsonFile.save({ useRushReporter: false }, EXPERIMENTS_JSON_PATH); + + const experimentsConfiguration: ExperimentsConfiguration = new ExperimentsConfiguration( + EXPERIMENTS_JSON_PATH + ); + + expect(experimentsConfiguration.configuration.useRushReporter).toBe(false); + }); + + it('rejects a non-boolean Rush reporter opt-in', () => { + JsonFile.save({ useRushReporter: 'yes' }, EXPERIMENTS_JSON_PATH); + + expect(() => new ExperimentsConfiguration(EXPERIMENTS_JSON_PATH)).toThrow(/useRushReporter/); + }); +}); diff --git a/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts new file mode 100644 index 00000000000..a188189023c --- /dev/null +++ b/libraries/rush-lib/src/api/test/RushConfigurationReporting.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; + +import { Rush } from '../Rush'; +import { RushConfiguration } from '../RushConfiguration'; + +const TEMP_FOLDER: string = path.join(__dirname, 'temp', 'RushConfigurationReporting'); +const RUSH_JSON_PATH: string = path.join(TEMP_FOLDER, 'rush.json'); + +function writeRushJson(reporting?: unknown): void { + JsonFile.save( + { + rushVersion: Rush.version, + pnpmVersion: '10.0.0', + projects: [], + ...(reporting === undefined ? {} : { reporting }) + }, + RUSH_JSON_PATH + ); +} + +describe('RushConfiguration reporting configuration', () => { + beforeEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + afterEach(() => { + FileSystem.ensureEmptyFolder(TEMP_FOLDER); + }); + + it('defaults agent environment variables to an empty array', () => { + writeRushJson(); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([]); + }); + + it('loads configured agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 'ANOTHER_AGENT'] + }); + + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH); + + expect(rushConfiguration.reportingConfiguration.agentEnvironmentVariables).toEqual([ + 'MY_AGENT_CLI', + 'ANOTHER_AGENT' + ]); + }); + + it('rejects invalid agent environment variables', () => { + writeRushJson({ + agentEnvironmentVariables: ['MY_AGENT_CLI', 123] + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow( + /agentEnvironmentVariables/ + ); + }); + + it('rejects unsupported reporting settings', () => { + writeRushJson({ + agentEnvironmentVariables: [], + defaultReporter: 'ai' + }); + + expect(() => RushConfiguration.loadFromConfigurationFile(RUSH_JSON_PATH)).toThrow(/defaultReporter/); + }); +}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index f2df5c851a1..0fdd200e775 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -20,7 +20,11 @@ export { export { ApprovedPackagesPolicy } from './api/ApprovedPackagesPolicy'; -export { RushConfiguration, type ITryFindRushJsonLocationOptions } from './api/RushConfiguration'; +export { + RushConfiguration, + type IRushReportingConfiguration, + type ITryFindRushJsonLocationOptions +} from './api/RushConfiguration'; export { Subspace } from './api/Subspace'; export { SubspacesConfiguration } from './api/SubspacesConfiguration'; diff --git a/libraries/rush-lib/src/schemas/experiments.schema.json b/libraries/rush-lib/src/schemas/experiments.schema.json index fa4d2ee1308..de8925a12ce 100644 --- a/libraries/rush-lib/src/schemas/experiments.schema.json +++ b/libraries/rush-lib/src/schemas/experiments.schema.json @@ -101,6 +101,10 @@ "provideNpmrcCredentialsViaEnvironment": { "description": "If true, when using PNPM 10.34.2 through 10.x or PNPM 11.5.3 through versions earlier than 11.6.0, Rush resolves the \"${VAR}\" tokens that appear in credentials and registry URLs in the .npmrc file, instead of relying on PNPM to expand them. Credentials are passed to PNPM using \"npm_config_*\" environment variables and are not written to the generated .npmrc file. PNPM 11.6.0 and newer support URL-scoped \"pnpm_config_//...\" environment variables, which should instead be supplied directly by CI so the trusted environment binds each credential to its registry. Dynamic registry and proxy settings must likewise come from trusted user, global, CLI, or environment configuration.", "type": "boolean" + }, + "useRushReporter": { + "description": "If true, Rush may use the experimental Rush reporter system. If omitted or false, Rush preserves the legacy reporting behavior.", + "type": "boolean" } }, "additionalProperties": false diff --git a/libraries/rush-lib/src/schemas/rush.schema.json b/libraries/rush-lib/src/schemas/rush.schema.json index dce5fcaae37..9593b014ee0 100644 --- a/libraries/rush-lib/src/schemas/rush.schema.json +++ b/libraries/rush-lib/src/schemas/rush.schema.json @@ -248,6 +248,22 @@ "description": "Indicates whether telemetry data should be collected and stored in the Rush temp folder during Rush runs.", "type": "boolean" }, + "reporting": { + "description": "Configures repository settings used by the Rush reporter system.", + "type": "object", + "properties": { + "agentEnvironmentVariables": { + "description": "Additional environment variable names that identify an agent environment.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, "allowedProjectTags": { "description": "This is an optional, but recommended, list of allowed tags that can be applied to Rush projects using the \"tags\" setting in this file. This list is useful for preventing mistakes such as misspelling, and it also provides a centralized place to document your tags. If \"allowedProjectTags\" list is not specified, then any valid tag is allowed. A tag name must be one or more words separated by hyphens or slashes, where a word may contain lowercase ASCII letters, digits, \".\", and \"@\" characters.", "type": "array", From 23de51af4ecfb45e4ee729d13719b6acbac09a60 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:06:20 +0000 Subject: [PATCH 002/133] Expose scoped RushSession reporter producers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 5 +- apps/rush/src/RushFrontend.ts | 10 +- apps/rush/src/test/RushFrontend.test.ts | 57 ++++- ...ter-r3a-session-sink_2026-08-28-02-38.json | 11 + .../build-tests-subspace/pnpm-lock.yaml | 1 + .../build-tests-subspace/repo-state.json | 4 +- .../config/subspaces/default/pnpm-lock.yaml | 3 + common/reviews/api/rush-lib.api.md | 47 +++++ libraries/rush-lib/src/api/Rush.ts | 13 ++ .../rush-lib/src/cli/RushCommandLineParser.ts | 9 +- .../src/cli/actions/BaseRushAction.ts | 5 +- libraries/rush-lib/src/index.ts | 16 ++ .../PluginLoader/PluginLoaderBase.ts | 17 ++ .../src/pluginFramework/PluginManager.ts | 14 +- .../src/pluginFramework/RushSession.test.ts | 152 ++++++++++++++ .../src/pluginFramework/RushSession.ts | 198 ++++++++++++++++-- libraries/rush-sdk/package.json | 1 + .../test/__snapshots__/script.test.ts.snap | 4 +- 18 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json create mode 100644 libraries/rush-lib/src/pluginFramework/RushSession.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a67..920ae96235f 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ILaunchOptions } from '@microsoft/rush-lib'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; /** * The cross-version launch contract owned by the Rush frontend. @@ -13,6 +12,6 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; * options, so an older engine can safely ignore the new property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { - readonly reporterEventSink: IReporterEventSink; + readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..044a060d6b9 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { randomUUID } from 'node:crypto'; + import type { ILaunchOptions } from '@microsoft/rush-lib'; import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; @@ -30,6 +32,7 @@ export interface IRushFrontendOptions { currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions ) => void | Promise; + readonly createSessionId?: () => string; readonly processLifecycle?: IRushFrontendProcessLifecycle; } @@ -132,6 +135,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, processLifecycle = createProcessLifecycle() } = options; @@ -152,9 +156,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink, + reporter: { + eventSink: reporterHost.sink, + sessionId + }, reporterCloseAsync }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 30b4081f405..233b3d7e2d1 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; import { @@ -18,6 +19,7 @@ import { } from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, type IInitializedRushReporterHost, @@ -178,7 +180,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; - let receivedOptions: Record | undefined; + let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -195,7 +197,7 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; order.push('engine'); - receivedOptions = launchOptions as unknown as Record; + receivedOptions = launchOptions; return launchOptions.reporterCloseAsync(); }, processLifecycle @@ -203,9 +205,10 @@ describe(launchRushFrontendAsync.name, () => { expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); - expect(receivedOptions?.reporterEventSink).toEqual( - expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink - ); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String) + }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); @@ -216,6 +219,46 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend' + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -603,7 +646,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); return launchOptions.reporterCloseAsync(); }, processLifecycle: createTestProcessLifecycle() @@ -644,7 +687,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); Object.defineProperty(parser, '_rushOptions', { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json new file mode 100644 index 00000000000..fa12adb823f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose an optional scoped reporter producer API to Rush actions and plugins while preserving legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 5a14a3f4f84..52b4949bd4d 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -4990,6 +4990,7 @@ snapshots: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-reporter': file:../../../libraries/reporter(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 4555e68e184..c12a6241868 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "2f7908424d103b2f677e95bcd5d85a385b75eda2", + "pnpmShrinkwrapHash": "e3fd56b3094928b8856da3821af80ef4deee0529", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "e8fe4109038ad6e9b1e97cbb83e63d9094d37fe4" + "packageJsonInjectedDependenciesHash": "b0634100322878d7a992fa589326473bc3965ab6" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 76e3feb4d1d..b4eb9477abb 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4407,6 +4407,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-reporter': + specifier: workspace:* + version: link:../reporter '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 37f1ea3ef31..13d17d81750 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -13,14 +13,22 @@ import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICreateRushDiagnosticOptions } from '@rushstack/rush-reporter'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterEventScope } from '@rushstack/rush-reporter'; +import { IReporterEventSink } from '@rushstack/rush-reporter'; +import { IRushDiagnostic } from '@rushstack/rush-reporter'; +import { IScopedLogger } from '@rushstack/rush-reporter'; +import { IScopedMessageOptions } from '@rushstack/rush-reporter'; +import { IScopedReporter } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +36,11 @@ import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; +import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; import type { PerformanceEntry as PerformanceEntry_2 } from 'node:perf_hooks'; +import { ReporterExtensionEventName } from '@rushstack/rush-reporter'; +import { ReporterJsonValue } from '@rushstack/rush-reporter'; +import { ReporterPrivacyClassification } from '@rushstack/rush-reporter'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; @@ -148,6 +160,8 @@ export class CommonVersionsConfiguration { saveAsync(): Promise; } +export { createRushDiagnostic } + export { CredentialCache } // @beta @@ -439,6 +453,8 @@ export interface ICreateOperationsContext { readonly rushConfiguration: RushConfiguration; } +export { ICreateRushDiagnosticOptions } + export { ICredentialCacheEntry } export { ICredentialCacheOptions } @@ -557,6 +573,8 @@ export interface ILaunchOptions { // @internal builtInPluginConfigurations?: _IBuiltInPluginConfiguration[]; isManaged: boolean; + // @internal + reporter?: IRushSessionReporterOptions; terminalProvider?: ITerminalProvider; } @@ -911,6 +929,10 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +export { IReporterEventScope } + +export { IReporterEventSink } + // @beta export interface IRushCommand { readonly actionName: string; @@ -943,6 +965,8 @@ export interface IRushCommandLineSpec { // @beta (undocumented) export type IRushConfigurationProjectForSnapshot = Pick; +export { IRushDiagnostic } + // @alpha (undocumented) export interface IRushPhaseSharding { count: number; @@ -983,10 +1007,23 @@ export interface IRushReportingConfiguration { export interface IRushSessionOptions { // (undocumented) getIsDebugMode: () => boolean; + reporter?: IRushSessionReporterOptions; // (undocumented) terminalProvider: ITerminalProvider; } +// @beta +export interface IRushSessionReporterOptions { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; +} + +export { IScopedLogger } + +export { IScopedMessageOptions } + +export { IScopedReporter } + // @beta export interface IStopwatchResult { get duration(): number; @@ -1288,6 +1325,8 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @beta export type Parallelism = number | IParallelismScalar; +export { parseReporterExtensionEventName } + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1365,6 +1404,12 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +export { ReporterExtensionEventName } + +export { ReporterJsonValue } + +export { ReporterPrivacyClassification } + // @public export class RepoStateFile { readonly filePath: string; @@ -1702,6 +1747,8 @@ export class RushSession { getCobuildLockProviderFactory(cobuildLockProviderName: string): CobuildLockProviderFactory | undefined; // (undocumented) getLogger(name: string): ILogger; + getReporter(scope?: IReporterEventScope): IScopedReporter | undefined; + getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined; // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index a51af8b0930..e75815484d6 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -14,6 +14,7 @@ import { RushXCommandLine } from '../cli/RushXCommandLine'; import { CommandLineMigrationAdvisor } from '../cli/CommandLineMigrationAdvisor'; import { EnvironmentVariableNames } from './EnvironmentConfiguration'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { IRushSessionReporterOptions } from '../pluginFramework/RushSession'; import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; @@ -58,6 +59,17 @@ export interface ILaunchOptions { * @internal */ builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + + /** + * Supplies the structured event sink owned by the Rush frontend. + * + * @remarks + * This is an internal cross-version frontend-to-engine handoff. Reporter + * selection and concrete reporter instances remain owned by the frontend. + * + * @internal + */ + reporter?: IRushSessionReporterOptions; } let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; @@ -98,6 +110,7 @@ export class Rush { const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations, + reporter: options.reporter, reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index b91d3ad8a6b..ba043788d88 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -57,7 +57,7 @@ import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; import { type ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; -import { RushSession } from '../pluginFramework/RushSession'; +import { type IRushSessionReporterOptions, RushSession } from '../pluginFramework/RushSession'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; import { RushAlerts } from '../utilities/RushAlerts'; @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; } @@ -131,7 +132,7 @@ export class RushCommandLineParser extends CommandLineParser { const terminal: Terminal = new Terminal(this._terminalProvider); this._terminal = terminal; this._rushOptions = this._normalizeOptions(options || {}); - const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations } = this._rushOptions; + const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this._rushOptions; let rushJsonFilePath: string | undefined; try { @@ -159,7 +160,8 @@ export class RushCommandLineParser extends CommandLineParser { this.rushSession = new RushSession({ getIsDebugMode: () => this.isDebug, - terminalProvider + terminalProvider, + reporter }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, @@ -338,6 +340,7 @@ export class RushCommandLineParser extends CommandLineParser { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporter: options.reporter, reporterCloseAsync: options.reporterCloseAsync }; } diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index 256224d10f4..62222ba7d76 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { CommandLineAction, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; import { LockFile } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { IScopedReporter } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; @@ -44,6 +45,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme protected readonly rushConfiguration: RushConfiguration | undefined; protected readonly terminal: ITerminal; protected readonly rushSession: RushSession; + protected readonly reporter: IScopedReporter | undefined; protected readonly rushGlobalFolder: RushGlobalFolder; protected readonly parser: RushCommandLineParser; @@ -57,6 +59,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme this.rushConfiguration = rushConfiguration; this.terminal = terminal; this.rushSession = rushSession; + this.reporter = rushSession.getReporter({ commandName: this.actionName }); this.rushGlobalFolder = rushGlobalFolder; } @@ -115,7 +118,7 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { return this._eventHooksManager; } - protected declare readonly rushConfiguration: RushConfiguration; + declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { if (!this.rushConfiguration) { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 0fdd200e775..6f0bb4c5e67 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -168,10 +168,26 @@ export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { RushSession, type IRushSessionOptions, + type IRushSessionReporterOptions, type CloudBuildCacheProviderFactory, type CobuildLockProviderFactory } from './pluginFramework/RushSession'; +export { + createRushDiagnostic, + parseReporterExtensionEventName, + type ICreateRushDiagnosticOptions, + type IReporterEventScope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedLogger, + type IScopedMessageOptions, + type IScopedReporter, + type ReporterExtensionEventName, + type ReporterJsonValue, + type ReporterPrivacyClassification +} from '@rushstack/rush-reporter'; + export { type IRushCommand, type IGlobalCommand, diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index e2b235d113f..adbc3ab26d4 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -7,6 +7,8 @@ import { FileSystem, InternalError, JsonFile, + PackageJsonLookup, + type IPackageJson, type JsonObject, JsonSchema } from '@rushstack/node-core-library'; @@ -51,6 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; + private _packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -84,6 +87,20 @@ export abstract class PluginLoaderBase< return this._getRushPluginManifest(); } + public get packageVersion(): string { + if (!this._packageVersionCache) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( + path.join(this.packageFolder, 'package.json') + ); + if (!packageJson.version) { + throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); + } + this._packageVersionCache = packageJson.version; + } + + return this._packageVersionCache; + } + public getCommandLineConfiguration(): CommandLineConfiguration | undefined { const commandLineJsonFilePath: string | undefined = this._getCommandLineJsonFilePath(); if (!commandLineJsonFilePath) { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 9a5181e078c..0e353f3a574 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -9,7 +9,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import { BuiltInPluginLoader, type IBuiltInPluginConfiguration } from './PluginLoader/BuiltInPluginLoader'; import type { IRushPlugin } from './IRushPlugin'; import { AutoinstallerPluginLoader } from './PluginLoader/AutoinstallerPluginLoader'; -import type { RushSession } from './RushSession'; +import { _createRushSessionForPlugin, type RushSession } from './RushSession'; import type { PluginLoaderBase } from './PluginLoader/PluginLoaderBase'; import { Rush } from '../api/Rush'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; @@ -205,7 +205,7 @@ export class PluginManager { const plugin: IRushPlugin | undefined = pluginLoader.load(); this._loadedPluginNames.add(pluginName); if (plugin) { - this._applyPlugin(plugin, pluginName); + this._applyPlugin(plugin, pluginLoader); } } } @@ -227,9 +227,15 @@ export class PluginManager { }); } - private _applyPlugin(plugin: IRushPlugin, pluginName: string): void { + private _applyPlugin(plugin: IRushPlugin, pluginLoader: PluginLoaderBase): void { + const { packageName, pluginName } = pluginLoader; try { - plugin.apply(this._rushSession, this._rushConfiguration); + const pluginSession: RushSession = _createRushSessionForPlugin(this._rushSession, () => ({ + packageName, + packageVersion: pluginLoader.packageVersion, + component: pluginName + })); + plugin.apply(pluginSession, this._rushConfiguration); } catch (e) { throw new InternalError(`Error applying "${pluginName}": ${e}`); } diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts new file mode 100644 index 00000000000..26a48160731 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { + IReporterEmitEventInput, + IReporterEventSource, + IReporterEventSink +} from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { Rush } from '../api/Rush'; +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + +function createSession(reporter?: IRushSessionReporterOptions): RushSession { + return new RushSession({ + getIsDebugMode: () => false, + terminalProvider: new StringBufferTerminalProvider(), + reporter + }); +} + +describe(RushSession.name, () => { + it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { + const session: RushSession = createSession(); + + expect(session.getReporter()).toBeUndefined(); + expect(session.getScopedLogger()).toBeUndefined(); + expect(session.getLogger('legacy')).toBeDefined(); + expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + }); + + it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-1' }); + const scope = { commandName: 'build', projectName: '@scope/project' }; + const reporter = session.getReporter(scope); + + expect(reporter).toBeDefined(); + expect(Object.keys(reporter!).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + expect('getSink' in reporter!).toBe(false); + expect('reporters' in reporter!).toBe(false); + expect(Object.keys(session)).not.toContain('reporter'); + + scope.commandName = 'spoofed'; + reporter!.emitMessage({ severity: 'info', text: 'hello' }); + + expect(sink.inputs).toHaveLength(1); + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-1', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { + commandName: 'build', + projectName: '@scope/project' + } + }); + expect(sink.inputs[0]).not.toHaveProperty('eventId'); + expect(sink.inputs[0]).not.toHaveProperty('sequence'); + expect(sink.inputs[0]).not.toHaveProperty('timestamp'); + expect(sink.inputs[0]).not.toHaveProperty('required'); + }); + + it('isolates plugin sources while sharing session state', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-2' }); + const pluginSource: IReporterEventSource = { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }; + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => pluginSource); + + expect(pluginSession.hooks).toBe(session.hooks); + (pluginSource as { packageName: string }).packageName = '@acme/spoofed'; + pluginSession.getReporter({ projectName: '@scope/a' })!.emitMessage({ + severity: 'info', + text: 'plugin' + }); + session.getReporter({ projectName: '@scope/b' })!.emitMessage({ + severity: 'info', + text: 'rush' + }); + + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }, + scope: { projectName: '@scope/a' } + }); + expect(sink.inputs[1]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { projectName: '@scope/b' } + }); + }); + + it('rejects invalid explicitly supplied reporter options', () => { + expect(() => + createSession({ + eventSink: {} as IReporterEventSink, + sessionId: 'session-3' + }) + ).toThrow(/eventSink/); + + expect(() => createSession({ eventSink: new CapturingSink(), sessionId: ' ' })).toThrow(/sessionId/); + }); + + it('does not resolve plugin identity when reporting is disabled', () => { + const session: RushSession = createSession(); + const getSource = jest.fn((): IReporterEventSource => { + throw new Error('should not resolve source'); + }); + + expect(_createRushSessionForPlugin(session, getSource)).toBe(session); + expect(getSource).not.toHaveBeenCalled(); + }); + + it('binds built-in action reporters to their command name', () => { + const sink: CapturingSink = new CapturingSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: os.tmpdir(), + reporter: { eventSink: sink, sessionId: 'session-4' } + }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; + + expect(action?.reporter).toBeDefined(); + action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); + expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 0e512764438..e017a9a8cbc 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { + RushSessionReporting, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IScopedLogger, + type IScopedReporter +} from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import { type ILogger, type ILoggerOptions, Logger } from './logging/Logger'; @@ -11,12 +19,43 @@ import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCa import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +/** + * The reporter channel supplied by the Rush frontend for a single Rush session. + * + * @remarks + * The frontend owns reporter selection and the concrete reporter instances. Rush + * only receives this presentation-free sink and binds producer identities before + * exposing scoped reporters to actions and plugins. + * + * @beta + */ +export interface IRushSessionReporterOptions { + /** + * The typed event sink owned by the Rush frontend. + */ + readonly eventSink: IReporterEventSink; + + /** + * The identifier assigned to this Rush session by the frontend. + */ + readonly sessionId: string; +} + /** * @beta */ export interface IRushSessionOptions { terminalProvider: ITerminalProvider; getIsDebugMode: () => boolean; + + /** + * The optional structured reporter channel for this session. + * + * @remarks + * When omitted, scoped reporter APIs return `undefined` and legacy terminal + * behavior remains unchanged. + */ + reporter?: IRushSessionReporterOptions; } /** @@ -33,20 +72,85 @@ export type CobuildLockProviderFactory = ( cobuildJson: ICobuildJson ) => ICobuildLockProvider | Promise; +interface IRushSessionState { + readonly options: IRushSessionOptions; + readonly cloudBuildCacheProviderFactories: Map; + readonly cobuildLockProviderFactories: Map; + readonly hooks: RushLifecycleHooks; + readonly reporting: RushSessionReporting | undefined; +} + +let _rushLibSource: IReporterEventSource | undefined; +const _rushSessionStates: WeakMap = new WeakMap(); + +function _getRushLibSource(): IReporterEventSource { + if (!_rushLibSource) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for @microsoft/rush-lib'); + } + + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); + if (!packageJson.version) { + throw new InternalError('The @microsoft/rush-lib package.json file does not specify a version'); + } + + _rushLibSource = { + packageName: '@microsoft/rush-lib', + packageVersion: packageJson.version + }; + } + + return _rushLibSource; +} + +function _createReporting( + reporterOptions: IRushSessionReporterOptions | undefined, + source: IReporterEventSource +): RushSessionReporting | undefined { + if (!reporterOptions) { + return undefined; + } + + const { eventSink, sessionId } = reporterOptions; + if (!eventSink || typeof eventSink.emit !== 'function') { + throw new TypeError('RushSession reporter.eventSink must implement IReporterEventSink'); + } + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); + } + + return new RushSessionReporting({ + sink: eventSink, + sessionId, + source: { ...source } + }); +} + +function _getSessionState(rushSession: RushSession): IRushSessionState { + const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); + if (!state) { + throw new InternalError('RushSession state was not initialized'); + } + return state; +} + /** * @beta */ export class RushSession { - private readonly _options: IRushSessionOptions; - private readonly _cloudBuildCacheProviderFactories: Map = new Map(); - private readonly _cobuildLockProviderFactories: Map = new Map(); - public readonly hooks: RushLifecycleHooks; public constructor(options: IRushSessionOptions) { - this._options = options; - this.hooks = new RushLifecycleHooks(); + _rushSessionStates.set(this, { + options, + cloudBuildCacheProviderFactories: new Map(), + cobuildLockProviderFactories: new Map(), + hooks: this.hooks, + reporting: options.reporter ? _createReporting(options.reporter, _getRushLibSource()) : undefined + }); } public getLogger(name: string): ILogger { @@ -54,51 +158,113 @@ export class RushSession { throw new InternalError('RushSession.getLogger(name) called without a name'); } - const terminalProvider: ITerminalProvider = this._options.terminalProvider; + const { options } = _getSessionState(this); + const terminalProvider: ITerminalProvider = options.terminalProvider; const loggerOptions: ILoggerOptions = { loggerName: name, - getShouldPrintStacks: () => this._options.getIsDebugMode(), + getShouldPrintStacks: () => options.getIsDebugMode(), terminalProvider }; return new Logger(loggerOptions); } public get terminalProvider(): ITerminalProvider { - return this._options.terminalProvider; + return _getSessionState(this).options.terminalProvider; + } + + /** + * Creates a structured reporter bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * The returned API cannot access concrete reporters or override the session and + * source identity bound by Rush. + */ + public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { + return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + } + + /** + * Creates a structured logger bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * This API is additive; {@link RushSession.getLogger} and terminal output remain + * available during the pre-major compatibility period. + */ + public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { + return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); } public registerCloudBuildCacheProviderFactory( cacheProviderName: string, factory: CloudBuildCacheProviderFactory ): void { - if (this._cloudBuildCacheProviderFactories.has(cacheProviderName)) { + const { cloudBuildCacheProviderFactories } = _getSessionState(this); + if (cloudBuildCacheProviderFactories.has(cacheProviderName)) { throw new Error(`A build cache provider factory for ${cacheProviderName} has already been registered`); } - this._cloudBuildCacheProviderFactories.set(cacheProviderName, factory); + cloudBuildCacheProviderFactories.set(cacheProviderName, factory); } public getCloudBuildCacheProviderFactory( cacheProviderName: string ): CloudBuildCacheProviderFactory | undefined { - return this._cloudBuildCacheProviderFactories.get(cacheProviderName); + return _getSessionState(this).cloudBuildCacheProviderFactories.get(cacheProviderName); } public registerCobuildLockProviderFactory( cobuildLockProviderName: string, factory: CobuildLockProviderFactory ): void { - if (this._cobuildLockProviderFactories.has(cobuildLockProviderName)) { + const { cobuildLockProviderFactories } = _getSessionState(this); + if (cobuildLockProviderFactories.has(cobuildLockProviderName)) { throw new Error( `A cobuild lock provider factory for ${cobuildLockProviderName} has already been registered` ); } - this._cobuildLockProviderFactories.set(cobuildLockProviderName, factory); + cobuildLockProviderFactories.set(cobuildLockProviderName, factory); } public getCobuildLockProviderFactory( cobuildLockProviderName: string ): CobuildLockProviderFactory | undefined { - return this._cobuildLockProviderFactories.get(cobuildLockProviderName); + return _getSessionState(this).cobuildLockProviderFactories.get(cobuildLockProviderName); + } +} + +/** + * Creates the RushSession facade passed to one plugin. + * + * @remarks + * This function is internal to rush-lib. PluginManager derives the source from + * trusted loader metadata so the plugin cannot choose another producer identity. + * + * @internal + */ +export function _createRushSessionForPlugin( + rushSession: RushSession, + getSource: () => IReporterEventSource +): RushSession { + const state: IRushSessionState = _getSessionState(rushSession); + if (!state.options.reporter) { + return rushSession; } + + const pluginSession: RushSession = Object.create(RushSession.prototype) as RushSession; + Object.defineProperty(pluginSession, 'hooks', { + configurable: false, + enumerable: true, + value: state.hooks, + writable: false + }); + _rushSessionStates.set(pluginSession, { + options: state.options, + cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, + cobuildLockProviderFactories: state.cobuildLockProviderFactories, + hooks: state.hooks, + reporting: _createReporting(state.options.reporter, getSource()) + }); + return pluginSession; } diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index 801c66f20fa..f42e357738a 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -50,6 +50,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-reporter": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 573fa555e28..80fc60cee1e 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -63,7 +63,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', - '_rushSdk_loadInternalModule' + '_rushSdk_loadInternalModule', + 'createRushDiagnostic', + 'parseReporterExtensionEventName' ]" `; From b4f0fc1573ea776a65879e3f62203d1ed1a669a8 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:10:57 +0000 Subject: [PATCH 003/133] Add Rush reporter frontend controls Create the authoritative frontend reporter host before version selection, register global reporter controls, and preserve legacy output unless a non-legacy reporter is explicitly selected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 17 + apps/rush/src/RushCommandSelector.ts | 5 +- apps/rush/src/RushFrontend.ts | 62 +++ apps/rush/src/RushReporterHost.ts | 514 ++++++++++++++++++ apps/rush/src/RushVersionSelector.ts | 5 +- apps/rush/src/start-dev.ts | 19 +- apps/rush/src/start.ts | 26 +- apps/rush/src/test/RushFrontend.test.ts | 93 ++++ apps/rush/src/test/RushReporterHost.test.ts | 227 ++++++++ ...ontend-host-controls_2026-08-28-03-00.json | 11 + .../RushCommandLine.test.ts.snap | 20 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 29 + .../rush-lib/src/cli/actions/CheckAction.ts | 9 +- .../cli/scriptActions/PhasedScriptAction.ts | 10 +- .../CommandLineHelp.test.ts.snap | 31 +- 15 files changed, 1014 insertions(+), 64 deletions(-) create mode 100644 apps/rush/src/IRushFrontendLaunchOptions.ts create mode 100644 apps/rush/src/RushFrontend.ts create mode 100644 apps/rush/src/RushReporterHost.ts create mode 100644 apps/rush/src/test/RushFrontend.test.ts create mode 100644 apps/rush/src/test/RushReporterHost.test.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts new file mode 100644 index 00000000000..828b03ed3d6 --- /dev/null +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +/** + * The cross-version launch contract owned by the Rush frontend. + * + * @remarks + * Reporter selection remains in `@microsoft/rush`. The selected `rush-lib` + * receives only the typed producer sink in addition to its existing launch + * options, so an older engine can safely ignore the new property. + */ +export interface IRushFrontendLaunchOptions extends ILaunchOptions { + readonly reporterEventSink: IReporterEventSink; +} diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index d85f00c5a91..46728020622 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -3,9 +3,10 @@ import * as path from 'node:path'; -import type { ILaunchOptions } from '@microsoft/rush-lib/lib/index'; import { Colorize } from '@rushstack/terminal'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + type CommandName = 'rush' | 'rush-pnpm' | 'rushx' | undefined; /** @@ -28,7 +29,7 @@ export class RushCommandSelector { public static execute( launcherVersion: string, selectedRushLib: typeof import('@microsoft/rush-lib'), - options: ILaunchOptions + options: IRushFrontendLaunchOptions ): void { const { Rush } = selectedRushLib; diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts new file mode 100644 index 00000000000..c60d1265081 --- /dev/null +++ b/apps/rush/src/RushFrontend.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ILaunchOptions } from '@microsoft/rush-lib'; + +import { + initializeRushReporterHostAsync, + stripReporterValueControls, + type IInitializedRushReporterHost +} from './RushReporterHost'; +import { RushCommandSelector } from './RushCommandSelector'; +import { RushVersionSelector } from './RushVersionSelector'; +import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; + +export interface IRushFrontendOptions { + readonly currentPackageVersion: string; + readonly rushVersionToLoad: string | undefined; + readonly configuration: MinimalRushConfiguration | undefined; + readonly launchOptions: ILaunchOptions; + readonly currentRushLib: typeof import('@microsoft/rush-lib'); + readonly initializeReporterHostAsync?: () => Promise; + readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; + readonly executeCurrentRush?: ( + currentPackageVersion: string, + currentRushLib: typeof import('@microsoft/rush-lib'), + launchOptions: IRushFrontendLaunchOptions + ) => void; +} + +export async function launchRushFrontendAsync(options: IRushFrontendOptions): Promise { + const { + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib, + initializeReporterHostAsync = initializeRushReporterHostAsync, + createVersionSelector = (version: string) => new RushVersionSelector(version), + executeCurrentRush = RushCommandSelector.execute + } = options; + + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync(); + if (!reporterHost.selection.enabled && reporterHost.selection.reason !== 'pre-major legacy default') { + process.argv = stripReporterValueControls(process.argv); + } + const reporterLaunchOptions: IRushFrontendLaunchOptions = { + ...launchOptions, + reporterEventSink: reporterHost.sink + }; + + if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { + const versionSelector: RushVersionSelector = createVersionSelector(currentPackageVersion); + await versionSelector.ensureRushVersionInstalledAsync( + rushVersionToLoad, + configuration, + reporterLaunchOptions + ); + } else { + executeCurrentRush(currentPackageVersion, currentRushLib, reporterLaunchOptions); + } +} diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts new file mode 100644 index 00000000000..cde7f83592c --- /dev/null +++ b/apps/rush/src/RushReporterHost.ts @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + AiReporter, + DefaultInteractiveReporter, + FileReporter, + JsonReporter, + PlaintextReporter, + ReporterHost, + isCiDetected, + isLegacyEmergencyFallbackRequested, + isSupportedLogLevel, + isSupportedReporterName, + parseOutputControl, + separateJsonControls, + shouldRenderAtLogLevel, + type IReporter, + type IReporterContext, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterOutputTarget, + type ReporterLogLevel, + type ReporterName +} from '@rushstack/rush-reporter'; + +export interface IRushReporterOutputStream { + readonly isTTY?: boolean; + readonly columns?: number; + write(text: string): unknown; +} + +export interface IRushReporterHostOptions { + readonly argv?: readonly string[]; + readonly env?: Record; + readonly cwd?: string; + readonly stdout?: IRushReporterOutputStream; + readonly includeDefaultFileReporter?: boolean; + readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; +} + +export interface IRushReporterSelection { + readonly reporter: ReporterName; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly commandJson: boolean; + readonly enabled: boolean; + readonly reason: 'explicit --reporter' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; +} + +export interface IInitializedRushReporterHost { + readonly host: ReporterHost; + readonly sink: IReporterEventSink; + readonly selection: IRushReporterSelection; +} + +const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); + +interface IParsedReporterControls { + readonly reporters: readonly string[]; + readonly logLevels: readonly string[]; + readonly outputs: readonly string[]; + readonly quiet: boolean; + readonly verbose: boolean; + readonly debug: boolean; +} + +class LogLevelReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + private readonly _logLevel: ReporterLogLevel; + + public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + this._reporter = reporter; + this._logLevel = logLevel; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if (shouldRenderAtLogLevel(this._logLevel, event)) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + +class ExplicitOutputReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: JsonReporter; + private readonly _filteredReporter: LogLevelReporter; + private readonly _outputPath: string; + private _fileDescriptor: number | undefined; + + public constructor(reporterName: string, outputPath: string, logLevel: ReporterLogLevel) { + this.name = `${reporterName}-output`; + this._outputPath = outputPath; + this._reporter = new JsonReporter({ + write: (text: string) => { + if (this._fileDescriptor === undefined) { + throw new Error(`Reporter output ${JSON.stringify(this._outputPath)} is not initialized.`); + } + fs.writeSync(this._fileDescriptor, text); + } + }); + this._filteredReporter = new LogLevelReporter(this._reporter, logLevel); + } + + public async initializeAsync(context: IReporterContext): Promise { + await fs.promises.mkdir(path.dirname(this._outputPath), { recursive: true }); + this._fileDescriptor = fs.openSync(this._outputPath, 'w', 0o600); + await this._filteredReporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + this._filteredReporter.report(event); + } + + public async flushAsync(): Promise { + await this._filteredReporter.flushAsync(); + if (this._fileDescriptor !== undefined) { + fs.fsyncSync(this._fileDescriptor); + } + } + + public async closeAsync(): Promise { + try { + await this._filteredReporter.closeAsync(); + } finally { + if (this._fileDescriptor !== undefined) { + fs.closeSync(this._fileDescriptor); + this._fileDescriptor = undefined; + } + } + } +} + +function readValue( + argv: readonly string[], + index: number, + flag: string +): { readonly value: string; readonly consumedNext: boolean } | undefined { + const argument: string = argv[index]; + const prefix: string = `${flag}=`; + if (argument.startsWith(prefix)) { + const value: string = argument.slice(prefix.length); + if (!value) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: false }; + } + if (argument !== flag) { + return undefined; + } + + const value: string | undefined = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + return { value, consumedNext: true }; +} + +export function stripReporterValueControls(argv: readonly string[]): string[] { + const result: string[] = []; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + const equalsIndex: number = argument.indexOf('='); + const flagName: string = equalsIndex < 0 ? argument : argument.slice(0, equalsIndex); + if (!REPORTER_VALUE_FLAGS.has(flagName)) { + result.push(argument); + continue; + } + if (equalsIndex < 0 && index + 1 < argv.length) { + index++; + } + } + return result; +} + +function parseReporterControls(argv: readonly string[]): IParsedReporterControls { + const reporters: string[] = []; + const logLevels: string[] = []; + const outputs: string[] = []; + let quiet: boolean = false; + let verbose: boolean = false; + let debug: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + const reporter: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--reporter' + ); + if (reporter) { + reporters.push(reporter.value); + index += reporter.consumedNext ? 1 : 0; + continue; + } + const logLevel: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--log-level' + ); + if (logLevel) { + logLevels.push(logLevel.value); + index += logLevel.consumedNext ? 1 : 0; + continue; + } + const output: { readonly value: string; readonly consumedNext: boolean } | undefined = readValue( + argv, + index, + '--output' + ); + if (output) { + outputs.push(output.value); + index += output.consumedNext ? 1 : 0; + continue; + } + + quiet ||= argument === '--quiet' || argument === '-q'; + verbose ||= argument === '--verbose'; + debug ||= argument === '--debug' || argument === '-d'; + } + + if (reporters.length > 1) { + throw new Error('--reporter may be specified only once.'); + } + if (logLevels.length > 1) { + throw new Error('--log-level may be specified only once.'); + } + + return { reporters, logLevels, outputs, quiet, verbose, debug }; +} + +function resolveLogLevel( + controls: IParsedReporterControls, + env: Record, + includeEnvironment: boolean +): ReporterLogLevel { + const requestedLevels: ReporterLogLevel[] = []; + const explicitLogLevel: string | undefined = controls.logLevels[0]; + if (explicitLogLevel !== undefined) { + if (!isSupportedLogLevel(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + requestedLevels.push(explicitLogLevel); + } + if (controls.quiet) { + requestedLevels.push('quiet'); + } + if (controls.verbose) { + requestedLevels.push('verbose'); + } + if (controls.debug) { + requestedLevels.push('debug'); + } + + const distinctLevels: Set = new Set(requestedLevels); + if (distinctLevels.size > 1) { + throw new Error( + `Contradictory reporter verbosity controls were specified: ${[...distinctLevels].sort().join(', ')}. ` + + 'Specify only one of --log-level, --quiet, --verbose, or --debug.' + ); + } + if (requestedLevels.length > 0) { + return requestedLevels[0]; + } + + const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + if (environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (!isSupportedLogLevel(normalizedLogLevel)) { + throw new Error( + `Unsupported RUSH_LOG_LEVEL value ${JSON.stringify(environmentLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return normalizedLogLevel; + } + + return 'normal'; +} + +function resolveOutputs(outputValues: readonly string[], cwd: string): readonly IReporterOutputTarget[] { + return outputValues.map((value: string) => { + const output: IReporterOutputTarget = parseOutputControl(value); + if (output.reporter !== 'file' && output.reporter !== 'json') { + throw new Error( + `Unsupported --output reporter ${JSON.stringify(output.reporter)}. ` + + 'This rollout stage supports file:// and json:// output targets.' + ); + } + if (!output.target) { + throw new Error(`The --output target must not be empty: ${JSON.stringify(value)}.`); + } + for (const parameterName of Object.keys(output.params)) { + if (parameterName !== 'logLevel') { + throw new Error( + `Unsupported --output query parameter ${JSON.stringify(parameterName)}. ` + + 'The only supported query parameter is logLevel.' + ); + } + } + const outputLogLevel: string | undefined = output.params.logLevel; + if (outputLogLevel !== undefined && !isSupportedLogLevel(outputLogLevel)) { + throw new Error( + `Unsupported --output logLevel ${JSON.stringify(outputLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + return { + ...output, + target: path.resolve(cwd, output.target) + }; + }); +} + +export function resolveRushReporterSelection(options: IRushReporterHostOptions = {}): IRushReporterSelection { + const argv: readonly string[] = options.argv ?? process.argv.slice(2); + const env: Record = options.env ?? process.env; + const commandName: 'rush' | 'rush-pnpm' | 'rushx' = options.commandName ?? getCommandName(); + if (commandName !== 'rush') { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: separateJsonControls(argv).commandJson, + enabled: false, + reason: 'pre-major legacy default' + }; + } + + const cwd: string = options.cwd ?? process.cwd(); + const controls: IParsedReporterControls = parseReporterControls(argv); + const commandJson: boolean = separateJsonControls(argv).commandJson; + + if (isLegacyEmergencyFallbackRequested(env)) { + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'RUSH_REPORTER=legacy' + }; + } + + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { + const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); + if (executableName === 'rush-pnpm') { + return 'rush-pnpm'; + } + if (executableName === 'rushx') { + return 'rushx'; + } + return 'rush'; + } + + const requestedReporter: string | undefined = controls.reporters[0]; + if (requestedReporter === undefined) { + const environmentReporter: string | undefined = env.RUSH_REPORTER; + if (environmentReporter?.trim()) { + throw new Error( + `RUSH_REPORTER=${JSON.stringify(environmentReporter)} cannot enable the pre-major reporter path. ` + + 'Use an explicit --reporter option, or set RUSH_REPORTER=legacy for the emergency fallback.' + ); + } + if (controls.outputs.length > 0 || controls.logLevels.length > 0) { + throw new Error('--output and --log-level require an explicit non-legacy --reporter selection.'); + } + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'pre-major legacy default' + }; + } + + if (!isSupportedReporterName(requestedReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(requestedReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + + if (requestedReporter === 'legacy') { + if (controls.outputs.length > 0 || controls.logLevels.length > 0) { + throw new Error('--output and --log-level are not supported with --reporter=legacy.'); + } + return { + reporter: 'legacy', + logLevel: resolveLogLevel(controls, env, false), + outputs: [], + commandJson, + enabled: false, + reason: 'explicit --reporter' + }; + } + + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + if (requestedReporter === 'default' && !stdout.isTTY) { + throw new Error( + '--reporter=default requires an interactive TTY. Use --reporter=plaintext for CI or redirected output.' + ); + } + + return { + reporter: requestedReporter, + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reason: 'explicit --reporter' + }; +} + +function createPrimaryReporter( + selection: IRushReporterSelection, + stdout: IRushReporterOutputStream, + env: Record +): IReporter | undefined { + switch (selection.reporter) { + case 'default': + return new DefaultInteractiveReporter({ + terminal: { + columns: stdout.columns ?? 80, + isTTY: stdout.isTTY === true, + write: (text: string) => { + stdout.write(text); + } + }, + env + }); + case 'ai': + return new AiReporter({ write: (text: string) => stdout.write(text) }); + case 'json': + return new JsonReporter({ write: (text: string) => stdout.write(text) }); + case 'plaintext': + return new PlaintextReporter({ + write: (text: string) => stdout.write(text), + variant: isCiDetected(env) ? 'detailed' : 'concise', + color: false + }); + case 'file': + return new FileReporter(); + case 'legacy': + return undefined; + } +} + +export async function initializeRushReporterHostAsync( + options: IRushReporterHostOptions = {} +): Promise { + const env: Record = options.env ?? process.env; + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ env }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + }); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } + } + + await host.manager.initializeAsync(); + return { host, sink: host.getSink(), selection }; +} diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 615aaa0e356..6e450e7aca0 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -7,9 +7,10 @@ import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; -import { _FlagFile, _RushGlobalFolder, type ILaunchOptions } from '@microsoft/rush-lib'; +import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; import { RushCommandSelector } from './RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; import type { MinimalRushConfiguration } from './MinimalRushConfiguration'; const MAX_INSTALL_ATTEMPTS: number = 3; @@ -26,7 +27,7 @@ export class RushVersionSelector { public async ensureRushVersionInstalledAsync( version: string, configuration: MinimalRushConfiguration | undefined, - executeOptions: ILaunchOptions + executeOptions: IRushFrontendLaunchOptions ): Promise { const isLegacyRushVersion: boolean = semver.lt(version, '4.0.0'); const expectedRushPath: string = path.join(this._rushGlobalFolder.nodeSpecificPath, `rush-${version}`); diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index bba3469421f..eda177e33c3 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -7,7 +7,7 @@ import * as rushLib from '@microsoft/rush-lib'; import { PackageJsonLookup, Import } from '@rushstack/node-core-library'; -import { RushCommandSelector } from './RushCommandSelector'; +import { launchRushFrontendAsync } from './RushFrontend'; const builtInPluginConfigurations: rushLib._IBuiltInPluginConfiguration[] = []; @@ -34,8 +34,17 @@ includePlugin('rush-serve-plugin'); includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); const currentPackageVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; -RushCommandSelector.execute(currentPackageVersion, rushLib, { - isManaged: false, - alreadyReportedNodeTooNewError: false, - builtInPluginConfigurations +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { + isManaged: false, + alreadyReportedNodeTooNewError: false, + builtInPluginConfigurations + }, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(error); }); diff --git a/apps/rush/src/start.ts b/apps/rush/src/start.ts index bf8d5927230..ff4db06b442 100644 --- a/apps/rush/src/start.ts +++ b/apps/rush/src/start.ts @@ -29,9 +29,8 @@ import { EnvironmentVariableNames } from '@microsoft/rush-lib'; import type { ILaunchOptions } from '@microsoft/rush-lib'; import * as rushLib from '@microsoft/rush-lib'; -import { RushCommandSelector } from './RushCommandSelector'; -import { RushVersionSelector } from './RushVersionSelector'; import { MinimalRushConfiguration } from './MinimalRushConfiguration'; +import { launchRushFrontendAsync } from './RushFrontend'; // Load the configuration const configuration: MinimalRushConfiguration | undefined = @@ -90,16 +89,13 @@ const terminalProvider: ITerminalProvider = new ConsoleTerminalProvider(); const launchOptions: ILaunchOptions = { isManaged, alreadyReportedNodeTooNewError, terminalProvider }; -// If we're inside a repo folder, and it's requesting a different version, then use the RushVersionManager to -// install it -if (rushVersionToLoad && rushVersionToLoad !== currentPackageVersion) { - const versionSelector: RushVersionSelector = new RushVersionSelector(currentPackageVersion); - versionSelector - .ensureRushVersionInstalledAsync(rushVersionToLoad, configuration, launchOptions) - .catch((error: Error) => { - console.log(Colorize.red('Error: ' + error.message)); - }); -} else { - // Otherwise invoke the rush-lib that came with this rush package - RushCommandSelector.execute(currentPackageVersion, rushLib, launchOptions); -} +launchRushFrontendAsync({ + currentPackageVersion, + rushVersionToLoad, + configuration, + launchOptions, + currentRushLib: rushLib +}).catch((error: Error) => { + process.exitCode = 1; + console.error(Colorize.red(`Error: ${error.message}`)); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts new file mode 100644 index 00000000000..cd0c2ca6618 --- /dev/null +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as rushLib from '@microsoft/rush-lib'; +import { ReporterHost, type IReporterEventSink } from '@rushstack/rush-reporter'; + +import { launchRushFrontendAsync } from '../RushFrontend'; +import type { IInitializedRushReporterHost } from '../RushReporterHost'; +import { RushVersionSelector } from '../RushVersionSelector'; + +async function createInitializedHostAsync( + order: string[], + reason: IInitializedRushReporterHost['selection']['reason'] = 'pre-major legacy default' +): Promise { + order.push('host'); + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + return { + host, + sink: host.getSink(), + selection: { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: false, + enabled: false, + reason + } + }; +} + +describe(launchRushFrontendAsync.name, () => { + it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { + const order: string[] = []; + let receivedOptions: Record | undefined; + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order, 'explicit --reporter'), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + order.push('engine'); + receivedOptions = launchOptions as unknown as Record; + } + }); + + expect(order).toEqual(['host', 'engine']); + expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); + expect(receivedOptions?.reporterEventSink).toEqual( + expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink + ); + expect(receivedOptions).not.toHaveProperty('selection'); + expect(receivedOptions).not.toHaveProperty('host'); + expect(receivedOptions).not.toHaveProperty('manager'); + } finally { + process.argv = originalArgv; + } + }); + + it('creates the host before selecting and installing a repository Rush version', async () => { + const order: string[] = []; + let receivedSink: IReporterEventSink | undefined; + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); + versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { + void version; + void configuration; + order.push('version-selection'); + receivedSink = (launchOptions as unknown as { reporterEventSink?: IReporterEventSink }) + .reporterEventSink; + }; + + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: () => createInitializedHostAsync(order), + createVersionSelector: () => versionSelector + }); + + expect(order).toEqual(['host', 'version-selection']); + expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + }); +}); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts new file mode 100644 index 00000000000..11ca6276dc0 --- /dev/null +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventSink } from '@rushstack/rush-reporter'; + +import { + initializeRushReporterHostAsync, + resolveRushReporterSelection, + stripReporterValueControls, + type IRushReporterOutputStream, + type IRushReporterSelection +} from '../RushReporterHost'; + +function resolve( + argv: readonly string[], + env: Record = {}, + isTTY: boolean = false +): IRushReporterSelection { + return resolveRushReporterSelection({ + argv, + env, + cwd: '/repo', + stdout: { isTTY, columns: 100, write: () => undefined } + }); +} + +function emitCommandStarted(sink: IReporterEventSink): void { + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandStarted', + payload: { commandName: 'build' } + }); +} + +describe(resolveRushReporterSelection.name, () => { + it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { + for (const testCase of [ + { env: {}, isTTY: true }, + { env: {}, isTTY: false }, + { env: { CI: 'true' }, isTTY: false }, + { env: { COPILOT_CLI: '1' }, isTTY: true } + ]) { + expect(resolve(['build'], testCase.env, testCase.isTTY)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'pre-major legacy default' + }); + } + }); + + it('requires an explicit non-legacy --reporter to opt in', () => { + expect(resolve(['build', '--reporter=json'], { CI: 'true' }, false)).toMatchObject({ + reporter: 'json', + enabled: true, + reason: 'explicit --reporter' + }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); + }); + + it('does not consume rush-pnpm or rushx reporter arguments', () => { + expect( + resolveRushReporterSelection({ + argv: ['install', '--reporter=append-only'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rush-pnpm' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + expect( + resolveRushReporterSelection({ + argv: ['build', '--reporter=custom-script-value'], + env: { RUSH_REPORTER: 'json' }, + commandName: 'rushx' + }) + ).toMatchObject({ reporter: 'legacy', enabled: false }); + }); + + it('keeps RUSH_REPORTER=legacy as an emergency override', () => { + expect(resolve(['build', '--reporter=json'], { RUSH_REPORTER: ' LEGACY ' })).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'RUSH_REPORTER=legacy' + }); + }); + + it('removes reporter-only value controls before invoking a legacy engine', () => { + expect( + stripReporterValueControls([ + 'node', + 'rush', + 'list', + '--json', + '--reporter=json', + '--output', + 'file://./rush.log', + '--log-level=debug', + '--quiet' + ]) + ).toEqual(['node', 'rush', 'list', '--json', '--quiet']); + }); + + it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { + expect( + resolve(['build', '--reporter=plaintext', '--verbose'], { RUSH_LOG_LEVEL: 'quiet' }).logLevel + ).toBe('verbose'); + expect(resolve(['build', '--reporter=plaintext'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe('debug'); + expect(() => resolve(['build', '--reporter=plaintext', '--quiet', '--debug'])).toThrow( + /Contradictory reporter verbosity/ + ); + }); + + it('ignores reporter environment selection before the gate but validates explicit controls', () => { + expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); + expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); + expect(() => resolve(['build', '--reporter=json', '--log-level=loud'])).toThrow(/Unsupported log level/); + expect(() => resolve(['build', '--output=json:\/\/events.jsonl'])).toThrow( + /require an explicit non-legacy --reporter/ + ); + }); + + it('rejects an interactive reporter on non-TTY output', () => { + expect(() => resolve(['build', '--reporter=default'], {}, false)).toThrow(/requires an interactive TTY/); + expect(resolve(['build', '--reporter=default'], {}, true).reporter).toBe('default'); + }); + + it('parses output targets and preserves command-specific --json independently', () => { + const selection: IRushReporterSelection = resolve( + [ + 'list', + '--json', + '--reporter=json', + '--output=file://./rush.log?logLevel=debug', + '--output=json://./events.jsonl' + ], + {}, + false + ); + + expect(selection.commandJson).toBe(true); + expect(selection.reporter).toBe('json'); + expect(selection.outputs).toEqual([ + { + reporter: 'file', + target: path.resolve('/repo', 'rush.log'), + params: { logLevel: 'debug' } + }, + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl'), + params: {} + } + ]); + }); + + it('surfaces unsupported and incomplete controls with actionable errors', () => { + expect(() => resolve(['build', '--reporter'])).toThrow(/--reporter requires a value/); + expect(() => resolve(['build', '--reporter=json', '--reporter=ai'])).toThrow( + /may be specified only once/ + ); + expect(() => resolve(['build', '--reporter=json', '--output=plaintext://./output.txt'])).toThrow( + /supports file:\/\/ and json:\/\// + ); + expect(() => resolve(['build', '--reporter=json', '--output=file://./output.txt?unknown=value'])).toThrow( + /only supported query parameter is logLevel/ + ); + }); +}); + +describe(initializeRushReporterHostAsync.name, () => { + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { + let output: string = ''; + const stdout: IRushReporterOutputStream = { + isTTY: false, + write: (text: string) => { + output += text; + } + }; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env: { CI: 'true', COPILOT_CLI: '1' }, + stdout, + includeDefaultFileReporter: false + }); + + const sink: IReporterEventSink = initialized.sink; + emitCommandStarted(sink); + await initialized.host.manager.flushAsync(); + + expect(initialized.selection.enabled).toBe(false); + expect(output).toBe(''); + }); + + it('initializes the explicitly selected reporter and output destinations', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(initialized.sink); + await initialized.host.manager.closeAsync(); + + expect(JSON.parse(stdoutText).type).toBe('commandStarted'); + expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json new file mode 100644 index 00000000000..0abc06b9dc2 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r2b-frontend-host-controls_2026-08-28-03-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add the pre-major ReporterHost and explicit global reporter controls while preserving legacy output by default.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index d913bb774e3..c6f5880848b 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -184,14 +184,6 @@ Object { "required": false, "shortName": undefined, }, - Object { - "description": "If this flag is specified, long lists of package names will not be truncated. This has no effect if the --json flag is also specified.", - "environmentVariable": undefined, - "kind": "Flag", - "longName": "--verbose", - "required": false, - "shortName": undefined, - }, Object { "description": "(EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only within that subspace (ignoring other subspaces). This parameter is required when the \\"subspacesEnabled\\" setting is set to true in subspaces.json.", "environmentVariable": undefined, @@ -1287,10 +1279,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, @@ -1441,10 +1433,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, @@ -1598,10 +1590,10 @@ Object { "shortName": undefined, }, Object { - "description": "Display the logs during the build, rather than just displaying the build status summary", + "description": "Display build logs instead of only status", "environmentVariable": undefined, "kind": "Flag", - "longName": "--verbose", + "longName": "--verbose-build-output", "required": false, "shortName": "-v", }, diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 47f2b3a640b..d9af1151214 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -8,6 +8,7 @@ import { type CommandLineFlagParameter, CommandLineHelper } from '@rushstack/ts-command-line'; +import { SUPPORTED_LOG_LEVELS, SUPPORTED_REPORTER_NAMES } from '@rushstack/rush-reporter'; import { InternalError, AlreadyReportedError, Text } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, @@ -83,6 +84,7 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _debugParameter: CommandLineFlagParameter; private readonly _quietParameter: CommandLineFlagParameter; + private readonly _verboseParameter: CommandLineFlagParameter; private readonly _restrictConsoleOutput: boolean = RushCommandLineParser.shouldRestrictConsoleOutput(); private readonly _rushOptions: IRushCommandLineParserOptions; private readonly _terminalProvider: ConsoleTerminalProvider; @@ -123,6 +125,29 @@ export class RushCommandLineParser extends CommandLineParser { description: 'Hide rush startup information' }); + this._verboseParameter = this.defineFlagParameter({ + parameterLongName: '--verbose', + description: 'Show detailed command and reporter output' + }); + + this.defineChoiceParameter({ + parameterLongName: '--reporter', + alternatives: [...SUPPORTED_REPORTER_NAMES], + description: 'Select the Rush output reporter' + }); + + this.defineStringListParameter({ + parameterLongName: '--output', + argumentName: 'DESTINATION', + description: 'Add a reporter output destination such as file://./rush.log' + }); + + this.defineChoiceParameter({ + parameterLongName: '--log-level', + alternatives: [...SUPPORTED_LOG_LEVELS], + description: 'Set the reporter log level' + }); + const terminalProvider: ConsoleTerminalProvider = new ConsoleTerminalProvider(); this._terminalProvider = terminalProvider; const terminal: Terminal = new Terminal(this._terminalProvider); @@ -202,6 +227,10 @@ export class RushCommandLineParser extends CommandLineParser { return this._quietParameter.value; } + public get isVerbose(): boolean { + return this._verboseParameter.value; + } + public get terminal(): ITerminal { return this._terminal; } diff --git a/libraries/rush-lib/src/cli/actions/CheckAction.ts b/libraries/rush-lib/src/cli/actions/CheckAction.ts index fcf752b0657..4a1cda2f8ec 100644 --- a/libraries/rush-lib/src/cli/actions/CheckAction.ts +++ b/libraries/rush-lib/src/cli/actions/CheckAction.ts @@ -11,7 +11,6 @@ import { getVariantAsync, VARIANT_PARAMETER } from '../../api/Variants'; export class CheckAction extends BaseRushAction { private readonly _jsonFlag: CommandLineFlagParameter; - private readonly _verboseFlag: CommandLineFlagParameter; private readonly _subspaceParameter: CommandLineStringParameter | undefined; private readonly _variantParameter: CommandLineStringParameter; @@ -32,12 +31,6 @@ export class CheckAction extends BaseRushAction { parameterLongName: '--json', description: 'If this flag is specified, output will be in JSON format.' }); - this._verboseFlag = this.defineFlagParameter({ - parameterLongName: '--verbose', - description: - 'If this flag is specified, long lists of package names will not be truncated. ' + - `This has no effect if the ${this._jsonFlag.longName} flag is also specified.` - }); this._subspaceParameter = this.defineStringParameter({ parameterLongName: '--subspace', argumentName: 'SUBSPACE_NAME', @@ -75,7 +68,7 @@ export class CheckAction extends BaseRushAction { VersionMismatchFinder.rushCheck(this.rushConfiguration, this.terminal, { variant, printAsJson: this._jsonFlag.value, - truncateLongPackageNameLists: !this._verboseFlag.value, + truncateLongPackageNameLists: !this.parser.isVerbose, subspace: this._subspaceParameter?.value ? this.rushConfiguration.getSubspace(this._subspaceParameter.value) : this.rushConfiguration.defaultSubspace diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1b2b7aa5812..4361afa6645 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -148,7 +148,7 @@ export class PhasedScriptAction extends BaseScriptAction i private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; - private readonly _verboseParameter: CommandLineFlagParameter; + private readonly _legacyVerboseParameter: CommandLineFlagParameter; private readonly _parallelismParameter: CommandLineStringParameter | undefined; private readonly _ignoreHooksParameter: CommandLineFlagParameter; private readonly _watchParameter: CommandLineFlagParameter | undefined; @@ -234,10 +234,10 @@ export class PhasedScriptAction extends BaseScriptAction i cwd: this.parser.cwd }); - this._verboseParameter = this.defineFlagParameter({ - parameterLongName: '--verbose', + this._legacyVerboseParameter = this.defineFlagParameter({ + parameterLongName: '--verbose-build-output', parameterShortName: '-v', - description: 'Display the logs during the build, rather than just displaying the build status summary' + description: 'Display build logs instead of only status' }); this._includePhaseDeps = this.defineFlagParameter({ @@ -455,7 +455,7 @@ export class PhasedScriptAction extends BaseScriptAction i }); } - const isQuietMode: boolean = !this._verboseParameter.value; + const isQuietMode: boolean = !(this.parser.isVerbose || this._legacyVerboseParameter.value); const changedProjectsOnly: boolean = !!this._changedProjectsOnlyParameter?.value; diff --git a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index efe3e717b7d..30c901116f6 100644 --- a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -1,7 +1,10 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`CommandLineHelp prints the global help 1`] = ` -"usage: rush [-h] [-d] [-q] ... +"usage: rush [-h] [-d] [-q] [--verbose] + [--reporter {default,ai,json,plaintext,file,legacy}] + [--output DESTINATION] [--log-level {quiet,normal,verbose,debug}] + ... Rush makes life easier for JavaScript developers who develop, build, and publish many packages from a central Git repo. It is designed to handle very @@ -81,6 +84,13 @@ Optional arguments: -d, --debug Show the full call stack if an error occurs while executing the tool -q, --quiet Hide rush startup information + --verbose Show detailed command and reporter output + --reporter {default,ai,json,plaintext,file,legacy} + Select the Rush output reporter + --output DESTINATION Add a reporter output destination such as file://. + /rush.log + --log-level {quiet,normal,verbose,debug} + Set the reporter log level [bold]For detailed help about a specific command, use: rush -h[normal] " @@ -304,8 +314,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -409,9 +419,7 @@ Optional arguments: `; exports[`CommandLineHelp prints the help for each action: check 1`] = ` -"usage: rush check [-h] [--json] [--verbose] [--subspace SUBSPACE_NAME] - [--variant VARIANT] - +"usage: rush check [-h] [--json] [--subspace SUBSPACE_NAME] [--variant VARIANT] Checks each project's package.json files and ensures that all dependencies are of the same version throughout the repository. @@ -420,9 +428,6 @@ Optional arguments: -h, --help Show this help message and exit. --json If this flag is specified, output will be in JSON format. - --verbose If this flag is specified, long lists of package - names will not be truncated. This has no effect if - the --json flag is also specified. --subspace SUBSPACE_NAME (EXPERIMENTAL) Specifies an individual Rush subspace to check, requiring versions to be consistent only @@ -598,8 +603,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might @@ -1245,8 +1250,8 @@ Optional arguments: each of the projects belonging to VERSION_POLICY_NAME. For details, refer to the website article \\"Selecting subsets of projects\\". - -v, --verbose Display the logs during the build, rather than just - displaying the build status summary + -v, --verbose-build-output + Display build logs instead of only status --include-phase-deps If the selected projects are \\"unsafe\\" (missing some dependencies), add the minimal set of phase dependencies. For example, \\"--from A\\" normally might From 87c97242aec0170863612fb9ab2adf2db1d3d7aa Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:33:18 +0000 Subject: [PATCH 004/133] Fix reporter telemetry privacy projection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...telemetry-privacy_2026-08-28-03-20-00.json | 11 ++ .../src/telemetry/TelemetryAggregate.ts | 2 +- .../src/telemetry/TelemetrySubscriber.ts | 52 +++++--- libraries/reporter/src/test/Telemetry.test.ts | 125 ++++++++++++++++++ 4 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json new file mode 100644 index 00000000000..5b7d8700177 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Prevent local-sensitive and secret reporter events from contributing producer identities or other values to telemetry aggregates.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/reporter/src/telemetry/TelemetryAggregate.ts b/libraries/reporter/src/telemetry/TelemetryAggregate.ts index d54fa16977f..c484e9dc296 100644 --- a/libraries/reporter/src/telemetry/TelemetryAggregate.ts +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -67,7 +67,7 @@ export interface ITelemetryAggregate { readonly protocolVersion?: IReporterProtocolVersion; /** - * The distinct `packageName@packageVersion` producers observed, sorted. + * The distinct `packageName@packageVersion` producers observed on public envelopes, sorted. */ readonly producerVersions: readonly string[]; } diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts index 48b45f50987..1cbc6f579f8 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -12,9 +12,10 @@ import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate' * * @remarks * The subscriber runs before reporter filtering, so it observes every event. It - * extracts only allowlisted values: from a diagnostic it keeps the code and - * category but never the parameters, remediation, or templates; it ignores - * messages, raw external output, and command arguments entirely. + * projects envelope metadata and lifecycle values only from public events. From + * a local-sensitive diagnostic it may keep the explicitly public code and + * category, but never parameters, remediation, or templates. It ignores secret + * events, messages, raw external output, and command arguments entirely. * * @beta */ @@ -48,8 +49,34 @@ export class TelemetrySubscriber { * Ingests one event, extracting only allowlisted values. */ public ingest(event: IReporterEventEnvelope): void { - this._protocolVersion = event.protocolVersion; - this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`); + const isPublicEnvelope: boolean = event.privacy === 'public'; + if (isPublicEnvelope) { + this._protocolVersion = event.protocolVersion; + this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`); + } + + if (event.type === 'diagnosticEmitted') { + if (event.privacy !== 'secret') { + // Code and category are public schema fields even when classified + // parameters make the diagnostic envelope local-sensitive. + const payload: { code?: string; category?: string } = event.payload as { + code?: string; + category?: string; + }; + if (payload.code !== undefined) { + this._diagnosticCodes.add(payload.code); + } + if (payload.category !== undefined) { + this._diagnosticCategoryCounts[payload.category] = + (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; + } + } + return; + } + + if (!isPublicEnvelope) { + return; + } switch (event.type) { case 'commandStarted': { @@ -114,21 +141,6 @@ export class TelemetrySubscriber { this._operationStatuses.set(payload.operationId, payload.status); break; } - case 'diagnosticEmitted': { - // Keeps only the code and category, never parameters, remediation, or templates. - const payload: { code?: string; category?: string } = event.payload as { - code?: string; - category?: string; - }; - if (payload.code !== undefined) { - this._diagnosticCodes.add(payload.code); - } - if (payload.category !== undefined) { - this._diagnosticCategoryCounts[payload.category] = - (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; - } - break; - } default: { // Messages, raw external output, artifacts, and extension events are not // telemetry. diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 423b8a78cf1..4ddcfc5b66f 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -110,6 +110,131 @@ describe('TelemetrySubscriber', () => { } }); + it('does not collect producer identities from local-sensitive or secret extension events', async () => { + const LOCAL_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/local-reporter-plugin', + packageVersion: '1.2.3-private' + }; + const SECRET_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/secret-reporter-plugin', + packageVersion: '4.5.6-secret' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('extension', { name: 'private.local.event', privateField: 'local-private-value' }), + source: LOCAL_PRIVATE_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event', secretField: 'secret-private-value' }), + source: SECRET_PRIVATE_SOURCE, + privacy: 'secret' + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + const serialized: string = JSON.stringify(aggregate); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.protocolVersion).toBeUndefined(); + for (const forbidden of [ + LOCAL_PRIVATE_SOURCE.packageName, + LOCAL_PRIVATE_SOURCE.packageVersion, + SECRET_PRIVATE_SOURCE.packageName, + SECRET_PRIVATE_SOURCE.packageVersion, + 'local-private-value', + 'secret-private-value' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('projects only public envelopes while aggregating public producers deterministically', async () => { + const PUBLIC_EXTENSION_SOURCE: IReporterEventSource = { + packageName: '@rushstack/public-reporter-plugin', + packageVersion: '1.2.3' + }; + const PRIVATE_FIRST_PARTY_SOURCE: IReporterEventSource = { + packageName: '@microsoft/internal-build-plugin', + packageVersion: '9.8.7-private' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('commandResult', { + commandName: 'private-command', + succeeded: false, + exitCode: 97 + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive', + protocolVersion: { major: 7, minor: 0 } + }); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('diagnosticEmitted', { code: 'RUSH_OPERATION_FAILED', category: 'operation' })); + manager.emit({ + ...rawInput('operationStatusChanged', { + operationId: 'private-operation', + status: 'failure' + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('diagnosticEmitted', { + code: 'PRIVATE_INTERNAL_DIAGNOSTIC', + category: 'private-category' + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret' + }); + manager.emit(rawInput('operationStatusChanged', { operationId: 'public-operation', status: 'success' })); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event' }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret', + protocolVersion: { major: 99, minor: 0 } + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 }, + protocolVersion: { major: 1, minor: 0 }, + producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/public-reporter-plugin@1.2.3'] + }); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [ + PRIVATE_FIRST_PARTY_SOURCE.packageName, + PRIVATE_FIRST_PARTY_SOURCE.packageVersion, + 'private-command', + 'PRIVATE_INTERNAL_DIAGNOSTIC', + 'private-category', + 'private-operation' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + it('never leaks messages, paths, arguments, remediation, raw output, or secret values', async () => { const SECRET: string = 'sk-super-secret-value'; const LOG_PATH: string = '/home/user/secret/install.log'; From e7d7c4136b05280de9405b9bbe13eed5e641fb98 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:37:28 +0000 Subject: [PATCH 005/133] Preserve public diagnostic telemetry fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...telemetry-privacy_2026-08-28-03-20-00.json | 2 +- .../src/telemetry/TelemetrySubscriber.ts | 33 +++++++++---------- libraries/reporter/src/test/Telemetry.test.ts | 16 +++++---- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json index 5b7d8700177..48ef859460f 100644 --- a/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/rush-reporter", - "comment": "Prevent local-sensitive and secret reporter events from contributing producer identities or other values to telemetry aggregates.", + "comment": "Prevent non-public reporter events from contributing producer identities or other non-public values to telemetry aggregates.", "type": "patch" } ], diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts index 1cbc6f579f8..719700f77cc 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -13,9 +13,10 @@ import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate' * @remarks * The subscriber runs before reporter filtering, so it observes every event. It * projects envelope metadata and lifecycle values only from public events. From - * a local-sensitive diagnostic it may keep the explicitly public code and - * category, but never parameters, remediation, or templates. It ignores secret - * events, messages, raw external output, and command arguments entirely. + * a diagnostic it keeps the explicitly public code and category regardless of + * the envelope privacy floor, but never parameters, remediation, or templates. + * It ignores all other values from non-public events, messages, raw external + * output, and command arguments entirely. * * @beta */ @@ -56,20 +57,18 @@ export class TelemetrySubscriber { } if (event.type === 'diagnosticEmitted') { - if (event.privacy !== 'secret') { - // Code and category are public schema fields even when classified - // parameters make the diagnostic envelope local-sensitive. - const payload: { code?: string; category?: string } = event.payload as { - code?: string; - category?: string; - }; - if (payload.code !== undefined) { - this._diagnosticCodes.add(payload.code); - } - if (payload.category !== undefined) { - this._diagnosticCategoryCounts[payload.category] = - (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; - } + // Code and category are public schema fields even when classified + // parameters make the diagnostic envelope non-public. + const payload: { code?: string; category?: string } = event.payload as { + code?: string; + category?: string; + }; + if (payload.code !== undefined) { + this._diagnosticCodes.add(payload.code); + } + if (payload.category !== undefined) { + this._diagnosticCategoryCounts[payload.category] = + (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; } return; } diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 4ddcfc5b66f..860751149b9 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -152,7 +152,7 @@ describe('TelemetrySubscriber', () => { } }); - it('projects only public envelopes while aggregating public producers deterministically', async () => { + it('projects public envelopes while preserving allowlisted diagnostic fields deterministically', async () => { const PUBLIC_EXTENSION_SOURCE: IReporterEventSource = { packageName: '@rushstack/public-reporter-plugin', packageVersion: '1.2.3' @@ -196,8 +196,11 @@ describe('TelemetrySubscriber', () => { }); manager.emit({ ...rawInput('diagnosticEmitted', { - code: 'PRIVATE_INTERNAL_DIAGNOSTIC', - category: 'private-category' + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + parameters: { + token: { value: 'private-secret-value', privacy: 'secret' } + } }), source: PRIVATE_FIRST_PARTY_SOURCE, privacy: 'secret' @@ -217,8 +220,8 @@ describe('TelemetrySubscriber', () => { result: 'succeeded', exitCode: 0, operationStatusCounts: { success: 1 }, - diagnosticCodes: ['RUSH_OPERATION_FAILED'], - diagnosticCategoryCounts: { operation: 1 }, + diagnosticCodes: ['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1, 'dependency-tool': 1 }, protocolVersion: { major: 1, minor: 0 }, producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/public-reporter-plugin@1.2.3'] }); @@ -227,8 +230,7 @@ describe('TelemetrySubscriber', () => { PRIVATE_FIRST_PARTY_SOURCE.packageName, PRIVATE_FIRST_PARTY_SOURCE.packageVersion, 'private-command', - 'PRIVATE_INTERNAL_DIAGNOSTIC', - 'private-category', + 'private-secret-value', 'private-operation' ]) { expect(serialized).not.toContain(forbidden); From 2450d5f3c473c45909a2fc85956a18d19b29fbb5 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 03:44:04 +0000 Subject: [PATCH 006/133] Fix reporter frontend integration Consume the repository experiment before Rush version selection, keep agent detection out of pre-major defaults, strip frontend-only controls before engine handoff, and preserve legacy verbosity compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/MinimalRushConfiguration.ts | 41 +++++++++++- apps/rush/src/RushFrontend.ts | 11 +++- apps/rush/src/RushReporterHost.ts | 41 ++++++++++-- .../src/test/MinimalRushConfiguration.test.ts | 2 + apps/rush/src/test/RushFrontend.test.ts | 43 ++++++++---- apps/rush/src/test/RushReporterHost.test.ts | 66 ++++++++++++++++++- .../repo/common/config/rush/experiments.json | 3 + 7 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 0cc4436b964..62aef01d11d 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; -import { JsonFile } from '@rushstack/node-core-library'; +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; import { RushConfiguration } from '@microsoft/rush-lib'; import { RushConstants } from '@microsoft/rush-lib/lib/logic/RushConstants'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; @@ -13,6 +13,10 @@ interface IMinimalRushConfigurationJson { rushVersion?: string; } +interface IMinimalExperimentsConfigurationJson { + useRushReporter?: boolean; +} + /** * Represents a minimal subset of the rush.json configuration file. It provides the information necessary to * decide which version of Rush should be installed/used. @@ -20,6 +24,7 @@ interface IMinimalRushConfigurationJson { export class MinimalRushConfiguration { private _rushVersion: string; private _commonRushConfigFolder: string; + private _useRushReporter: boolean; private constructor(minimalRushConfigurationJson: IMinimalRushConfigurationJson, rushJsonFilename: string) { this._rushVersion = @@ -30,6 +35,20 @@ export class MinimalRushConfiguration { 'config', 'rush' ); + + const experimentsJsonFilename: string = path.join( + this._commonRushConfigFolder, + RushConstants.experimentsFilename + ); + const experimentsConfiguration: IMinimalExperimentsConfigurationJson | undefined = + _loadExperimentsConfigurationJson(experimentsJsonFilename); + if ( + experimentsConfiguration?.useRushReporter !== undefined && + typeof experimentsConfiguration.useRushReporter !== 'boolean' + ) { + throw new Error(`The "useRushReporter" setting in "${experimentsJsonFilename}" must be true or false.`); + } + this._useRushReporter = experimentsConfiguration?.useRushReporter === true; } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { @@ -68,6 +87,13 @@ export class MinimalRushConfiguration { public get commonRushConfigFolder(): string { return this._commonRushConfigFolder; } + + /** + * Whether the repository explicitly opted in to the experimental Rush reporter frontend. + */ + public get useRushReporter(): boolean { + return this._useRushReporter; + } } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { @@ -77,3 +103,16 @@ function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigura return undefined; } } + +function _loadExperimentsConfigurationJson( + experimentsJsonFilename: string +): IMinimalExperimentsConfigurationJson | undefined { + try { + return JsonFile.load(experimentsJsonFilename); + } catch (e) { + if (FileSystem.isNotExistError(e)) { + return undefined; + } + throw e; + } +} diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index c60d1265081..05e522a8149 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -6,6 +6,7 @@ import type { ILaunchOptions } from '@microsoft/rush-lib'; import { initializeRushReporterHostAsync, stripReporterValueControls, + type IRushReporterHostOptions, type IInitializedRushReporterHost } from './RushReporterHost'; import { RushCommandSelector } from './RushCommandSelector'; @@ -19,7 +20,9 @@ export interface IRushFrontendOptions { readonly configuration: MinimalRushConfiguration | undefined; readonly launchOptions: ILaunchOptions; readonly currentRushLib: typeof import('@microsoft/rush-lib'); - readonly initializeReporterHostAsync?: () => Promise; + readonly initializeReporterHostAsync?: ( + options: IRushReporterHostOptions + ) => Promise; readonly createVersionSelector?: (currentPackageVersion: string) => RushVersionSelector; readonly executeCurrentRush?: ( currentPackageVersion: string, @@ -40,8 +43,10 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr executeCurrentRush = RushCommandSelector.execute } = options; - const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync(); - if (!reporterHost.selection.enabled && reporterHost.selection.reason !== 'pre-major legacy default') { + const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ + repositoryOptIn: configuration?.useRushReporter + }); + if (reporterHost.selection.reporterControlsOwnedByFrontend) { process.argv = stripReporterValueControls(process.argv); } const reporterLaunchOptions: IRushFrontendLaunchOptions = { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index cde7f83592c..d81a2df9711 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -40,6 +40,7 @@ export interface IRushReporterHostOptions { readonly stdout?: IRushReporterOutputStream; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; + readonly repositoryOptIn?: boolean; } export interface IRushReporterSelection { @@ -48,7 +49,12 @@ export interface IRushReporterSelection { readonly outputs: readonly IReporterOutputTarget[]; readonly commandJson: boolean; readonly enabled: boolean; - readonly reason: 'explicit --reporter' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; + readonly reporterControlsOwnedByFrontend: boolean; + readonly reason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default'; } export interface IInitializedRushReporterHost { @@ -345,25 +351,28 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: [], commandJson: separateJsonControls(argv).commandJson, enabled: false, + reporterControlsOwnedByFrontend: false, reason: 'pre-major legacy default' }; } const cwd: string = options.cwd ?? process.cwd(); - const controls: IParsedReporterControls = parseReporterControls(argv); const commandJson: boolean = separateJsonControls(argv).commandJson; if (isLegacyEmergencyFallbackRequested(env)) { return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'RUSH_REPORTER=legacy' }; } + const controls: IParsedReporterControls = parseReporterControls(argv); + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); if (executableName === 'rush-pnpm') { @@ -385,14 +394,32 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = ); } if (controls.outputs.length > 0 || controls.logLevels.length > 0) { - throw new Error('--output and --log-level require an explicit non-legacy --reporter selection.'); + if (!options.repositoryOptIn) { + throw new Error( + '--output and --log-level require an explicit non-legacy --reporter selection or the ' + + 'useRushReporter repository experiment.' + ); + } + } + if (options.repositoryOptIn) { + const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + return { + reporter: isCiDetected(env) || !stdout.isTTY ? 'plaintext' : 'default', + logLevel: resolveLogLevel(controls, env, true), + outputs: resolveOutputs(controls.outputs, cwd), + commandJson, + enabled: true, + reporterControlsOwnedByFrontend: true, + reason: 'repository experiment' + }; } return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'pre-major legacy default' }; } @@ -410,10 +437,11 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = } return { reporter: 'legacy', - logLevel: resolveLogLevel(controls, env, false), + logLevel: 'normal', outputs: [], commandJson, enabled: false, + reporterControlsOwnedByFrontend: true, reason: 'explicit --reporter' }; } @@ -431,6 +459,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = outputs: resolveOutputs(controls.outputs, cwd), commandJson, enabled: true, + reporterControlsOwnedByFrontend: true, reason: 'explicit --reporter' }; } diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 391c9feeeb2..80b95dbd6aa 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -19,6 +19,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('2.5.0'); + expect(config.useRushReporter).toBe(false); }); }); @@ -31,6 +32,7 @@ describe(MinimalRushConfiguration.name, () => { const config: MinimalRushConfiguration = MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); + expect(config.useRushReporter).toBe(true); }); }); }); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index cd0c2ca6618..765b4d9a1dc 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -24,6 +24,7 @@ async function createInitializedHostAsync( outputs: [], commandJson: false, enabled: false, + reporterControlsOwnedByFrontend: true, reason } }; @@ -77,17 +78,37 @@ describe(launchRushFrontendAsync.name, () => { .reporterEventSink; }; - await launchRushFrontendAsync({ - currentPackageVersion: '5.178.1', - rushVersionToLoad: '5.177.0', - configuration: undefined, - launchOptions: { isManaged: true }, - currentRushLib: rushLib, - initializeReporterHostAsync: () => createInitializedHostAsync(order), - createVersionSelector: () => versionSelector - }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', '--reporter=json', '--log-level=debug']; - expect(order).toEqual(['host', 'version-selection']); - expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: '5.177.0', + configuration: undefined, + launchOptions: { isManaged: true }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => { + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + return { + ...initialized, + selection: { + ...initialized.selection, + reporter: 'json', + logLevel: 'debug', + enabled: true, + reason: 'explicit --reporter' + } + }; + }, + createVersionSelector: () => versionSelector + }); + + expect(order).toEqual(['host', 'version-selection']); + expect(process.argv).toEqual(['node', 'rush', 'build']); + expect(receivedSink).toEqual(expect.objectContaining({ emit: expect.any(Function) })); + } finally { + process.argv = originalArgv; + } }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 11ca6276dc0..fa6f8bcf077 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -18,13 +18,15 @@ import { function resolve( argv: readonly string[], env: Record = {}, - isTTY: boolean = false + isTTY: boolean = false, + repositoryOptIn: boolean = false ): IRushReporterSelection { return resolveRushReporterSelection({ argv, env, cwd: '/repo', - stdout: { isTTY, columns: 100, write: () => undefined } + stdout: { isTTY, columns: 100, write: () => undefined }, + repositoryOptIn }); } @@ -66,6 +68,44 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('uses deterministic non-agent selection for the repository experiment', () => { + expect(resolve(['build'], {}, true, true)).toMatchObject({ + reporter: 'default', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { CI: 'true' }, true, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], {}, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + expect(resolve(['build'], { COPILOT_CLI: '1' }, false, true)).toMatchObject({ + reporter: 'plaintext', + enabled: true, + reason: 'repository experiment' + }); + }); + + it('allows reporter controls with the repository experiment', () => { + expect( + resolve(['build', '--log-level=debug', '--output=json://./events.jsonl'], {}, false, true) + ).toMatchObject({ + reporter: 'plaintext', + logLevel: 'debug', + outputs: [ + { + reporter: 'json', + target: path.resolve('/repo', 'events.jsonl') + } + ] + }); + }); + it('does not consume rush-pnpm or rushx reporter arguments', () => { expect( resolveRushReporterSelection({ @@ -84,7 +124,14 @@ describe(resolveRushReporterSelection.name, () => { }); it('keeps RUSH_REPORTER=legacy as an emergency override', () => { - expect(resolve(['build', '--reporter=json'], { RUSH_REPORTER: ' LEGACY ' })).toMatchObject({ + expect( + resolve( + ['build', '--reporter=json', '--quiet', '--debug', '--log-level=invalid'], + { RUSH_REPORTER: ' LEGACY ' }, + false, + true + ) + ).toMatchObject({ reporter: 'legacy', enabled: false, reason: 'RUSH_REPORTER=legacy' @@ -117,6 +164,19 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { + expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false + }); + expect(resolve(['build', '--reporter=legacy', '--quiet', '--debug'])).toMatchObject({ + reporter: 'legacy', + logLevel: 'normal', + enabled: false + }); + }); + it('ignores reporter environment selection before the gate but validates explicit controls', () => { expect(resolve(['build'], { RUSH_LOG_LEVEL: 'not-a-level' }).enabled).toBe(false); expect(() => resolve(['build', '--reporter=unknown'])).toThrow(/Unsupported reporter/); diff --git a/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json new file mode 100644 index 00000000000..596ca68ca76 --- /dev/null +++ b/apps/rush/src/test/sandbox/repo/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "useRushReporter": true +} From c6b0349a11d9c57c61146bca955ab25cc801ed64 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:15:43 +0000 Subject: [PATCH 007/133] Emit shadow Rush lifecycle events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + common/reviews/api/rush-lib.api.md | 2 + common/reviews/api/rush-reporter.api.md | 6 + .../diagnostics/RushDiagnosticCodeRegistry.ts | 39 +-- .../src/diagnostics/templates/operation.ts | 3 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 110 +++++- .../cli/scriptActions/PhasedScriptAction.ts | 2 + .../cli/test/RushCommandLineParser.test.ts | 33 +- ...RushCommandLineParserReporterClose.test.ts | 25 ++ libraries/rush-lib/src/cli/test/TestUtils.ts | 6 +- libraries/rush-lib/src/logic/Telemetry.ts | 33 ++ .../operations/ReporterOperationEventSink.ts | 314 ++++++++++++++++++ .../test/OperationGraphEventSink.test.ts | 235 ++++++++++++- .../rush-lib/src/logic/test/Telemetry.test.ts | 44 ++- .../src/pluginFramework/RushSession.test.ts | 95 +++++- .../src/pluginFramework/RushSession.ts | 250 +++++++++++++- 17 files changed, 1179 insertions(+), 40 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..71b7d371662 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit shadow Rush lifecycle, phase-aware operation, diagnostic, telemetry, and command-result events without changing legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..5f23b51eea9 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add a stable structured diagnostic code for Rush command failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 13d17d81750..bf214916f0b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import { IRushDiagnostic } from '@rushstack/rush-reporter'; import { IScopedLogger } from '@rushstack/rush-reporter'; import { IScopedMessageOptions } from '@rushstack/rush-reporter'; import { IScopedReporter } from '@rushstack/rush-reporter'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -1044,6 +1045,7 @@ export interface ITelemetryData { readonly operationResults?: Record; readonly performanceEntries?: readonly PerformanceEntry_2[]; readonly platform?: string; + readonly reporterData?: ITelemetryAggregate; readonly result: 'Succeeded' | 'Failed'; readonly rushVersion?: string; readonly timestampMs?: number; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..ecf09047dbd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1506,6 +1506,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; readonly detailKey: undefined; +}, { + readonly code: "RUSH_COMMAND_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_COMMAND_FAILED.summary"; + readonly detailKey: undefined; }]; // @beta diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 11f5c1d933a..0d697f893e6 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -111,16 +111,13 @@ type AreValidRushDiagnosticCodeSegments< ? IsValidRushDiagnosticCodeSegment : false; -type ValidateRushDiagnosticCode = - TCode extends `RUSH_${infer Segments}` - ? AreValidRushDiagnosticCodeSegments extends true - ? TCode - : never - : never; +type ValidateRushDiagnosticCode = TCode extends `RUSH_${infer Segments}` + ? AreValidRushDiagnosticCodeSegments extends true + ? TCode + : never + : never; -type ValidatedRushDiagnosticCodeDefinitions< - TDefinitions extends readonly IRushDiagnosticCodeDefinition[] -> = { +type ValidatedRushDiagnosticCodeDefinitions = { readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition ? TDefinitions[K] & { readonly code: ValidateRushDiagnosticCode; @@ -130,9 +127,7 @@ type ValidatedRushDiagnosticCodeDefinitions< function defineRushDiagnosticCodeDefinitions< const TDefinitions extends readonly IRushDiagnosticCodeDefinition[] ->( - definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions -): TDefinitions { +>(definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions): TDefinitions { return definitions; } @@ -233,6 +228,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', detailKey: undefined + }, + { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + detailKey: undefined } ]); @@ -257,12 +259,11 @@ export type RushDiagnosticTemplateKey = NonNullable< * * @beta */ -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = - new Map( - RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( - (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const - ) - ); +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const + ) +); export { isValidRushDiagnosticCode } from './RushDiagnosticCode'; -export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; \ No newline at end of file +export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 32107668384..456adc6c8eb 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -11,5 +11,6 @@ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', - 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}', + 'diagnostic.RUSH_COMMAND_FAILED.summary': 'The Rush command {commandName} failed.' } as const; diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index ba043788d88..7a149ca5744 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -16,6 +16,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; +import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter } from '@rushstack/rush-reporter'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushConstants } from '../logic/RushConstants'; @@ -64,6 +65,13 @@ import { RushAlerts } from '../utilities/RushAlerts'; import { initializeDotEnv } from '../logic/dotenv'; import { measureAsyncFn } from '../utilities/performance'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { + _correlateRushSessionError, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionReporterSourceVersion, + _isRushSessionErrorRepresented +} from '../pluginFramework/RushSession'; /** * Options for `RushCommandLineParser`. @@ -91,6 +99,11 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; private _initializationFailed: boolean = false; + private _sessionLifecycleEmitter: LifecycleEmitter | undefined; + private _commandLifecycleEmitter: LifecycleEmitter | undefined; + private _sessionStartTimeMs: number | undefined; + private _commandStartTimeMs: number | undefined; + private _reporterCompletionEmitted: boolean = false; private _reporterClosePromise: Promise | undefined; /** @@ -264,12 +277,30 @@ export class RushCommandLineParser extends CommandLineParser { this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); + this._sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + if (this._sessionLifecycleEmitter) { + this._sessionStartTimeMs = performance.now(); + this._sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => this.pluginManager.tryInitializeUnassociatedPluginsAsync() ); - return await super.executeAsync(args); + const succeeded: boolean = await super.executeAsync(args); + if (!this._reporterCompletionEmitted) { + this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + } + return succeeded; + } catch (error) { + if (!process.exitCode) { + process.exitCode = 1; + } + this._reportErrorAndSetExitCode(error as Error); + return false; } finally { await this._closeReporterAsync(); } @@ -287,6 +318,17 @@ export class RushCommandLineParser extends CommandLineParser { InternalError.breakInDebugger = true; } + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName) { + this._commandLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession, { + commandName + }); + if (this._commandLifecycleEmitter) { + this._commandStartTimeMs = performance.now(); + this._commandLifecycleEmitter.emitCommandStarted({ commandName }); + } + } + try { await this._wrapOnExecuteAsync(); @@ -327,6 +369,7 @@ export class RushCommandLineParser extends CommandLineParser { // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; + this._emitReporterCompletion(0); } catch (error) { this._reportErrorAndSetExitCode(error as Error); } @@ -544,6 +587,20 @@ export class RushCommandLineParser extends CommandLineParser { } private _reportErrorAndSetExitCode(error: Error): void { + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { + parameters: { + commandName: { + value: this.selectedAction?.actionName ?? 'unknown', + privacy: 'public' + } + } + }); + this._commandLifecycleEmitter?.emitDiagnostic(diagnostic); + _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); + } + if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -564,6 +621,7 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } + this._emitReporterCompletion(_getNumericProcessExitCode(1)); this.flushTelemetry(); const configuredExitCode: string | number | undefined = process.exitCode; @@ -620,4 +678,54 @@ export class RushCommandLineParser extends CommandLineParser { } return this._reporterClosePromise; } + + private _emitReporterCompletion(exitCode: number): void { + if (this._reporterCompletionEmitted) { + return; + } + this._reporterCompletionEmitted = true; + + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName && this._commandLifecycleEmitter) { + const durationMs: number | undefined = + this._commandStartTimeMs === undefined ? undefined : performance.now() - this._commandStartTimeMs; + this._commandLifecycleEmitter.emitCommandResult({ + commandName, + succeeded: exitCode === 0, + exitCode + }); + this._commandLifecycleEmitter.emitCommandCompleted({ + commandName, + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + if (this._sessionLifecycleEmitter) { + const durationMs: number | undefined = + this._sessionStartTimeMs === undefined ? undefined : performance.now() - this._sessionStartTimeMs; + this._sessionLifecycleEmitter.emitSessionCompleted({ + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + // Shadow derivation is deliberately observational. process.exitCode remains authoritative. + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession) { + _getRushSessionDerivedExitStatus(rushSession); + } + } +} + +function _getNumericProcessExitCode(fallback: number): number { + const { exitCode } = process; + if (typeof exitCode === 'number') { + return exitCode; + } + if (typeof exitCode === 'string') { + const parsed: number = Number(exitCode); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; } diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1b2b7aa5812..e310a7c32d1 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -62,6 +62,7 @@ import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParameter import { TrimRushEnvironmentVariablesPlugin } from '../../logic/operations/TrimRushEnvironmentVariablesPlugin'; import { DebugHashesPlugin } from '../../logic/operations/DebugHashesPlugin'; import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import { attachReporterOperationEventSink } from '../../logic/operations/ReporterOperationEventSink'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; @@ -677,6 +678,7 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { await hooks.onGraphCreatedAsync.promise(graph, graphContext); }); + attachReporterOperationEventSink(graph, this.rushSession, this.actionName); const executeOptions: IExecuteOperationsOptions = { graph, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 64d47c1cfdf..6f1184c7dde 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -31,6 +31,7 @@ import './mockRushCommandLineParser'; import type { SpawnOptions } from 'node:child_process'; import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { Autoinstaller } from '../../logic/Autoinstaller'; import type { ITelemetryData } from '../../logic/Telemetry'; import { @@ -47,6 +48,15 @@ import { IS_WINDOWS } from '../../utilities/executionUtilities'; // we only reference the one that is common. const SPAWN_ARG_OPTIONS: number = 2; +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function spawnOptionEquals( spawnCall: SpawnMockCall, optionName: TOption, @@ -93,7 +103,11 @@ describe('RushCommandLineParser', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; - const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build'); + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build', { + eventSink: reporterSink, + sessionId: 'parser-shadow' + }); await expect(parser.executeAsync()).resolves.toEqual(true); @@ -111,6 +125,23 @@ describe('RushCommandLineParser', () => { const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1]; expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp); cwdOptionEquals(secondSpawn, `${repoPath}/b`); + + const eventTypes: string[] = reporterSink.inputs.map(({ type }) => type); + expect(eventTypes[0]).toBe('sessionStarted'); + expect(eventTypes[1]).toBe('commandStarted'); + expect(eventTypes).toContain('operationRegistered'); + expect(eventTypes).toContain('operationStatusChanged'); + expect(eventTypes.slice(-3)).toEqual(['commandResult', 'commandCompleted', 'sessionCompleted']); + expect(reporterSink.inputs.at(-3)?.payload).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationRegistered')) { + const scope = event.scope!; + expect(scope.operationId).toBe(`${scope.projectName}#${scope.phaseName}`); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 601bb70185d..1b46d180f4c 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -147,4 +147,29 @@ describe('RushCommandLineParser reporter close', () => { expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); }); + + it('shares one reporter close operation across failure and finalization paths', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + + const closeReporterAsync: () => Promise = ( + parser as unknown as { + _closeReporterAsync(): Promise; + } + )._closeReporterAsync.bind(parser); + const firstClose: Promise = closeReporterAsync(); + const secondClose: Promise = closeReporterAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([undefined, undefined]); + expect(closeAsync).toHaveBeenCalledTimes(1); + }); }); diff --git a/libraries/rush-lib/src/cli/test/TestUtils.ts b/libraries/rush-lib/src/cli/test/TestUtils.ts index c8191358c2c..29fa4482233 100644 --- a/libraries/rush-lib/src/cli/test/TestUtils.ts +++ b/libraries/rush-lib/src/cli/test/TestUtils.ts @@ -4,6 +4,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser'; +import type { IRushSessionReporterOptions } from '../../pluginFramework/RushSession'; import { FlagFile } from '../../api/FlagFile'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -76,7 +77,8 @@ export const TEST_REPO_FOLDER_PATH: string = `${PROJECT_ROOT}/temp/test/unit-tes */ export async function getCommandLineParserInstanceAsync( repoName: string, - taskName: string + taskName: string, + reporter?: IRushSessionReporterOptions ): Promise { // Copy the test repo to a sandbox folder const repoPath: string = `${TEST_REPO_FOLDER_PATH}/${repoName}-${performance.now()}`; @@ -100,7 +102,7 @@ export async function getCommandLineParserInstanceAsync( // to exit and clear the Rush file lock. So running multiple `it` or `describe` test blocks over the same test // repo will fail due to contention over the same lock which is kept until the test runner process // ends. - const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath }); + const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath, reporter }); // Bulk tasks are hard-coded to expect install to have been completed. So, ensure the last-link.flag // file exists and is valid diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 8d855cd46a0..e9d0f54ad95 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import type { PerformanceEntry } from 'node:perf_hooks'; import { FileSystem, type FileSystemStats, JsonFile } from '@rushstack/node-core-library'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../api/RushConfiguration'; import { Rush } from '../api/Rush'; import type { RushSession } from '../pluginFramework/RushSession'; +import { _getRushSessionTelemetryAggregate } from '../pluginFramework/RushSession'; import { collectPerformanceEntries } from '../utilities/performance'; /** @@ -138,6 +140,16 @@ export interface ITelemetryData { * This is an array of `PerformanceEntry` objects, which can include marks, measures, and function timings. */ readonly performanceEntries?: readonly PerformanceEntry[]; + + /** + * The allowlisted projection derived from shadow reporter events. + * + * @remarks + * This is present only when the Rush frontend supplied a reporter event sink. + * It never contains messages, paths, arguments, raw output, remediation + * parameters, stack traces, or non-public envelope metadata. + */ + readonly reporterData?: ITelemetryAggregate; } const MAX_FILE_COUNT: number = 100; @@ -166,9 +178,30 @@ export class Telemetry { if (!this._enabled) { return; } + const reporterAggregate: ITelemetryAggregate | undefined = _getRushSessionTelemetryAggregate( + this._rushSession + ); + const processExitCode: number = + typeof process.exitCode === 'number' ? process.exitCode : Number(process.exitCode); const cpus: os.CpuInfo[] = os.cpus(); const data: ITelemetryData = { ...telemetryData, + reporterData: reporterAggregate + ? { + ...reporterAggregate, + commandName: reporterAggregate.commandName ?? telemetryData.name, + result: + reporterAggregate.result ?? (telemetryData.result === 'Succeeded' ? 'succeeded' : 'failed'), + exitCode: + reporterAggregate.exitCode ?? + (telemetryData.result === 'Succeeded' + ? 0 + : Number.isFinite(processExitCode) + ? processExitCode + : 1), + durationMs: reporterAggregate.durationMs ?? telemetryData.durationInSeconds * 1000 + } + : telemetryData.reporterData, performanceEntries: telemetryData.performanceEntries || collectPerformanceEntries(this._telemetryStartTime), machineInfo: telemetryData.machineInfo || { diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts new file mode 100644 index 00000000000..11a100e68bd --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + type IRushDiagnostic, + type LifecycleEmitter, + type OperationStatus as ReporterOperationStatus +} from '@rushstack/rush-reporter'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { RushSession } from '../../pluginFramework/RushSession'; +import { + _correlateRushSessionError, + _getRushSessionLifecycleEmitter +} from '../../pluginFramework/RushSession'; +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import type { OperationGraph } from './OperationGraph'; + +interface IReporterOperation { + readonly emitter: LifecycleEmitter; + readonly legacyOperationIds: Set; + readonly operationId: string; + readonly phaseName: string; + readonly projectName: string; + readonly registeredOperationIds: Set; + readonly statuses: Map; + lastEmittedStatus: ReporterOperationStatus | undefined; + silent: boolean; +} + +class ReporterOperationEventSink implements IOperationGraphEventSink { + private readonly _operationsByLegacyId: Map = new Map(); + private readonly _diagnosedOperations: Set = new Set(); + private readonly _rushSession: RushSession; + + public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { + this._rushSession = rushSession; + const operationsByReporterId: Map = new Map(); + + for (const operation of operations) { + const projectName: string = operation.associatedProject.packageName; + const phaseName: string = operation.associatedPhase.name; + const operationId: string = `${projectName}#${phaseName}`; + let reporterOperation: IReporterOperation | undefined = operationsByReporterId.get(operationId); + if (!reporterOperation) { + const emitter: LifecycleEmitter | undefined = _getRushSessionLifecycleEmitter(rushSession, { + commandName, + operationId, + projectName, + phaseName + }); + if (!emitter) { + continue; + } + reporterOperation = { + emitter, + legacyOperationIds: new Set(), + operationId, + phaseName, + projectName, + registeredOperationIds: new Set(), + statuses: new Map(), + lastEmittedStatus: undefined, + silent: true + }; + operationsByReporterId.set(operationId, reporterOperation); + } + reporterOperation.legacyOperationIds.add(operation.name); + reporterOperation.silent &&= !operation.enabled || operation.runner?.silent === true; + this._operationsByLegacyId.set(operation.name, reporterOperation); + } + } + + public get isEnabled(): boolean { + return this._operationsByLegacyId.size > 0; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation) { + return; + } + + if (operation.registeredOperationIds.size === operation.legacyOperationIds.size) { + operation.registeredOperationIds.clear(); + operation.statuses.clear(); + operation.lastEmittedStatus = undefined; + operation.silent = true; + this._diagnosedOperations.delete(operation.operationId); + } + + operation.registeredOperationIds.add(operationId); + operation.silent &&= silent; + if (operation.registeredOperationIds.size !== operation.legacyOperationIds.size || operation.silent) { + return; + } + + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } + + public onOperationStatusChanged(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation) { + return; + } + + if ( + result.status === OperationStatus.Ready && + operation.registeredOperationIds.size === operation.legacyOperationIds.size + ) { + return; + } + + operation.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !this._diagnosedOperations.has(operation.operationId)) { + this._diagnosedOperations.add(operation.operationId); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: operation.projectName, privacy: 'public' } + } + }); + operation.emitter.emitDiagnostic(diagnostic); + if (result.error) { + _correlateRushSessionError(this._rushSession, result.error, diagnostic.diagnosticId); + } + } + + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation); + if (status === undefined || status === operation.lastEmittedStatus) { + return; + } + operation.lastEmittedStatus = status; + if (!operation.silent) { + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + operation.emitter.emitOperationStatusChanged({ + operationId: operation.operationId, + status, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + } +} + +class CompositeOperationGraphEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + + private readonly _first: IOperationGraphEventSink; + private readonly _second: IOperationGraphEventSink; + + public constructor(first: IOperationGraphEventSink, second: IOperationGraphEventSink) { + this._first = first; + this._second = second; + this.onOperationChunk = + first.onOperationChunk || second.onOperationChunk + ? (operationId, chunk) => { + first.onOperationChunk?.(operationId, chunk); + second.onOperationChunk?.(operationId, chunk); + } + : undefined; + this.onOperationStreamClosed = + first.onOperationStreamClosed || second.onOperationStreamClosed + ? (operationId) => { + first.onOperationStreamClosed?.(operationId); + second.onOperationStreamClosed?.(operationId); + } + : undefined; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + this._first.onOperationRegistered?.(operationId, silent); + this._second.onOperationRegistered?.(operationId, silent); + } + + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { + this._first.onOperationStatusChanged?.(result, previousStatus); + this._second.onOperationStatusChanged?.(result, previousStatus); + } + + public onOperationHeader(operationId: string, completedOperations: number, totalOperations: number): void { + this._first.onOperationHeader?.(operationId, completedOperations, totalOperations); + this._second.onOperationHeader?.(operationId, completedOperations, totalOperations); + } + + public onActivity(text: string, options?: IOperationActivityOptions): void { + this._first.onActivity?.(text, options); + this._second.onActivity?.(text, options); + } +} + +/** + * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * + * @internal + */ +export function attachReporterOperationEventSink( + graph: OperationGraph, + rushSession: RushSession, + commandName: string +): void { + const reporterSink: ReporterOperationEventSink = new ReporterOperationEventSink( + rushSession, + commandName, + graph.operations + ); + if (!reporterSink.isEnabled) { + return; + } + + graph.eventSink = graph.eventSink + ? new CompositeOperationGraphEventSink(graph.eventSink, reporterSink) + : reporterSink; +} + +function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { + switch (status) { + case OperationStatus.Ready: + return 'ready'; + case OperationStatus.Waiting: + return 'waiting'; + case OperationStatus.Queued: + return 'queued'; + case OperationStatus.Executing: + return 'executing'; + case OperationStatus.Success: + return 'success'; + case OperationStatus.SuccessWithWarning: + return 'successWithWarnings'; + case OperationStatus.Failure: + return 'failure'; + case OperationStatus.Blocked: + return 'blocked'; + case OperationStatus.Skipped: + return 'skipped'; + case OperationStatus.FromCache: + return 'fromCache'; + case OperationStatus.NoOp: + return 'noOp'; + case OperationStatus.Aborted: + return 'aborted'; + } +} + +function _getAggregateStatus(operation: IReporterOperation): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...operation.statuses.values()]; + if ( + statuses.some((status) => status === OperationStatus.Executing) || + operation.lastEmittedStatus === 'executing' + ) { + if ( + operation.statuses.size !== operation.legacyOperationIds.size || + statuses.some((status) => !_isTerminalStatus(status)) + ) { + return 'executing'; + } + } + if ( + operation.statuses.size === operation.legacyOperationIds.size && + statuses.every((status) => _isTerminalStatus(status)) + ) { + return _getAggregateTerminalStatus(statuses); + } + if (statuses.some((status) => status === OperationStatus.Queued)) { + return 'queued'; + } + if (statuses.some((status) => status === OperationStatus.Ready)) { + return 'ready'; + } + if (statuses.some((status) => status === OperationStatus.Waiting)) { + return 'waiting'; + } + return operation.legacyOperationIds.size === 1 + ? _toReporterStatus(statuses[0] ?? OperationStatus.Ready) + : undefined; +} + +function _getAggregateTerminalStatus(operationStatuses: Iterable): ReporterOperationStatus { + const statuses: Set = new Set(operationStatuses); + if (statuses.has(OperationStatus.Failure)) return 'failure'; + if (statuses.has(OperationStatus.Aborted)) return 'aborted'; + if (statuses.has(OperationStatus.Blocked)) return 'blocked'; + if (statuses.has(OperationStatus.SuccessWithWarning)) return 'successWithWarnings'; + if (statuses.has(OperationStatus.Success)) return 'success'; + if (statuses.has(OperationStatus.FromCache)) return 'fromCache'; + if (statuses.has(OperationStatus.Skipped)) return 'skipped'; + return 'noOp'; +} + +function _isTerminalStatus(status: OperationStatus): boolean { + switch (status) { + case OperationStatus.Success: + case OperationStatus.SuccessWithWarning: + case OperationStatus.Failure: + case OperationStatus.Blocked: + case OperationStatus.Skipped: + case OperationStatus.FromCache: + case OperationStatus.NoOp: + case OperationStatus.Aborted: + return true; + default: + return false; + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c16d6c91f32..fc6e58fe38a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -34,7 +34,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); -import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -46,6 +47,13 @@ import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; +import { + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + RushSession +} from '../../../pluginFramework/RushSession'; +import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; const mockPhase: IPhase = { name: 'phase', @@ -57,12 +65,17 @@ const mockPhase: IPhase = { missingScriptBehavior: 'silent' }; -function createOperation(name: string, runner: IOperationRunner): Operation { +function createOperation( + name: string, + runner: IOperationRunner, + phase: IPhase = mockPhase, + projectName: string = name +): Operation { return new Operation({ runner, logFilenameIdentifier: name, - phase: mockPhase, - project: { packageName: name } as unknown as RushConfigurationProject + phase, + project: { packageName: projectName } as unknown as RushConfigurationProject }); } @@ -95,6 +108,15 @@ class RecordingSink implements IOperationGraphEventSink { } } +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { return { quietMode, @@ -207,4 +229,209 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + + it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + }); + const createFailingOperation = (): Operation => + createOperation( + '@scope/project', + new MockOperationRunner('@scope/project (phase)', async () => OperationStatus.Failure) + ); + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createFailingOperation()]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const operation: Operation = createFailingOperation(); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' || type === 'operationStatusChanged' + ); + expect(operationEvents.length).toBeGreaterThan(1); + for (const event of operationEvents) { + expect(event.scope).toMatchObject({ + commandName: 'build', + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase' + }); + } + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'diagnosticEmitted', + payload: expect.objectContaining({ code: 'RUSH_OPERATION_FAILED' }) + }) + ); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); + + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'sharded-operation-shadow' } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardOneRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 1/2`, + async () => OperationStatus.Success + ); + let shardTwoOutcome: OperationStatus = OperationStatus.Failure; + const shardTwoRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 2/2`, + async () => shardTwoOutcome + ); + const collatorRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - collate`, + async () => OperationStatus.Success + ); + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shardOne: Operation = createOperation('shard-one', shardOneRunner, mockPhase, projectName); + const shardTwo: Operation = createOperation('shard-two', shardTwoRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shardOne.addDependency(preShard); + shardTwo.addDependency(preShard); + collator.addDependency(shardOne); + collator.addDependency(shardTwo); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shardOne, shardTwo]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const reporterOperationId: string = `${projectName}#phase`; + const operationEvents = (): IReporterEmitEventInput[] => + reporterSink.inputs.filter(({ scope }) => scope?.operationId === reporterOperationId); + expect(operationEvents().filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'failure' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + failure: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + + shardTwoOutcome = OperationStatus.Success; + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + expect( + operationEvents() + .filter(({ type }) => type === 'operationRegistered') + .map(({ scope }) => scope?.operationId) + ).toEqual([reporterOperationId, reporterOperationId]); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'success' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + success: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + + const lifecycleEmitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + lifecycleEmitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + lifecycleEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + lifecycleEmitter.emitSessionCompleted({ exitCode: 0 }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); + + it('recomputes grouped silence for each watch-style iteration', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'grouped-silence-shadow' } + }); + const projectName: string = '@scope/silence'; + const first: Operation = createOperation( + 'first', + new MockOperationRunner(`${projectName} (phase) - first`), + mockPhase, + projectName + ); + const second: Operation = createOperation( + 'second', + new MockOperationRunner(`${projectName} (phase) - second`), + mockPhase, + projectName + ); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationId: string = `${projectName}#phase`; + const countEvents = (type: IReporterEmitEventInput['type']): number => + reporterSink.inputs.filter( + ({ type: eventType, scope }) => eventType === type && scope?.operationId === operationId + ).length; + const registrationCount: number = countEvents('operationRegistered'); + const statusCount: number = countEvents('operationStatusChanged'); + expect(registrationCount).toBe(1); + expect(statusCount).toBeGreaterThan(0); + + first.enabled = false; + second.enabled = false; + graph.invalidateOperations(undefined, 'disable group'); + await graph.executeAsync({}); + + expect(countEvents('operationRegistered')).toBe(registrationCount); + expect(countEvents('operationStatusChanged')).toBe(statusCount); + }); }); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index aebc60ef4a9..4afccd7e86e 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -2,12 +2,22 @@ // See LICENSE in the project root for license information. import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import { Rush } from '../../api/Rush'; import { Telemetry, type ITelemetryData, type ITelemetryMachineInfo } from '../Telemetry'; -import { RushSession } from '../../pluginFramework/RushSession'; +import { _getRushSessionLifecycleEmitter, RushSession } from '../../pluginFramework/RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} interface ITelemetryPrivateMembers extends Omit { _flushAsyncTasks: Map>; @@ -136,6 +146,38 @@ describe(Telemetry.name, () => { expect(result.timestampMs).toBeDefined(); }); + it('projects public shadow events into legacy telemetry without exposing command arguments', () => { + const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); + const sink: CapturingSink = new CapturingSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: sink, sessionId: 'telemetry-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=secret'] }); + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'success' }); + + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ + name: 'build', + durationInSeconds: 2, + result: 'Succeeded', + machineInfo: {} as ITelemetryMachineInfo, + performanceEntries: [] + }); + + expect(telemetry.store[0].reporterData).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + expect(JSON.stringify(telemetry.store[0].reporterData)).not.toContain('--auth-token=secret'); + }); + it('calls custom flush telemetry', async () => { const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 26a48160731..c4287f8d580 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -3,16 +3,27 @@ import * as os from 'node:os'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, IReporterEventSink } from '@rushstack/rush-reporter'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { Rush } from '../api/Rush'; import { RushCommandLineParser } from '../cli/RushCommandLineParser'; -import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; +import { + _correlateRushSessionError, + _createRushSessionForPlugin, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + _isRushSessionErrorRepresented, + type IRushSessionReporterOptions, + RushSession +} from './RushSession'; class CapturingSink implements IReporterEventSink { public readonly inputs: IReporterEmitEventInput[] = []; @@ -149,4 +160,86 @@ describe(RushSession.name, () => { action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); }); + + it('observes shadow lifecycle, diagnostics, telemetry, and legacy correlation without terminal output', () => { + const sink: CapturingSink = new CapturingSink(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const session: RushSession = new RushSession({ + getIsDebugMode: () => false, + terminalProvider, + reporter: { eventSink: sink, sessionId: 'session-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + const error: AlreadyReportedError = new AlreadyReportedError(); + + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationRegistered({ + operationId: '@scope/project#_phase:test', + projectName: '@scope/project', + phaseName: '_phase:test' + }); + emitter.emitOperationStatusChanged({ + operationId: '@scope/project#_phase:test', + status: 'failure' + }); + const diagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: '@scope/project', privacy: 'public' } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 1, durationMs: 25 }); + emitter.emitSessionCompleted({ exitCode: 1, durationMs: 30 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(_getRushSessionTelemetryAggregate(session)).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + operationStatusCounts: { failure: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 } + }); + expect(terminalProvider.getAllOutput(false)).toEqual({ + log: '', + warning: '', + error: '', + verbose: '', + debug: '' + }); + }); + + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@private/plugin', + packageVersion: '1.0.0' + })); + + pluginSession.getReporter()!.emitMessage({ + severity: 'info', + text: '/local/private/path' + }); + _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + + const aggregate = _getRushSessionTelemetryAggregate(session)!; + expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); + expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e017a9a8cbc..fa9771ccb08 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -3,10 +3,19 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; import { + LifecycleEmitter, + LegacyErrorBridge, RushSessionReporting, + TelemetrySubscriber, + isReporterEventRequired, + resolveExitStatus, + type IReporterEmitEventInput, + type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IRushExitStatus, + type ITelemetryAggregate, type IScopedLogger, type IScopedReporter } from '@rushstack/rush-reporter'; @@ -77,7 +86,23 @@ interface IRushSessionState { readonly cloudBuildCacheProviderFactories: Map; readonly cobuildLockProviderFactories: Map; readonly hooks: RushLifecycleHooks; - readonly reporting: RushSessionReporting | undefined; + readonly reporting: IRushSessionReportingState | undefined; +} + +interface IRushSessionReportingState { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sessionReporting: RushSessionReporting; + readonly observer: IRushSessionShadowEventObserver; +} + +interface IRushSessionShadowEventObserver { + ingest(event: IReporterEmitEventInput, eventId: string): void; + buildTelemetryAggregate(): ITelemetryAggregate; + resolveExitStatus(): IRushExitStatus; + correlateError(error: unknown, diagnosticId: string): void; + isErrorRepresented(error: unknown): boolean; } let _rushLibSource: IReporterEventSource | undefined; @@ -107,8 +132,9 @@ function _getRushLibSource(): IReporterEventSource { function _createReporting( reporterOptions: IRushSessionReporterOptions | undefined, - source: IReporterEventSource -): RushSessionReporting | undefined { + source: IReporterEventSource, + observer?: IRushSessionShadowEventObserver +): IRushSessionReportingState | undefined { if (!reporterOptions) { return undefined; } @@ -121,10 +147,148 @@ function _createReporting( throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); } - return new RushSessionReporting({ - sink: eventSink, + const shadowObserver: IRushSessionShadowEventObserver = observer ?? _createRushSessionShadowEventObserver(); + const observedEventSink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const eventId: string = eventSink.emit(event); + shadowObserver.ingest(event, eventId); + return eventId; + } + }; + const boundSource: IReporterEventSource = { ...source }; + + return { + eventSink: observedEventSink, sessionId, - source: { ...source } + source: boundSource, + observer: shadowObserver, + sessionReporting: new RushSessionReporting({ + sink: observedEventSink, + sessionId, + source: boundSource + }) + }; +} + +function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserver { + const legacyErrorBridge: LegacyErrorBridge = new LegacyErrorBridge(); + const telemetrySubscriber: TelemetrySubscriber = new TelemetrySubscriber(); + const operationStatuses: Map = new Map(); + let sequence: number = 0; + let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let hasUnscopedFailure: boolean = false; + + const updateDerivedOperationStatus = (): void => { + const hasOperationFailure: boolean = [...operationStatuses.values()].some( + (status) => status === 'failure' || status === 'aborted' + ); + derivedExitStatus = resolveExitStatus({ + hasFailures: hasUnscopedFailure || hasOperationFailure + }); + }; + + return { + ingest(event: IReporterEmitEventInput, eventId: string): void { + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: ++sequence, + timestamp: new Date().toISOString(), + required: isReporterEventRequired(event.type) + }; + legacyErrorBridge.ingest(envelope); + + if (envelope.parentSessionId === undefined) { + switch (envelope.type) { + case 'commandStarted': { + operationStatuses.clear(); + hasUnscopedFailure = false; + derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; + break; + } + case 'operationRegistered': { + const { operationId } = envelope.payload as { operationId: string }; + operationStatuses.set(operationId, 'ready'); + updateDerivedOperationStatus(); + break; + } + case 'operationStatusChanged': { + const { operationId, status } = envelope.payload as { + operationId: string; + status: string; + }; + operationStatuses.set(operationId, status); + updateDerivedOperationStatus(); + break; + } + case 'diagnosticEmitted': { + const { severity } = envelope.payload as { severity?: string }; + if (severity === 'error' && envelope.scope?.operationId === undefined) { + hasUnscopedFailure = true; + updateDerivedOperationStatus(); + } + break; + } + case 'commandResult': { + const { succeeded, exitCode } = envelope.payload as { + succeeded: boolean; + exitCode: number; + }; + derivedExitStatus = resolveExitStatus({ + hasFailures: !succeeded || exitCode !== 0 + }); + break; + } + case 'commandCompleted': + case 'sessionCompleted': { + const { exitCode } = envelope.payload as { exitCode: number }; + derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + break; + } + default: + break; + } + } + + // Match the privacy behavior from #5990 without duplicating its reporter-package changes: + // only public envelopes contribute source, protocol, lifecycle, or diagnostic telemetry. + // Remove this outer gate after #5990 reaches shared main and the hardened subscriber is in this ancestry. + if (envelope.privacy === 'public') { + telemetrySubscriber.ingest(envelope); + } + }, + + buildTelemetryAggregate(): ITelemetryAggregate { + return telemetrySubscriber.buildAggregate(); + }, + + resolveExitStatus(): IRushExitStatus { + return derivedExitStatus; + }, + + correlateError(error: unknown, diagnosticId: string): void { + legacyErrorBridge.correlate(error, diagnosticId); + }, + + isErrorRepresented(error: unknown): boolean { + return legacyErrorBridge.shouldSuppressRendering(error); + } + }; +} + +function _createLifecycleEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + if (!state) { + return undefined; + } + + return new LifecycleEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined }); } @@ -181,7 +345,9 @@ export class RushSession { * source identity bound by Rush. */ public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { - return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedReporter( + scope ? { ...scope } : undefined + ); } /** @@ -193,7 +359,9 @@ export class RushSession { * available during the pre-major compatibility period. */ public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { - return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedLogger( + scope ? { ...scope } : undefined + ); } public registerCloudBuildCacheProviderFactory( @@ -248,7 +416,8 @@ export function _createRushSessionForPlugin( getSource: () => IReporterEventSource ): RushSession { const state: IRushSessionState = _getSessionState(rushSession); - if (!state.options.reporter) { + const reporting: IRushSessionReportingState | undefined = state.reporting; + if (!state.options.reporter || !reporting) { return rushSession; } @@ -264,7 +433,68 @@ export function _createRushSessionForPlugin( cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, cobuildLockProviderFactories: state.cobuildLockProviderFactories, hooks: state.hooks, - reporting: _createReporting(state.options.reporter, getSource()) + reporting: _createReporting(state.options.reporter, getSource(), reporting.observer) }); return pluginSession; } + +/** + * Creates a Rush-owned lifecycle emitter for internal command and operation paths. + * + * @internal + */ +export function _getRushSessionLifecycleEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); +} + +/** + * Returns the current allowlisted reporter telemetry projection. + * + * @internal + */ +export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITelemetryAggregate | undefined { + return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); +} + +/** + * Derives the shadow exit status without changing the authoritative process exit code. + * + * @internal + */ +export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +} + +/** + * Returns the Rush version bound to structured events for this session. + * + * @internal + */ +export function _getRushSessionReporterSourceVersion(rushSession: RushSession): string | undefined { + return _getSessionState(rushSession).reporting?.source.packageVersion; +} + +/** + * Correlates a legacy failure sentinel with an emitted structured diagnostic. + * + * @internal + */ +export function _correlateRushSessionError( + rushSession: RushSession, + error: unknown, + diagnosticId: string +): void { + _getSessionState(rushSession).reporting?.observer.correlateError(error, diagnosticId); +} + +/** + * Returns whether a failure is already represented by an emitted diagnostic or legacy sentinel. + * + * @internal + */ +export function _isRushSessionErrorRepresented(rushSession: RushSession, error: unknown): boolean { + return _getSessionState(rushSession).reporting?.observer.isErrorRepresented(error) ?? false; +} From 853470e64fa95ed8b3b4a803762e8dd99bd7be9f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:29:53 +0000 Subject: [PATCH 008/133] Complete shadow reporter parity coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...er-r3c-shadow-parity_2026-08-28-04-55.json | 11 ++ .../test/OperationGraphEventSink.test.ts | 86 ++++++++++++ .../src/pluginFramework/RushSession.test.ts | 123 +++++++++++++++++- .../src/pluginFramework/RushSession.ts | 22 ++-- 4 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json new file mode 100644 index 00000000000..a51b0ff8971 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3c-shadow-parity_2026-08-28-04-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Complete shadow reporter parity coverage for event identity, telemetry privacy, exit status, repeated operation phases, and unchanged legacy output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 9a9883dfdb7..e91029084c0 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -480,4 +480,90 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(countEvents('operationRegistered')).toBe(registrationCount); expect(countEvents('operationStatusChanged')).toBe(statusCount); }); + + it('keeps project x phase identities stable across repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + }); + const compilePhase: IPhase = { + ...mockPhase, + name: '_phase:compile', + logFilenameIdentifier: '_phase_compile' + }; + const testPhase: IPhase = { + ...mockPhase, + name: '_phase:test', + logFilenameIdentifier: '_phase_test' + }; + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation( + '@scope/project compile', + new MockOperationRunner('@scope/project (_phase:compile)'), + compilePhase, + '@scope/project' + ), + createOperation( + '@scope/project test', + new MockOperationRunner('@scope/project (_phase:test)'), + testPhase, + '@scope/project' + ) + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + const registrations: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' + ); + expect(registrations.map(({ scope }) => scope?.operationId)).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { + expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); + expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); + } + }); + + it('leaves stdout, stderr, and StreamCollator rendering byte-identical with shadow reporting', async () => { + const createOutputRunner = (): MockOperationRunner => + new MockOperationRunner('output', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('shadow parity stdout'); + terminal.writeStderrLine('shadow parity stderr'); + return OperationStatus.Success; + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + }); + const shadowWritable: MockWritable = new MockWritable(); + const shadowGraph: OperationGraph = new OperationGraph( + new Set([createOperation('output', createOutputRunner())]), + createGraphOptions(shadowWritable, false) + ); + attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + await shadowGraph.executeAsync({}); + expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index c4287f8d580..348663bf53d 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -7,7 +7,10 @@ import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, - IReporterEventSink + IReporterEventSink, + IResolveExitStatusFromEventsOptions, + IRushExitStatus, + LifecycleEmitter } from '@rushstack/rush-reporter'; import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; @@ -45,11 +48,16 @@ function createSession(reporter?: IRushSessionReporterOptions): RushSession { describe(RushSession.name, () => { it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { const session: RushSession = createSession(); + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: os.tmpdir() }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; expect(session.getReporter()).toBeUndefined(); expect(session.getScopedLogger()).toBeUndefined(); expect(session.getLogger('legacy')).toBeDefined(); expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + expect(action?.reporter).toBeUndefined(); }); it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { @@ -223,6 +231,100 @@ describe(RushSession.name, () => { }); }); + it('preserves event order, correlation, session identity, and trusted producer identity', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'ordered-session' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + })); + const sessionEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + const commandEmitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session, { + commandName: 'build' + })!; + const diagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED'); + const error: Error = new Error('represented'); + + sessionEmitter.emitSessionStarted({ rushVersion: Rush.version }); + commandEmitter.emitCommandStarted({ commandName: 'build' }); + pluginSession.getReporter({ commandName: 'build' })!.emitMessage({ + severity: 'info', + text: 'plugin message' + }); + commandEmitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + commandEmitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + commandEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 1 }); + sessionEmitter.emitSessionCompleted({ exitCode: 1 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'messageEmitted', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(new Set(sink.inputs.map(({ sessionId }) => sessionId))).toEqual(new Set(['ordered-session'])); + expect(sink.inputs[0].source).toMatchObject({ + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }); + expect(sink.inputs[2].source).toEqual({ + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }); + expect(sink.inputs[3].payload).toMatchObject({ diagnosticId: diagnostic.diagnosticId }); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + }); + + it('derives legacy-compatible exit status for success, warnings, failures, cancellation, and errors', () => { + const derive = ( + emitEvents: (emitter: LifecycleEmitter) => void, + options?: IResolveExitStatusFromEventsOptions + ): IRushExitStatus => { + const session: RushSession = createSession({ + eventSink: new CapturingSink(), + sessionId: 'exit-session' + }); + emitEvents(_getRushSessionLifecycleEmitter(session, { commandName: 'build' })!); + return _getRushSessionDerivedExitStatus(session, options)!; + }; + + expect( + derive((emitter) => { + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED', { severity: 'warning' })); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + }) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + + expect( + derive((emitter) => { + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'failure' }); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + + expect(derive(() => {}, { cancelled: true })).toEqual({ exitCode: 1, outcome: 'cancelled' }); + + for (const code of ['RUSH_CONFIG_INVALID_JSON', 'RUSH_INTERNAL_UNEXPECTED'] as const) { + expect( + derive((emitter) => { + emitter.emitDiagnostic(createRushDiagnostic(code)); + }) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + } + }); + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { const sink: CapturingSink = new CapturingSink(); const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); @@ -235,11 +337,28 @@ describe(RushSession.name, () => { severity: 'info', text: '/local/private/path' }); - _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + pluginSession.getReporter()!.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: 'private-secret-token', privacy: 'secret' } + } + }) + ); + const emitter: LifecycleEmitter = _getRushSessionLifecycleEmitter(session)!; + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=public-envelope-secret'] }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); const aggregate = _getRushSessionTelemetryAggregate(session)!; expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(JSON.stringify(aggregate)).not.toContain('private-secret-token'); + expect(JSON.stringify(aggregate)).not.toContain('--auth-token=public-envelope-secret'); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0 + }); expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index fa9771ccb08..e52cbb0ad02 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -8,12 +8,13 @@ import { RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, - resolveExitStatus, + resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IResolveExitStatusFromEventsOptions, type IRushExitStatus, type ITelemetryAggregate, type IScopedLogger, @@ -100,7 +101,7 @@ interface IRushSessionReportingState { interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; - resolveExitStatus(): IRushExitStatus; + resolveExitStatus(options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; correlateError(error: unknown, diagnosticId: string): void; isErrorRepresented(error: unknown): boolean; } @@ -182,7 +183,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve const hasOperationFailure: boolean = [...operationStatuses.values()].some( (status) => status === 'failure' || status === 'aborted' ); - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: hasUnscopedFailure || hasOperationFailure }); }; @@ -234,7 +235,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve succeeded: boolean; exitCode: number; }; - derivedExitStatus = resolveExitStatus({ + derivedExitStatus = resolveRushExitStatus({ hasFailures: !succeeded || exitCode !== 0 }); break; @@ -242,7 +243,7 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve case 'commandCompleted': case 'sessionCompleted': { const { exitCode } = envelope.payload as { exitCode: number }; - derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + derivedExitStatus = resolveRushExitStatus({ hasFailures: exitCode !== 0 }); break; } default: @@ -262,8 +263,8 @@ function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserve return telemetrySubscriber.buildAggregate(); }, - resolveExitStatus(): IRushExitStatus { - return derivedExitStatus; + resolveExitStatus(options: IResolveExitStatusFromEventsOptions = {}): IRushExitStatus { + return resolveRushExitStatus({ hasFailures: derivedExitStatus.exitCode !== 0, ...options }); }, correlateError(error: unknown, diagnosticId: string): void { @@ -464,8 +465,11 @@ export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITe * * @internal */ -export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { - return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +export function _getRushSessionDerivedExitStatus( + rushSession: RushSession, + options?: IResolveExitStatusFromEventsOptions +): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(options); } /** From 79b1e6e292955066a6decc31e3e6d965106c383f Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:32:11 +0000 Subject: [PATCH 009/133] Add reporter bootstrap handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 7 + apps/rush/src/RushCommandSelector.ts | 88 ++- apps/rush/src/RushFrontend.ts | 4 +- apps/rush/src/RushReporterHost.ts | 74 ++- apps/rush/src/RushVersionSelector.ts | 29 +- .../rush/src/test/RushCommandSelector.test.ts | 173 ++++++ apps/rush/src/test/RushFrontend.test.ts | 2 + apps/rush/src/test/RushReporterHost.test.ts | 95 ++- ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + common/reviews/api/rush-reporter.api.md | 7 + .../src/bootstrap/BootstrapProtocol.ts | 4 +- .../reporter/src/frontend/ReporterHost.ts | 63 +- libraries/reporter/src/index.ts | 6 +- .../reporter/src/test/ReporterHost.test.ts | 7 +- .../src/scripts/InstallRunRushBootstrap.ts | 572 ++++++++++++++++++ .../scripts/generated/BootstrapProtocol.ts | 45 ++ .../rush-lib/src/scripts/install-run-rush.ts | 73 ++- libraries/rush-lib/src/scripts/install-run.ts | 209 ++++++- .../test/InstallRunRushBootstrap.test.ts | 261 ++++++++ libraries/rush-lib/webpack.config.js | 57 +- 21 files changed, 1704 insertions(+), 94 deletions(-) create mode 100644 apps/rush/src/test/RushCommandSelector.test.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts create mode 100644 libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 920ae96235f..d14c1858da6 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -14,4 +14,11 @@ import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rus export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; + readonly reporterEnabled: boolean; + readonly reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 8d29eac6afa..0453811b4de 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -2,6 +2,14 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +import { + OldEngineOutputAdapter, + REPORTER_PROTOCOL_VERSION, + resolveReporterCompatibility, + type IReporterCompatibilityDecision +} from '@rushstack/rush-reporter'; import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; @@ -37,6 +45,37 @@ export class RushCommandSelector { } const commandName: CommandName = _getCommandName(); + const engineProtocolMajor: number | undefined = ( + Rush as typeof Rush & { readonly _reporterProtocolMajor?: number } + )._reporterProtocolMajor; + const compatibility: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: REPORTER_PROTOCOL_VERSION.major, hasManager: true }, + { + supportsStructuredSink: engineProtocolMajor !== undefined, + protocolMajor: engineProtocolMajor + } + ); + let effectiveOptions: IRushFrontendLaunchOptions = options; + if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); + } else if ( + compatibility.mode === 'old-frontend-new-engine' && + engineProtocolMajor !== undefined && + options.reporterEnabled + ) { + if (options.reporterSelectionReason === 'explicit --reporter') { + throw new Error( + `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + + `frontend supports major ${REPORTER_PROTOCOL_VERSION.major}. Update global Rush or use ` + + '--reporter=legacy.' + ); + } + effectiveOptions = { + ...options, + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }; + } if (commandName === 'rush-pnpm') { if (!Rush.launchRushPnpm) { @@ -56,13 +95,58 @@ export class RushCommandSelector { ` which does not support the "rushx" command` ); } - Rush.launchRushX(launcherVersion, options); + Rush.launchRushX(launcherVersion, effectiveOptions); } else { - Rush.launch(launcherVersion, options); + Rush.launch(launcherVersion, effectiveOptions); } } } +function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): void { + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: options.reporterEventSink, + sessionId: `rush_old_engine_${process.pid}`, + source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } + }); + const legacyWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); + _observeStream(process.stdout, 'stdout', adapter, legacyWrite); + _observeStream(process.stderr, 'stderr', adapter, legacyWrite); +} + +function _observeStream( + stream: NodeJS.WriteStream, + streamName: 'stdout' | 'stderr', + adapter: OldEngineOutputAdapter, + legacyWrite: typeof process.stderr.write +): void { + const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); + const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + if (markedStream[marker]) { + return; + } + markedStream[marker] = true; + + const decoder: StringDecoder = new StringDecoder('utf8'); + stream.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ): boolean => { + const text: string = + typeof chunk === 'string' + ? chunk + : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + if (text) { + adapter.capture(streamName, text); + } + if (typeof encodingOrCallback === 'function') { + return legacyWrite(chunk, encodingOrCallback); + } + return legacyWrite(chunk, encodingOrCallback, callback); + }) as typeof stream.write; +} + function _failWithError(message: string): never { throw new Error(message); } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 044a060d6b9..4c9dc4618dd 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -163,7 +163,9 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr eventSink: reporterHost.sink, sessionId }, - reporterCloseAsync + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterSelectionReason: reporterHost.selection.reason }; try { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index dfa9b84a7e4..0163b3b16aa 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,8 +23,12 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type IBootstrapReplayResult, type ReporterLogLevel, - type ReporterName + type ReporterName, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '@rushstack/rush-reporter'; export interface IRushReporterOutputStream { @@ -38,11 +42,15 @@ export interface IRushReporterHostOptions { readonly env?: Record; readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; } export interface IRushReporterSelection { @@ -57,7 +65,8 @@ export interface IRushReporterSelection { | 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' - | 'pre-major legacy default'; + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } export interface IInitializedRushReporterHost { @@ -65,6 +74,8 @@ export interface IInitializedRushReporterHost { readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -603,9 +614,23 @@ export async function initializeRushReporterHostAsync( options: IRushReporterHostOptions = {} ): Promise { const env: Record = options.env ?? process.env; - const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; - const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); - const host: ReporterHost = new ReporterHost({ env }); + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + columns: process.stdout.columns, + write: process.stdout.write.bind(process.stdout) + }; + const stderr: IRushReporterOutputStream = options.stderr ?? { + isTTY: process.stderr.isTTY, + columns: process.stderr.columns, + write: process.stderr.write.bind(process.stderr) + }; + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ + env, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); @@ -640,11 +665,48 @@ export async function initializeRushReporterHostAsync( } await host.manager.initializeAsync(); + let bootstrapReplay: IBootstrapReplayResult; + try { + bootstrapReplay = await host.replayBootstrapHandoffAsync(); + } finally { + delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + } + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); + if (bootstrapReplay.skipReason === 'incompatible-protocol') { + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + throw new Error( + 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); + } + let closePromise: Promise | undefined; return { host, - sink: host.getSink(), + sink, selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, closeAsync: (timeoutMs?: number) => { closePromise ??= host.manager.closeAsync(timeoutMs); return closePromise; diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 6e450e7aca0..077152d5444 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -39,16 +39,19 @@ export class RushVersionSelector { let installIsValid: boolean = await installMarker.isValidAsync(); if (!installIsValid) { // Need to install Rush - console.log(`Rush version ${version} is not currently installed. Installing...`); + this._reportStartupMessage( + executeOptions, + `Rush version ${version} is not currently installed. Installing...` + ); const resourceName: string = `rush-${version}`; - console.log(`Trying to acquire lock for ${resourceName}`); + this._reportStartupMessage(executeOptions, `Trying to acquire lock for ${resourceName}`); const lock: LockFile = await LockFile.acquireAsync(expectedRushPath, resourceName); installIsValid = await installMarker.isValidAsync(); if (installIsValid) { - console.log('Another process performed the installation.'); + this._reportStartupMessage(executeOptions, 'Another process performed the installation.'); } else { await Utilities.installPackageInDirectoryAsync({ directory: expectedRushPath, @@ -69,7 +72,10 @@ export class RushVersionSelector { filterNpmIncompatibleProperties: true }); - console.log(`Successfully installed Rush version ${version} in ${expectedRushPath}.`); + this._reportStartupMessage( + executeOptions, + `Successfully installed Rush version ${version} in ${expectedRushPath}.` + ); // If we've made it here without exception, write the flag file await installMarker.createAsync(); @@ -101,4 +107,19 @@ export class RushVersionSelector { RushCommandSelector.execute(this._currentPackageVersion, rushCliEntrypoint, executeOptions); } } + + private _reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { + if (options.reporterEnabled) { + options.reporterEventSink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: `rush_frontend_${process.pid}`, + source: { packageName: '@microsoft/rush', packageVersion: this._currentPackageVersion }, + privacy: 'public', + type: 'activityChanged', + payload: { kind: 'version-selection', text } + }); + } else { + console.log(text); + } + } } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 00000000000..3ec4e45375a --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; + +import { RushCommandSelector } from '../RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly events: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise {} + + public report(event: IReporterEventEnvelope): void { + this.events.push(event); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +describe(RushCommandSelector.name, () => { + it('keeps old-engine legacy output visible while bridging it to the frontend host', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + let visibleOutput: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stderr.write = ((text: string): boolean => { + visibleOutput += text; + return true; + }) as typeof process.stderr.write; + + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy engine output\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, options); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(visibleOutput).toBe('legacy engine output\n'); + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0]).toMatchObject({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'legacy engine output\n' } + }); + }); + + it('preserves a UTF-8 code point split across old-engine buffer writes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write(Buffer.from([0xe2])); + process.stdout.write(Buffer.from([0x82, 0xac])); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€' }); + }); + + it('fails an explicit reporter request for an incompatible new engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 2/ + ); + }); + + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + }); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 233b3d7e2d1..1ee58d32b16 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -40,6 +40,8 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'legacy', logLevel: 'normal', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fc3e630773e..77b3bf1a6a1 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -6,6 +6,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import { + BootstrapEventBuffer, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -68,9 +74,11 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'explicit --reporter' }); - expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( - /cannot enable the pre-major reporter path/ - ); + expect(resolve(['build'], { RUSH_REPORTER: 'json' })).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'pre-major legacy default' + }); }); it('uses deterministic non-agent selection for the repository experiment', () => { @@ -458,4 +466,85 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('replays and deletes a bootstrap handoff before returning the authoritative host', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: { rushVersion: '5.178.1' } }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.host.manager.flushAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(JSON.parse(stdoutText).type).toBe('sessionStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'activityChanged', payload: { text: 'installing Rush' } }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace(/"major":1/g, '"major":2')); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('installing Rush\nnpm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..0405adb2302 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a bounded nonce-protected install-run-rush handoff, replay it before version selection, and bridge cross-version reporter compatibility.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..aa1ab9990ea --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Preserve ordered legacy fallback output when a bootstrap handoff protocol is incompatible.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..342b21b8754 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -330,11 +330,18 @@ export interface IBootstrapHandoffWriteResult { readonly nonce: string; } +// @beta +export interface IBootstrapLegacyOutput { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; diff --git a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts index 84b72c09b78..7edcf861f82 100644 --- a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts +++ b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts @@ -74,8 +74,6 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string }); } -// END GENERATED BOOTSTRAP PROTOCOL - /** * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). * @@ -120,3 +118,5 @@ export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NO */ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = 'rush.reporter.buffer-truncated'; + +// END GENERATED BOOTSTRAP PROTOCOL diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 0b6acdd3787..770986282d0 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -11,10 +11,7 @@ import type { ReporterEventType } from '../events/ReporterEventType'; import type { IReporterEventSink } from '../producers/IReporterEventSink'; import { REPORTER_EVENT_TYPES } from '../events/ReporterEventType'; import { ReporterManager } from '../manager/ReporterManager'; -import { - REPORTER_PROTOCOL_VERSION, - isReporterProtocolCompatible -} from '../protocol/ReporterProtocol'; +import { REPORTER_PROTOCOL_VERSION, isReporterProtocolCompatible } from '../protocol/ReporterProtocol'; import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR @@ -101,7 +98,35 @@ export interface IBootstrapReplayResult { * The reason no events were replayed, when a handoff path was present. * `nonce-mismatch` means the file failed authentication and was rejected. */ - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: + | 'unreadable' + | 'invalid-path' + | 'nonce-mismatch' + | 'invalid-event' + | 'incompatible-protocol'; + + /** + * Ordered raw output that a legacy fallback can render when the handoff + * protocol is incompatible. + */ + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; +} + +/** + * A raw bootstrap write retained for legacy-visible fallback. + * + * @beta + */ +export interface IBootstrapLegacyOutput { + /** + * The original output stream. + */ + readonly stream: 'stdout' | 'stderr'; + + /** + * The unmodified output text. + */ + readonly text: string; } function isRecord(value: unknown): value is Record { @@ -143,6 +168,25 @@ function isReporterEventEnvelope(value: unknown): value is IReporterEventEnvelop ); } +function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOutput[] { + const output: IBootstrapLegacyOutput[] = []; + for (const event of events) { + if (!isRecord(event) || !isRecord(event.payload)) { + continue; + } + if ( + event.type === 'externalOutput' && + (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && + typeof event.payload.text === 'string' + ) { + output.push({ stream: event.payload.stream, text: event.payload.text }); + } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { + output.push({ stream: 'stdout', text: `${event.payload.text}\n` }); + } + } + return output; +} + /** * Hosts the authoritative {@link ReporterManager} in the frontend, before Rush * version selection. @@ -247,17 +291,16 @@ export class ReporterHost { let skippedEventCount: number = discardedRecordCount; for (const event of events) { const protocolVersion: IReporterProtocolVersion | undefined = getProtocolVersion(event); - if ( - protocolVersion && - !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion) - ) { + if (protocolVersion && !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion)) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'incompatible-protocol' + skipReason: 'incompatible-protocol', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } if (!isReporterEventEnvelope(event)) { diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f8..7cba1719773 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -138,7 +138,11 @@ export { export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; -export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; +export type { + IReporterHostOptions, + IBootstrapReplayResult, + IBootstrapLegacyOutput +} from './frontend/ReporterHost'; export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; export type { diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 208c458b761..4ebca154427 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -188,6 +188,7 @@ describe('ReporterHost handoff replay', () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); buffer.emit({ type: 'sessionStarted', payload: {} }); + buffer.addExternalOutput('stderr', 'legacy bootstrap output\n'); const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); @@ -204,6 +205,7 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toEqual([{ stream: 'stderr', text: 'legacy bootstrap output\n' }]); }); }); @@ -267,10 +269,7 @@ describe('ReporterHost handoff replay', () => { const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); await manager.flushAsync(); expect(result).toMatchObject({ replayed: true, eventCount: 2, skippedEventCount: 1 }); - expect(reporter.reported.map((event) => event.type)).toEqual([ - 'sessionStarted', - 'diagnosticEmitted' - ]); + expect(reporter.reported.map((event) => event.type)).toEqual(['sessionStarted', 'diagnosticEmitted']); }); }); diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts new file mode 100644 index 00000000000..fc86bc729e1 --- /dev/null +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -0,0 +1,572 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// IMPORTANT: This file is bundled into install-run-rush.js and must use only Node.js built-ins. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILogger } from '../utilities/npmrcUtilities'; +import { + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + BOOTSTRAP_PROTOCOL_MAJOR, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + encodeBootstrapEnvelope +} from './generated/BootstrapProtocol'; + +const TRUNCATION_NOTICE_RESERVE_BYTES: number = 512; +const BOOTSTRAP_HANDOFF_FILE_PREFIX: string = 'rush-reporter-bootstrap-'; +const BOOTSTRAP_HANDOFF_FILE_SUFFIX: string = '.ndjson'; +const SUPPORTED_REPORTERS: ReadonlySet = new Set([ + 'default', + 'ai', + 'json', + 'plaintext', + 'file', + 'legacy' +]); +const SUPPORTED_LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +type BootstrapStream = 'stdout' | 'stderr'; + +interface IBootstrapEventInput { + readonly type: string; + readonly privacy: 'public' | 'local-sensitive'; + readonly payload: unknown; +} + +interface IBufferedBootstrapEntry { + readonly line: string; + readonly bytes: number; + readonly required: boolean; + readonly fallbackWrite?: IFallbackWrite; +} + +interface IFallbackWrite { + readonly stream: BootstrapStream; + readonly text: string; +} + +export interface IInstallRunRushBootstrapOptions { + readonly argv: readonly string[]; + readonly env: Record; + readonly rushJsonFolder: string; + readonly rushVersion: string; + readonly bootstrapVersion: string; + readonly commandName: 'rush' | 'rush-pnpm' | 'rushx'; + readonly quiet: boolean; + readonly stdout?: (text: string) => void; + readonly stderr?: (text: string) => void; + readonly handoffDirectory?: string; + readonly maxBytes?: number; + readonly now?: () => string; + readonly randomUUID?: () => string; +} + +export interface IInstallRunRushBootstrap { + readonly enabled: boolean; + readonly logger: ILogger; + readonly externalOutputCaptureMaxBytes: number | undefined; + readonly externalOutputHandler: ((stream: BootstrapStream, text: string) => void) | undefined; + readonly externalOutputOverflowHandler: (() => void) | undefined; + readonly prepareToRun: (() => void) | undefined; +} + +function readSingleFlagValue(argv: readonly string[], flag: string): string | undefined { + let result: string | undefined; + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + let value: string | undefined; + if (argument.startsWith(prefix)) { + value = argument.slice(prefix.length); + } else if (argument === flag) { + value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + index++; + } + + if (value !== undefined) { + if (!value) { + throw new Error(`${flag} requires a value.`); + } + if (result !== undefined) { + throw new Error(`${flag} may be specified only once.`); + } + result = value; + } + } + return result; +} + +function repositoryUsesRushReporter(rushJsonFolder: string): boolean { + const experimentsPath: string = path.join(rushJsonFolder, 'common', 'config', 'rush', 'experiments.json'); + let contents: string; + try { + contents = fs.readFileSync(experimentsPath, 'utf8'); + } catch (error) { + const code: unknown = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return false; + } + throw error; + } + + const matches: RegExpMatchArray[] = [ + ...stripJsonComments(contents).matchAll(/"useRushReporter"\s*:\s*(true|false)/g) + ]; + return matches.length > 0 && matches[matches.length - 1][1] === 'true'; +} + +function stripJsonComments(text: string): string { + let result: string = ''; + let inString: boolean = false; + let escaped: boolean = false; + let lineComment: boolean = false; + let blockComment: boolean = false; + + for (let index: number = 0; index < text.length; index++) { + const character: string = text[index]; + const nextCharacter: string | undefined = text[index + 1]; + if (lineComment) { + if (character === '\n' || character === '\r') { + lineComment = false; + result += character; + } + continue; + } + if (blockComment) { + if (character === '*' && nextCharacter === '/') { + blockComment = false; + index++; + } else if (character === '\n' || character === '\r') { + result += character; + } + continue; + } + if (inString) { + result += character; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (character === '/' && nextCharacter === '/') { + lineComment = true; + index++; + } else if (character === '/' && nextCharacter === '*') { + blockComment = true; + index++; + } else { + result += character; + } + } + return result; +} + +interface IParsedVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[] | undefined; +} + +function parseVersion(version: string): IParsedVersion | undefined { + const match: RegExpMatchArray | null = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split('.') + }; +} + +function comparePrerelease( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): number { + if (!left) { + return right ? 1 : 0; + } + if (!right) { + return -1; + } + const length: number = Math.max(left.length, right.length); + for (let index: number = 0; index < length; index++) { + const leftPart: string | undefined = left[index]; + const rightPart: string | undefined = right[index]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + const leftNumeric: boolean = /^\d+$/.test(leftPart); + const rightNumeric: boolean = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) { + return Number(leftPart) - Number(rightPart); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function supportsBootstrapHandoff(rushVersion: string, bootstrapVersion: string): boolean { + const rush: IParsedVersion | undefined = parseVersion(rushVersion); + const bootstrap: IParsedVersion | undefined = parseVersion(bootstrapVersion); + if (!rush || !bootstrap) { + return false; + } + for (let index: number = 0; index < rush.core.length; index++) { + if (rush.core[index] !== bootstrap.core[index]) { + return rush.core[index] > bootstrap.core[index]; + } + } + return comparePrerelease(rush.prerelease, bootstrap.prerelease) >= 0; +} + +function* chunkUtf8Text(text: string, maxChunkBytes: number): Iterable { + let chunkStart: number = 0; + let chunkBytes: number = 0; + let offset: number = 0; + + while (offset < text.length) { + const codePoint: number = text.codePointAt(offset)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (chunkBytes > 0 && chunkBytes + codePointBytes > maxChunkBytes) { + yield text.slice(chunkStart, offset); + chunkStart = offset; + chunkBytes = 0; + } + chunkBytes += codePointBytes; + offset += codeUnits; + } + + if (chunkStart < text.length) { + yield text.slice(chunkStart); + } +} + +class InstallRunRushBootstrap implements IInstallRunRushBootstrap { + public readonly enabled: boolean = true; + public readonly logger: ILogger; + public readonly externalOutputCaptureMaxBytes: number; + public readonly externalOutputHandler: (stream: BootstrapStream, text: string) => void; + public readonly externalOutputOverflowHandler: () => void; + public readonly prepareToRun: () => void; + + private readonly _entries: IBufferedBootstrapEntry[]; + private readonly _env: Record; + private readonly _stdout: (text: string) => void; + private readonly _stderr: (text: string) => void; + private readonly _handoffDirectory: string; + private readonly _maxBytes: number; + private readonly _now: () => string; + private readonly _randomUUID: () => string; + private readonly _sessionId: string; + private readonly _sourceVersion: string; + private readonly _entryLimit: number; + private _usedBytes: number; + private _nextSequence: number; + private _nextEventNumber: number; + private _droppedReplaceable: number; + private _droppedRequired: number; + private _failureFlushed: boolean; + + public constructor(options: IInstallRunRushBootstrapOptions) { + this._entries = []; + this._env = options.env; + this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + this._stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); + this._maxBytes = options.maxBytes ?? BOOTSTRAP_BUFFER_MAX_BYTES; + this._now = options.now ?? (() => new Date().toISOString()); + this._randomUUID = options.randomUUID ?? (() => crypto.randomUUID()); + this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; + this._sourceVersion = options.bootstrapVersion; + this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + if (this._entryLimit <= 0) { + throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); + } + this._usedBytes = 0; + this._nextSequence = 1; + this._nextEventNumber = 1; + this._droppedReplaceable = 0; + this._droppedRequired = 0; + this._failureFlushed = false; + this.externalOutputCaptureMaxBytes = this._maxBytes; + this._addEvent({ + type: 'sessionStarted', + privacy: 'public', + payload: { rushVersion: options.rushVersion, cwd: process.cwd() } + }); + this._addEvent({ + type: 'commandStarted', + privacy: 'public', + payload: { commandName: options.argv[0] ?? 'unknown', argv: options.argv } + }); + + this.logger = { + info: (text: string) => { + this._addEvent( + { + type: 'activityChanged', + privacy: 'public', + payload: { kind: 'bootstrap', text } + }, + { stream: 'stdout', text: `${text}\n` } + ); + }, + error: (text: string) => { + const droppedRequiredBefore: number = this._droppedRequired; + this._addExternalOutput('stderr', `${text}\n`); + this._flushFailureOutput(); + if (this._droppedRequired > droppedRequiredBefore) { + this._stderr(`${text}\n`); + } + } + }; + this.externalOutputHandler = (stream: BootstrapStream, text: string) => { + this._addExternalOutput(stream, text); + }; + this.externalOutputOverflowHandler = () => { + this._droppedRequired++; + }; + this.prepareToRun = () => { + this._writeHandoff(); + }; + } + + private _addEvent(event: IBootstrapEventInput, fallbackWrite?: IFallbackWrite): void { + const required: boolean = event.type !== 'activityChanged'; + const line: string = encodeBootstrapEnvelope({ + eventId: `boot_${this._nextEventNumber++}`, + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: event.privacy, + required, + type: event.type, + payload: event.payload + }); + const bytes: number = Buffer.byteLength(line, 'utf8') + 1; + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + return; + } + + if (!required) { + this._droppedReplaceable++; + return; + } + + for ( + let index: number = 0; + this._usedBytes + bytes > this._entryLimit && index < this._entries.length; + + ) { + const entry: IBufferedBootstrapEntry = this._entries[index]; + if (entry.required) { + index++; + } else { + this._entries.splice(index, 1); + this._usedBytes -= entry.bytes; + this._droppedReplaceable++; + } + } + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + } else { + this._droppedRequired++; + } + } + + private _addExternalOutput(stream: BootstrapStream, text: string): void { + if (!text) { + return; + } + for (const chunk of chunkUtf8Text(text, BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES)) { + this._addEvent( + { + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream, text: chunk } + }, + { stream, text: chunk } + ); + } + } + + private _flushFailureOutput(): void { + if (this._failureFlushed) { + return; + } + this._failureFlushed = true; + for (const entry of this._entries) { + const write: IFallbackWrite | undefined = entry.fallbackWrite; + if (write) { + (write.stream === 'stdout' ? this._stdout : this._stderr)(write.text); + } + } + } + + private _writeHandoff(): void { + const serialized: string = this._serializeEvents(); + const nonce: string = this._randomUUID(); + const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${process.pid}-${nonce}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`; + const handoffPath: string = path.join(this._handoffDirectory, fileName); + fs.mkdirSync(this._handoffDirectory, { recursive: true }); + fs.writeFileSync(handoffPath, `${JSON.stringify({ kind: 'bootstrapHandoff', nonce })}\n${serialized}`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }); + if (process.platform !== 'win32') { + fs.chmodSync(handoffPath, 0o600); + } + this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + } + + private _serializeEvents(): string { + const truncated: boolean = this._droppedReplaceable + this._droppedRequired > 0; + if (truncated) { + const notice: string = encodeBootstrapEnvelope({ + eventId: 'boot_bufferTruncated', + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: 'public', + required: true, + type: 'extension', + payload: { + name: BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + droppedReplaceable: this._droppedReplaceable, + droppedOther: 0, + droppedRequired: this._droppedRequired, + failed: this._droppedRequired > 0 + } + }); + if (Buffer.byteLength(notice, 'utf8') + 1 > TRUNCATION_NOTICE_RESERVE_BYTES) { + throw new Error('The bootstrap truncation notice exceeded its reserved capacity.'); + } + this._entries.push({ + line: notice, + bytes: Buffer.byteLength(notice, 'utf8') + 1, + required: true + }); + } + + if (this._droppedRequired > 0) { + throw new Error( + `The Rush reporter bootstrap buffer exceeded ${this._maxBytes} bytes and could not preserve ` + + `${this._droppedRequired} required event(s).` + ); + } + + return this._entries.length > 0 + ? `${this._entries.map((entry: IBufferedBootstrapEntry) => entry.line).join('\n')}\n` + : ''; + } +} + +function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { + const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + return { + enabled: false, + logger: options.quiet + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`) } + : { + info: (text: string) => stdout(`${text}\n`), + error: (text: string) => stderr(`${text}\n`) + }, + externalOutputHandler: undefined, + externalOutputCaptureMaxBytes: undefined, + externalOutputOverflowHandler: undefined, + prepareToRun: undefined + }; +} + +export function createInstallRunRushBootstrap( + options: IInstallRunRushBootstrapOptions +): IInstallRunRushBootstrap { + if (BOOTSTRAP_PROTOCOL_MAJOR < 1) { + throw new Error('The generated Rush reporter bootstrap protocol is invalid.'); + } + delete options.env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete options.env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + + if (options.commandName !== 'rush') { + return createLegacyBootstrap(options); + } + + const environmentReporter: string | undefined = options.env.RUSH_REPORTER?.trim().toLowerCase(); + if (environmentReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const explicitReporter: string | undefined = readSingleFlagValue(options.argv, '--reporter'); + const explicitLogLevel: string | undefined = readSingleFlagValue(options.argv, '--log-level'); + if (explicitReporter !== undefined && !SUPPORTED_REPORTERS.has(explicitReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(explicitReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + if (explicitLogLevel !== undefined && !SUPPORTED_LOG_LEVELS.has(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + + if (explicitReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const repositoryOptIn: boolean = repositoryUsesRushReporter(options.rushJsonFolder); + const explicitOptIn: boolean = explicitReporter !== undefined; + if (!explicitOptIn && !repositoryOptIn) { + return createLegacyBootstrap(options); + } + + if (!supportsBootstrapHandoff(options.rushVersion, options.bootstrapVersion)) { + if (explicitOptIn) { + throw new Error( + `Rush version ${options.rushVersion} does not support the reporter bootstrap requested by ` + + `${JSON.stringify(`--reporter=${explicitReporter}`)}. Update the repository Rush version or ` + + 'use --reporter=legacy.' + ); + } + return createLegacyBootstrap(options); + } + + return new InstallRunRushBootstrap(options); +} diff --git a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts index 97bfe28545b..82c6a588674 100644 --- a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts +++ b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts @@ -70,3 +70,48 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string payload: input.payload === undefined ? {} : input.payload }); } + +/** + * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). + * + * @beta + */ +export const BOOTSTRAP_BUFFER_MAX_BYTES: number = 1024 * 1024; + +/** + * The maximum size of a single raw external-output chunk, in bytes (64 KiB). + * + * @beta + */ +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number = 64 * 1024; + +/** + * The private environment variable used to hand the bootstrap NDJSON file path + * to the installed frontend. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF' = + '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + +/** + * The private environment variable carrying the one-time nonce that must match + * the handoff file's header line. + * + * @remarks + * The nonce proves the handoff file was written by the same bootstrap process + * that set the environment variable: a stale or foreign handoff file (same + * temp directory, different invocation) is rejected rather than replayed. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE' = + '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + +/** + * The namespaced extension event name that describes bootstrap buffer truncation. + * + * @beta + */ +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = + 'rush.reporter.buffer-truncated'; diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index 1bb7b29d0c5..d0fd5eb5edb 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -7,14 +7,13 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import type { ILogger } from '../utilities/npmrcUtilities'; +import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap } from './InstallRunRushBootstrap'; import { BOOTSTRAP_PROTOCOL_MAJOR, encodeBootstrapEnvelope } from './generated/BootstrapProtocol'; -const { - installAndRun, - findRushJsonFolder, - RUSH_JSON_FILENAME, - runWithErrorAndStatusCode -}: typeof import('./install-run') = __non_webpack_require__('./install-run'); +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME }: typeof import('./install-run') = + __non_webpack_require__('./install-run'); + +declare const RUSH_LIB_VERSION_FOR_BOOTSTRAP: string; const PACKAGE_NAME: string = '@microsoft/rush'; const RUSH_PREVIEW_VERSION: string = 'RUSH_PREVIEW_VERSION'; @@ -28,11 +27,13 @@ function _validateBundledBootstrapProtocol(): void { } } -function _getRushVersion(logger: ILogger): string { +function _getRushVersion(): { readonly version: string; readonly sourceMessage?: string } { const rushPreviewVersion: string | undefined = process.env[RUSH_PREVIEW_VERSION]; if (rushPreviewVersion !== undefined) { - logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); - return rushPreviewVersion; + return { + version: rushPreviewVersion, + sourceMessage: `Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}` + }; } const rushJsonFolder: string = findRushJsonFolder(); @@ -44,7 +45,7 @@ function _getRushVersion(logger: ILogger): string { const rushJsonMatches: string[] = rushJsonContents.match( /\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/ )!; - return rushJsonMatches[1]; + return { version: rushJsonMatches[1] }; } catch (e) { throw new Error( `Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + @@ -54,7 +55,7 @@ function _getRushVersion(logger: ILogger): string { } } -function _getBin(scriptName: string): string { +function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { switch (scriptName.toLowerCase()) { case 'install-run-rush-pnpm.js': return 'rush-pnpm'; @@ -77,7 +78,7 @@ function _run(): void { // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the // appropriate binary inside the rush package to run const scriptName: string = path.basename(scriptPath); - const bin: string = _getBin(scriptName); + const bin: 'rush' | 'rush-pnpm' | 'rushx' = _getBin(scriptName); if (!nodePath || !scriptPath) { throw new Error('Unexpected exception: could not detect node path or script path'); } @@ -115,13 +116,25 @@ function _run(): void { process.exit(1); } - const logger: ILogger = quiet - ? { info: () => {}, error: console.error } - : { info: console.log, error: console.error }; - - runWithErrorAndStatusCode(logger, () => { - const version: string = _getRushVersion(logger); - logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); + let bootstrap: IInstallRunRushBootstrap | undefined; + process.exitCode = 1; + try { + bootstrap = createInstallRunRushBootstrap({ + argv: packageBinArgs, + env: process.env, + rushJsonFolder, + rushVersion: rushVersion.version, + bootstrapVersion: RUSH_LIB_VERSION_FOR_BOOTSTRAP, + commandName: bin, + quiet + }); + const logger: ILogger = bootstrap.logger; + if (rushVersion.sourceMessage) { + logger.info(rushVersion.sourceMessage); + } + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${rushVersion.version}`); const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { @@ -130,8 +143,26 @@ function _run(): void { ); } - return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); - }); + process.exitCode = installAndRun( + logger, + PACKAGE_NAME, + rushVersion.version, + bin, + packageBinArgs, + lockFilePath, + { + onExternalOutput: bootstrap.externalOutputHandler, + onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, + externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + prepareToRun: bootstrap.prepareToRun + } + ); + } catch (error) { + const logger: ILogger = + bootstrap?.logger ?? + (quiet ? { info: () => {}, error: console.error } : { info: console.log, error: console.error }); + logger.error(`\n\n${String(error)}\n`); + } } _run(); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 7f568566485..84e350938a0 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -7,6 +7,7 @@ import * as childProcess from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import type { IPackageJson } from '@rushstack/node-core-library'; @@ -20,6 +21,61 @@ const INSTALL_RUN_LOCKFILE_PATH_VARIABLE: 'INSTALL_RUN_LOCKFILE_PATH' = 'INSTALL const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; +let _externalOutputCaptureId: number = 0; +const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const { StringDecoder } = require('node:string_decoder'); +const [command, argsJson, capturePath, useShell, maxBytesText] = process.argv.slice(1); +const child = childProcess.spawn(command, JSON.parse(argsJson), { + cwd: process.cwd(), + env: process.env, + shell: useShell === '1', + windowsVerbatimArguments: false, + stdio: ['inherit', 'pipe', 'pipe'] +}); +const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }; +const maxBytes = Number(maxBytesText); +let capturedBytes = 0; +let overflowed = false; +function capture(stream, text) { + if (!text || overflowed) { + return; + } + const record = JSON.stringify({ stream, text }) + '\\n'; + const recordBytes = Buffer.byteLength(record); + if (capturedBytes + recordBytes <= maxBytes) { + fs.appendFileSync(capturePath, record); + capturedBytes += recordBytes; + } else { + overflowed = true; + fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); + } +} +child.stdout.on('data', (chunk) => capture('stdout', decoders.stdout.write(chunk))); +child.stderr.on('data', (chunk) => capture('stderr', decoders.stderr.write(chunk))); +child.on('error', (error) => { + process.stderr.write(String(error) + '\\n'); + process.exitCode = 1; +}); +child.on('close', (code, signal) => { + capture('stdout', decoders.stdout.end()); + capture('stderr', decoders.stderr.end()); + if (signal) { + process.stderr.write('npm was terminated by signal: ' + signal + '\\n'); + process.exitCode = 1; + } else { + process.exitCode = code === null ? 1 : code; + } +}); +`; + +export interface IInstallAndRunOptions { + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string) => void; + readonly onExternalOutputOverflow?: () => void; + readonly externalOutputCaptureMaxBytes?: number; + readonly prepareToRun?: () => void; +} /** * Parse a package specifier (in the form of name\@version) into name and version parts. @@ -352,22 +408,102 @@ function _installPackage( packageInstallFolder: string, name: string, version: string, - npmCommand: 'install' | 'ci' + npmCommand: 'install' | 'ci', + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined, + onExternalOutputOverflow: (() => void) | undefined, + externalOutputCaptureMaxBytes: number | undefined ): void { + let capturePath: string | undefined; try { logger.info(`Installing ${name}...`); - _runNpmConfirmSuccess( - [npmCommand], - { - stdio: 'inherit', - cwd: packageInstallFolder, - env: process.env - }, - `npm ${npmCommand}` - ); - logger.info(`Successfully installed ${name}@${version}`); + if (onExternalOutput) { + capturePath = path.join( + packageInstallFolder, + `.install-run-output-${process.pid}-${_externalOutputCaptureId++}.log` + ); + fs.closeSync(fs.openSync(capturePath, 'wx', 0o600)); + } + if (capturePath) { + _runNpmWithCaptureConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + capturePath, + externalOutputCaptureMaxBytes ?? 1024 * 1024, + `npm ${npmCommand}` + ); + } else { + _runNpmConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + `npm ${npmCommand}` + ); + } } catch (e) { throw new Error(`Unable to install package: ${e}`); + } finally { + if (capturePath !== undefined) { + try { + _readCapturedNpmOutput(capturePath, onExternalOutput!, onExternalOutputOverflow); + } finally { + _deleteFile(capturePath); + } + } + } + logger.info(`Successfully installed ${name}@${version}`); +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string) => void, + onExternalOutputOverflow: (() => void) | undefined +): void { + const fileDescriptor: number = fs.openSync(capturePath, 'r'); + const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); + const decoder: StringDecoder = new StringDecoder('utf8'); + let pending: string = ''; + try { + for (;;) { + const bytesRead: number = fs.readSync(fileDescriptor, buffer, 0, buffer.length, null); + if (bytesRead === 0) { + break; + } + pending += decoder.write(buffer.subarray(0, bytesRead)); + let newlineIndex: number; + while ((newlineIndex = pending.indexOf('\n')) >= 0) { + const line: string = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (line) { + const record: { stream?: unknown; text?: unknown; overflow?: unknown } = JSON.parse(line); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else if ( + (record.stream === 'stdout' || record.stream === 'stderr') && + typeof record.text === 'string' + ) { + onExternalOutput(record.stream, record.text); + } + } + } + } + pending += decoder.end(); + if (pending.trim()) { + const record: { overflow?: unknown } = JSON.parse(pending); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else { + throw new Error('The npm output capture ended with an incomplete record.'); + } + } + } finally { + fs.closeSync(fileDescriptor); } } @@ -417,7 +553,41 @@ function _runNpmConfirmSuccess( } else { result = childProcess.spawnSync(command, args, options); } + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} +function _runNpmWithCaptureConfirmSuccess( + args: string[], + options: childProcess.SpawnSyncOptions, + capturePath: string, + captureMaxBytes: number, + commandNameForLogging: string +): childProcess.SpawnSyncReturns { + const npmPath: string = getNpmPath(); + const command: string = IS_WINDOWS ? _buildShellCommand(npmPath, args) : npmPath; + const commandArgs: string[] = IS_WINDOWS ? [] : args; + const result: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + command, + JSON.stringify(commandArgs), + capturePath, + IS_WINDOWS ? '1' : '0', + String(captureMaxBytes) + ], + options + ); + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} + +function _throwIfSpawnFailed( + result: childProcess.SpawnSyncReturns, + commandNameForLogging: string +): void { if (result.status !== 0) { if (!result.status) { // Is status null or undefined? @@ -432,8 +602,6 @@ function _runNpmConfirmSuccess( throw new Error(`"${commandNameForLogging}" returned error code ${result.status}`); } } - - return result; } export function installAndRun( @@ -442,7 +610,8 @@ export function installAndRun( packageVersion: string, packageBinName: string, packageBinArgs: string[], - lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE] + lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE], + options: IInstallAndRunOptions = {} ): number { const rushJsonFolder: string = findRushJsonFolder(); const rushCommonFolder: string = path.join(rushJsonFolder, 'common'); @@ -470,13 +639,23 @@ export function installAndRun( _createPackageJson(packageInstallFolder, packageName, packageVersion); const installCommand: 'install' | 'ci' = lockFilePath ? 'ci' : 'install'; - _installPackage(logger, packageInstallFolder, packageName, packageVersion, installCommand); + _installPackage( + logger, + packageInstallFolder, + packageName, + packageVersion, + installCommand, + options.onExternalOutput, + options.onExternalOutputOverflow, + options.externalOutputCaptureMaxBytes + ); _writeFlagFile(packageInstallFolder); } const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + options.prepareToRun?.(); const binPath: string = _getBinPath(packageInstallFolder, packageBinName); const binFolderPath: string = path.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts new file mode 100644 index 00000000000..443d63a14c7 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + createInstallRunRushBootstrap, + type IInstallRunRushBootstrap, + type IInstallRunRushBootstrapOptions +} from '../InstallRunRushBootstrap'; +import { + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR +} from '../generated/BootstrapProtocol'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +function makeOptions( + directory: string, + overrides: Partial = {} +): { + readonly options: IInstallRunRushBootstrapOptions; + readonly env: Record; + readonly stdout: string[]; + readonly stderr: string[]; +} { + const env: Record = {}; + const stdout: string[] = []; + const stderr: string[] = []; + return { + env, + stdout, + stderr, + options: { + argv: ['build'], + env, + rushJsonFolder: directory, + rushVersion: '5.178.1', + bootstrapVersion: '5.178.1', + commandName: 'rush', + quiet: false, + stdout: (text: string) => stdout.push(text), + stderr: (text: string) => stderr.push(text), + handoffDirectory: directory, + now: () => '2026-08-28T00:00:00.000Z', + randomUUID: (() => { + let index: number = 0; + return () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}`; + })(), + ...overrides + } + }; +} + +function readHandoff(env: Record): { + readonly path: string; + readonly records: Record[]; +} { + const handoffPath: string | undefined = env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + if (!handoffPath) { + throw new Error('Expected a bootstrap handoff path.'); + } + const records: Record[] = fs + .readFileSync(handoffPath, 'utf8') + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + return { path: handoffPath, records }; +} + +describe(createInstallRunRushBootstrap.name, () => { + it('preserves direct legacy bootstrap output without an opt-in', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory); + env.RUSH_REPORTER = 'unsupported-automatic-value'; + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('legacy startup'); + bootstrap.prepareToRun?.(); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['legacy startup\n']); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + }); + }); + + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory, { + argv: ['build', '--reporter=json', '--log-level=debug'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('resolving Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n'); + bootstrap.logger.info('invoking Rush'); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + expect(bootstrap.enabled).toBe(true); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( + (handoff.records[0] as { nonce?: string }).nonce + ); + expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'activityChanged', + 'externalOutput', + 'activityChanged' + ]); + expect((handoff.records[4].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + if (process.platform !== 'win32') { + expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); + } + }); + }); + + it('uses repository opt-in but safely falls back for an old frontend', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + '{ "useRushReporter": true }\n' + ); + const { options, stdout } = makeOptions(directory, { rushVersion: '5.177.0' }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('old frontend startup'); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['old frontend startup\n']); + }); + }); + + it('ignores a commented hypothetical repository opt-in', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + [ + '{', + ' // "useRushReporter": true,', + ' "exampleUrl": "https://example.test/*not-a-comment*/"', + '}' + ].join('\n') + ); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap( + makeOptions(directory).options + ); + + expect(bootstrap.enabled).toBe(false); + }); + }); + + it('does not treat an older prerelease of the bootstrap version as compatible', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.178.1-dev.1', + bootstrapVersion: '5.178.1-dev.10' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('fails unsupported explicit requests and explicit requests for an old frontend', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { argv: ['build', '--reporter=unknown'] }).options + ) + ).toThrow(/Unsupported reporter/); + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.177.0' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('honors the legacy emergency override before validating reporter controls', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=unknown', '--log-level=invalid'] + }); + env.RUSH_REPORTER = ' LEGACY '; + + expect(createInstallRunRushBootstrap(options).enabled).toBe(false); + }); + }); + + it('truncates replaceable startup status with a required marker', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 50; index++) { + bootstrap.logger.info(`status ${index} ${'x'.repeat(80)}`); + } + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const eventRecords: Record[] = handoff.records.slice(1); + const marker: Record = eventRecords[eventRecords.length - 1]; + expect(marker.type).toBe('extension'); + expect((marker.payload as { name: string }).name).toBe(BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME); + expect((marker.payload as { droppedReplaceable: number }).droppedReplaceable).toBeGreaterThan(0); + expect( + Buffer.byteLength(fs.readFileSync(handoff.path, 'utf8').split('\n').slice(1).join('\n'), 'utf8') + ).toBeLessThanOrEqual(1800); + }); + }); + + it('fails instead of dropping required external output', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000)); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + bootstrap.logger.error('bootstrap failed'); + + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(stderr.join('')).toContain('bootstrap failed'); + }); + }); + + it('fails when the npm capture reports overflow before replay', async () => { + await withTempDir(async (directory: string) => { + const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputOverflowHandler?.(); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + }); + }); +}); diff --git a/libraries/rush-lib/webpack.config.js b/libraries/rush-lib/webpack.config.js index 6952beba9dc..fd758205ace 100644 --- a/libraries/rush-lib/webpack.config.js +++ b/libraries/rush-lib/webpack.config.js @@ -116,32 +116,39 @@ module.exports = () => { } } ), - generateConfiguration({ - [PathConstants.pnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.subspacePnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushxScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, - ...SCRIPT_ENTRY_OPTIONS + generateConfiguration( + { + [PathConstants.pnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.subspacePnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushxScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushPnpmScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, + ...SCRIPT_ENTRY_OPTIONS + } }, - [PathConstants.installRunRushPnpmScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, - ...SCRIPT_ENTRY_OPTIONS - } - }) + [ + new webpack.DefinePlugin({ + RUSH_LIB_VERSION_FOR_BOOTSTRAP: JSON.stringify(packageJson.version) + }) + ] + ) ]; return configurations; From 5041c96162c8b91ff271323ec64f760d5e361ef1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:52:39 +0000 Subject: [PATCH 010/133] Fix reporter bootstrap compatibility failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushCommandSelector.ts | 12 +- apps/rush/src/RushReporterHost.ts | 154 ++++++++++-------- .../rush/src/test/RushCommandSelector.test.ts | 55 ++++++- apps/rush/src/test/RushReporterHost.test.ts | 127 +++++++++++++++ common/reviews/api/rush-reporter.api.md | 3 +- .../reporter/src/frontend/ReporterHost.ts | 33 +++- .../reporter/src/test/ReporterHost.test.ts | 40 ++++- 7 files changed, 342 insertions(+), 82 deletions(-) diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 0453811b4de..f486016b4da 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import { + LegacyFallbackSink, OldEngineOutputAdapter, REPORTER_PROTOCOL_VERSION, resolveReporterCompatibility, @@ -56,13 +57,7 @@ export class RushCommandSelector { } ); let effectiveOptions: IRushFrontendLaunchOptions = options; - if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { - _observeOldEngineOutput(options, Rush.version); - } else if ( - compatibility.mode === 'old-frontend-new-engine' && - engineProtocolMajor !== undefined && - options.reporterEnabled - ) { + if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { if (options.reporterSelectionReason === 'explicit --reporter') { throw new Error( `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + @@ -72,9 +67,12 @@ export class RushCommandSelector { } effectiveOptions = { ...options, + reporterEventSink: new LegacyFallbackSink(), reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }; + } else if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); } if (commandName === 'rush-pnpm') { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 0163b3b16aa..2944e364fb7 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -624,92 +624,104 @@ export async function initializeRushReporterHostAsync( columns: process.stderr.columns, write: process.stderr.write.bind(process.stderr) }; - let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); const host: ReporterHost = new ReporterHost({ env, handoffDirectory: options.handoffDirectory, retentionMs: options.handoffRetentionMs, nowMs: options.nowMs }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; - if (selection.enabled) { - const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); - if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' - }); + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + }); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' ) { - host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); - } - - for (const output of selection.outputs) { - const outputLogLevel: ReporterLogLevel = - output.params.logLevel && isSupportedLogLevel(output.params.logLevel) - ? output.params.logLevel - : output.reporter === 'file' - ? 'debug' - : selection.logLevel; - host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { - destination: output.target - }); + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + const incompatibility: string = + bootstrapReplay.skipReason === 'incompatible-protocol' + ? 'protocol is incompatible' + : 'contains an unsupported required event'; + throw new Error( + `The install-run-rush bootstrap reporter ${incompatibility} with this Rush frontend. ` + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); } - } - await host.manager.initializeAsync(); - let bootstrapReplay: IBootstrapReplayResult; - try { - bootstrapReplay = await host.replayBootstrapHandoffAsync(); + return { + host, + sink, + selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; } finally { + if (!handoffReplayAttempted) { + await host.discardBootstrapHandoffAsync(); + } delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; } - const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); - - let sink: IReporterEventSink = host.getSink(); - if (bootstrapReplay.skipReason === 'incompatible-protocol') { - for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { - const target: IRushReporterOutputStream = - selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; - target.write(output.text); - } - if (selection.reason === 'explicit --reporter') { - throw new Error( - 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + - 'Update the global Rush installation or use --reporter=legacy.' - ); - } - selection = { - reporter: 'legacy', - logLevel: 'normal', - outputs: [], - commandJson: selection.commandJson, - enabled: false, - reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, - reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, - reason: 'bootstrap compatibility fallback' - }; - sink = new LegacyFallbackSink(); - } - - let closePromise: Promise | undefined; - return { - host, - sink, - selection, - bootstrapReplay, - abandonedHandoffFilesDeleted, - closeAsync: (timeoutMs?: number) => { - closePromise ??= host.manager.closeAsync(timeoutMs); - return closePromise; - } - }; } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index 3ec4e45375a..1db9f261068 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; +import { + LegacyFallbackSink, + ReporterManager, + type IReporter, + type IReporterEventEnvelope +} from '@rushstack/rush-reporter'; import { RushCommandSelector } from '../RushCommandSelector'; import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; @@ -145,6 +150,26 @@ describe(RushCommandSelector.name, () => { ); }); + it('fails an explicit reporter request for an incompatible older engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 0/ + ); + }); + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { let receivedOptions: IRushFrontendLaunchOptions | undefined; const options: IRushFrontendLaunchOptions = { @@ -169,5 +194,33 @@ describe(RushCommandSelector.name, () => { reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); + }); + + it('falls back to legacy engine rendering for an implicit older protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 77b3bf1a6a1..fe9d32531cc 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -547,4 +547,131 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('falls back when repository opt-in meets an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('fails an explicit reporter request for an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stderrText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/unsupported required event/); + + expect(stderrText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('deletes an authenticated handoff when explicit reporter validation fails', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/requires an interactive TTY/); + + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 342b21b8754..3865d31aefc 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -344,7 +344,7 @@ export interface IBootstrapReplayResult { readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'unsupported-required-event' | 'incompatible-protocol'; } // @beta @@ -1373,6 +1373,7 @@ export type ReporterExtensionEventName = `${string}.${string}` & { export class ReporterHost { constructor(options?: IReporterHostOptions); cleanAbandonedHandoffFilesAsync(): Promise; + discardBootstrapHandoffAsync(): Promise; getSink(): IReporterEventSink; get manager(): ReporterManager; replayBootstrapHandoffAsync(): Promise; diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 770986282d0..7f1c4fa19c5 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -103,6 +103,7 @@ export interface IBootstrapReplayResult { | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' + | 'unsupported-required-event' | 'incompatible-protocol'; /** @@ -305,13 +306,15 @@ export class ReporterHost { } if (!isReporterEventEnvelope(event)) { if (isRecord(event) && event.required === true) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'invalid-event' + skipReason: 'unsupported-required-event', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } skippedEventCount++; @@ -348,6 +351,34 @@ export class ReporterHost { }; } + /** + * Deletes the current authenticated bootstrap handoff without replaying it. + * + * @remarks + * This is used when frontend initialization fails before replay can begin. + * Paths outside the configured handoff directory and nonce mismatches are + * rejected without deleting the referenced file. + * + */ + public async discardBootstrapHandoffAsync(): Promise { + const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + const expectedNonce: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + if (!handoffPath || !expectedNonce || !this._isOwnedHandoffPath(handoffPath)) { + return; + } + + try { + const { header } = await readBootstrapHandoffFileAsync(handoffPath); + if (header?.nonce !== expectedNonce) { + return; + } + } catch { + // Match replay behavior for an unreadable file at an authenticated private path. + } + + await deleteBootstrapHandoffFileAsync(handoffPath); + } + /** * Deletes abandoned handoff files older than the retention window. * diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 4ebca154427..f1a53954a5d 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -300,7 +300,45 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); - expect(result).toMatchObject({ replayed: false, skipReason: 'invalid-event' }); + expect(result).toMatchObject({ replayed: false, skipReason: 'unsupported-required-event' }); + }); + }); +}); + +describe('ReporterHost handoff discard', () => { + it('deletes only the current authenticated handoff', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + + it('does not delete a handoff with a mismatched nonce', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: 'wrong-nonce' + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(true); }); }); }); From b80a277340b7895055fb5b4f8b6ccefad947e7c2 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 06:41:03 +0000 Subject: [PATCH 011/133] Add feature-flagged operation event adapter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushFrontend.ts | 3 +- apps/rush/src/RushReporterHost.ts | 46 +++- apps/rush/src/test/RushFrontend.test.ts | 8 +- apps/rush/src/test/RushReporterHost.test.ts | 66 +++++- ...5a-operation-adapter_2026-08-28-06-35.json | 11 + ...5a-operation-adapter_2026-08-28-06-35.json | 11 + common/reviews/api/rush-lib.api.md | 3 + common/reviews/api/rush-reporter.api.md | 22 +- .../reporter/src/config/LogLevelFilter.ts | 2 + .../reporter/src/events/ReporterEventType.ts | 8 +- libraries/reporter/src/index.ts | 2 + .../reporter/src/lifecycle/LifecycleEvents.ts | 44 ++++ .../reporter/src/protocol/ReporterProtocol.ts | 2 +- .../src/scheduler/OperationStreamEmitter.ts | 68 +++++- .../src/test/IReporterEventEnvelope.test.ts | 4 +- .../reporter/src/test/LogLevelFilter.test.ts | 2 + .../src/test/OperationStreamEmitter.test.ts | 40 +++- libraries/reporter/src/test/Protocol.test.ts | 6 +- libraries/reporter/src/test/Telemetry.test.ts | 2 +- .../test/__snapshots__/Goldens.test.ts.snap | 2 +- .../logic/operations/OperationEventSink.ts | 9 +- .../operations/OperationExecutionRecord.ts | 24 +- .../src/logic/operations/OperationGraph.ts | 8 +- .../operations/ReporterOperationEventSink.ts | 187 +++++++++++++-- .../test/OperationGraphEventSink.test.ts | 217 +++++++++++++++++- .../src/pluginFramework/RushSession.ts | 43 ++++ 26 files changed, 765 insertions(+), 75 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 044a060d6b9..00caadebf4b 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -161,7 +161,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr ...launchOptions, reporter: { eventSink: reporterHost.sink, - sessionId + sessionId, + operationStreamEnabled: reporterHost.selection.enabled }, reporterCloseAsync }; diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index dfa9b84a7e4..82dc2d4f902 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,6 +23,7 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type ReporterEventType, type ReporterLogLevel, type ReporterName } from '@rushstack/rush-reporter'; @@ -70,6 +71,13 @@ export interface IInitializedRushReporterHost { const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; +const DEFERRED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ + 'operationRegistered', + 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted', + 'externalOutput' +]); interface IParsedReporterControls { readonly reporters: readonly string[]; @@ -111,6 +119,38 @@ class LogLevelReporter implements IReporter { } } +/** + * Keeps operation presentation on the legacy collator until R5B transfers terminal ownership. + */ +class DeferredOperationPresentationReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + + public constructor(reporter: IReporter) { + this._reporter = reporter; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + if (!DEFERRED_OPERATION_EVENT_TYPES.has(event.type)) { + this._reporter.report(event); + } + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + class ExplicitOutputReporter implements IReporter { public readonly name: string; @@ -610,7 +650,11 @@ export async function initializeRushReporterHostAsync( if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + const presentationReporter: IReporter = + selection.reporter === 'file' + ? primaryReporter + : new DeferredOperationPresentationReporter(primaryReporter); + host.manager.addReporter(new LogLevelReporter(presentationReporter, selection.logLevel), { destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' }); } diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 233b3d7e2d1..4a59142d851 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -178,7 +178,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { } describe(launchRushFrontendAsync.name, () => { - it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { + it('creates the authoritative host before invoking the bundled rush-lib and passes only its channel', async () => { const order: string[] = []; let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); @@ -207,7 +207,8 @@ describe(launchRushFrontendAsync.name, () => { expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); expect(receivedOptions?.reporter).toEqual({ eventSink: expect.objectContaining({ emit: expect.any(Function) }), - sessionId: expect.any(String) + sessionId: expect.any(String), + operationStreamEnabled: false }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); @@ -249,7 +250,8 @@ describe(launchRushFrontendAsync.name, () => { expect(createSessionId).toHaveBeenCalledTimes(1); expect(receivedOptions?.reporter).toEqual({ eventSink: initialized.sink, - sessionId: 'session-from-frontend' + sessionId: 'session-from-frontend', + operationStreamEnabled: false }); await initialized.closeAsync(); expect(order).toEqual(['host', 'close']); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fc3e630773e..7846cb673c6 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -44,6 +44,45 @@ function emitCommandStarted(sink: IReporterEventSink): void { }); } +function emitOperationEvents(sink: IReporterEventSink): void { + const base = { + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + scope: { commandName: 'build', operationId: 'project#phase' } + } as const; + sink.emit({ + ...base, + privacy: 'public', + type: 'operationRegistered', + payload: { operationId: 'project#phase', projectName: 'project', phaseName: 'phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStatusChanged', + payload: { operationId: 'project#phase', previousStatus: 'queued', status: 'executing' } + }); + sink.emit({ + ...base, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stdout', text: 'raw operation output\n' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationStreamClosed', + payload: { operationId: 'project#phase' } + }); + sink.emit({ + ...base, + privacy: 'public', + type: 'operationCompleted', + payload: { operationId: 'project#phase', status: 'success' } + }); +} + describe(resolveRushReporterSelection.name, () => { it('preserves the legacy path without an explicit opt-in in TTY, non-TTY, CI, and agent environments', () => { for (const testCase of [ @@ -436,7 +475,12 @@ describe(initializeRushReporterHostAsync.name, () => { let stdoutText: string = ''; try { const initialized = await initializeRushReporterHostAsync({ - argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + argv: [ + 'build', + '--reporter=json', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], env: {}, stdout: { isTTY: false, @@ -448,12 +492,28 @@ describe(initializeRushReporterHostAsync.name, () => { }); emitCommandStarted(initialized.sink); + emitOperationEvents(initialized.sink); const firstClose: Promise = initialized.closeAsync(); expect(initialized.closeAsync()).toBe(firstClose); await firstClose; - expect(JSON.parse(stdoutText).type).toBe('commandStarted'); - expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf8')).type).toBe('commandStarted'); + const stdoutEvents: Record[] = stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + const fileEvents: Record[] = (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(stdoutEvents.map(({ type }) => type)).toEqual(['commandStarted']); + expect(fileEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); } finally { await fs.promises.rm(directory, { recursive: true, force: true }); } diff --git a/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json b/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json new file mode 100644 index 00000000000..2e0fd43f62f --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit feature-flagged phase-aware operation registration, status, raw output, stream-close, and completion events while preserving the legacy StreamCollator output path.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json new file mode 100644 index 00000000000..fe11e3b0e35 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r5a-operation-adapter_2026-08-28-06-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Extend OperationStreamEmitter with silent registration metadata, previous status, stream-close, and operation-completion events.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e9201654ba4..5ae22050de6 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -684,6 +684,7 @@ export interface IOperationGraphContext extends ICreateOperationsContext { export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationCompleted?(result: IOperationExecutionResult): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; @@ -1016,6 +1017,8 @@ export interface IRushSessionOptions { // @beta export interface IRushSessionReporterOptions { readonly eventSink: IReporterEventSink; + // @internal + readonly operationStreamEnabled?: boolean; readonly sessionId: string; } diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index ecf09047dbd..029dea99d87 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -604,20 +604,34 @@ export interface IOldEngineOutputAdapterOptions { readonly source: IReporterEventSource; } +// @beta +export interface IOperationCompletedPayload { + readonly durationMs?: number; + readonly operationId: string; + readonly status: OperationStatus; +} + // @beta export interface IOperationRegisteredPayload { readonly operationId: string; readonly phaseName?: string; readonly projectName?: string; + readonly silent?: boolean; } // @beta export interface IOperationStatusChangedPayload { readonly durationMs?: number; readonly operationId: string; + readonly previousStatus?: OperationStatus; readonly status: OperationStatus; } +// @beta +export interface IOperationStreamClosedPayload { + readonly operationId: string; +} + // @beta export interface IOperationStreamEmitterOptions { readonly maxChunkBytes?: number; @@ -1251,11 +1265,13 @@ export type OperationStatus = 'ready' | 'waiting' | 'queued' | 'executing' | 'su // @beta export class OperationStreamEmitter { constructor(options: IOperationStreamEmitterOptions); - changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string; + changeStatus(operationId: string, status: OperationStatus, durationMs?: number, previousStatus?: OperationStatus): string; + closeOperationStream(operationId: string): string; completeCommand(commandName: string, succeeded: boolean, exitCode: number, operationCounts?: { readonly [status: string]: number; }): string; - registerOperation(operationId: string, projectName?: string, phaseName?: string): string; + completeOperation(operationId: string, status: OperationStatus, durationMs?: number): string; + registerOperation(operationId: string, projectName?: string, phaseName?: string, silent?: boolean): string; writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[]; } @@ -1328,7 +1344,7 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; +export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension", "operationStreamClosed", "operationCompleted"]; // @beta export const REPORTER_KNOWN_CAPABILITIES: readonly []; diff --git a/libraries/reporter/src/config/LogLevelFilter.ts b/libraries/reporter/src/config/LogLevelFilter.ts index f644a0edda2..6ea09cdf5f3 100644 --- a/libraries/reporter/src/config/LogLevelFilter.ts +++ b/libraries/reporter/src/config/LogLevelFilter.ts @@ -59,6 +59,7 @@ export function getEventMinimumLogLevel(event: IReporterEventEnvelope): case 'sessionStarted': case 'commandStarted': case 'operationStatusChanged': + case 'operationCompleted': case 'watchCycleCompleted': case 'artifactAvailable': return 'normal'; @@ -79,6 +80,7 @@ export function getEventMinimumLogLevel(event: IReporterEventEnvelope): case 'externalProcessCompleted': return 'verbose'; case 'externalOutput': + case 'operationStreamClosed': return 'debug'; case 'extension': return 'normal'; diff --git a/libraries/reporter/src/events/ReporterEventType.ts b/libraries/reporter/src/events/ReporterEventType.ts index f0c99004fc3..7e7010b9534 100644 --- a/libraries/reporter/src/events/ReporterEventType.ts +++ b/libraries/reporter/src/events/ReporterEventType.ts @@ -30,6 +30,8 @@ * | `artifactAvailable` | yes | `normal` | * | `commandResult` | yes | `quiet` | * | `extension` | yes | `normal` | + * | `operationStreamClosed` | yes | `debug` | + * | `operationCompleted` | yes | `normal` | * * Coalescing a replaceable `activityChanged` event under queue pressure leaves * gaps in the delivered `sequence` values; gaps are legal and are not a @@ -57,7 +59,9 @@ export const REPORTER_EVENT_TYPES = [ 'externalProcessCompleted', 'artifactAvailable', 'commandResult', - 'extension' + 'extension', + 'operationStreamClosed', + 'operationCompleted' ] as const; /** @@ -83,4 +87,4 @@ export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; */ export function isReporterEventRequired(type: ReporterEventType): boolean { return type !== 'activityChanged'; -} \ No newline at end of file +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f8..21672628495 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -177,6 +177,8 @@ export type { ICommandCompletedPayload, IOperationRegisteredPayload, IOperationStatusChangedPayload, + IOperationStreamClosedPayload, + IOperationCompletedPayload, ICommandResultPayload, IWatchCycleCompletedPayload } from './lifecycle/LifecycleEvents'; diff --git a/libraries/reporter/src/lifecycle/LifecycleEvents.ts b/libraries/reporter/src/lifecycle/LifecycleEvents.ts index e142e9ef323..81b27752713 100644 --- a/libraries/reporter/src/lifecycle/LifecycleEvents.ts +++ b/libraries/reporter/src/lifecycle/LifecycleEvents.ts @@ -113,6 +113,11 @@ export interface IOperationRegisteredPayload { * The phase the operation belongs to. */ readonly phaseName?: string; + + /** + * Whether the operation is architectural and normally omitted from visible summaries. + */ + readonly silent?: boolean; } /** @@ -131,12 +136,51 @@ export interface IOperationStatusChangedPayload { */ readonly status: OperationStatus; + /** + * The status immediately preceding this transition. + */ + readonly previousStatus?: OperationStatus; + /** * The operation duration in milliseconds when known. */ readonly durationMs?: number; } +/** + * The payload of an `operationStreamClosed` event. + * + * @beta + */ +export interface IOperationStreamClosedPayload { + /** + * The operation whose output stream has closed. + */ + readonly operationId: string; +} + +/** + * The payload of an `operationCompleted` event. + * + * @beta + */ +export interface IOperationCompletedPayload { + /** + * The completed operation. + */ + readonly operationId: string; + + /** + * The terminal operation status. + */ + readonly status: OperationStatus; + + /** + * The final operation duration in milliseconds when known. + */ + readonly durationMs?: number; +} + /** * The payload of a `commandResult` event. * diff --git a/libraries/reporter/src/protocol/ReporterProtocol.ts b/libraries/reporter/src/protocol/ReporterProtocol.ts index 40119adea8b..15dc630563d 100644 --- a/libraries/reporter/src/protocol/ReporterProtocol.ts +++ b/libraries/reporter/src/protocol/ReporterProtocol.ts @@ -15,7 +15,7 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion */ export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion = { major: 1, - minor: 0 + minor: 1 }; /** diff --git a/libraries/reporter/src/scheduler/OperationStreamEmitter.ts b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts index 259c935a1ea..241217781e2 100644 --- a/libraries/reporter/src/scheduler/OperationStreamEmitter.ts +++ b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts @@ -49,11 +49,11 @@ export interface IOperationStreamEmitterOptions { * * @remarks * The operation scheduler uses this to publish operation registration, status - * transitions, raw output chunks, and the aggregate command result. Output - * chunks are emitted immediately in call order and are never collated, so the - * concise reporter can derive activity without buffering, the detailed and file - * reporters can own grouping, and problem matchers can consume the same - * uncollated source stream. + * transitions, raw output chunks, stream close, operation completion, and the + * aggregate command result. Output chunks are emitted immediately in call order + * and are never collated, so the concise reporter can derive activity without + * buffering, the detailed and file reporters can own grouping, and problem + * matchers can consume the same uncollated source stream. * * @beta */ @@ -71,8 +71,7 @@ export class OperationStreamEmitter { this._source = options.source; this._scope = options.scope; this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; - const maxChunkBytes: number = - options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; + const maxChunkBytes: number = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; if ( !Number.isInteger(maxChunkBytes) || maxChunkBytes < 4 || @@ -88,10 +87,20 @@ export class OperationStreamEmitter { /** * Emits an operation registration event. */ - public registerOperation(operationId: string, projectName?: string, phaseName?: string): string { + public registerOperation( + operationId: string, + projectName?: string, + phaseName?: string, + silent?: boolean + ): string { return this._emit( 'operationRegistered', - { operationId, projectName, phaseName }, + { + operationId, + projectName, + phaseName, + ...(silent === undefined ? {} : { silent }) + }, { operationId, projectName, phaseName }, 'public' ); @@ -100,10 +109,20 @@ export class OperationStreamEmitter { /** * Emits an operation status transition. */ - public changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string { + public changeStatus( + operationId: string, + status: OperationStatus, + durationMs?: number, + previousStatus?: OperationStatus + ): string { return this._emit( 'operationStatusChanged', - { operationId, status, durationMs }, + { + operationId, + status, + ...(previousStatus === undefined ? {} : { previousStatus }), + ...(durationMs === undefined ? {} : { durationMs }) + }, { operationId }, 'public' ); @@ -146,6 +165,25 @@ export class OperationStreamEmitter { return eventIds; } + /** + * Emits the authoritative signal that no more output will be emitted for an operation. + */ + public closeOperationStream(operationId: string): string { + return this._emit('operationStreamClosed', { operationId }, { operationId }, 'public'); + } + + /** + * Emits the final outcome of an operation. + */ + public completeOperation(operationId: string, status: OperationStatus, durationMs?: number): string { + return this._emit( + 'operationCompleted', + { operationId, status, ...(durationMs === undefined ? {} : { durationMs }) }, + { operationId }, + 'public' + ); + } + /** * Emits the aggregate command result. */ @@ -164,7 +202,13 @@ export class OperationStreamEmitter { } private _emit( - type: 'operationRegistered' | 'operationStatusChanged' | 'externalOutput' | 'commandResult', + type: + | 'operationRegistered' + | 'operationStatusChanged' + | 'operationStreamClosed' + | 'operationCompleted' + | 'externalOutput' + | 'commandResult', payload: unknown, scopeOverride: IReporterEventScope, privacy: 'public' | 'local-sensitive' | 'secret' diff --git a/libraries/reporter/src/test/IReporterEventEnvelope.test.ts b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts index 949f38df083..0786651183b 100644 --- a/libraries/reporter/src/test/IReporterEventEnvelope.test.ts +++ b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts @@ -27,7 +27,9 @@ describe('ReporterEventType', () => { 'externalProcessCompleted', 'artifactAvailable', 'commandResult', - 'extension' + 'extension', + 'operationStreamClosed', + 'operationCompleted' ]); }); diff --git a/libraries/reporter/src/test/LogLevelFilter.test.ts b/libraries/reporter/src/test/LogLevelFilter.test.ts index b1c6569ffe5..a50fa6d3dfa 100644 --- a/libraries/reporter/src/test/LogLevelFilter.test.ts +++ b/libraries/reporter/src/test/LogLevelFilter.test.ts @@ -40,6 +40,7 @@ describe('getEventMinimumLogLevel', () => { it('classifies standard lifecycle and non-required warnings as normal', () => { expect(getEventMinimumLogLevel(ev('commandStarted', { commandName: 'build' }))).toBe('normal'); expect(getEventMinimumLogLevel(ev('operationStatusChanged', { status: 'success' }))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('operationCompleted', { status: 'success' }))).toBe('normal'); expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe('normal'); }); @@ -47,6 +48,7 @@ describe('getEventMinimumLogLevel', () => { expect(getEventMinimumLogLevel(ev('operationRegistered', {}))).toBe('normal'); expect(getEventMinimumLogLevel(ev('externalProcessStarted', {}))).toBe('verbose'); expect(getEventMinimumLogLevel(ev('externalOutput', { stream: 'stdout', text: 'x' }))).toBe('debug'); + expect(getEventMinimumLogLevel(ev('operationStreamClosed', {}))).toBe('debug'); expect(getEventMinimumLogLevel(ev('messageEmitted', { severity: 'debug', text: 'd' }))).toBe('debug'); expect(getEventMinimumLogLevel(ev('extension', { name: 'a.b' }, false))).toBe('normal'); }); diff --git a/libraries/reporter/src/test/OperationStreamEmitter.test.ts b/libraries/reporter/src/test/OperationStreamEmitter.test.ts index a6b47eaf746..680cdb49e17 100644 --- a/libraries/reporter/src/test/OperationStreamEmitter.test.ts +++ b/libraries/reporter/src/test/OperationStreamEmitter.test.ts @@ -61,10 +61,12 @@ describe('OperationStreamEmitter', () => { it('emits registration, status, output, and result with operation scope', () => { const sink: CapturingSink = new CapturingSink(); const emitter: OperationStreamEmitter = makeEmitter(sink); - emitter.registerOperation('op1', 'project-a', 'build'); - emitter.changeStatus('op1', 'executing'); + emitter.registerOperation('op1', 'project-a', 'build', false); + emitter.changeStatus('op1', 'executing', 0, 'queued'); emitter.writeOutput('op1', 'stdout', 'hello\n'); - emitter.changeStatus('op1', 'success', 100); + emitter.changeStatus('op1', 'success', 100, 'executing'); + emitter.closeOperationStream('op1'); + emitter.completeOperation('op1', 'success', 100); emitter.completeCommand('build', true, 0, { success: 1 }); expect(sink.inputs.map((i) => i.type)).toEqual([ @@ -72,15 +74,45 @@ describe('OperationStreamEmitter', () => { 'operationStatusChanged', 'externalOutput', 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted', 'commandResult' ]); expect(sink.inputs[2].scope).toEqual({ commandName: 'build', operationId: 'op1' }); expect(sink.inputs[2].privacy).toBe('local-sensitive'); - expect(sink.inputs[3].payload).toMatchObject({ operationId: 'op1', status: 'success', durationMs: 100 }); + expect(sink.inputs[0].payload).toMatchObject({ operationId: 'op1', silent: false }); + expect(sink.inputs[3].payload).toMatchObject({ + operationId: 'op1', + previousStatus: 'executing', + status: 'success', + durationMs: 100 + }); // externalOutput is protected (never coalesced/dropped); the manager derives `required`. expect(isReporterEventRequired('externalOutput')).toBe(true); }); + it('records silent metadata and orders close before completion', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.registerOperation('silent-op', 'project-a', '_phase:synthetic', true); + emitter.changeStatus('silent-op', 'noOp', 0, 'ready'); + emitter.closeOperationStream('silent-op'); + emitter.completeOperation('silent-op', 'noOp', 0); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'operationRegistered', + 'operationStatusChanged', + 'operationStreamClosed', + 'operationCompleted' + ]); + expect(sink.inputs[0].payload).toEqual({ + operationId: 'silent-op', + projectName: 'project-a', + phaseName: '_phase:synthetic', + silent: true + }); + }); + it('splits raw output into uncollated chunks', () => { const sink: CapturingSink = new CapturingSink(); const emitter: OperationStreamEmitter = makeEmitter(sink, 4); diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts index b602d9d5cc1..8efe5138ba0 100644 --- a/libraries/reporter/src/test/Protocol.test.ts +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -18,6 +18,7 @@ import { describe('ReporterProtocol', () => { it('advertises protocol major 1 and the specified byte limits', () => { expect(REPORTER_PROTOCOL_VERSION.major).toBe(1); + expect(REPORTER_PROTOCOL_VERSION.minor).toBe(1); expect(REPORTER_PROTOCOL_LIMITS.bootstrapBufferBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes).toBe(64 * 1024); @@ -175,10 +176,7 @@ describe('negotiateReporterHello', () => { it('rejects a malformed wire hello with a predictable validation error', () => { expect(() => - negotiateReporterHello( - { kind: 'hello' }, - { supportedProtocolVersion: { major: 1, minor: 0 } } - ) + negotiateReporterHello({ kind: 'hello' }, { supportedProtocolVersion: { major: 1, minor: 0 } }) ).toThrow(InvalidReporterHelloError); expect(() => negotiateReporterHello( diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 423b8a78cf1..fed71783a89 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -85,7 +85,7 @@ describe('TelemetrySubscriber', () => { expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); expect(aggregate.reporterMode).toBe('default'); - expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 0 }); + expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 1 }); expect(aggregate.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); // The subscriber runs alongside a rendering reporter and does not consume events from it. diff --git a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap index 76b6b15a1c5..e46cd3c517c 100644 --- a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap +++ b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap @@ -3,7 +3,7 @@ exports[`compatibility goldens advertises the current protocol version as the negotiation baseline 1`] = ` Object { "major": 1, - "minor": 0, + "minor": 1, } `; diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index b5d90e0ad8a..9e42aadf489 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -60,14 +60,19 @@ export interface IOperationGraphEventSink { * quiet-mode filtering. Concatenated chunks for one operation exactly match * what the collated sink receives for that operation. */ - onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationChunk?(result: IOperationExecutionResult, chunk: ITerminalChunk): void; /** * Invoked when an operation's collated output stream is closed at the end of * its execution, after all status lines and output have been written. This * is the authoritative "no more output for this operation" signal. */ - onOperationStreamClosed?(operationId: string): void; + onOperationStreamClosed?(result: IOperationExecutionResult): void; + + /** + * Invoked after the operation stream is closed and the final outcome is authoritative. + */ + onOperationCompleted?(result: IOperationExecutionResult): void; /** * Invoked for each human-oriented status line written to the terminal, diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 47317ecfac8..031a48b5482 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -173,6 +173,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera private _status: OperationStatus; private _stateHash: string | undefined; private _stateHashComponents: IOperationStateHashComponents | undefined; + private _operationStreamClosed: boolean = false; public constructor(operation: Operation, context: IOperationExecutionRecordContext) { const { runner, associatedPhase, associatedProject, enabled } = operation; @@ -283,6 +284,18 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return !this.enabled || this.runner.silent; } + /** + * Notifies observers that this iteration cannot emit more output for the operation. + * + * @internal + */ + public closeOperationStream(): void { + if (!this._operationStreamClosed) { + this._operationStreamClosed = true; + this._context.eventSink?.onOperationStreamClosed?.(this); + } + } + public getStateHash(): string { if (this._stateHash === undefined) { const { dependencies, local, config } = this.getStateHashComponents(); @@ -402,9 +415,11 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // Tap the stream upstream of the quiet-mode discard so the sink observes // the exact bytes the collated writer would receive, regardless of verbosity. chunkTapDestinations.push( - new OperationChunkTap(this.name, (operationId, chunk) => - eventSink.onOperationChunk?.(operationId, chunk) - ) + new OperationChunkTap(this.name, (operationId, chunk) => { + if (operationId === this.name) { + eventSink.onOperationChunk?.(this, chunk); + } + }) ); } @@ -486,9 +501,6 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } finally { if (this.isTerminal) { this._collatedWriter?.close(); - if (this._collatedWriter) { - this._context.eventSink?.onOperationStreamClosed?.(this.name); - } this.stdioSummarizer.close(); this.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index b2c1362b6a2..a135e796cd3 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -676,9 +676,7 @@ export class OperationGraph implements IOperationGraph { operation, iterationContext ); - executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { @@ -717,6 +715,10 @@ export class OperationGraph implements IOperationGraph { return; } + for (const executionRecord of executionRecords.values()) { + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + } + this._setScheduledIteration(iterationContext); // Notify listeners that an iteration has been scheduled with the planned operation records try { @@ -961,6 +963,8 @@ export class OperationGraph implements IOperationGraph { } } for (const record of executionRecords.values()) { + record.closeOperationStream(); + eventSink?.onOperationCompleted?.(record); record.stdioSummarizer.close(); record.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index d3287c44270..3c82a943cea 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -5,14 +5,16 @@ import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter, + type OperationStreamEmitter, type OperationStatus as ReporterOperationStatus } from '@rushstack/rush-reporter'; -import type { ITerminalChunk } from '@rushstack/terminal'; +import { TerminalChunkKind, type ITerminalChunk } from '@rushstack/terminal'; import type { RushSession } from '../../pluginFramework/RushSession'; import { _correlateRushSessionError, - _getRushSessionLifecycleEmitter + _getRushSessionLifecycleEmitter, + _getRushSessionOperationStreamEmitter } from '../../pluginFramework/RushSession'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; @@ -26,18 +28,26 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + readonly streamEmitter: OperationStreamEmitter | undefined; registrationCycle: IReporterOperationCycle | undefined; } interface IReporterOperationCycle { readonly registeredOperationIds: Set; readonly statuses: Map; + readonly closedOperationIds: Set; + readonly completedResults: Map; diagnosed: boolean; lastEmittedStatus: ReporterOperationStatus | undefined; silent: boolean; + streamClosed: boolean; } class ReporterOperationEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; + private readonly _operationsByLegacyId: Map = new Map(); private readonly _cyclesByResult: WeakMap = new WeakMap(); @@ -62,12 +72,22 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!emitter) { continue; } + const streamEmitter: OperationStreamEmitter | undefined = _getRushSessionOperationStreamEmitter( + rushSession, + { + commandName, + operationId, + projectName, + phaseName + } + ); reporterOperation = { emitter, legacyOperationIds: new Set(), operationId, phaseName, projectName, + streamEmitter, registrationCycle: undefined }; operationsByReporterId.set(operationId, reporterOperation); @@ -75,6 +95,16 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { reporterOperation.legacyOperationIds.add(operation.name); this._operationsByLegacyId.set(operation.name, reporterOperation); } + + if (Array.from(this._operationsByLegacyId.values()).some(({ streamEmitter }) => !!streamEmitter)) { + this.onOperationChunk = (operationId, chunk) => this._onOperationChunk(operationId, chunk); + this.onOperationStreamClosed = (operationId) => this._onOperationStreamClosed(operationId); + this.onOperationCompleted = (result) => this._onOperationCompleted(result); + } else { + this.onOperationChunk = undefined; + this.onOperationStreamClosed = undefined; + this.onOperationCompleted = undefined; + } } public get isEnabled(): boolean { @@ -96,9 +126,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { cycle = { registeredOperationIds: new Set(), statuses: new Map(), + closedOperationIds: new Set(), + completedResults: new Map(), diagnosed: false, lastEmittedStatus: undefined, - silent: true + silent: true, + streamClosed: false }; operation.registrationCycle = cycle; } @@ -106,18 +139,27 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { this._cyclesByResult.set(result, cycle); cycle.registeredOperationIds.add(operationId); cycle.silent &&= silent; - if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size) { return; } - operation.emitter.emitOperationRegistered({ - operationId: operation.operationId, - projectName: operation.projectName, - phaseName: operation.phaseName - }); + if (operation.streamEmitter) { + operation.streamEmitter.registerOperation( + operation.operationId, + operation.projectName, + operation.phaseName, + cycle.silent + ); + } else if (!cycle.silent) { + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } } - public onOperationStatusChanged(result: IOperationExecutionResult): void { + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); if (!operation) { return; @@ -152,12 +194,22 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (status === undefined || status === cycle.lastEmittedStatus) { return; } + const aggregatePreviousStatus: ReporterOperationStatus | undefined = + cycle.lastEmittedStatus ?? + (operation.legacyOperationIds.size === 1 ? _toReporterStatus(previousStatus) : undefined); cycle.lastEmittedStatus = status; - if (!cycle.silent) { - const durationMs: number | undefined = - operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined - ? result.stopwatch.duration * 1000 - : undefined; + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + if (operation.streamEmitter) { + operation.streamEmitter.changeStatus( + operation.operationId, + status, + durationMs, + aggregatePreviousStatus + ); + } else if (!cycle.silent) { operation.emitter.emitOperationStatusChanged({ operationId: operation.operationId, status, @@ -165,11 +217,64 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { }); } } + + private _onOperationChunk(result: IOperationExecutionResult, chunk: ITerminalChunk): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get( + result.operation.name + ); + if (!operation?.streamEmitter || !this._cyclesByResult.has(result)) { + return; + } + + if (chunk.kind === TerminalChunkKind.Stdout) { + operation.streamEmitter.writeOutput(operation.operationId, 'stdout', chunk.text); + } else if (chunk.kind === TerminalChunkKind.Stderr) { + operation.streamEmitter.writeOutput(operation.operationId, 'stderr', chunk.text); + } + } + + private _onOperationStreamClosed(result: IOperationExecutionResult): void { + const operationId: string = result.operation.name; + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!operation?.streamEmitter || !cycle || cycle.streamClosed) { + return; + } + cycle.closedOperationIds.add(operationId); + if (cycle.closedOperationIds.size === operation.legacyOperationIds.size) { + cycle.streamClosed = true; + operation.streamEmitter.closeOperationStream(operation.operationId); + } + } + + private _onOperationCompleted(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation?.streamEmitter) { + return; + } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } + + cycle.completedResults.set(result.operation.name, result); + if (cycle.completedResults.size !== operation.legacyOperationIds.size) { + return; + } + const status: ReporterOperationStatus = _getAggregateTerminalStatus( + Array.from(cycle.completedResults.values(), ({ status: resultStatus }) => resultStatus) + ); + const durationMs: number | undefined = _getAggregateDurationMs(cycle.completedResults); + operation.streamEmitter.completeOperation(operation.operationId, status, durationMs); + } } class CompositeOperationGraphEventSink implements IOperationGraphEventSink { - public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; - public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + public readonly onOperationChunk: + | ((result: IOperationExecutionResult, chunk: ITerminalChunk) => void) + | undefined; + public readonly onOperationStreamClosed: ((result: IOperationExecutionResult) => void) | undefined; + public readonly onOperationCompleted: ((result: IOperationExecutionResult) => void) | undefined; private readonly _first: IOperationGraphEventSink; private readonly _second: IOperationGraphEventSink; @@ -179,16 +284,23 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { this._second = second; this.onOperationChunk = first.onOperationChunk || second.onOperationChunk - ? (operationId, chunk) => { - first.onOperationChunk?.(operationId, chunk); - second.onOperationChunk?.(operationId, chunk); + ? (result, chunk) => { + first.onOperationChunk?.(result, chunk); + second.onOperationChunk?.(result, chunk); } : undefined; this.onOperationStreamClosed = first.onOperationStreamClosed || second.onOperationStreamClosed - ? (operationId) => { - first.onOperationStreamClosed?.(operationId); - second.onOperationStreamClosed?.(operationId); + ? (result) => { + first.onOperationStreamClosed?.(result); + second.onOperationStreamClosed?.(result); + } + : undefined; + this.onOperationCompleted = + first.onOperationCompleted || second.onOperationCompleted + ? (result) => { + first.onOperationCompleted?.(result); + second.onOperationCompleted?.(result); } : undefined; } @@ -219,7 +331,7 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { } /** - * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * Adds reporter emission without changing the graph's visible output or collator routing. * * @internal */ @@ -334,3 +446,30 @@ function _isTerminalStatus(status: OperationStatus): boolean { return false; } } + +function _getAggregateDurationMs( + results: ReadonlyMap +): number | undefined { + let startTime: number | undefined; + let endTime: number | undefined; + for (const result of results.values()) { + if (result.stopwatch.startTime !== undefined) { + startTime = + startTime === undefined + ? result.stopwatch.startTime + : Math.min(startTime, result.stopwatch.startTime); + } + if (result.stopwatch.endTime !== undefined) { + endTime = + endTime === undefined ? result.stopwatch.endTime : Math.max(endTime, result.stopwatch.endTime); + } + } + if (startTime !== undefined && endTime !== undefined) { + return Math.max(0, endTime - startTime); + } + if (results.size === 1) { + const result: IOperationExecutionResult | undefined = results.values().next().value; + return result?.stopwatch.startTime === undefined ? undefined : result.stopwatch.duration * 1000; + } + return undefined; +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index e91029084c0..c93400e7165 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -35,7 +35,12 @@ jest.mock('../ProjectLogWritable', () => { }); import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; -import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; +import { + MockWritable, + StringBufferTerminalProvider, + TerminalProviderSeverity, + type ITerminalChunk +} from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -85,6 +90,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly headers: [string, number, number][] = []; public readonly activities: string[] = []; public readonly chunks: Map = new Map(); + public readonly closed: string[] = []; + public readonly completed: [string, string][] = []; public onOperationRegistered(operationId: string, silent: boolean): void { this.registered.push([operationId, silent]); @@ -106,6 +113,12 @@ class RecordingSink implements IOperationGraphEventSink { } chunks.push(chunk.text); } + public onOperationStreamClosed(operationId: string): void { + this.closed.push(operationId); + } + public onOperationCompleted(result: IOperationExecutionResult): void { + this.completed.push([result.operation.name, result.status]); + } } class CapturingReporterSink implements IReporterEventSink { @@ -167,6 +180,11 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(sink.activities.some((line: string) => line.includes('"alpha" completed successfully'))).toBe( true ); + expect([...sink.closed].sort()).toEqual(['alpha', 'beta']); + expect([...sink.completed].sort()).toEqual([ + ['alpha', OperationStatus.Success], + ['beta', OperationStatus.Success] + ]); }); it('emits raw per-operation chunks even in quiet mode, matching the collated stream', async () => { @@ -235,7 +253,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + reporter: { + eventSink: reporterSink, + sessionId: 'operation-shadow', + operationStreamEnabled: false + } }); const createFailingOperation = (): Operation => createOperation( @@ -279,6 +301,174 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + it('emits the opted-in canonical stream without duplicating or losing operation chunks', async () => { + const stdoutText: string = `${'a'.repeat(64 * 1024 + 7)}\n`; + const stderrText: string = 'stderr detail\n'; + const createOutputRunner = (): IOperationRunner => ({ + name: '@scope/project (_phase:build)', + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: true, + isNoOp: false, + executeAsync: async (context: IOperationRunnerContext) => + await context.runWithTerminalAsync( + async (terminal, terminalProvider) => { + void terminal; + terminalProvider.write(stdoutText, TerminalProviderSeverity.log); + terminalProvider.write(stderrText, TerminalProviderSeverity.error); + return OperationStatus.SuccessWithWarning; + }, + { createLogFile: false, logFileSuffix: '' } + ), + getConfigHash: () => 'mock' + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('@scope/project', createOutputRunner(), mockPhase, '@scope/project')]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'operation-stream', + operationStreamEnabled: true + } + }); + const streamedWritable: MockWritable = new MockWritable(); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('@scope/project', createOutputRunner(), mockPhase, '@scope/project')]), + createGraphOptions(streamedWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + expect(streamedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ scope }) => scope?.operationId === '@scope/project#phase' + ); + expect(operationEvents[0]).toMatchObject({ + type: 'operationRegistered', + payload: { + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase', + silent: false + } + }); + + const statusEvents: IReporterEmitEventInput[] = operationEvents.filter( + ({ type }) => type === 'operationStatusChanged' + ); + expect(statusEvents.map(({ payload }) => payload)).toEqual([ + expect.objectContaining({ previousStatus: 'ready', status: 'queued' }), + expect.objectContaining({ previousStatus: 'queued', status: 'executing' }), + expect.objectContaining({ previousStatus: 'executing', status: 'successWithWarnings' }) + ]); + + const outputEvents: IReporterEmitEventInput[] = operationEvents.filter( + ({ type }) => type === 'externalOutput' + ); + expect( + outputEvents.every( + ({ payload }) => Buffer.byteLength((payload as { text: string }).text, 'utf8') <= 64 * 1024 + ) + ).toBe(true); + const stdoutChunks: string = outputEvents + .filter(({ payload }) => (payload as { stream: string }).stream === 'stdout') + .map(({ payload }) => (payload as { text: string }).text) + .join(''); + const stderrChunks: string = outputEvents + .filter(({ payload }) => (payload as { stream: string }).stream === 'stderr') + .map(({ payload }) => (payload as { text: string }).text) + .join(''); + expect(stdoutChunks).toBe(stdoutText); + expect(stderrChunks).toBe(stderrText); + + const closedIndex: number = operationEvents.findIndex(({ type }) => type === 'operationStreamClosed'); + const completedIndex: number = operationEvents.findIndex(({ type }) => type === 'operationCompleted'); + expect(closedIndex).toBeGreaterThan(operationEvents.lastIndexOf(outputEvents.at(-1)!)); + expect(completedIndex).toBeGreaterThan(closedIndex); + expect(operationEvents[completedIndex].payload).toMatchObject({ + operationId: '@scope/project#phase', + status: 'successWithWarnings' + }); + }); + + it('reports silent operation metadata and outcomes on the opted-in stream', async () => { + const silentRunner: IOperationRunner = { + name: 'silent synthetic', + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async () => OperationStatus.Success, + getConfigHash: () => 'silent' + }; + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { + eventSink: reporterSink, + sessionId: 'silent-operation', + operationStreamEnabled: true + } + }); + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation('visible', new MockOperationRunner('visible'), mockPhase, '@scope/visible'), + createOperation('silent synthetic', silentRunner, mockPhase, '@scope/project') + ]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'operationRegistered', + payload: expect.objectContaining({ + operationId: '@scope/project#phase', + silent: true + }) + }) + ); + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'operationCompleted', + payload: expect.objectContaining({ + operationId: '@scope/project#phase', + status: 'success' + }) + }) + ); + }); + + it('does not attach an operation adapter when the session has no reporter sink', () => { + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('no sink', new MockOperationRunner('no sink'))]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + + expect(graph.eventSink).toBeUndefined(); + }); + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ @@ -486,7 +676,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'operation-retries' } + reporter: { + eventSink: reporterSink, + sessionId: 'operation-retries', + operationStreamEnabled: true + } }); const compilePhase: IPhase = { ...mockPhase, @@ -530,6 +724,16 @@ describe('OperationGraph event sink (dual-emit)', () => { '@scope/project#_phase:compile', '@scope/project#_phase:test' ]); + expect( + reporterSink.inputs + .filter(({ type }) => type === 'operationCompleted') + .map(({ scope }) => scope?.operationId) + ).toEqual([ + '@scope/project#_phase:compile', + '@scope/project#_phase:test', + '@scope/project#_phase:compile', + '@scope/project#_phase:test' + ]); for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationStatusChanged')) { expect(event.scope?.operationId).toBe(`@scope/project#${event.scope?.phaseName}`); expect((event.payload as { operationId: string }).operationId).toBe(event.scope?.operationId); @@ -554,7 +758,11 @@ describe('OperationGraph event sink (dual-emit)', () => { const rushSession: RushSession = new RushSession({ terminalProvider: new StringBufferTerminalProvider(), getIsDebugMode: () => false, - reporter: { eventSink: reporterSink, sessionId: 'output-parity' } + reporter: { + eventSink: reporterSink, + sessionId: 'output-parity', + operationStreamEnabled: false + } }); const shadowWritable: MockWritable = new MockWritable(); const shadowGraph: OperationGraph = new OperationGraph( @@ -562,6 +770,7 @@ describe('OperationGraph event sink (dual-emit)', () => { createGraphOptions(shadowWritable, false) ); attachReporterOperationEventSink(shadowGraph, rushSession, 'build'); + expect(shadowGraph.eventSink?.onOperationChunk).toBeUndefined(); await shadowGraph.executeAsync({}); expect(shadowWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e52cbb0ad02..9525b92dc43 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -5,6 +5,7 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/ import { LifecycleEmitter, LegacyErrorBridge, + OperationStreamEmitter, RushSessionReporting, TelemetrySubscriber, isReporterEventRequired, @@ -49,6 +50,17 @@ export interface IRushSessionReporterOptions { * The identifier assigned to this Rush session by the frontend. */ readonly sessionId: string; + + /** + * Enables raw semantic operation events for the pre-major reporter opt-in path. + * + * @remarks + * When false or omitted, Rush retains the shadow lifecycle-only behavior and + * does not tap operation output. + * + * @internal + */ + readonly operationStreamEnabled?: boolean; } /** @@ -293,6 +305,22 @@ function _createLifecycleEmitter( }); } +function _createOperationStreamEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): OperationStreamEmitter | undefined { + if (!state) { + return undefined; + } + + return new OperationStreamEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined + }); +} + function _getSessionState(rushSession: RushSession): IRushSessionState { const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); if (!state) { @@ -451,6 +479,21 @@ export function _getRushSessionLifecycleEmitter( return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); } +/** + * Creates the raw operation stream emitter only for the pre-major opt-in path. + * + * @internal + */ +export function _getRushSessionOperationStreamEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): OperationStreamEmitter | undefined { + const state: IRushSessionState = _getSessionState(rushSession); + return state.options.reporter?.operationStreamEnabled + ? _createOperationStreamEmitter(state.reporting, scope) + : undefined; +} + /** * Returns the current allowlisted reporter telemetry projection. * From 6af34ca1c946f3fcee7ca55ea4e77b9eb2f79ad1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 07:52:04 +0000 Subject: [PATCH 012/133] Add direct Rush reporter demo path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/MinimalRushConfiguration.ts | 42 +++- apps/rush/src/RushFrontend.ts | 25 +- apps/rush/src/RushReporterHost.ts | 168 +++++++++---- .../src/test/MinimalRushConfiguration.test.ts | 1 + apps/rush/src/test/RushReporterHost.test.ts | 76 +++++- .../src/test/sandbox/reporter-demo/README.md | 25 ++ .../src/test/sandbox/reporter-demo/run.mjs | 51 ++++ ...r-r5b-demo-reporters_2026-08-28-07-10.json | 11 + ...r-r5b-demo-reporters_2026-08-28-07-10.json | 11 + common/reviews/api/rush-lib.api.md | 2 + common/reviews/api/rush-reporter.api.md | 1 + .../reporter/src/reporters/AiReporter.ts | 25 +- .../reporters/DefaultInteractiveReporter.ts | 79 ++++-- .../reporter/src/reporters/FileReporter.ts | 170 ++++++++++++- .../reporter/src/reporters/LegacyReporter.ts | 4 +- .../src/reporters/PlaintextReporter.ts | 71 +++++- .../test/DefaultInteractiveReporter.test.ts | 32 ++- .../reporter/src/test/FileReporter.test.ts | 64 ++++- .../reporter/src/test/JsonAiReporter.test.ts | 26 ++ .../src/test/OperationStreamEmitter.test.ts | 3 + .../src/test/PlaintextReporter.test.ts | 35 +++ libraries/rush-lib/src/api/Rush.ts | 2 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 144 ++++++++--- .../src/cli/actions/BaseRushAction.ts | 11 +- .../cli/scriptActions/PhasedScriptAction.ts | 38 ++- .../cli/test/RushCommandLineParser.test.ts | 30 +++ .../rush-lib/src/logic/ProjectWatcher.ts | 14 +- .../operations/ReporterOperationEventSink.ts | 212 ++++++++++------ .../test/OperationGraphEventSink.test.ts | 231 ++++++++++++++---- .../src/pluginFramework/RushSession.ts | 25 ++ 30 files changed, 1343 insertions(+), 286 deletions(-) create mode 100644 apps/rush/src/test/sandbox/reporter-demo/README.md create mode 100644 apps/rush/src/test/sandbox/reporter-demo/run.mjs create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r5b-demo-reporters_2026-08-28-07-10.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r5b-demo-reporters_2026-08-28-07-10.json diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 62aef01d11d..8a7ffa0d3b7 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -52,14 +52,29 @@ export class MinimalRushConfiguration { } public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { + const showVerbose: boolean = !RushCommandLineParser.shouldRestrictConsoleOutput(); const rushJsonLocation: string | undefined = RushConfiguration.tryFindRushJsonLocation({ - showVerbose: !RushCommandLineParser.shouldRestrictConsoleOutput() + showVerbose: false }); if (rushJsonLocation) { const minimalRushConfigurationJson: IMinimalRushConfigurationJson | undefined = _loadConfigurationJson(rushJsonLocation); if (minimalRushConfigurationJson) { - return new MinimalRushConfiguration(minimalRushConfigurationJson, rushJsonLocation); + const configuration: MinimalRushConfiguration = new MinimalRushConfiguration( + minimalRushConfigurationJson, + rushJsonLocation + ); + if ( + showVerbose && + !configuration.useRushReporter && + !_hasExplicitNonLegacyReporter(process.argv.slice(2)) && + path.dirname(rushJsonLocation) !== process.cwd() + ) { + // Preserve the legacy discovery message exactly when the reporter path is not taking ownership. + console.log('Found configuration in ' + rushJsonLocation); + console.log(''); + } + return configuration; } return undefined; } else { @@ -94,6 +109,29 @@ export class MinimalRushConfiguration { public get useRushReporter(): boolean { return this._useRushReporter; } + + /** + * The repository's common temp folder, used for invocation-scoped reporter logs. + */ + public get commonTempFolder(): string { + return path.resolve(this._commonRushConfigFolder, '..', '..', 'temp'); + } +} + +function _hasExplicitNonLegacyReporter(argv: readonly string[]): boolean { + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + let value: string | undefined; + if (argument === '--reporter') { + value = argv[index + 1]; + } else if (argument.startsWith('--reporter=')) { + value = argument.slice('--reporter='.length); + } + if (value !== undefined) { + return value.trim().toLowerCase() !== 'legacy'; + } + } + return false; } function _loadConfigurationJson(rushJsonFilename: string): IMinimalRushConfigurationJson | undefined { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 00caadebf4b..c6088eb195d 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto'; import type { ILaunchOptions } from '@microsoft/rush-lib'; -import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; +import { + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, + REPORTER_PROTOCOL_VERSION +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -139,10 +142,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr processLifecycle = createProcessLifecycle() } = options; + const engineArgv: string[] = stripReporterValueControls(process.argv.slice(2)); const reporterHost: IInitializedRushReporterHost = await initializeReporterHostAsync({ repositoryOptIn: configuration?.useRushReporter, forceLegacy: rushVersionToLoad !== undefined && rushVersionToLoad !== currentPackageVersion, - selectedRushVersion: rushVersionToLoad + selectedRushVersion: rushVersionToLoad, + commonTempFolder: configuration?.commonTempFolder, + actionName: engineArgv.find((argument: string) => !argument.startsWith('-')) }); const reporterLifecycle: RushFrontendReporterLifecycle | undefined = reporterHost.selection.enabled ? new RushFrontendReporterLifecycle(reporterHost, processLifecycle) @@ -157,6 +163,21 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); + if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { + reporterHost.sink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId, + source: { packageName: '@microsoft/rush', packageVersion: currentPackageVersion }, + privacy: 'local-sensitive', + type: 'artifactAvailable', + payload: { + role: 'log', + path: reporterHost.logArtifact.path, + format: 'plaintext', + complete: false + } + }); + } const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporter: { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 82dc2d4f902..fcfc53232cf 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -20,8 +20,10 @@ import { shouldRenderAtLogLevel, type IReporter, type IReporterContext, + type IReporterEmitEventInput, type IReporterEventEnvelope, type IReporterEventSink, + type IFileReporterArtifact, type IReporterOutputTarget, type ReporterEventType, type ReporterLogLevel, @@ -39,6 +41,9 @@ export interface IRushReporterHostOptions { readonly env?: Record; readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; + readonly commonTempFolder?: string; + readonly actionName?: string; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; @@ -65,13 +70,14 @@ export interface IInitializedRushReporterHost { readonly host: ReporterHost; readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; + readonly logArtifact: IFileReporterArtifact | undefined; closeAsync(timeoutMs?: number): Promise; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); const ALL_REPORTER_VALUE_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; const REPORTER_SELECTION_FLAG: readonly string[] = ['--reporter']; -const DEFERRED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ +const GROUPED_OPERATION_EVENT_TYPES: ReadonlySet = new Set([ 'operationRegistered', 'operationStatusChanged', 'operationStreamClosed', @@ -93,10 +99,16 @@ class LogLevelReporter implements IReporter { private readonly _reporter: IReporter; private readonly _logLevel: ReporterLogLevel; + private readonly _preserveOperationStream: boolean; - public constructor(reporter: IReporter, logLevel: ReporterLogLevel) { + public constructor( + reporter: IReporter, + logLevel: ReporterLogLevel, + preserveOperationStream: boolean = false + ) { this._reporter = reporter; this._logLevel = logLevel; + this._preserveOperationStream = preserveOperationStream; this.name = reporter.name; } @@ -105,39 +117,18 @@ class LogLevelReporter implements IReporter { } public report(event: IReporterEventEnvelope): void { - if (shouldRenderAtLogLevel(this._logLevel, event)) { - this._reporter.report(event); - } - } - - public flushAsync(): Promise { - return this._reporter.flushAsync(); - } - - public closeAsync(): Promise { - return this._reporter.closeAsync(); - } -} - -/** - * Keeps operation presentation on the legacy collator until R5B transfers terminal ownership. - */ -class DeferredOperationPresentationReporter implements IReporter { - public readonly name: string; - - private readonly _reporter: IReporter; - - public constructor(reporter: IReporter) { - this._reporter = reporter; - this.name = reporter.name; - } - - public initializeAsync(context: IReporterContext): Promise { - return this._reporter.initializeAsync(context); - } - - public report(event: IReporterEventEnvelope): void { - if (!DEFERRED_OPERATION_EVENT_TYPES.has(event.type)) { + const verboseTerminalMessage: boolean = + this._logLevel === 'verbose' && + event.type === 'messageEmitted' && + (event.payload as { severity?: string }).severity === 'debug'; + if ( + shouldRenderAtLogLevel(this._logLevel, event) || + verboseTerminalMessage || + event.type === 'artifactAvailable' || + (this._preserveOperationStream && + GROUPED_OPERATION_EVENT_TYPES.has(event.type) && + !(this._logLevel === 'quiet' && event.type === 'externalOutput')) + ) { this._reporter.report(event); } } @@ -202,6 +193,43 @@ class ExplicitOutputReporter implements IReporter { } } +class FilePathReporter implements IReporter { + public readonly name: string = 'file-path'; + + private readonly _write: (text: string) => unknown; + private _path: string | undefined; + + public constructor(write: (text: string) => unknown) { + this._write = write; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + if (event.type === 'artifactAvailable') { + const payload: { role?: string; path?: string } = event.payload as { + role?: string; + path?: string; + }; + if (payload.role === 'log') { + this._path = payload.path; + } + } else if (event.type === 'commandResult' && this._path) { + this._write(`Rush full log: ${this._path}\n`); + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + function readValue( argv: readonly string[], index: number, @@ -397,6 +425,19 @@ function resolveLogLevel( } const environmentLogLevel: string | undefined = includeEnvironment ? env.RUSH_LOG_LEVEL : undefined; + const environmentQuiet: boolean = + includeEnvironment && (env.RUSH_QUIET_MODE === '1' || env.RUSH_QUIET_MODE?.toLowerCase() === 'true'); + if (environmentQuiet && environmentLogLevel) { + const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); + if (normalizedLogLevel !== 'quiet') { + throw new Error( + 'RUSH_QUIET_MODE contradicts RUSH_LOG_LEVEL. Remove one of these environment controls.' + ); + } + } + if (environmentQuiet) { + return 'quiet'; + } if (environmentLogLevel) { const normalizedLogLevel: string = environmentLogLevel.trim().toLowerCase(); if (!isSupportedLogLevel(normalizedLogLevel)) { @@ -527,6 +568,19 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + if (argv.includes('--help') || argv.includes('-h')) { + return { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson, + enabled: false, + reporterControlsOwnedByFrontend: requestedReporter !== undefined, + reporterValueFlagsToStrip: requestedReporter === undefined ? [] : REPORTER_SELECTION_FLAG, + reason: requestedReporter === undefined ? 'pre-major legacy default' : 'explicit --reporter' + }; + } + function getCommandName(): 'rush' | 'rush-pnpm' | 'rushx' { const executableName: string = path.basename(process.argv[1] ?? '').toLowerCase(); if (executableName === 'rush-pnpm') { @@ -629,11 +683,12 @@ function createPrimaryReporter( case 'plaintext': return new PlaintextReporter({ write: (text: string) => stdout.write(text), - variant: isCiDetected(env) ? 'detailed' : 'concise', - color: false + variant: selection.reason === 'explicit --reporter' || isCiDetected(env) ? 'detailed' : 'concise', + color: false, + logLevel: selection.logLevel }); case 'file': - return new FileReporter(); + return undefined; case 'legacy': return undefined; } @@ -644,30 +699,36 @@ export async function initializeRushReporterHostAsync( ): Promise { const env: Record = options.env ?? process.env; const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; + const stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); const host: ReporterHost = new ReporterHost({ env }); + let fullDetailReporter: FileReporter | undefined; if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); if (primaryReporter) { - const presentationReporter: IReporter = - selection.reporter === 'file' - ? primaryReporter - : new DeferredOperationPresentationReporter(primaryReporter); - host.manager.addReporter(new LogLevelReporter(presentationReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + host.manager.addReporter( + new LogLevelReporter(primaryReporter, selection.logLevel, selection.reporter === 'plaintext'), + { + destination: 'stdout' + } + ); + } + + if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { + fullDetailReporter = new FileReporter({ + commonTempFolder: options.commonTempFolder, + actionName: options.actionName + }); + host.manager.addReporter(fullDetailReporter, { + destination: 'file:auto' }); } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); - if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput - ) { - host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + if (selection.reporter === 'file') { + host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { + destination: 'stderr' + }); } for (const output of selection.outputs) { @@ -689,6 +750,7 @@ export async function initializeRushReporterHostAsync( host, sink: host.getSink(), selection, + logArtifact: fullDetailReporter?.getArtifact(), closeAsync: (timeoutMs?: number) => { closePromise ??= host.manager.closeAsync(timeoutMs); return closePromise; diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 80b95dbd6aa..3f01ee36a9b 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -33,6 +33,7 @@ describe(MinimalRushConfiguration.name, () => { MinimalRushConfiguration.loadFromDefaultLocation() as MinimalRushConfiguration; expect(config.rushVersion).toEqual('4.0.0'); expect(config.useRushReporter).toBe(true); + expect(config.commonTempFolder).toBe(path.resolve(__dirname, 'sandbox', 'repo', 'common', 'temp')); }); }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 7846cb673c6..abd6884bbc2 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -234,6 +234,14 @@ describe(resolveRushReporterSelection.name, () => { ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); }); + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -325,6 +333,13 @@ describe(resolveRushReporterSelection.name, () => { ); }); + it('preserves RUSH_QUIET_MODE as a quiet reporter alias', () => { + expect(resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: 'true' }).logLevel).toBe('quiet'); + expect(() => + resolve(['build', '--reporter=plaintext'], { RUSH_QUIET_MODE: '1', RUSH_LOG_LEVEL: 'debug' }) + ).toThrow(/contradicts RUSH_LOG_LEVEL/); + }); + it('preserves legacy verbosity combinations when the reporter path is disabled', () => { expect(resolve(['build', '--quiet', '--debug'])).toMatchObject({ reporter: 'legacy', @@ -466,9 +481,61 @@ describe(initializeRushReporterHostAsync.name, () => { await initialized.closeAsync(); expect(initialized.selection.enabled).toBe(false); + expect(initialized.logArtifact).toBeUndefined(); expect(output).toBe(''); }); + it('always creates a repository full-detail log on the enabled path', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-full-log-')); + try { + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext'], + env: {}, + commonTempFolder: directory, + actionName: 'build', + stdout: { isTTY: false, write: () => undefined } + }); + + expect(initialized.logArtifact).toMatchObject({ available: true }); + expect(initialized.logArtifact?.path).toMatch( + new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ); + await initialized.closeAsync(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not render operation output at quiet plaintext log level', async () => { + let output: string = ''; + const quietHost = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=plaintext', '--log-level=quiet'], + env: {}, + stdout: { + isTTY: false, + write: (text: string) => { + output += text; + } + }, + includeDefaultFileReporter: false + }); + + emitCommandStarted(quietHost.sink); + emitOperationEvents(quietHost.sink); + quietHost.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }); + await quietHost.closeAsync(); + + expect(output).not.toContain('raw operation output'); + expect(output).toContain('rush build succeeded'); + }); + it('initializes the explicitly selected reporter and output destinations', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -505,7 +572,14 @@ describe(initializeRushReporterHostAsync.name, () => { .trim() .split('\n') .map((line: string) => JSON.parse(line) as Record); - expect(stdoutEvents.map(({ type }) => type)).toEqual(['commandStarted']); + expect(stdoutEvents.map(({ type }) => type)).toEqual([ + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStreamClosed', + 'operationCompleted' + ]); expect(fileEvents.map(({ type }) => type)).toEqual([ 'commandStarted', 'operationRegistered', diff --git a/apps/rush/src/test/sandbox/reporter-demo/README.md b/apps/rush/src/test/sandbox/reporter-demo/README.md new file mode 100644 index 00000000000..39eeccdd4fb --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -0,0 +1,25 @@ +# Direct Rush reporter demo + +Build the three reporter projects, then run the self-checking direct invocation demo: + +```sh +rush build --to @microsoft/rush +node apps/rush/src/test/sandbox/reporter-demo/run.mjs +``` + +The script runs the same `rush build --only @rushstack/rush-reporter` operation stream through legacy, +plaintext, JSON, and AI modes. It verifies JSON/AI payload-only stdout, confirms the plaintext result +contains an existing absolute full-log path, and writes captured stdout/stderr files to a temporary folder. + +For an individual invocation: + +```sh +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=plaintext +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json --log-level=debug +node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=ai +RUSH_REPORTER=legacy node apps/rush/bin/rush build --only @rushstack/rush-reporter --reporter=json +``` + +Repositories can opt in without a command-line flag by setting `"useRushReporter": true` in +`common/config/rush/experiments.json`. Remove that setting or use `RUSH_REPORTER=legacy` for immediate +rollback. diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs new file mode 100644 index 00000000000..0cf345a6314 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptFolder = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptFolder, '..', '..', '..', '..', '..', '..'); +const rushBin = path.join(repoRoot, 'apps', 'rush', 'bin', 'rush'); +const outputFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-reporter-demo-')); +const commonArgs = ['build', '--only', '@rushstack/rush-reporter']; + +function run(name, args, env = {}) { + const result = spawnSync(process.execPath, [rushBin, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8' + }); + fs.writeFileSync(path.join(outputFolder, `${name}.stdout`), result.stdout); + fs.writeFileSync(path.join(outputFolder, `${name}.stderr`), result.stderr); + if (result.status !== 0) { + throw new Error(`${name} failed with exit code ${result.status}\n${result.stderr}`); + } + return result.stdout; +} + +run('warmup', commonArgs); +const legacy = run('legacy', commonArgs); +const rollback = run('rollback', [...commonArgs, '--reporter=json'], { RUSH_REPORTER: 'legacy' }); +const normalizeDurations = (text) => text.replace(/\d+\.\d+ seconds/g, '