From 5ec417f4c0c8ea0222a6a7c255657e6b34e7b2a4 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:18:13 +0000 Subject: [PATCH 1/8] Add scoped session reporting and plugin API compatibility Expose the producer-facing reporting surface for @rushstack/reporter (#5858). - Add createScopedReporter and RushSessionReporting so RushSession can create scoped reporters and loggers bound to a command, operation, project, and phase - Add IScopedLogger, a presentation-free logger with no terminal handle - Pass the sink to actions through IReporterExecutionContext while exposing only emit methods, so plugins cannot inspect modes, destinations, or thresholds - Add the Rush plugin API version, manifest field, a support check, and a structured migration diagnostic for incompatible plugins - Cover scoped emission, the privacy floor, the emit-only surface, logging, the session facade, and plugin compatibility with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 61 ++++++ .../diagnostics/RushDiagnosticCodeRegistry.ts | 11 ++ libraries/reporter/src/index.ts | 13 ++ libraries/reporter/src/session/PluginApi.ts | 79 ++++++++ .../src/session/RushSessionReporting.ts | 120 ++++++++++++ .../reporter/src/session/ScopedLogger.ts | 60 ++++++ .../src/session/ScopedReporterFactory.ts | 114 +++++++++++ libraries/reporter/src/test/Session.test.ts | 182 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 17 ++ 10 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/session/PluginApi.ts create mode 100644 libraries/reporter/src/session/RushSessionReporting.ts create mode 100644 libraries/reporter/src/session/ScopedLogger.ts create mode 100644 libraries/reporter/src/session/ScopedReporterFactory.ts create mode 100644 libraries/reporter/src/test/Session.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 38bd197ff8..0cd46660d4 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -41,9 +41,18 @@ export function computeEnvelopePrivacyFloor(classifications: Iterable; diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index fad3b340c5..b548989021 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -78,6 +78,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefin defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary' }, + { + code: 'RUSH_PLUGIN_API_INCOMPATIBLE', + category: 'configuration', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary', + detailKey: 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.detail' + }, { code: 'RUSH_DEPENDENCY_TOOL_FAILED', category: 'dependency-tool', @@ -143,6 +150,10 @@ export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = diagnostic.parameters + ? Object.values(diagnostic.parameters).map((value) => value.privacy) + : []; + return sink.emit({ + protocolVersion, + sessionId, + source, + scope, + privacy: computeEnvelopePrivacyFloor(classifications), + required: diagnostic.severity === 'error', + type: 'diagnosticEmitted', + payload: diagnostic + }); + }, + + emitExtension(name: string, payload: TPayload): string { + if (!isReporterExtensionEventName(name)) { + throw new Error(`Invalid extension event name: ${JSON.stringify(name)}`); + } + return sink.emit({ + protocolVersion, + sessionId, + source, + scope, + privacy: 'public', + required: false, + type: 'extension', + payload: { name, payload } + }); + } + }; +} diff --git a/libraries/reporter/src/test/Session.test.ts b/libraries/reporter/src/test/Session.test.ts new file mode 100644 index 0000000000..8553aa5bf5 --- /dev/null +++ b/libraries/reporter/src/test/Session.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createScopedReporter, + createScopedLogger, + createRushDiagnostic, + RushSessionReporting, + ReporterManager, + RUSH_PLUGIN_API_VERSION, + isPluginApiVersionSupported, + createPluginApiIncompatibleDiagnostic, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IReporterExecutionContext, + type IRushDiagnostic, + type IScopedLogger, + type IScopedReporter +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: Record[] = []; + + public emit(event: Record): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +describe('createScopedReporter', () => { + it('emits scoped messages on the activityChanged channel', () => { + const sink: CapturingSink = new CapturingSink(); + const scope: IReporterEventScope = { commandName: 'build', projectName: '@my/project' }; + const reporter: IScopedReporter = createScopedReporter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope + }); + + reporter.emitMessage({ severity: 'info', text: 'hello' }); + expect(sink.inputs[0].type).toBe('activityChanged'); + expect(sink.inputs[0].scope).toEqual(scope); + expect(sink.inputs[0].required).toBe(false); + expect(sink.inputs[0].payload).toEqual({ kind: 'message', severity: 'info', text: 'hello' }); + }); + + it('marks warning and error messages as required', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + reporter.emitMessage({ severity: 'warning', text: 'careful' }); + reporter.emitMessage({ severity: 'error', text: 'boom' }); + expect(sink.inputs[0].required).toBe(true); + expect(sink.inputs[1].required).toBe(true); + }); + + it('emits diagnostics with the envelope privacy floor', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: 'p', privacy: 'public' }, + logPath: { value: '/tmp/x.log', privacy: 'secret' } + } + }); + reporter.emitDiagnostic(diagnostic); + expect(sink.inputs[0].type).toBe('diagnosticEmitted'); + // Least sensitive field is the floor. + expect(sink.inputs[0].privacy).toBe('public'); + expect(sink.inputs[0].required).toBe(true); + expect(sink.inputs[0].payload).toBe(diagnostic); + }); + + it('validates and wraps extension events, rejecting non-namespaced names', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + reporter.emitExtension('acme.cache-warmed', { hits: 3 }); + expect(sink.inputs[0].type).toBe('extension'); + expect(sink.inputs[0].payload).toEqual({ name: 'acme.cache-warmed', payload: { hits: 3 } }); + expect(() => reporter.emitExtension('notnamespaced', {})).toThrow(/Invalid extension event name/); + }); + + it('exposes only emit methods, hiding modes, destinations, and thresholds', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + expect(Object.keys(reporter).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + }); +}); + +describe('createScopedLogger', () => { + it('maps log methods to message severities and has no terminal handle', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + const logger: IScopedLogger = createScopedLogger(reporter); + + logger.writeLine('a'); + logger.writeDebugLine('b'); + logger.writeWarningLine('c'); + logger.writeErrorLine('d'); + + const severities: unknown[] = sink.inputs.map( + (input: Record) => (input.payload as { severity: string }).severity + ); + expect(severities).toEqual(['info', 'debug', 'warning', 'error']); + expect(Object.keys(logger)).not.toContain('terminal'); + }); +}); + +describe('RushSessionReporting', () => { + it('creates scoped reporters, loggers, and an execution context that reach reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(recording); + await manager.initializeAsync(); + + const reporting: RushSessionReporting = new RushSessionReporting({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + + expect(reporting.getSink()).toBe(manager); + + const context: IReporterExecutionContext = reporting.createExecutionContext({ commandName: 'build' }); + context.reporter.emitMessage({ severity: 'warning', text: 'from action' }); + + const logger: IScopedLogger = reporting.createScopedLogger({ projectName: '@my/project' }); + logger.writeErrorLine('from plugin'); + + await manager.flushAsync(); + + expect(recording.reported).toHaveLength(2); + expect(recording.reported[0].scope).toEqual({ commandName: 'build' }); + expect(recording.reported[1].scope).toEqual({ projectName: '@my/project' }); + }); +}); + +describe('plugin API compatibility', () => { + it('accepts a matching major and rejects a mismatched or invalid major', () => { + expect(isPluginApiVersionSupported(RUSH_PLUGIN_API_VERSION)).toBe(true); + expect(isPluginApiVersionSupported('1.4.2')).toBe(true); + expect(isPluginApiVersionSupported('2.0.0')).toBe(false); + expect(isPluginApiVersionSupported('not-a-version')).toBe(false); + }); + + it('builds a migration diagnostic for an incompatible plugin', () => { + const diagnostic: IRushDiagnostic = createPluginApiIncompatibleDiagnostic({ + pluginName: '@acme/rush-plugin', + pluginApiVersion: '2.0.0' + }); + expect(diagnostic.code).toBe('RUSH_PLUGIN_API_INCOMPATIBLE'); + expect(diagnostic.category).toBe('configuration'); + expect(diagnostic.parameters?.pluginName.value).toBe('@acme/rush-plugin'); + expect(diagnostic.parameters?.declaredApiVersion.value).toBe('2.0.0'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 95be8f5011..6fb6ad651d 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -131,7 +131,7 @@ "Prevent plugins from inspecting modes, destinations, or thresholds", "Declare the supported Rush plugin API version in plugin manifests" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 6af8cc0572..dcee4384b3 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -165,3 +165,20 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - resolver (5 combos incl newer-major), engine sink (structured + fallback), adapter bridge + chunk-split tests pass; all exports @beta Next: Feature 11/28 - Expose RushSession event sink and scoped producers to actions and plugins + +[2026-07-14] Feature 11/28 COMPLETE: RushSession event sink + scoped producers for actions/plugins + Files (new): + - session/ScopedReporterFactory.ts (createScopedReporter -> IScopedReporter; emitMessage->activityChanged {kind:'message',severity,text}, required for warning/error; emitDiagnostic->diagnosticEmitted with computeEnvelopePrivacyFloor; emitExtension validates namespaced name, wraps {name,payload}) + - session/ScopedLogger.ts (IScopedLogger writeLine/writeDebugLine/writeWarningLine/writeErrorLine; NO .terminal handle) + - session/RushSessionReporting.ts (facade: createScopedReporter/createScopedLogger; getSink(); createExecutionContext()->IReporterExecutionContext {sink, reporter}) + - session/PluginApi.ts (RUSH_PLUGIN_API_VERSION '1.0.0', IRushPluginManifest.pluginApiVersion, isPluginApiVersionSupported major-equality, createPluginApiIncompatibleDiagnostic migration diagnostic) + - test/Session.test.ts + Files (modified): diagnostics/RushDiagnosticCodeRegistry.ts (APPENDED RUSH_PLUGIN_API_INCOMPATIBLE code+templates), index.ts, api.md + Notes: + - Human messages map to activityChanged (closed union has no 'message' type; golden freezes the 15). required-by-severity so warnings/errors aren't coalesced. + - Step 3 (no modes/destinations/thresholds) satisfied structurally: scoped reporter/logger + sink are emit-only. Test asserts Object.keys == 3 emit methods. + - Same source-of-truth scoping (RushSession itself lives in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - scoped message/diagnostic/extension, privacy floor, emit-only surface, logger severities, session facade end-to-end via ReporterManager, plugin API compat + migration diagnostic tests pass; all exports @beta + Next: Feature 12/28 - Emit shadow structured lifecycle + diagnostic events without changing output From 8cd3825bbd30add7a2198a117581f4b42bfe29f5 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:18:36 +0000 Subject: [PATCH 2/8] Add rush change file for scoped session reporting Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-18-26.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json new file mode 100644 index 0000000000..68ab0a54a4 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add scoped session reporting (createScopedReporter, RushSessionReporting, IScopedLogger, execution context) and plugin API version compatibility with a migration diagnostic", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 34a3706fdfb2f8541f77b4de22df5a517fca0294 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:24:03 +0000 Subject: [PATCH 3/8] Add shadow lifecycle emission and parity helpers Add first-party lifecycle emission for @rushstack/reporter (#5858). - Add typed lifecycle payloads and a LifecycleEmitter that actions, the scheduler, and plugins use to publish session, command, and operation events plus structured diagnostics - Mark every lifecycle, result, and diagnostic event required, and keep the emitter output-neutral so legacy rendering stays the sole visible output - Add deriveExitCodeFromEvents and summarizeShadowResult to validate exit-code and result parity against the frozen baselines - Cover emission, scope merging, output neutrality, and parity with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 104 ++++++++++ libraries/reporter/src/index.ts | 16 ++ .../src/lifecycle/LifecycleEmitter.ts | 157 +++++++++++++++ .../reporter/src/lifecycle/LifecycleEvents.ts | 174 +++++++++++++++++ .../reporter/src/lifecycle/ShadowParity.ts | 103 ++++++++++ libraries/reporter/src/test/Lifecycle.test.ts | 178 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 8 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/lifecycle/LifecycleEmitter.ts create mode 100644 libraries/reporter/src/lifecycle/LifecycleEvents.ts create mode 100644 libraries/reporter/src/lifecycle/ShadowParity.ts create mode 100644 libraries/reporter/src/test/Lifecycle.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 0cd46660d4..7a3a40d2b9 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -65,6 +65,9 @@ export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number; // @beta export function deleteBootstrapHandoffFileAsync(filePath: string): Promise; +// @beta +export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number; + // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; @@ -118,6 +121,29 @@ export interface IClassifiedDiagnosticValue { readonly value: ReporterJsonValue; } +// @beta +export interface ICommandCompletedPayload { + readonly commandName: string; + readonly durationMs?: number; + readonly exitCode: number; +} + +// @beta +export interface ICommandResultPayload { + readonly commandName: string; + readonly exitCode: number; + readonly operationCounts?: { + readonly [status: string]: number; + }; + readonly succeeded: boolean; +} + +// @beta +export interface ICommandStartedPayload { + readonly argv?: readonly string[]; + readonly commandName: string; +} + // @beta export interface ICreateRushDiagnosticOptions { readonly causeDiagnosticIds?: readonly string[]; @@ -153,6 +179,15 @@ export interface IEngineSinkResolution { readonly sink: IReporterEventSink; } +// @beta +export interface ILifecycleEmitterOptions { + readonly protocolVersion?: IReporterProtocolVersion; + readonly scope?: IReporterEventScope; + readonly sessionId: string; + readonly sink: IReporterEventSink; + readonly source: IReporterEventSource; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -167,6 +202,19 @@ export interface IOldEngineOutputAdapterOptions { readonly source: IReporterEventSource; } +// @beta +export interface IOperationRegisteredPayload { + readonly operationId: string; + readonly phaseName?: string; + readonly projectName?: string; +} + +// @beta +export interface IOperationStatusChangedPayload { + readonly operationId: string; + readonly status: OperationStatus; +} + // @beta export interface IReporter { closeAsync(): Promise; @@ -398,6 +446,28 @@ export interface IScopedReporter { emitMessage(options: IScopedMessageOptions): string; } +// @beta +export interface ISessionCompletedPayload { + readonly durationMs?: number; + readonly exitCode: number; +} + +// @beta +export interface ISessionStartedPayload { + readonly cwd?: string; + readonly rushVersion: string; +} + +// @beta +export interface IShadowResultSummary { + readonly commandName?: string; + readonly exitCode: number; + readonly operationCounts: { + readonly [status: string]: number; + }; + readonly succeeded: boolean; +} + // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; @@ -410,6 +480,12 @@ export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, // @beta export function isValidRushDiagnosticCode(code: string): boolean; +// @beta +export interface IWatchCycleCompletedPayload { + readonly changedProjects?: readonly string[]; + readonly succeeded: boolean; +} + // @beta export interface IWriteBootstrapHandoffOptions { readonly directory?: string; @@ -422,6 +498,28 @@ export class LegacyFallbackSink implements IReporterEventSink { emit(): string; } +// @beta +export class LifecycleEmitter { + constructor(options: ILifecycleEmitterOptions); + // (undocumented) + emitCommandCompleted(payload: ICommandCompletedPayload): string; + // (undocumented) + emitCommandResult(payload: ICommandResultPayload): string; + // (undocumented) + emitCommandStarted(payload: ICommandStartedPayload): string; + emitDiagnostic(diagnostic: IRushDiagnostic): string; + // (undocumented) + emitOperationRegistered(payload: IOperationRegisteredPayload): string; + // (undocumented) + emitOperationStatusChanged(payload: IOperationStatusChangedPayload): string; + // (undocumented) + emitSessionCompleted(payload: ISessionCompletedPayload): string; + // (undocumented) + emitSessionStarted(payload: ISessionStartedPayload): string; + // (undocumented) + emitWatchCycleCompleted(payload: IWatchCycleCompletedPayload): string; +} + // @beta export class NdjsonDecoder { constructor(options?: INdjsonOptions); @@ -444,6 +542,9 @@ export class OldEngineOutputAdapter { capture(stream: 'stdout' | 'stderr', text: string): string[]; } +// @beta +export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; + // @beta export function parseEarlyReporterControls(argv: readonly string[], env: Record): IEarlyReporterControls; @@ -568,6 +669,9 @@ export class RushSessionReporting { getSink(): IReporterEventSink; } +// @beta +export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; + // @beta export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 4c7b11b851..8e4a589899 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -127,6 +127,22 @@ export { createPluginApiIncompatibleDiagnostic } from './session/PluginApi'; +export type { + OperationStatus, + ISessionStartedPayload, + ISessionCompletedPayload, + ICommandStartedPayload, + ICommandCompletedPayload, + IOperationRegisteredPayload, + IOperationStatusChangedPayload, + ICommandResultPayload, + IWatchCycleCompletedPayload +} from './lifecycle/LifecycleEvents'; +export type { ILifecycleEmitterOptions } from './lifecycle/LifecycleEmitter'; +export { LifecycleEmitter } from './lifecycle/LifecycleEmitter'; +export type { IShadowResultSummary } from './lifecycle/ShadowParity'; +export { deriveExitCodeFromEvents, summarizeShadowResult } from './lifecycle/ShadowParity'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/lifecycle/LifecycleEmitter.ts b/libraries/reporter/src/lifecycle/LifecycleEmitter.ts new file mode 100644 index 0000000000..b94ba13a21 --- /dev/null +++ b/libraries/reporter/src/lifecycle/LifecycleEmitter.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { computeEnvelopePrivacyFloor } from '../diagnostics/DiagnosticPrivacy'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; +import type { + ISessionStartedPayload, + ISessionCompletedPayload, + ICommandStartedPayload, + ICommandCompletedPayload, + IOperationRegisteredPayload, + IOperationStatusChangedPayload, + ICommandResultPayload, + IWatchCycleCompletedPayload +} from './LifecycleEvents'; + +/** + * Options for constructing a {@link LifecycleEmitter}. + * + * @beta + */ +export interface ILifecycleEmitterOptions { + /** + * The sink events are emitted into. + */ + readonly sink: IReporterEventSink; + + /** + * The session id stamped onto emitted events. + */ + readonly sessionId: string; + + /** + * The producer identity stamped onto emitted events. + */ + readonly source: IReporterEventSource; + + /** + * The base scope merged into every emitted event. + */ + readonly scope?: IReporterEventScope; + + /** + * The protocol version stamped onto emitted events. Defaults to + * {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; +} + +/** + * Emits the canonical first-party lifecycle and diagnostic events. + * + * @remarks + * Actions, the operation scheduler, and plugins use this to publish structured + * events. During the shadow phase these events flow to subscribers while legacy + * rendering remains the sole visible output; the emitter itself writes nothing + * to stdout or stderr. Every lifecycle, result, and diagnostic event is marked + * required so the manager never drops it. + * + * @beta + */ +export class LifecycleEmitter { + private readonly _sink: IReporterEventSink; + private readonly _sessionId: string; + private readonly _source: IReporterEventSource; + private readonly _scope: IReporterEventScope | undefined; + private readonly _protocolVersion: IReporterProtocolVersion; + + public constructor(options: ILifecycleEmitterOptions) { + this._sink = options.sink; + this._sessionId = options.sessionId; + this._source = options.source; + this._scope = options.scope; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + } + + public emitSessionStarted(payload: ISessionStartedPayload): string { + return this._emit('sessionStarted', payload, 'public'); + } + + public emitSessionCompleted(payload: ISessionCompletedPayload): string { + return this._emit('sessionCompleted', payload, 'public'); + } + + public emitCommandStarted(payload: ICommandStartedPayload): string { + return this._emit('commandStarted', payload, 'public', { commandName: payload.commandName }); + } + + public emitCommandCompleted(payload: ICommandCompletedPayload): string { + return this._emit('commandCompleted', payload, 'public', { commandName: payload.commandName }); + } + + public emitOperationRegistered(payload: IOperationRegisteredPayload): string { + return this._emit('operationRegistered', payload, 'public', { + operationId: payload.operationId, + projectName: payload.projectName, + phaseName: payload.phaseName + }); + } + + public emitOperationStatusChanged(payload: IOperationStatusChangedPayload): string { + return this._emit('operationStatusChanged', payload, 'public', { + operationId: payload.operationId + }); + } + + public emitCommandResult(payload: ICommandResultPayload): string { + return this._emit('commandResult', payload, 'public', { commandName: payload.commandName }); + } + + public emitWatchCycleCompleted(payload: IWatchCycleCompletedPayload): string { + return this._emit('watchCycleCompleted', payload, 'public'); + } + + /** + * Emits a structured diagnostic alongside the existing legacy rendering. + */ + public emitDiagnostic(diagnostic: IRushDiagnostic): string { + const classifications: ReadonlyArray<'public' | 'local-sensitive' | 'secret'> = diagnostic.parameters + ? Object.values(diagnostic.parameters).map((value) => value.privacy) + : []; + return this._emit('diagnosticEmitted', diagnostic, computeEnvelopePrivacyFloor(classifications)); + } + + private _emit( + type: + | 'sessionStarted' + | 'sessionCompleted' + | 'commandStarted' + | 'commandCompleted' + | 'operationRegistered' + | 'operationStatusChanged' + | 'commandResult' + | 'watchCycleCompleted' + | 'diagnosticEmitted', + payload: unknown, + privacy: 'public' | 'local-sensitive' | 'secret', + scopeOverride?: IReporterEventScope + ): string { + const scope: IReporterEventScope | undefined = + this._scope || scopeOverride ? { ...this._scope, ...scopeOverride } : undefined; + return this._sink.emit({ + protocolVersion: this._protocolVersion, + sessionId: this._sessionId, + source: this._source, + scope, + privacy, + required: true, + type, + payload + }); + } +} diff --git a/libraries/reporter/src/lifecycle/LifecycleEvents.ts b/libraries/reporter/src/lifecycle/LifecycleEvents.ts new file mode 100644 index 0000000000..6143457410 --- /dev/null +++ b/libraries/reporter/src/lifecycle/LifecycleEvents.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The terminal or transient status of a scheduled operation. + * + * @beta + */ +export type OperationStatus = + | 'ready' + | 'executing' + | 'success' + | 'successWithWarnings' + | 'failure' + | 'blocked' + | 'skipped' + | 'fromCache' + | 'noOp'; + +/** + * The payload of a `sessionStarted` event. + * + * @beta + */ +export interface ISessionStartedPayload { + /** + * The Rush version that started the session. + */ + readonly rushVersion: string; + + /** + * The working directory, when recorded. + */ + readonly cwd?: string; +} + +/** + * The payload of a `sessionCompleted` event. + * + * @beta + */ +export interface ISessionCompletedPayload { + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The total session duration in milliseconds. + */ + readonly durationMs?: number; +} + +/** + * The payload of a `commandStarted` event. + * + * @beta + */ +export interface ICommandStartedPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * The command arguments. + */ + readonly argv?: readonly string[]; +} + +/** + * The payload of a `commandCompleted` event. + * + * @beta + */ +export interface ICommandCompletedPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The command duration in milliseconds. + */ + readonly durationMs?: number; +} + +/** + * The payload of an `operationRegistered` event. + * + * @beta + */ +export interface IOperationRegisteredPayload { + /** + * The operation id. + */ + readonly operationId: string; + + /** + * The project the operation belongs to. + */ + readonly projectName?: string; + + /** + * The phase the operation belongs to. + */ + readonly phaseName?: string; +} + +/** + * The payload of an `operationStatusChanged` event. + * + * @beta + */ +export interface IOperationStatusChangedPayload { + /** + * The operation id. + */ + readonly operationId: string; + + /** + * The new status. + */ + readonly status: OperationStatus; +} + +/** + * The payload of a `commandResult` event. + * + * @beta + */ +export interface ICommandResultPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * Whether the command succeeded, including warning-only success. + */ + readonly succeeded: boolean; + + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * Operation counts keyed by status. + */ + readonly operationCounts?: { readonly [status: string]: number }; +} + +/** + * The payload of a `watchCycleCompleted` event. + * + * @beta + */ +export interface IWatchCycleCompletedPayload { + /** + * Whether the watch cycle succeeded. + */ + readonly succeeded: boolean; + + /** + * The projects that changed to trigger the cycle. + */ + readonly changedProjects?: readonly string[]; +} diff --git a/libraries/reporter/src/lifecycle/ShadowParity.ts b/libraries/reporter/src/lifecycle/ShadowParity.ts new file mode 100644 index 0000000000..d5b4604555 --- /dev/null +++ b/libraries/reporter/src/lifecycle/ShadowParity.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { ICommandResultPayload, IOperationStatusChangedPayload } from './LifecycleEvents'; + +/** + * A shadow-phase summary derived from the structured event stream, used to + * validate parity with legacy behavior. + * + * @beta + */ +export interface IShadowResultSummary { + /** + * The command name, when a command result was present. + */ + readonly commandName?: string; + + /** + * Whether the command succeeded. + */ + readonly succeeded: boolean; + + /** + * The derived process exit code. + */ + readonly exitCode: number; + + /** + * The number of operations that reached each status. + */ + readonly operationCounts: { readonly [status: string]: number }; +} + +/** + * Derives the process exit code from a structured event stream. + * + * @remarks + * This shadow-phase helper validates exit-code parity: a `commandResult` maps a + * successful command, including warning-only success, to `0` and a failure to + * its non-zero code. A `sessionCompleted` code is used as a fallback. The + * authoritative exit-code semantics are defined separately. + * + * @param events - the structured events emitted during the command + * + * @beta + */ +export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number { + for (const event of events) { + if (event.type === 'commandResult') { + const payload: ICommandResultPayload = event.payload as ICommandResultPayload; + if (payload.succeeded) { + return 0; + } + return payload.exitCode !== 0 ? payload.exitCode : 1; + } + } + + for (const event of events) { + if (event.type === 'sessionCompleted') { + return (event.payload as { exitCode: number }).exitCode; + } + } + + return 0; +} + +/** + * Summarizes a command's structured event stream for parity validation. + * + * @remarks + * The returned counts and result are shadow-phase parity data, not the + * allowlisted telemetry projection. + * + * @param events - the structured events emitted during the command + * + * @beta + */ +export function summarizeShadowResult( + events: readonly IReporterEventEnvelope[] +): IShadowResultSummary { + const operationCounts: { [status: string]: number } = {}; + let commandName: string | undefined; + let succeeded: boolean = true; + + for (const event of events) { + if (event.type === 'operationStatusChanged') { + const payload: IOperationStatusChangedPayload = event.payload as IOperationStatusChangedPayload; + operationCounts[payload.status] = (operationCounts[payload.status] ?? 0) + 1; + } else if (event.type === 'commandResult') { + const payload: ICommandResultPayload = event.payload as ICommandResultPayload; + commandName = payload.commandName; + succeeded = payload.succeeded; + } + } + + return { + commandName, + succeeded, + exitCode: deriveExitCodeFromEvents(events), + operationCounts + }; +} diff --git a/libraries/reporter/src/test/Lifecycle.test.ts b/libraries/reporter/src/test/Lifecycle.test.ts new file mode 100644 index 0000000000..5641185d11 --- /dev/null +++ b/libraries/reporter/src/test/Lifecycle.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + LifecycleEmitter, + deriveExitCodeFromEvents, + summarizeShadowResult, + createRushDiagnostic, + ReporterManager, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterEventSource, + type IShadowResultSummary +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: Record[] = []; + + public emit(event: Record): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +function ev(type: string, payload: unknown): IReporterEventEnvelope { + return { type, payload } as unknown as IReporterEventEnvelope; +} + +describe('LifecycleEmitter', () => { + it('emits required lifecycle events with merged scope', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + + emitter.emitOperationRegistered({ operationId: 'op1', projectName: 'p', phaseName: '_phase:build' }); + + expect(sink.inputs[0].type).toBe('operationRegistered'); + expect(sink.inputs[0].required).toBe(true); + expect(sink.inputs[0].scope).toEqual({ + commandName: 'build', + operationId: 'op1', + projectName: 'p', + phaseName: '_phase:build' + }); + }); + + it('emits diagnostics on the diagnosticEmitted channel with the privacy floor', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ sink, sessionId: 'sess', source: SOURCE }); + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { logPath: { value: '/tmp/x.log', privacy: 'local-sensitive' } } + }) + ); + expect(sink.inputs[0].type).toBe('diagnosticEmitted'); + expect(sink.inputs[0].privacy).toBe('local-sensitive'); + expect(sink.inputs[0].required).toBe(true); + }); + + it('writes nothing to stdout or stderr while events flow (shadow mode)', () => { + const stdoutSpy: jest.SpyInstance = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ sink, sessionId: 'sess', source: SOURCE }); + emitter.emitSessionStarted({ rushVersion: '5.177.2' }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED')); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + + expect(sink.inputs).toHaveLength(5); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); +}); + +describe('deriveExitCodeFromEvents', () => { + it('maps a successful command, including warning-only success, to zero', () => { + expect( + deriveExitCodeFromEvents([ + ev('operationStatusChanged', { operationId: 'op1', status: 'successWithWarnings' }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]) + ).toBe(0); + }); + + it('maps a failed command to its non-zero exit code', () => { + expect( + deriveExitCodeFromEvents([ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })]) + ).toBe(1); + }); + + it('falls back to the sessionCompleted exit code', () => { + expect(deriveExitCodeFromEvents([ev('sessionCompleted', { exitCode: 1 })])).toBe(1); + }); + + it('defaults to zero when no result is present', () => { + expect(deriveExitCodeFromEvents([ev('commandStarted', { commandName: 'build' })])).toBe(0); + }); +}); + +describe('summarizeShadowResult', () => { + it('aggregates operation statuses and the command result', () => { + const summary: IShadowResultSummary = summarizeShadowResult([ + ev('operationStatusChanged', { operationId: 'a', status: 'success' }), + ev('operationStatusChanged', { operationId: 'b', status: 'success' }), + ev('operationStatusChanged', { operationId: 'c', status: 'fromCache' }), + ev('operationStatusChanged', { operationId: 'd', status: 'failure' }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(summary.commandName).toBe('build'); + expect(summary.succeeded).toBe(false); + expect(summary.exitCode).toBe(1); + expect(summary.operationCounts).toEqual({ success: 2, fromCache: 1, failure: 1 }); + }); +}); + +describe('shadow emission parity through the manager', () => { + it('reproduces the exit code and result summary from delivered events', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + + emitter.emitSessionStarted({ rushVersion: '5.177.2' }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationStatusChanged({ operationId: 'op1', status: 'success' }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + await manager.flushAsync(); + + expect(deriveExitCodeFromEvents(reporter.reported)).toBe(0); + const summary: IShadowResultSummary = summarizeShadowResult(reporter.reported); + expect(summary.succeeded).toBe(true); + expect(summary.operationCounts).toEqual({ success: 1 }); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 6fb6ad651d..9b523e33e4 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -142,7 +142,7 @@ "Keep legacy output unchanged while events flow", "Validate telemetry and exit-code parity against the frozen baselines" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index dcee4384b3..48ad25017c 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -182,3 +182,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - scoped message/diagnostic/extension, privacy floor, emit-only surface, logger severities, session facade end-to-end via ReporterManager, plugin API compat + migration diagnostic tests pass; all exports @beta Next: Feature 12/28 - Emit shadow structured lifecycle + diagnostic events without changing output + +[2026-07-14] Feature 12/28 COMPLETE: Shadow structured lifecycle + diagnostic emission + Files (new): + - lifecycle/LifecycleEvents.ts (OperationStatus + payload interfaces: session/command started+completed, operation registered+statusChanged, commandResult, watchCycleCompleted) + - lifecycle/LifecycleEmitter.ts (emit* methods, all required=true; merges base scope + per-event scope override; emitDiagnostic uses privacy floor; writes nothing to stdout/stderr) + - lifecycle/ShadowParity.ts (deriveExitCodeFromEvents: commandResult succeeded->0 else exitCode/1, sessionCompleted fallback; summarizeShadowResult: operationCounts + result) + - test/Lifecycle.test.ts + Files (modified): index.ts, api.md + Notes: + - Step 3 "keep legacy output unchanged": emitter is output-neutral (only sink.emit). Test spies process.stdout/stderr.write and asserts not called. + - Step 4 parity: exit-code parity helper + result summary. Full telemetry allowlist deferred to Feature 13; authoritative exit-code semantics to Feature 14 (this is shadow parity derivation). + - Same source-of-truth scoping (actions/scheduler/plugins live in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - lifecycle emit + scope merge, diagnostic privacy floor, shadow output-neutrality, exit-code parity (4 cases), result summary, manager integration tests pass; all exports @beta + Next: Feature 13/28 - Telemetry projection subscriber (allowlist + beforeLog adapter) From 3af7b4fb85579e1b094df5c63a22eca1455d96fb Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:24:24 +0000 Subject: [PATCH 4/8] Add rush change file for shadow lifecycle emission Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-24-15.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json new file mode 100644 index 0000000000..d33c5fb97f --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add shadow-phase lifecycle emission: typed lifecycle payloads, a LifecycleEmitter for session/command/operation and diagnostic events, and exit-code and result parity helpers", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 3b0052ed928fadb8822b6c2bce43287cddea21f8 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:30:08 +0000 Subject: [PATCH 5/8] Add allowlisted telemetry projection subscriber Add the telemetry subscriber for @rushstack/reporter (#5858). - Add TelemetrySubscriber, which consumes canonical events before reporter filtering and produces an allowlisted aggregate at command completion - Keep only allowlisted values: command and result, timing, operation status counts, diagnostic codes and categories, reporter mode, and protocol and producer versions - Exclude messages, paths, raw output, command arguments, remediation parameters, stacks, and local-sensitive and secret values by construction - Add createTelemetryReporter to wire the subscriber into the manager - Preserve the legacy beforeLog hook through createBeforeLogAdapter - Add allowlist schema and leakage tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 41 ++++ libraries/reporter/src/index.ts | 6 + .../src/telemetry/BeforeLogAdapter.ts | 38 ++++ .../src/telemetry/TelemetryAggregate.ts | 94 +++++++++ .../src/telemetry/TelemetrySubscriber.ts | 190 ++++++++++++++++++ libraries/reporter/src/test/Telemetry.test.ts | 187 +++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 8 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/telemetry/BeforeLogAdapter.ts create mode 100644 libraries/reporter/src/telemetry/TelemetryAggregate.ts create mode 100644 libraries/reporter/src/telemetry/TelemetrySubscriber.ts create mode 100644 libraries/reporter/src/test/Telemetry.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 7a3a40d2b9..9b945e37bf 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -38,6 +38,9 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec // @beta export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; +// @beta +export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => void; + // @beta export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution; @@ -53,6 +56,9 @@ export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; // @beta export function createScopedReporter(options: ICreateScopedReporterOptions): IScopedReporter; +// @beta +export function createTelemetryReporter(subscriber: TelemetrySubscriber): IReporter; + // @beta export const DEFAULT_FLUSH_TIMEOUT_MS: number; @@ -480,6 +486,24 @@ export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, // @beta export function isValidRushDiagnosticCode(code: string): boolean; +// @beta +export interface ITelemetryAggregate { + readonly commandName?: string; + readonly diagnosticCategoryCounts: { + readonly [category: string]: number; + }; + readonly diagnosticCodes: readonly string[]; + readonly durationMs?: number; + readonly exitCode?: number; + readonly operationStatusCounts: { + readonly [status: string]: number; + }; + readonly producerVersions: readonly string[]; + readonly protocolVersion?: IReporterProtocolVersion; + readonly reporterMode?: string; + readonly result?: TelemetryResult; +} + // @beta export interface IWatchCycleCompletedPayload { readonly changedProjects?: readonly string[]; @@ -492,6 +516,9 @@ export interface IWriteBootstrapHandoffOptions { readonly pid?: number; } +// @beta +export type LegacyBeforeLogHook = (telemetry: Record) => void; + // @beta export class LegacyFallbackSink implements IReporterEventSink { // (undocumented) @@ -672,6 +699,20 @@ export class RushSessionReporting { // @beta export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; +// @beta +export const TELEMETRY_AGGREGATE_KEYS: readonly string[]; + +// @beta +export type TelemetryResult = 'succeeded' | 'failed'; + +// @beta +export class TelemetrySubscriber { + constructor(); + buildAggregate(): ITelemetryAggregate; + ingest(event: IReporterEventEnvelope): void; + setReporterMode(reporterMode: string): void; +} + // @beta export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 8e4a589899..7df4118655 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -143,6 +143,12 @@ export { LifecycleEmitter } from './lifecycle/LifecycleEmitter'; export type { IShadowResultSummary } from './lifecycle/ShadowParity'; export { deriveExitCodeFromEvents, summarizeShadowResult } from './lifecycle/ShadowParity'; +export type { TelemetryResult, ITelemetryAggregate } from './telemetry/TelemetryAggregate'; +export { TELEMETRY_AGGREGATE_KEYS } from './telemetry/TelemetryAggregate'; +export { TelemetrySubscriber, createTelemetryReporter } from './telemetry/TelemetrySubscriber'; +export type { LegacyBeforeLogHook } from './telemetry/BeforeLogAdapter'; +export { createBeforeLogAdapter } from './telemetry/BeforeLogAdapter'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/telemetry/BeforeLogAdapter.ts b/libraries/reporter/src/telemetry/BeforeLogAdapter.ts new file mode 100644 index 0000000000..f2195748a1 --- /dev/null +++ b/libraries/reporter/src/telemetry/BeforeLogAdapter.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ITelemetryAggregate } from './TelemetryAggregate'; + +/** + * The legacy `beforeLog` telemetry hook signature. + * + * @remarks + * Existing telemetry consumers register a `beforeLog` hook that runs with the + * telemetry record before it is written. The hook receives a plain object. + * + * @beta + */ +export type LegacyBeforeLogHook = (telemetry: Record) => void; + +/** + * Adapts the allowlisted telemetry aggregate to the legacy `beforeLog` hook. + * + * @remarks + * During migration the existing `beforeLog` hook is preserved: the adapter runs + * each legacy hook with a plain-object copy of the new aggregate, so no hook + * observes non-allowlisted data. + * + * @param hooks - the legacy hooks to preserve + * + * @beta + */ +export function createBeforeLogAdapter( + hooks: readonly LegacyBeforeLogHook[] +): (aggregate: ITelemetryAggregate) => void { + return (aggregate: ITelemetryAggregate): void => { + const record: Record = { ...aggregate }; + for (const hook of hooks) { + hook(record); + } + }; +} diff --git a/libraries/reporter/src/telemetry/TelemetryAggregate.ts b/libraries/reporter/src/telemetry/TelemetryAggregate.ts new file mode 100644 index 0000000000..d54fa16977 --- /dev/null +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; + +/** + * The command result recorded by telemetry. + * + * @beta + */ +export type TelemetryResult = 'succeeded' | 'failed'; + +/** + * The allowlisted telemetry aggregate produced at command completion. + * + * @remarks + * Only these fields ever leave the machine. Messages, templates, paths, raw + * stdout/stderr, command arguments, remediation parameters, stack traces, and + * any local-sensitive or secret values are excluded by construction. + * + * @beta + */ +export interface ITelemetryAggregate { + /** + * The command name. + */ + readonly commandName?: string; + + /** + * Whether the command succeeded or failed. + */ + readonly result?: TelemetryResult; + + /** + * The process exit code. + */ + readonly exitCode?: number; + + /** + * The command or session duration in milliseconds. + */ + readonly durationMs?: number; + + /** + * The number of operations that reached each status. + */ + readonly operationStatusCounts: { readonly [status: string]: number }; + + /** + * The distinct diagnostic codes emitted, sorted. + */ + readonly diagnosticCodes: readonly string[]; + + /** + * The number of diagnostics emitted in each category. + */ + readonly diagnosticCategoryCounts: { readonly [category: string]: number }; + + /** + * The selected reporter mode. + */ + readonly reporterMode?: string; + + /** + * The reporter protocol version. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The distinct `packageName@packageVersion` producers observed, sorted. + */ + readonly producerVersions: readonly string[]; +} + +/** + * The complete set of allowlisted telemetry aggregate keys. + * + * @remarks + * Used to assert that no non-allowlisted field ever appears in the aggregate. + * + * @beta + */ +export const TELEMETRY_AGGREGATE_KEYS: readonly string[] = [ + 'commandName', + 'result', + 'exitCode', + 'durationMs', + 'operationStatusCounts', + 'diagnosticCodes', + 'diagnosticCategoryCounts', + 'reporterMode', + 'protocolVersion', + 'producerVersions' +]; diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts new file mode 100644 index 0000000000..fa38ffc7b2 --- /dev/null +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporter } from '../manager/IReporter'; +import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate'; + +/** + * Consumes canonical events and produces the allowlisted telemetry aggregate. + * + * @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. + * + * @beta + */ +export class TelemetrySubscriber { + private _commandName: string | undefined; + private _result: TelemetryResult | undefined; + private _exitCode: number | undefined; + private _durationMs: number | undefined; + private _reporterMode: string | undefined; + private _protocolVersion: IReporterProtocolVersion | undefined; + private readonly _operationStatusCounts: { [status: string]: number }; + private readonly _diagnosticCategoryCounts: { [category: string]: number }; + private readonly _diagnosticCodes: Set; + private readonly _producerVersions: Set; + + public constructor() { + this._operationStatusCounts = {}; + this._diagnosticCategoryCounts = {}; + this._diagnosticCodes = new Set(); + this._producerVersions = new Set(); + } + + /** + * Records the selected reporter mode. + */ + public setReporterMode(reporterMode: string): void { + this._reporterMode = reporterMode; + } + + /** + * 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}`); + + switch (event.type) { + case 'commandStarted': { + // Deliberately ignores argv. + this._commandName = (event.payload as { commandName: string }).commandName; + break; + } + case 'commandResult': { + const payload: { commandName: string; succeeded: boolean; exitCode: number } = event.payload as { + commandName: string; + succeeded: boolean; + exitCode: number; + }; + this._commandName = payload.commandName; + this._result = payload.succeeded ? 'succeeded' : 'failed'; + this._exitCode = payload.exitCode; + break; + } + case 'commandCompleted': { + const payload: { durationMs?: number } = event.payload as { durationMs?: number }; + if (payload.durationMs !== undefined) { + this._durationMs = payload.durationMs; + } + break; + } + case 'sessionCompleted': { + const payload: { exitCode: number; durationMs?: number } = event.payload as { + exitCode: number; + durationMs?: number; + }; + if (this._exitCode === undefined) { + this._exitCode = payload.exitCode; + } + if (payload.durationMs !== undefined) { + this._durationMs = payload.durationMs; + } + break; + } + case 'operationStatusChanged': { + const status: string = (event.payload as { status: string }).status; + this._operationStatusCounts[status] = (this._operationStatusCounts[status] ?? 0) + 1; + 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. + break; + } + } + } + + /** + * Builds the allowlisted aggregate. + */ + public buildAggregate(): ITelemetryAggregate { + const aggregate: { + commandName?: string; + result?: TelemetryResult; + exitCode?: number; + durationMs?: number; + operationStatusCounts: { [status: string]: number }; + diagnosticCodes: string[]; + diagnosticCategoryCounts: { [category: string]: number }; + reporterMode?: string; + protocolVersion?: IReporterProtocolVersion; + producerVersions: string[]; + } = { + operationStatusCounts: { ...this._operationStatusCounts }, + diagnosticCodes: [...this._diagnosticCodes].sort(), + diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, + producerVersions: [...this._producerVersions].sort() + }; + + if (this._commandName !== undefined) { + aggregate.commandName = this._commandName; + } + if (this._result !== undefined) { + aggregate.result = this._result; + } + if (this._exitCode !== undefined) { + aggregate.exitCode = this._exitCode; + } + if (this._durationMs !== undefined) { + aggregate.durationMs = this._durationMs; + } + if (this._reporterMode !== undefined) { + aggregate.reporterMode = this._reporterMode; + } + if (this._protocolVersion !== undefined) { + aggregate.protocolVersion = this._protocolVersion; + } + + return aggregate; + } +} + +/** + * Wraps a telemetry subscriber as a reporter so it can be registered with the + * manager and observe every event before reporter filtering. + * + * @remarks + * The returned reporter owns no destination and renders nothing. + * + * @param subscriber - the telemetry subscriber to feed + * + * @beta + */ +export function createTelemetryReporter(subscriber: TelemetrySubscriber): IReporter { + return { + name: 'telemetry', + async initializeAsync(): Promise { + /* no-op */ + }, + report(event: IReporterEventEnvelope): void { + subscriber.ingest(event); + }, + async flushAsync(): Promise { + /* no-op */ + }, + async closeAsync(): Promise { + /* no-op */ + } + }; +} diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts new file mode 100644 index 0000000000..890383a7bf --- /dev/null +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + TelemetrySubscriber, + createTelemetryReporter, + createBeforeLogAdapter, + TELEMETRY_AGGREGATE_KEYS, + LifecycleEmitter, + ReporterManager, + createRushDiagnostic, + type IReporter, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSource, + type ITelemetryAggregate, + type LegacyBeforeLogHook +} from '../index'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +function rawInput(type: string, payload: unknown): IReporterEmitEventInput { + return { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: SOURCE, + privacy: 'public', + required: false, + type: type as IReporterEmitEventInput['type'], + payload + }; +} + +describe('TelemetrySubscriber', () => { + it('produces an allowlisted aggregate from the event stream before reporter filtering', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + telemetry.setReporterMode('default'); + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(createTelemetryReporter(telemetry)); + manager.addReporter(recording); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--to', 'x'] }); + emitter.emitOperationStatusChanged({ operationId: 'op1', status: 'success' }); + emitter.emitOperationStatusChanged({ operationId: 'op2', status: 'fromCache' }); + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED')); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0, durationMs: 1234 }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0, durationMs: 1500 }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.commandName).toBe('build'); + expect(aggregate.result).toBe('succeeded'); + expect(aggregate.exitCode).toBe(0); + expect(aggregate.durationMs).toBe(1500); + expect(aggregate.operationStatusCounts).toEqual({ success: 1, fromCache: 1 }); + 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.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); + + // The subscriber runs alongside a rendering reporter and does not consume events from it. + expect(recording.reported.length).toBeGreaterThan(0); + }); + + it('only ever contains allowlisted keys', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + await manager.flushAsync(); + + for (const key of Object.keys(telemetry.buildAggregate())) { + expect(TELEMETRY_AGGREGATE_KEYS).toContain(key); + } + }); + + 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'; + const ARG: string = '--auth-token=abc123'; + const MESSAGE: string = 'verbose diagnostic message text'; + const REMEDIATION_COMMAND: string = 'rush update --purge-and-leak'; + + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + emitter.emitCommandStarted({ commandName: 'build', argv: [ARG] }); + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: SECRET, privacy: 'secret' }, + logPath: { value: LOG_PATH, privacy: 'local-sensitive' } + }, + remediation: [ + { descriptionKey: 'r', command: REMEDIATION_COMMAND, automatedExecutionSafety: 'unsafe' } + ] + }) + ); + manager.emit(rawInput('externalOutput', { stream: 'stdout', text: `${SECRET} raw output` })); + manager.emit(rawInput('activityChanged', { kind: 'message', severity: 'info', text: MESSAGE })); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [SECRET, LOG_PATH, ARG, MESSAGE, REMEDIATION_COMMAND]) { + expect(serialized).not.toContain(forbidden); + } + // But the allowlisted diagnostic code and category are retained. + expect(aggregate.diagnosticCodes).toEqual(['RUSH_DEPENDENCY_TOOL_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ 'dependency-tool': 1 }); + expect(aggregate.result).toBe('failed'); + for (const key of Object.keys(aggregate)) { + expect(TELEMETRY_AGGREGATE_KEYS).toContain(key); + } + }); +}); + +describe('createBeforeLogAdapter', () => { + it('runs legacy hooks with a plain copy of the aggregate', () => { + const observed: Record[] = []; + const hook: LegacyBeforeLogHook = (telemetry: Record) => { + observed.push(telemetry); + }; + const adapter: (aggregate: ITelemetryAggregate) => void = createBeforeLogAdapter([hook]); + + const aggregate: ITelemetryAggregate = { + commandName: 'build', + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 2 }, + diagnosticCodes: [], + diagnosticCategoryCounts: {}, + producerVersions: ['@microsoft/rush-lib@5.177.2'] + }; + adapter(aggregate); + + expect(observed).toHaveLength(1); + expect(observed[0]).toEqual({ ...aggregate }); + // The hook receives a copy, not the aggregate itself. + expect(observed[0]).not.toBe(aggregate); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 9b523e33e4..e9614673b4 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -154,7 +154,7 @@ "Preserve the existing beforeLog hook through an adapter", "Add allowlist schema and leakage tests" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 48ad25017c..600a8dd467 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -198,3 +198,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - lifecycle emit + scope merge, diagnostic privacy floor, shadow output-neutrality, exit-code parity (4 cases), result summary, manager integration tests pass; all exports @beta Next: Feature 13/28 - Telemetry projection subscriber (allowlist + beforeLog adapter) + +[2026-07-14] Feature 13/28 COMPLETE: Telemetry projection subscriber (allowlist + beforeLog adapter) + Files (new): + - telemetry/TelemetryAggregate.ts (ITelemetryAggregate allowlist: commandName/result/exitCode/durationMs/operationStatusCounts/diagnosticCodes/diagnosticCategoryCounts/reporterMode/protocolVersion/producerVersions; TELEMETRY_AGGREGATE_KEYS) + - telemetry/TelemetrySubscriber.ts (ingest extracts only allowlisted values - diagnostics keep code+category only, ignores argv/messages/external/artifacts; buildAggregate; setReporterMode; createTelemetryReporter adapter = IReporter with no destination, feeds before reporter filtering) + - telemetry/BeforeLogAdapter.ts (LegacyBeforeLogHook + createBeforeLogAdapter runs hooks with plain copy of aggregate) + - test/Telemetry.test.ts + Files (modified): index.ts, api.md + Notes: + - "Before reporter filtering": manager fans full events to all reporters (each filters internally), so telemetry-as-reporter sees unfiltered events. + - Leakage test emits secret param/path/argv/message/remediation-command/raw-output; asserts JSON.stringify(aggregate) contains none; keys subset of allowlist. + - Same source-of-truth scoping (Rush telemetry lives in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - aggregate build, allowlist schema, leakage (5 forbidden strings absent), beforeLog adapter (plain copy) tests pass; all exports @beta + Next: Feature 14/28 - Preserve command success + exit-code semantics independent of reporters From 6a93ed73f31f75d9e7e8aa7475c2cfd05cf37b6a Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:30:30 +0000 Subject: [PATCH 6/8] Add rush change file for telemetry subscriber Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-30-21.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json new file mode 100644 index 0000000000..5d5907b71f --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the telemetry projection subscriber that produces an allowlisted aggregate from canonical events, a reporter adapter to observe events before filtering, and a beforeLog adapter", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From b500e1b07ed854698a131a7bd3a14a1731115f02 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:34:50 +0000 Subject: [PATCH 7/8] Add reporter-independent exit-code semantics Preserve command success and exit-code semantics for @rushstack/reporter (#5858). - Add resolveExitStatus and resolveExitStatusFromEvents, which return 0 for success including warning-only success, 1 for failure and logical cancellation, and the conventional signal-derived status for OS signals - Take only failure, cancellation, and signal state as inputs, so the reporter mode and diagnostic categories can never select the exit code - Add separateJsonControls so the command-specific --json flag stays distinct from the json reporter and keeps its own schema - Cover success, failure, cancellation, signal precedence, category independence, and JSON-control separation with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 47 +++++ libraries/reporter/src/exit/CommandJson.ts | 50 ++++++ libraries/reporter/src/exit/ExitStatus.ts | 164 ++++++++++++++++++ libraries/reporter/src/index.ts | 16 ++ .../reporter/src/test/ExitStatus.test.ts | 130 ++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 7 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/exit/CommandJson.ts create mode 100644 libraries/reporter/src/exit/ExitStatus.ts create mode 100644 libraries/reporter/src/test/ExitStatus.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 9b945e37bf..43d771684b 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -77,9 +77,18 @@ export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; +// @beta +export const EXIT_CODE_FAILURE: 1; + +// @beta +export const EXIT_CODE_SUCCESS: 0; + // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; +// @beta +export function getSignalExitCode(signal: NodeJS.Signals): number; + // @beta export interface IBootstrapEventBufferOptions { readonly maxBytes?: number; @@ -185,6 +194,12 @@ export interface IEngineSinkResolution { readonly sink: IReporterEventSink; } +// @beta +export interface IJsonControls { + readonly commandJson: boolean; + readonly reporterJson: boolean; +} + // @beta export interface ILifecycleEmitterOptions { readonly protocolVersion?: IReporterProtocolVersion; @@ -370,6 +385,19 @@ export interface IReporterRegistrationOptions { readonly required?: boolean; } +// @beta +export interface IResolveExitStatusFromEventsOptions { + readonly cancelled?: boolean; + readonly signal?: NodeJS.Signals; +} + +// @beta +export interface IResolveExitStatusOptions { + readonly cancelled?: boolean; + readonly hasFailures?: boolean; + readonly signal?: NodeJS.Signals; +} + // @beta export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; @@ -405,6 +433,13 @@ export interface IRushDiagnosticSource { readonly toolName?: string; } +// @beta +export interface IRushExitStatus { + readonly exitCode: number; + readonly outcome: RushCommandOutcome; + readonly signal?: NodeJS.Signals; +} + // @beta export interface IRushPluginManifest { readonly pluginApiVersion: string; @@ -648,6 +683,12 @@ export class ReporterMultiplexer implements IReporter { // @beta export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; +// @beta +export function resolveExitStatus(options: IResolveExitStatusOptions): IRushExitStatus; + +// @beta +export function resolveExitStatusFromEvents(events: readonly IReporterEventEnvelope[], options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; + // @beta export function resolveReporterCompatibility(frontend: IReporterFrontendDescriptor, engine: IReporterEngineDescriptor): IReporterCompatibilityDecision; @@ -671,6 +712,9 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; +// @beta +export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; + // @beta export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; @@ -696,6 +740,9 @@ export class RushSessionReporting { getSink(): IReporterEventSink; } +// @beta +export function separateJsonControls(argv: readonly string[]): IJsonControls; + // @beta export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; diff --git a/libraries/reporter/src/exit/CommandJson.ts b/libraries/reporter/src/exit/CommandJson.ts new file mode 100644 index 0000000000..2e3d14cbb9 --- /dev/null +++ b/libraries/reporter/src/exit/CommandJson.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The independently-resolved JSON controls on a command line. + * + * @beta + */ +export interface IJsonControls { + /** + * Whether the command-specific `--json` flag was requested. This selects the + * command's own JSON schema and is unchanged by the reporter system. + */ + readonly commandJson: boolean; + + /** + * Whether the `json` reporter was requested through `--reporter`. + */ + readonly reporterJson: boolean; +} + +/** + * Separates the command-specific `--json` flag from the `json` reporter selection. + * + * @remarks + * The command-specific `--json` behavior is preserved and is never an alias for + * `--reporter=json`. Both may be requested independently, and each keeps its own + * output schema. + * + * @param argv - the command-line arguments, excluding the node executable and script + * + * @beta + */ +export function separateJsonControls(argv: readonly string[]): IJsonControls { + let commandJson: boolean = false; + let reporterJson: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg === '--json') { + commandJson = true; + } else if (arg === '--reporter=json') { + reporterJson = true; + } else if (arg === '--reporter' && argv[index + 1] === 'json') { + reporterJson = true; + } + } + + return { commandJson, reporterJson }; +} diff --git a/libraries/reporter/src/exit/ExitStatus.ts b/libraries/reporter/src/exit/ExitStatus.ts new file mode 100644 index 0000000000..b675fc8ebf --- /dev/null +++ b/libraries/reporter/src/exit/ExitStatus.ts @@ -0,0 +1,164 @@ +// 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 { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; + +/** + * The exit code returned for success, including warning-only success. + * + * @beta + */ +export const EXIT_CODE_SUCCESS: 0 = 0; + +/** + * The exit code returned for a Rush or operation failure and for logical cancellation. + * + * @beta + */ +export const EXIT_CODE_FAILURE: 1 = 1; + +/** + * The logical outcome of a command, independent of presentation. + * + * @beta + */ +export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; + +/** + * The resolved exit status of a command. + * + * @beta + */ +export interface IRushExitStatus { + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The logical outcome. + */ + readonly outcome: RushCommandOutcome; + + /** + * The terminating OS signal, when the outcome is `signal`. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Returns the conventional exit code for an OS signal, `128 + signalNumber`. + * + * @param signal - the terminating signal + * + * @beta + */ +export function getSignalExitCode(signal: NodeJS.Signals): number { + const signalNumber: number | undefined = (os.constants.signals as Record)[signal]; + return 128 + (signalNumber ?? 0); +} + +/** + * Options for {@link resolveExitStatus}. + * + * @beta + */ +export interface IResolveExitStatusOptions { + /** + * Whether the command had a Rush or operation failure. Warnings alone are not failures. + */ + readonly hasFailures?: boolean; + + /** + * Whether the command was logically cancelled or aborted. + */ + readonly cancelled?: boolean; + + /** + * The terminating OS signal, if any. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Resolves a command's exit status from its outcome. + * + * @remarks + * A signal termination yields the conventional signal-derived status. + * Cancellation and failure yield `1`. Everything else, including warning-only + * success, yields `0`. The reporter mode and diagnostic categories are never + * inputs, so they can never select the exit code. + * + * @param options - the command outcome + * + * @beta + */ +export function resolveExitStatus(options: IResolveExitStatusOptions): IRushExitStatus { + if (options.signal) { + return { exitCode: getSignalExitCode(options.signal), outcome: 'signal', signal: options.signal }; + } + if (options.cancelled) { + return { exitCode: EXIT_CODE_FAILURE, outcome: 'cancelled' }; + } + if (options.hasFailures) { + return { exitCode: EXIT_CODE_FAILURE, outcome: 'failed' }; + } + return { exitCode: EXIT_CODE_SUCCESS, outcome: 'succeeded' }; +} + +/** + * Options for {@link resolveExitStatusFromEvents}. + * + * @beta + */ +export interface IResolveExitStatusFromEventsOptions { + /** + * Whether the command was logically cancelled or aborted. + */ + readonly cancelled?: boolean; + + /** + * The terminating OS signal, if any. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Resolves a command's exit status from its structured event stream. + * + * @remarks + * A failure is any failed command result, any error-severity diagnostic, or any + * failed operation. Diagnostic categories and the selected reporter are never + * consulted, so they cannot influence the exit code. Warning-severity + * diagnostics never cause failure. + * + * @param events - the structured events emitted during the command + * @param options - cancellation and signal state + * + * @beta + */ +export function resolveExitStatusFromEvents( + events: readonly IReporterEventEnvelope[], + options: IResolveExitStatusFromEventsOptions = {} +): IRushExitStatus { + let hasFailures: boolean = false; + for (const event of events) { + if (event.type === 'commandResult') { + if ((event.payload as { succeeded: boolean }).succeeded === false) { + hasFailures = true; + } + } else if (event.type === 'diagnosticEmitted') { + if ((event.payload as { severity?: string }).severity === 'error') { + hasFailures = true; + } + } else if (event.type === 'operationStatusChanged') { + if ((event.payload as { status?: string }).status === 'failure') { + hasFailures = true; + } + } + } + + return resolveExitStatus({ hasFailures, cancelled: options.cancelled, signal: options.signal }); +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 7df4118655..a13aaf1b37 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -149,6 +149,22 @@ export { TelemetrySubscriber, createTelemetryReporter } from './telemetry/Teleme export type { LegacyBeforeLogHook } from './telemetry/BeforeLogAdapter'; export { createBeforeLogAdapter } from './telemetry/BeforeLogAdapter'; +export type { + RushCommandOutcome, + IRushExitStatus, + IResolveExitStatusOptions, + IResolveExitStatusFromEventsOptions +} from './exit/ExitStatus'; +export { + EXIT_CODE_SUCCESS, + EXIT_CODE_FAILURE, + getSignalExitCode, + resolveExitStatus, + resolveExitStatusFromEvents +} from './exit/ExitStatus'; +export type { IJsonControls } from './exit/CommandJson'; +export { separateJsonControls } from './exit/CommandJson'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/ExitStatus.test.ts b/libraries/reporter/src/test/ExitStatus.test.ts new file mode 100644 index 0000000000..83942e5ff6 --- /dev/null +++ b/libraries/reporter/src/test/ExitStatus.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + resolveExitStatus, + resolveExitStatusFromEvents, + getSignalExitCode, + separateJsonControls, + EXIT_CODE_SUCCESS, + EXIT_CODE_FAILURE, + type IJsonControls, + type IReporterEventEnvelope, + type IRushExitStatus +} from '../index'; + +function ev(type: string, payload: unknown): IReporterEventEnvelope { + return { type, payload } as unknown as IReporterEventEnvelope; +} + +describe('resolveExitStatus', () => { + it('returns success for a clean run', () => { + expect(resolveExitStatus({})).toEqual({ exitCode: EXIT_CODE_SUCCESS, outcome: 'succeeded' }); + }); + + it('treats warning-only success as success', () => { + // Warnings are not failures, so hasFailures stays false. + expect(resolveExitStatus({ hasFailures: false }).exitCode).toBe(0); + }); + + it('returns failure for failures and for logical cancellation', () => { + expect(resolveExitStatus({ hasFailures: true })).toEqual({ + exitCode: EXIT_CODE_FAILURE, + outcome: 'failed' + }); + expect(resolveExitStatus({ cancelled: true })).toEqual({ + exitCode: EXIT_CODE_FAILURE, + outcome: 'cancelled' + }); + }); + + it('returns the signal-derived status, which takes precedence over failures', () => { + const status: IRushExitStatus = resolveExitStatus({ hasFailures: true, signal: 'SIGINT' }); + expect(status.outcome).toBe('signal'); + expect(status.signal).toBe('SIGINT'); + expect(status.exitCode).toBe(130); + }); +}); + +describe('getSignalExitCode', () => { + it('uses the conventional 128 + signal number', () => { + expect(getSignalExitCode('SIGINT')).toBe(130); + expect(getSignalExitCode('SIGTERM')).toBe(143); + }); +}); + +describe('resolveExitStatusFromEvents', () => { + it('maps warning-only diagnostics with a successful result to success', () => { + const status: IRushExitStatus = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'RUSH_X', category: 'operation', severity: 'warning' }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]); + expect(status.exitCode).toBe(0); + expect(status.outcome).toBe('succeeded'); + }); + + it('fails on an error diagnostic, a failed operation, or a failed result', () => { + expect( + resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'E', category: 'internal', severity: 'error' }) + ]).exitCode + ).toBe(1); + expect( + resolveExitStatusFromEvents([ev('operationStatusChanged', { operationId: 'a', status: 'failure' })]) + .exitCode + ).toBe(1); + expect( + resolveExitStatusFromEvents([ev('commandResult', { commandName: 'b', succeeded: false, exitCode: 1 })]) + .exitCode + ).toBe(1); + }); + + it('never lets the diagnostic category select the exit code', () => { + const errorInternal: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'A', category: 'internal', severity: 'error' }) + ]).exitCode; + const errorConfig: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'B', category: 'configuration', severity: 'error' }) + ]).exitCode; + expect(errorInternal).toBe(errorConfig); + + const warnInternal: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'C', category: 'internal', severity: 'warning' }) + ]).exitCode; + const warnConfig: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'D', category: 'network-auth', severity: 'warning' }) + ]).exitCode; + expect(warnInternal).toBe(0); + expect(warnConfig).toBe(0); + }); + + it('honors cancellation and signal state', () => { + expect(resolveExitStatusFromEvents([], { cancelled: true }).outcome).toBe('cancelled'); + expect(resolveExitStatusFromEvents([], { signal: 'SIGTERM' }).exitCode).toBe(143); + }); +}); + +describe('separateJsonControls', () => { + it('keeps command-specific --json separate from the json reporter', () => { + expect(separateJsonControls(['list', '--json'])).toEqual({ + commandJson: true, + reporterJson: false + }); + expect(separateJsonControls(['build', '--reporter=json'])).toEqual({ + commandJson: false, + reporterJson: true + }); + expect(separateJsonControls(['build', '--reporter', 'json'])).toEqual({ + commandJson: false, + reporterJson: true + }); + expect(separateJsonControls(['list', '--json', '--reporter=json'])).toEqual({ + commandJson: true, + reporterJson: true + }); + expect(separateJsonControls(['build'])).toEqual({ + commandJson: false, + reporterJson: false + }); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index e9614673b4..5439ccdeec 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -166,7 +166,7 @@ "Ensure reporters and diagnostic categories never select exit codes", "Preserve command-specific JSON schemas" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 600a8dd467..eb21e760bb 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -214,3 +214,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - aggregate build, allowlist schema, leakage (5 forbidden strings absent), beforeLog adapter (plain copy) tests pass; all exports @beta Next: Feature 14/28 - Preserve command success + exit-code semantics independent of reporters + +[2026-07-14] Feature 14/28 COMPLETE: Authoritative command success + exit-code semantics + Files (new): + - exit/ExitStatus.ts (EXIT_CODE_SUCCESS=0/FAILURE=1; RushCommandOutcome succeeded|failed|cancelled|signal; getSignalExitCode=128+os.constants.signals[sig]; resolveExitStatus(hasFailures/cancelled/signal); resolveExitStatusFromEvents - failure = failed commandResult OR error diagnostic OR failed operation; category & reporter never inputs; precedence signal>cancelled>failure) + - exit/CommandJson.ts (separateJsonControls -> {commandJson (--json), reporterJson (--reporter=json/--reporter json)} independent) + - test/ExitStatus.test.ts + Files (modified): index.ts, api.md + Notes: + - Warning-only success -> 0 (warnings not failures). Signal-derived status conventional (SIGINT->130, SIGTERM->143). + - Category-independence proven: error diagnostics of any category ->1; warnings of any category ->0. + - Command-specific --json preserved and NOT an alias for --reporter=json. + - Distinct from Feature 12 shadow deriveExitCodeFromEvents (this is authoritative full outcome incl cancel/signal). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - resolveExitStatus (success/warning-only/failure/cancel/signal precedence), signal codes, from-events (warning/error/op-failure/failed-result/category-independence/cancel/signal), separateJsonControls (5 cases) tests pass; all exports @beta + Next: Feature 15/28 - Reporter selection + configuration controls with precedence From 9a7365deb45675ea078fa41efcf6743f808c4410 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:35:13 +0000 Subject: [PATCH 8/8] Add rush change file for exit-code semantics Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-35-03.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json new file mode 100644 index 0000000000..3eb683e0e4 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add reporter-independent exit-code semantics (resolveExitStatus, resolveExitStatusFromEvents, getSignalExitCode) and separateJsonControls to keep command-specific --json distinct from the json reporter", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +}