diff --git a/README.md b/README.md index 8a54e1dea4e..06373a7c3d2 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/libraries/package-deps-hash](./libraries/package-deps-hash/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-deps-hash.svg)](https://badge.fury.io/js/%40rushstack%2Fpackage-deps-hash) | [changelog](./libraries/package-deps-hash/CHANGELOG.md) | [@rushstack/package-deps-hash](https://www.npmjs.com/package/@rushstack/package-deps-hash) | | [/libraries/package-extractor](./libraries/package-extractor/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor.svg)](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor) | [changelog](./libraries/package-extractor/CHANGELOG.md) | [@rushstack/package-extractor](https://www.npmjs.com/package/@rushstack/package-extractor) | | [/libraries/problem-matcher](./libraries/problem-matcher/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) | +| [/libraries/reporter](./libraries/reporter/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Freporter.svg)](https://badge.fury.io/js/%40rushstack%2Freporter) | [changelog](./libraries/reporter/CHANGELOG.md) | [@rushstack/reporter](https://www.npmjs.com/package/@rushstack/reporter) | | [/libraries/rig-package](./libraries/rig-package/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) | | [/libraries/rush-lib](./libraries/rush-lib/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush-lib.svg)](https://badge.fury.io/js/%40microsoft%2Frush-lib) | | [@microsoft/rush-lib](https://www.npmjs.com/package/@microsoft/rush-lib) | | [/libraries/rush-pnpm-kit-v10](./libraries/rush-pnpm-kit-v10/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10.svg)](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10) | [changelog](./libraries/rush-pnpm-kit-v10/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v10](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v10) | diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-22-55-36.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-22-55-36.json new file mode 100644 index 00000000000..9246a208764 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-22-55-36.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Initial public beta scaffold of the @rushstack/reporter package", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-09-57.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-09-57.json new file mode 100644 index 00000000000..e723fc15a89 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-09-57.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the canonical reporter event envelope, closed core event type union, privacy classification, and JSON payload types", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-16-33.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-16-33.json new file mode 100644 index 00000000000..132b498ea17 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-16-33.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the scoped producer API (IReporterEventSink, IScopedReporter) and namespaced extension event names", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-26-09.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-26-09.json new file mode 100644 index 00000000000..9eea730a380 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-26-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add structured diagnostics: category, remediation, and source types, a central RUSH__ code registry with English templates, field privacy floor helpers, createRushDiagnostic, and RushError", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-32-18.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-32-18.json new file mode 100644 index 00000000000..4da29f76ce1 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-32-18.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the NDJSON wire protocol: protocol version and byte limits, an NDJSON encoder and streaming decoder, and hello/helloAck capability negotiation with an update-global-Rush diagnostic", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-41-00.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-41-00.json new file mode 100644 index 00000000000..e51bebb18a1 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-41-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add ReporterManager and IReporter: monotonic session ordering, per-reporter queues, exclusive destination ownership with a multiplexer, timed flush/close, status coalescing, and optional/required failure handling", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-51-22.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-51-22.json new file mode 100644 index 00000000000..8fcf41fee0a --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-14-23-51-22.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add protocol, compatibility, and legacy output golden tests (test-only baselines)", + "type": "none" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 6b7da175d86..180983c8e96 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4001,6 +4001,18 @@ importers: specifier: ~9.37.0 version: 9.37.0 + ../../../libraries/reporter: + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../libraries/rig-package: dependencies: jju: diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md new file mode 100644 index 00000000000..cddd21971e1 --- /dev/null +++ b/common/reviews/api/reporter.api.md @@ -0,0 +1,333 @@ +## API Report File for "@rushstack/reporter" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @beta +export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; + +// @beta +export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; + +// @beta +export const DEFAULT_FLUSH_TIMEOUT_MS: number; + +// @beta +export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number; + +// @beta +export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; + +// @beta +export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; + +// @beta +export interface IClassifiedDiagnosticValue { + readonly privacy: ReporterPrivacyClassification; + readonly value: ReporterJsonValue; +} + +// @beta +export interface ICreateRushDiagnosticOptions { + readonly causeDiagnosticIds?: readonly string[]; + readonly diagnosticId?: string; + readonly parameters?: { + readonly [name: string]: IClassifiedDiagnosticValue; + }; + readonly relatedArtifactIds?: readonly string[]; + readonly remediation?: readonly IRushRemediationAction[]; + readonly retryable?: boolean; + readonly severity?: RushDiagnosticSeverity; + readonly source?: IRushDiagnosticSource; +} + +// @beta +export interface INdjsonOptions { + readonly maxRecordBytes?: number; +} + +// @beta +export interface IReporter { + closeAsync(): Promise; + flushAsync(): Promise; + initializeAsync(context: IReporterContext): Promise; + readonly name: string; + report(event: IReporterEventEnvelope): void; +} + +// @beta +export interface IReporterContext { + readonly destination?: string; + readonly protocolVersion: IReporterProtocolVersion; +} + +// @beta +export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; + +// @beta +export interface IReporterEventEnvelope { + readonly eventId: string; + readonly parentOperationId?: string; + readonly parentSessionId?: string; + readonly payload: TPayload; + readonly privacy: ReporterPrivacyClassification; + readonly protocolVersion: IReporterProtocolVersion; + readonly required: boolean; + readonly scope?: IReporterEventScope; + readonly sequence: number; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sourceSequence?: number; + readonly timestamp: string; + readonly type: ReporterEventType; +} + +// @beta +export interface IReporterEventScope { + readonly commandName?: string; + readonly operationId?: string; + readonly phaseName?: string; + readonly projectName?: string; +} + +// @beta +export interface IReporterEventSink { + emit(event: IReporterEmitEventInput): string; +} + +// @beta +export interface IReporterEventSource { + readonly component?: string; + readonly packageName: string; + readonly packageVersion: string; +} + +// @beta +export interface IReporterHandshakeOptions { + readonly supportedCapabilities?: readonly string[]; + readonly supportedProtocolVersion: IReporterProtocolVersion; +} + +// @beta +export interface IReporterHandshakeResult { + readonly accepted: boolean; + readonly ack: IReporterHelloAck; + readonly diagnostic?: IRushDiagnostic; +} + +// @beta +export interface IReporterHello { + readonly capabilities: string[]; + readonly kind: 'hello'; + readonly producerVersion: string; + readonly protocolVersion: IReporterProtocolVersion; + readonly requiredFeatures: string[]; +} + +// @beta +export interface IReporterHelloAck { + readonly acceptedCapabilities: string[]; + readonly kind: 'helloAck'; + readonly protocolVersion: IReporterProtocolVersion; + readonly rejectedRequiredFeatures: string[]; +} + +// @beta +export interface IReporterManagerOptions { + readonly coalesceThreshold?: number; + readonly emergencyDiagnosticWriter?: (message: string) => void; + readonly now?: () => string; + readonly protocolVersion?: IReporterProtocolVersion; +} + +// @beta +export interface IReporterProtocolLimits { + readonly bootstrapBufferBytes: number; + readonly externalOutputChunkBytes: number; + readonly ndjsonRecordBytes: number; +} + +// @beta +export interface IReporterProtocolVersion { + readonly major: number; + readonly minor: number; +} + +// @beta +export interface IReporterRegistrationOptions { + readonly destination?: string; + readonly required?: boolean; +} + +// @beta +export interface IRushDiagnostic { + readonly category: RushDiagnosticCategory; + readonly causeDiagnosticIds?: readonly string[]; + readonly code: string; + readonly detailKey?: string; + readonly diagnosticId: string; + readonly parameters?: { + readonly [name: string]: IClassifiedDiagnosticValue; + }; + readonly relatedArtifactIds?: readonly string[]; + readonly remediation?: readonly IRushRemediationAction[]; + readonly retryable?: boolean; + readonly severity: RushDiagnosticSeverity; + readonly source?: IRushDiagnosticSource; + readonly summaryKey: string; +} + +// @beta +export interface IRushDiagnosticCodeDefinition { + readonly category: RushDiagnosticCategory; + readonly code: string; + readonly defaultSeverity: RushDiagnosticSeverity; + readonly detailKey?: string; + readonly summaryKey: string; +} + +// @beta +export interface IRushDiagnosticSource { + readonly column?: number; + readonly file?: string; + readonly line?: number; + readonly toolName?: string; +} + +// @beta +export interface IRushRemediationAction { + readonly automatedExecutionSafety: RushRemediationSafety; + readonly command?: string; + readonly descriptionKey: string; + readonly documentationUrl?: string; +} + +// @beta +export interface IScopedMessageOptions { + readonly privacy?: ReporterPrivacyClassification; + readonly severity: ReporterMessageSeverity; + readonly text: string; +} + +// @beta +export interface IScopedReporter { + emitDiagnostic(diagnostic: IRushDiagnostic): string; + emitExtension(name: ReporterExtensionEventName, payload: TPayload): string; + emitMessage(options: IScopedMessageOptions): string; +} + +// @beta +export function isReporterExtensionEventName(name: string): boolean; + +// @beta +export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, producer: IReporterProtocolVersion): boolean; + +// @beta +export function isValidRushDiagnosticCode(code: string): boolean; + +// @beta +export class NdjsonDecoder { + constructor(options?: INdjsonOptions); + decode(chunk: string): unknown[]; + flush(): unknown[]; +} + +// @beta +export class NdjsonRecordTooLargeError extends Error { + constructor(maxRecordBytes: number); + readonly maxRecordBytes: number; +} + +// @beta +export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult; + +// @beta +export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; + +// @beta +export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; + +// @beta +export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; + +// @beta +export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; + +// @beta +export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; + +// @beta +export type ReporterExtensionEventName = string; + +// @beta +export type ReporterJsonNull = null; + +// @beta +export type ReporterJsonValue = string | number | boolean | ReporterJsonNull | readonly ReporterJsonValue[] | { + readonly [key: string]: ReporterJsonValue; +}; + +// @beta +export class ReporterManager implements IReporterEventSink { + constructor(options?: IReporterManagerOptions); + addReporter(reporter: IReporter, options?: IReporterRegistrationOptions): void; + closeAsync(timeoutMs?: number): Promise; + emit(event: IReporterEmitEventInput): string; + flushAsync(timeoutMs?: number): Promise; + ingestForeignEnvelope(envelope: IReporterEventEnvelope): string; + initializeAsync(): Promise; + signalFlushAsync(timeoutMs?: number): Promise; +} + +// @beta +export type ReporterMessageSeverity = 'debug' | 'info' | 'warning' | 'error'; + +// @beta +export class ReporterMultiplexer implements IReporter { + constructor(name: string, reporters: readonly IReporter[]); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(context: IReporterContext): Promise; + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + +// @beta +export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; + +// @beta +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; + +// @beta +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; + +// @beta +export const RUSH_DIAGNOSTIC_TEMPLATES: { + readonly [resourceKey: string]: string; +}; + +// @beta +export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; + +// @beta +export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; + +// @beta +export type RushDiagnosticSeverity = 'warning' | 'error'; + +// @beta +export class RushError extends Error { + constructor(diagnostic: IRushDiagnostic, message?: string); + readonly diagnostic: IRushDiagnostic; + get diagnosticId(): string; +} + +// @beta +export type RushRemediationSafety = 'safe' | 'requires-confirmation' | 'unsafe'; + +``` diff --git a/libraries/reporter/.npmignore b/libraries/reporter/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/libraries/reporter/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/reporter/LICENSE b/libraries/reporter/LICENSE new file mode 100644 index 00000000000..fb724170fdf --- /dev/null +++ b/libraries/reporter/LICENSE @@ -0,0 +1,24 @@ +@rushstack/reporter + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md new file mode 100644 index 00000000000..f8cff10e4cb --- /dev/null +++ b/libraries/reporter/README.md @@ -0,0 +1,14 @@ +# @rushstack/reporter + +Canonical event protocol, reporter manager, and built-in reporters for Rush. + +This package is released as a public beta. Exported contracts may change before the stable release. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find + out what's new in the latest version +- [API Reference](https://api.rushstack.io/pages/reporter/) + +`@rushstack/reporter` is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/libraries/reporter/config/api-extractor.json b/libraries/reporter/config/api-extractor.json new file mode 100644 index 00000000000..3dbb76c0e6f --- /dev/null +++ b/libraries/reporter/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "local-node-rig/profiles/default/config/api-extractor-base.json" +} diff --git a/libraries/reporter/config/jest.config.json b/libraries/reporter/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/libraries/reporter/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/reporter/config/rig.json b/libraries/reporter/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/libraries/reporter/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/libraries/reporter/eslint.config.js b/libraries/reporter/eslint.config.js new file mode 100644 index 00000000000..87132f43292 --- /dev/null +++ b/libraries/reporter/eslint.config.js @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/reporter/package.json b/libraries/reporter/package.json new file mode 100644 index 00000000000..aaf9d916fe5 --- /dev/null +++ b/libraries/reporter/package.json @@ -0,0 +1,63 @@ +{ + "name": "@rushstack/reporter", + "version": "0.1.0", + "description": "Canonical event protocol, reporter manager, and built-in reporters for Rush (public beta).", + "main": "./lib-commonjs/index.js", + "module": "./lib-esm/index.js", + "types": "./dist/reporter.d.ts", + "exports": { + ".": { + "types": "./dist/reporter.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib-esm/index.js", + "require": "./lib-commonjs/index.js" + }, + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "keywords": [ + "rush", + "reporter", + "reporting", + "events", + "ndjson", + "diagnostics" + ], + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/reporter" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "sideEffects": false +} diff --git a/libraries/reporter/src/diagnostics/DiagnosticPrivacy.ts b/libraries/reporter/src/diagnostics/DiagnosticPrivacy.ts new file mode 100644 index 00000000000..80c11a3a023 --- /dev/null +++ b/libraries/reporter/src/diagnostics/DiagnosticPrivacy.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; + +const PRIVACY_RANK: { readonly [classification in ReporterPrivacyClassification]: number } = { + public: 0, + 'local-sensitive': 1, + secret: 2 +}; + +const RANK_TO_CLASSIFICATION: readonly ReporterPrivacyClassification[] = [ + 'public', + 'local-sensitive', + 'secret' +]; + +/** + * Returns the sensitivity rank of a privacy classification, where a larger + * number is more sensitive. + * + * @remarks + * `public` is `0`, `local-sensitive` is `1`, and `secret` is `2`. + * + * @beta + */ +export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number { + return PRIVACY_RANK[classification]; +} + +/** + * Computes the envelope privacy floor for a set of field classifications. + * + * @remarks + * The envelope classification is the minimum classification floor for every + * field in the event: every field is at least as sensitive as the returned + * value. It is therefore the least sensitive classification present, or + * `public` when no fields are provided. Field-level classification remains + * authoritative for redaction; the floor is only a coarse lower bound. + * + * @param classifications - the privacy classifications of the event's fields + * + * @beta + */ +export function computeEnvelopePrivacyFloor( + classifications: Iterable +): ReporterPrivacyClassification { + let minRank: number = PRIVACY_RANK.secret; + let sawAny: boolean = false; + for (const classification of classifications) { + sawAny = true; + const rank: number = PRIVACY_RANK[classification]; + if (rank < minRank) { + minRank = rank; + } + } + if (!sawAny) { + return 'public'; + } + return RANK_TO_CLASSIFICATION[minRank]; +} diff --git a/libraries/reporter/src/diagnostics/IClassifiedDiagnosticValue.ts b/libraries/reporter/src/diagnostics/IClassifiedDiagnosticValue.ts new file mode 100644 index 00000000000..9da357b7fc0 --- /dev/null +++ b/libraries/reporter/src/diagnostics/IClassifiedDiagnosticValue.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ReporterJsonValue } from '../events/ReporterJsonValue'; +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; + +/** + * A diagnostic parameter value paired with its own privacy classification. + * + * @remarks + * Field-level classification is authoritative. Reporters and telemetry decide, + * per destination, whether an individual value may be rendered based on its + * `privacy`, independent of the enclosing event's envelope classification. + * + * @beta + */ +export interface IClassifiedDiagnosticValue { + /** + * The JSON-serializable value. + */ + readonly value: ReporterJsonValue; + + /** + * The privacy classification of this value. + */ + readonly privacy: ReporterPrivacyClassification; +} diff --git a/libraries/reporter/src/diagnostics/IRushDiagnostic.ts b/libraries/reporter/src/diagnostics/IRushDiagnostic.ts new file mode 100644 index 00000000000..a31d6edddc4 --- /dev/null +++ b/libraries/reporter/src/diagnostics/IRushDiagnostic.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 { RushDiagnosticCategory } from './RushDiagnosticCategory'; +import type { IClassifiedDiagnosticValue } from './IClassifiedDiagnosticValue'; +import type { IRushRemediationAction } from './IRushRemediationAction'; +import type { IRushDiagnosticSource } from './IRushDiagnosticSource'; + +/** + * The severity of a diagnostic. + * + * @remarks + * Diagnostic severity is independent of a reporter's log level. A reporter + * decides, based on its configured log level, whether to render a given + * severity. + * + * @beta + */ +export type RushDiagnosticSeverity = 'warning' | 'error'; + +/** + * A structured, presentation-free diagnostic emitted by Rush-owned code. + * + * @remarks + * This DTO is the wire contract. An internal `RushError` may wrap it in-process, + * but only the DTO crosses process and destination boundaries. Its + * {@link IRushDiagnostic.code | code} keys a permanent entry in the central + * registry and its English templates. + * + * @beta + */ +export interface IRushDiagnostic { + /** + * A unique identifier for this diagnostic instance. Propagated failures + * reference this id rather than re-rendering the diagnostic. + */ + readonly diagnosticId: string; + + /** + * A stable, never-reused code of the form `RUSH__` that + * identifies the diagnostic and keys its English template. + */ + readonly code: string; + + /** + * The root-cause category of the diagnostic. + */ + readonly category: RushDiagnosticCategory; + + /** + * The severity of the diagnostic. + */ + readonly severity: RushDiagnosticSeverity; + + /** + * The resource key of the human-readable summary template for this diagnostic. + */ + readonly summaryKey: string; + + /** + * The resource key of an optional detailed template for this diagnostic. + */ + readonly detailKey?: string; + + /** + * Named parameters referenced by the templates, each with its own privacy classification. + */ + readonly parameters?: { readonly [name: string]: IClassifiedDiagnosticValue }; + + /** + * Suggested remediation actions. + */ + readonly remediation?: readonly IRushRemediationAction[]; + + /** + * The source location the diagnostic refers to. + */ + readonly source?: IRushDiagnosticSource; + + /** + * The ids of diagnostics that caused this one, for root-cause chaining. + */ + readonly causeDiagnosticIds?: readonly string[]; + + /** + * Whether the failing operation may succeed if retried. + */ + readonly retryable?: boolean; + + /** + * The ids of artifacts related to this diagnostic, such as log files. + */ + readonly relatedArtifactIds?: readonly string[]; +} diff --git a/libraries/reporter/src/diagnostics/IRushDiagnosticSource.ts b/libraries/reporter/src/diagnostics/IRushDiagnosticSource.ts new file mode 100644 index 00000000000..443bfab057b --- /dev/null +++ b/libraries/reporter/src/diagnostics/IRushDiagnosticSource.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Identifies the source location that a diagnostic refers to. + * + * @remarks + * File paths are `local-sensitive` and never enter telemetry. + * + * @beta + */ +export interface IRushDiagnosticSource { + /** + * The file the diagnostic refers to. + */ + readonly file?: string; + + /** + * The 1-based line number within {@link IRushDiagnosticSource.file | file}. + */ + readonly line?: number; + + /** + * The 1-based column number within {@link IRushDiagnosticSource.file | file}. + */ + readonly column?: number; + + /** + * The name of the tool that produced the diagnostic, when applicable. + */ + readonly toolName?: string; +} diff --git a/libraries/reporter/src/diagnostics/IRushRemediationAction.ts b/libraries/reporter/src/diagnostics/IRushRemediationAction.ts new file mode 100644 index 00000000000..31b8957db38 --- /dev/null +++ b/libraries/reporter/src/diagnostics/IRushRemediationAction.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Classifies whether a remediation action is safe to execute automatically. + * + * @remarks + * - `safe` actions may be executed without user confirmation. + * - `requires-confirmation` actions may be executed only after explicit user consent. + * - `unsafe` actions must never be executed automatically and are shown for + * manual follow-up only. + * + * @beta + */ +export type RushRemediationSafety = 'safe' | 'requires-confirmation' | 'unsafe'; + +/** + * A suggested action that may resolve a diagnostic. + * + * @remarks + * A remediation action may offer a command to run, a documentation URL, or both. + * Its {@link IRushRemediationAction.automatedExecutionSafety | safety + * classification} states whether an agent may execute the command automatically. + * + * @beta + */ +export interface IRushRemediationAction { + /** + * The resource key of the human-readable description of this action. + */ + readonly descriptionKey: string; + + /** + * An optional shell command that performs the remediation. + */ + readonly command?: string; + + /** + * An optional documentation URL describing the remediation. + */ + readonly documentationUrl?: string; + + /** + * Whether this action is safe to execute automatically. + */ + readonly automatedExecutionSafety: RushRemediationSafety; +} diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCategory.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCategory.ts new file mode 100644 index 00000000000..904c5d0b733 --- /dev/null +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCategory.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The category of a Rush diagnostic. + * + * @remarks + * Categories group diagnostics by root-cause domain. They are used for + * aggregation and telemetry, and they never select the process exit code. + * + * @beta + */ +export type RushDiagnosticCategory = + | 'configuration' + | 'input' + | 'dependency-tool' + | 'environment' + | 'network-auth' + | 'operation' + | 'internal'; diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts new file mode 100644 index 00000000000..fad3b340c5d --- /dev/null +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushDiagnosticCategory } from './RushDiagnosticCategory'; +import type { RushDiagnosticSeverity } from './IRushDiagnostic'; + +/** + * A permanent registry entry describing a Rush diagnostic code. + * + * @beta + */ +export interface IRushDiagnosticCodeDefinition { + /** + * The stable `RUSH__` code. + */ + readonly code: string; + + /** + * The root-cause category of the diagnostic. + */ + readonly category: RushDiagnosticCategory; + + /** + * The severity applied when a producer does not override it. + */ + readonly defaultSeverity: RushDiagnosticSeverity; + + /** + * The resource key of the summary template. + */ + readonly summaryKey: string; + + /** + * The resource key of an optional detailed template. + */ + readonly detailKey?: string; +} + +/** + * The stable code used for unexpected internal (programmer) failures. + * + * @beta + */ +export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED' = 'RUSH_INTERNAL_UNEXPECTED'; + +const RUSH_DIAGNOSTIC_CODE_REGEXP: RegExp = /^RUSH_[A-Z0-9]+(?:_[A-Z0-9]+)+$/; + +/** + * Returns `true` if `code` is shaped like a valid `RUSH__` code. + * + * @param code - the candidate diagnostic code + * + * @beta + */ +export function isValidRushDiagnosticCode(code: string): boolean { + return RUSH_DIAGNOSTIC_CODE_REGEXP.test(code); +} + +/** + * The permanent, never-reused list of Rush diagnostic code definitions. + * + * @remarks + * Codes are append-only. A code is never removed or repurposed, so consumers can + * rely on a code always meaning the same thing. + * + * @beta + */ +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[] = [ + { + code: 'RUSH_CONFIG_INVALID_JSON', + category: 'configuration', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary' + }, + { + code: 'RUSH_INPUT_UNKNOWN_PROJECT', + category: 'input', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary' + }, + { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary', + detailKey: 'diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail' + }, + { + code: 'RUSH_ENVIRONMENT_UNSUPPORTED_NODE', + category: 'environment', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary' + }, + { + code: 'RUSH_NETWORK_AUTH_UNAUTHORIZED', + category: 'network-auth', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary' + }, + { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_OPERATION_FAILED.summary' + }, + { + code: 'RUSH_PROTOCOL_UPDATE_REQUIRED', + category: 'environment', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary', + detailKey: 'diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail' + }, + { + code: RUSH_INTERNAL_ERROR_CODE, + category: 'internal', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.summary', + detailKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.detail' + } +]; + +/** + * The permanent registry of Rush diagnostic codes, keyed by code. + * + * @beta + */ +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map((definition: IRushDiagnosticCodeDefinition) => [ + definition.code, + definition + ]) +); + +/** + * The English templates for Rush diagnostics, keyed by resource key. + * + * @remarks + * Placeholders of the form `{name}` are substituted with the diagnostic's + * classified parameters at render time. Only English is provided in v1. + * + * @beta + */ +export const RUSH_DIAGNOSTIC_TEMPLATES: { readonly [resourceKey: string]: string } = { + 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary': 'The configuration file {file} contains invalid JSON.', + 'diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary': 'The project {projectName} was not found in rush.json.', + 'diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary': 'The package manager exited with code {exitCode}.', + 'diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail': + 'The command {command} failed. See {logPath} for the full output.', + 'diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary': + 'Node.js {actualVersion} is not supported; expected {expectedRange}.', + 'diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary': + 'Authentication failed for the registry {registryUrl}.', + 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', + 'diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary': + 'A reporter protocol feature required by {producerVersion} is not supported by this Rush.', + 'diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail': + 'The producer advertised protocol major {producerProtocolMajor}. Update your global Rush installation to a version that supports it.', + 'diagnostic.RUSH_INTERNAL_UNEXPECTED.summary': 'An unexpected internal error occurred in Rush.', + 'diagnostic.RUSH_INTERNAL_UNEXPECTED.detail': + 'This is a bug in Rush. See {logPath} for details, then report it upstream.' +}; diff --git a/libraries/reporter/src/diagnostics/RushError.ts b/libraries/reporter/src/diagnostics/RushError.ts new file mode 100644 index 00000000000..84ebe50f196 --- /dev/null +++ b/libraries/reporter/src/diagnostics/RushError.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IRushDiagnostic } from './IRushDiagnostic'; + +/** + * An in-process `Error` that wraps a structured diagnostic DTO. + * + * @remarks + * `RushError` lets Rush-owned code throw and propagate a failure while carrying + * the already-emitted diagnostic. The wrapped {@link IRushDiagnostic} is the + * wire contract; catch boundaries use its {@link RushError.diagnosticId} to + * reference the failure rather than re-rendering it. + * + * @beta + */ +export class RushError extends Error { + /** + * The structured diagnostic carried by this error. + */ + public readonly diagnostic: IRushDiagnostic; + + public constructor(diagnostic: IRushDiagnostic, message?: string) { + super(message ?? diagnostic.code); + this.name = 'RushError'; + this.diagnostic = diagnostic; + + // Restore the prototype chain, which is broken when subclassing a built-in + // and compiling to ES5/ES2018 CommonJS. + Object.setPrototypeOf(this, RushError.prototype); + } + + /** + * The id of the wrapped diagnostic. + */ + public get diagnosticId(): string { + return this.diagnostic.diagnosticId; + } +} diff --git a/libraries/reporter/src/diagnostics/createRushDiagnostic.ts b/libraries/reporter/src/diagnostics/createRushDiagnostic.ts new file mode 100644 index 00000000000..fbf624b1958 --- /dev/null +++ b/libraries/reporter/src/diagnostics/createRushDiagnostic.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; + +import type { IRushDiagnostic, RushDiagnosticSeverity } from './IRushDiagnostic'; +import type { IClassifiedDiagnosticValue } from './IClassifiedDiagnosticValue'; +import type { IRushRemediationAction } from './IRushRemediationAction'; +import type { IRushDiagnosticSource } from './IRushDiagnosticSource'; +import { RUSH_DIAGNOSTIC_CODES, type IRushDiagnosticCodeDefinition } from './RushDiagnosticCodeRegistry'; + +/** + * Options for {@link createRushDiagnostic}. + * + * @beta + */ +export interface ICreateRushDiagnosticOptions { + /** + * A pre-assigned diagnostic id. When omitted, a new id is generated. + */ + readonly diagnosticId?: string; + + /** + * Overrides the registry default severity. + */ + readonly severity?: RushDiagnosticSeverity; + + /** + * Named parameters referenced by the templates. + */ + readonly parameters?: { readonly [name: string]: IClassifiedDiagnosticValue }; + + /** + * Suggested remediation actions. + */ + readonly remediation?: readonly IRushRemediationAction[]; + + /** + * The source location the diagnostic refers to. + */ + readonly source?: IRushDiagnosticSource; + + /** + * The ids of diagnostics that caused this one. + */ + readonly causeDiagnosticIds?: readonly string[]; + + /** + * Whether the failing operation may succeed if retried. + */ + readonly retryable?: boolean; + + /** + * The ids of artifacts related to this diagnostic. + */ + readonly relatedArtifactIds?: readonly string[]; +} + +/** + * Creates a structured diagnostic from a registered code. + * + * @remarks + * The registry is the single source of truth for the category, default + * severity, and template keys, so producers supply only the code and + * instance-specific data. A fresh {@link IRushDiagnostic.diagnosticId} is + * generated unless one is provided. + * + * @param code - a code present in the central registry + * @param options - instance-specific diagnostic data + * @throws Error if `code` is not registered + * + * @beta + */ +export function createRushDiagnostic( + code: string, + options: ICreateRushDiagnosticOptions = {} +): IRushDiagnostic { + const definition: IRushDiagnosticCodeDefinition | undefined = RUSH_DIAGNOSTIC_CODES.get(code); + if (!definition) { + throw new Error(`Unknown Rush diagnostic code: ${code}`); + } + + return { + diagnosticId: options.diagnosticId ?? randomUUID(), + code: definition.code, + category: definition.category, + severity: options.severity ?? definition.defaultSeverity, + summaryKey: definition.summaryKey, + detailKey: definition.detailKey, + parameters: options.parameters, + remediation: options.remediation, + source: options.source, + causeDiagnosticIds: options.causeDiagnosticIds, + retryable: options.retryable, + relatedArtifactIds: options.relatedArtifactIds + }; +} diff --git a/libraries/reporter/src/events/IReporterEventEnvelope.ts b/libraries/reporter/src/events/IReporterEventEnvelope.ts new file mode 100644 index 00000000000..d08c1ba4947 --- /dev/null +++ b/libraries/reporter/src/events/IReporterEventEnvelope.ts @@ -0,0 +1,142 @@ +// 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 './ReporterProtocolVersion'; +import type { ReporterPrivacyClassification } from './ReporterPrivacyClassification'; +import type { ReporterEventType } from './ReporterEventType'; + +/** + * Identifies the code that produced a reporter event. + * + * @beta + */ +export interface IReporterEventSource { + /** + * The npm package name of the producer, for example `@microsoft/rush-lib`. + */ + readonly packageName: string; + + /** + * The version of the producing package. + */ + readonly packageVersion: string; + + /** + * An optional finer-grained component name within the producing package. + */ + readonly component?: string; +} + +/** + * Associates a reporter event with the command, operation, project, and phase it belongs to. + * + * @beta + */ +export interface IReporterEventScope { + /** + * The name of the Rush command the event belongs to. + */ + readonly commandName?: string; + + /** + * The identifier of the operation the event belongs to. + */ + readonly operationId?: string; + + /** + * The name of the project the event belongs to. + */ + readonly projectName?: string; + + /** + * The name of the phase the event belongs to. + */ + readonly phaseName?: string; +} + +/** + * The canonical, immutable envelope that wraps every reporter event. + * + * @remarks + * Envelopes are immutable and JSON-serializable; JavaScript `Error` instances + * are never serialized directly. `sequence` is authoritative for ordering and + * `timestamp` is informational only. + * + * For a child session, {@link IReporterEventEnvelope.sequence | sequence} is the + * global sequence assigned by the manager in receipt order, and + * {@link IReporterEventEnvelope.sourceSequence | sourceSequence} preserves the + * producer's own local sequence. + * + * @beta + */ +export interface IReporterEventEnvelope { + /** + * The protocol version that produced this event. + */ + readonly protocolVersion: IReporterProtocolVersion; + + /** + * A unique identifier for this event, assigned by the sink on emission. + */ + readonly eventId: string; + + /** + * The identifier of the session that produced this event. + */ + readonly sessionId: string; + + /** + * The identifier of the parent session, when this event originates from a child session. + */ + readonly parentSessionId?: string; + + /** + * The identifier of the parent operation that spawned the child session, when applicable. + */ + readonly parentOperationId?: string; + + /** + * The authoritative monotonic ordering value assigned by the manager. + */ + readonly sequence: number; + + /** + * For child sessions, the producer's original local sequence value. + */ + readonly sourceSequence?: number; + + /** + * The informational ISO 8601 time at which the event was created. + */ + readonly timestamp: string; + + /** + * The code that produced this event. + */ + readonly source: IReporterEventSource; + + /** + * The command, operation, project, and phase this event belongs to. + */ + readonly scope?: IReporterEventScope; + + /** + * The minimum privacy classification floor for every field in this event. + */ + readonly privacy: ReporterPrivacyClassification; + + /** + * Whether this event is correctness-critical and must never be dropped. + */ + readonly required: boolean; + + /** + * The core event type, or `extension` for a namespaced beta extension event. + */ + readonly type: ReporterEventType; + + /** + * The JSON-serializable payload for this event type. + */ + readonly payload: TPayload; +} diff --git a/libraries/reporter/src/events/ReporterEventType.ts b/libraries/reporter/src/events/ReporterEventType.ts new file mode 100644 index 00000000000..0d5bc6364b8 --- /dev/null +++ b/libraries/reporter/src/events/ReporterEventType.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The closed set of core reporter event type identifiers. + * + * @remarks + * Rush controls creation of every core lifecycle event. The set is intentionally + * closed: producers that need a custom event use the `extension` type with a + * namespaced beta identifier rather than adding a new core type. + * + * @beta + */ +export type ReporterEventType = + | 'sessionStarted' + | 'sessionCompleted' + | 'commandStarted' + | 'commandCompleted' + | 'operationRegistered' + | 'operationStatusChanged' + | 'activityChanged' + | 'watchCycleCompleted' + | 'diagnosticEmitted' + | 'externalProcessStarted' + | 'externalOutput' + | 'externalProcessCompleted' + | 'artifactAvailable' + | 'commandResult' + | 'extension'; + +/** + * The runtime list of every core reporter event type, in canonical order. + * + * @remarks + * This companion to {@link ReporterEventType} lets consumers validate and + * enumerate the closed core union at runtime. + * + * @beta + */ +export const REPORTER_EVENT_TYPES: readonly ReporterEventType[] = [ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' +]; diff --git a/libraries/reporter/src/events/ReporterJsonValue.ts b/libraries/reporter/src/events/ReporterJsonValue.ts new file mode 100644 index 00000000000..bc7f53ff8e6 --- /dev/null +++ b/libraries/reporter/src/events/ReporterJsonValue.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Represents JSON's `null`, which reporter event payloads may contain. + * + * @remarks + * The Rush Stack lint rules discourage usage of `null`. However, JSON parsers + * always return JavaScript's `null`. Reporter event payloads are transported as + * JSON, so `ReporterJsonNull` is provided to describe that value without + * triggering the lint rule. Do not use it for any other purpose. + * + * @beta + */ +// eslint-disable-next-line @rushstack/no-new-null +export type ReporterJsonNull = null; + +/** + * A JSON-serializable value. + * + * @remarks + * Reporter events are immutable and JSON-serializable, and JavaScript `Error` + * instances are never serialized directly. Typing an event payload as + * `ReporterJsonValue` ensures that it can round-trip through + * `JSON.stringify`/`JSON.parse` without loss and contains no functions, class + * instances, or `undefined` values. + * + * @beta + */ +export type ReporterJsonValue = + | string + | number + | boolean + | ReporterJsonNull + | readonly ReporterJsonValue[] + | { readonly [key: string]: ReporterJsonValue }; diff --git a/libraries/reporter/src/events/ReporterPrivacyClassification.ts b/libraries/reporter/src/events/ReporterPrivacyClassification.ts new file mode 100644 index 00000000000..3127d090a33 --- /dev/null +++ b/libraries/reporter/src/events/ReporterPrivacyClassification.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Classifies how sensitive a value is, and therefore which destinations may receive it. + * + * @remarks + * - `public` values may be written to any destination, including telemetry. + * - `local-sensitive` values may appear in local reporter output such as the + * full-detail log, but never in telemetry. + * - `secret` values must never reach any local log or telemetry. + * + * Individual diagnostic fields carry their own classification. On an event + * envelope this value is the minimum classification floor for every field in + * that event. + * + * @beta + */ +export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; diff --git a/libraries/reporter/src/events/ReporterProtocolVersion.ts b/libraries/reporter/src/events/ReporterProtocolVersion.ts new file mode 100644 index 00000000000..6855b16e1dd --- /dev/null +++ b/libraries/reporter/src/events/ReporterProtocolVersion.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Identifies the version of the reporter event protocol that produced an event. + * + * @remarks + * The `major` version gates compatibility. Consumers reject a protocol whose + * `major` they do not support. `minor` versions are additive, so a consumer + * ignores unknown optional fields and events introduced by a newer `minor`. + * + * @beta + */ +export interface IReporterProtocolVersion { + /** + * The major protocol version. Incremented only for breaking changes. + */ + readonly major: number; + + /** + * The minor protocol version. Incremented for additive, backward-compatible changes. + */ + readonly minor: number; +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts new file mode 100644 index 00000000000..e2b17c59b0b --- /dev/null +++ b/libraries/reporter/src/index.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Canonical event protocol, reporter manager, and built-in reporters for Rush. + * + * @remarks + * This package is released as a public beta. Exported contracts may change + * before the stable release. + * + * @packageDocumentation + */ + +/** + * The npm package name of this package. + * + * @beta + */ +export const REPORTER_PACKAGE_NAME: '@rushstack/reporter' = '@rushstack/reporter'; + +export type { IReporterProtocolVersion } from './events/ReporterProtocolVersion'; +export type { ReporterPrivacyClassification } from './events/ReporterPrivacyClassification'; +export type { ReporterJsonNull, ReporterJsonValue } from './events/ReporterJsonValue'; +export type { ReporterEventType } from './events/ReporterEventType'; +export { REPORTER_EVENT_TYPES } from './events/ReporterEventType'; +export type { + IReporterEventSource, + IReporterEventScope, + IReporterEventEnvelope +} from './events/IReporterEventEnvelope'; + +export type { RushDiagnosticSeverity, IRushDiagnostic } from './diagnostics/IRushDiagnostic'; +export type { RushDiagnosticCategory } from './diagnostics/RushDiagnosticCategory'; +export type { RushRemediationSafety, IRushRemediationAction } from './diagnostics/IRushRemediationAction'; +export type { IRushDiagnosticSource } from './diagnostics/IRushDiagnosticSource'; +export type { IClassifiedDiagnosticValue } from './diagnostics/IClassifiedDiagnosticValue'; +export { getPrivacyClassificationRank, computeEnvelopePrivacyFloor } from './diagnostics/DiagnosticPrivacy'; +export type { IRushDiagnosticCodeDefinition } from './diagnostics/RushDiagnosticCodeRegistry'; +export { + RUSH_INTERNAL_ERROR_CODE, + isValidRushDiagnosticCode, + RUSH_DIAGNOSTIC_CODE_DEFINITIONS, + RUSH_DIAGNOSTIC_CODES, + RUSH_DIAGNOSTIC_TEMPLATES +} from './diagnostics/RushDiagnosticCodeRegistry'; +export { RushError } from './diagnostics/RushError'; +export type { ICreateRushDiagnosticOptions } from './diagnostics/createRushDiagnostic'; +export { createRushDiagnostic } from './diagnostics/createRushDiagnostic'; + +export type { IReporterProtocolLimits } from './protocol/ReporterProtocol'; +export { + REPORTER_PROTOCOL_VERSION, + REPORTER_PROTOCOL_LIMITS, + isReporterProtocolCompatible +} from './protocol/ReporterProtocol'; +export type { INdjsonOptions } from './protocol/Ndjson'; +export { NdjsonRecordTooLargeError, encodeNdjsonRecord, NdjsonDecoder } from './protocol/Ndjson'; +export type { + IReporterHello, + IReporterHelloAck, + IReporterHandshakeOptions, + IReporterHandshakeResult +} from './protocol/ReporterHandshake'; +export { negotiateReporterHello } from './protocol/ReporterHandshake'; + +export type { IReporter, IReporterContext } from './manager/IReporter'; +export type { IReporterRegistrationOptions, IReporterManagerOptions } from './manager/ReporterManager'; +export { + ReporterManager, + DEFAULT_FLUSH_TIMEOUT_MS, + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS +} from './manager/ReporterManager'; +export { ReporterMultiplexer } from './manager/ReporterMultiplexer'; + +export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; +export type { + ReporterMessageSeverity, + IScopedMessageOptions, + IScopedReporter +} from './producers/IScopedReporter'; +export type { ReporterExtensionEventName } from './producers/ReporterExtensionEventName'; +export { isReporterExtensionEventName } from './producers/ReporterExtensionEventName'; diff --git a/libraries/reporter/src/manager/IReporter.ts b/libraries/reporter/src/manager/IReporter.ts new file mode 100644 index 00000000000..e648e4be318 --- /dev/null +++ b/libraries/reporter/src/manager/IReporter.ts @@ -0,0 +1,61 @@ +// 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'; + +/** + * The context supplied to a reporter when it is initialized. + * + * @beta + */ +export interface IReporterContext { + /** + * The protocol version the manager implements. + */ + readonly protocolVersion: IReporterProtocolVersion; + + /** + * The exclusive destination the reporter owns, when one was declared. + */ + readonly destination?: string; +} + +/** + * A subscriber that renders reporter events to a destination. + * + * @remarks + * The manager owns fan-out and ordering. It calls {@link IReporter.report} for + * every event in session-sequence order, and it never calls a reporter + * concurrently with itself. `report` is synchronous so the manager can preserve + * order and apply backpressure; deferred work belongs in + * {@link IReporter.flushAsync}. + * + * @beta + */ +export interface IReporter { + /** + * A stable, unique name for this reporter. + */ + readonly name: string; + + /** + * Prepares the reporter for use. A rejection is fatal to the session. + */ + initializeAsync(context: IReporterContext): Promise; + + /** + * Renders a single event. Called in session-sequence order. + */ + report(event: IReporterEventEnvelope): void; + + /** + * Flushes any buffered output. + */ + flushAsync(): Promise; + + /** + * Flushes and releases the reporter's destination. + */ + closeAsync(): Promise; +} diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts new file mode 100644 index 00000000000..63e58974d1a --- /dev/null +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -0,0 +1,366 @@ +// 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 { IReporterEmitEventInput, IReporterEventSink } from '../producers/IReporterEventSink'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; +import type { IReporter, IReporterContext } from './IReporter'; + +/** + * The default flush timeout for normal and error completion, in milliseconds. + * + * @beta + */ +export const DEFAULT_FLUSH_TIMEOUT_MS: number = 10000; + +/** + * The default best-effort flush timeout used on signal termination, in milliseconds. + * + * @beta + */ +export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number = 2000; + +const DEFAULT_COALESCE_THRESHOLD: number = 64; + +/** + * Options for registering a reporter with a {@link ReporterManager}. + * + * @beta + */ +export interface IReporterRegistrationOptions { + /** + * An exclusive destination the reporter owns, such as `stdout` or a file path. + * No two reporters may own the same destination; share one only through an + * explicit multiplexer registered as a single reporter. + */ + readonly destination?: string; + + /** + * Whether a runtime failure of this reporter is fatal to the session. Required + * reporters (such as the parent or wire reporter) are fatal; optional + * reporters are disabled on failure. Defaults to `false`. + */ + readonly required?: boolean; +} + +/** + * Options for constructing a {@link ReporterManager}. + * + * @beta + */ +export interface IReporterManagerOptions { + /** + * The protocol version the manager advertises to reporters. Defaults to + * {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * Returns the current timestamp as an ISO 8601 string. Injectable for testing. + */ + readonly now?: () => string; + + /** + * The pending-queue length at which replaceable status events begin to + * coalesce. Defaults to 64. + */ + readonly coalesceThreshold?: number; + + /** + * Writes a one-line emergency diagnostic, used when an optional reporter is + * disabled. Defaults to writing to `process.stderr`. + */ + readonly emergencyDiagnosticWriter?: (message: string) => void; +} + +interface IReporterEntry { + readonly reporter: IReporter; + readonly destination: string | undefined; + readonly required: boolean; + disabled: boolean; + readonly queue: IReporterEventEnvelope[]; + draining: boolean; + drainPromise: Promise; +} + +/** + * Assigns session ordering to events and fans them out to reporters. + * + * @remarks + * The manager is the authoritative in-process sink. It assigns one monotonic + * session `sequence`, `eventId`, and `timestamp` to each event, then delivers + * events to each reporter through an independent ordered queue. It enforces + * exclusive destination ownership, coalesces replaceable status events under + * pressure, and never drops lifecycle, diagnostic, result, artifact, or + * external-output events. + * + * @beta + */ +export class ReporterManager implements IReporterEventSink { + private readonly _entries: IReporterEntry[]; + private readonly _ownedDestinations: Set; + private readonly _protocolVersion: IReporterProtocolVersion; + private readonly _now: () => string; + private readonly _coalesceThreshold: number; + private readonly _emergencyDiagnosticWriter: (message: string) => void; + private _nextSequence: number; + private _nextEventId: number; + private _initialized: boolean; + private _fatalError: Error | undefined; + + public constructor(options: IReporterManagerOptions = {}) { + this._entries = []; + this._ownedDestinations = new Set(); + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + this._now = options.now ?? (() => new Date().toISOString()); + this._coalesceThreshold = options.coalesceThreshold ?? DEFAULT_COALESCE_THRESHOLD; + this._emergencyDiagnosticWriter = + options.emergencyDiagnosticWriter ?? + ((message: string) => { + process.stderr.write(`${message}\n`); + }); + this._nextSequence = 1; + this._nextEventId = 1; + this._initialized = false; + this._fatalError = undefined; + } + + /** + * Registers a reporter and, optionally, the destination it exclusively owns. + * + * @throws Error if the destination is already owned, or if called after initialization + */ + public addReporter(reporter: IReporter, options: IReporterRegistrationOptions = {}): void { + if (this._initialized) { + throw new Error('Reporters cannot be added after the manager is initialized.'); + } + const destination: string | undefined = options.destination; + if (destination !== undefined) { + if (this._ownedDestinations.has(destination)) { + throw new Error( + `The destination ${JSON.stringify(destination)} is already owned by another reporter. ` + + `Share a destination only through an explicit multiplexer.` + ); + } + this._ownedDestinations.add(destination); + } + this._entries.push({ + reporter, + destination, + required: options.required ?? false, + disabled: false, + queue: [], + draining: false, + drainPromise: Promise.resolve() + }); + } + + /** + * Initializes every registered reporter. + * + * @remarks + * Initialization failure is fatal: the returned promise rejects with the first + * reporter's error. + */ + public async initializeAsync(): Promise { + for (const entry of this._entries) { + const context: IReporterContext = { + protocolVersion: this._protocolVersion, + destination: entry.destination + }; + await entry.reporter.initializeAsync(context); + } + this._initialized = true; + } + + /** + * Publishes an in-process event, assigning its `eventId`, `sequence`, and + * `timestamp`, and returns the assigned `eventId`. + */ + public emit(event: IReporterEmitEventInput): string { + const eventId: string = `evt_${this._nextEventId++}`; + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: this._nextSequence++, + timestamp: this._now() + }; + this._fanOut(envelope); + return eventId; + } + + /** + * Ingests a fully-formed envelope received from a child session. + * + * @remarks + * The manager assigns a new global `sequence` in receipt order and preserves + * the producer's original sequence as `sourceSequence`. + * + * @returns the ingested event's `eventId` + */ + public ingestForeignEnvelope(envelope: IReporterEventEnvelope): string { + const rehomed: IReporterEventEnvelope = { + ...envelope, + sequence: this._nextSequence++, + sourceSequence: envelope.sequence + }; + this._fanOut(rehomed); + return rehomed.eventId; + } + + /** + * Drains every reporter queue and flushes each reporter, bounded by a timeout. + * + * @param timeoutMs - the flush timeout in milliseconds + * @throws the captured fatal error if a required reporter failed + */ + public async flushAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + await this._settleAsync(async (entry: IReporterEntry): Promise => { + await entry.drainPromise; + if (!entry.disabled) { + await entry.reporter.flushAsync(); + } + }, timeoutMs); + if (this._fatalError) { + throw this._fatalError; + } + } + + /** + * Performs a best-effort flush suitable for signal termination. + * + * @remarks + * Uses a short timeout and never throws, so a signal handler can call it + * without risk. + */ + public async signalFlushAsync(timeoutMs: number = DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS): Promise { + await this._settleAsync(async (entry: IReporterEntry): Promise => { + await entry.drainPromise; + if (!entry.disabled) { + await entry.reporter.flushAsync(); + } + }, timeoutMs); + } + + /** + * Flushes and then closes every reporter, bounded by a timeout. + * + * @throws the captured fatal error if a required reporter failed + */ + public async closeAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + let flushError: Error | undefined; + try { + await this.flushAsync(timeoutMs); + } catch (error) { + flushError = error as Error; + } + await this._settleAsync(async (entry: IReporterEntry): Promise => { + if (!entry.disabled) { + await entry.reporter.closeAsync(); + } + }, timeoutMs); + if (flushError) { + throw flushError; + } + } + + private _fanOut(envelope: IReporterEventEnvelope): void { + for (const entry of this._entries) { + if (!entry.disabled) { + this._enqueue(entry, envelope); + } + } + } + + private _enqueue(entry: IReporterEntry, envelope: IReporterEventEnvelope): void { + const lastIndex: number = entry.queue.length - 1; + if ( + entry.queue.length >= this._coalesceThreshold && + this._isCoalescibleStatusEvent(envelope) && + lastIndex >= 0 && + this._isCoalescibleStatusEvent(entry.queue[lastIndex]) + ) { + // Under pressure, a replaceable status event supersedes the previous + // unsent one instead of growing the queue. Protected events are never + // coalesced or dropped. + entry.queue[lastIndex] = envelope; + } else { + entry.queue.push(envelope); + } + + if (!entry.draining) { + entry.draining = true; + entry.drainPromise = this._drainEntryAsync(entry); + } + } + + private async _drainEntryAsync(entry: IReporterEntry): Promise { + try { + while (entry.queue.length > 0) { + const envelope: IReporterEventEnvelope = entry.queue.shift()!; + try { + entry.reporter.report(envelope); + } catch (error) { + this._handleReporterFailure(entry, error as Error); + if (entry.disabled) { + entry.queue.length = 0; + break; + } + } + // Yield so producers and coalescing can interleave with delivery. + await Promise.resolve(); + } + } finally { + entry.draining = false; + } + } + + private _handleReporterFailure(entry: IReporterEntry, error: Error): void { + if (entry.required) { + if (!this._fatalError) { + this._fatalError = error; + } + this._emergencyDiagnosticWriter( + `[reporter] Required reporter ${JSON.stringify(entry.reporter.name)} failed: ${error.message}` + ); + return; + } + entry.disabled = true; + this._emergencyDiagnosticWriter( + `[reporter] Disabling optional reporter ${JSON.stringify(entry.reporter.name)} after failure: ${error.message}` + ); + } + + private _isCoalescibleStatusEvent(envelope: IReporterEventEnvelope): boolean { + // Only non-required activity/liveness events are replaceable. Every other + // event type, and any required event, must be delivered. + return envelope.type === 'activityChanged' && !envelope.required; + } + + private async _settleAsync( + action: (entry: IReporterEntry) => Promise, + timeoutMs: number + ): Promise { + const work: Promise = Promise.all( + this._entries.map((entry: IReporterEntry) => + action(entry).catch((error: Error) => { + this._handleReporterFailure(entry, error); + }) + ) + ).then(() => undefined); + + let timer: ReturnType | undefined; + const timeout: Promise = new Promise((resolve: () => void) => { + timer = setTimeout(resolve, timeoutMs); + }); + + try { + await Promise.race([work, timeout]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } + } +} diff --git a/libraries/reporter/src/manager/ReporterMultiplexer.ts b/libraries/reporter/src/manager/ReporterMultiplexer.ts new file mode 100644 index 00000000000..b10d53909d4 --- /dev/null +++ b/libraries/reporter/src/manager/ReporterMultiplexer.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporter, IReporterContext } from './IReporter'; + +/** + * Combines several reporters that share a single destination into one reporter. + * + * @remarks + * Exclusive destination ownership means two reporters cannot both own, for + * example, `stdout`. When sharing is genuinely required, wrap the reporters in a + * multiplexer and register the multiplexer as the single owner of that + * destination. The multiplexer fans every lifecycle call out to its children in + * registration order. + * + * @beta + */ +export class ReporterMultiplexer implements IReporter { + /** + * A stable, unique name for this multiplexer. + */ + public readonly name: string; + + private readonly _reporters: readonly IReporter[]; + + public constructor(name: string, reporters: readonly IReporter[]) { + this.name = name; + this._reporters = [...reporters]; + } + + public async initializeAsync(context: IReporterContext): Promise { + for (const reporter of this._reporters) { + await reporter.initializeAsync(context); + } + } + + public report(event: IReporterEventEnvelope): void { + for (const reporter of this._reporters) { + reporter.report(event); + } + } + + public async flushAsync(): Promise { + for (const reporter of this._reporters) { + await reporter.flushAsync(); + } + } + + public async closeAsync(): Promise { + for (const reporter of this._reporters) { + await reporter.closeAsync(); + } + } +} diff --git a/libraries/reporter/src/producers/IReporterEventSink.ts b/libraries/reporter/src/producers/IReporterEventSink.ts new file mode 100644 index 00000000000..510715014d7 --- /dev/null +++ b/libraries/reporter/src/producers/IReporterEventSink.ts @@ -0,0 +1,39 @@ +// 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'; + +/** + * The fields of an event envelope that a producer supplies when emitting. + * + * @remarks + * The sink assigns `eventId`, the authoritative `sequence`, and the `timestamp`, + * so producers never provide them. + * + * @beta + */ +export type IReporterEmitEventInput = Omit< + IReporterEventEnvelope, + 'eventId' | 'sequence' | 'timestamp' +>; + +/** + * The low-level typed, in-process channel that producers use to publish events. + * + * @remarks + * The sink never exposes reporter implementations, destinations, or thresholds. + * Producers publish immutable, JSON-serializable events and receive back only + * the assigned event id. + * + * @beta + */ +export interface IReporterEventSink { + /** + * Publishes an event and returns its assigned event id. + * + * @param event - the event envelope without the sink-assigned `eventId`, + * `sequence`, and `timestamp` + * @returns the `eventId` assigned to the published event + */ + emit(event: IReporterEmitEventInput): string; +} diff --git a/libraries/reporter/src/producers/IScopedReporter.ts b/libraries/reporter/src/producers/IScopedReporter.ts new file mode 100644 index 00000000000..43f135b9934 --- /dev/null +++ b/libraries/reporter/src/producers/IScopedReporter.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import type { ReporterExtensionEventName } from './ReporterExtensionEventName'; + +/** + * The severity of a human-oriented scoped message. + * + * @remarks + * Message severity is independent of a reporter's log level; each reporter + * decides whether to render a given severity based on its configured level. + * + * @beta + */ +export type ReporterMessageSeverity = 'debug' | 'info' | 'warning' | 'error'; + +/** + * Options describing a human-oriented message emitted through a scoped reporter. + * + * @beta + */ +export interface IScopedMessageOptions { + /** + * The severity of the message. + */ + readonly severity: ReporterMessageSeverity; + + /** + * The human-readable message text. + */ + readonly text: string; + + /** + * The privacy classification of the message text. Defaults to `public`. + */ + readonly privacy?: ReporterPrivacyClassification; +} + +/** + * The scoped, presentation-free producer API handed to actions, plugins, and operations. + * + * @remarks + * A scoped reporter is pre-bound to a command, operation, project, and phase + * scope. It never exposes reporter implementations, destinations, active modes, + * or thresholds. Creation of core lifecycle events remains controlled by Rush; + * producers use {@link IScopedReporter.emitExtension} for their own events. + * + * Every emit method returns the assigned event id. + * + * @beta + */ +export interface IScopedReporter { + /** + * Emits a human-oriented message and returns its assigned event id. + */ + emitMessage(options: IScopedMessageOptions): string; + + /** + * Emits a structured diagnostic and returns its assigned diagnostic id. + */ + emitDiagnostic(diagnostic: IRushDiagnostic): string; + + /** + * Emits a namespaced extension event with a JSON-serializable payload and + * returns its assigned event id. + * + * @param name - a namespaced beta identifier, see {@link ReporterExtensionEventName} + * @param payload - a JSON-serializable payload + */ + emitExtension(name: ReporterExtensionEventName, payload: TPayload): string; +} diff --git a/libraries/reporter/src/producers/ReporterExtensionEventName.ts b/libraries/reporter/src/producers/ReporterExtensionEventName.ts new file mode 100644 index 00000000000..4982e49cbc1 --- /dev/null +++ b/libraries/reporter/src/producers/ReporterExtensionEventName.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The name of an extension event. + * + * @remarks + * Extension events carry namespaced beta identifiers of the form + * `.`, for example `acme.cache-warmed`. Each dot-separated + * segment is lowercase and begins with a letter. Namespacing keeps third-party + * event names from colliding with the closed core event set, which remains + * controlled by Rush. + * + * @beta + */ +export type ReporterExtensionEventName = string; + +const EXTENSION_EVENT_NAME_REGEXP: RegExp = + /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/; + +/** + * Returns `true` if `name` is a valid namespaced extension event identifier. + * + * @remarks + * A valid name has at least two dot-separated segments (a namespace and a name). + * Each segment is lowercase, begins with a letter, and may contain digits and + * internal single hyphens. + * + * @param name - the candidate extension event name + * + * @beta + */ +export function isReporterExtensionEventName(name: string): boolean { + return EXTENSION_EVENT_NAME_REGEXP.test(name); +} diff --git a/libraries/reporter/src/protocol/Ndjson.ts b/libraries/reporter/src/protocol/Ndjson.ts new file mode 100644 index 00000000000..3e330299113 --- /dev/null +++ b/libraries/reporter/src/protocol/Ndjson.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { REPORTER_PROTOCOL_LIMITS } from './ReporterProtocol'; + +/** + * Thrown when an NDJSON record exceeds the maximum record size. + * + * @beta + */ +export class NdjsonRecordTooLargeError extends Error { + /** + * The maximum allowed record size in bytes. + */ + public readonly maxRecordBytes: number; + + public constructor(maxRecordBytes: number) { + super(`The NDJSON record exceeds the maximum size of ${maxRecordBytes} bytes.`); + this.name = 'NdjsonRecordTooLargeError'; + this.maxRecordBytes = maxRecordBytes; + + // Restore the prototype chain, which is broken when subclassing a built-in + // and compiling to CommonJS. + Object.setPrototypeOf(this, NdjsonRecordTooLargeError.prototype); + } +} + +/** + * Options controlling NDJSON record size enforcement. + * + * @beta + */ +export interface INdjsonOptions { + /** + * The maximum size of a single record in bytes. Defaults to the protocol + * limit of 1 MiB. + */ + readonly maxRecordBytes?: number; +} + +/** + * Encodes a value as a single newline-delimited JSON record. + * + * @remarks + * `JSON.stringify` escapes any embedded newlines, so the returned string + * contains exactly one `\n`, at the end. + * + * @param value - the JSON-serializable value to encode + * @param options - record size options + * @throws NdjsonRecordTooLargeError if the encoded record exceeds the limit + * + * @beta + */ +export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string { + const json: string = JSON.stringify(value); + const maxRecordBytes: number = options?.maxRecordBytes ?? REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes; + if (Buffer.byteLength(json, 'utf8') > maxRecordBytes) { + throw new NdjsonRecordTooLargeError(maxRecordBytes); + } + return json + '\n'; +} + +/** + * Incrementally decodes newline-delimited JSON records from a stream of chunks. + * + * @remarks + * Call {@link NdjsonDecoder.decode} for each received chunk to obtain the + * records completed by that chunk, then call {@link NdjsonDecoder.flush} once + * the stream ends to obtain any trailing record that was not newline-terminated. + * + * @beta + */ +export class NdjsonDecoder { + private readonly _maxRecordBytes: number; + private _buffer: string; + + public constructor(options?: INdjsonOptions) { + this._maxRecordBytes = options?.maxRecordBytes ?? REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes; + this._buffer = ''; + } + + /** + * Appends a chunk and returns any records it completed. + * + * @param chunk - a fragment of the NDJSON stream + * @throws NdjsonRecordTooLargeError if a record exceeds the limit + */ + public decode(chunk: string): unknown[] { + this._buffer += chunk; + const records: unknown[] = []; + + let newlineIndex: number = this._buffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line: string = this._buffer.slice(0, newlineIndex); + this._buffer = this._buffer.slice(newlineIndex + 1); + this._processLine(line, records); + newlineIndex = this._buffer.indexOf('\n'); + } + + // A partial line that already exceeds the limit can never become a valid record. + if (Buffer.byteLength(this._buffer, 'utf8') > this._maxRecordBytes) { + throw new NdjsonRecordTooLargeError(this._maxRecordBytes); + } + + return records; + } + + /** + * Returns any trailing record that was not newline-terminated and resets the buffer. + * + * @throws NdjsonRecordTooLargeError if the trailing record exceeds the limit + */ + public flush(): unknown[] { + const records: unknown[] = []; + if (this._buffer.length > 0) { + const line: string = this._buffer; + this._buffer = ''; + this._processLine(line, records); + } + return records; + } + + private _processLine(line: string, records: unknown[]): void { + const trimmed: string = line.trim(); + if (trimmed.length === 0) { + return; + } + if (Buffer.byteLength(trimmed, 'utf8') > this._maxRecordBytes) { + throw new NdjsonRecordTooLargeError(this._maxRecordBytes); + } + records.push(JSON.parse(trimmed)); + } +} diff --git a/libraries/reporter/src/protocol/ReporterHandshake.ts b/libraries/reporter/src/protocol/ReporterHandshake.ts new file mode 100644 index 00000000000..9d028785d3c --- /dev/null +++ b/libraries/reporter/src/protocol/ReporterHandshake.ts @@ -0,0 +1,161 @@ +// 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 { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; +import { isReporterProtocolCompatible } from './ReporterProtocol'; + +/** + * The opening message a cross-process producer sends to negotiate the wire protocol. + * + * @beta + */ +export interface IReporterHello { + /** + * Discriminates the message kind. + */ + readonly kind: 'hello'; + + /** + * The protocol version the producer implements. + */ + readonly protocolVersion: IReporterProtocolVersion; + + /** + * The version of the producing package. + */ + readonly producerVersion: string; + + /** + * Optional capabilities the producer can use if the consumer supports them. + */ + readonly capabilities: string[]; + + /** + * Correctness-critical features the producer requires the consumer to support. + */ + readonly requiredFeatures: string[]; +} + +/** + * The consumer's reply that accepts capabilities and reports unsupported required features. + * + * @beta + */ +export interface IReporterHelloAck { + /** + * Discriminates the message kind. + */ + readonly kind: 'helloAck'; + + /** + * The protocol version the consumer implements. + */ + readonly protocolVersion: IReporterProtocolVersion; + + /** + * The subset of the producer's capabilities the consumer accepted. + */ + readonly acceptedCapabilities: string[]; + + /** + * The producer's required features the consumer does not support. + */ + readonly rejectedRequiredFeatures: string[]; +} + +/** + * Options for {@link negotiateReporterHello}. + * + * @beta + */ +export interface IReporterHandshakeOptions { + /** + * The protocol version the consumer implements. + */ + readonly supportedProtocolVersion: IReporterProtocolVersion; + + /** + * The capabilities the consumer supports. Anything not listed is treated as + * an unknown optional capability and is simply not accepted. + */ + readonly supportedCapabilities?: readonly string[]; +} + +/** + * The result of negotiating a producer hello. + * + * @beta + */ +export interface IReporterHandshakeResult { + /** + * Whether the connection was accepted. A connection is accepted only when the + * majors are compatible and every required feature is supported. + */ + readonly accepted: boolean; + + /** + * The acknowledgement to send back to the producer. + */ + readonly ack: IReporterHelloAck; + + /** + * An update-global-Rush diagnostic, present when the major is unsupported or a + * required feature is unknown. + */ + readonly diagnostic?: IRushDiagnostic; +} + +/** + * Negotiates a producer's hello against the consumer's supported protocol. + * + * @remarks + * Capabilities are optional: the consumer accepts the intersection of the + * producer's capabilities and its own, and ignores the rest. Only unknown + * required features or an unsupported major cause rejection, and either emits an + * update-global-Rush diagnostic. A differing minor is always compatible because + * minor versions are additive. + * + * @param hello - the producer's hello message + * @param options - the consumer's supported protocol and capabilities + * + * @beta + */ +export function negotiateReporterHello( + hello: IReporterHello, + options: IReporterHandshakeOptions +): IReporterHandshakeResult { + const consumerVersion: IReporterProtocolVersion = options.supportedProtocolVersion; + const supportedCapabilities: ReadonlySet = new Set(options.supportedCapabilities ?? []); + + const acceptedCapabilities: string[] = hello.capabilities.filter((capability: string) => + supportedCapabilities.has(capability) + ); + const rejectedRequiredFeatures: string[] = hello.requiredFeatures.filter( + (feature: string) => !supportedCapabilities.has(feature) + ); + + const majorSupported: boolean = isReporterProtocolCompatible(consumerVersion, hello.protocolVersion); + const accepted: boolean = majorSupported && rejectedRequiredFeatures.length === 0; + + const ack: IReporterHelloAck = { + kind: 'helloAck', + protocolVersion: consumerVersion, + acceptedCapabilities, + rejectedRequiredFeatures + }; + + if (accepted) { + return { accepted: true, ack }; + } + + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_PROTOCOL_UPDATE_REQUIRED', { + parameters: { + producerVersion: { value: hello.producerVersion, privacy: 'public' }, + producerProtocolMajor: { value: hello.protocolVersion.major, privacy: 'public' } + } + }); + + return { accepted: false, ack, diagnostic }; +} diff --git a/libraries/reporter/src/protocol/ReporterProtocol.ts b/libraries/reporter/src/protocol/ReporterProtocol.ts new file mode 100644 index 00000000000..7fe8fcc7c1c --- /dev/null +++ b/libraries/reporter/src/protocol/ReporterProtocol.ts @@ -0,0 +1,76 @@ +// 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 reporter protocol version implemented by this package. + * + * @remarks + * The `major` gates compatibility; a consumer supports one major. `minor` + * versions are additive, so a newer minor only introduces optional events and + * capabilities. + * + * @beta + */ +export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion = { + major: 1, + minor: 0 +}; + +/** + * The byte-size limits enforced by the reporter wire protocol. + * + * @beta + */ +export interface IReporterProtocolLimits { + /** + * The maximum size of the buffered bootstrap event stream, in bytes. + */ + readonly bootstrapBufferBytes: number; + + /** + * The maximum size of a single NDJSON record, in bytes. + */ + readonly ndjsonRecordBytes: number; + + /** + * The maximum size of a single raw external-output chunk, in bytes. + */ + readonly externalOutputChunkBytes: number; +} + +/** + * The byte-size limits enforced by the reporter wire protocol. + * + * @remarks + * The bootstrap buffer and NDJSON record are each 1 MiB, and a raw + * external-output chunk is 64 KiB. + * + * @beta + */ +export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits = { + bootstrapBufferBytes: 1024 * 1024, + ndjsonRecordBytes: 1024 * 1024, + externalOutputChunkBytes: 64 * 1024 +}; + +/** + * Returns `true` if a consumer of the given protocol version supports a + * producer's protocol version. + * + * @remarks + * Compatibility requires an equal `major`. A differing `minor` is always + * compatible because minor versions are additive. + * + * @param consumer - the protocol version supported by the consumer + * @param producer - the protocol version advertised by the producer + * + * @beta + */ +export function isReporterProtocolCompatible( + consumer: IReporterProtocolVersion, + producer: IReporterProtocolVersion +): boolean { + return consumer.major === producer.major; +} diff --git a/libraries/reporter/src/test/Diagnostics.test.ts b/libraries/reporter/src/test/Diagnostics.test.ts new file mode 100644 index 00000000000..cafde099b9c --- /dev/null +++ b/libraries/reporter/src/test/Diagnostics.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + RUSH_DIAGNOSTIC_CODE_DEFINITIONS, + RUSH_DIAGNOSTIC_CODES, + RUSH_DIAGNOSTIC_TEMPLATES, + RUSH_INTERNAL_ERROR_CODE, + isValidRushDiagnosticCode, + computeEnvelopePrivacyFloor, + getPrivacyClassificationRank, + createRushDiagnostic, + RushError, + type IRushDiagnostic, + type IRushDiagnosticCodeDefinition, + type RushDiagnosticCategory, + type ReporterPrivacyClassification +} from '../index'; + +const VALID_CATEGORIES: readonly RushDiagnosticCategory[] = [ + 'configuration', + 'input', + 'dependency-tool', + 'environment', + 'network-auth', + 'operation', + 'internal' +]; + +describe('RushDiagnosticCodeRegistry', () => { + it('uses well-formed, unique, never-reused codes', () => { + const seen: Set = new Set(); + for (const definition of RUSH_DIAGNOSTIC_CODE_DEFINITIONS) { + expect(isValidRushDiagnosticCode(definition.code)).toBe(true); + expect(seen.has(definition.code)).toBe(false); + seen.add(definition.code); + } + expect(seen.size).toBe(RUSH_DIAGNOSTIC_CODE_DEFINITIONS.length); + }); + + it('indexes every definition by its own code', () => { + expect(RUSH_DIAGNOSTIC_CODES.size).toBe(RUSH_DIAGNOSTIC_CODE_DEFINITIONS.length); + for (const definition of RUSH_DIAGNOSTIC_CODE_DEFINITIONS) { + expect(RUSH_DIAGNOSTIC_CODES.get(definition.code)).toBe(definition); + } + }); + + it('assigns a valid category to every definition', () => { + for (const definition of RUSH_DIAGNOSTIC_CODE_DEFINITIONS) { + expect(VALID_CATEGORIES).toContain(definition.category); + } + }); + + it('provides an English template for every summary and detail key', () => { + for (const definition of RUSH_DIAGNOSTIC_CODE_DEFINITIONS) { + expect(typeof RUSH_DIAGNOSTIC_TEMPLATES[definition.summaryKey]).toBe('string'); + if (definition.detailKey !== undefined) { + expect(typeof RUSH_DIAGNOSTIC_TEMPLATES[definition.detailKey]).toBe('string'); + } + } + }); + + it('registers the stable internal-error code under the internal category', () => { + const definition: IRushDiagnosticCodeDefinition | undefined = + RUSH_DIAGNOSTIC_CODES.get(RUSH_INTERNAL_ERROR_CODE); + expect(definition).toBeDefined(); + expect(definition?.category).toBe('internal'); + }); + + it('rejects malformed codes', () => { + expect(isValidRushDiagnosticCode('rush_config_invalid')).toBe(false); // lowercase + expect(isValidRushDiagnosticCode('RUSH_CONFIG')).toBe(false); // missing name segment + expect(isValidRushDiagnosticCode('CONFIG_INVALID_JSON')).toBe(false); // missing RUSH_ prefix + expect(isValidRushDiagnosticCode('RUSH__DOUBLE')).toBe(false); // empty segment + }); +}); + +describe('DiagnosticPrivacy', () => { + it('ranks classifications from least to most sensitive', () => { + expect(getPrivacyClassificationRank('public')).toBe(0); + expect(getPrivacyClassificationRank('local-sensitive')).toBe(1); + expect(getPrivacyClassificationRank('secret')).toBe(2); + }); + + it('defaults to public when there are no fields', () => { + expect(computeEnvelopePrivacyFloor([])).toBe('public'); + }); + + it('returns the least sensitive classification as the floor', () => { + expect(computeEnvelopePrivacyFloor(['public', 'secret'])).toBe('public'); + expect(computeEnvelopePrivacyFloor(['local-sensitive', 'secret'])).toBe('local-sensitive'); + expect(computeEnvelopePrivacyFloor(['secret', 'secret'])).toBe('secret'); + }); + + it('produces a floor no more sensitive than any field', () => { + const fields: ReporterPrivacyClassification[] = ['secret', 'local-sensitive', 'public']; + const floor: ReporterPrivacyClassification = computeEnvelopePrivacyFloor(fields); + for (const field of fields) { + expect(getPrivacyClassificationRank(floor)).toBeLessThanOrEqual(getPrivacyClassificationRank(field)); + } + }); +}); + +describe('createRushDiagnostic', () => { + it('derives category, severity, and template keys from the registry', () => { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED'); + expect(diagnostic.code).toBe('RUSH_DEPENDENCY_TOOL_FAILED'); + expect(diagnostic.category).toBe('dependency-tool'); + expect(diagnostic.severity).toBe('error'); + expect(diagnostic.summaryKey).toBe('diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary'); + expect(diagnostic.detailKey).toBe('diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail'); + }); + + it('generates a unique diagnostic id when none is supplied', () => { + const first: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED'); + const second: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED'); + expect(typeof first.diagnosticId).toBe('string'); + expect(first.diagnosticId.length).toBeGreaterThan(0); + expect(first.diagnosticId).not.toBe(second.diagnosticId); + }); + + it('honors an explicit diagnostic id, severity, and parameters', () => { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_INPUT_UNKNOWN_PROJECT', { + diagnosticId: 'diag_fixed', + severity: 'warning', + parameters: { + projectName: { value: 'my-project', privacy: 'public' } + } + }); + expect(diagnostic.diagnosticId).toBe('diag_fixed'); + expect(diagnostic.severity).toBe('warning'); + expect(diagnostic.parameters?.projectName.value).toBe('my-project'); + }); + + it('throws for an unknown code', () => { + expect(() => createRushDiagnostic('RUSH_NOT_A_REAL_CODE')).toThrow(/Unknown Rush diagnostic code/); + }); +}); + +describe('RushError', () => { + it('wraps a diagnostic and references its id', () => { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + diagnosticId: 'diag_err' + }); + const error: RushError = new RushError(diagnostic); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RushError); + expect(error.name).toBe('RushError'); + expect(error.diagnostic).toBe(diagnostic); + expect(error.diagnosticId).toBe('diag_err'); + expect(error.message).toBe('RUSH_OPERATION_FAILED'); + }); +}); diff --git a/libraries/reporter/src/test/Goldens.test.ts b/libraries/reporter/src/test/Goldens.test.ts new file mode 100644 index 00000000000..14861593b3b --- /dev/null +++ b/libraries/reporter/src/test/Goldens.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + encodeNdjsonRecord, + NdjsonDecoder, + negotiateReporterHello, + REPORTER_PROTOCOL_VERSION, + type IReporterEventEnvelope, + type IReporterHello, + type IReporterHelloAck, + type IReporterProtocolVersion, + type IRushDiagnostic +} from '../index'; + +// Golden schema tests freeze the serialized wire form of the protocol so that +// any accidental change is a visible, reviewable diff. +describe('protocol goldens', () => { + const goldenEnvelope: IReporterEventEnvelope<{ commandName: string; argv: readonly string[] }> = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt_0001', + sessionId: 'sess_root', + parentSessionId: 'sess_parent', + parentOperationId: 'op_17', + sequence: 5, + sourceSequence: 2, + timestamp: '2026-07-12T00:00:00.000Z', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: '5.177.2', + component: 'PhasedScriptAction' + }, + scope: { + commandName: 'build', + operationId: 'op_17', + projectName: '@my-company/project-a', + phaseName: '_phase:build' + }, + privacy: 'public', + required: true, + type: 'commandStarted', + payload: { commandName: 'build', argv: ['--to', '@my-company/project-a'] } + }; + + const goldenDiagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + diagnosticId: 'diag_0001', + parameters: { + exitCode: { value: 1, privacy: 'public' }, + command: { value: 'pnpm install', privacy: 'public' }, + logPath: { value: '/repo/common/temp/install.log', privacy: 'local-sensitive' } + }, + remediation: [ + { + descriptionKey: 'remediation.reinstall', + command: 'rush update --purge', + documentationUrl: 'https://rushjs.io/pages/commands/rush_update/', + automatedExecutionSafety: 'requires-confirmation' + } + ], + source: { file: 'common/config/rush/pnpm-config.json', toolName: 'pnpm' }, + causeDiagnosticIds: ['diag_0000'], + retryable: true, + relatedArtifactIds: ['artifact_install_log'] + }); + + it('freezes the event envelope schema', () => { + expect(goldenEnvelope).toMatchSnapshot(); + }); + + it('freezes the diagnostic schema', () => { + expect(goldenDiagnostic).toMatchSnapshot(); + }); + + it('freezes the handshake hello and helloAck schemas', () => { + const hello: IReporterHello = { + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['color', 'watch'], + requiredFeatures: ['ordered-sequence'] + }; + const ack: IReporterHelloAck = { + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['color', 'watch'], + rejectedRequiredFeatures: [] + }; + expect(hello).toMatchSnapshot('hello'); + expect(ack).toMatchSnapshot('helloAck'); + }); + + it('round-trips the golden envelope and diagnostic through NDJSON without loss', () => { + for (const value of [goldenEnvelope, goldenDiagnostic]) { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + const [decoded] = decoder.decode(encodeNdjsonRecord(value)); + expect(decoded).toEqual(JSON.parse(JSON.stringify(value))); + } + }); +}); + +describe('compatibility goldens', () => { + interface INegotiationCase { + readonly name: string; + readonly producer: IReporterProtocolVersion; + readonly capabilities: string[]; + readonly requiredFeatures: string[]; + } + + const supportedCapabilities: readonly string[] = ['color', 'watch', 'ordered-sequence']; + + const cases: readonly INegotiationCase[] = [ + { + name: 'same major and minor', + producer: { major: 1, minor: 0 }, + capabilities: [], + requiredFeatures: [] + }, + { + name: 'same major, newer additive minor', + producer: { major: 1, minor: 5 }, + capabilities: ['color'], + requiredFeatures: [] + }, + { + name: 'unknown optional capability is ignored', + producer: { major: 1, minor: 0 }, + capabilities: ['color', 'future-cap'], + requiredFeatures: [] + }, + { + name: 'unknown required feature is rejected', + producer: { major: 1, minor: 0 }, + capabilities: [], + requiredFeatures: ['future-required'] + }, + { + name: 'unsupported newer major is rejected', + producer: { major: 2, minor: 0 }, + capabilities: [], + requiredFeatures: [] + } + ]; + + it('freezes negotiation outcomes across supported majors and minors', () => { + const outcomes: unknown[] = cases.map((testCase: INegotiationCase) => { + const result = negotiateReporterHello( + { + kind: 'hello', + protocolVersion: testCase.producer, + producerVersion: 'producer 1.0.0', + capabilities: testCase.capabilities, + requiredFeatures: testCase.requiredFeatures + }, + { supportedProtocolVersion: { major: 1, minor: 0 }, supportedCapabilities } + ); + // Normalize away the non-deterministic diagnostic id. + return { + name: testCase.name, + accepted: result.accepted, + acceptedCapabilities: result.ack.acceptedCapabilities, + rejectedRequiredFeatures: result.ack.rejectedRequiredFeatures, + diagnosticCode: result.diagnostic?.code + }; + }); + expect(outcomes).toMatchSnapshot(); + }); + + it('retains unknown optional fields from a newer minor (additive) event', () => { + // A newer minor may add optional fields. Decoding must preserve them so a + // full-detail reporter can retain the complete record. + const forwardCompatible: Record = { + ...({ + protocolVersion: { major: 1, minor: 1 }, + eventId: 'evt_future', + sessionId: 'sess_root', + sequence: 9, + timestamp: '2026-07-12T00:00:00.000Z', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: false, + type: 'activityChanged', + payload: {} + } satisfies IReporterEventEnvelope>), + futureOptionalField: { detail: 'added in a later minor' } + }; + + const decoder: NdjsonDecoder = new NdjsonDecoder(); + const [decoded] = decoder.decode(encodeNdjsonRecord(forwardCompatible)); + expect(decoded).toEqual(forwardCompatible); + expect((decoded as Record).futureOptionalField).toEqual({ + detail: 'added in a later minor' + }); + }); + + it('advertises the current protocol version as the negotiation baseline', () => { + expect(REPORTER_PROTOCOL_VERSION).toMatchSnapshot(); + }); +}); + +describe('legacy output snapshots', () => { + // The current Rush legacy renderer is the baseline the future legacy reporter + // must reproduce. These representative samples are frozen so parity work has a + // stable reference. Durations are normalized because they vary per run. + function normalizeDurations(text: string): string { + return text.replace(/\d+\.\d+ seconds/g, 'X.XX seconds'); + } + + const legacySuccessOutput: string = [ + 'Starting "rush build"', + '', + 'Executing a maximum of 4 simultaneous processes...', + '', + '==[ @my-company/project-a (build) ]================================[ 1 of 2 ]==', + 'Building project-a...', + 'project-a done.', + '', + '==[ @my-company/project-b (build) ]================================[ 2 of 2 ]==', + 'Building project-b...', + 'project-b done.', + '', + '', + '==[ SUCCESS: 2 operations ]====================================================', + '', + 'These operations completed successfully:', + ' @my-company/project-a (build) 1.23 seconds', + ' @my-company/project-b (build) 2.34 seconds', + '', + 'rush build (3.70 seconds)' + ].join('\n'); + + const legacyFailureOutput: string = [ + 'Starting "rush build"', + '', + '==[ @my-company/project-a (build) ]================================[ 1 of 2 ]==', + 'Building project-a...', + 'Error: Command failed with exit code 1', + '', + '==[ @my-company/project-b (build) ]================================[ 2 of 2 ]==', + '"@my-company/project-b" is blocked by a failed dependency.', + '', + '==[ FAILURE: 1 operation ]=====================================================', + '', + 'The following projects failed to build:', + ' @my-company/project-a (build) 0.50 seconds', + '', + 'rush build (0.75 seconds) ==> ERROR: Project(s) failed to build' + ].join('\n'); + + it('freezes the legacy success output', () => { + expect(normalizeDurations(legacySuccessOutput)).toMatchSnapshot(); + }); + + it('freezes the legacy failure output', () => { + expect(normalizeDurations(legacyFailureOutput)).toMatchSnapshot(); + }); +}); diff --git a/libraries/reporter/src/test/IReporterEventEnvelope.test.ts b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts new file mode 100644 index 00000000000..b49914907d3 --- /dev/null +++ b/libraries/reporter/src/test/IReporterEventEnvelope.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + REPORTER_EVENT_TYPES, + type IReporterEventEnvelope, + type ReporterEventType, + type ReporterJsonValue +} from '../index'; + +describe('ReporterEventType', () => { + it('exposes the closed core union as an ordered runtime list', () => { + // Golden: the closed core union. Changing this set is a protocol change. + expect(REPORTER_EVENT_TYPES).toEqual([ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' + ]); + }); + + it('contains no duplicate identifiers', () => { + expect(new Set(REPORTER_EVENT_TYPES).size).toBe(REPORTER_EVENT_TYPES.length); + }); +}); + +describe('IReporterEventEnvelope', () => { + interface ICommandStartedPayload { + readonly commandName: string; + readonly argv: readonly string[]; + } + + function createEnvelope(): IReporterEventEnvelope { + const type: ReporterEventType = 'commandStarted'; + return { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt_0001', + sessionId: 'sess_root', + sequence: 1, + timestamp: '2026-07-12T00:00:00.000Z', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: '5.177.2' + }, + scope: { + commandName: 'build' + }, + privacy: 'public', + required: true, + type, + payload: { + commandName: 'build', + argv: ['--to', '@rushstack/reporter'] + } + }; + } + + it('matches the golden envelope shape', () => { + expect(createEnvelope()).toMatchSnapshot(); + }); + + it('round-trips through JSON without data loss', () => { + const envelope: IReporterEventEnvelope = createEnvelope(); + const roundTripped: IReporterEventEnvelope = JSON.parse(JSON.stringify(envelope)); + expect(roundTripped).toEqual(envelope); + }); + + it('preserves the producer local sequence as sourceSequence for child sessions', () => { + const childEnvelope: IReporterEventEnvelope = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt_0002', + sessionId: 'sess_child', + parentSessionId: 'sess_root', + parentOperationId: 'op_42', + sequence: 7, + sourceSequence: 2, + timestamp: '2026-07-12T00:00:01.000Z', + source: { + packageName: '@rushstack/heft', + packageVersion: '1.2.19' + }, + privacy: 'public', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'Building...' } + }; + + // The manager assigns the global `sequence` while the producer's own order is retained. + expect(childEnvelope.sequence).toBe(7); + expect(childEnvelope.sourceSequence).toBe(2); + expect(JSON.parse(JSON.stringify(childEnvelope))).toEqual(childEnvelope); + }); + + it('carries Error information as a JSON-serializable payload rather than an Error instance', () => { + const error: Error = new Error('something failed'); + + // A raw Error does not survive JSON serialization: its `message` is dropped. + expect(JSON.stringify(error)).toBe('{}'); + + // Producers convert the Error into plain fields before emitting. + const envelope: IReporterEventEnvelope = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt_0003', + sessionId: 'sess_root', + sequence: 9, + timestamp: '2026-07-12T00:00:02.000Z', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type: 'diagnosticEmitted', + payload: { name: error.name, message: error.message } + }; + + const roundTripped: IReporterEventEnvelope = JSON.parse(JSON.stringify(envelope)); + expect(roundTripped.payload).toEqual({ name: 'Error', message: 'something failed' }); + }); +}); diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts new file mode 100644 index 00000000000..550ecd1c4f6 --- /dev/null +++ b/libraries/reporter/src/test/Manager.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + ReporterManager, + ReporterMultiplexer, + type IReporter, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type ReporterEventType, + type ReporterJsonValue +} from '../index'; + +class RecordingReporter implements IReporter { + public readonly name: string; + public readonly reported: IReporterEventEnvelope[] = []; + public initCount: number = 0; + public flushCount: number = 0; + public closeCount: number = 0; + public throwOnInit: boolean = false; + public throwOnReportType: ReporterEventType | undefined = undefined; + + public constructor(name: string) { + this.name = name; + } + + public async initializeAsync(): Promise { + this.initCount++; + if (this.throwOnInit) { + throw new Error(`init failed ${this.name}`); + } + } + + public report(event: IReporterEventEnvelope): void { + if (this.throwOnReportType !== undefined && event.type === this.throwOnReportType) { + throw new Error(`report failed ${this.name}`); + } + this.reported.push(event); + } + + public async flushAsync(): Promise { + this.flushCount++; + } + + public async closeAsync(): Promise { + this.closeCount++; + } +} + +function makeInput( + type: ReporterEventType, + payload: ReporterJsonValue = {}, + required: boolean = false +): IReporterEmitEventInput { + return { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required, + type, + payload + }; +} + +describe('ReporterManager ordering and assignment', () => { + it('assigns monotonic sequence, event ids, and timestamps in order', async () => { + const manager: ReporterManager = new ReporterManager({ now: () => '2026-01-01T00:00:00.000Z' }); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const id1: string = manager.emit(makeInput('commandStarted')); + const id2: string = manager.emit(makeInput('activityChanged')); + await manager.flushAsync(); + + expect([id1, id2]).toEqual(['evt_1', 'evt_2']); + expect(reporter.reported.map((e: IReporterEventEnvelope) => e.sequence)).toEqual([1, 2]); + expect(reporter.reported[0].eventId).toBe('evt_1'); + expect(reporter.reported[0].timestamp).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('rehomes a foreign envelope with a new sequence and preserved sourceSequence', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + manager.emit(makeInput('commandStarted')); + const foreign: IReporterEventEnvelope = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'child_evt', + sessionId: 'child', + sequence: 42, + timestamp: '2026-01-01T00:00:01.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.19' }, + privacy: 'public', + required: false, + type: 'externalOutput', + payload: {} + }; + const id: string = manager.ingestForeignEnvelope(foreign); + await manager.flushAsync(); + + expect(id).toBe('child_evt'); + const ingested: IReporterEventEnvelope | undefined = reporter.reported.find( + (e: IReporterEventEnvelope) => e.eventId === 'child_evt' + ); + expect(ingested?.sequence).toBe(2); + expect(ingested?.sourceSequence).toBe(42); + }); +}); + +describe('ReporterManager destinations', () => { + it('enforces exclusive destination ownership', () => { + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(new RecordingReporter('a'), { destination: 'stdout' }); + expect(() => manager.addReporter(new RecordingReporter('b'), { destination: 'stdout' })).toThrow( + /already owned/ + ); + }); + + it('allows sharing a destination through a multiplexer', async () => { + const child1: RecordingReporter = new RecordingReporter('c1'); + const child2: RecordingReporter = new RecordingReporter('c2'); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(new ReporterMultiplexer('mux', [child1, child2]), { destination: 'stdout' }); + await manager.initializeAsync(); + + manager.emit(makeInput('commandStarted')); + await manager.flushAsync(); + + expect(child1.initCount).toBe(1); + expect(child1.reported).toHaveLength(1); + expect(child2.reported).toHaveLength(1); + }); + + it('rejects reporters added after initialization', async () => { + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(new RecordingReporter('a')); + await manager.initializeAsync(); + expect(() => manager.addReporter(new RecordingReporter('b'))).toThrow(/after the manager/); + }); +}); + +describe('ReporterManager failure handling', () => { + it('treats initialization failure as fatal', async () => { + const reporter: RecordingReporter = new RecordingReporter('a'); + reporter.throwOnInit = true; + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(reporter); + await expect(manager.initializeAsync()).rejects.toThrow(/init failed/); + }); + + it('disables an optional reporter on runtime failure and keeps others running', async () => { + const emergency: string[] = []; + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: (message: string) => emergency.push(message) + }); + const bad: RecordingReporter = new RecordingReporter('bad'); + bad.throwOnReportType = 'activityChanged'; + const good: RecordingReporter = new RecordingReporter('good'); + manager.addReporter(bad); + manager.addReporter(good); + await manager.initializeAsync(); + + manager.emit(makeInput('activityChanged')); + manager.emit(makeInput('commandCompleted')); + await manager.flushAsync(); + + expect(good.reported).toHaveLength(2); + expect(bad.reported).toHaveLength(0); + expect(emergency.some((m: string) => m.includes('Disabling optional reporter "bad"'))).toBe(true); + }); + + it('surfaces a required reporter failure as a fatal flush error', async () => { + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const bad: RecordingReporter = new RecordingReporter('bad'); + bad.throwOnReportType = 'activityChanged'; + manager.addReporter(bad, { required: true }); + await manager.initializeAsync(); + + manager.emit(makeInput('activityChanged')); + await expect(manager.flushAsync()).rejects.toThrow(/report failed/); + }); +}); + +describe('ReporterManager coalescing', () => { + it('coalesces replaceable status events under pressure but never drops protected events', async () => { + const manager: ReporterManager = new ReporterManager({ coalesceThreshold: 4 }); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + for (let i: number = 0; i < 20; i++) { + manager.emit(makeInput('activityChanged', { i })); + } + manager.emit(makeInput('commandResult')); + manager.emit(makeInput('diagnosticEmitted')); + await manager.flushAsync(); + + const activity: IReporterEventEnvelope[] = reporter.reported.filter( + (e: IReporterEventEnvelope) => e.type === 'activityChanged' + ); + const protectedEvents: IReporterEventEnvelope[] = reporter.reported.filter( + (e: IReporterEventEnvelope) => e.type === 'commandResult' || e.type === 'diagnosticEmitted' + ); + + expect(protectedEvents).toHaveLength(2); + expect(activity.length).toBeGreaterThan(0); + expect(activity.length).toBeLessThan(20); + }); +}); + +describe('ReporterManager flush and close', () => { + it('flushes and closes every reporter', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + manager.emit(makeInput('commandStarted')); + await manager.closeAsync(); + + expect(reporter.reported).toHaveLength(1); + expect(reporter.flushCount).toBeGreaterThanOrEqual(1); + expect(reporter.closeCount).toBe(1); + }); + + it('returns from flush even when a reporter never resolves, using the timeout', async () => { + const slow: IReporter = { + name: 'slow', + async initializeAsync(): Promise { + /* no-op */ + }, + report(): void { + /* no-op */ + }, + flushAsync(): Promise { + return new Promise(() => { + /* never resolves */ + }); + }, + async closeAsync(): Promise { + /* no-op */ + } + }; + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(slow); + await manager.initializeAsync(); + + manager.emit(makeInput('commandStarted')); + await manager.flushAsync(50); + expect(true).toBe(true); + }); +}); diff --git a/libraries/reporter/src/test/ProducerApi.test.ts b/libraries/reporter/src/test/ProducerApi.test.ts new file mode 100644 index 00000000000..ba02d40d960 --- /dev/null +++ b/libraries/reporter/src/test/ProducerApi.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + isReporterExtensionEventName, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedReporter +} from '../index'; + +describe('IReporterEventSink', () => { + it('accepts an input that omits the sink-assigned fields and returns an event id', () => { + const published: IReporterEventEnvelope[] = []; + let nextSequence: number = 1; + + const sink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const sequence: number = nextSequence++; + const eventId: string = `evt_${sequence}`; + published.push({ + ...event, + eventId, + sequence, + timestamp: '2026-07-12T00:00:00.000Z' + }); + return eventId; + } + }; + + // The producer never supplies eventId, sequence, or timestamp. + const input: IReporterEmitEventInput<{ commandName: string }> = { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess_root', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type: 'commandStarted', + payload: { commandName: 'build' } + }; + + const eventId: string = sink.emit(input); + + expect(eventId).toBe('evt_1'); + expect(published).toHaveLength(1); + expect(published[0].sequence).toBe(1); + expect(published[0].eventId).toBe('evt_1'); + expect(published[0].timestamp).toBe('2026-07-12T00:00:00.000Z'); + expect(published[0].payload).toEqual({ commandName: 'build' }); + }); +}); + +describe('IScopedReporter', () => { + function createScopedReporter(sink: string[]): IScopedReporter { + let nextId: number = 1; + const assign = (): string => `evt_${nextId++}`; + return { + emitMessage(): string { + const id: string = assign(); + sink.push(id); + return id; + }, + emitDiagnostic(): string { + const id: string = assign(); + sink.push(id); + return id; + }, + emitExtension(): string { + const id: string = assign(); + sink.push(id); + return id; + } + }; + } + + it('returns an event id from every emit method', () => { + const ids: string[] = []; + const reporter: IScopedReporter = createScopedReporter(ids); + + const diagnostic: IRushDiagnostic = { + diagnosticId: 'diag_0001', + code: 'RUSH_CONFIG_INVALID_JSON', + category: 'configuration', + severity: 'error', + summaryKey: 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary' + }; + + expect(reporter.emitMessage({ severity: 'info', text: 'Building...' })).toBe('evt_1'); + expect(reporter.emitDiagnostic(diagnostic)).toBe('evt_2'); + expect(reporter.emitExtension('acme.cache-warmed', { hits: 3 })).toBe('evt_3'); + expect(ids).toEqual(['evt_1', 'evt_2', 'evt_3']); + }); + + it('exposes only emit methods, not reporter instances, destinations, or thresholds', () => { + const reporter: IScopedReporter = createScopedReporter([]); + expect(Object.keys(reporter).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + }); +}); + +describe('isReporterExtensionEventName', () => { + it('accepts namespaced beta identifiers', () => { + expect(isReporterExtensionEventName('acme.cache-warmed')).toBe(true); + expect(isReporterExtensionEventName('acme.build.step2')).toBe(true); + expect(isReporterExtensionEventName('vendor1.a1-b2')).toBe(true); + }); + + it('rejects non-namespaced or malformed identifiers', () => { + expect(isReporterExtensionEventName('cacheWarmed')).toBe(false); // no namespace + expect(isReporterExtensionEventName('Acme.Event')).toBe(false); // uppercase + expect(isReporterExtensionEventName('1acme.event')).toBe(false); // leading digit + expect(isReporterExtensionEventName('acme.')).toBe(false); // trailing dot + expect(isReporterExtensionEventName('acme..event')).toBe(false); // empty segment + expect(isReporterExtensionEventName('acme.event-')).toBe(false); // trailing hyphen + }); +}); diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts new file mode 100644 index 00000000000..6ea0b1696ba --- /dev/null +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + REPORTER_PROTOCOL_VERSION, + REPORTER_PROTOCOL_LIMITS, + isReporterProtocolCompatible, + encodeNdjsonRecord, + NdjsonDecoder, + NdjsonRecordTooLargeError, + negotiateReporterHello, + type IReporterHello, + type IReporterHandshakeResult +} from '../index'; + +describe('ReporterProtocol', () => { + it('advertises protocol major 1 and the specified byte limits', () => { + expect(REPORTER_PROTOCOL_VERSION.major).toBe(1); + expect(REPORTER_PROTOCOL_LIMITS.bootstrapBufferBytes).toBe(1024 * 1024); + expect(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes).toBe(1024 * 1024); + expect(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes).toBe(64 * 1024); + }); + + it('treats an equal major as compatible regardless of minor', () => { + expect(isReporterProtocolCompatible({ major: 1, minor: 0 }, { major: 1, minor: 9 })).toBe(true); + expect(isReporterProtocolCompatible({ major: 1, minor: 0 }, { major: 2, minor: 0 })).toBe(false); + }); +}); + +describe('NDJSON encode/decode', () => { + it('encodes a value as a single newline-terminated record', () => { + const record: string = encodeNdjsonRecord({ text: 'a\nb', n: 1 }); + // The embedded newline is escaped, so the only real newline is the terminator. + expect(record.endsWith('\n')).toBe(true); + expect(record.indexOf('\n')).toBe(record.length - 1); + }); + + it('round-trips values through the decoder', () => { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + const stream: string = encodeNdjsonRecord({ id: 1, text: 'line\nbreak' }) + encodeNdjsonRecord({ id: 2 }); + const records: unknown[] = decoder.decode(stream); + expect(records).toEqual([{ id: 1, text: 'line\nbreak' }, { id: 2 }]); + expect(decoder.flush()).toEqual([]); + }); + + it('reassembles records split across chunks and ignores blank lines', () => { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + expect(decoder.decode('{"a":')).toEqual([]); + expect(decoder.decode('1}\n\n{"b":2}')).toEqual([{ a: 1 }]); + expect(decoder.flush()).toEqual([{ b: 2 }]); + }); + + it('throws when an encoded record exceeds the limit', () => { + expect(() => encodeNdjsonRecord({ text: 'x'.repeat(100) }, { maxRecordBytes: 10 })).toThrow( + NdjsonRecordTooLargeError + ); + }); + + it('throws when a decoded record exceeds the limit', () => { + const decoder: NdjsonDecoder = new NdjsonDecoder({ maxRecordBytes: 10 }); + expect(() => decoder.decode(`${'"'}${'x'.repeat(100)}${'"'}\n`)).toThrow(NdjsonRecordTooLargeError); + }); + + it('throws when a partial line exceeds the limit before a newline arrives', () => { + const decoder: NdjsonDecoder = new NdjsonDecoder({ maxRecordBytes: 10 }); + expect(() => decoder.decode('x'.repeat(50))).toThrow(NdjsonRecordTooLargeError); + }); +}); + +describe('negotiateReporterHello', () => { + function makeHello(overrides?: Partial): IReporterHello { + return { + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: [], + requiredFeatures: [], + ...overrides + }; + } + + it('accepts a compatible hello and returns the capability intersection', () => { + const result: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ capabilities: ['color', 'watch', 'unknown-cap'] }), + { supportedProtocolVersion: { major: 1, minor: 0 }, supportedCapabilities: ['color', 'watch'] } + ); + expect(result.accepted).toBe(true); + expect(result.ack.kind).toBe('helloAck'); + expect(result.ack.acceptedCapabilities).toEqual(['color', 'watch']); + expect(result.ack.rejectedRequiredFeatures).toEqual([]); + expect(result.diagnostic).toBeUndefined(); + }); + + it('accepts across an additive minor difference', () => { + const result: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ protocolVersion: { major: 1, minor: 7 } }), + { supportedProtocolVersion: { major: 1, minor: 0 } } + ); + expect(result.accepted).toBe(true); + expect(result.diagnostic).toBeUndefined(); + }); + + it('rejects an unknown required feature with an update-global-Rush diagnostic', () => { + const result: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ requiredFeatures: ['streaming-artifacts'] }), + { supportedProtocolVersion: { major: 1, minor: 0 }, supportedCapabilities: ['color'] } + ); + expect(result.accepted).toBe(false); + expect(result.ack.rejectedRequiredFeatures).toEqual(['streaming-artifacts']); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_UPDATE_REQUIRED'); + expect(result.diagnostic?.category).toBe('environment'); + }); + + it('rejects an unsupported major with an update-global-Rush diagnostic', () => { + const result: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ protocolVersion: { major: 2, minor: 0 } }), + { supportedProtocolVersion: { major: 1, minor: 0 } } + ); + expect(result.accepted).toBe(false); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_UPDATE_REQUIRED'); + expect(result.diagnostic?.parameters?.producerProtocolMajor.value).toBe(2); + }); + + it('accepts a required feature that the consumer supports', () => { + const result: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ requiredFeatures: ['ordered-sequence'] }), + { + supportedProtocolVersion: { major: 1, minor: 0 }, + supportedCapabilities: ['ordered-sequence'] + } + ); + expect(result.accepted).toBe(true); + expect(result.ack.rejectedRequiredFeatures).toEqual([]); + }); +}); diff --git a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap new file mode 100644 index 00000000000..e796eb1268f --- /dev/null +++ b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`compatibility goldens advertises the current protocol version as the negotiation baseline 1`] = ` +Object { + "major": 1, + "minor": 0, +} +`; + +exports[`compatibility goldens freezes negotiation outcomes across supported majors and minors 1`] = ` +Array [ + Object { + "accepted": true, + "acceptedCapabilities": Array [], + "diagnosticCode": undefined, + "name": "same major and minor", + "rejectedRequiredFeatures": Array [], + }, + Object { + "accepted": true, + "acceptedCapabilities": Array [ + "color", + ], + "diagnosticCode": undefined, + "name": "same major, newer additive minor", + "rejectedRequiredFeatures": Array [], + }, + Object { + "accepted": true, + "acceptedCapabilities": Array [ + "color", + ], + "diagnosticCode": undefined, + "name": "unknown optional capability is ignored", + "rejectedRequiredFeatures": Array [], + }, + Object { + "accepted": false, + "acceptedCapabilities": Array [], + "diagnosticCode": "RUSH_PROTOCOL_UPDATE_REQUIRED", + "name": "unknown required feature is rejected", + "rejectedRequiredFeatures": Array [ + "future-required", + ], + }, + Object { + "accepted": false, + "acceptedCapabilities": Array [], + "diagnosticCode": "RUSH_PROTOCOL_UPDATE_REQUIRED", + "name": "unsupported newer major is rejected", + "rejectedRequiredFeatures": Array [], + }, +] +`; + +exports[`legacy output snapshots freezes the legacy failure output 1`] = ` +"Starting \\"rush build\\" + +==[ @my-company/project-a (build) ]================================[ 1 of 2 ]== +Building project-a... +Error: Command failed with exit code 1 + +==[ @my-company/project-b (build) ]================================[ 2 of 2 ]== +\\"@my-company/project-b\\" is blocked by a failed dependency. + +==[ FAILURE: 1 operation ]===================================================== + +The following projects failed to build: + @my-company/project-a (build) X.XX seconds + +rush build (X.XX seconds) ==> ERROR: Project(s) failed to build" +`; + +exports[`legacy output snapshots freezes the legacy success output 1`] = ` +"Starting \\"rush build\\" + +Executing a maximum of 4 simultaneous processes... + +==[ @my-company/project-a (build) ]================================[ 1 of 2 ]== +Building project-a... +project-a done. + +==[ @my-company/project-b (build) ]================================[ 2 of 2 ]== +Building project-b... +project-b done. + + +==[ SUCCESS: 2 operations ]==================================================== + +These operations completed successfully: + @my-company/project-a (build) X.XX seconds + @my-company/project-b (build) X.XX seconds + +rush build (X.XX seconds)" +`; + +exports[`protocol goldens freezes the diagnostic schema 1`] = ` +Object { + "category": "dependency-tool", + "causeDiagnosticIds": Array [ + "diag_0000", + ], + "code": "RUSH_DEPENDENCY_TOOL_FAILED", + "detailKey": "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail", + "diagnosticId": "diag_0001", + "parameters": Object { + "command": Object { + "privacy": "public", + "value": "pnpm install", + }, + "exitCode": Object { + "privacy": "public", + "value": 1, + }, + "logPath": Object { + "privacy": "local-sensitive", + "value": "/repo/common/temp/install.log", + }, + }, + "relatedArtifactIds": Array [ + "artifact_install_log", + ], + "remediation": Array [ + Object { + "automatedExecutionSafety": "requires-confirmation", + "command": "rush update --purge", + "descriptionKey": "remediation.reinstall", + "documentationUrl": "https://rushjs.io/pages/commands/rush_update/", + }, + ], + "retryable": true, + "severity": "error", + "source": Object { + "file": "common/config/rush/pnpm-config.json", + "toolName": "pnpm", + }, + "summaryKey": "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary", +} +`; + +exports[`protocol goldens freezes the event envelope schema 1`] = ` +Object { + "eventId": "evt_0001", + "parentOperationId": "op_17", + "parentSessionId": "sess_parent", + "payload": Object { + "argv": Array [ + "--to", + "@my-company/project-a", + ], + "commandName": "build", + }, + "privacy": "public", + "protocolVersion": Object { + "major": 1, + "minor": 0, + }, + "required": true, + "scope": Object { + "commandName": "build", + "operationId": "op_17", + "phaseName": "_phase:build", + "projectName": "@my-company/project-a", + }, + "sequence": 5, + "sessionId": "sess_root", + "source": Object { + "component": "PhasedScriptAction", + "packageName": "@microsoft/rush-lib", + "packageVersion": "5.177.2", + }, + "sourceSequence": 2, + "timestamp": "2026-07-12T00:00:00.000Z", + "type": "commandStarted", +} +`; + +exports[`protocol goldens freezes the handshake hello and helloAck schemas: hello 1`] = ` +Object { + "capabilities": Array [ + "color", + "watch", + ], + "kind": "hello", + "producerVersion": "@rushstack/heft 1.2.19", + "protocolVersion": Object { + "major": 1, + "minor": 0, + }, + "requiredFeatures": Array [ + "ordered-sequence", + ], +} +`; + +exports[`protocol goldens freezes the handshake hello and helloAck schemas: helloAck 1`] = ` +Object { + "acceptedCapabilities": Array [ + "color", + "watch", + ], + "kind": "helloAck", + "protocolVersion": Object { + "major": 1, + "minor": 0, + }, + "rejectedRequiredFeatures": Array [], +} +`; diff --git a/libraries/reporter/src/test/__snapshots__/IReporterEventEnvelope.test.ts.snap b/libraries/reporter/src/test/__snapshots__/IReporterEventEnvelope.test.ts.snap new file mode 100644 index 00000000000..c25c32daf63 --- /dev/null +++ b/libraries/reporter/src/test/__snapshots__/IReporterEventEnvelope.test.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`IReporterEventEnvelope matches the golden envelope shape 1`] = ` +Object { + "eventId": "evt_0001", + "payload": Object { + "argv": Array [ + "--to", + "@rushstack/reporter", + ], + "commandName": "build", + }, + "privacy": "public", + "protocolVersion": Object { + "major": 1, + "minor": 0, + }, + "required": true, + "scope": Object { + "commandName": "build", + }, + "sequence": 1, + "sessionId": "sess_root", + "source": Object { + "packageName": "@microsoft/rush-lib", + "packageVersion": "5.177.2", + }, + "timestamp": "2026-07-12T00:00:00.000Z", + "type": "commandStarted", +} +`; diff --git a/libraries/reporter/tsconfig.json b/libraries/reporter/tsconfig.json new file mode 100644 index 00000000000..9a79fa4af11 --- /dev/null +++ b/libraries/reporter/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} diff --git a/research/docs/2026-07-12-rush-operation-reporting.md b/research/docs/2026-07-12-rush-operation-reporting.md new file mode 100644 index 00000000000..aff90229f4c --- /dev/null +++ b/research/docs/2026-07-12-rush-operation-reporting.md @@ -0,0 +1,193 @@ +--- +date: 2026-07-12 14:11:07 UTC +researcher: Sean Larkin +git_commit: 012bae77ba8dd013d8bcd806b55d05c8001e5d92 +branch: main +repository: rushstack +topic: "Current Rush phased operation reporting, errors, and telemetry for GitHub issue #5858" +tags: [research, codebase, rush, operations, errors, telemetry] +status: complete +last_updated: 2026-07-12 +last_updated_by: Sean Larkin +last_updated_note: "Added design review constraints for telemetry and structured errors" +--- + +# Research + +## Research Question + +How do current phased Rush commands expose operation status, render summaries, +propagate errors, set exit codes, publish plugin hooks, and record telemetry? + +## Summary + +Phased reporting is distributed across the operation scheduler and records, +terminal pipelines, fixed completion plugins, `AlreadyReportedError`, +top-level parser handling, and a separate telemetry path. Operations emit +`OperationStatus` transitions through `PhasedCommandHooks`; built-in timeline +and result-summary plugins render after execution; unsuccessful commands throw +an already-rendered sentinel; and `RushCommandLineParser` owns final exit-code +and telemetry-drain behavior. + +## Detailed Findings + +### Session and plugin registration + +[`RushCommandLineParser`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L126-L168) +creates one `ConsoleTerminalProvider`, `RushSession`, and `PluginManager`. +Plugins implement +[`IRushPlugin.apply()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/pluginFramework/IRushPlugin.ts#L7-L12) +and receive the shared session and Rush configuration. + +[`RushSession.getLogger()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/pluginFramework/RushSession.ts#L52-L68) +creates scoped `Logger` instances over the parser's shared terminal provider. +The current +[`ILogger`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/pluginFramework/logging/Logger.ts#L9-L77) +surface consists of a `Terminal`, `emitError()`, and `emitWarning()`. + +### Phased hook lifecycle + +Each `PhasedScriptAction` owns +[`PhasedCommandHooks`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts#L146-L215). +The current hooks cover: + +- operation graph creation; +- before-all, before-operation, and after-operation execution; +- operation status changes; +- operation environment creation; +- after-all execution; +- watch shutdown and waiting notifications; +- telemetry mutation through `beforeLog`. + +The action wires these hooks into the execution manager at +[`PhasedScriptAction.ts:595-616`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L595-L616) +and invokes `afterExecuteOperations` at +[`PhasedScriptAction.ts:970-1000`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L970-L1000). + +### Operation status events + +[`OperationExecutionRecord`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts#L145-L230) +initializes operations as `Waiting` or `Ready`. Later assignments pass through +the status setter, which invokes the configured status-change callback. + +The scheduler and record produce transitions including `Ready`, `Queued`, +`Executing`, `Success`, `SuccessWithWarning`, `Failure`, `Blocked`, `Aborted`, +`Skipped`, `FromCache`, and `NoOp`. The aggregate precedence is failure, +aborted, warning-success, then success +([`OperationExecutionManager.ts:241-335`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts#L241-L335)). + +### Built-in output plugins + +The action conditionally registers +[`ConsoleTimelinePlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts#L49-L68) +for `--timeline`, always registers +[`OperationResultSummarizerPlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts#L31-L46), +and conditionally registers +[`DebugHashesPlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/DebugHashesPlugin.ts#L12-L38). +The registration order is defined in +[`PhasedScriptAction.ts:404-466`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L404-L466). + +The standard summarizer groups operations by terminal status, prints condensed +success/cache/skip sections, prints detailed warning/failure sections from each +record's bounded stdio summary, and ends with the aggregate result +([`OperationResultSummarizerPlugin.ts:52-282`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts#L52-L282)). + +### Full project logs + +[`ProjectLogWritable`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts#L93-L241) +writes: + +- a merged color-stripped text log; +- a stderr-only text log; +- an optional JSONL chunk log retaining stream identity and ANSI text. + +Log paths are generated per project and phase at +[`ProjectLogWritable.ts:250-307`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts#L250-L307). +The bounded console failure summary and full project files are separate +artifacts. + +### Error propagation + +[`AlreadyReportedError`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/node-core-library/src/AlreadyReportedError.ts#L8-L55) +is a control-flow sentinel indicating that user-facing error output was +already emitted. + +At operation level, +[`OperationExecutionManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts#L338-L359) +suppresses the stored message for this sentinel and renders other errors into +the operation terminal and summary. + +At command level, +[`PhasedScriptAction`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L1010-L1027) +distinguishes the sentinel from unexpected errors when rendering completion. +Every unsuccessful non-watch phased execution later throws a fresh sentinel +([`PhasedScriptAction.ts:1160-1169`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L1160-L1169)). + +At parser level, +[`_reportErrorAndSetExitCode()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L513-L555) +suppresses generic error text for the sentinel, prints other errors, includes +stacks in debug mode, flushes telemetry, and exits nonzero. The parser +preassigns `process.exitCode = 1` and changes it to `0` only after successful +completion +([`RushCommandLineParser.ts:249-300`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L249-L300)). + +### Telemetry + +[`Telemetry`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/Telemetry.ts#L146-L270) +is a separate path from terminal output. Phased execution builds command and +operation result records, invokes `beforeLog`, and calls `Telemetry.log()` +([`PhasedScriptAction.ts:1047-1164`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L1047-L1164)). + +Flush writes local telemetry files and invokes the session's parallel +`flushTelemetry` hook. `ensureFlushedAsync()` waits for outstanding plugin +flush promises before normal or error exit. + +## Code References + +- `libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts:146-215` - Phased event hooks. +- `libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts:145-433` - Status and terminal lifecycle. +- `libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts:241-476` - Aggregate execution and status output. +- `libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts:31-282` - Fixed result rendering. +- `libraries/node-core-library/src/AlreadyReportedError.ts:8-55` - Already-rendered error sentinel. +- `libraries/rush-lib/src/logic/Telemetry.ts:146-270` - Telemetry storage and flush. + +## Architecture Documentation + +Current operation events are exposed through phased command hooks, while +presentation is performed by fixed plugins and manager/record methods. +Telemetry consumes a separately assembled representation after command +execution rather than the operation status stream itself. + +## Historical Context + +Historical plugin research documented `RushSession`, `ILogger`, phased hooks, +and telemetry extension points without a separate reporter abstraction: +[`research/docs/2026-02-07-rush-plugin-architecture.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-rush-plugin-architecture.md). + +Historical command-registration research documented that plugin command +configuration was discovered before executable plugin loading: +[`research/docs/2026-02-07-plugin-command-registration.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-plugin-command-registration.md). + +## Related Research + +- [Rush output pipeline](./2026-07-12-rush-output-pipeline.md) +- [Rush reporting patterns](./2026-07-12-rush-reporting-patterns.md) +- [Issue #5858 synthesis](../tickets/2026-07-12-5858-rush-reporter-overhaul.md) + +## Open Questions + +- **Telemetry — partially answered:** Telemetry may consume the + reporter/underlying event stream or a separate event stream, but it must not + be limited to presentation-visible data. The meeting did not select an + integration. +- **Structured errors — partially answered:** Rush-owned producers emit + structured events with available concise and detailed context; reporters + choose presentation. External tool output remains stream-based and may be + interpreted through problem matchers. No event/error schema, stable codes, + categories, or remediation contract was selected. +- **`AlreadyReportedError` — unresolved:** The meeting identified the current + log-then-throw pattern but did not decide whether it remains. + +See +[the design review note](../notes/2026-07-12-rush-reporter-design-review.md) +for timestamped meeting evidence. diff --git a/research/docs/2026-07-12-rush-output-pipeline.md b/research/docs/2026-07-12-rush-output-pipeline.md new file mode 100644 index 00000000000..1ba08bc0f16 --- /dev/null +++ b/research/docs/2026-07-12-rush-output-pipeline.md @@ -0,0 +1,195 @@ +--- +date: 2026-07-12 14:11:07 UTC +researcher: Sean Larkin +git_commit: 012bae77ba8dd013d8bcd806b55d05c8001e5d92 +branch: main +repository: rushstack +topic: "Current Rush startup and terminal output pipeline for GitHub issue #5858" +tags: [research, codebase, rush, terminal, stream-collator] +status: complete +last_updated: 2026-07-12 +last_updated_by: Sean Larkin +last_updated_note: "Added design review constraints for bootstrap, compatibility, and CI output" +--- + +# Research + +## Research Question + +How does a current Rush invocation produce and route terminal output from the +`install-run-rush` shim through the selected `rush-lib`, command-line parsing, +phased execution, and per-project streams? + +## Summary + +A managed Rush invocation can cross three independently reporting layers: + +1. `install-run-rush` locates and installs the requested `@microsoft/rush` + package, then launches its binary with inherited stdio. +2. The `@microsoft/rush` frontend selects the `rush-lib` version requested by + `rush.json`. +3. The selected `rush-lib` parses the command and, for phased commands, routes + project output through terminal transforms, project logs, and + `StreamCollator`. + +Output is produced through both direct `console.*` calls and +`@rushstack/terminal`. Global `--quiet`, phased `--verbose`, and global +`--debug` control different parts of this flow. Current Rush has no global +`--reporter`, `--log-level`, `COPILOT_CLI`, or `RUSH_REPORTER` handling. + +## Detailed Findings + +### Bootstrap and version installation + +The source for the checked-in bootstrap script is +[`install-run-rush.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/scripts/install-run-rush.ts#L23-L125). +It derives `rush`, `rushx`, or `rush-pnpm` from the wrapper filename, reads the +requested Rush version, and delegates package installation and process launch +to +[`install-run.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/scripts/install-run.ts#L439-L527). + +Bootstrap quiet mode is enabled by `RUSH_QUIET_MODE=1`, `true`, `-q`, or +`--quiet`. It suppresses informational logger calls, but not usage text, npm +output inherited by the install process, invoked Rush output, or errors +([`install-run-rush.ts:77-123`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/scripts/install-run-rush.ts#L77-L123)). + +### Frontend and selected `rush-lib` + +[`apps/rush/src/start.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/start.ts#L36-L105) +loads the minimal Rush configuration, handles preview-version output, and +chooses the bundled or requested engine. A different requested version is +installed and loaded by +[`RushVersionSelector`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/RushVersionSelector.ts#L26-L102). + +[`RushCommandSelector.execute()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/RushCommandSelector.ts#L28-L64) +dispatches to `Rush.launch()`, `Rush.launchRushX()`, or +`Rush.launchRushPnpm()` on the selected library. From that point, command +behavior belongs to the selected `rush-lib`, which may differ from the +frontend's bundled version. + +### Parser startup and banner suppression + +[`Rush.launch()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/api/Rush.ts#L79-L100) +prints the startup banner and constructs `RushCommandLineParser`. +[`RushStartupBanner`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushStartupBanner.ts#L9-L44) +uses direct stdout output. + +[`shouldRestrictConsoleOutput()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L209-L231) +suppresses startup information for tab completion, `-q`, `--quiet`, any +`--json`, or `RUSH_QUIET_MODE`. It gates the banner, configuration-discovery +messages, and the base action's `Starting "rush "` line. It does not +silence action output or phased-operation output. + +The parser currently defines global `--debug` and `--quiet` +([`RushCommandLineParser.ts:99-124`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L99-L124)). +Phased `--verbose` is defined separately +([`PhasedScriptAction.ts:257-261`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L257-L261)). + +### Distinct output controls + +- Global `--quiet` suppresses startup information. +- Phased `--verbose` controls whether project stdout is shown live. +- Global `--debug` enables verbose/debug terminal channels and stack output. +- `--json` is action-specific for commands such as `list`, `scan`, and + `check`; the parser recognizes its presence only to suppress ancillary + startup output. + +For phased execution, `isQuietMode` is computed as the inverse of +`--verbose` +([`PhasedScriptAction.ts:468-471`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L468-L471)). +This phased quiet mode is independent of the global `--quiet` flag. + +### Terminal severity and stream routing + +[`Terminal`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/Terminal.ts#L14-L187) +broadcasts writes to registered providers and strips ANSI codes for providers +without color support. +[`ConsoleTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/ConsoleTerminalProvider.ts#L35-L89) +routes log, verbose, and debug output to stdout and warning/error output to +stderr. + +Terminal chunks preserve only stream identity and text: +[`ITerminalChunk`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/ITerminalChunk.ts#L8-L50) +uses `Stdout` (`"O"`) and `Stderr` (`"E"`). + +### Per-operation output graph + +[`OperationExecutionRecord.runWithTerminalAsync()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts#L293-L385) +constructs this graph: + +```text +normalized operation output + ├─ quiet filter or collated console writer + └─ stderr line classifier + ├─ project logs + ├─ bounded stdio summary + └─ color removal → problem collector +``` + +Without phased `--verbose`, +[`DiscardStdoutTransform`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/DiscardStdoutTransform.ts#L60-L101) +removes live stdout while preserving stderr. Project logs, failure summaries, +and problem collection still receive both channels. + +### Stream collation + +[`StreamCollator`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/stream-collator/src/StreamCollator.ts#L39-L198) +keeps one writer active and buffers inactive writers so parallel projects do +not interleave. Its activation callback is used by +[`OperationExecutionManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts#L131-L235) +to print operation headers and `N of total` progress. + +### TTY and color + +Color support is delegated to `supports-color` by +[`ConsoleTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/ConsoleTerminalProvider.ts#L35-L51). +The normal phased output path does not select a different layout based on TTY. +TTY checks are used for specific behaviors such as watch-mode keyboard input +([`PhasedScriptAction.ts:865-868`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L865-L868)) +and pnpm reporter selection +([`WorkspaceInstallManager.ts:863-880`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts#L863-L880)). + +## Code References + +- `libraries/rush-lib/src/scripts/install-run-rush.ts:23-125` - Bootstrap selection and quiet behavior. +- `apps/rush/src/start.ts:36-105` - Frontend version selection. +- `libraries/rush-lib/src/cli/RushCommandLineParser.ts:99-246` - Global parameters and startup restriction. +- `libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts:218-471` - Phased output parameters. +- `libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts:293-385` - Per-operation output graph. +- `libraries/stream-collator/src/StreamCollator.ts:39-198` - Parallel stream buffering. + +## Architecture Documentation + +The current architecture separates bootstrap, frontend version selection, and +selected-engine reporting. Within phased execution, terminal providers express +severity, terminal chunks express stdout/stderr identity, transforms fan out +and normalize output, and `StreamCollator` serializes parallel project output. + +## Historical Context + +Historical research described `@microsoft/rush` as the CLI frontend, +`@microsoft/rush-lib` as the implementation/API package, and +`@rushstack/terminal` as the output abstraction: +[`research/docs/2026-02-07-rushstack-architecture-and-build-systems.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-rushstack-architecture-and-build-systems.md). + +## Related Research + +- [Rush operation reporting](./2026-07-12-rush-operation-reporting.md) +- [Rush reporting patterns](./2026-07-12-rush-reporting-patterns.md) +- [Issue #5858 synthesis](../tickets/2026-07-12-5858-rush-reporter-overhaul.md) + +## Open Questions + +- **Cross-version events — partially answered:** Reporter initialization begins + in the global version-selector or `install-run-rush` layer. An unknown event + from a newer repository engine may produce a diagnostic asking the user to + update global Rush. The event protocol and version negotiation remain + undefined. +- **CI selection — partially answered:** A StreamCollator-like detailed + reporter remains available for CI/log browsing, and CI-specific formats are + valid reporter implementations. Automatic CI detection and reporter + selection rules remain undefined. + +See +[the design review note](../notes/2026-07-12-rush-reporter-design-review.md) +for timestamped meeting evidence. diff --git a/research/docs/2026-07-12-rush-reporting-patterns.md b/research/docs/2026-07-12-rush-reporting-patterns.md new file mode 100644 index 00000000000..55f5ec24839 --- /dev/null +++ b/research/docs/2026-07-12-rush-reporting-patterns.md @@ -0,0 +1,179 @@ +--- +date: 2026-07-12 14:11:07 UTC +researcher: Sean Larkin +git_commit: 012bae77ba8dd013d8bcd806b55d05c8001e5d92 +branch: main +repository: rushstack +topic: "Existing Rush Stack reporting, serialization, Heft, and localization patterns for GitHub issue #5858" +tags: [research, codebase, rush, heft, reporters, localization] +status: complete +last_updated: 2026-07-12 +last_updated_by: Sean Larkin +last_updated_note: "Added design review scope for localization and output baselines" +--- + +# Research + +## Research Question + +Which existing Rush Stack abstractions and implementations already express +provider selection, output fan-out, reporter injection, machine-readable +output, localization, progress, and output test contracts? + +## Summary + +The repository contains several reporter-adjacent patterns, but no Rush-wide +reporter contract. `@rushstack/terminal` provides provider strategy and +transform graphs; Rush project logs serialize terminal chunks as JSONL; Heft +aggregates scoped loggers and injects a custom Jest reporter; several commands +emit typed JSON; localization utilities parse external string resources; and +snapshots encode current output behavior. + +## Detailed Findings + +### Terminal provider strategy and fan-out + +[`ITerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/ITerminalProvider.ts#L22-L59) +is a strategy interface with color/newline capabilities and a severity-aware +`write()` method. `Terminal` broadcasts each write to all providers +([`Terminal.ts:14-33`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/Terminal.ts#L14-L33)). + +Existing provider adapters include: + +- [`ConsoleTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/ConsoleTerminalProvider.ts#L35-L97) for stdout/stderr; +- [`PrefixProxyTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/PrefixProxyTerminalProvider.ts#L56-L116) for line prefixes; +- [`StringBufferTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/StringBufferTerminalProvider.ts#L85-L153) for capture and tests; +- [`NoOpTerminalProvider`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/NoOpTerminalProvider.ts#L13-L31) for discarded output. + +[`SplitterTransform`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/SplitterTransform.ts#L19-L97) +fans the same immutable terminal chunk out to multiple writable destinations. + +### Machine-preserving logs + +[`JsonLFileWritable`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts#L68-L132) +serializes each `{ kind, text }` terminal chunk as one JSON line. Text and +stderr-only logs are produced in parallel by `SplitLogFileWritable`. + +[`OperationMetadataManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationMetadataManager.ts#L152-L173) +can replay JSONL chunks with original stdout/stderr classification and falls +back to a merged text log when chunk metadata is absent. + +### Heft scoped logging + +[`LoggingManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/heft/src/pluginFramework/logging/LoggingManager.ts#L16-L103) +owns Heft's scoped loggers, tracks warnings/errors, resets state between runs, +and aggregates messages. Each +[`ScopedLogger`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/heft/src/pluginFramework/logging/ScopedLogger.ts#L59-L135) +uses a prefixing provider and stores emitted errors. + +[`HeftActionRunner.runWithLoggingAsync()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/heft/src/cli/HeftActionRunner.ts#L121-L197) +prints a final result and repeats aggregated warning/error summaries. + +### Reporter injection in the Heft Jest plugin + +[`HeftJestReporter`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-jest-plugin/src/HeftJestReporter.ts#L37-L248) +implements Jest's reporter interface and renders suite, test, console, failure, +snapshot, and aggregate results through a Heft scoped logger. + +[`JestPlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-jest-plugin/src/JestPlugin.ts#L827-L891) +replaces Jest's `"default"` reporter entry with `HeftJestReporter`, installs it +when no reporter is configured, and preserves configured third-party +reporters. `--debug-heft-reporter` leaves Jest's reporter behavior active. + +### Other message interception + +API Extractor exposes a message callback whose mutable message can be marked +handled or assigned another log level +([`ExtractorMessage.ts:157-204`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/api-extractor/src/api/ExtractorMessage.ts#L157-L204)). +The Heft API Extractor plugin maps those messages into Heft logger severities +and marks them handled +([`ApiExtractorRunner.ts:78-139`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-api-extractor-plugin/src/ApiExtractorRunner.ts#L78-L139)). + +### Machine-readable command output + +Rush currently provides command-specific JSON: + +- [`rush list --json`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/actions/ListAction.ts#L128-L188); +- [`rush scan --json`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/actions/ScanAction.ts#L204-L212); +- [`rush check --json`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/actions/CheckAction.ts#L31-L82). + +Rush also consumes machine formats: + +- Git porcelain v2 through + [`Git.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/Git.ts#L433-L444); +- pnpm NDJSON reporting through + [`BaseInstallManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/base/BaseInstallManager.ts#L895-L901); +- SARIF output in the Heft lint plugin through + [`SarifFormatter`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-lint-plugin/src/SarifFormatter.ts#L11-L230). + +### Localization infrastructure + +[`@rushstack/localization-utilities`](https://github.com/microsoft/rushstack/tree/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/localization-utilities) +models localized entries as `{ value, comment? }` and parses `.resx`, +`.loc.json`, `.resx.json`, and `.resjson`. +[`parseLocFile()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/localization-utilities/src/LocFileParser.ts#L12-L86) +selects and caches parsers, while +[`parseLocJson()`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/localization-utilities/src/parsers/parseLocJson.ts#L9-L33) +validates and normalizes entries. + +### Progress and output contracts + +Current Rush progress is the operation activation header produced by +[`OperationExecutionManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts#L203-L233). +Watch mode separately rewrites persistent status lines through Node's +`readline` cursor APIs +([`ProjectWatcher.ts:432-447`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/ProjectWatcher.ts#L432-L447)). + +Output behavior is encoded in snapshots and focused tests for: + +- Rush operation execution and cobuild output; +- terminal severity, color, and chunks; +- splitter fan-out; +- bounded stdio summaries; +- operation metadata replay; +- Heft operation-graph output. + +## Code References + +- `libraries/terminal/src/ITerminalProvider.ts:22-59` - Provider strategy. +- `libraries/terminal/src/SplitterTransform.ts:19-97` - Output fan-out. +- `libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts:68-241` - Text and JSONL project logs. +- `apps/heft/src/pluginFramework/logging/LoggingManager.ts:16-103` - Heft logger aggregation. +- `heft-plugins/heft-jest-plugin/src/JestPlugin.ts:827-891` - Reporter replacement/injection. +- `libraries/localization-utilities/src/LocFileParser.ts:12-86` - String resource parsing. + +## Architecture Documentation + +Existing patterns separate message creation from transport in several local +contexts: terminal providers, terminal transforms, API Extractor callbacks, +Heft scoped loggers, and Jest reporter injection. These contracts are scoped +to their packages and are not currently unified as a Rush session reporter. + +## Historical Context + +A historical draft for the interactive upgrade UI documented proposed TTY and +CI rendering tiers and cited GitHub Copilot CLI as an Ink adopter, but it did +not define a Copilot or reporter integration: +[`research/specs/2026-01-23-interactive-upgrade-ui-rewrite.md`](https://github.com/microsoft/rushstack/blob/daada7cfa94ab0b3eaeca355706ba95f876fceb4/research/specs/2026-01-23-interactive-upgrade-ui-rewrite.md). + +Historical implementation research later recorded that the interactive command +still used Inquirer and had no CI-specific fallback: +[`research/docs/2026-02-07-upgrade-interactive-implementation.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-upgrade-interactive-implementation.md). + +## Related Research + +- [Rush output pipeline](./2026-07-12-rush-output-pipeline.md) +- [Rush operation reporting](./2026-07-12-rush-operation-reporting.md) +- [Issue #5858 synthesis](../tickets/2026-07-12-5858-rush-reporter-overhaul.md) + +## Open Questions + +- **Localization — partially answered:** Translated locales are outside this + work. The reporter architecture may support later localization, but the + meeting did not define string externalization. +- **Output snapshots — unresolved:** The meeting did not discuss testing or + snapshot migration for the current fixed renderers. + +See +[the design review note](../notes/2026-07-12-rush-reporter-design-review.md) +for timestamped meeting evidence. diff --git a/research/feature-list.json b/research/feature-list.json new file mode 100644 index 00000000000..25fb22397d0 --- /dev/null +++ b/research/feature-list.json @@ -0,0 +1,338 @@ +[ + { + "category": "functional", + "description": "Scaffold @rushstack/reporter as a public beta package with no dependency on rush-lib", + "steps": [ + "Create libraries/reporter as the @rushstack/reporter package", + "Configure API Extractor to mark exported contracts public beta", + "Ensure package.json declares no dependency on rush-lib", + "Register the package in rush.json projects", + "Verify rush build --to @rushstack/reporter succeeds" + ], + "passes": true + }, + { + "category": "functional", + "description": "Define the canonical event envelope and closed core event type union", + "steps": [ + "Add IReporterProtocolVersion, ReporterPrivacyClassification, and IReporterEventEnvelope", + "Define the closed ReporterEventType core union from sessionStarted through extension", + "Keep events immutable and JSON-serializable and never serialize Error instances", + "Document sequence as authoritative ordering and timestamp as informational", + "Preserve producer local sequence as sourceSequence for child sessions", + "Add a golden envelope schema test" + ], + "passes": true + }, + { + "category": "functional", + "description": "Define the producer API exposing scoped emit without reporter or destination access", + "steps": [ + "Add IReporterEventSink.emit that assigns eventId, sequence, and timestamp", + "Add IScopedReporter with emitMessage, emitDiagnostic, and emitExtension", + "Keep core lifecycle event creation controlled by Rush", + "Ensure reporter instances, destinations, and thresholds are not exposed to producers", + "Add namespaced beta identifiers for extension events" + ], + "passes": true + }, + { + "category": "functional", + "description": "Define structured diagnostics with a central permanent code registry and field privacy", + "steps": [ + "Add RushDiagnosticCategory, IRushDiagnostic, remediation, and source types", + "Add a central never-reused RUSH__ code registry", + "Add English templates keyed by code and resource key", + "Classify fields as public, local-sensitive, or secret with the envelope as the floor", + "Return a diagnostic ID from emission and reference it from propagated failures", + "Add a code registry validation test" + ], + "passes": true + }, + { + "category": "functional", + "description": "Implement the NDJSON wire protocol with handshake, versioning, and size limits", + "steps": [ + "Add NDJSON encoder and decoder", + "Add hello and helloAck handshake with protocol version and capabilities", + "Make minor versions additive and default events and capabilities to optional", + "Emit an update-global-Rush diagnostic for unknown required features or unsupported majors", + "Enforce 1 MiB bootstrap buffer, 1 MiB NDJSON record, and 64 KiB external chunk limits", + "Add negotiation and limit tests" + ], + "passes": true + }, + { + "category": "functional", + "description": "Implement ReporterManager lifecycle, ordering, and fan-out", + "steps": [ + "Add the IReporter interface with initialize, report, flush, and close", + "Assign one monotonic session sequence and one ordered async queue per reporter", + "Enforce exclusive destination ownership and allow shared output only via a multiplexer", + "Flush on normal and error completion with a 10 second timeout and signal flush with 2 seconds", + "Coalesce replaceable UI status events under pressure", + "Never drop lifecycle, diagnostic, result, artifact, or external-output events" + ], + "passes": true + }, + { + "category": "test", + "description": "Freeze legacy output snapshots and add protocol and compatibility goldens", + "steps": [ + "Capture current legacy output as frozen snapshots", + "Add protocol golden tests for envelopes and diagnostics", + "Add compatibility goldens for supported majors and minors", + "Wire the snapshots into the existing test runner" + ], + "passes": true + }, + { + "category": "functional", + "description": "Add two-stage bootstrap in install-run-rush that buffers startup events to a handoff file", + "steps": [ + "Parse early reporter controls in the install-run-rush prelude", + "Embed only a minimal frozen envelope encoder and protocol-major constant with no runtime import", + "Encode up to 1 MiB of bootstrap events and preserve inherited npm output as raw external output", + "Write the bounded events to a temporary NDJSON handoff file", + "Pass the handoff path to the frontend via a private environment variable", + "Preserve required and diagnostic events on overflow and emit a bufferTruncated event" + ], + "passes": false + }, + { + "category": "functional", + "description": "Create the frontend ReporterManager host and replay the bootstrap handoff", + "steps": [ + "Create ReporterManager in apps/rush before version selection", + "Replay the bootstrap handoff file then delete it", + "Pass a typed sink to the selected rush-lib without giving it selection ownership", + "Skip the handoff file for direct rush invocations", + "Clean abandoned handoff files using the OS-temp fallback retention policy" + ], + "passes": false + }, + { + "category": "functional", + "description": "Add cross-version compatibility adapters while keeping legacy rendering visible", + "steps": [ + "Support a new frontend with an old engine through an adapter", + "Support an old frontend with a new engine through fallback", + "Keep legacy rendering as the sole visible output in this phase", + "Add cross-version compatibility tests for both directions" + ], + "passes": false + }, + { + "category": "functional", + "description": "Expose the RushSession event sink and scoped producers to actions and plugins", + "steps": [ + "Expose scoped reporter and logger creation from RushSession", + "Pass the sink to actions through execution context", + "Prevent plugins from inspecting modes, destinations, or thresholds", + "Declare the supported Rush plugin API version in plugin manifests" + ], + "passes": false + }, + { + "category": "functional", + "description": "Emit shadow structured lifecycle and diagnostic events without changing output", + "steps": [ + "Emit first-party lifecycle events from actions, scheduler, and plugins", + "Emit structured diagnostics alongside existing rendering", + "Keep legacy output unchanged while events flow", + "Validate telemetry and exit-code parity against the frozen baselines" + ], + "passes": false + }, + { + "category": "functional", + "description": "Add the telemetry projection subscriber with an allowlist and beforeLog adapter", + "steps": [ + "Subscribe telemetry to canonical events before reporter filtering", + "Produce an allowlisted aggregate at command completion", + "Exclude messages, paths, raw streams, stacks, remediation parameters, and secret values", + "Preserve the existing beforeLog hook through an adapter", + "Add allowlist schema and leakage tests" + ], + "passes": false + }, + { + "category": "functional", + "description": "Preserve command success and exit-code semantics independent of reporters", + "steps": [ + "Return 0 for success including warning-only success", + "Return 1 for Rush or operation failure and for logical cancellation", + "Return the conventional signal-derived status for OS signal termination", + "Ensure reporters and diagnostic categories never select exit codes", + "Preserve command-specific JSON schemas" + ], + "passes": false + }, + { + "category": "functional", + "description": "Add reporter selection and configuration controls with precedence", + "steps": [ + "Add --reporter, --log-level, and --output CLI controls", + "Add RUSH_REPORTER, RUSH_LOG_LEVEL, COPILOT_CLI, and configured agent environment controls", + "Implement precedence from explicit CLI down to generic non-TTY plaintext", + "Map --quiet, --verbose, and --debug as permanent aliases and reject contradictory controls", + "Keep command-specific --json unchanged and not an alias for --reporter=json", + "Fail explicit unsupported reporter requests" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement independent per-reporter log levels", + "steps": [ + "Implement quiet, normal, verbose, and debug filtering per reporter", + "Keep diagnostic severity separate from reporter log level", + "Default the full-detail file reporter to debug", + "Add log-level filtering tests" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement automatic reporter selection matrix by environment", + "steps": [ + "Select ai plus file when an agent is detected", + "Select detailed plaintext plus file for recognized CI", + "Select default plus file for interactive TTY and concise plaintext plus file otherwise", + "Give machine reporters exclusive stdout and route human progress and emergencies to stderr or files", + "Record the selection reason in the detailed log" + ], + "passes": false + }, + { + "category": "ui", + "description": "Implement the default interactive reporter with a three-row live region", + "steps": [ + "Render aggregate phase progress and spinner, width-aware active projects with +N more, and latest activity", + "Refresh at no more than 10 Hz and react to terminal resize", + "Restore cursor state on success, failure, cancellation, and signal", + "Leave at most three stable lines on success and append a bounded diagnostic block and log path on failure", + "Keep the live region in watch mode and append one summary per completed cycle", + "Follow TTY color capability while honoring NO_COLOR and FORCE_COLOR" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement the plaintext and non-TTY reporter", + "steps": [ + "Make plaintext append-only with no cursor movement and color off by default", + "Emit start, meaningful state changes, diagnostics, and final result", + "Emit a compact heartbeat every 30 seconds for long non-TTY and CI sessions", + "Retain StreamCollator-like operation grouping in detailed CI mode", + "Add stable plaintext snapshot tests" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement the JSON and AI reporters", + "steps": [ + "Emit the complete versioned NDJSON event stream from the JSON reporter on exclusive stdout", + "Emit a bounded AI projection with result, scope, error codes, remediation, counts, log path, and artifact completeness", + "Cap AI output at 64 KiB and 20 detailed diagnostics and exclude raw logs and stacks by default", + "Keep absolute log paths as local reporter output and never in telemetry", + "Add stdout-purity tests for JSON and AI" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement the full-detail file reporter with retention and fallback", + "steps": [ + "Write /rush-logs/--.log at debug", + "Maintain latest.log for successful and failed commands per platform capability", + "Buffer events before configuration loads and use an OS-temp fallback", + "Use owner-only permissions and exclude fields classified as secret", + "Delete files older than 14 days, cap at 20 sessions, and let rush purge remove the directory", + "Treat file creation failure as nonfatal with an emergency warning and unavailable artifact" + ], + "passes": false + }, + { + "category": "functional", + "description": "Implement the legacy reporter as a selectable and emergency-fallback mode", + "steps": [ + "Reproduce current Rush output as a selectable legacy reporter", + "Wire RUSH_REPORTER=legacy as an emergency fallback for at least one major", + "Validate the legacy reporter against the frozen output snapshots" + ], + "passes": false + }, + { + "category": "refactor", + "description": "Replace StreamCollator in the primary Rush output path with raw semantic events", + "steps": [ + "Emit operation registration, status transitions, output chunks, completion, and command result from the scheduler", + "Derive current activity in the concise reporter without buffering project output", + "Own grouping and buffering in the detailed plaintext and file reporters", + "Feed the same uncollated stream to problem matchers", + "Remove StreamCollator from the primary path after parity tests pass" + ], + "passes": false + }, + { + "category": "functional", + "description": "Preserve external output losslessly and recover diagnostics through problem matchers", + "steps": [ + "Preserve raw child stdout and stderr as ordered chunks", + "Run tool- and version-scoped matchers on ANSI-normalized text without modifying output or status", + "Emit linked diagnostics without replacing evidence and preserve unmatched text", + "Cap duplicate diagnostics and gate default enablement on high-confidence corpus tests", + "Route older Heft versions through this path" + ], + "passes": false + }, + { + "category": "refactor", + "description": "Prohibit new AlreadyReportedError usage and bridge legacy sentinels", + "steps": [ + "Prohibit new AlreadyReportedError usage once structured diagnostic APIs are available", + "Correlate legacy sentinels with previously emitted diagnostics", + "Suppress duplicate rendering through the bridge", + "Plan bridge removal after zero first-party usages and published migration guidance" + ], + "passes": false + }, + { + "category": "functional", + "description": "Integrate Heft through negotiated child descriptors with raw-stream fallback", + "steps": [ + "Pass a dynamically allocated inherited file descriptor to the child and communicate it via a private env var", + "Correlate child events using child session ID, parent session ID, and parent operation ID", + "Fall back to normal stdout and stderr when descriptor negotiation is unavailable", + "Keep the raw-stream and problem-matcher path for older Heft versions", + "Add old and new Heft descriptor path tests" + ], + "passes": false + }, + { + "category": "performance", + "description": "Meet reporter performance and capacity budgets", + "steps": [ + "Keep representative-build wall-time regression at no more than 3 percent", + "Keep additional peak memory at no more than 32 MiB", + "Use bounded streaming rather than whole-build buffering", + "Preserve lifecycle, diagnostics, results, artifacts, and external output under queue pressure", + "Add benchmark, queue-pressure, and status-coalescing tests" + ], + "passes": false + }, + { + "category": "migration", + "description": "Flip defaults in the Rush daemon-aligned major release", + "steps": [ + "Enable environment-based automatic reporter selection by default", + "Remove ILogger.terminal and RushSession.terminalProvider", + "Fail incompatible plugins with a structured migration diagnostic before apply()", + "Retain the legacy renderer, verbosity aliases, and sentinel bridge", + "Keep every phase independently releasable and revertible" + ], + "passes": false + } +] diff --git a/research/notes/2026-07-12-rush-reporter-design-review.md b/research/notes/2026-07-12-rush-reporter-design-review.md new file mode 100644 index 00000000000..53df9d145ca --- /dev/null +++ b/research/notes/2026-07-12-rush-reporter-design-review.md @@ -0,0 +1,137 @@ +--- +date: 2026-07-12 14:25:21 UTC +researcher: Sean Larkin +git_commit: 012bae77ba8dd013d8bcd806b55d05c8001e5d92 +branch: main +repository: rushstack +topic: "Rush Reporter Overhaul design review" +tags: [research, meeting, rush, reporters, heft, telemetry] +status: complete +last_updated: 2026-07-12 +last_updated_by: Sean Larkin +--- + +# Rush Reporter Overhaul Design Review + +## Source + +Design review transcript supplied for GitHub issue +[#5858](https://github.com/microsoft/rushstack/issues/5858), covering +00:00-35:47. Speaker identities and attributions are intentionally omitted. + +## Decisions + +### Scope + +- The initial implementation targets the main `rush` binary + (00:09:25-00:09:44). +- `rushx` is outside the first implementation + (00:09:25-00:09:35). +- `rush-pnpm` remains a thin shim that preserves pnpm semantics + (00:09:35-00:10:00). +- Translated locales and scheduling/parallelism changes are not part of this + work (00:07:18-00:09:25). + +### Package and session architecture + +- Reporter contracts live in a separate package. `rush-lib` may depend on that + package, but the reporter package does not depend on `rush-lib` + (00:10:36-00:10:52). +- A Rush session produces a presentation-free event stream consumed by one or + more reporters (00:11:25-00:11:43). +- Reporter access becomes part of the Rush plugin/action contract in the same + breaking change as the Rush daemon work (00:11:45-00:12:20). +- Plugins and actions talk to the centralized reporter subsystem and do not + know which concrete reporter is active (00:12:20-00:12:45, + 00:25:31-00:25:55). +- Lazy construction of verbose events is deferred; event writes are expected + to remain inexpensive and be profiled (00:13:03-00:13:26). + +### Reporter configuration + +- Each reporter owns its configured/default log level + (00:14:55-00:15:03). +- Output style is reporter-owned rather than a separate global abstraction. + Individual reporters may expose their own options (00:15:03-00:15:16, + 00:17:37-00:18:09). +- Multiple simultaneous reporters are required. The discussed model is roughly + one reporter per destination/file descriptor, such as terminal, file, JSON, + stderr, stdout, or IPC (00:15:47-00:17:20). +- A full-detail file reporter remains active when console or agent output is + reduced (00:15:47-00:15:56). + +### Initialization and compatibility + +- Reporter initialization starts at the global version-selector or + `install-run-rush` boundary so it controls output emitted before the + repository-selected `rush-lib` loads (00:19:26-00:21:23). +- A lightweight reporter engine communicates with the selected Rush engine + through the event protocol (00:20:54-00:21:23). +- An older global Rush that encounters an unknown event may direct the user to + update the global installation; protocol negotiation was not defined + (00:21:27-00:21:44). + +### Structured output and errors + +- Rush-owned components emit structured events rather than presentation + strings. Producers provide available concise and detailed context, and + reporters decide what to render (00:27:52-00:29:43). +- External tools remain stream-based. Rush can normalize ANSI output and use + problem matchers to recover structured diagnostics such as file, line, and + column information (00:29:15-00:30:15). + +### Heft + +- Heft should gain compatible reporter/event support + (00:30:19-00:32:48). +- Existing Heft `emitError` and `emitWarning` concepts extend toward generic + structured event emission (00:31:43-00:32:48). +- Heft reporter integration is a breaking major-version change + (00:32:49-00:32:57). +- Rush can pass a standard reporter option to Heft and consume a + machine-understandable stream; the protocol was not specified + (00:31:35-00:32:30). + +### Minimum functional behavior + +- The reporter system must replace `StreamCollator` + (00:34:05-00:34:13). +- Default terminal output is constrained to approximately three lines for + status, current activity, and completion/liveness (00:34:14-00:35:04). +- A detailed StreamCollator-like reporter remains available for retained or CI + logs (00:34:28-00:34:42). + +## Open Question Answers + +| Question | Meeting answer | Status | +| --- | --- | --- | +| Does telemetry consume reporter events? | It may consume the reporter/underlying event stream or a separate stream, but it must not be limited to presentation-visible data. | Partially answered | +| How are newer repository events handled by an older global Rush? | Reporter handling starts globally; an unknown event may produce an update-global-Rush diagnostic. Protocol negotiation remains undefined. | Partially answered | +| What is the smallest migration? | Separate reporter package, early initialization, centralized event emission from actions/plugins, replacement of `StreamCollator`, retained detailed output, and compatible Heft support are agreed foundations. Exact ordering and adapters remain undefined. | Partially answered | +| What happens to `AlreadyReportedError`? | The existing log-then-throw pattern was identified, but retention or replacement was not decided. | Unresolved | +| Are category-mapped exit codes used? | Rush exit-code policy was not discussed. | Unresolved | +| Is localization included? | Translations/locales are out of scope; the string externalization mechanism was not specified. | Partially answered | +| Is CI output selected automatically? | CI-tailored reporters and a detailed CI log format are valid, but automatic selection rules were not decided. | Partially answered | +| Is Heft part of the reporter work? | Yes, with compatible structured reporting and a breaking major version; P0 sequencing was not finalized. | Partially answered | + +## Unresolved Details + +- Exact reporter protocol and version negotiation. +- Whether `install-run-rush` directly hosts the reporter engine or delegates to + another bootstrap layer. +- Whether early `apps/rush` output is converted to events or prohibited. +- Exact log-level names, thresholds, and CLI controls. +- Default reporter set and destination configuration syntax. +- Legacy `ITerminal` compatibility for plugins. +- Structured event and error schemas, including codes and remediation. +- Telemetry integration and scope. +- Fate of `AlreadyReportedError`. +- Ordered Rush/Heft migration sequence and P0 boundary. +- Exit-code policy. + +## Related Research + +- [Issue #5858 synthesis](../tickets/2026-07-12-5858-rush-reporter-overhaul.md) +- [Current Rush output pipeline](../docs/2026-07-12-rush-output-pipeline.md) +- [Current operation reporting](../docs/2026-07-12-rush-operation-reporting.md) +- [Existing reporting patterns](../docs/2026-07-12-rush-reporting-patterns.md) diff --git a/research/progress.txt b/research/progress.txt new file mode 100644 index 00000000000..54cfdd76d3d --- /dev/null +++ b/research/progress.txt @@ -0,0 +1,121 @@ +[2026-07-14] Feature 1/28 COMPLETE: Scaffold @rushstack/reporter public beta package + Files: + - libraries/reporter/package.json (name @rushstack/reporter v0.1.0, no rush-lib dep) + - libraries/reporter/tsconfig.json, config/{api-extractor,jest,rig}.json, eslint.config.js + - libraries/reporter/.npmignore, LICENSE, README.md + - libraries/reporter/src/index.ts (REPORTER_PACKAGE_NAME, @beta) + - rush.json (registered @rushstack/reporter after problem-matcher) + - common/reviews/api/reporter.api.md (generated, shows // @beta) + - common/config/subspaces/default/pnpm-lock.yaml (rush update) + Verify: + - rush update: success (~20s) + - rush build --to @rushstack/reporter: success + - rush build --only @rushstack/reporter: clean SUCCESS, no warnings + - api report confirms @beta release tag on exports + - no rush-lib reference in package.json + Next: Feature 2/28 - Define canonical event envelope and closed core event type union + +[2026-07-14] Feature 2/28 COMPLETE: Canonical event envelope and closed core event type union + Files: + - libraries/reporter/src/events/ReporterProtocolVersion.ts (IReporterProtocolVersion) + - libraries/reporter/src/events/ReporterPrivacyClassification.ts (public|local-sensitive|secret) + - libraries/reporter/src/events/ReporterJsonValue.ts (ReporterJsonNull, ReporterJsonValue) + - libraries/reporter/src/events/ReporterEventType.ts (union + REPORTER_EVENT_TYPES, 15 members) + - libraries/reporter/src/events/IReporterEventEnvelope.ts (source, scope, envelope; all readonly) + - libraries/reporter/src/index.ts (re-exports) + - libraries/reporter/src/test/IReporterEventEnvelope.test.ts (+ __snapshots__ golden) + - common/reviews/api/reporter.api.md (regenerated, all @beta) + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - golden envelope snapshot + JSON round-trip + closed-set + sourceSequence + Error-not-serialized tests pass + - api report confirms all exports @beta, envelope fields readonly + Next: Feature 3/28 - Producer API (IReporterEventSink, IScopedReporter) + +[2026-07-14] Feature 3/28 COMPLETE: Producer API (scoped emit without reporter/destination access) + Files: + - libraries/reporter/src/producers/IReporterEventSink.ts (IReporterEmitEventInput, IReporterEventSink.emit) + - libraries/reporter/src/producers/IScopedReporter.ts (ReporterMessageSeverity, IScopedMessageOptions, IScopedReporter) + - libraries/reporter/src/producers/ReporterExtensionEventName.ts (type + isReporterExtensionEventName validator) + - libraries/reporter/src/diagnostics/IRushDiagnostic.ts (MINIMAL stub: code/severity/summaryKey; expanded in Feature 4) + - libraries/reporter/src/index.ts (re-exports) + - libraries/reporter/src/test/ProducerApi.test.ts + - common/reviews/api/reporter.api.md (regenerated, all @beta) + Notes: + - emitDiagnostic requires IRushDiagnostic; introduced a MINIMAL stub now. Feature 4 must EXPAND it + (add RushDiagnosticCategory, remediation, source, classified parameters, field privacy, code registry, templates, tests) - not recreate. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest), no warnings + - sink Omit contract, scoped emit ids, emit-only surface, extension-name validation tests pass + Next: Feature 4/28 - Structured diagnostics (registry, categories, remediation, field privacy) + +[2026-07-14] Feature 4/28 COMPLETE: Structured diagnostics (registry, categories, remediation, field privacy) + Files (new): + - diagnostics/RushDiagnosticCategory.ts (7 categories) + - diagnostics/IRushRemediationAction.ts (RushRemediationSafety + action) + - diagnostics/IRushDiagnosticSource.ts + - diagnostics/IClassifiedDiagnosticValue.ts (value + privacy) + - diagnostics/DiagnosticPrivacy.ts (getPrivacyClassificationRank, computeEnvelopePrivacyFloor = min sensitivity) + - diagnostics/RushDiagnosticCodeRegistry.ts (defs, RUSH_DIAGNOSTIC_CODES map, templates, isValidRushDiagnosticCode, RUSH_INTERNAL_ERROR_CODE) + - diagnostics/RushError.ts (wraps DTO, exposes diagnosticId) + - diagnostics/createRushDiagnostic.ts (factory, randomUUID id) + - test/Diagnostics.test.ts + Files (modified): + - diagnostics/IRushDiagnostic.ts (EXPANDED from Feature 3 stub: diagnosticId, category, detailKey, parameters, remediation, source, causeDiagnosticIds, retryable, relatedArtifactIds) + - test/ProducerApi.test.ts (updated IRushDiagnostic literal for new required fields) + - index.ts (re-exports), common/reviews/api/reporter.api.md + Notes: + - Privacy floor = MIN sensitivity across fields (spec: "minimum classification floor for every field"); field-level classification remains authoritative for redaction. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - registry validation (code pattern/uniqueness/category/templates/internal-code), privacy floor, factory, RushError tests pass + - all 35 exports @beta + Next: Feature 5/28 - NDJSON wire protocol (handshake, versioning, size limits) + +[2026-07-14] Feature 5/28 COMPLETE: NDJSON wire protocol (handshake, versioning, size limits) + Files (new): + - protocol/ReporterProtocol.ts (REPORTER_PROTOCOL_VERSION {1,0}, REPORTER_PROTOCOL_LIMITS 1MiB/1MiB/64KiB, isReporterProtocolCompatible = major-equality) + - protocol/Ndjson.ts (encodeNdjsonRecord, NdjsonDecoder streaming, NdjsonRecordTooLargeError, INdjsonOptions) + - protocol/ReporterHandshake.ts (IReporterHello, IReporterHelloAck, negotiateReporterHello -> {accepted, ack, diagnostic}) + - test/Protocol.test.ts + Files (modified): + - diagnostics/RushDiagnosticCodeRegistry.ts (APPENDED code RUSH_PROTOCOL_UPDATE_REQUIRED + templates; registry is append-only) + - index.ts, common/reviews/api/reporter.api.md + Notes: + - Capabilities optional (accepted = intersection; unknown ignored). Only unknown requiredFeatures or unsupported major reject + emit RUSH_PROTOCOL_UPDATE_REQUIRED. Minor additive. + - Byte limits enforced via Buffer.byteLength (utf8), incl. oversized partial-line guard in decoder. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - encode/decode round-trip, chunk-split reassembly, blank-line skip, record/partial size limits, negotiation (compat major, minor additive, unknown required, unsupported major, supported required) tests pass + - all exports @beta + Next: Feature 6/28 - ReporterManager lifecycle, ordering, and fan-out + +[2026-07-14] Feature 6/28 COMPLETE: ReporterManager lifecycle, ordering, and fan-out + Files (new): + - manager/IReporter.ts (IReporter {name, initializeAsync, report, flushAsync, closeAsync}, IReporterContext) + - manager/ReporterManager.ts (implements IReporterEventSink; emit assigns eventId/sequence/timestamp; ingestForeignEnvelope sets sourceSequence; per-reporter ordered async drain queue; addReporter destination exclusivity + required flag; initializeAsync fatal on failure; flushAsync 10s / signalFlushAsync 2s / closeAsync; coalescing under pressure; emergency stderr writer) + - manager/ReporterMultiplexer.ts (shared-destination fan-out) + - test/Manager.test.ts + Files (modified): index.ts, common/reviews/api/reporter.api.md + Notes: + - Coalescible = type 'activityChanged' && !required; only coalesces when queue length >= coalesceThreshold (default 64). Protected types never coalesced/dropped. + - Optional reporter runtime failure -> disabled + 1 emergency stderr line; required failure -> captured, flush/close reject (fatal). Init failure always fatal. + - report() is sync; drain loop yields via await Promise.resolve() between events so flush is the sync point. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - ordering/sequence/eventId/timestamp, foreign rehome, destination exclusivity, multiplexer, add-after-init guard, init-fatal, optional-disable, required-fatal-flush, coalescing (protected preserved), flush/close, flush-timeout tests pass + - all exports @beta + Next: Feature 7/28 - Freeze legacy output snapshots + protocol/compatibility goldens + +[2026-07-14] Feature 7/28 COMPLETE: Freeze legacy output snapshots + protocol/compatibility goldens (test-only) + Files (new): + - test/Goldens.test.ts + test/__snapshots__/Goldens.test.ts.snap (8 snapshots) + Files (modified): research/* + Notes: + - No new production API -> api.md unchanged. Legacy output samples INLINED as string constants (jest runs against lib/, so .txt fixtures would not be copied). Durations normalized (X.XX seconds). + - Protocol goldens: envelope schema, full diagnostic schema (fixed diagnosticId), hello/helloAck, NDJSON round-trip. + - Compatibility goldens: negotiation matrix (same major/minor, additive minor, unknown optional cap ignored, unknown required rejected+RUSH_PROTOCOL_UPDATE_REQUIRED, unsupported major rejected) normalized to drop random diagnosticId; minor-additive unknown-field retention on decode; protocol-version baseline. + - Legacy goldens: success + failure output frozen. + - Change file type=none (test-only, no version bump). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest), 8 snapshots + Next: Feature 8/28 - Two-stage bootstrap in install-run-rush (handoff file) diff --git a/research/tickets/2026-07-12-5858-rush-reporter-overhaul.md b/research/tickets/2026-07-12-5858-rush-reporter-overhaul.md new file mode 100644 index 00000000000..729fbce6a32 --- /dev/null +++ b/research/tickets/2026-07-12-5858-rush-reporter-overhaul.md @@ -0,0 +1,290 @@ +--- +date: 2026-07-12 14:11:07 UTC +researcher: Sean Larkin +git_commit: 012bae77ba8dd013d8bcd806b55d05c8001e5d92 +branch: main +repository: rushstack +topic: "GitHub issue #5858: Rush Reporter Overhaul" +tags: [research, codebase, rush, reporters, terminal, errors, telemetry] +status: complete +last_updated: 2026-07-12 +last_updated_by: Sean Larkin +last_updated_note: "Added design review transcript decisions and open-question answers" +--- + +# Research + +## Research Question + +Use the `research-codebase` skill to help with GitHub issue +[#5858, "[rush] Rush Reporter Overhaul"](https://github.com/microsoft/rushstack/issues/5858). + +The refined scope documents the current Rush reporting/output architecture +from bootstrap through phased execution, including terminal routing, +summarizers, plugins, verbosity controls, error propagation, telemetry, +package boundaries, Heft patterns, localization infrastructure, and historical +research relevant to the reviewed issue design. + +## Summary + +The current codebase has no Rush-wide `IReporter`, `ReporterManager`, +`RushError` hierarchy, global `--reporter`, global `--log-level`, +`COPILOT_CLI`, or `RUSH_REPORTER` implementation. Reporting is distributed +across three startup layers, `@rushstack/terminal`, `StreamCollator`, phased +operation hooks, manager/record output methods, fixed completion plugins, +project log writers, `AlreadyReportedError`, and a separate telemetry path. + +The reviewed design in the +[#5858 design comment](https://github.com/microsoft/rushstack/issues/5858#issuecomment-4949487094) +maps onto existing code surfaces as follows: + +| Design area | Current implementation surface | +| --- | --- | +| Initialization before repo `rush-lib` | `install-run-rush`, `apps/rush/start.ts`, `RushVersionSelector`, `RushCommandSelector` | +| Session/plugin output | `RushSession`, `ILogger`, shared `ITerminalProvider` | +| Structured operation lifecycle | `OperationStatus`, `PhasedCommandHooks`, `OperationExecutionRecord`, `OperationExecutionManager` | +| Current console presentation | `OperationExecutionManager`, `OperationResultSummarizerPlugin`, `ConsoleTimelinePlugin`, `DebugHashesPlugin` | +| Multiple output destinations | `Terminal` providers, `SplitterTransform`, project text/error/JSONL logs | +| Full-detail log retention | `ProjectLogWritable`, `OperationMetadataManager` | +| Error suppression/deduplication | `AlreadyReportedError`, parser and phased-action catches | +| Telemetry | `Telemetry`, phased `beforeLog`, session `flushTelemetry` | +| Reporter-like package patterns | Heft `LoggingManager`, `ScopedLogger`, `HeftJestReporter`, API Extractor message callbacks | +| String externalization | `@rushstack/localization-utilities` | + +## Detailed Findings + +### Issue design context + +The issue identifies verbose output, limited liveness signals, and +unstructured error context for both users and agents. The reviewed design adds: + +- pluggable reporters selected by flag or environment; +- one presentation-free event stream with multiple subscribers; +- concise default build output plus an always-on full-detail file; +- agent-specific output under `COPILOT_CLI`; +- structured, code-keyed errors; +- a reporter contract package that can initialize before `rush-lib`; +- compatible reporter work in Heft. + +The design explicitly leaves telemetry/event-stream integration, cross-version +event compatibility, migration sequence, and category-mapped exit codes open. + +### Startup boundaries + +The first output can occur before `rush-lib` is loaded: + +1. [`install-run-rush.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/scripts/install-run-rush.ts#L23-L125) + selects and installs `@microsoft/rush`. +2. [`apps/rush/src/start.ts`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/start.ts#L36-L105) + handles frontend warnings and version selection. +3. [`RushVersionSelector`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/RushVersionSelector.ts#L26-L102) + installs/loads the requested engine. +4. [`RushCommandSelector`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/rush/src/RushCommandSelector.ts#L28-L64) + calls the selected library's `Rush.launch*()` API. + +These layers currently use their own direct console or logger output and quiet +checks. + +### Current command-line surface + +[`RushCommandLineParser`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L99-L231) +defines global `--debug` and `--quiet`. `--json` exists on individual commands, +and its literal presence suppresses ancillary startup output. Phased +`--verbose` is separately defined by +[`PhasedScriptAction`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L257-L261). + +Current pnpm `--reporter` usage configures pnpm rather than Rush: +[`WorkspaceInstallManager.ts:863-880`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts#L863-L880) +and +[`BaseInstallManager.ts:895-901`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/base/BaseInstallManager.ts#L895-L901). + +### Current event and presentation path + +Status changes are present as structured enum values and hook callbacks. +[`OperationExecutionRecord.status`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts#L222-L230) +notifies the manager, which forwards changes through +[`PhasedCommandHooks.onOperationStatusChanged`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts#L166-L174). + +Presentation remains distributed: + +- operation activation headers and status-specific messages are emitted by + [`OperationExecutionManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts#L203-L235); +- final grouped results are emitted by + [`OperationResultSummarizerPlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts#L52-L282); +- optional timeline/debug output is emitted by separate plugins; +- startup banners, action-start lines, parser errors, and frontend warnings are + outside the phased hook stream. + +### Multiple destinations and retained logs + +The terminal package already supports multiple providers and explicit fan-out. +[`SplitterTransform`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/terminal/src/SplitterTransform.ts#L19-L97) +sends one immutable terminal chunk to multiple destinations. + +Each operation can write: + +- live collated console output; +- merged text log; +- stderr-only text log; +- JSONL terminal chunks; +- bounded stdio failure summary; +- collected problems. + +The graph is assembled by +[`OperationExecutionRecord`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts#L293-L385), +and file destinations are implemented by +[`ProjectLogWritable`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts#L93-L241). + +### Errors and exit codes + +Current structured error information is split between thrown `Error` objects, +stored operation errors, operation statuses, plugin logger collections, and +telemetry fields. The common deduplication mechanism is +[`AlreadyReportedError`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/node-core-library/src/AlreadyReportedError.ts#L8-L55). + +The parser sets a defensive failure exit code before execution and changes it +to success only after command completion +([`RushCommandLineParser.ts:249-300`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/RushCommandLineParser.ts#L249-L300)). +Current failures therefore use the normal process failure code rather than a +Rush error-category mapping. + +### Telemetry + +Phased telemetry is assembled after execution from the command result and +operation records, then passed through `beforeLog` +([`PhasedScriptAction.ts:1047-1164`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts#L1047-L1164)). +[`Telemetry`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/rush-lib/src/logic/Telemetry.ts#L146-L270) +writes local records and delivers batches to the session's +`flushTelemetry` hook. It does not currently consume a shared presentation +event stream. + +### Package relationships + +Current package dependencies flow from Rush into terminal infrastructure: + +- `@microsoft/rush` depends on `@microsoft/rush-lib` and + `@rushstack/terminal`; +- `@microsoft/rush-lib` depends on `@rushstack/terminal` and + `@rushstack/stream-collator`; +- `@rushstack/stream-collator` depends on `@rushstack/terminal`; +- Heft depends on `@rushstack/terminal` and + `@rushstack/operation-graph`. + +No separate reporter-contract package exists in this revision. + +### Heft and other reporter-like implementations + +Heft owns scoped loggers through +[`LoggingManager`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/apps/heft/src/pluginFramework/logging/LoggingManager.ts#L16-L103) +and derives final action status from aggregated logging and operation results. + +The Jest integration provides direct reporter injection: +[`JestPlugin`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-jest-plugin/src/JestPlugin.ts#L827-L891) +replaces Jest's default reporter with +[`HeftJestReporter`](https://github.com/microsoft/rushstack/blob/012bae77ba8dd013d8bcd806b55d05c8001e5d92/heft-plugins/heft-jest-plugin/src/HeftJestReporter.ts#L37-L248) +while preserving configured third-party reporters. + +### String externalization + +The repository already contains parsing and typing infrastructure for +externalized strings in +[`@rushstack/localization-utilities`](https://github.com/microsoft/rushstack/tree/012bae77ba8dd013d8bcd806b55d05c8001e5d92/libraries/localization-utilities). +Current Rush CLI reporter/error strings documented in this research remain +inline in their emitting classes. + +## Code References + +- `libraries/rush-lib/src/scripts/install-run-rush.ts:23-125` - Earliest managed Rush output. +- `apps/rush/src/start.ts:36-105` - Frontend output and selected-engine boundary. +- `libraries/rush-lib/src/cli/RushCommandLineParser.ts:99-555` - Global flags, startup suppression, errors, and exit behavior. +- `libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts:146-215` - Existing phased lifecycle events. +- `libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts:203-476` - Current progress/status presentation. +- `libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts:31-282` - Current final presentation. +- `libraries/rush-lib/src/logic/operations/ProjectLogWritable.ts:68-307` - Full text and JSONL logs. +- `libraries/rush-lib/src/logic/Telemetry.ts:146-270` - Separate telemetry path. +- `apps/heft/src/pluginFramework/logging/LoggingManager.ts:16-103` - Heft logger aggregation. +- `heft-plugins/heft-jest-plugin/src/JestPlugin.ts:827-891` - Existing reporter injection. + +## Architecture Documentation + +```text +install-run-rush + → @microsoft/rush frontend + → selected rush-lib + → RushCommandLineParser / RushSession + → PhasedScriptAction / PhasedCommandHooks + → OperationExecutionManager + → OperationExecutionRecord terminal graph + ├─ StreamCollator → console + ├─ project text/error/JSONL logs + ├─ stdio summary + └─ problem collector + → timeline/result plugins + → AlreadyReportedError / parser exit handling + → separately assembled telemetry +``` + +## Historical Context + +The current checkout has no `research/` history, but relevant documents remain +reachable in Git history: + +- [`2026-02-07-rush-plugin-architecture.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-rush-plugin-architecture.md) documented `RushSession`, `ILogger`, phased hooks, and telemetry extension points, with no reporter abstraction. +- [`2026-02-07-plugin-command-registration.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-plugin-command-registration.md) documented command discovery and deferred plugin loading. +- [`2026-02-07-rushstack-architecture-and-build-systems.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-rushstack-architecture-and-build-systems.md) documented package roles for Rush, `rush-lib`, the SDK, terminal, and command-line packages. +- [`2026-02-07-upgrade-interactive-implementation.md`](https://github.com/microsoft/rushstack/blob/311a90ee4d57eb3ddfe05bf965c01b94fa879dbf/research/docs/2026-02-07-upgrade-interactive-implementation.md) documented direct interactive terminal behavior without a CI-specific fallback. +- [`2026-01-23-interactive-upgrade-ui-rewrite.md`](https://github.com/microsoft/rushstack/blob/daada7cfa94ab0b3eaeca355706ba95f876fceb4/research/specs/2026-01-23-interactive-upgrade-ui-rewrite.md) proposed TTY/CI rendering tiers and cited GitHub Copilot CLI as an Ink adopter; it did not define a reporter integration. + +These documents are historical context; the live-code findings above are based +on commit `012bae77ba8dd013d8bcd806b55d05c8001e5d92`. + +## Related Research + +- [Current Rush startup and output pipeline](../docs/2026-07-12-rush-output-pipeline.md) +- [Current phased reporting, errors, and telemetry](../docs/2026-07-12-rush-operation-reporting.md) +- [Existing reporting and localization patterns](../docs/2026-07-12-rush-reporting-patterns.md) +- [Rush Reporter Overhaul design review](../notes/2026-07-12-rush-reporter-design-review.md) + +## Follow-up Research 2026-07-12 14:25 UTC + +The design review confirms these directions: + +1. **Package boundary:** Reporter contracts live in a separate package. + `rush-lib` depends on the reporter package, not the reverse. +2. **Session event stream:** A Rush session produces a presentation-free event + stream with one or more reporter subscribers. +3. **Plugin/action contract:** Plugins and actions emit into the centralized + subsystem and cannot depend on a concrete reporter. +4. **Reporter configuration:** Log level is configured per reporter. Output + style belongs to the reporter rather than a separate global axis. +5. **Multiple destinations:** Multiple reporters are mandatory, with a model + approximating one reporter per file descriptor or destination. A + full-detail file reporter remains active alongside reduced console output. +6. **Early initialization:** Reporter handling starts at the version selector + or `install-run-rush` boundary so it owns output before repository + `rush-lib` loads. +7. **Structured Rush output:** Rush-owned code emits structured events rather + than presentation strings. External tool streams remain ingestible, with + problem matchers recovering structured diagnostics where possible. +8. **Heft:** Heft gains compatible structured reporting in a breaking major + version. Rush can pass it a reporter option and consume a + machine-understandable stream. +9. **Minimum behavior:** The reporter system replaces `StreamCollator`, reduces + default terminal output to approximately three status/activity/result + lines, and retains a detailed StreamCollator-like format for CI or log + browsing. +10. **Initial scope:** The main `rush` binary is the target. `rushx` is outside + the first implementation, and `rush-pnpm` remains a thin pnpm shim. + +The complete timestamped decision record is in +[the design review note](../notes/2026-07-12-rush-reporter-design-review.md). + +## Open Questions After Design Review + +| Question | Answer from the design review | Status | +| --- | --- | --- | +| Does telemetry consume reporter events? | Telemetry may consume the reporter/underlying event stream or a separate event stream. It must not be restricted to only presentation-visible data. No integration was selected. | Partially answered | +| How does an older global frontend handle newer repository event types? | Reporter initialization occurs in the global bootstrap. An unknown event may produce a diagnostic asking the user to update global Rush, but protocol/version negotiation remains undefined. | Partially answered | +| What is the smallest migration sequence? | Agreed foundations are a separate package, early initialization, centralized action/plugin events, replacement of `StreamCollator`, retained detailed output, and compatible Heft support. Exact order, compatibility adapters, and P0 boundary remain undefined. | Partially answered | +| Are category-mapped exit codes opt-in or default? | Exit-code behavior was not discussed. | Unresolved | +| Does `AlreadyReportedError` remain? | The existing log-then-throw pattern was identified, but retention or replacement was not decided. | Unresolved | diff --git a/rush.json b/rush.json index aaec86ca966..722f7d6c82d 100644 --- a/rush.json +++ b/rush.json @@ -1226,6 +1226,12 @@ "shouldPublish": true, "decoupledLocalDependencies": ["@rushstack/heft"] }, + { + "packageName": "@rushstack/reporter", + "projectFolder": "libraries/reporter", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/heft-config-file", "projectFolder": "libraries/heft-config-file", diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md new file mode 100644 index 00000000000..a99ede393dc --- /dev/null +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -0,0 +1,902 @@ +# Rush Reporter Overhaul Technical Design Document / RFC + +| Document Metadata | Details | +| --- | --- | +| Author(s) | Sean Larkin | +| Status | In Review (RFC) | +| Team / Owner | Rush team | +| Created / Last Updated | 2026-07-12 | +| Tracking Issue | [#5858 — Rush Reporter Overhaul](https://github.com/microsoft/rushstack/issues/5858) | + +## 1. Executive Summary + +Rush currently emits presentation strings from its bootstrap, frontend, +command parser, operation scheduler, plugins, and child processes. This makes +output verbose, difficult to adapt for terminals, CI, and agents, and unable to +provide a consistent structured error or remediation contract. + +This RFC introduces `@rushstack/reporter`, a public beta package containing the +canonical event protocol, reporter manager, and built-in reporters. Rush-owned +code emits presentation-free structured events through a scoped sink. +Reporters independently render concise interactive output, append-only +plaintext, versioned JSON, bounded AI output, full-detail files, or the legacy +format. Reporting initializes before repository `rush-lib` loads, supports +multiple destinations, and uses a negotiated NDJSON channel across process +boundaries. + +The default interactive build occupies at most a three-row live region, while +every invocation retains a detailed local log. The migration ships structured +events in shadow mode, adds opt-in reporters, and changes defaults only in the +Rush daemon-aligned major release. + +## 2. Context and Motivation + +This proposal implements the direction documented in +[the #5858 codebase research](../research/tickets/2026-07-12-5858-rush-reporter-overhaul.md) +and +[the design review record](../research/notes/2026-07-12-rush-reporter-design-review.md). + +### 2.1 Current State + +Rush output crosses three separately reporting layers: + +1. `install-run-rush` selects and installs `@microsoft/rush`. +2. The `@microsoft/rush` frontend selects the repository-requested + `rush-lib`. +3. The selected `rush-lib` parses commands and executes operations. + +The current output path uses direct `console.*` calls, +`@rushstack/terminal`, `StreamCollator`, fixed summarizer plugins, per-project +logs, `AlreadyReportedError`, and a separate telemetry assembly path. The +current architecture is documented in +[Rush output pipeline research](../research/docs/2026-07-12-rush-output-pipeline.md) +and +[operation reporting research](../research/docs/2026-07-12-rush-operation-reporting.md). + +```mermaid +flowchart TD + Wrapper["install-run-rush"] --> Frontend["@microsoft/rush frontend"] + Frontend --> Engine["Repository-selected rush-lib"] + Engine --> Parser["RushCommandLineParser / RushSession"] + Parser --> Action["PhasedScriptAction"] + Action --> Manager["OperationExecutionManager"] + Manager --> Collator["StreamCollator"] + Manager --> Summary["Timeline and result plugins"] + Collator --> Console["stdout / stderr"] + Manager --> ProjectLogs["Project text, error, and JSONL logs"] + Action --> Telemetry["Separate telemetry assembly"] +``` + +### 2.2 The Problem + +- **Human impact:** Default builds emit large, scrolling output with limited + compact liveness information. +- **Agent impact:** Verbose output consumes context, while errors lack stable + codes, structured remediation, and deterministic log handoff. +- **CI impact:** Current presentation is coupled to the operation scheduler + rather than selected for CI, TTY, pipe, or machine consumers. +- **Plugin impact:** Plugins write through terminal abstractions and can depend + on presentation behavior. +- **Compatibility impact:** Output begins before repository `rush-lib` loads, + so a reporter initialized only inside `rush-lib` cannot own the complete + session. +- **Technical debt:** The print-then-throw `AlreadyReportedError` pattern + encodes presentation state into control flow. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] Create `@rushstack/reporter` as a public beta package containing the + event contracts, wire protocol, reporter manager, and built-in reporters. +- [ ] Initialize reporting in a two-stage bootstrap before repository + `rush-lib` loads. +- [ ] Emit a canonical presentation-free event stream from Rush-owned code. +- [ ] Support simultaneous reporters with exclusive destination ownership. +- [ ] Provide built-in `default`, `ai`, `json`, `plaintext`, `file`, and + `legacy` reporters. +- [ ] Reduce interactive default output to a three-row live region. +- [ ] Preserve a full-detail invocation log and existing per-project logs. +- [ ] Provide versioned structured diagnostics with stable codes and + remediation. +- [ ] Preserve raw child-process stdout/stderr and recover diagnostics through + tested problem matchers. +- [ ] Support current and future Heft versions without requiring lockstep + rollout. +- [ ] Preserve command success semantics and command-specific JSON schemas. + +### 3.2 Non-Goals + +- [ ] Do not redesign operation scheduling, parallelism, or cobuild. +- [ ] Do not add translated locales in v1. +- [ ] Do not load third-party reporter implementations in v1. +- [ ] Do not add full reporter CLI support to `rushx` or reinterpret + `rush-pnpm` output. +- [ ] Do not make native Heft structured reporting block Rush reporter v1. +- [ ] Do not introduce category-mapped exit codes in v1. +- [ ] Do not make Markdown a streaming transport or ship porcelain before a + concrete consumer requires it. + +## 4. Proposed Solution + +### 4.1 System Architecture + +```mermaid +flowchart TB + Wrapper["install-run-rush prelude"] + ReporterPackage["@rushstack/reporter"] + Frontend["@microsoft/rush frontend"] + Manager["ReporterManager"] + Engine["Repository-selected rush-lib"] + Session["RushSession event sink"] + Producers["Actions, plugins, operations"] + Heft["Heft / external child"] + FileFD["Dynamic inherited reporter FD"] + Default["default reporter"] + AI["ai reporter"] + JSON["json reporter"] + Plaintext["plaintext reporter"] + File["file reporter"] + Legacy["legacy reporter"] + Telemetry["Telemetry projection subscriber"] + + Wrapper -->|"temporary bootstrap NDJSON"| Frontend + ReporterPackage --> Wrapper + ReporterPackage --> Frontend + Frontend --> Manager + Frontend --> Engine + Engine --> Session + Session --> Producers + Producers -->|"typed in-process events"| Manager + Heft -->|"NDJSON hello/events"| FileFD + FileFD --> Manager + Manager --> Default + Manager --> AI + Manager --> JSON + Manager --> Plaintext + Manager --> File + Manager --> Legacy + Manager --> Telemetry +``` + +### 4.2 Architectural Pattern + +The design uses a publisher-subscriber event architecture: + +- Rush-owned producers publish immutable structured events. +- `ReporterManager` assigns session ordering and fans events out to subscribers. +- Each reporter owns presentation, filtering, and one exclusive destination or + destination group. +- Telemetry is a non-presentation subscriber that consumes an allowlisted + projection before reporter filtering. +- Cross-process producers use a negotiated NDJSON adapter for the same semantic + event model. + +### 4.3 Key Components + +| Component | Responsibility | Location | +| --- | --- | --- | +| `@rushstack/reporter` | Contracts, event types, manager, wire adapters, and built-in reporters | New package | +| Bootstrap prelude | Parse early controls and buffer Rush-owned startup events | `install-run-rush` | +| Frontend reporter host | Create the authoritative manager before version selection | `apps/rush` | +| Event sink | Provide scoped producer APIs without exposing reporter instances | `rush-lib` / future Heft | +| Wire adapter | Exchange negotiated NDJSON on an inherited descriptor | `@rushstack/reporter` | +| Compatibility adapters | Bridge old engines, legacy renderer, telemetry, and sentinel errors | `apps/rush` / `rush-lib` | +| Problem matcher registry | Recover structured diagnostics from raw child output | `rush-lib` / Heft integrations | + +## 5. Detailed Design + +### 5.1 Package Boundary + +`@rushstack/reporter` contains: + +- public beta TypeScript contracts; +- core event and diagnostic DTOs; +- reporter lifecycle and manager; +- protocol version and capability types; +- typed in-process sink; +- NDJSON encoder, decoder, and handshake; +- built-in `default`, `ai`, `json`, `plaintext`, `file`, and `legacy` + reporters; +- English resource keys and templates for Rush-owned reporter presentation. + +It must not depend on `rush-lib`. `rush-lib` and future Heft versions depend on +it to emit events. `@rushstack/terminal` remains a low-level utility but is not +the canonical producer contract. + +The committed zero-dependency `install-run-rush` bundle does not import the +package at runtime. Its build embeds only a minimal frozen bootstrap envelope +encoder and protocol-major constant generated from `@rushstack/reporter`. + +Third-party reporter loading is deferred. Namespaced extension events are +public beta in v1, but only built-in reporters are instantiated. + +### 5.2 Canonical Event Contract + +```ts +export interface IReporterProtocolVersion { + major: number; + minor: number; +} + +export type ReporterPrivacyClassification = + | 'public' + | 'local-sensitive' + | 'secret'; + +export interface IReporterEventEnvelope { + protocolVersion: IReporterProtocolVersion; + eventId: string; + sessionId: string; + parentSessionId?: string; + parentOperationId?: string; + sequence: number; + sourceSequence?: number; + timestamp: string; + source: { + packageName: string; + packageVersion: string; + component?: string; + }; + scope?: { + commandName?: string; + operationId?: string; + projectName?: string; + phaseName?: string; + }; + privacy: ReporterPrivacyClassification; + required: boolean; + type: ReporterEventType; + payload: TPayload; +} +``` + +`sequence` is authoritative for ordering. `timestamp` is informational. +Events are immutable and JSON-serializable. JavaScript `Error` instances are +never serialized directly. + +For child sessions, `ReporterManager` assigns the global `sequence` in receipt +order and preserves the producer's local sequence as `sourceSequence`. + +The closed core union includes: + +- `sessionStarted`, `sessionCompleted`; +- `commandStarted`, `commandCompleted`; +- `operationRegistered`, `operationStatusChanged`; +- `activityChanged`, `watchCycleCompleted`; +- `diagnosticEmitted`; +- `externalProcessStarted`, `externalOutput`, `externalProcessCompleted`; +- `artifactAvailable`; +- `commandResult`; +- `extension`. + +Extension events use namespaced beta identifiers and JSON-serializable payloads. +Core lifecycle event creation remains controlled by Rush. + +### 5.3 Producer API + +Reporter implementations and destinations are never exposed to producers. + +```ts +export interface IReporterEventSink { + emit( + event: Omit, 'eventId' | 'sequence' | 'timestamp'> + ): string; +} + +export interface IScopedReporter { + emitMessage(options: IScopedMessageOptions): string; + emitDiagnostic(diagnostic: IRushDiagnostic): string; + emitExtension(name: string, payload: TPayload): string; +} +``` + +`RushSession` exposes scoped reporter/logger creation. Actions receive the same +sink through execution context. Plugins cannot inspect active modes, +destinations, or thresholds. + +The breaking major removes `ILogger.terminal` and +`RushSession.terminalProvider`. Plugin manifests declare the supported Rush +plugin API version. Incompatible plugins fail before `apply()` with a +structured migration diagnostic. + +### 5.4 Reporter Lifecycle and Fan-Out + +```ts +export interface IReporter { + readonly name: string; + initializeAsync(context: IReporterContext): Promise; + report(event: IReporterEventEnvelope): void; + flushAsync(): Promise; + closeAsync(): Promise; +} +``` + +`ReporterManager`: + +- assigns one monotonic session sequence; +- uses one ordered asynchronous queue per reporter; +- enforces exclusive destination ownership; +- permits shared output only through an explicit multiplexer; +- flushes for normal and error completion with a 10-second timeout; +- performs best-effort signal flush with a 2-second timeout; +- coalesces replaceable UI status events under pressure; +- never drops lifecycle, diagnostic, result, artifact, or external-output + events. + +Explicit reporter initialization failures are fatal. Runtime failure disables +an optional reporter and emits one emergency stderr diagnostic. Failure of a +required parent/wire reporter is fatal. Failure to create the full-detail file +at both repository and OS-temp paths is nonfatal but emits an emergency warning +and marks the artifact unavailable. + +### 5.5 Bootstrap and Wire Protocol + +`install-run-rush` performs a minimal prelude: + +1. Parse early reporter controls. +2. Encode up to 1 MiB of structured bootstrap events using the bundled minimal + envelope encoder. +3. Preserve inherited npm output as raw external output. +4. Write the bounded events to a temporary NDJSON handoff file. +5. Pass the file path to the installed frontend using a private environment + variable. + +The `@microsoft/rush` frontend creates `ReporterManager` before version +selection, replays the bootstrap file, and deletes it. The selected `rush-lib` +receives a typed sink and does not own selection. + +A direct `rush` invocation starts at the frontend and does not use the handoff +file. Abandoned handoff files are cleaned using the OS-temp fallback retention +policy. + +If the bootstrap buffer fills, required and diagnostic events are preserved. +Replaceable status events may be coalesced or discarded, and replay includes a +`bufferTruncated` event describing the loss. A required event that cannot be +preserved fails the bootstrap. + +Cross-process producers use NDJSON on a dynamically allocated inherited file +descriptor. The descriptor number is communicated using a private environment +variable. stdout and stderr remain normal process streams. + +```ts +interface IReporterHello { + kind: 'hello'; + protocolVersion: IReporterProtocolVersion; + producerVersion: string; + capabilities: string[]; + requiredFeatures: string[]; +} + +interface IReporterHelloAck { + kind: 'helloAck'; + protocolVersion: IReporterProtocolVersion; + acceptedCapabilities: string[]; + rejectedRequiredFeatures: string[]; +} +``` + +Protocol rules: + +- Support the current protocol major. +- Minor versions are additive. +- Events and capabilities default to optional. +- Producers mark only correctness-critical semantics as required. +- Unknown optional events are ignored or retained in full-detail output. +- Unknown required features or unsupported majors produce an update-global-Rush + diagnostic. +- Explicit unsupported reporter requests fail. +- Automatic selection may safely fall back to legacy/plaintext behavior. + +Limits: + +- bootstrap buffer: 1 MiB; +- NDJSON record: 1 MiB; +- raw external-output chunk: 64 KiB. + +### 5.6 Reporter Configuration + +Public controls: + +```text +--reporter +--log-level +--output ://?key=value +``` + +Examples: + +```text +rush build --reporter=plaintext +rush build --reporter=json --output=file://./rush-debug.log?logLevel=debug +rush build --output=json://./rush-events.jsonl +``` + +Environment controls: + +- `RUSH_REPORTER`; +- `RUSH_LOG_LEVEL`; +- `COPILOT_CLI`; +- configured agent environment names from `rush.json`. + +```json +{ + "reporting": { + "agentEnvironmentVariables": ["MY_AGENT_CLI", "ANOTHER_AGENT"] + } +} +``` + +An agent variable is active when defined and not equal, case-insensitively, to +an empty string, `0`, `false`, `no`, or `off`. + +Precedence: + +1. Explicit CLI controls. +2. `RUSH_REPORTER` / `RUSH_LOG_LEVEL`. +3. Agent detection (`COPILOT_CLI` plus configured variables). +4. CI detection (`CI` plus known vendor variables). +5. Interactive TTY. +6. Generic non-TTY plaintext. + +Legacy flags remain permanent compatibility aliases for the primary reporter: + +- `--quiet` maps to `quiet`; +- `--verbose` maps to `verbose`; +- `--debug` maps to `debug`; +- contradictory verbosity controls are rejected. + +Command-specific `--json` behavior remains unchanged and is not an alias for +`--reporter=json`. + +### 5.7 Log Levels + +Each reporter independently applies: + +1. `quiet` — failures, required warnings, and final result; +2. `normal` — standard lifecycle, progress, diagnostics, and result; +3. `verbose` — detailed operation and external process activity; +4. `debug` — verbose plus protocol, cache, internal, and stack details. + +Event diagnostic severity remains separate from reporter log level. The +full-detail file reporter defaults to `debug`. + +### 5.8 Automatic Reporter Matrix + +| Environment | Primary reporter | Additional reporter | +| --- | --- | --- | +| Agent detected | `ai` | `file` | +| Recognized CI | detailed `plaintext` | `file` | +| Interactive TTY | `default` | `file` | +| Other non-TTY | concise `plaintext` | `file` | + +Machine reporters own stdout exclusively. Their stdout contains payload records +only; human progress and emergency diagnostics use stderr or files. + +`rushx` and `rush-pnpm` receive no public reporter flags in phase 1. +`rush-pnpm --reporter` remains pnpm's option. Rush-owned bootstrap chatter may +be suppressed in machine modes, and only private child context is propagated. + +### 5.9 Default Interactive Reporter + +The interactive reporter uses a maximum three-row live region: + +1. aggregate phase/progress and spinner; +2. width-aware active projects with `+N more`; +3. latest activity, liveness, or result. + +Requirements: + +- refresh at no more than 10 Hz; +- react to terminal resize; +- restore cursor state on success, failure, cancellation, and signal; +- successful completion leaves no more than three stable lines; +- failure may append a bounded diagnostic block and log path; +- watch mode keeps the live region and appends one summary per completed cycle. + +Color follows TTY capability while honoring `NO_COLOR` and `FORCE_COLOR`. No +new global color flag is introduced. + +### 5.10 Plaintext and Non-TTY Reporter + +Plaintext is append-only, has no cursor movement, and disables color by default. +It is human-readable but not a parsing contract. + +Long-running non-TTY and CI sessions emit a compact heartbeat every 30 seconds, +in addition to start, meaningful state changes, diagnostics, and final result. +The detailed CI mode retains StreamCollator-like operation grouping. + +### 5.11 JSON and AI Reporters + +The stable JSON reporter emits the complete versioned NDJSON event stream. + +The AI reporter is a versioned public beta bounded projection. It emits status +and final records containing: + +- result and exit code; +- operation/project scope; +- error codes and categories; +- structured remediation; +- aggregate counts; +- primary log path and format; +- artifact completeness. + +Limits: + +- at most 64 KiB per invocation; +- at most 20 detailed diagnostics; +- when failures exist, warnings are represented by counts and references; +- warning-only success may include bounded warning details; +- raw logs and stacks are excluded by default. + +Absolute log paths are local reporter output and never enter telemetry. +Automatic agent selection is enabled only after deterministic corpus +evaluations pass. Offline model evaluation may supplement but never gate +network-dependent CI. + +Markdown remains experimental and post-run only. Porcelain is deferred. + +### 5.12 Full-Detail File Reporter + +Every invocation attempts to write: + +```text +/rush-logs/--.log +``` + +`latest.log` points to or copies the latest successfully written invocation log +according to platform capability, regardless of whether the Rush command +succeeded or failed. Before configuration loads, the reporter buffers events +and may use an OS-temp fallback. + +The invocation log: + +- runs at `debug`; +- preserves ordered external stdout/stderr; +- excludes structured fields classified as `secret`; +- uses owner-only permissions where supported; +- documents that raw child output cannot be reliably redacted. + +Existing project merged, error-only, and chunk JSONL logs remain unchanged +initially because cache replay and bounded summaries depend on them. + +Retention: + +- delete invocation files older than 14 days; +- retain at most 20 sessions per repository; +- apply the same 14-day policy and a 20-session per-user cap to OS-temp fallback + files; +- `rush purge` removes the directory. + +### 5.13 StreamCollator Replacement + +The operation scheduler emits raw semantic events: + +- operation registration; +- status transitions; +- output chunks; +- completion; +- aggregate command result. + +The concise reporter derives current activity without buffering project output. +The detailed plaintext and file reporters own grouping and buffering equivalent +to current StreamCollator behavior. Problem matchers receive the same +uncollated source stream. + +`@rushstack/stream-collator` is removed from the primary Rush output path after +parity tests pass. Package removal is a later cleanup. + +### 5.14 Structured Diagnostics + +```ts +export type RushDiagnosticCategory = + | 'configuration' + | 'input' + | 'dependency-tool' + | 'environment' + | 'network-auth' + | 'operation' + | 'internal'; + +export interface IRushDiagnostic { + diagnosticId: string; + code: string; + category: RushDiagnosticCategory; + severity: 'warning' | 'error'; + summaryKey: string; + detailKey?: string; + parameters?: Record; + remediation?: IRushRemediationAction[]; + source?: IRushDiagnosticSource; + causeDiagnosticIds?: string[]; + retryable?: boolean; + relatedArtifactIds?: string[]; +} +``` + +Codes use a central permanent never-reused registry: + +```text +RUSH__ +``` + +English templates are keyed by code and resource key. Remediation actions may +include: + +- a command; +- a documentation URL; +- a safety classification indicating whether automated execution is safe. + +Diagnostic emission returns a diagnostic ID. Propagated failures reference +that ID. Catch boundaries emit only failures not already represented. + +Unexpected programmer failures produce a stable internal-error code and log +pointer. Stack traces appear only in debug/full-detail output. + +Fields are classified individually as `public`, `local-sensitive`, or +`secret`. Field-level classification is authoritative. The envelope-level +privacy classification is the minimum classification floor for every field in +that event. Reporters enforce destination policy; telemetry uses a separate +allowlist. + +An internal `RushError` may wrap a diagnostic DTO in-process, but the DTO is the +wire contract. + +### 5.15 `AlreadyReportedError` + +New usage is prohibited when structured diagnostic APIs become available. +A compatibility bridge correlates legacy sentinels with previously emitted +diagnostics and suppresses duplicate rendering. + +The bridge is removed in a later major after: + +- zero first-party usages remain; +- plugin API migration guidance is published; +- ecosystem notice and migration time are provided. + +### 5.16 External Output and Problem Matchers + +External stdout/stderr is preserved losslessly as ordered chunks. ANSI-normalized +text is processed separately by tool- and version-scoped problem matchers. + +Matchers: + +- never modify raw output or process status; +- emit linked diagnostics without replacing evidence; +- require high-confidence corpus tests before default enablement; +- cap duplicate diagnostics; +- preserve unmatched text. + +Older Heft versions use this path. + +### 5.17 Telemetry + +Telemetry subscribes to canonical events before reporter filtering and produces +an allowlisted aggregate at command completion. + +Allowed fields: + +- command and result; +- duration and aggregate operation timing; +- operation status counts; +- diagnostic codes and categories; +- selected reporter mode; +- protocol and producer versions. + +Excluded fields: + +- messages and templates; +- paths; +- raw stdout/stderr; +- command arguments; +- remediation parameters; +- stack traces; +- local-sensitive and secret values. + +The existing `beforeLog` hook is preserved through an adapter during migration. + +### 5.18 Exit Codes + +- success, including warning-only success: `0`; +- Rush or operation failure: `1`; +- logical cancellation/aborted command: `1`; +- OS signal termination: conventional signal-derived status. + +Reporters and diagnostic categories never select exit codes. + +### 5.19 Heft Integration + +The shared protocol is Heft-capable in P0, but native Heft emission does not +block Rush v1. + +- Older Heft versions continue through raw streams and problem matchers. +- A future Heft breaking major depends on `@rushstack/reporter`. +- Heft evolves `LoggingManager` and `ScopedLogger` into structured emitters. +- Rush passes a dynamic descriptor and correlates child events using child + session ID, parent session ID, and parent operation ID. +- If descriptor negotiation is unavailable, Heft falls back to normal + stdout/stderr. +- Standalone future Heft uses the same explicit, agent, CI, TTY, and non-TTY + selection model. + +## 6. Alternatives Considered + +| Option | Advantages | Disadvantages | Decision | +| --- | --- | --- | --- | +| Keep reporters in `rush-lib` | Fewer packages | Cannot own bootstrap output; wrong dependency direction | Rejected | +| Extend only `@rushstack/terminal` | Reuses providers | Terminal chunks lack lifecycle and diagnostic semantics | Rejected | +| Serialize NDJSON in-process | One transport implementation | Unnecessary hot-path allocation and parsing | Rejected | +| Keep StreamCollator before reporters | Low migration cost | Scheduling remains coupled to presentation | Rejected | +| One composite reporter | Simple destination handling | Prevents independent levels and destinations | Rejected | +| Upload the complete event stream as telemetry | Maximum detail | Privacy, volume, and presentation coupling risks | Rejected | +| Manifest-only log handoff | Avoids duplicate output | Harder for users and agents to consume one narrative | Rejected | +| Big-bang major rewrite | Short transition | High compatibility and diagnosis risk | Rejected | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- Structured fields carry field-level privacy classifications. +- `secret` structured fields never reach local full logs. +- Invocation logs use owner-only permissions where supported. +- Raw child output is preserved and may contain sensitive tool output; this is + documented because pattern redaction is not reliable. +- Machine stdout contains payload only. +- Telemetry is allowlist-only and excludes paths, text, raw streams, and stacks. +- Third-party reporter loading is deferred, avoiding a new v1 code-loading + trust boundary. + +### 7.2 Observability + +The reporter subsystem records local debug information for: + +- selected reporters and selection reason; +- protocol negotiation and fallback; +- disabled reporter failures; +- queue pressure and coalesced status events; +- file reporter location and completeness; +- matcher identity and emitted diagnostic IDs. + +These local details are not automatically telemetry fields. + +### 7.3 Performance and Capacity + +Blocking budgets: + +- no more than 3% representative-build wall-time regression; +- no more than 32 MiB additional peak memory; +- interactive refresh no more than 10 Hz; +- bounded streaming rather than whole-build buffering; +- no loss of lifecycle, diagnostics, results, artifacts, or external output; +- AI output capped at 64 KiB and 20 detailed diagnostics. + +Lazy payload construction is not part of P0. It is introduced only if profiling +shows material producer-side cost. + +### 7.4 Reliability + +- Reporter queues preserve per-reporter order. +- Required wire failure is fatal. +- Optional reporter failure is isolated. +- Explicit incompatible reporter requests fail. +- Automatic selection may fall back safely and records the reason in the + detailed log. +- Full-detail file failure does not fail a build. + +## 8. Migration, Rollout, and Testing + +### 8.1 Migration Phases + +1. **Contracts and baselines** + - Publish `@rushstack/reporter`. + - Freeze legacy output snapshots. + - Add protocol and compatibility goldens. +2. **Bootstrap and compatibility adapters** + - Add two-stage initialization. + - Support new frontend/old engine and old frontend/new engine fallback. + - Keep legacy rendering as the sole visible output. +3. **Shadow structured emission** + - Emit first-party lifecycle and diagnostic events without changing output. + - Validate telemetry and exit-code parity. +4. **Opt-in reporters** + - Add `file`, `plaintext`, `json`, `default`, and `ai`. + - Expose CLI opt-in and an experimental repository setting. + - Replace StreamCollator only when a new reporter path is enabled. +5. **Heft protocol track** + - Support negotiated child descriptors. + - Continue raw-stream compatibility for older Heft. +6. **Daemon-aligned major default flip** + - Enable environment-based automatic selection. + - Remove legacy terminal APIs and fail incompatible plugins before `apply()`. + - Retain the legacy renderer, aliases, and sentinel bridge. +7. **Later cleanup major** + - Remove the legacy renderer after at least one full major of default use, + no blocking canary regressions, and documented migration. + - Remove the `AlreadyReportedError` bridge after ecosystem criteria are met. + +Every phase must be independently releasable and revertible. + +### 8.2 Rollout Controls + +- Pre-major exposure: explicit CLI plus an experimental repository setting. +- Agent auto-selection: enabled only after deterministic AI corpus gates pass. +- Default flip: Rush daemon-aligned major release. +- Emergency fallback: `RUSH_REPORTER=legacy` for at least one major. +- Legacy verbosity aliases remain permanent documented compatibility aliases. + +### 8.3 Test Plan + +#### Protocol and API + +- Golden event envelope and diagnostic schema tests. +- hello/ack capability negotiation tests. +- current-major/minor compatibility tests. +- unknown optional and required event behavior. +- NDJSON record, chunk, and buffer limit tests. +- bootstrap handoff-file replay, deletion, truncation-marker, and required-event + overflow tests. +- parent/child receipt ordering and `sourceSequence` preservation tests. + +#### Cross-Version Compatibility + +- new frontend + new engine; +- new frontend + old engine adapter; +- old frontend + new engine fallback; +- unsupported explicit reporter failure; +- automatic fallback behavior; +- old and new Heft descriptor paths. + +#### Reporter Behavior + +- stable plaintext snapshots; +- normalized semantic snapshots for dynamic TTY output; +- resize, width, color, heartbeat, and cursor restoration; +- watch-cycle summaries; +- stdout-purity tests for JSON and AI; +- full-log path, permissions, retention, and flush completeness; +- `latest.log` behavior for successful and failed commands; +- OS-temp fallback cleanup; +- optional and required reporter failure isolation. + +#### Diagnostics and External Tools + +- exactly-once diagnostic rendering; +- central code registry validation; +- privacy classification and redaction; +- internal-error stack visibility; +- ANSI, split-chunk, malformed-output, and matcher corpus tests; +- duplicate matcher caps. + +#### Telemetry + +- allowlist schema tests; +- proof that paths, messages, raw streams, stacks, and remediation parameters do + not escape; +- current telemetry parity through the adapter. + +#### Performance + +- representative Rush build benchmarks against the 3% / 32 MiB budgets; +- queue pressure and status coalescing; +- full-log streaming without unbounded allocation. + +#### AI Evaluation + +- deterministic corpus asserting stable codes, remediation, bounded output, + root-cause ordering, and valid flushed log paths; +- supplementary offline model evaluation; +- no network-dependent model evaluation in CI. + +## 9. Open Questions / Unresolved Issues + +No blocking design questions remain from the specification wizard. + +The following are explicitly deferred rather than unresolved: + +- third-party reporter loading; +- translated locales and pluralization; +- stable Markdown and porcelain contracts; +- full reporter support for `rushx`; +- category-mapped exit codes; +- removal of existing per-project logs; +- removal of the `@rushstack/stream-collator` package itself.