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 0000000000..a51b0ff897 --- /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 fc6e58fe38..19f7bf16d6 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -434,4 +434,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 c4287f8d58..348663bf53 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 fa9771ccb0..e52cbb0ad0 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); } /**