From a1822b7392fddaed303e7248237378e6f9abee28 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:40:48 +0000 Subject: [PATCH 01/22] Add reporter selection and configuration precedence Resolve the reporter and log level from controls for @rushstack/reporter (#5858). - Add the built-in reporter names and log levels with validation guards - Add agent and CI detection, including COPILOT_CLI and configured agent environment variables and the active-value semantics - Parse the --output control into reporter, target, and params - Add resolveReporterSelection: the primary reporter runs from explicit CLI through RUSH_REPORTER, agent, CI, and TTY down to generic non-TTY plaintext, and always pairs with the file reporter - Resolve the log level independently, mapping --quiet, --verbose, and --debug aliases and rejecting contradictions - Keep command-specific --json distinct from the json reporter and fail explicit unsupported reporter or log-level requests - Cover precedence, aliases, contradictions, outputs, and detection with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 66 ++++++ .../reporter/src/config/AgentDetection.ts | 94 ++++++++ .../reporter/src/config/OutputControl.ts | 64 ++++++ .../reporter/src/config/ReporterNames.ts | 55 +++++ .../reporter/src/config/ReporterSelection.ts | 206 ++++++++++++++++++ libraries/reporter/src/index.ts | 19 ++ .../src/test/ReporterSelection.test.ts | 135 ++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 17 ++ 9 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/config/AgentDetection.ts create mode 100644 libraries/reporter/src/config/OutputControl.ts create mode 100644 libraries/reporter/src/config/ReporterNames.ts create mode 100644 libraries/reporter/src/config/ReporterSelection.ts create mode 100644 libraries/reporter/src/test/ReporterSelection.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 43d771684b..f9ebf0150b 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -38,6 +38,9 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec // @beta export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; +// @beta +export const COPILOT_CLI_ENV_VAR: 'COPILOT_CLI'; + // @beta export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => void; @@ -74,6 +77,9 @@ export function deleteBootstrapHandoffFileAsync(filePath: string): Promise // @beta export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number; +// @beta +export function detectAgent(env: Record, configuredVariables?: readonly string[]): boolean; + // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; @@ -366,6 +372,15 @@ export interface IReporterManagerOptions { readonly protocolVersion?: IReporterProtocolVersion; } +// @beta +export interface IReporterOutputTarget { + readonly params: { + readonly [key: string]: string; + }; + readonly reporter: string; + readonly target: string; +} + // @beta export interface IReporterProtocolLimits { readonly bootstrapBufferBytes: number; @@ -385,6 +400,24 @@ export interface IReporterRegistrationOptions { readonly required?: boolean; } +// @beta +export interface IReporterSelection { + readonly additionalReporters: readonly ReporterName[]; + readonly commandJson: boolean; + readonly logLevel: ReporterLogLevel; + readonly outputs: readonly IReporterOutputTarget[]; + readonly primaryReporter: ReporterName; + readonly reason: string; +} + +// @beta +export interface IReporterSelectionInput { + readonly agentEnvironmentVariables?: readonly string[]; + readonly argv: readonly string[]; + readonly env: Record; + readonly isTTY: boolean; +} + // @beta export interface IResolveExitStatusFromEventsOptions { readonly cancelled?: boolean; @@ -462,9 +495,15 @@ export interface IRushSessionReportingOptions { readonly source: IReporterEventSource; } +// @beta +export function isAgentVariableActive(value: string | undefined): boolean; + // @beta export function isBootstrapHandoffFileName(fileName: string): boolean; +// @beta +export function isCiDetected(env: Record): boolean; + // @beta export interface IScopedLogger { writeDebugLine(text: string): string; @@ -518,6 +557,12 @@ export function isReporterExtensionEventName(name: string): boolean; // @beta export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, producer: IReporterProtocolVersion): boolean; +// @beta +export function isSupportedLogLevel(level: string): level is ReporterLogLevel; + +// @beta +export function isSupportedReporterName(name: string): name is ReporterName; + // @beta export function isValidRushDiagnosticCode(code: string): boolean; @@ -551,6 +596,9 @@ export interface IWriteBootstrapHandoffOptions { readonly pid?: number; } +// @beta +export const KNOWN_CI_ENV_VARS: readonly string[]; + // @beta export type LegacyBeforeLogHook = (telemetry: Record) => void; @@ -610,6 +658,9 @@ export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWa // @beta export function parseEarlyReporterControls(argv: readonly string[], env: Record): IEarlyReporterControls; +// @beta +export function parseOutputControl(value: string): IReporterOutputTarget; + // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; @@ -651,6 +702,9 @@ export type ReporterJsonValue = string | number | boolean | ReporterJsonNull | r readonly [key: string]: ReporterJsonValue; }; +// @beta +export type ReporterLogLevel = 'quiet' | 'normal' | 'verbose' | 'debug'; + // @beta export class ReporterManager implements IReporterEventSink { constructor(options?: IReporterManagerOptions); @@ -680,6 +734,9 @@ export class ReporterMultiplexer implements IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export type ReporterName = 'default' | 'ai' | 'json' | 'plaintext' | 'file' | 'legacy'; + // @beta export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; @@ -692,6 +749,9 @@ export function resolveExitStatusFromEvents(events: readonly IReporterEventEnvel // @beta export function resolveReporterCompatibility(frontend: IReporterFrontendDescriptor, engine: IReporterEngineDescriptor): IReporterCompatibilityDecision; +// @beta +export function resolveReporterSelection(input: IReporterSelectionInput): IReporterSelection; + // @beta export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; @@ -746,6 +806,12 @@ export function separateJsonControls(argv: readonly string[]): IJsonControls; // @beta export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; +// @beta +export const SUPPORTED_LOG_LEVELS: readonly ReporterLogLevel[]; + +// @beta +export const SUPPORTED_REPORTER_NAMES: readonly ReporterName[]; + // @beta export const TELEMETRY_AGGREGATE_KEYS: readonly string[]; diff --git a/libraries/reporter/src/config/AgentDetection.ts b/libraries/reporter/src/config/AgentDetection.ts new file mode 100644 index 0000000000..09ecbaa85e --- /dev/null +++ b/libraries/reporter/src/config/AgentDetection.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. + +/** + * The environment variable that indicates the GitHub Copilot CLI agent. + * + * @beta + */ +export const COPILOT_CLI_ENV_VAR: 'COPILOT_CLI' = 'COPILOT_CLI'; + +/** + * The environment variables that indicate a recognized CI environment. + * + * @beta + */ +export const KNOWN_CI_ENV_VARS: readonly string[] = [ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'TF_BUILD', + 'JENKINS_URL', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'TEAMCITY_VERSION', + 'APPVEYOR', + 'CODEBUILD_BUILD_ID', + 'BITBUCKET_BUILD_NUMBER' +]; + +/** + * Returns `true` if an agent or CI environment variable is active. + * + * @remarks + * A variable is active when defined and not equal, case-insensitively, to an + * empty string, `0`, `false`, `no`, or `off`. + * + * @param value - the environment variable value + * + * @beta + */ +export function isAgentVariableActive(value: string | undefined): boolean { + if (value === undefined) { + return false; + } + const normalized: string = value.trim().toLowerCase(); + return !( + normalized === '' || + normalized === '0' || + normalized === 'false' || + normalized === 'no' || + normalized === 'off' + ); +} + +/** + * Returns `true` if an agent is detected from `COPILOT_CLI` or a configured + * agent environment variable. + * + * @param env - the environment variables + * @param configuredVariables - agent variable names configured in rush.json + * + * @beta + */ +export function detectAgent( + env: Record, + configuredVariables: readonly string[] = [] +): boolean { + if (isAgentVariableActive(env[COPILOT_CLI_ENV_VAR])) { + return true; + } + for (const name of configuredVariables) { + if (isAgentVariableActive(env[name])) { + return true; + } + } + return false; +} + +/** + * Returns `true` if a recognized CI environment is detected. + * + * @param env - the environment variables + * + * @beta + */ +export function isCiDetected(env: Record): boolean { + for (const name of KNOWN_CI_ENV_VARS) { + if (isAgentVariableActive(env[name])) { + return true; + } + } + return false; +} diff --git a/libraries/reporter/src/config/OutputControl.ts b/libraries/reporter/src/config/OutputControl.ts new file mode 100644 index 0000000000..4ceb9ea917 --- /dev/null +++ b/libraries/reporter/src/config/OutputControl.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * A parsed `--output` control target. + * + * @beta + */ +export interface IReporterOutputTarget { + /** + * The reporter scheme, for example `file` or `json`. + */ + readonly reporter: string; + + /** + * The destination target, for example a file path. + */ + readonly target: string; + + /** + * The query parameters, for example `logLevel=debug`. + */ + readonly params: { readonly [key: string]: string }; +} + +const OUTPUT_CONTROL_REGEXP: RegExp = /^([a-z][a-z0-9]*):\/\/(.*)$/i; + +/** + * Parses a `--output` control of the form `://?key=value`. + * + * @param value - the raw `--output` value + * @throws Error if the value is not a valid output control + * + * @beta + */ +export function parseOutputControl(value: string): IReporterOutputTarget { + const match: RegExpExecArray | null = OUTPUT_CONTROL_REGEXP.exec(value); + if (!match) { + throw new Error(`Invalid --output control: ${JSON.stringify(value)}`); + } + + const reporter: string = match[1]; + const rest: string = match[2]; + const params: { [key: string]: string } = {}; + + let target: string = rest; + const queryIndex: number = rest.indexOf('?'); + if (queryIndex >= 0) { + target = rest.slice(0, queryIndex); + for (const pair of rest.slice(queryIndex + 1).split('&')) { + if (pair.length === 0) { + continue; + } + const equalsIndex: number = pair.indexOf('='); + if (equalsIndex >= 0) { + params[pair.slice(0, equalsIndex)] = pair.slice(equalsIndex + 1); + } else { + params[pair] = ''; + } + } + } + + return { reporter, target, params }; +} diff --git a/libraries/reporter/src/config/ReporterNames.ts b/libraries/reporter/src/config/ReporterNames.ts new file mode 100644 index 0000000000..b463dedc81 --- /dev/null +++ b/libraries/reporter/src/config/ReporterNames.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. + +/** + * The name of a built-in reporter. + * + * @beta + */ +export type ReporterName = 'default' | 'ai' | 'json' | 'plaintext' | 'file' | 'legacy'; + +/** + * A reporter log level, in order of increasing verbosity. + * + * @beta + */ +export type ReporterLogLevel = 'quiet' | 'normal' | 'verbose' | 'debug'; + +/** + * The built-in reporter names. + * + * @beta + */ +export const SUPPORTED_REPORTER_NAMES: readonly ReporterName[] = [ + 'default', + 'ai', + 'json', + 'plaintext', + 'file', + 'legacy' +]; + +/** + * The supported log levels. + * + * @beta + */ +export const SUPPORTED_LOG_LEVELS: readonly ReporterLogLevel[] = ['quiet', 'normal', 'verbose', 'debug']; + +/** + * Returns `true` if `name` is a supported reporter name. + * + * @beta + */ +export function isSupportedReporterName(name: string): name is ReporterName { + return (SUPPORTED_REPORTER_NAMES as readonly string[]).includes(name); +} + +/** + * Returns `true` if `level` is a supported log level. + * + * @beta + */ +export function isSupportedLogLevel(level: string): level is ReporterLogLevel { + return (SUPPORTED_LOG_LEVELS as readonly string[]).includes(level); +} diff --git a/libraries/reporter/src/config/ReporterSelection.ts b/libraries/reporter/src/config/ReporterSelection.ts new file mode 100644 index 0000000000..ec275d4497 --- /dev/null +++ b/libraries/reporter/src/config/ReporterSelection.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + isSupportedReporterName, + isSupportedLogLevel, + type ReporterName, + type ReporterLogLevel +} from './ReporterNames'; +import { detectAgent, isCiDetected } from './AgentDetection'; +import { parseOutputControl, type IReporterOutputTarget } from './OutputControl'; +import { separateJsonControls } from '../exit/CommandJson'; + +/** + * The inputs used to resolve the reporter selection. + * + * @beta + */ +export interface IReporterSelectionInput { + /** + * The command-line arguments, excluding the node executable and script. + */ + readonly argv: readonly string[]; + + /** + * The environment variables. + */ + readonly env: Record; + + /** + * Whether the output stream is an interactive TTY. + */ + readonly isTTY: boolean; + + /** + * Agent environment variable names configured in rush.json. + */ + readonly agentEnvironmentVariables?: readonly string[]; +} + +/** + * The resolved reporter selection. + * + * @beta + */ +export interface IReporterSelection { + /** + * The primary reporter. + */ + readonly primaryReporter: ReporterName; + + /** + * The additional reporters, such as the full-detail file reporter. + */ + readonly additionalReporters: readonly ReporterName[]; + + /** + * The resolved log level. + */ + readonly logLevel: ReporterLogLevel; + + /** + * A short explanation of why the primary reporter was chosen. + */ + readonly reason: string; + + /** + * The explicit `--output` targets. + */ + readonly outputs: readonly IReporterOutputTarget[]; + + /** + * Whether the command-specific `--json` flag was requested. It never selects + * the json reporter. + */ + readonly commandJson: boolean; +} + +function readFlagValue(argv: readonly string[], flag: string): string | undefined { + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg.startsWith(prefix)) { + return arg.slice(prefix.length); + } + if (arg === flag && index + 1 < argv.length) { + return argv[index + 1]; + } + } + return undefined; +} + +function readAllFlagValues(argv: readonly string[], flag: string): string[] { + const prefix: string = `${flag}=`; + const values: string[] = []; + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg.startsWith(prefix)) { + values.push(arg.slice(prefix.length)); + } else if (arg === flag && index + 1 < argv.length) { + values.push(argv[index + 1]); + } + } + return values; +} + +function resolvePrimaryReporter(input: IReporterSelectionInput): { reporter: ReporterName; reason: string } { + const cliReporter: string | undefined = readFlagValue(input.argv, '--reporter'); + if (cliReporter !== undefined) { + if (!isSupportedReporterName(cliReporter)) { + throw new Error(`Unsupported reporter requested with --reporter: ${JSON.stringify(cliReporter)}`); + } + return { reporter: cliReporter, reason: 'explicit --reporter' }; + } + + const envReporter: string | undefined = input.env.RUSH_REPORTER; + if (envReporter !== undefined && envReporter.length > 0) { + if (!isSupportedReporterName(envReporter)) { + throw new Error(`Unsupported reporter requested with RUSH_REPORTER: ${JSON.stringify(envReporter)}`); + } + return { reporter: envReporter, reason: 'RUSH_REPORTER' }; + } + + if (detectAgent(input.env, input.agentEnvironmentVariables)) { + return { reporter: 'ai', reason: 'agent detected' }; + } + if (isCiDetected(input.env)) { + return { reporter: 'plaintext', reason: 'CI detected' }; + } + if (input.isTTY) { + return { reporter: 'default', reason: 'interactive TTY' }; + } + return { reporter: 'plaintext', reason: 'generic non-TTY' }; +} + +function resolveLogLevel(argv: readonly string[], env: Record): ReporterLogLevel { + const signals: ReporterLogLevel[] = []; + + const cliLevel: string | undefined = readFlagValue(argv, '--log-level'); + if (cliLevel !== undefined) { + if (!isSupportedLogLevel(cliLevel)) { + throw new Error(`Unsupported log level requested with --log-level: ${JSON.stringify(cliLevel)}`); + } + signals.push(cliLevel); + } + if (argv.includes('--quiet') || argv.includes('-q')) { + signals.push('quiet'); + } + if (argv.includes('--verbose')) { + signals.push('verbose'); + } + if (argv.includes('--debug')) { + signals.push('debug'); + } + + const distinct: Set = new Set(signals); + if (distinct.size > 1) { + throw new Error(`Contradictory log level controls: ${[...distinct].sort().join(', ')}`); + } + if (distinct.size === 1) { + return signals[0]; + } + + const envLevel: string | undefined = env.RUSH_LOG_LEVEL; + if (envLevel !== undefined && envLevel.length > 0) { + if (!isSupportedLogLevel(envLevel)) { + throw new Error(`Unsupported log level requested with RUSH_LOG_LEVEL: ${JSON.stringify(envLevel)}`); + } + return envLevel; + } + + return 'normal'; +} + +/** + * Resolves the reporter selection from the command line and environment. + * + * @remarks + * Precedence for the primary reporter runs from explicit CLI controls, through + * `RUSH_REPORTER`, agent detection, CI detection, and interactive TTY, down to + * generic non-TTY plaintext. The log level is resolved independently, with the + * `--quiet`, `--verbose`, and `--debug` aliases mapping to levels; + * contradictory verbosity controls are rejected. The command-specific `--json` + * flag is preserved and never selects the json reporter. Explicit unsupported + * reporter or log-level requests fail. + * + * @param input - the command line, environment, and TTY state + * + * @beta + */ +export function resolveReporterSelection(input: IReporterSelectionInput): IReporterSelection { + const { reporter, reason } = resolvePrimaryReporter(input); + const logLevel: ReporterLogLevel = resolveLogLevel(input.argv, input.env); + const outputs: IReporterOutputTarget[] = readAllFlagValues(input.argv, '--output').map(parseOutputControl); + const additionalReporters: ReporterName[] = reporter === 'file' ? [] : ['file']; + const commandJson: boolean = separateJsonControls(input.argv).commandJson; + + return { + primaryReporter: reporter, + additionalReporters, + logLevel, + reason, + outputs, + commandJson + }; +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index a13aaf1b37..47be9fd7ce 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -165,6 +165,25 @@ export { export type { IJsonControls } from './exit/CommandJson'; export { separateJsonControls } from './exit/CommandJson'; +export type { ReporterName, ReporterLogLevel } from './config/ReporterNames'; +export { + SUPPORTED_REPORTER_NAMES, + SUPPORTED_LOG_LEVELS, + isSupportedReporterName, + isSupportedLogLevel +} from './config/ReporterNames'; +export { + COPILOT_CLI_ENV_VAR, + KNOWN_CI_ENV_VARS, + isAgentVariableActive, + detectAgent, + isCiDetected +} from './config/AgentDetection'; +export type { IReporterOutputTarget } from './config/OutputControl'; +export { parseOutputControl } from './config/OutputControl'; +export type { IReporterSelectionInput, IReporterSelection } from './config/ReporterSelection'; +export { resolveReporterSelection } from './config/ReporterSelection'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/ReporterSelection.test.ts b/libraries/reporter/src/test/ReporterSelection.test.ts new file mode 100644 index 0000000000..a3aead3d2f --- /dev/null +++ b/libraries/reporter/src/test/ReporterSelection.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 { + resolveReporterSelection, + parseOutputControl, + isAgentVariableActive, + type IReporterOutputTarget, + type IReporterSelection +} from '../index'; + +function selection( + argv: string[], + env: Record = {}, + isTTY: boolean = false, + agentEnvironmentVariables: string[] = [] +): IReporterSelection { + return resolveReporterSelection({ argv, env, isTTY, agentEnvironmentVariables }); +} + +describe('resolveReporterSelection primary reporter precedence', () => { + it('honors an explicit --reporter over everything else', () => { + const result: IReporterSelection = selection(['build', '--reporter=ai'], { CI: 'true' }, true); + expect(result.primaryReporter).toBe('ai'); + expect(result.reason).toBe('explicit --reporter'); + }); + + it('uses RUSH_REPORTER over agent, CI, and TTY', () => { + const result: IReporterSelection = selection( + ['build'], + { RUSH_REPORTER: 'json', COPILOT_CLI: '1' }, + true + ); + expect(result.primaryReporter).toBe('json'); + }); + + it('selects the ai reporter when an agent is detected', () => { + expect(selection(['build'], { COPILOT_CLI: '1' }).primaryReporter).toBe('ai'); + expect(selection(['build'], { MY_AGENT: 'yes' }, false, ['MY_AGENT']).primaryReporter).toBe('ai'); + }); + + it('prefers agent detection over CI', () => { + const result: IReporterSelection = selection(['build'], { COPILOT_CLI: '1', CI: 'true' }); + expect(result.primaryReporter).toBe('ai'); + }); + + it('selects plaintext for CI, default for TTY, and plaintext for generic non-TTY', () => { + expect(selection(['build'], { CI: 'true' }).primaryReporter).toBe('plaintext'); + expect(selection(['build'], {}, true).primaryReporter).toBe('default'); + expect(selection(['build'], {}, false).primaryReporter).toBe('plaintext'); + }); + + it('adds the file reporter except when the primary is already file', () => { + expect(selection(['build'], {}, true).additionalReporters).toEqual(['file']); + expect(selection(['build', '--reporter=file']).additionalReporters).toEqual([]); + }); + + it('fails an explicit unsupported reporter request', () => { + expect(() => selection(['build', '--reporter=bogus'])).toThrow(/Unsupported reporter/); + expect(() => selection(['build'], { RUSH_REPORTER: 'bogus' })).toThrow(/RUSH_REPORTER/); + }); +}); + +describe('resolveReporterSelection log level', () => { + it('resolves explicit --log-level, aliases, and RUSH_LOG_LEVEL, defaulting to normal', () => { + expect(selection(['build', '--log-level=verbose']).logLevel).toBe('verbose'); + expect(selection(['build', '--quiet']).logLevel).toBe('quiet'); + expect(selection(['build', '--verbose']).logLevel).toBe('verbose'); + expect(selection(['build', '--debug']).logLevel).toBe('debug'); + expect(selection(['build'], { RUSH_LOG_LEVEL: 'debug' }).logLevel).toBe('debug'); + expect(selection(['build']).logLevel).toBe('normal'); + }); + + it('accepts repeated identical verbosity but rejects contradictions', () => { + expect(selection(['build', '--verbose', '--verbose']).logLevel).toBe('verbose'); + expect(() => selection(['build', '--quiet', '--verbose'])).toThrow(/Contradictory/); + expect(() => selection(['build', '--log-level=quiet', '--verbose'])).toThrow(/Contradictory/); + }); + + it('fails an unsupported log level', () => { + expect(() => selection(['build', '--log-level=loud'])).toThrow(/Unsupported log level/); + }); +}); + +describe('resolveReporterSelection command json and outputs', () => { + it('keeps command-specific --json from selecting the json reporter', () => { + const result: IReporterSelection = selection(['list', '--json'], {}, true); + expect(result.primaryReporter).toBe('default'); + expect(result.commandJson).toBe(true); + }); + + it('parses --output targets', () => { + const result: IReporterSelection = selection([ + 'build', + '--output=file://./rush-debug.log?logLevel=debug', + '--output=json://./events.jsonl' + ]); + expect(result.outputs).toEqual([ + { reporter: 'file', target: './rush-debug.log', params: { logLevel: 'debug' } }, + { reporter: 'json', target: './events.jsonl', params: {} } + ]); + }); +}); + +describe('parseOutputControl', () => { + it('parses reporter, target, and params', () => { + expect(parseOutputControl('file://./x.log?a=1&b=2')).toEqual({ + reporter: 'file', + target: './x.log', + params: { a: '1', b: '2' } + }); + }); + + it('throws on an invalid output control', () => { + expect(() => parseOutputControl('not-a-url')).toThrow(/Invalid --output/); + }); +}); + +describe('isAgentVariableActive', () => { + it('treats defined non-falsey values as active', () => { + expect(isAgentVariableActive('1')).toBe(true); + expect(isAgentVariableActive('true')).toBe(true); + expect(isAgentVariableActive('copilot')).toBe(true); + }); + + it('treats undefined and falsey values as inactive', () => { + expect(isAgentVariableActive(undefined)).toBe(false); + expect(isAgentVariableActive('')).toBe(false); + expect(isAgentVariableActive('0')).toBe(false); + expect(isAgentVariableActive('false')).toBe(false); + expect(isAgentVariableActive('FALSE')).toBe(false); + expect(isAgentVariableActive('no')).toBe(false); + expect(isAgentVariableActive('off')).toBe(false); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 5439ccdeec..e4e76a15ac 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -179,7 +179,7 @@ "Keep command-specific --json unchanged and not an alias for --reporter=json", "Fail explicit unsupported reporter requests" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index eb21e760bb..b3d9f6f9b6 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -230,3 +230,20 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - resolveExitStatus (success/warning-only/failure/cancel/signal precedence), signal codes, from-events (warning/error/op-failure/failed-result/category-independence/cancel/signal), separateJsonControls (5 cases) tests pass; all exports @beta Next: Feature 15/28 - Reporter selection + configuration controls with precedence + +[2026-07-14] Feature 15/28 COMPLETE: Reporter selection + configuration controls with precedence + Files (new): + - config/ReporterNames.ts (ReporterName default|ai|json|plaintext|file|legacy; ReporterLogLevel quiet|normal|verbose|debug; SUPPORTED_* + isSupported* guards) + - config/AgentDetection.ts (COPILOT_CLI_ENV_VAR, KNOWN_CI_ENV_VARS, isAgentVariableActive [inactive if undefined/''/0/false/no/off case-insens], detectAgent(env,configuredVars), isCiDetected) + - config/OutputControl.ts (IReporterOutputTarget, parseOutputControl '://?k=v') + - config/ReporterSelection.ts (resolveReporterSelection: primary precedence CLI --reporter > RUSH_REPORTER > agent > CI > TTY > non-TTY plaintext; log level independent CLI/aliases/RUSH_LOG_LEVEL default normal; contradiction throws; additional=['file'] unless primary file; commandJson preserved; unsupported reporter/level throws) + - test/ReporterSelection.test.ts + Files (modified): index.ts, api.md + Notes: + - Full resolver (Feature 8 was early subset). Orthogonal axes: reporter (format) vs log level (severity) per pnpm memory. + - Matrix: agent->ai+file, CI->plaintext+file, TTY->default+file, non-TTY->plaintext+file. + - --json (command) never aliases --reporter=json. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - primary precedence (explicit/env/agent/CI/TTY/non-TTY), additional file, unsupported-reporter throw, log-level aliases + contradiction + unsupported, command-json preserved, --output parse, agent-active semantics tests pass; all exports @beta + Next: Feature 16/28 - Independent per-reporter log levels From 009429918bd18b91e0e710018c27469b1267c733 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:41:13 +0000 Subject: [PATCH 02/22] Add rush change file for reporter selection Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-41-00.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json new file mode 100644 index 0000000000..5a59691a17 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add reporter selection with precedence: reporter names and log levels, agent and CI detection, --output parsing, and resolveReporterSelection that resolves the primary reporter and log level from CLI controls and the environment", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 55244024901411bb08d53473fb0bc6a1370c5626 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:45:02 +0000 Subject: [PATCH 03/22] Add independent per-reporter log-level filtering Add per-reporter log-level filtering for @rushstack/reporter (#5858). - Classify each event to a minimum log level and add shouldRenderAtLogLevel and filterEventsForLogLevel so each reporter applies quiet, normal, verbose, or debug filtering independently - Keep diagnostic severity separate from the reporter log level: severity sets the minimum level while the reporter's configured level gates rendering - Default the full-detail file reporter to debug - Cover ranking, classification, per-level rendering, monotonicity, and the severity separation with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 15 ++ .../reporter/src/config/LogLevelFilter.ts | 118 +++++++++++++++ libraries/reporter/src/index.ts | 7 + .../reporter/src/test/LogLevelFilter.test.ts | 137 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 14 ++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/config/LogLevelFilter.ts create mode 100644 libraries/reporter/src/test/LogLevelFilter.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index f9ebf0150b..f5a38e3d8d 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -89,6 +89,18 @@ export const EXIT_CODE_FAILURE: 1; // @beta export const EXIT_CODE_SUCCESS: 0; +// @beta +export const FILE_REPORTER_DEFAULT_LOG_LEVEL: ReporterLogLevel; + +// @beta +export function filterEventsForLogLevel(logLevel: ReporterLogLevel, events: readonly IReporterEventEnvelope[]): IReporterEventEnvelope[]; + +// @beta +export function getEventMinimumLogLevel(event: IReporterEventEnvelope): ReporterLogLevel; + +// @beta +export function getLogLevelRank(level: ReporterLogLevel): number; + // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; @@ -803,6 +815,9 @@ export class RushSessionReporting { // @beta export function separateJsonControls(argv: readonly string[]): IJsonControls; +// @beta +export function shouldRenderAtLogLevel(logLevel: ReporterLogLevel, event: IReporterEventEnvelope): boolean; + // @beta export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; diff --git a/libraries/reporter/src/config/LogLevelFilter.ts b/libraries/reporter/src/config/LogLevelFilter.ts new file mode 100644 index 0000000000..72e84620fe --- /dev/null +++ b/libraries/reporter/src/config/LogLevelFilter.ts @@ -0,0 +1,118 @@ +// 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 { ReporterLogLevel } from './ReporterNames'; + +const LOG_LEVEL_RANK: { readonly [level in ReporterLogLevel]: number } = { + quiet: 0, + normal: 1, + verbose: 2, + debug: 3 +}; + +/** + * The log level the full-detail file reporter uses by default. + * + * @beta + */ +export const FILE_REPORTER_DEFAULT_LOG_LEVEL: ReporterLogLevel = 'debug'; + +/** + * Returns the numeric rank of a log level, where a larger number is more verbose. + * + * @beta + */ +export function getLogLevelRank(level: ReporterLogLevel): number { + return LOG_LEVEL_RANK[level]; +} + +/** + * Returns the minimum log level at which an event is rendered. + * + * @remarks + * - `quiet` renders failures, required warnings, and the final result. + * - `normal` adds standard lifecycle, progress, and all diagnostics. + * - `verbose` adds detailed operation and external-process activity. + * - `debug` adds protocol, cache, internal, and debug-message details. + * + * The classification uses the event type and, for diagnostics, its severity, but + * a reporter's log level and a diagnostic's severity remain separate axes. + * + * @param event - the event to classify + * + * @beta + */ +export function getEventMinimumLogLevel(event: IReporterEventEnvelope): ReporterLogLevel { + switch (event.type) { + case 'commandResult': + return 'quiet'; + case 'diagnosticEmitted': { + const severity: string | undefined = (event.payload as { severity?: string }).severity; + if (severity === 'error') { + return 'quiet'; + } + if (severity === 'warning') { + return event.required ? 'quiet' : 'normal'; + } + return 'normal'; + } + case 'sessionStarted': + case 'sessionCompleted': + case 'commandStarted': + case 'commandCompleted': + case 'operationRegistered': + case 'operationStatusChanged': + case 'watchCycleCompleted': + case 'artifactAvailable': + return 'normal'; + case 'activityChanged': { + const payload: { kind?: string; severity?: string } = event.payload as { + kind?: string; + severity?: string; + }; + if (payload.kind === 'message' && payload.severity === 'debug') { + return 'debug'; + } + return 'normal'; + } + case 'externalProcessStarted': + case 'externalProcessCompleted': + case 'externalOutput': + return 'verbose'; + case 'extension': + return event.required ? 'normal' : 'debug'; + default: + return 'normal'; + } +} + +/** + * Returns `true` if a reporter at `logLevel` should render `event`. + * + * @param logLevel - the reporter's configured log level + * @param event - the event to test + * + * @beta + */ +export function shouldRenderAtLogLevel( + logLevel: ReporterLogLevel, + event: IReporterEventEnvelope +): boolean { + return LOG_LEVEL_RANK[logLevel] >= LOG_LEVEL_RANK[getEventMinimumLogLevel(event)]; +} + +/** + * Filters an event stream to those an event a reporter at `logLevel` renders. + * + * @param logLevel - the reporter's configured log level + * @param events - the events to filter + * + * @beta + */ +export function filterEventsForLogLevel( + logLevel: ReporterLogLevel, + events: readonly IReporterEventEnvelope[] +): IReporterEventEnvelope[] { + return events.filter((event: IReporterEventEnvelope) => shouldRenderAtLogLevel(logLevel, event)); +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 47be9fd7ce..aeb5f07524 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -183,6 +183,13 @@ export type { IReporterOutputTarget } from './config/OutputControl'; export { parseOutputControl } from './config/OutputControl'; export type { IReporterSelectionInput, IReporterSelection } from './config/ReporterSelection'; export { resolveReporterSelection } from './config/ReporterSelection'; +export { + FILE_REPORTER_DEFAULT_LOG_LEVEL, + getLogLevelRank, + getEventMinimumLogLevel, + shouldRenderAtLogLevel, + filterEventsForLogLevel +} from './config/LogLevelFilter'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/test/LogLevelFilter.test.ts b/libraries/reporter/src/test/LogLevelFilter.test.ts new file mode 100644 index 0000000000..d87132f19f --- /dev/null +++ b/libraries/reporter/src/test/LogLevelFilter.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + shouldRenderAtLogLevel, + getEventMinimumLogLevel, + getLogLevelRank, + filterEventsForLogLevel, + FILE_REPORTER_DEFAULT_LOG_LEVEL, + type IReporterEventEnvelope, + type ReporterLogLevel +} from '../index'; + +function ev(type: string, payload: unknown = {}, required: boolean = false): IReporterEventEnvelope { + return { type, payload, required } as unknown as IReporterEventEnvelope; +} + +const ALL_LEVELS: readonly ReporterLogLevel[] = ['quiet', 'normal', 'verbose', 'debug']; + +describe('getLogLevelRank', () => { + it('orders the levels from quiet to debug', () => { + expect(getLogLevelRank('quiet')).toBe(0); + expect(getLogLevelRank('normal')).toBe(1); + expect(getLogLevelRank('verbose')).toBe(2); + expect(getLogLevelRank('debug')).toBe(3); + }); +}); + +describe('getEventMinimumLogLevel', () => { + it('classifies failures, required warnings, and results as quiet', () => { + expect(getEventMinimumLogLevel(ev('commandResult', { succeeded: false, exitCode: 1 }))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'error' }))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'warning' }, true))).toBe('quiet'); + }); + + it('classifies standard lifecycle and non-required warnings as normal', () => { + expect(getEventMinimumLogLevel(ev('commandStarted', { commandName: 'build' }))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('operationStatusChanged', { status: 'success' }))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe('normal'); + }); + + it('classifies external activity as verbose and debug details as debug', () => { + expect(getEventMinimumLogLevel(ev('externalOutput', { stream: 'stdout', text: 'x' }))).toBe('verbose'); + expect(getEventMinimumLogLevel(ev('externalProcessStarted', {}))).toBe('verbose'); + expect( + getEventMinimumLogLevel(ev('activityChanged', { kind: 'message', severity: 'debug', text: 'd' })) + ).toBe('debug'); + expect(getEventMinimumLogLevel(ev('extension', { name: 'a.b' }, false))).toBe('debug'); + }); +}); + +describe('shouldRenderAtLogLevel', () => { + it('renders only failures, required warnings, and the result at quiet', () => { + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'error' }))).toBe(true); + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'warning' }, true))).toBe( + true + ); + expect(shouldRenderAtLogLevel('quiet', ev('commandResult', { succeeded: true, exitCode: 0 }))).toBe(true); + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe( + false + ); + expect(shouldRenderAtLogLevel('quiet', ev('commandStarted', {}))).toBe(false); + expect(shouldRenderAtLogLevel('quiet', ev('externalOutput', {}))).toBe(false); + }); + + it('adds lifecycle and diagnostics at normal but not external or debug detail', () => { + expect(shouldRenderAtLogLevel('normal', ev('commandStarted', {}))).toBe(true); + expect(shouldRenderAtLogLevel('normal', ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe( + true + ); + expect(shouldRenderAtLogLevel('normal', ev('externalOutput', {}))).toBe(false); + expect( + shouldRenderAtLogLevel('normal', ev('activityChanged', { kind: 'message', severity: 'debug' })) + ).toBe(false); + }); + + it('adds external activity at verbose and everything at debug', () => { + expect(shouldRenderAtLogLevel('verbose', ev('externalOutput', {}))).toBe(true); + expect( + shouldRenderAtLogLevel('verbose', ev('activityChanged', { kind: 'message', severity: 'debug' })) + ).toBe(false); + expect( + shouldRenderAtLogLevel('debug', ev('activityChanged', { kind: 'message', severity: 'debug' })) + ).toBe(true); + expect(shouldRenderAtLogLevel('debug', ev('extension', { name: 'a.b' }, false))).toBe(true); + }); + + it('is monotonic: an event shown at a level is shown at every higher level', () => { + const events: IReporterEventEnvelope[] = [ + ev('commandResult', { succeeded: true, exitCode: 0 }), + ev('diagnosticEmitted', { severity: 'error' }), + ev('diagnosticEmitted', { severity: 'warning' }, false), + ev('commandStarted', {}), + ev('externalOutput', {}), + ev('activityChanged', { kind: 'message', severity: 'debug' }) + ]; + for (const event of events) { + let seen: boolean = false; + for (const level of ALL_LEVELS) { + const rendered: boolean = shouldRenderAtLogLevel(level, event); + if (seen) { + expect(rendered).toBe(true); + } + seen = seen || rendered; + } + } + }); + + it('keeps diagnostic severity separate from the reporter log level', () => { + // Same severity, different reporter level flips visibility; the severity itself is unchanged. + const warning: IReporterEventEnvelope = ev('diagnosticEmitted', { severity: 'warning' }, false); + expect(shouldRenderAtLogLevel('quiet', warning)).toBe(false); + expect(shouldRenderAtLogLevel('normal', warning)).toBe(true); + // An error at quiet is shown, a non-required warning is not — driven by severity, gated by level. + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'error' }))).toBe(true); + }); +}); + +describe('filterEventsForLogLevel and file reporter default', () => { + it('filters a mixed stream to the level', () => { + const stream: IReporterEventEnvelope[] = [ + ev('commandStarted', {}), + ev('externalOutput', {}), + ev('commandResult', { succeeded: true, exitCode: 0 }) + ]; + expect(filterEventsForLogLevel('quiet', stream).map((e) => e.type)).toEqual(['commandResult']); + expect(filterEventsForLogLevel('verbose', stream).map((e) => e.type)).toEqual([ + 'commandStarted', + 'externalOutput', + 'commandResult' + ]); + }); + + it('defaults the full-detail file reporter to debug', () => { + expect(FILE_REPORTER_DEFAULT_LOG_LEVEL).toBe('debug'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index e4e76a15ac..d561e09860 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -190,7 +190,7 @@ "Default the full-detail file reporter to debug", "Add log-level filtering tests" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index b3d9f6f9b6..fd20ac501f 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -247,3 +247,17 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - primary precedence (explicit/env/agent/CI/TTY/non-TTY), additional file, unsupported-reporter throw, log-level aliases + contradiction + unsupported, command-json preserved, --output parse, agent-active semantics tests pass; all exports @beta Next: Feature 16/28 - Independent per-reporter log levels + +[2026-07-14] Feature 16/28 COMPLETE: Independent per-reporter log levels + Files (new): + - config/LogLevelFilter.ts (LOG_LEVEL_RANK quiet0/normal1/verbose2/debug3; getEventMinimumLogLevel classifies events; shouldRenderAtLogLevel; filterEventsForLogLevel; FILE_REPORTER_DEFAULT_LOG_LEVEL='debug') + - test/LogLevelFilter.test.ts + Files (modified): index.ts, api.md + Classification (min level per §5.7): commandResult=quiet; diagnostic error=quiet, warning required=quiet else normal; lifecycle/operation/artifact=normal; activityChanged debug-message=debug else normal; externalOutput/externalProcess*=verbose; extension required=normal else debug. + Notes: + - Diagnostic severity separate from log level: severity sets min-level, reporter's configured level gates rendering. Test flips warning visibility by level, not severity. + - Monotonic: shown at level L -> shown at all higher levels. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - rank, classification, per-level rendering (quiet/normal/verbose/debug), monotonicity, severity-separation, filter, file-default tests pass; all exports @beta + Next: Feature 17/28 - Automatic reporter selection matrix by environment From 26e3a1363625bf152adb99c0640e09c4927e6c45 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:45:25 +0000 Subject: [PATCH 04/22] Add rush change file for per-reporter log levels Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-45-15.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json new file mode 100644 index 0000000000..d7e107da87 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add independent per-reporter log-level filtering (quiet/normal/verbose/debug) with event classification, keeping diagnostic severity separate, and default the file reporter to debug", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 8d790ceb075a82edfeaf99ec4bc869beb4df3e75 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:49:56 +0000 Subject: [PATCH 05/22] Add automatic reporter selection matrix Resolve the automatic reporter plan by environment for @rushstack/reporter (#5858). - Add planAutomaticReporters, which pairs ai with file for an agent, detailed plaintext with file for CI, default with file for an interactive TTY, and concise plaintext with file otherwise - Give machine reporters (ai, json) exclusive stdout and route human progress to stderr, with emergency diagnostics always on stderr - Add describeReporterPlan to record the selection reason and reporters in the detailed log - Cover the full matrix, machine stdout ownership, and the plan description with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 31 ++++ .../src/config/AutomaticReporterMatrix.ts | 163 ++++++++++++++++++ libraries/reporter/src/index.ts | 10 ++ .../src/test/AutomaticReporterMatrix.test.ts | 86 +++++++++ research/feature-list.json | 2 +- research/progress.txt | 15 ++ 6 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/config/AutomaticReporterMatrix.ts create mode 100644 libraries/reporter/src/test/AutomaticReporterMatrix.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index f5a38e3d8d..cd3a8a7d5b 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -77,6 +77,9 @@ export function deleteBootstrapHandoffFileAsync(filePath: string): Promise // @beta export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number; +// @beta +export function describeReporterPlan(plan: IAutomaticReporterPlan): string; + // @beta export function detectAgent(env: Record, configuredVariables?: readonly string[]): boolean; @@ -107,6 +110,16 @@ export function getPrivacyClassificationRank(classification: ReporterPrivacyClas // @beta export function getSignalExitCode(signal: NodeJS.Signals): number; +// @beta +export interface IAutomaticReporterPlan { + readonly emergencyDestination: 'stderr'; + readonly entries: readonly IReporterPlanEntry[]; + readonly humanProgressDestination: 'stdout' | 'stderr'; + readonly primary: IReporterPlanEntry; + readonly reason: string; + readonly stdoutOwner: 'machine' | 'human'; +} + // @beta export interface IBootstrapEventBufferOptions { readonly maxBytes?: number; @@ -393,6 +406,15 @@ export interface IReporterOutputTarget { readonly target: string; } +// @beta +export interface IReporterPlanEntry { + readonly destination: string; + readonly machine: boolean; + readonly reporter: ReporterName; + readonly role: 'primary' | 'additional'; + readonly variant?: PlaintextVariant; +} + // @beta export interface IReporterProtocolLimits { readonly bootstrapBufferBytes: number; @@ -560,6 +582,9 @@ export interface IShadowResultSummary { readonly succeeded: boolean; } +// @beta +export function isMachineReporter(reporter: ReporterName): boolean; + // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; @@ -673,6 +698,12 @@ export function parseEarlyReporterControls(argv: readonly string[], env: Record< // @beta export function parseOutputControl(value: string): IReporterOutputTarget; +// @beta +export type PlaintextVariant = 'detailed' | 'concise'; + +// @beta +export function planAutomaticReporters(selection: IReporterSelection): IAutomaticReporterPlan; + // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; diff --git a/libraries/reporter/src/config/AutomaticReporterMatrix.ts b/libraries/reporter/src/config/AutomaticReporterMatrix.ts new file mode 100644 index 0000000000..17cefd40b9 --- /dev/null +++ b/libraries/reporter/src/config/AutomaticReporterMatrix.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ReporterName } from './ReporterNames'; +import type { IReporterSelection } from './ReporterSelection'; + +/** + * A plaintext rendering variant. + * + * @remarks + * CI uses the `detailed` variant, which retains StreamCollator-like operation + * grouping; other non-TTY environments use the `concise` variant. + * + * @beta + */ +export type PlaintextVariant = 'detailed' | 'concise'; + +/** + * A single reporter in an automatic selection plan. + * + * @beta + */ +export interface IReporterPlanEntry { + /** + * The reporter name. + */ + readonly reporter: ReporterName; + + /** + * Whether this is the primary reporter or an additional one. + */ + readonly role: 'primary' | 'additional'; + + /** + * The destination the reporter owns, for example `stdout` or `file`. + */ + readonly destination: string; + + /** + * The plaintext variant, when the reporter is plaintext. + */ + readonly variant?: PlaintextVariant; + + /** + * Whether this is a machine reporter whose stdout carries payload records only. + */ + readonly machine: boolean; +} + +/** + * A resolved automatic reporter plan. + * + * @beta + */ +export interface IAutomaticReporterPlan { + /** + * All reporters in the plan, primary first. + */ + readonly entries: readonly IReporterPlanEntry[]; + + /** + * The primary reporter. + */ + readonly primary: IReporterPlanEntry; + + /** + * Whether stdout is owned by a machine reporter (payload only) or a human reporter. + */ + readonly stdoutOwner: 'machine' | 'human'; + + /** + * Where human progress is written. Machine modes route it to stderr. + */ + readonly humanProgressDestination: 'stdout' | 'stderr'; + + /** + * Where emergency diagnostics are written. + */ + readonly emergencyDestination: 'stderr'; + + /** + * The reason the primary reporter was selected, recorded in the detailed log. + */ + readonly reason: string; +} + +/** + * Returns `true` if the reporter is a machine reporter that owns stdout exclusively. + * + * @beta + */ +export function isMachineReporter(reporter: ReporterName): boolean { + return reporter === 'json' || reporter === 'ai'; +} + +function toEntry(reporter: ReporterName, role: 'primary' | 'additional', reason: string): IReporterPlanEntry { + const machine: boolean = isMachineReporter(reporter); + const variant: PlaintextVariant | undefined = + reporter === 'plaintext' ? (reason === 'CI detected' ? 'detailed' : 'concise') : undefined; + return { + reporter, + role, + destination: reporter === 'file' ? 'file' : 'stdout', + variant, + machine + }; +} + +/** + * Builds the automatic reporter plan from a resolved selection. + * + * @remarks + * The matrix pairs `ai` with `file` for an agent, detailed `plaintext` with + * `file` for CI, `default` with `file` for an interactive TTY, and concise + * `plaintext` with `file` otherwise. When the primary is a machine reporter it + * owns stdout exclusively and human progress moves to stderr. Emergency + * diagnostics always use stderr. + * + * @param selection - the resolved reporter selection + * + * @beta + */ +export function planAutomaticReporters(selection: IReporterSelection): IAutomaticReporterPlan { + const primary: IReporterPlanEntry = toEntry(selection.primaryReporter, 'primary', selection.reason); + const additional: IReporterPlanEntry[] = selection.additionalReporters.map((reporter: ReporterName) => + toEntry(reporter, 'additional', selection.reason) + ); + + const stdoutOwner: 'machine' | 'human' = primary.machine ? 'machine' : 'human'; + const humanProgressDestination: 'stdout' | 'stderr' = primary.machine ? 'stderr' : 'stdout'; + + return { + entries: [primary, ...additional], + primary, + stdoutOwner, + humanProgressDestination, + emergencyDestination: 'stderr', + reason: selection.reason + }; +} + +/** + * Describes a reporter plan for the detailed log. + * + * @param plan - the plan to describe + * + * @beta + */ +export function describeReporterPlan(plan: IAutomaticReporterPlan): string { + const describeEntry = (entry: IReporterPlanEntry): string => { + const variant: string = entry.variant ? `[${entry.variant}]` : ''; + return `${entry.reporter}${variant}->${entry.destination}`; + }; + const additional: string = plan.entries + .filter((entry: IReporterPlanEntry) => entry.role === 'additional') + .map(describeEntry) + .join(', '); + return ( + `Reporter selection (${plan.reason}): primary ${describeEntry(plan.primary)}; ` + + `additional [${additional}]; stdout owned by ${plan.stdoutOwner}; ` + + `human progress -> ${plan.humanProgressDestination}.` + ); +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index aeb5f07524..d6f3073127 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -190,6 +190,16 @@ export { shouldRenderAtLogLevel, filterEventsForLogLevel } from './config/LogLevelFilter'; +export type { + PlaintextVariant, + IReporterPlanEntry, + IAutomaticReporterPlan +} from './config/AutomaticReporterMatrix'; +export { + isMachineReporter, + planAutomaticReporters, + describeReporterPlan +} from './config/AutomaticReporterMatrix'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/test/AutomaticReporterMatrix.test.ts b/libraries/reporter/src/test/AutomaticReporterMatrix.test.ts new file mode 100644 index 0000000000..906c37ce33 --- /dev/null +++ b/libraries/reporter/src/test/AutomaticReporterMatrix.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + planAutomaticReporters, + describeReporterPlan, + isMachineReporter, + resolveReporterSelection, + type IAutomaticReporterPlan +} from '../index'; + +function plan( + env: Record, + isTTY: boolean, + argv: string[] = ['build'], + agentEnvironmentVariables: string[] = [] +): IAutomaticReporterPlan { + return planAutomaticReporters(resolveReporterSelection({ argv, env, isTTY, agentEnvironmentVariables })); +} + +describe('planAutomaticReporters matrix', () => { + it('selects ai plus file for an agent, with machine stdout and stderr progress', () => { + const result: IAutomaticReporterPlan = plan({ COPILOT_CLI: '1' }, false); + expect(result.primary.reporter).toBe('ai'); + expect(result.primary.machine).toBe(true); + expect(result.stdoutOwner).toBe('machine'); + expect(result.humanProgressDestination).toBe('stderr'); + expect(result.entries.map((e) => e.reporter)).toEqual(['ai', 'file']); + }); + + it('selects detailed plaintext plus file for CI', () => { + const result: IAutomaticReporterPlan = plan({ CI: 'true' }, false); + expect(result.primary.reporter).toBe('plaintext'); + expect(result.primary.variant).toBe('detailed'); + expect(result.stdoutOwner).toBe('human'); + expect(result.humanProgressDestination).toBe('stdout'); + expect(result.entries.map((e) => e.reporter)).toEqual(['plaintext', 'file']); + }); + + it('selects default plus file for an interactive TTY', () => { + const result: IAutomaticReporterPlan = plan({}, true); + expect(result.primary.reporter).toBe('default'); + expect(result.primary.machine).toBe(false); + expect(result.entries.map((e) => e.reporter)).toEqual(['default', 'file']); + }); + + it('selects concise plaintext plus file for a generic non-TTY', () => { + const result: IAutomaticReporterPlan = plan({}, false); + expect(result.primary.reporter).toBe('plaintext'); + expect(result.primary.variant).toBe('concise'); + expect(result.entries.map((e) => e.reporter)).toEqual(['plaintext', 'file']); + }); + + it('gives an explicitly requested json reporter exclusive machine stdout', () => { + const result: IAutomaticReporterPlan = plan({}, true, ['build', '--reporter=json']); + expect(result.primary.reporter).toBe('json'); + expect(result.stdoutOwner).toBe('machine'); + expect(result.humanProgressDestination).toBe('stderr'); + }); + + it('always routes emergency diagnostics to stderr', () => { + expect(plan({ COPILOT_CLI: '1' }, false).emergencyDestination).toBe('stderr'); + expect(plan({}, true).emergencyDestination).toBe('stderr'); + }); +}); + +describe('isMachineReporter', () => { + it('identifies json and ai as machine reporters', () => { + expect(isMachineReporter('json')).toBe(true); + expect(isMachineReporter('ai')).toBe(true); + expect(isMachineReporter('default')).toBe(false); + expect(isMachineReporter('plaintext')).toBe(false); + expect(isMachineReporter('file')).toBe(false); + expect(isMachineReporter('legacy')).toBe(false); + }); +}); + +describe('describeReporterPlan', () => { + it('records the selection reason and reporters for the detailed log', () => { + const description: string = describeReporterPlan(plan({ CI: 'true' }, false)); + expect(description).toContain('CI detected'); + expect(description).toContain('plaintext[detailed]->stdout'); + expect(description).toContain('file->file'); + expect(description).toContain('stdout owned by human'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index d561e09860..752404b31a 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -202,7 +202,7 @@ "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 + "passes": true }, { "category": "ui", diff --git a/research/progress.txt b/research/progress.txt index fd20ac501f..ab04df8cb9 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -261,3 +261,18 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - rank, classification, per-level rendering (quiet/normal/verbose/debug), monotonicity, severity-separation, filter, file-default tests pass; all exports @beta Next: Feature 17/28 - Automatic reporter selection matrix by environment + +[2026-07-14] Feature 17/28 COMPLETE: Automatic reporter selection matrix by environment + Files (new): + - config/AutomaticReporterMatrix.ts (PlaintextVariant detailed|concise; IReporterPlanEntry; IAutomaticReporterPlan; isMachineReporter(json|ai); planAutomaticReporters(selection)->plan; describeReporterPlan for detailed log) + - test/AutomaticReporterMatrix.test.ts + Files (modified): index.ts, api.md + Matrix: agent->ai+file (machine stdout, human progress stderr); CI->plaintext[detailed]+file; TTY->default+file; non-TTY->plaintext[concise]+file. Emergency always stderr. + Notes: + - Composes Feature 15 resolveReporterSelection; plaintext variant from reason (CI detected->detailed else concise). + - Machine reporters (ai/json) own stdout exclusively -> stdoutOwner 'machine', humanProgressDestination 'stderr'. Applies to explicit --reporter=json too. + - describeReporterPlan records selection reason + reporters for the detailed (file/debug) log. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - agent/CI/TTY/non-TTY matrix, json machine stdout, emergency stderr, isMachineReporter, describeReporterPlan tests pass; all exports @beta + Next: Feature 18/28 - Default interactive reporter (three-row live region) From 1f6ae7ee30a84c154a46de8630ae97806a05e15c Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:50:19 +0000 Subject: [PATCH 06/22] Add rush change file for automatic reporter matrix Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-00-50-08.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json new file mode 100644 index 0000000000..76790f0dd8 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the automatic reporter selection matrix (planAutomaticReporters, describeReporterPlan, isMachineReporter) that maps agent, CI, TTY, and non-TTY environments to reporters and gives machine reporters exclusive stdout", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From dc565fcf1e374134bf6d45c7b7afbc7893feb225 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:59:47 +0000 Subject: [PATCH 07/22] Add default interactive reporter with three-row live region Add the concise default reporter for @rushstack/reporter (#5858). - Add interactive rendering helpers: a spinner, color resolution honoring NO_COLOR and FORCE_COLOR, width-aware active projects with +N more, and a three-row live region renderer that truncates before coloring - Add DefaultInteractiveReporter, which paints the live region at no more than 10 Hz, reacts to terminal width, hides and restores the cursor, leaves at most three stable lines on success, appends a bounded diagnostic block and log path on failure, and appends one summary per completed watch cycle - Cover rendering helpers and reporter behavior with an injected terminal and clock Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 88 +++++ libraries/reporter/src/index.ts | 21 ++ .../reporters/DefaultInteractiveReporter.ts | 312 ++++++++++++++++++ .../src/reporters/InteractiveRendering.ts | 228 +++++++++++++ .../test/DefaultInteractiveReporter.test.ts | 194 +++++++++++ research/feature-list.json | 2 +- research/progress.txt | 15 + 7 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/reporters/DefaultInteractiveReporter.ts create mode 100644 libraries/reporter/src/reporters/InteractiveRendering.ts create mode 100644 libraries/reporter/src/test/DefaultInteractiveReporter.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index cd3a8a7d5b..2a2cc9772e 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -44,6 +44,9 @@ export const COPILOT_CLI_ENV_VAR: 'COPILOT_CLI'; // @beta export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => void; +// @beta +export function createColorizer(enabled: boolean): IColorizer; + // @beta export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution; @@ -71,6 +74,21 @@ export const DEFAULT_HANDOFF_RETENTION_MS: number; // @beta export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number; +// @beta +export class DefaultInteractiveReporter implements IReporter { + constructor(options: IDefaultInteractiveReporterOptions); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export function deleteBootstrapHandoffFileAsync(filePath: string): Promise; @@ -167,6 +185,22 @@ export interface IClassifiedDiagnosticValue { readonly value: ReporterJsonValue; } +// @beta +export interface IColorizer { + // (undocumented) + bold(text: string): string; + // (undocumented) + cyan(text: string): string; + // (undocumented) + dim(text: string): string; + // (undocumented) + green(text: string): string; + // (undocumented) + red(text: string): string; + // (undocumented) + yellow(text: string): string; +} + // @beta export interface ICommandCompletedPayload { readonly commandName: string; @@ -213,6 +247,15 @@ export interface ICreateScopedReporterOptions { readonly source: IReporterEventSource; } +// @beta +export interface IDefaultInteractiveReporterOptions { + readonly color?: boolean; + readonly logPath?: string; + readonly minRefreshIntervalMs?: number; + readonly nowMs?: () => number; + readonly terminal: IInteractiveTerminal; +} + // @beta export interface IEarlyReporterControls { readonly logLevel?: string; @@ -225,6 +268,13 @@ export interface IEngineSinkResolution { readonly sink: IReporterEventSink; } +// @beta +export interface IInteractiveTerminal { + readonly columns: number; + readonly isTTY: boolean; + write(text: string): void; +} + // @beta export interface IJsonControls { readonly commandJson: boolean; @@ -240,6 +290,16 @@ export interface ILifecycleEmitterOptions { readonly source: IReporterEventSource; } +// @beta +export interface ILiveRegionState { + readonly activeProjects: readonly string[]; + readonly commandName?: string; + readonly completedOperations: number; + readonly failedOperations: number; + readonly latestActivity: string; + readonly totalOperations: number; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -267,6 +327,13 @@ export interface IOperationStatusChangedPayload { readonly status: OperationStatus; } +// @beta +export interface IRenderLiveRegionOptions { + readonly color: IColorizer; + readonly spinnerFrame: string; + readonly width: number; +} + // @beta export interface IReporter { closeAsync(): Promise; @@ -667,6 +734,9 @@ export class LifecycleEmitter { emitWatchCycleCompleted(payload: IWatchCycleCompletedPayload): string; } +// @beta +export const MIN_REFRESH_INTERVAL_MS: number; + // @beta export class NdjsonDecoder { constructor(options?: INdjsonOptions); @@ -707,6 +777,12 @@ export function planAutomaticReporters(selection: IReporterSelection): IAutomati // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; +// @beta +export function renderActiveProjectsRow(projects: readonly string[], width: number): string; + +// @beta +export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; + // @beta export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; @@ -783,6 +859,9 @@ export type ReporterName = 'default' | 'ai' | 'json' | 'plaintext' | 'file' | 'l // @beta export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; +// @beta +export function resolveColorEnabled(env: Record, isTTY: boolean): boolean; + // @beta export function resolveExitStatus(options: IResolveExitStatusOptions): IRushExitStatus; @@ -846,9 +925,15 @@ export class RushSessionReporting { // @beta export function separateJsonControls(argv: readonly string[]): IJsonControls; +// @beta +export function shouldRefresh(lastPaintMs: number, nowMs: number, minIntervalMs?: number): boolean; + // @beta export function shouldRenderAtLogLevel(logLevel: ReporterLogLevel, event: IReporterEventEnvelope): boolean; +// @beta +export const SPINNER_FRAMES: readonly string[]; + // @beta export function summarizeShadowResult(events: readonly IReporterEventEnvelope[]): IShadowResultSummary; @@ -872,6 +957,9 @@ export class TelemetrySubscriber { setReporterMode(reporterMode: string): void; } +// @beta +export function truncateToWidth(text: string, width: number): string; + // @beta export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index d6f3073127..ca412319ec 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -201,6 +201,27 @@ export { describeReporterPlan } from './config/AutomaticReporterMatrix'; +export type { + ILiveRegionState, + IColorizer, + IRenderLiveRegionOptions +} from './reporters/InteractiveRendering'; +export { + SPINNER_FRAMES, + MIN_REFRESH_INTERVAL_MS, + resolveColorEnabled, + createColorizer, + truncateToWidth, + renderActiveProjectsRow, + renderLiveRegion, + shouldRefresh +} from './reporters/InteractiveRendering'; +export type { + IInteractiveTerminal, + IDefaultInteractiveReporterOptions +} from './reporters/DefaultInteractiveReporter'; +export { DefaultInteractiveReporter } from './reporters/DefaultInteractiveReporter'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts new file mode 100644 index 0000000000..a632c9d0e2 --- /dev/null +++ b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts @@ -0,0 +1,312 @@ +// 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 } from '../manager/IReporter'; +import { + SPINNER_FRAMES, + MIN_REFRESH_INTERVAL_MS, + createColorizer, + renderLiveRegion, + shouldRefresh, + type IColorizer, + type ILiveRegionState +} from './InteractiveRendering'; + +const HIDE_CURSOR: string = '\u001b[?25l'; +const SHOW_CURSOR: string = '\u001b[?25h'; +const MAX_FINAL_DIAGNOSTICS: number = 10; +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'success', + 'successWithWarnings', + 'failure', + 'blocked', + 'skipped', + 'fromCache', + 'noOp' +]); + +/** + * The terminal an interactive reporter writes to. + * + * @beta + */ +export interface IInteractiveTerminal { + /** + * The terminal width in columns. + */ + readonly columns: number; + + /** + * Whether the terminal is an interactive TTY. + */ + readonly isTTY: boolean; + + /** + * Writes text to the terminal. + */ + write(text: string): void; +} + +/** + * Options for {@link DefaultInteractiveReporter}. + * + * @beta + */ +export interface IDefaultInteractiveReporterOptions { + /** + * The terminal to render to. + */ + readonly terminal: IInteractiveTerminal; + + /** + * Whether color is enabled. Defaults to the terminal TTY capability. + */ + readonly color?: boolean; + + /** + * Returns the current time in milliseconds. Injectable for testing. + */ + readonly nowMs?: () => number; + + /** + * The minimum refresh interval in milliseconds. Defaults to 100 ms. + */ + readonly minRefreshIntervalMs?: number; + + /** + * The full-detail log path shown on failure. + */ + readonly logPath?: string; +} + +/** + * The concise default reporter that renders a three-row interactive live region. + * + * @remarks + * The live region shows aggregate progress with a spinner, width-aware active + * projects with `+N more`, and the latest activity. It refreshes at no more than + * 10 Hz, reacts to terminal width, restores the cursor on completion, leaves at + * most three stable lines on success, appends a bounded diagnostic block and log + * path on failure, and in watch mode keeps the live region while appending one + * summary per completed cycle. + * + * @beta + */ +export class DefaultInteractiveReporter implements IReporter { + public readonly name: string = 'default'; + + private readonly _terminal: IInteractiveTerminal; + private readonly _color: IColorizer; + private readonly _colorEnabled: boolean; + private readonly _nowMs: () => number; + private readonly _minRefreshIntervalMs: number; + + private _commandName: string | undefined; + private _totalOperations: number; + private _completedOperations: number; + private _failedOperations: number; + private readonly _activeProjects: Map; + private _latestActivity: string; + private readonly _diagnostics: string[]; + private _result: { succeeded: boolean; exitCode: number } | undefined; + private _logPath: string | undefined; + + private _spinnerIndex: number; + private _lastPaintMs: number; + private _paintedRowCount: number; + private _cursorHidden: boolean; + private _finalized: boolean; + + public constructor(options: IDefaultInteractiveReporterOptions) { + this._terminal = options.terminal; + this._colorEnabled = options.color ?? options.terminal.isTTY; + this._color = createColorizer(this._colorEnabled); + this._nowMs = options.nowMs ?? (() => Date.now()); + this._minRefreshIntervalMs = options.minRefreshIntervalMs ?? MIN_REFRESH_INTERVAL_MS; + + this._commandName = undefined; + this._totalOperations = 0; + this._completedOperations = 0; + this._failedOperations = 0; + this._activeProjects = new Map(); + this._latestActivity = ''; + this._diagnostics = []; + this._result = undefined; + this._logPath = options.logPath; + + this._spinnerIndex = 0; + this._lastPaintMs = Number.NEGATIVE_INFINITY; + this._paintedRowCount = 0; + this._cursorHidden = false; + this._finalized = false; + } + + public async initializeAsync(): Promise { + /* The cursor is hidden lazily on the first paint. */ + } + + public report(event: IReporterEventEnvelope): void { + this._update(event); + if (event.type === 'watchCycleCompleted') { + this._appendWatchSummary(event); + return; + } + if (this._terminal.isTTY && shouldRefresh(this._lastPaintMs, this._nowMs(), this._minRefreshIntervalMs)) { + this._paint(); + } + } + + public async flushAsync(): Promise { + if (this._terminal.isTTY && !this._finalized) { + this._paint(); + } + } + + public async closeAsync(): Promise { + this._finalize(); + } + + private _update(event: IReporterEventEnvelope): void { + switch (event.type) { + case 'commandStarted': { + this._commandName = (event.payload as { commandName?: string }).commandName; + break; + } + case 'operationRegistered': { + this._totalOperations++; + break; + } + case 'operationStatusChanged': { + const payload: { operationId: string; status: string; projectName?: string } = event.payload as { + operationId: string; + status: string; + projectName?: string; + }; + const projectName: string = payload.projectName ?? event.scope?.projectName ?? payload.operationId; + if (payload.status === 'executing') { + this._activeProjects.set(payload.operationId, projectName); + } else if (TERMINAL_STATUSES.has(payload.status)) { + this._activeProjects.delete(payload.operationId); + this._completedOperations++; + if (payload.status === 'failure') { + this._failedOperations++; + } + } + this._latestActivity = `${payload.status} ${projectName}`; + break; + } + case 'activityChanged': { + const payload: { kind?: string; text?: string } = event.payload as { kind?: string; text?: string }; + if (payload.text !== undefined) { + this._latestActivity = payload.text; + } + break; + } + case 'diagnosticEmitted': { + const payload: { code?: string; severity?: string } = event.payload as { + code?: string; + severity?: string; + }; + if (payload.severity === 'error' || payload.severity === 'warning') { + this._diagnostics.push(`[${payload.severity}] ${payload.code ?? 'unknown'}`); + } + break; + } + case 'artifactAvailable': { + const payload: { role?: string; path?: string } = event.payload as { role?: string; path?: string }; + if (payload.role === 'log' && payload.path !== undefined) { + this._logPath = payload.path; + } + break; + } + case 'commandResult': { + this._result = event.payload as { succeeded: boolean; exitCode: number }; + break; + } + default: + break; + } + } + + private _snapshot(): ILiveRegionState { + return { + commandName: this._commandName, + totalOperations: this._totalOperations, + completedOperations: this._completedOperations, + failedOperations: this._failedOperations, + activeProjects: [...this._activeProjects.values()], + latestActivity: this._latestActivity + }; + } + + private _paint(): void { + if (!this._cursorHidden) { + this._terminal.write(HIDE_CURSOR); + this._cursorHidden = true; + } + const spinnerFrame: string = SPINNER_FRAMES[this._spinnerIndex % SPINNER_FRAMES.length]; + this._spinnerIndex++; + const rows: string[] = renderLiveRegion(this._snapshot(), { + width: this._terminal.columns, + spinnerFrame, + color: this._color + }); + this._terminal.write(`${this._clearRegion()}${rows.join('\n')}\n`); + this._paintedRowCount = rows.length; + this._lastPaintMs = this._nowMs(); + } + + private _clearRegion(): string { + if (this._paintedRowCount === 0) { + return ''; + } + return `\u001b[${this._paintedRowCount}A\u001b[0J`; + } + + private _appendWatchSummary(event: IReporterEventEnvelope): void { + const payload: { succeeded?: boolean } = event.payload as { succeeded?: boolean }; + const marker: string = payload.succeeded ? this._color.green('✔') : this._color.red('✖'); + const summary: string = `${marker} watch cycle ${payload.succeeded ? 'succeeded' : 'failed'}`; + this._terminal.write(`${this._clearRegion()}${summary}\n`); + this._paintedRowCount = 0; + if (this._terminal.isTTY) { + this._paint(); + } + } + + private _finalize(): void { + if (this._finalized) { + return; + } + this._finalized = true; + + const lines: string[] = []; + const succeeded: boolean = this._result ? this._result.succeeded : this._failedOperations === 0; + if (succeeded) { + lines.push( + `${this._color.green('✔')} ${this._commandName ?? 'rush'} succeeded — ` + + `${this._completedOperations}/${this._totalOperations} operations` + ); + } else { + lines.push( + `${this._color.red('✖')} ${this._commandName ?? 'rush'} failed — ${this._failedOperations} failed` + ); + for (const diagnostic of this._diagnostics.slice(0, MAX_FINAL_DIAGNOSTICS)) { + lines.push(` ${diagnostic}`); + } + if (this._diagnostics.length > MAX_FINAL_DIAGNOSTICS) { + lines.push(` +${this._diagnostics.length - MAX_FINAL_DIAGNOSTICS} more diagnostics`); + } + if (this._logPath !== undefined) { + lines.push(` ${this._color.dim(`Log: ${this._logPath}`)}`); + } + } + + const clear: string = this._clearRegion(); + const restore: string = this._cursorHidden ? SHOW_CURSOR : ''; + this._cursorHidden = false; + this._paintedRowCount = 0; + this._terminal.write(`${clear}${lines.join('\n')}\n${restore}`); + } +} diff --git a/libraries/reporter/src/reporters/InteractiveRendering.ts b/libraries/reporter/src/reporters/InteractiveRendering.ts new file mode 100644 index 0000000000..973ca3b483 --- /dev/null +++ b/libraries/reporter/src/reporters/InteractiveRendering.ts @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The spinner frames used by the interactive live region. + * + * @beta + */ +export const SPINNER_FRAMES: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/** + * The minimum interval between interactive refreshes, in milliseconds (10 Hz). + * + * @beta + */ +export const MIN_REFRESH_INTERVAL_MS: number = 100; + +/** + * The snapshot of live state rendered into the three-row region. + * + * @beta + */ +export interface ILiveRegionState { + /** + * The command name. + */ + readonly commandName?: string; + + /** + * The total number of registered operations. + */ + readonly totalOperations: number; + + /** + * The number of completed operations. + */ + readonly completedOperations: number; + + /** + * The number of failed operations. + */ + readonly failedOperations: number; + + /** + * The projects with currently executing operations. + */ + readonly activeProjects: readonly string[]; + + /** + * The latest activity, liveness, or result line. + */ + readonly latestActivity: string; +} + +/** + * Resolves whether color is enabled from the environment and TTY capability. + * + * @remarks + * `NO_COLOR` disables color regardless of other settings. `FORCE_COLOR` enables + * it unless set to `0` or `false`. Otherwise color follows TTY capability. No + * new global color flag is introduced. + * + * @param env - the environment variables + * @param isTTY - whether the output is an interactive TTY + * + * @beta + */ +export function resolveColorEnabled(env: Record, isTTY: boolean): boolean { + if (env.NO_COLOR !== undefined) { + return false; + } + const force: string | undefined = env.FORCE_COLOR; + if (force !== undefined) { + return !(force === '0' || force.toLowerCase() === 'false'); + } + return isTTY; +} + +/** + * A set of color functions. + * + * @beta + */ +export interface IColorizer { + dim(text: string): string; + red(text: string): string; + green(text: string): string; + yellow(text: string): string; + cyan(text: string): string; + bold(text: string): string; +} + +function wrap(open: number, text: string, enabled: boolean): string { + return enabled ? `\u001b[${open}m${text}\u001b[0m` : text; +} + +/** + * Creates a colorizer that emits ANSI codes only when enabled. + * + * @param enabled - whether color is enabled + * + * @beta + */ +export function createColorizer(enabled: boolean): IColorizer { + return { + dim: (text: string): string => wrap(2, text, enabled), + red: (text: string): string => wrap(31, text, enabled), + green: (text: string): string => wrap(32, text, enabled), + yellow: (text: string): string => wrap(33, text, enabled), + cyan: (text: string): string => wrap(36, text, enabled), + bold: (text: string): string => wrap(1, text, enabled) + }; +} + +/** + * Truncates a line to a maximum width, adding an ellipsis when it overflows. + * + * @beta + */ +export function truncateToWidth(text: string, width: number): string { + if (width <= 0) { + return ''; + } + if (text.length <= width) { + return text; + } + if (width === 1) { + return '…'; + } + return `${text.slice(0, width - 1)}…`; +} + +/** + * Renders the width-aware active-projects row with a `+N more` suffix. + * + * @param projects - the active project names + * @param width - the available width + * + * @beta + */ +export function renderActiveProjectsRow(projects: readonly string[], width: number): string { + if (projects.length === 0) { + return ''; + } + const shown: string[] = []; + for (let index: number = 0; index < projects.length; index++) { + const tentative: string = [...shown, projects[index]].join(', '); + const remaining: number = projects.length - (index + 1); + const suffix: string = remaining > 0 ? ` +${remaining} more` : ''; + if (tentative.length + suffix.length > width && shown.length > 0) { + const hidden: number = projects.length - shown.length; + return `${shown.join(', ')} +${hidden} more`; + } + shown.push(projects[index]); + } + return shown.join(', '); +} + +/** + * Options for {@link renderLiveRegion}. + * + * @beta + */ +export interface IRenderLiveRegionOptions { + /** + * The terminal width. + */ + readonly width: number; + + /** + * The current spinner frame. + */ + readonly spinnerFrame: string; + + /** + * The colorizer. + */ + readonly color: IColorizer; +} + +/** + * Renders the three-row live region. + * + * @remarks + * Row one is aggregate phase progress with a spinner, row two is the width-aware + * active projects with a `+N more` suffix, and row three is the latest activity. + * + * @param state - the live state + * @param options - width, spinner, and color + * + * @beta + */ +export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[] { + const { width, spinnerFrame, color } = options; + + const failedText: string = state.failedOperations > 0 ? ` ${state.failedOperations} failed` : ''; + const progressRow: string = + `${spinnerFrame} ${state.commandName ?? 'rush'} ` + + `${state.completedOperations}/${state.totalOperations}${failedText}`; + + const activeRow: string = renderActiveProjectsRow(state.activeProjects, width); + const activityRow: string = state.latestActivity; + + // Color is applied after truncation so ANSI codes never affect the width or + // get split mid-sequence. + return [ + color.cyan(truncateToWidth(progressRow, width)), + color.dim(truncateToWidth(activeRow, width)), + truncateToWidth(activityRow, width) + ]; +} + +/** + * Returns `true` if the live region may refresh, capping the rate at 10 Hz. + * + * @param lastPaintMs - the time of the last paint + * @param nowMs - the current time + * @param minIntervalMs - the minimum interval; defaults to 100 ms + * + * @beta + */ +export function shouldRefresh( + lastPaintMs: number, + nowMs: number, + minIntervalMs: number = MIN_REFRESH_INTERVAL_MS +): boolean { + return nowMs - lastPaintMs >= minIntervalMs; +} diff --git a/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts new file mode 100644 index 0000000000..ed634f0136 --- /dev/null +++ b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DefaultInteractiveReporter, + resolveColorEnabled, + createColorizer, + truncateToWidth, + renderActiveProjectsRow, + renderLiveRegion, + shouldRefresh, + type IInteractiveTerminal, + type IReporterEventEnvelope +} from '../index'; + +const HIDE_CURSOR: string = '\u001b[?25l'; +const SHOW_CURSOR: string = '\u001b[?25h'; + +class FakeTerminal implements IInteractiveTerminal { + public columns: number; + public isTTY: boolean; + public output: string = ''; + + public constructor(columns: number = 80, isTTY: boolean = true) { + this.columns = columns; + this.isTTY = isTTY; + } + + public write(text: string): void { + this.output += text; + } +} + +function ev( + type: string, + payload: unknown = {}, + scope?: { projectName?: string } +): IReporterEventEnvelope { + return { type, payload, scope, required: true } as unknown as IReporterEventEnvelope; +} + +describe('interactive rendering helpers', () => { + it('resolves color from NO_COLOR, FORCE_COLOR, and TTY', () => { + expect(resolveColorEnabled({ NO_COLOR: '' }, true)).toBe(false); + expect(resolveColorEnabled({ FORCE_COLOR: '1' }, false)).toBe(true); + expect(resolveColorEnabled({ FORCE_COLOR: '0' }, true)).toBe(false); + expect(resolveColorEnabled({ FORCE_COLOR: 'false' }, true)).toBe(false); + expect(resolveColorEnabled({}, true)).toBe(true); + expect(resolveColorEnabled({}, false)).toBe(false); + }); + + it('emits ANSI only when color is enabled', () => { + expect(createColorizer(true).red('x')).toContain('\u001b[31m'); + expect(createColorizer(false).red('x')).toBe('x'); + }); + + it('truncates to width with an ellipsis', () => { + expect(truncateToWidth('hello', 10)).toBe('hello'); + expect(truncateToWidth('hello', 3)).toBe('he…'); + expect(truncateToWidth('hello', 1)).toBe('…'); + expect(truncateToWidth('hello', 0)).toBe(''); + }); + + it('renders width-aware active projects with a +N more suffix', () => { + expect(renderActiveProjectsRow([], 80)).toBe(''); + expect(renderActiveProjectsRow(['a', 'b', 'c'], 80)).toBe('a, b, c'); + expect(renderActiveProjectsRow(['a', 'b', 'c', 'd', 'e'], 10)).toBe('a +4 more'); + }); + + it('renders three rows and throttles refreshes', () => { + const rows: string[] = renderLiveRegion( + { + commandName: 'build', + totalOperations: 10, + completedOperations: 3, + failedOperations: 1, + activeProjects: ['project-a', 'project-b'], + latestActivity: 'building project-a' + }, + { width: 80, spinnerFrame: '⠋', color: createColorizer(false) } + ); + expect(rows).toHaveLength(3); + expect(rows[0]).toContain('build'); + expect(rows[0]).toContain('3/10'); + expect(rows[0]).toContain('1 failed'); + expect(rows[1]).toContain('project-a'); + expect(rows[2]).toBe('building project-a'); + + expect(shouldRefresh(0, 50, 100)).toBe(false); + expect(shouldRefresh(0, 100, 100)).toBe(true); + }); +}); + +describe('DefaultInteractiveReporter', () => { + it('hides the cursor and paints the live region on TTY, throttled to 10 Hz', async () => { + let now: number = 0; + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => now + }); + await reporter.initializeAsync(); + + reporter.report(ev('commandStarted', { commandName: 'buildX' })); // paints at now=0 + now = 50; + reporter.report(ev('operationRegistered', { operationId: 'op1' })); // throttled + now = 60; + reporter.report( + ev('operationStatusChanged', { operationId: 'op1', status: 'executing' }, { projectName: 'p' }) + ); + now = 120; + reporter.report( + ev('operationStatusChanged', { operationId: 'op2', status: 'executing' }, { projectName: 'q' }) + ); // paints + + expect(terminal.output).toContain(HIDE_CURSOR); + expect(terminal.output.split('buildX').length - 1).toBe(2); + }); + + it('leaves a single success line and restores the cursor', async () => { + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0 + }); + await reporter.initializeAsync(); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('operationRegistered', { operationId: 'op1' })); + reporter.report( + ev('operationStatusChanged', { operationId: 'op1', status: 'success' }, { projectName: 'p' }) + ); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + expect(terminal.output).toContain('✔'); + expect(terminal.output).toContain('build succeeded — 1/1 operations'); + expect(terminal.output).toContain(SHOW_CURSOR); + }); + + it('appends a bounded diagnostic block and log path on failure', async () => { + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0, + logPath: '/tmp/rush-logs/latest.log' + }); + await reporter.initializeAsync(); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report( + ev('operationStatusChanged', { operationId: 'op1', status: 'failure' }, { projectName: 'p' }) + ); + reporter.report(ev('diagnosticEmitted', { code: 'RUSH_OPERATION_FAILED', severity: 'error' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + await reporter.closeAsync(); + + expect(terminal.output).toContain('✖'); + expect(terminal.output).toContain('build failed — 1 failed'); + expect(terminal.output).toContain('[error] RUSH_OPERATION_FAILED'); + expect(terminal.output).toContain('Log: /tmp/rush-logs/latest.log'); + }); + + it('appends one summary per completed watch cycle while keeping the live region', async () => { + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0 + }); + await reporter.initializeAsync(); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('watchCycleCompleted', { succeeded: true })); + + expect(terminal.output).toContain('watch cycle succeeded'); + }); + + it('does not paint a live region on a non-TTY but still writes the final summary', async () => { + const terminal: FakeTerminal = new FakeTerminal(80, false); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0 + }); + await reporter.initializeAsync(); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + expect(terminal.output).not.toContain(HIDE_CURSOR); + expect(terminal.output).toContain('build succeeded'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 752404b31a..08e4313a83 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -215,7 +215,7 @@ "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 + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index ab04df8cb9..fd51f37335 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -276,3 +276,18 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - agent/CI/TTY/non-TTY matrix, json machine stdout, emergency stderr, isMachineReporter, describeReporterPlan tests pass; all exports @beta Next: Feature 18/28 - Default interactive reporter (three-row live region) + +[2026-07-14] Feature 18/28 COMPLETE: Default interactive reporter (three-row live region) + Files (new): + - reporters/InteractiveRendering.ts (SPINNER_FRAMES, MIN_REFRESH_INTERVAL_MS=100; resolveColorEnabled NO_COLOR>FORCE_COLOR>isTTY; createColorizer; truncateToWidth; renderActiveProjectsRow +N more; renderLiveRegion 3 rows [color applied AFTER truncation]; shouldRefresh) + - reporters/DefaultInteractiveReporter.ts (IReporter; injected IInteractiveTerminal + nowMs; state from events; throttled paint <=10Hz; cursor hide/show ANSI; success<=3 lines; failure bounded diag block <=10 + Log path; watchCycleCompleted summary; non-TTY skips live region but writes final) + - test/DefaultInteractiveReporter.test.ts + Files (modified): index.ts, api.md + Notes: + - Color applied after truncateToWidth so ANSI never affects width/split mid-code. + - Resize-aware: reads terminal.columns each paint. + - Testable via FakeTerminal capturing writes + injected clock. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - color resolution, colorizer, truncate, +N more, 3-row render, throttle; reporter hide/paint/throttle, success line + cursor restore, failure diag+log, watch summary, non-TTY tests pass; all exports @beta + Next: Feature 19/28 - Plaintext and non-TTY reporter From cb388f3a258e7d7a4b4d24eeea90cd4524bd696c Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:00:11 +0000 Subject: [PATCH 08/22] Add rush change file for default interactive reporter Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-00-00.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json new file mode 100644 index 0000000000..3dacc510ba --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the default interactive reporter with a three-row live region, spinner, width-aware active projects, throttled refresh, cursor restoration, failure diagnostic block, watch summaries, and NO_COLOR/FORCE_COLOR handling", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From ef1fa2827719d7e388644cf85e3680124a774343 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:04:50 +0000 Subject: [PATCH 09/22] Add append-only plaintext and non-TTY reporter Add the plaintext reporter for @rushstack/reporter (#5858). - Add PlaintextReporter, an append-only reporter that never moves the cursor and disables color by default - Emit the start line, meaningful operation state changes, diagnostics, and the final result - Add a compact 30-second heartbeat for long non-TTY and CI sessions - Retain StreamCollator-like operation grouping in the detailed CI variant by grouping each operation's output under a header - Add stable concise and detailed plaintext snapshot tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 25 ++ libraries/reporter/src/index.ts | 2 + .../src/reporters/PlaintextReporter.ts | 269 ++++++++++++++++++ .../src/test/PlaintextReporter.test.ts | 107 +++++++ .../PlaintextReporter.test.ts.snap | 20 ++ research/feature-list.json | 2 +- research/progress.txt | 13 + 7 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/reporters/PlaintextReporter.ts create mode 100644 libraries/reporter/src/test/PlaintextReporter.test.ts create mode 100644 libraries/reporter/src/test/__snapshots__/PlaintextReporter.test.ts.snap diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 2a2cc9772e..2ddb108c23 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -327,6 +327,15 @@ export interface IOperationStatusChangedPayload { readonly status: OperationStatus; } +// @beta +export interface IPlaintextReporterOptions { + readonly color?: boolean; + readonly heartbeatIntervalMs?: number; + readonly nowMs?: () => number; + readonly variant?: PlaintextVariant; + readonly write: (text: string) => void; +} + // @beta export interface IRenderLiveRegionOptions { readonly color: IColorizer; @@ -768,6 +777,22 @@ export function parseEarlyReporterControls(argv: readonly string[], env: Record< // @beta export function parseOutputControl(value: string): IReporterOutputTarget; +// @beta +export class PlaintextReporter implements IReporter { + constructor(options: IPlaintextReporterOptions); + // (undocumented) + closeAsync(): Promise; + emitHeartbeatIfDue(): boolean; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export type PlaintextVariant = 'detailed' | 'concise'; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index ca412319ec..112a05a18e 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -221,6 +221,8 @@ export type { IDefaultInteractiveReporterOptions } from './reporters/DefaultInteractiveReporter'; export { DefaultInteractiveReporter } from './reporters/DefaultInteractiveReporter'; +export type { IPlaintextReporterOptions } from './reporters/PlaintextReporter'; +export { PlaintextReporter } from './reporters/PlaintextReporter'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/reporters/PlaintextReporter.ts b/libraries/reporter/src/reporters/PlaintextReporter.ts new file mode 100644 index 0000000000..84cba41ac0 --- /dev/null +++ b/libraries/reporter/src/reporters/PlaintextReporter.ts @@ -0,0 +1,269 @@ +// 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 } from '../manager/IReporter'; +import type { PlaintextVariant } from '../config/AutomaticReporterMatrix'; +import { createColorizer, type IColorizer } from './InteractiveRendering'; + +const HEARTBEAT_INTERVAL_MS: number = 30000; +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'success', + 'successWithWarnings', + 'failure', + 'blocked', + 'skipped', + 'fromCache', + 'noOp' +]); + +interface IOperationRecord { + readonly projectName: string; + readonly phaseName?: string; + readonly buffer: string[]; +} + +/** + * Options for {@link PlaintextReporter}. + * + * @beta + */ +export interface IPlaintextReporterOptions { + /** + * The append-only sink. Never receives cursor-movement codes. + */ + readonly write: (text: string) => void; + + /** + * The rendering variant. `detailed` retains StreamCollator-like operation + * grouping for CI; `concise` is minimal. Defaults to `concise`. + */ + readonly variant?: PlaintextVariant; + + /** + * Whether color is enabled. Defaults to `false`. + */ + readonly color?: boolean; + + /** + * Returns the current time in milliseconds. Injectable for testing. + */ + readonly nowMs?: () => number; + + /** + * The heartbeat interval in milliseconds. Defaults to 30000. + */ + readonly heartbeatIntervalMs?: number; +} + +/** + * An append-only reporter for non-TTY and CI environments. + * + * @remarks + * The reporter never moves the cursor and disables color by default. It emits + * the start line, meaningful state changes, diagnostics, and the final result. + * Long sessions can emit a compact heartbeat every 30 seconds. In the detailed + * CI variant it groups each operation's output under a header, retaining + * StreamCollator-like grouping. + * + * @beta + */ +export class PlaintextReporter implements IReporter { + public readonly name: string = 'plaintext'; + + private readonly _write: (text: string) => void; + private readonly _variant: PlaintextVariant; + private readonly _color: IColorizer; + private readonly _nowMs: () => number; + private readonly _heartbeatIntervalMs: number; + + private _commandName: string | undefined; + private _total: number; + private _completed: number; + private _failed: number; + private _lastOutputMs: number; + private readonly _operations: Map; + + public constructor(options: IPlaintextReporterOptions) { + this._write = options.write; + this._variant = options.variant ?? 'concise'; + this._color = createColorizer(options.color ?? false); + this._nowMs = options.nowMs ?? (() => Date.now()); + this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS; + + this._commandName = undefined; + this._total = 0; + this._completed = 0; + this._failed = 0; + this._lastOutputMs = 0; + this._operations = new Map(); + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + switch (event.type) { + case 'commandStarted': { + this._commandName = (event.payload as { commandName: string }).commandName; + this._writeLine(`Starting "rush ${this._commandName}"`); + break; + } + case 'operationRegistered': { + const payload: { operationId: string; projectName?: string; phaseName?: string } = event.payload as { + operationId: string; + projectName?: string; + phaseName?: string; + }; + this._operations.set(payload.operationId, { + projectName: payload.projectName ?? payload.operationId, + phaseName: payload.phaseName, + buffer: [] + }); + this._total++; + break; + } + case 'operationStatusChanged': { + this._onStatusChanged(event); + break; + } + case 'externalOutput': { + this._onExternalOutput(event); + break; + } + case 'diagnosticEmitted': { + const payload: { code?: string; severity?: string } = event.payload as { + code?: string; + severity?: string; + }; + if (payload.severity === 'error' || payload.severity === 'warning') { + this._writeLine(this._formatDiagnostic(payload.severity, payload.code ?? 'unknown')); + } + break; + } + case 'watchCycleCompleted': { + const succeeded: boolean = (event.payload as { succeeded?: boolean }).succeeded === true; + this._writeLine(`Watch cycle ${succeeded ? 'succeeded' : 'failed'}`); + break; + } + case 'commandResult': { + this._onResult(event.payload as { commandName: string; succeeded: boolean; exitCode: number }); + break; + } + default: + break; + } + } + + public async flushAsync(): Promise { + /* Append-only output is written eagerly. */ + } + + public async closeAsync(): Promise { + /* no-op */ + } + + /** + * Emits a compact heartbeat if the heartbeat interval has elapsed since the + * last output. Returns whether a heartbeat was emitted. + */ + public emitHeartbeatIfDue(): boolean { + if (this._nowMs() - this._lastOutputMs >= this._heartbeatIntervalMs) { + this._writeLine( + `... ${this._commandName ?? 'rush'} still running — ${this._completed}/${this._total} operations` + ); + return true; + } + return false; + } + + private _onStatusChanged(event: IReporterEventEnvelope): void { + const payload: { operationId: string; status: string } = event.payload as { + operationId: string; + status: string; + }; + const record: IOperationRecord | undefined = this._operations.get(payload.operationId); + const projectName: string = record?.projectName ?? event.scope?.projectName ?? payload.operationId; + + if (!TERMINAL_STATUSES.has(payload.status)) { + return; + } + + this._completed++; + if (payload.status === 'failure') { + this._failed++; + } + + if (this._variant === 'detailed') { + const phase: string = record?.phaseName ? ` (${record.phaseName})` : ''; + this._writeLine(''); + this._writeLine(`==[ ${projectName}${phase} ]==`); + if (record) { + for (const chunk of record.buffer) { + this._writeRaw(chunk); + } + } + this._writeLine(this._formatStatus(projectName, payload.status)); + } else { + this._writeLine(this._formatStatus(projectName, payload.status)); + } + } + + private _onExternalOutput(event: IReporterEventEnvelope): void { + if (this._variant !== 'detailed') { + return; + } + const operationId: string | undefined = event.scope?.operationId; + const text: string = (event.payload as { text?: string }).text ?? ''; + const record: IOperationRecord | undefined = + operationId !== undefined ? this._operations.get(operationId) : undefined; + if (record) { + record.buffer.push(text); + } else { + this._writeRaw(text); + } + } + + private _onResult(payload: { commandName: string; succeeded: boolean; exitCode: number }): void { + const commandName: string = payload.commandName ?? this._commandName ?? 'rush'; + if (payload.succeeded) { + this._writeLine( + this._color.green( + `rush ${commandName} succeeded (${this._completed}/${this._total} operations, ${this._failed} failed)` + ) + ); + } else { + this._writeLine(this._color.red(`rush ${commandName} failed (${this._failed} failed)`)); + } + } + + private _formatStatus(projectName: string, status: string): string { + const line: string = `${projectName}: ${status}`; + if (status === 'failure') { + return this._color.red(line); + } + return line; + } + + private _formatDiagnostic(severity: string, code: string): string { + const line: string = `[${severity}] ${code}`; + if (severity === 'error') { + return this._color.red(line); + } + if (severity === 'warning') { + return this._color.yellow(line); + } + return line; + } + + private _writeLine(text: string): void { + this._write(`${text}\n`); + this._lastOutputMs = this._nowMs(); + } + + private _writeRaw(text: string): void { + this._write(text.endsWith('\n') ? text : `${text}\n`); + this._lastOutputMs = this._nowMs(); + } +} diff --git a/libraries/reporter/src/test/PlaintextReporter.test.ts b/libraries/reporter/src/test/PlaintextReporter.test.ts new file mode 100644 index 0000000000..8009623758 --- /dev/null +++ b/libraries/reporter/src/test/PlaintextReporter.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { PlaintextReporter, type IReporterEventEnvelope } from '../index'; + +function ev( + type: string, + payload: unknown = {}, + scope?: { operationId?: string; projectName?: string } +): IReporterEventEnvelope { + return { type, payload, scope, required: true } as unknown as IReporterEventEnvelope; +} + +interface ICapture { + readonly reporter: PlaintextReporter; + getOutput(): string; +} + +function makeConcise(): ICapture { + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => { + output += text; + }, + variant: 'concise', + nowMs: () => 0 + }); + return { reporter, getOutput: () => output }; +} + +function makeDetailed(): ICapture { + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => { + output += text; + }, + variant: 'detailed', + nowMs: () => 0 + }); + return { reporter, getOutput: () => output }; +} + +describe('PlaintextReporter', () => { + it('is append-only, uses no cursor movement, and disables color by default', () => { + const capture: ICapture = makeConcise(); + capture.reporter.report(ev('commandStarted', { commandName: 'build' })); + capture.reporter.report(ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' })); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + capture.reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + + // No escape sequences of any kind (no color, no cursor movement). + expect(capture.getOutput()).not.toContain('\u001b'); + }); + + it('renders a stable concise plaintext transcript', () => { + const capture: ICapture = makeConcise(); + capture.reporter.report(ev('commandStarted', { commandName: 'build' })); + capture.reporter.report(ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' })); + capture.reporter.report(ev('operationRegistered', { operationId: 'op2', projectName: 'project-b' })); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + capture.reporter.report( + ev('diagnosticEmitted', { code: 'RUSH_INPUT_UNKNOWN_PROJECT', severity: 'warning' }) + ); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op2', status: 'failure' })); + capture.reporter.report(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + + expect(capture.getOutput()).toMatchSnapshot(); + }); + + it('renders a stable detailed transcript with StreamCollator-like grouping', () => { + const capture: ICapture = makeDetailed(); + capture.reporter.report(ev('commandStarted', { commandName: 'build' })); + capture.reporter.report( + ev('operationRegistered', { operationId: 'op1', projectName: 'project-a', phaseName: '_phase:build' }) + ); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + capture.reporter.report( + ev('externalOutput', { stream: 'stdout', text: 'Building project-a...\n' }, { operationId: 'op1' }) + ); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + capture.reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + + expect(capture.getOutput()).toMatchSnapshot(); + }); + + it('emits a compact heartbeat only after the interval elapses', () => { + let now: number = 0; + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => { + output += text; + }, + nowMs: () => now, + heartbeatIntervalMs: 30000 + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + + now = 10000; + expect(reporter.emitHeartbeatIfDue()).toBe(false); + now = 30000; + expect(reporter.emitHeartbeatIfDue()).toBe(true); + // Immediately after emitting, the timer resets. + expect(reporter.emitHeartbeatIfDue()).toBe(false); + + expect(output).toContain('still running'); + }); +}); diff --git a/libraries/reporter/src/test/__snapshots__/PlaintextReporter.test.ts.snap b/libraries/reporter/src/test/__snapshots__/PlaintextReporter.test.ts.snap new file mode 100644 index 0000000000..8392b94e8a --- /dev/null +++ b/libraries/reporter/src/test/__snapshots__/PlaintextReporter.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`PlaintextReporter renders a stable concise plaintext transcript 1`] = ` +"Starting \\"rush build\\" +project-a: success +[warning] RUSH_INPUT_UNKNOWN_PROJECT +project-b: failure +rush build failed (1 failed) +" +`; + +exports[`PlaintextReporter renders a stable detailed transcript with StreamCollator-like grouping 1`] = ` +"Starting \\"rush build\\" + +==[ project-a (_phase:build) ]== +Building project-a... +project-a: success +rush build succeeded (1/1 operations, 0 failed) +" +`; diff --git a/research/feature-list.json b/research/feature-list.json index 08e4313a83..e3836606a7 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -227,7 +227,7 @@ "Retain StreamCollator-like operation grouping in detailed CI mode", "Add stable plaintext snapshot tests" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index fd51f37335..e6ee6115e5 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -291,3 +291,16 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - color resolution, colorizer, truncate, +N more, 3-row render, throttle; reporter hide/paint/throttle, success line + cursor restore, failure diag+log, watch summary, non-TTY tests pass; all exports @beta Next: Feature 19/28 - Plaintext and non-TTY reporter + +[2026-07-14] Feature 19/28 COMPLETE: Plaintext and non-TTY reporter + Files (new): + - reporters/PlaintextReporter.ts (IReporter; append-only via injected write; no cursor codes; color off default; variant concise|detailed; emits Starting line, terminal status lines, [severity] code diagnostics, final result; emitHeartbeatIfDue 30s; detailed groups externalOutput under ==[ project (phase) ]== StreamCollator-like) + - test/PlaintextReporter.test.ts + __snapshots__/PlaintextReporter.test.ts.snap (concise + detailed) + Files (modified): index.ts, api.md + Notes: + - No-ANSI assertion (append-only, no cursor, color off). Heartbeat interval-based via injected clock (report resets lastOutput). + - Detailed buffers external output per operationId, flushes under header on terminal status. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - no-ANSI, concise snapshot, detailed grouping snapshot, heartbeat timing tests pass; all exports @beta + Next: Feature 20/28 - JSON and AI reporters From 8dd75085e1f6d3b2b82bccc7eb8c71d4b128963d Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:05:13 +0000 Subject: [PATCH 10/22] Add rush change file for plaintext reporter Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-05-03.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json new file mode 100644 index 0000000000..4faf1d972a --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the append-only plaintext and non-TTY reporter with concise and detailed variants, a 30-second heartbeat, StreamCollator-like grouping in detailed CI mode, and stable snapshots", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From e0d4572e758205880080f1b0c1c07a26ce9325c4 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:12:16 +0000 Subject: [PATCH 11/22] Add JSON and bounded AI reporters Add the machine reporters for @rushstack/reporter (#5858). - Add JsonReporter, which emits the complete versioned NDJSON event stream on exclusive stdout and replaces an oversized record with a valid marker - Add AiReporter, a bounded projection that emits a status record and a final record with the result, scope, error codes and categories, structured remediation, aggregate counts, log reference, and artifact completeness - Cap the AI record at 64 KiB and 20 detailed diagnostics, represent warnings by count when failures exist, and exclude raw output and stacks - Keep the absolute log path in AI output while telemetry continues to exclude it - Add stdout-purity tests for both reporters and a telemetry path cross-check Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 102 ++++++ libraries/reporter/src/index.ts | 9 + .../reporter/src/reporters/AiReporter.ts | 320 ++++++++++++++++++ .../reporter/src/reporters/JsonReporter.ts | 79 +++++ .../reporter/src/test/JsonAiReporter.test.ts | 206 +++++++++++ research/feature-list.json | 2 +- research/progress.txt | 15 + 7 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/reporters/AiReporter.ts create mode 100644 libraries/reporter/src/reporters/JsonReporter.ts create mode 100644 libraries/reporter/src/test/JsonAiReporter.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 2ddb108c23..2f9b8bdcd8 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -4,6 +4,21 @@ ```ts +// @beta +export class AiReporter implements IReporter { + constructor(options: IAiReporterOptions); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export const BOOTSTRAP_BUFFER_MAX_BYTES: number; @@ -128,6 +143,72 @@ export function getPrivacyClassificationRank(classification: ReporterPrivacyClas // @beta export function getSignalExitCode(signal: NodeJS.Signals): number; +// @beta +export interface IAiDiagnostic { + // (undocumented) + readonly category: string; + // (undocumented) + readonly code: string; + // (undocumented) + readonly remediation?: readonly IRushRemediationAction[]; + // (undocumented) + readonly severity: string; +} + +// @beta +export interface IAiFinalRecord { + // (undocumented) + readonly diagnosticCategoryCounts: { + readonly [category: string]: number; + }; + // (undocumented) + readonly diagnostics: readonly IAiDiagnostic[]; + // (undocumented) + readonly errorCodes: readonly string[]; + // (undocumented) + readonly errorCount: number; + // (undocumented) + readonly exitCode: number; + // (undocumented) + readonly kind: 'ai.final'; + // (undocumented) + readonly log?: IAiLogReference; + // (undocumented) + readonly operationCounts: { + readonly [status: string]: number; + }; + // (undocumented) + readonly protocolVersion: IReporterProtocolVersion; + // (undocumented) + readonly result: 'succeeded' | 'failed'; + // (undocumented) + readonly scope: { + readonly commandName?: string; + readonly failedProjects: readonly string[]; + }; + // (undocumented) + readonly truncated: boolean; + // (undocumented) + readonly warningCount: number; +} + +// @beta +export interface IAiLogReference { + // (undocumented) + readonly complete: boolean; + // (undocumented) + readonly format?: string; + // (undocumented) + readonly path: string; +} + +// @beta +export interface IAiReporterOptions { + readonly maxBytes?: number; + readonly maxDetailedDiagnostics?: number; + readonly write: (text: string) => void; +} + // @beta export interface IAutomaticReporterPlan { readonly emergencyDestination: 'stderr'; @@ -281,6 +362,12 @@ export interface IJsonControls { readonly reporterJson: boolean; } +// @beta +export interface IJsonReporterOptions { + readonly maxRecordBytes?: number; + readonly write: (text: string) => void; +} + // @beta export interface ILifecycleEmitterOptions { readonly protocolVersion?: IReporterProtocolVersion; @@ -709,6 +796,21 @@ export interface IWriteBootstrapHandoffOptions { readonly pid?: number; } +// @beta +export class JsonReporter implements IReporter { + constructor(options: IJsonReporterOptions); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 112a05a18e..471d816f67 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -223,6 +223,15 @@ export type { export { DefaultInteractiveReporter } from './reporters/DefaultInteractiveReporter'; export type { IPlaintextReporterOptions } from './reporters/PlaintextReporter'; export { PlaintextReporter } from './reporters/PlaintextReporter'; +export type { IJsonReporterOptions } from './reporters/JsonReporter'; +export { JsonReporter } from './reporters/JsonReporter'; +export type { + IAiDiagnostic, + IAiLogReference, + IAiFinalRecord, + IAiReporterOptions +} from './reporters/AiReporter'; +export { AiReporter } from './reporters/AiReporter'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/reporters/AiReporter.ts b/libraries/reporter/src/reporters/AiReporter.ts new file mode 100644 index 0000000000..769227be57 --- /dev/null +++ b/libraries/reporter/src/reporters/AiReporter.ts @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporter } from '../manager/IReporter'; +import type { IRushRemediationAction } from '../diagnostics/IRushRemediationAction'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; + +const DEFAULT_AI_MAX_BYTES: number = 64 * 1024; +const DEFAULT_AI_MAX_DETAILED_DIAGNOSTICS: number = 20; +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'success', + 'successWithWarnings', + 'failure', + 'blocked', + 'skipped', + 'fromCache', + 'noOp' +]); + +/** + * A bounded diagnostic in an AI record. + * + * @beta + */ +export interface IAiDiagnostic { + readonly code: string; + readonly category: string; + readonly severity: string; + readonly remediation?: readonly IRushRemediationAction[]; +} + +/** + * The AI reporter's log reference. + * + * @beta + */ +export interface IAiLogReference { + readonly path: string; + readonly format?: string; + readonly complete: boolean; +} + +/** + * The AI reporter's bounded final record. + * + * @beta + */ +export interface IAiFinalRecord { + readonly kind: 'ai.final'; + readonly protocolVersion: IReporterProtocolVersion; + readonly result: 'succeeded' | 'failed'; + readonly exitCode: number; + readonly scope: { readonly commandName?: string; readonly failedProjects: readonly string[] }; + readonly errorCodes: readonly string[]; + readonly diagnosticCategoryCounts: { readonly [category: string]: number }; + readonly diagnostics: readonly IAiDiagnostic[]; + readonly errorCount: number; + readonly warningCount: number; + readonly operationCounts: { readonly [status: string]: number }; + readonly log?: IAiLogReference; + readonly truncated: boolean; +} + +/** + * Options for {@link AiReporter}. + * + * @beta + */ +export interface IAiReporterOptions { + /** + * The exclusive stdout sink. It receives bounded NDJSON records only. + */ + readonly write: (text: string) => void; + + /** + * The maximum size of the final record in bytes. Defaults to 64 KiB. + */ + readonly maxBytes?: number; + + /** + * The maximum number of detailed diagnostics. Defaults to 20. + */ + readonly maxDetailedDiagnostics?: number; +} + +/** + * The bounded AI reporter, a versioned public beta projection. + * + * @remarks + * The reporter owns stdout exclusively and emits a compact status record and a + * bounded final record. The final record carries the result and exit code, + * operation and project scope, error codes and categories, structured + * remediation, aggregate counts, the primary log reference, and artifact + * completeness. It is capped at 64 KiB and 20 detailed diagnostics, excludes raw + * logs and stacks, and represents warnings by count when failures exist. The + * absolute log path is local reporter output and never enters telemetry. + * + * @beta + */ +export class AiReporter implements IReporter { + public readonly name: string = 'ai'; + + private readonly _write: (text: string) => void; + private readonly _maxBytes: number; + private readonly _maxDetailedDiagnostics: number; + + private _protocolVersion: IReporterProtocolVersion; + private _commandName: string | undefined; + private readonly _projectByOperation: Map; + private readonly _operationCounts: { [status: string]: number }; + private readonly _failedProjects: string[]; + private readonly _errorDiagnostics: IAiDiagnostic[]; + private readonly _warningDiagnostics: IAiDiagnostic[]; + private readonly _diagnosticCategoryCounts: { [category: string]: number }; + private _errorCount: number; + private _warningCount: number; + private _logPath: string | undefined; + private _logFormat: string | undefined; + private _artifactComplete: boolean; + private _finalEmitted: boolean; + + public constructor(options: IAiReporterOptions) { + this._write = options.write; + this._maxBytes = options.maxBytes ?? DEFAULT_AI_MAX_BYTES; + this._maxDetailedDiagnostics = options.maxDetailedDiagnostics ?? DEFAULT_AI_MAX_DETAILED_DIAGNOSTICS; + + this._protocolVersion = REPORTER_PROTOCOL_VERSION; + this._commandName = undefined; + this._projectByOperation = new Map(); + this._operationCounts = {}; + this._failedProjects = []; + this._errorDiagnostics = []; + this._warningDiagnostics = []; + this._diagnosticCategoryCounts = {}; + this._errorCount = 0; + this._warningCount = 0; + this._logPath = undefined; + this._logFormat = undefined; + this._artifactComplete = true; + this._finalEmitted = false; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this._protocolVersion = event.protocolVersion; + switch (event.type) { + case 'commandStarted': { + this._commandName = (event.payload as { commandName: string }).commandName; + this._write( + `${JSON.stringify({ + kind: 'ai.status', + protocolVersion: this._protocolVersion, + commandName: this._commandName + })}\n` + ); + break; + } + case 'operationRegistered': { + const payload: { operationId: string; projectName?: string } = event.payload as { + operationId: string; + projectName?: string; + }; + if (payload.projectName !== undefined) { + this._projectByOperation.set(payload.operationId, payload.projectName); + } + break; + } + case 'operationStatusChanged': { + const payload: { operationId: string; status: string } = event.payload as { + operationId: string; + status: string; + }; + if (TERMINAL_STATUSES.has(payload.status)) { + this._operationCounts[payload.status] = (this._operationCounts[payload.status] ?? 0) + 1; + if (payload.status === 'failure') { + const projectName: string = + this._projectByOperation.get(payload.operationId) ?? + event.scope?.projectName ?? + payload.operationId; + this._failedProjects.push(projectName); + } + } + break; + } + case 'diagnosticEmitted': { + this._collectDiagnostic(event.payload as IAiDiagnostic); + break; + } + case 'artifactAvailable': { + const payload: { role?: string; path?: string; format?: string; complete?: boolean } = + event.payload as { role?: string; path?: string; format?: string; complete?: boolean }; + if (payload.role === 'log' && payload.path !== undefined) { + this._logPath = payload.path; + this._logFormat = payload.format; + this._artifactComplete = payload.complete !== false; + } + break; + } + case 'commandResult': { + const payload: { succeeded: boolean; exitCode: number } = event.payload as { + succeeded: boolean; + exitCode: number; + }; + this._emitFinal(payload.succeeded, payload.exitCode); + break; + } + default: + break; + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + if (!this._finalEmitted) { + this._emitFinal(this._errorCount === 0, this._errorCount === 0 ? 0 : 1); + } + } + + private _collectDiagnostic(diagnostic: IAiDiagnostic): void { + if (diagnostic.category !== undefined) { + this._diagnosticCategoryCounts[diagnostic.category] = + (this._diagnosticCategoryCounts[diagnostic.category] ?? 0) + 1; + } + if (diagnostic.severity === 'error') { + this._errorCount++; + this._errorDiagnostics.push({ + code: diagnostic.code, + category: diagnostic.category, + severity: 'error', + remediation: diagnostic.remediation + }); + } else if (diagnostic.severity === 'warning') { + this._warningCount++; + this._warningDiagnostics.push({ + code: diagnostic.code, + category: diagnostic.category, + severity: 'warning', + remediation: diagnostic.remediation + }); + } + } + + private _emitFinal(succeeded: boolean, exitCode: number): void { + if (this._finalEmitted) { + return; + } + this._finalEmitted = true; + + const hasFailures: boolean = !succeeded || this._errorCount > 0; + // When failures exist, warnings are represented by counts only. Warning-only + // success may include bounded warning details. + const detailedSource: IAiDiagnostic[] = hasFailures ? this._errorDiagnostics : this._warningDiagnostics; + + const record: { + kind: 'ai.final'; + protocolVersion: IReporterProtocolVersion; + result: 'succeeded' | 'failed'; + exitCode: number; + scope: { commandName?: string; failedProjects: string[] }; + errorCodes: string[]; + diagnosticCategoryCounts: { [category: string]: number }; + diagnostics: IAiDiagnostic[]; + errorCount: number; + warningCount: number; + operationCounts: { [status: string]: number }; + log?: IAiLogReference; + truncated: boolean; + } = { + kind: 'ai.final', + protocolVersion: this._protocolVersion, + result: succeeded ? 'succeeded' : 'failed', + exitCode, + scope: { commandName: this._commandName, failedProjects: [...this._failedProjects] }, + errorCodes: [...new Set(this._errorDiagnostics.map((d: IAiDiagnostic) => d.code))].sort(), + diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, + diagnostics: detailedSource.slice(0, this._maxDetailedDiagnostics), + errorCount: this._errorCount, + warningCount: this._warningCount, + operationCounts: { ...this._operationCounts }, + truncated: detailedSource.length > this._maxDetailedDiagnostics + }; + + if (this._logPath !== undefined) { + record.log = { path: this._logPath, format: this._logFormat, complete: this._artifactComplete }; + } + + // Enforce the byte cap by progressively trimming detailed diagnostics, then + // error codes, then failed projects, so the record always fits the budget. + const trimTargets: Array<{ get: () => unknown[]; set: (value: unknown[]) => void }> = [ + { + get: () => record.diagnostics, + set: (value: unknown[]) => (record.diagnostics = value as IAiDiagnostic[]) + }, + { get: () => record.errorCodes, set: (value: unknown[]) => (record.errorCodes = value as string[]) }, + { + get: () => record.scope.failedProjects, + set: (value: unknown[]) => (record.scope.failedProjects = value as string[]) + } + ]; + for (const target of trimTargets) { + while (Buffer.byteLength(JSON.stringify(record), 'utf8') > this._maxBytes && target.get().length > 0) { + target.set(target.get().slice(0, target.get().length - 1)); + record.truncated = true; + } + if (Buffer.byteLength(JSON.stringify(record), 'utf8') <= this._maxBytes) { + break; + } + } + + this._write(`${JSON.stringify(record)}\n`); + } +} diff --git a/libraries/reporter/src/reporters/JsonReporter.ts b/libraries/reporter/src/reporters/JsonReporter.ts new file mode 100644 index 0000000000..ad372f07d7 --- /dev/null +++ b/libraries/reporter/src/reporters/JsonReporter.ts @@ -0,0 +1,79 @@ +// 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 } from '../manager/IReporter'; +import { encodeNdjsonRecord, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; + +/** + * Options for {@link JsonReporter}. + * + * @beta + */ +export interface IJsonReporterOptions { + /** + * The exclusive stdout sink. It receives NDJSON payload records only. + */ + readonly write: (text: string) => void; + + /** + * The maximum NDJSON record size in bytes. Defaults to the protocol limit. + */ + readonly maxRecordBytes?: number; +} + +/** + * The stable machine reporter that emits the complete versioned NDJSON event stream. + * + * @remarks + * The reporter owns stdout exclusively; every line is a JSON-serialized event + * envelope and nothing else. An oversized event is replaced with a compact + * record-too-large marker so the stream stays valid NDJSON. + * + * @beta + */ +export class JsonReporter implements IReporter { + public readonly name: string = 'json'; + + private readonly _write: (text: string) => void; + private readonly _maxRecordBytes: number; + + public constructor(options: IJsonReporterOptions) { + this._write = options.write; + this._maxRecordBytes = options.maxRecordBytes ?? REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + try { + this._write(encodeNdjsonRecord(event, { maxRecordBytes: this._maxRecordBytes })); + } catch (error) { + if (error instanceof NdjsonRecordTooLargeError) { + this._write( + encodeNdjsonRecord({ + protocolVersion: event.protocolVersion, + eventId: event.eventId, + sessionId: event.sessionId, + sequence: event.sequence, + type: 'extension', + payload: { name: 'rush.reporter.recordTooLarge', originalType: event.type } + }) + ); + return; + } + throw error; + } + } + + public async flushAsync(): Promise { + /* NDJSON is written eagerly. */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} diff --git a/libraries/reporter/src/test/JsonAiReporter.test.ts b/libraries/reporter/src/test/JsonAiReporter.test.ts new file mode 100644 index 0000000000..2b9276d8cf --- /dev/null +++ b/libraries/reporter/src/test/JsonAiReporter.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + JsonReporter, + AiReporter, + TelemetrySubscriber, + type IAiFinalRecord, + type IReporterEventEnvelope, + type ITelemetryAggregate +} from '../index'; + +function ev( + type: string, + payload: unknown = {}, + scope?: { operationId?: string; projectName?: string } +): IReporterEventEnvelope { + return { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt', + sessionId: 'sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type, + payload, + scope + } as unknown as IReporterEventEnvelope; +} + +function parseLines(output: string): Record[] { + return output + .split('\n') + .filter((line: string) => line.length > 0) + .map((line: string) => JSON.parse(line) as Record); +} + +describe('JsonReporter', () => { + it('emits every event as a valid NDJSON record on stdout', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ write: (text: string) => (output += text) }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + + const records: Record[] = parseLines(output); + expect(records).toHaveLength(3); + expect(records.map((r) => r.type)).toEqual(['commandStarted', 'operationStatusChanged', 'commandResult']); + // stdout purity: the output is only NDJSON, one record per line. + expect(output.endsWith('\n')).toBe(true); + }); + + it('replaces an oversized record with a valid too-large marker', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ + write: (text: string) => (output += text), + maxRecordBytes: 50 + }); + reporter.report(ev('externalOutput', { stream: 'stdout', text: 'x'.repeat(1000) })); + + const records: Record[] = parseLines(output); + expect(records).toHaveLength(1); + expect((records[0].payload as { name: string }).name).toBe('rush.reporter.recordTooLarge'); + }); +}); + +describe('AiReporter', () => { + function run( + events: IReporterEventEnvelope[], + options?: { maxBytes?: number } + ): { + records: Record[]; + final: IAiFinalRecord; + } { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ + write: (text: string) => (output += text), + maxBytes: options?.maxBytes + }); + for (const event of events) { + reporter.report(event); + } + const records: Record[] = parseLines(output); + return { records, final: records[records.length - 1] as unknown as IAiFinalRecord }; + } + + it('emits a status record and a bounded final record with scope, codes, and log', () => { + const { records, final } = run([ + ev('commandStarted', { commandName: 'build' }), + ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' }), + ev('operationStatusChanged', { operationId: 'op1', status: 'failure' }), + ev('diagnosticEmitted', { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + remediation: [{ descriptionKey: 'r', command: 'rush rebuild', automatedExecutionSafety: 'safe' }] + }), + ev('artifactAvailable', { role: 'log', path: '/abs/rush.log', format: 'plaintext', complete: true }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(records[0].kind).toBe('ai.status'); + expect(final.kind).toBe('ai.final'); + expect(final.result).toBe('failed'); + expect(final.exitCode).toBe(1); + expect(final.scope.commandName).toBe('build'); + expect(final.scope.failedProjects).toEqual(['project-a']); + expect(final.errorCodes).toEqual(['RUSH_OPERATION_FAILED']); + expect(final.diagnostics[0].remediation?.[0].command).toBe('rush rebuild'); + expect(final.operationCounts).toEqual({ failure: 1 }); + expect(final.log).toEqual({ path: '/abs/rush.log', format: 'plaintext', complete: true }); + }); + + it('caps detailed diagnostics at 20 and marks the record truncated', () => { + const events: IReporterEventEnvelope[] = [ev('commandStarted', { commandName: 'build' })]; + for (let i: number = 0; i < 25; i++) { + events.push(ev('diagnosticEmitted', { code: `RUSH_E_${i}`, category: 'operation', severity: 'error' })); + } + events.push(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + + const { final } = run(events); + expect(final.diagnostics).toHaveLength(20); + expect(final.truncated).toBe(true); + expect(final.errorCount).toBe(25); + }); + + it('enforces the byte cap by trimming diagnostics', () => { + const events: IReporterEventEnvelope[] = [ev('commandStarted', { commandName: 'build' })]; + for (let i: number = 0; i < 10; i++) { + events.push( + ev('diagnosticEmitted', { + code: `RUSH_ERROR_WITH_A_LONG_CODE_${i}`, + category: 'operation', + severity: 'error', + remediation: [ + { + descriptionKey: `remediation.step.${i}`, + command: 'rush rebuild --verbose', + automatedExecutionSafety: 'requires-confirmation' + } + ] + }) + ); + } + events.push(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text), maxBytes: 400 }); + for (const event of events) { + reporter.report(event); + } + const finalLine: string = output.trim().split('\n').pop() ?? ''; + expect(Buffer.byteLength(finalLine, 'utf8')).toBeLessThanOrEqual(400); + expect((JSON.parse(finalLine) as IAiFinalRecord).truncated).toBe(true); + }); + + it('represents warnings by count when failures exist but details them on warning-only success', () => { + const failing = run([ + ev('diagnosticEmitted', { code: 'RUSH_E', category: 'operation', severity: 'error' }), + ev('diagnosticEmitted', { code: 'RUSH_W', category: 'input', severity: 'warning' }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + expect(failing.final.warningCount).toBe(1); + expect(failing.final.diagnostics.every((d) => d.severity === 'error')).toBe(true); + + const warningOnly = run([ + ev('diagnosticEmitted', { code: 'RUSH_W', category: 'input', severity: 'warning' }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]); + expect(warningOnly.final.result).toBe('succeeded'); + expect(warningOnly.final.diagnostics.map((d) => d.severity)).toEqual(['warning']); + }); + + it('excludes raw external output and keeps stdout pure JSON', () => { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('externalOutput', { stream: 'stdout', text: 'SENSITIVE-RAW-abc' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + + expect(output).not.toContain('SENSITIVE-RAW-abc'); + // Every emitted line parses as JSON. + expect(() => parseLines(output)).not.toThrow(); + }); + + it('keeps the absolute log path in AI output but never in telemetry', () => { + const logPath: string = '/home/user/.rush/logs/latest.log'; + const events: IReporterEventEnvelope[] = [ + ev('commandStarted', { commandName: 'build' }), + ev('artifactAvailable', { role: 'log', path: logPath, complete: true }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]; + + const { final } = run(events); + expect(final.log?.path).toBe(logPath); + + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + for (const event of events) { + telemetry.ingest(event); + } + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(JSON.stringify(aggregate)).not.toContain(logPath); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index e3836606a7..d5ae482756 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -239,7 +239,7 @@ "Keep absolute log paths as local reporter output and never in telemetry", "Add stdout-purity tests for JSON and AI" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index e6ee6115e5..9a3a20e323 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -304,3 +304,18 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - no-ANSI, concise snapshot, detailed grouping snapshot, heartbeat timing tests pass; all exports @beta Next: Feature 20/28 - JSON and AI reporters + +[2026-07-14] Feature 20/28 COMPLETE: JSON and AI reporters + Files (new): + - reporters/JsonReporter.ts (IReporter; encodeNdjsonRecord per event to exclusive stdout; oversized -> rush.reporter.recordTooLarge marker so stream stays valid NDJSON) + - reporters/AiReporter.ts (IReporter; ai.status on commandStarted; ai.final on commandResult/close; IAiFinalRecord result/exitCode/scope{commandName,failedProjects}/errorCodes/diagnosticCategoryCounts/diagnostics(remediation)/counts/log{path,format,complete}; caps 64KiB + 20 diagnostics; warnings by count when failures else warning-only details; excludes raw external output/stacks; byte-cap trims diagnostics->errorCodes->failedProjects) + - test/JsonAiReporter.test.ts + Files (modified): index.ts, api.md + Notes: + - Fixed byte-cap: trim errorCodes/failedProjects too (base record with many long codes exceeded tiny maxBytes otherwise). Real cap 64KiB. + - Log path in AI final.log.path (local) but telemetry aggregate cross-check asserts path NOT present. + - stdout purity: every emitted line parses as JSON. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - JSON stream + oversized marker; AI status+final, log/scope/codes/remediation, 20-cap, 64KiB byte-cap trim, warnings-by-count vs warning-only, raw-excluded, stdout purity, telemetry path cross-check tests pass; all exports @beta + Next: Feature 21/28 - Full-detail file reporter (retention + fallback) From 262497055d2b1e5fe15483f1531daab5950eed10 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:12:39 +0000 Subject: [PATCH 12/22] Add rush change file for JSON and AI reporters Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-12-29.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json new file mode 100644 index 0000000000..2759628055 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the JSON reporter that emits the complete NDJSON event stream and the bounded AI reporter with a 64 KiB, 20-diagnostic projection carrying result, scope, codes, remediation, counts, and log reference", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From d135a4cb20bd2de6a2a285d2a4253b6c4211e8eb Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:18:39 +0000 Subject: [PATCH 13/22] Add full-detail file reporter with retention and fallback Add the full-detail file reporter for @rushstack/reporter (#5858). - Add FileReporter, which writes a debug NDJSON invocation log to /rush-logs/--.log with owner-only permissions and redacts fields classified as secret - Maintain a latest.log pointer for both successful and failed commands - Buffer events until the first flush and fall back to the OS temp folder - Delete logs older than 14 days and cap retention at 20 sessions - Treat failure at both paths as nonfatal, emitting an emergency warning and marking the artifact unavailable - Cover writing, permissions, redaction, retention, fallback, and failure with tests against real temp directories Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 40 +++ libraries/reporter/src/index.ts | 2 + .../reporter/src/reporters/FileReporter.ts | 300 ++++++++++++++++++ .../reporter/src/test/FileReporter.test.ts | 196 ++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 14 + 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/reporters/FileReporter.ts create mode 100644 libraries/reporter/src/test/FileReporter.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 2f9b8bdcd8..85a262688a 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -128,6 +128,22 @@ export const EXIT_CODE_SUCCESS: 0; // @beta export const FILE_REPORTER_DEFAULT_LOG_LEVEL: ReporterLogLevel; +// @beta +export class FileReporter implements IReporter { + constructor(options?: IFileReporterOptions); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + getArtifact(): IFileReporterArtifact; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export function filterEventsForLogLevel(logLevel: ReporterLogLevel, events: readonly IReporterEventEnvelope[]): IReporterEventEnvelope[]; @@ -349,6 +365,24 @@ export interface IEngineSinkResolution { readonly sink: IReporterEventSink; } +// @beta +export interface IFileReporterArtifact { + readonly available: boolean; + readonly path?: string; +} + +// @beta +export interface IFileReporterOptions { + readonly actionName?: string; + readonly commonTempFolder?: string; + readonly emergencyWarn?: (message: string) => void; + readonly maxSessions?: number; + readonly nowMs?: () => number; + readonly osTempFolder?: string; + readonly pid?: number; + readonly retentionDays?: number; +} + // @beta export interface IInteractiveTerminal { readonly columns: number; @@ -814,6 +848,9 @@ export class JsonReporter implements IReporter { // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; +// @beta +export const LATEST_LOG_NAME: 'latest.log'; + // @beta export type LegacyBeforeLogHook = (telemetry: Record) => void; @@ -1015,6 +1052,9 @@ export const RUSH_DIAGNOSTIC_TEMPLATES: { // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; +// @beta +export const RUSH_LOGS_DIR_NAME: 'rush-logs'; + // @beta export const RUSH_PLUGIN_API_VERSION: '1.0.0'; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 471d816f67..b818a73843 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -232,6 +232,8 @@ export type { IAiReporterOptions } from './reporters/AiReporter'; export { AiReporter } from './reporters/AiReporter'; +export type { IFileReporterArtifact, IFileReporterOptions } from './reporters/FileReporter'; +export { FileReporter, RUSH_LOGS_DIR_NAME, LATEST_LOG_NAME } from './reporters/FileReporter'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/reporters/FileReporter.ts b/libraries/reporter/src/reporters/FileReporter.ts new file mode 100644 index 0000000000..2649435170 --- /dev/null +++ b/libraries/reporter/src/reporters/FileReporter.ts @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporter } from '../manager/IReporter'; + +/** + * The subdirectory that holds full-detail invocation logs. `rush purge` removes it. + * + * @beta + */ +export const RUSH_LOGS_DIR_NAME: 'rush-logs' = 'rush-logs'; + +/** + * The name of the pointer to the latest invocation log. + * + * @beta + */ +export const LATEST_LOG_NAME: 'latest.log' = 'latest.log'; + +const DEFAULT_RETENTION_DAYS: number = 14; +const DEFAULT_MAX_SESSIONS: number = 20; +const OWNER_ONLY_MODE: number = 0o600; +const MS_PER_DAY: number = 24 * 60 * 60 * 1000; + +/** + * The resolved full-detail log artifact. + * + * @beta + */ +export interface IFileReporterArtifact { + /** + * Whether the log was successfully written. + */ + readonly available: boolean; + + /** + * The absolute path to the log, when available. + */ + readonly path?: string; +} + +/** + * Options for {@link FileReporter}. + * + * @beta + */ +export interface IFileReporterOptions { + /** + * The repository common temp folder. The log is written under its `rush-logs` + * subdirectory when available. + */ + readonly commonTempFolder?: string; + + /** + * The OS temp folder used as a fallback. Defaults to the OS temp directory. + */ + readonly osTempFolder?: string; + + /** + * The action name embedded in the log file name. + */ + readonly actionName?: string; + + /** + * The process id embedded in the log file name. Defaults to `process.pid`. + */ + readonly pid?: number; + + /** + * Returns the current time in milliseconds. Injectable for testing. + */ + readonly nowMs?: () => number; + + /** + * The retention window in days. Defaults to 14. + */ + readonly retentionDays?: number; + + /** + * The maximum number of retained sessions. Defaults to 20. + */ + readonly maxSessions?: number; + + /** + * Writes a one-line emergency warning when the log cannot be written. + */ + readonly emergencyWarn?: (message: string) => void; +} + +/** + * Writes a full-detail, debug-level invocation log with retention and an OS-temp fallback. + * + * @remarks + * The reporter buffers events and, on flush, writes them as NDJSON to + * `/rush-logs/--.log` with + * owner-only permissions, redacting fields classified as secret. It maintains a + * `latest.log` pointer for both successful and failed commands, deletes logs + * older than 14 days, caps retention at 20 sessions, and falls back to the OS + * temp folder. Failure at both paths is nonfatal: it emits an emergency warning + * and marks the artifact unavailable. + * + * @beta + */ +export class FileReporter implements IReporter { + public readonly name: string = 'file'; + + private readonly _commonTempFolder: string | undefined; + private readonly _osTempFolder: string; + private readonly _actionName: string; + private readonly _pid: number; + private readonly _nowMs: () => number; + private readonly _retentionDays: number; + private readonly _maxSessions: number; + private readonly _emergencyWarn: (message: string) => void; + + private readonly _lines: string[]; + private _writtenCount: number; + private _targetResolved: boolean; + private _available: boolean; + private _targetPath: string | undefined; + private readonly _fileName: string; + + public constructor(options: IFileReporterOptions = {}) { + this._commonTempFolder = options.commonTempFolder; + this._osTempFolder = options.osTempFolder ?? os.tmpdir(); + this._actionName = options.actionName ?? 'rush'; + this._pid = options.pid ?? process.pid; + this._nowMs = options.nowMs ?? (() => Date.now()); + this._retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS; + this._maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + this._emergencyWarn = + options.emergencyWarn ?? + ((message: string) => { + process.stderr.write(`${message}\n`); + }); + + this._lines = []; + this._writtenCount = 0; + this._targetResolved = false; + this._available = false; + this._targetPath = undefined; + + const timestamp: string = new Date(this._nowMs()).toISOString().replace(/[:.]/g, '-'); + this._fileName = `${timestamp}-${this._pid}-${this._actionName}.log`; + } + + public async initializeAsync(): Promise { + /* Events are buffered until the first flush. */ + } + + public report(event: IReporterEventEnvelope): void { + this._lines.push(this._formatLine(event)); + } + + public async flushAsync(): Promise { + await this._writeAsync(); + } + + public async closeAsync(): Promise { + await this._writeAsync(); + } + + /** + * Returns the resolved log artifact. + */ + public getArtifact(): IFileReporterArtifact { + return this._targetPath !== undefined + ? { available: this._available, path: this._targetPath } + : { available: this._available }; + } + + private _formatLine(event: IReporterEventEnvelope): string { + let payload: unknown = event.payload; + if (event.privacy === 'secret') { + payload = '[secret]'; + } else if (event.type === 'diagnosticEmitted') { + payload = this._redactDiagnostic(event.payload); + } + return `${JSON.stringify({ ...event, payload })}\n`; + } + + private _redactDiagnostic(payload: unknown): unknown { + const diagnostic: { parameters?: { [name: string]: { value: unknown; privacy: string } } } = payload as { + parameters?: { [name: string]: { value: unknown; privacy: string } }; + }; + if (!diagnostic.parameters) { + return payload; + } + const parameters: { [name: string]: { value: unknown; privacy: string } } = {}; + for (const [name, classified] of Object.entries(diagnostic.parameters)) { + parameters[name] = + classified.privacy === 'secret' ? { value: '[secret]', privacy: 'secret' } : classified; + } + return { ...diagnostic, parameters }; + } + + private async _writeAsync(): Promise { + if (!this._targetResolved) { + this._targetResolved = true; + await this._resolveTargetAsync(); + } + if (!this._available || this._targetPath === undefined) { + return; + } + const newLines: string[] = this._lines.slice(this._writtenCount); + if (newLines.length > 0) { + await fs.promises.appendFile(this._targetPath, newLines.join(''), { encoding: 'utf8' }); + this._writtenCount = this._lines.length; + } + } + + private async _resolveTargetAsync(): Promise { + const candidateDirs: string[] = []; + if (this._commonTempFolder !== undefined) { + candidateDirs.push(path.join(this._commonTempFolder, RUSH_LOGS_DIR_NAME)); + } + candidateDirs.push(path.join(this._osTempFolder, RUSH_LOGS_DIR_NAME)); + + let lastError: Error | undefined; + for (const dir of candidateDirs) { + try { + await fs.promises.mkdir(dir, { recursive: true }); + const filePath: string = path.join(dir, this._fileName); + await fs.promises.writeFile(filePath, '', { mode: OWNER_ONLY_MODE }); + await fs.promises.chmod(filePath, OWNER_ONLY_MODE); + this._targetPath = filePath; + this._available = true; + await this._updateLatestAsync(dir, filePath); + await this._applyRetentionAsync(dir); + return; + } catch (error) { + lastError = error as Error; + } + } + + this._available = false; + this._emergencyWarn( + `[reporter] Unable to write the full-detail log; the artifact is unavailable: ${lastError?.message ?? 'unknown error'}` + ); + } + + private async _updateLatestAsync(dir: string, filePath: string): Promise { + const latestPath: string = path.join(dir, LATEST_LOG_NAME); + try { + await fs.promises.rm(latestPath, { force: true }); + await fs.promises.symlink(path.basename(filePath), latestPath); + } catch { + try { + await fs.promises.copyFile(filePath, latestPath); + } catch { + /* latest.log is best-effort. */ + } + } + } + + private async _applyRetentionAsync(dir: string): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(dir); + } catch { + return; + } + + const cutoff: number = this._nowMs() - this._retentionDays * MS_PER_DAY; + const logs: { path: string; mtimeMs: number }[] = []; + for (const entry of entries) { + if (entry === LATEST_LOG_NAME || !entry.endsWith('.log')) { + continue; + } + const entryPath: string = path.join(dir, entry); + try { + const stats: fs.Stats = await fs.promises.stat(entryPath); + if (stats.mtimeMs < cutoff) { + await fs.promises.rm(entryPath, { force: true }); + } else { + logs.push({ path: entryPath, mtimeMs: stats.mtimeMs }); + } + } catch { + /* Ignore files that vanish. */ + } + } + + if (logs.length > this._maxSessions) { + logs.sort((a, b) => a.mtimeMs - b.mtimeMs); + const excess: number = logs.length - this._maxSessions; + for (let index: number = 0; index < excess; index++) { + try { + await fs.promises.rm(logs[index].path, { force: true }); + } catch { + /* Ignore. */ + } + } + } + } +} diff --git a/libraries/reporter/src/test/FileReporter.test.ts b/libraries/reporter/src/test/FileReporter.test.ts new file mode 100644 index 0000000000..bceece0fb9 --- /dev/null +++ b/libraries/reporter/src/test/FileReporter.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + FileReporter, + RUSH_LOGS_DIR_NAME, + LATEST_LOG_NAME, + type IFileReporterArtifact, + type IReporterEventEnvelope +} from '../index'; + +const FIXED_NOW: number = Date.UTC(2026, 6, 15, 1, 0, 0); +const MS_PER_DAY: number = 24 * 60 * 60 * 1000; + +function ev( + type: string, + payload: unknown = {}, + privacy: string = 'public' +): IReporterEventEnvelope { + return { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'evt', + sessionId: 'sess', + sequence: 1, + timestamp: '2026-07-15T01:00:00.000Z', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy, + required: true, + type, + payload + } as unknown as IReporterEventEnvelope; +} + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-reporter-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +describe('FileReporter', () => { + it('writes a debug NDJSON log with owner-only permissions and a latest.log pointer', async () => { + await withTempDir(async (base: string) => { + const reporter: FileReporter = new FileReporter({ + commonTempFolder: base, + actionName: 'build', + pid: 4242, + nowMs: () => FIXED_NOW + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('externalOutput', { stream: 'stdout', text: 'Building...\n' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + const artifact: IFileReporterArtifact = reporter.getArtifact(); + expect(artifact.available).toBe(true); + expect(artifact.path).toContain(path.join(base, RUSH_LOGS_DIR_NAME)); + expect(path.basename(artifact.path!)).toBe('2026-07-15T01-00-00-000Z-4242-build.log'); + + const content: string = await fs.promises.readFile(artifact.path!, 'utf8'); + const records: Record[] = content + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records.map((r) => r.type)).toEqual(['commandStarted', 'externalOutput', 'commandResult']); + + const latestPath: string = path.join(base, RUSH_LOGS_DIR_NAME, LATEST_LOG_NAME); + expect(fs.existsSync(latestPath)).toBe(true); + + if (process.platform !== 'win32') { + const stats: fs.Stats = await fs.promises.stat(artifact.path!); + expect(stats.mode % 0o1000).toBe(0o600); + } + }); + }); + + it('maintains latest.log for a failed command too', async () => { + await withTempDir(async (base: string) => { + const reporter: FileReporter = new FileReporter({ + commonTempFolder: base, + actionName: 'build', + nowMs: () => FIXED_NOW + }); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + await reporter.closeAsync(); + + expect(fs.existsSync(path.join(base, RUSH_LOGS_DIR_NAME, LATEST_LOG_NAME))).toBe(true); + }); + }); + + it('excludes secret fields but keeps local-sensitive values', async () => { + await withTempDir(async (base: string) => { + const reporter: FileReporter = new FileReporter({ commonTempFolder: base, nowMs: () => FIXED_NOW }); + reporter.report( + ev('diagnosticEmitted', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + severity: 'error', + parameters: { + token: { value: 'sk-secret-value', privacy: 'secret' }, + logPath: { value: '/home/user/install.log', privacy: 'local-sensitive' } + } + }) + ); + await reporter.closeAsync(); + + const content: string = await fs.promises.readFile(reporter.getArtifact().path!, 'utf8'); + expect(content).not.toContain('sk-secret-value'); + expect(content).toContain('[secret]'); + expect(content).toContain('/home/user/install.log'); + }); + }); + + it('deletes logs older than the retention window and caps the session count', async () => { + await withTempDir(async (base: string) => { + const logsDir: string = path.join(base, RUSH_LOGS_DIR_NAME); + await fs.promises.mkdir(logsDir, { recursive: true }); + + const oldLog: string = path.join(logsDir, 'old-1-build.log'); + await fs.promises.writeFile(oldLog, '{}\n'); + const oldTime: Date = new Date(FIXED_NOW - 20 * MS_PER_DAY); + await fs.promises.utimes(oldLog, oldTime, oldTime); + + // Create more than the cap of recent logs. + for (let i: number = 0; i < 22; i++) { + const recent: string = path.join(logsDir, `recent-${i}-build.log`); + await fs.promises.writeFile(recent, '{}\n'); + const time: Date = new Date(FIXED_NOW - (i + 1) * 1000); + await fs.promises.utimes(recent, time, time); + } + + const reporter: FileReporter = new FileReporter({ + commonTempFolder: base, + maxSessions: 20, + nowMs: () => FIXED_NOW + }); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + const remaining: string[] = (await fs.promises.readdir(logsDir)).filter( + (name: string) => name.endsWith('.log') && name !== LATEST_LOG_NAME + ); + expect(remaining).not.toContain('old-1-build.log'); + expect(remaining.length).toBe(20); + }); + }); + + it('falls back to the OS temp folder when the repository path fails', async () => { + await withTempDir(async (base: string) => { + const blocker: string = path.join(base, 'blocker'); + await fs.promises.writeFile(blocker, 'not a directory'); + const osTemp: string = path.join(base, 'os-temp'); + await fs.promises.mkdir(osTemp); + + const reporter: FileReporter = new FileReporter({ + commonTempFolder: blocker, + osTempFolder: osTemp, + nowMs: () => FIXED_NOW + }); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + const artifact: IFileReporterArtifact = reporter.getArtifact(); + expect(artifact.available).toBe(true); + expect(artifact.path).toContain(path.join(osTemp, RUSH_LOGS_DIR_NAME)); + }); + }); + + it('treats failure at both paths as nonfatal with an emergency warning', async () => { + await withTempDir(async (base: string) => { + const repoBlocker: string = path.join(base, 'repo-blocker'); + const osBlocker: string = path.join(base, 'os-blocker'); + await fs.promises.writeFile(repoBlocker, 'file'); + await fs.promises.writeFile(osBlocker, 'file'); + + const warnings: string[] = []; + const reporter: FileReporter = new FileReporter({ + commonTempFolder: repoBlocker, + osTempFolder: osBlocker, + nowMs: () => FIXED_NOW, + emergencyWarn: (message: string) => warnings.push(message) + }); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + await reporter.closeAsync(); + + expect(reporter.getArtifact().available).toBe(false); + expect(warnings.some((w: string) => w.includes('artifact is unavailable'))).toBe(true); + }); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index d5ae482756..32ffd4756c 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -252,7 +252,7 @@ "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 + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 9a3a20e323..0b1706bf95 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -319,3 +319,17 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - JSON stream + oversized marker; AI status+final, log/scope/codes/remediation, 20-cap, 64KiB byte-cap trim, warnings-by-count vs warning-only, raw-excluded, stdout purity, telemetry path cross-check tests pass; all exports @beta Next: Feature 21/28 - Full-detail file reporter (retention + fallback) + +[2026-07-14] Feature 21/28 COMPLETE: Full-detail file reporter (retention + fallback) + Files (new): + - reporters/FileReporter.ts (IReporter; RUSH_LOGS_DIR_NAME 'rush-logs', LATEST_LOG_NAME 'latest.log'; buffers lines, writes NDJSON at debug to /rush-logs/--.log; owner-only 0o600; redacts secret fields ->[secret], keeps local-sensitive; latest.log symlink/copy for success+failure; retention delete >14d + cap 20; OS-temp fallback; both-fail nonfatal emergencyWarn + getArtifact().available=false) + - test/FileReporter.test.ts (real temp dirs) + Files (modified): index.ts, api.md + Notes: + - report() buffers; _writeAsync (flush/close) resolves target lazily: try repoDir then osTempDir; appendFile new lines. + - Timestamp filename: toISOString().replace(/[:.]/g,'-'). rush purge removes the rush-logs dir (path convention). + - Test mode check uses %0o1000 (no-bitwise lint), guarded on non-win32. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - write+perms+latest, latest-on-failure, secret redaction, retention (old delete + cap 20), OS-temp fallback, both-fail nonfatal tests pass; all exports @beta + Next: Feature 22/28 - Legacy reporter (selectable + emergency fallback) From ceaaba330cb7d17c2905996a6b5f962c2f49f080 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:19:05 +0000 Subject: [PATCH 14/22] Add rush change file for full-detail file reporter Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-18-53.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json new file mode 100644 index 0000000000..13517034e7 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the full-detail file reporter that writes a debug NDJSON invocation log with owner-only permissions, secret redaction, a latest.log pointer, 14-day and 20-session retention, an OS-temp fallback, and nonfatal failure handling", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From d8a3a49977e942b084e53c9501321314d2ec5bd3 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:23:52 +0000 Subject: [PATCH 15/22] Add legacy reporter and emergency fallback Add the legacy reporter for @rushstack/reporter (#5858). - Add LegacyReporter, which reproduces the current Rush output: the start line, the parallelism line, StreamCollator-style operation headers with grouped output, and a success or failure summary with durations - Add isLegacyEmergencyFallbackRequested and confirm RUSH_REPORTER=legacy selects the legacy reporter as an emergency fallback - Validate the reporter against the frozen legacy output snapshots Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 27 ++ libraries/reporter/src/index.ts | 6 + .../reporter/src/reporters/LegacyReporter.ts | 234 ++++++++++++++++++ .../reporter/src/test/LegacyReporter.test.ts | 120 +++++++++ .../__snapshots__/LegacyReporter.test.ts.snap | 44 ++++ research/feature-list.json | 2 +- research/progress.txt | 13 + 7 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/reporters/LegacyReporter.ts create mode 100644 libraries/reporter/src/test/LegacyReporter.test.ts create mode 100644 libraries/reporter/src/test/__snapshots__/LegacyReporter.test.ts.snap diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 85a262688a..d4883397b5 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -402,6 +402,12 @@ export interface IJsonReporterOptions { readonly write: (text: string) => void; } +// @beta +export interface ILegacyReporterOptions { + readonly maxParallelism?: number; + readonly write: (text: string) => void; +} + // @beta export interface ILifecycleEmitterOptions { readonly protocolVersion?: IReporterProtocolVersion; @@ -779,6 +785,9 @@ export interface IShadowResultSummary { readonly succeeded: boolean; } +// @beta +export function isLegacyEmergencyFallbackRequested(env: Record): boolean; + // @beta export function isMachineReporter(reporter: ReporterName): boolean; @@ -860,6 +869,21 @@ export class LegacyFallbackSink implements IReporterEventSink { emit(): string; } +// @beta +export class LegacyReporter implements IReporter { + constructor(options: ILegacyReporterOptions); + // (undocumented) + closeAsync(): Promise; + // (undocumented) + flushAsync(): Promise; + // (undocumented) + initializeAsync(): Promise; + // (undocumented) + readonly name: string; + // (undocumented) + report(event: IReporterEventEnvelope): void; +} + // @beta export class LifecycleEmitter { constructor(options: ILifecycleEmitterOptions); @@ -1061,6 +1085,9 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; +// @beta +export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; + // @beta export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index b818a73843..13ebaceaa6 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -234,6 +234,12 @@ export type { export { AiReporter } from './reporters/AiReporter'; export type { IFileReporterArtifact, IFileReporterOptions } from './reporters/FileReporter'; export { FileReporter, RUSH_LOGS_DIR_NAME, LATEST_LOG_NAME } from './reporters/FileReporter'; +export type { ILegacyReporterOptions } from './reporters/LegacyReporter'; +export { + LegacyReporter, + RUSH_REPORTER_ENV_VAR, + isLegacyEmergencyFallbackRequested +} from './reporters/LegacyReporter'; export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { diff --git a/libraries/reporter/src/reporters/LegacyReporter.ts b/libraries/reporter/src/reporters/LegacyReporter.ts new file mode 100644 index 0000000000..25c5daafad --- /dev/null +++ b/libraries/reporter/src/reporters/LegacyReporter.ts @@ -0,0 +1,234 @@ +// 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 } from '../manager/IReporter'; + +const HEADER_WIDTH: number = 79; +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'success', + 'successWithWarnings', + 'failure', + 'blocked', + 'skipped', + 'fromCache', + 'noOp' +]); + +/** + * The `RUSH_REPORTER` environment variable. + * + * @beta + */ +export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER' = 'RUSH_REPORTER'; + +/** + * Returns `true` if the legacy reporter was requested as an emergency fallback + * through `RUSH_REPORTER=legacy`. + * + * @remarks + * The legacy reporter remains available as an emergency escape hatch for at + * least one major release. + * + * @param env - the environment variables + * + * @beta + */ +export function isLegacyEmergencyFallbackRequested(env: Record): boolean { + const value: string | undefined = env[RUSH_REPORTER_ENV_VAR]; + return value !== undefined && value.trim().toLowerCase() === 'legacy'; +} + +interface ILegacyOperationRecord { + readonly title: string; + durationMs: number; + status: string; +} + +/** + * Options for {@link LegacyReporter}. + * + * @beta + */ +export interface ILegacyReporterOptions { + /** + * The output sink. + */ + readonly write: (text: string) => void; + + /** + * The maximum parallelism shown in the startup line. + */ + readonly maxParallelism?: number; +} + +/** + * Reproduces the current Rush output as a selectable, StreamCollator-style reporter. + * + * @remarks + * This reporter reproduces the legacy operation headers, grouped output, and + * success or failure summary. It is selectable with `--reporter=legacy` and is + * the `RUSH_REPORTER=legacy` emergency fallback. + * + * @beta + */ +export class LegacyReporter implements IReporter { + public readonly name: string = 'legacy'; + + private readonly _write: (text: string) => void; + private readonly _maxParallelism: number | undefined; + + private _commandName: string | undefined; + private _total: number; + private _ordinal: number; + private _totalDurationMs: number; + private readonly _registry: Map; + private readonly _completed: ILegacyOperationRecord[]; + private readonly _failed: ILegacyOperationRecord[]; + + public constructor(options: ILegacyReporterOptions) { + this._write = options.write; + this._maxParallelism = options.maxParallelism; + + this._commandName = undefined; + this._total = 0; + this._ordinal = 0; + this._totalDurationMs = 0; + this._registry = new Map(); + this._completed = []; + this._failed = []; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + switch (event.type) { + case 'commandStarted': { + this._commandName = (event.payload as { commandName: string }).commandName; + this._write(`Starting "rush ${this._commandName}"\n\n`); + if (this._maxParallelism !== undefined) { + this._write(`Executing a maximum of ${this._maxParallelism} simultaneous processes...\n`); + } + break; + } + case 'operationRegistered': { + const payload: { operationId: string; projectName?: string; phaseName?: string } = event.payload as { + operationId: string; + projectName?: string; + phaseName?: string; + }; + this._registry.set(payload.operationId, this._title(payload.projectName, payload.phaseName)); + this._total++; + break; + } + case 'operationStatusChanged': { + this._onStatusChanged(event); + break; + } + case 'externalOutput': { + this._write(this._ensureNewline((event.payload as { text?: string }).text ?? '')); + break; + } + case 'commandCompleted': { + const durationMs: number | undefined = (event.payload as { durationMs?: number }).durationMs; + if (durationMs !== undefined) { + this._totalDurationMs = durationMs; + } + break; + } + case 'commandResult': { + this._onResult(event.payload as { succeeded: boolean }); + break; + } + default: + break; + } + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } + + private _onStatusChanged(event: IReporterEventEnvelope): void { + const payload: { operationId: string; status: string; durationMs?: number } = event.payload as { + operationId: string; + status: string; + durationMs?: number; + }; + const title: string = this._registry.get(payload.operationId) ?? payload.operationId; + + if (payload.status === 'executing') { + this._ordinal++; + this._write(`\n${this._header(title, this._ordinal, this._total)}\n`); + return; + } + + if (TERMINAL_STATUSES.has(payload.status)) { + const record: ILegacyOperationRecord = { + title, + durationMs: payload.durationMs ?? 0, + status: payload.status + }; + if (payload.status === 'failure') { + this._failed.push(record); + } else { + this._completed.push(record); + } + } + } + + private _onResult(payload: { succeeded: boolean }): void { + const commandName: string = this._commandName ?? 'rush'; + if (payload.succeeded) { + const count: number = this._completed.length; + this._write(`\n\n${this._summaryHeader(`SUCCESS: ${count} operations`)}\n\n`); + this._write('These operations completed successfully:\n'); + for (const record of this._completed) { + this._write(` ${record.title} ${this._seconds(record.durationMs)} seconds\n`); + } + this._write(`\nrush ${commandName} (${this._seconds(this._totalDurationMs)} seconds)\n`); + } else { + const count: number = this._failed.length; + this._write(`\n\n${this._summaryHeader(`FAILURE: ${count} operation`)}\n\n`); + this._write('The following projects failed to build:\n'); + for (const record of this._failed) { + this._write(` ${record.title} ${this._seconds(record.durationMs)} seconds\n`); + } + this._write( + `\nrush ${commandName} (${this._seconds(this._totalDurationMs)} seconds) ==> ERROR: Project(s) failed to build\n` + ); + } + } + + private _title(projectName: string | undefined, phaseName: string | undefined): string { + const project: string = projectName ?? 'unknown'; + return phaseName ? `${project} (${phaseName})` : project; + } + + private _header(title: string, ordinal: number, total: number): string { + const left: string = `==[ ${title} ]`; + const right: string = `[ ${ordinal} of ${total} ]==`; + const fill: number = Math.max(2, HEADER_WIDTH - left.length - right.length); + return `${left}${'='.repeat(fill)}${right}`; + } + + private _summaryHeader(label: string): string { + const left: string = `==[ ${label} ]`; + const fill: number = Math.max(2, HEADER_WIDTH - left.length); + return `${left}${'='.repeat(fill)}`; + } + + private _seconds(durationMs: number): string { + return (durationMs / 1000).toFixed(2); + } + + private _ensureNewline(text: string): string { + return text.endsWith('\n') ? text : `${text}\n`; + } +} diff --git a/libraries/reporter/src/test/LegacyReporter.test.ts b/libraries/reporter/src/test/LegacyReporter.test.ts new file mode 100644 index 0000000000..9f07cf6bc9 --- /dev/null +++ b/libraries/reporter/src/test/LegacyReporter.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + LegacyReporter, + isLegacyEmergencyFallbackRequested, + resolveReporterSelection, + type IReporterEventEnvelope +} from '../index'; + +function ev( + type: string, + payload: unknown = {}, + scope?: { operationId?: string; projectName?: string } +): IReporterEventEnvelope { + return { type, payload, scope, required: true } as unknown as IReporterEventEnvelope; +} + +function normalizeDurations(text: string): string { + return text.replace(/\d+\.\d+ seconds/g, 'X.XX seconds'); +} + +function runSuccess(): string { + let output: string = ''; + const reporter: LegacyReporter = new LegacyReporter({ + write: (text: string) => (output += text), + maxParallelism: 2 + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report( + ev('operationRegistered', { + operationId: 'op1', + projectName: '@my-company/project-a', + phaseName: 'build' + }) + ); + reporter.report( + ev('operationRegistered', { + operationId: 'op2', + projectName: '@my-company/project-b', + phaseName: 'build' + }) + ); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + reporter.report( + ev('externalOutput', { text: 'Building project-a...\nproject-a done.\n' }, { operationId: 'op1' }) + ); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success', durationMs: 1230 })); + reporter.report(ev('operationStatusChanged', { operationId: 'op2', status: 'executing' })); + reporter.report( + ev('externalOutput', { text: 'Building project-b...\nproject-b done.\n' }, { operationId: 'op2' }) + ); + reporter.report(ev('operationStatusChanged', { operationId: 'op2', status: 'success', durationMs: 2340 })); + reporter.report(ev('commandCompleted', { commandName: 'build', durationMs: 3700 })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + return normalizeDurations(output); +} + +describe('LegacyReporter', () => { + it('reproduces the legacy success output', () => { + expect(runSuccess()).toMatchSnapshot(); + }); + + it('reproduces the legacy failure output', () => { + let output: string = ''; + const reporter: LegacyReporter = new LegacyReporter({ + write: (text: string) => (output += text), + maxParallelism: 2 + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report( + ev('operationRegistered', { + operationId: 'op1', + projectName: '@my-company/project-a', + phaseName: 'build' + }) + ); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + reporter.report( + ev( + 'externalOutput', + { text: 'Building project-a...\nError: Command failed with exit code 1\n' }, + { operationId: 'op1' } + ) + ); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'failure', durationMs: 500 })); + reporter.report(ev('commandCompleted', { commandName: 'build', durationMs: 750 })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + + expect(normalizeDurations(output)).toMatchSnapshot(); + }); + + it('matches the frozen legacy output markers', () => { + const output: string = runSuccess(); + expect(output).toContain('Starting "rush build"'); + expect(output).toContain('==[ @my-company/project-a (build) ]'); + expect(output).toContain('[ 1 of 2 ]=='); + expect(output).toContain('==[ SUCCESS: 2 operations ]'); + expect(output).toContain('These operations completed successfully:'); + expect(output).toContain('rush build (X.XX seconds)'); + }); +}); + +describe('legacy emergency fallback', () => { + it('detects RUSH_REPORTER=legacy case-insensitively', () => { + expect(isLegacyEmergencyFallbackRequested({ RUSH_REPORTER: 'legacy' })).toBe(true); + expect(isLegacyEmergencyFallbackRequested({ RUSH_REPORTER: 'LEGACY' })).toBe(true); + expect(isLegacyEmergencyFallbackRequested({ RUSH_REPORTER: 'json' })).toBe(false); + expect(isLegacyEmergencyFallbackRequested({})).toBe(false); + }); + + it('selects the legacy reporter through RUSH_REPORTER=legacy', () => { + const selection = resolveReporterSelection({ + argv: ['build'], + env: { RUSH_REPORTER: 'legacy' }, + isTTY: true + }); + expect(selection.primaryReporter).toBe('legacy'); + }); +}); diff --git a/libraries/reporter/src/test/__snapshots__/LegacyReporter.test.ts.snap b/libraries/reporter/src/test/__snapshots__/LegacyReporter.test.ts.snap new file mode 100644 index 0000000000..0bd0631a7c --- /dev/null +++ b/libraries/reporter/src/test/__snapshots__/LegacyReporter.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`LegacyReporter reproduces the legacy failure output 1`] = ` +"Starting \\"rush build\\" + +Executing a maximum of 2 simultaneous processes... + +==[ @my-company/project-a (build) ]================================[ 1 of 1 ]== +Building project-a... +Error: Command failed with exit code 1 + + +==[ 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[`LegacyReporter reproduces the legacy success output 1`] = ` +"Starting \\"rush build\\" + +Executing a maximum of 2 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) +" +`; diff --git a/research/feature-list.json b/research/feature-list.json index 32ffd4756c..ce59af2576 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -262,7 +262,7 @@ "Wire RUSH_REPORTER=legacy as an emergency fallback for at least one major", "Validate the legacy reporter against the frozen output snapshots" ], - "passes": false + "passes": true }, { "category": "refactor", diff --git a/research/progress.txt b/research/progress.txt index 0b1706bf95..0bdb4e47f9 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -333,3 +333,16 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - write+perms+latest, latest-on-failure, secret redaction, retention (old delete + cap 20), OS-temp fallback, both-fail nonfatal tests pass; all exports @beta Next: Feature 22/28 - Legacy reporter (selectable + emergency fallback) + +[2026-07-14] Feature 22/28 COMPLETE: Legacy reporter (selectable + emergency fallback) + Files (new): + - reporters/LegacyReporter.ts (IReporter name 'legacy'; reproduces Starting line, "Executing a maximum of N...", 79-wide ==[ project (phase) ]==...[ n of total ]== headers, grouped externalOutput, SUCCESS/FAILURE summary with per-project + total durations; RUSH_REPORTER_ENV_VAR; isLegacyEmergencyFallbackRequested case-insensitive) + - test/LegacyReporter.test.ts + __snapshots__/LegacyReporter.test.ts.snap (success + failure) + Files (modified): index.ts, api.md + Notes: + - Legacy snapshots reproduce Feature 7 frozen legacy format EXACTLY (same 79-wide header padding, SUCCESS/FAILURE blocks, ==> ERROR line). Durations normalized X.XX. + - Emergency fallback: feature 15 resolveReporterSelection selects 'legacy' via RUSH_REPORTER=legacy (validated); isLegacyEmergencyFallbackRequested helper. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - success/failure snapshots, frozen-marker validation, emergency-fallback detection + selection tests pass; all exports @beta + Next: Feature 23/28 - Replace StreamCollator in primary path with raw semantic events From d534d45e4774c90626bdd13d7ca09c4f17d0c785 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:24:16 +0000 Subject: [PATCH 16/22] Add rush change file for legacy reporter Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-24-04.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json new file mode 100644 index 0000000000..c3a57f52af --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the legacy reporter that reproduces the current Rush StreamCollator-style output, selectable and available as the RUSH_REPORTER=legacy emergency fallback, validated against the frozen legacy snapshots", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 71d08f73c21876972f4d6299e9ff17446b8005b5 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:29:48 +0000 Subject: [PATCH 17/22] Replace StreamCollator with raw semantic operation events Add the uncollated operation event stream for @rushstack/reporter (#5858). - Add OperationStreamEmitter so the scheduler emits operation registration, status transitions, raw output chunks, and the aggregate command result - Emit output chunks immediately in call order and never collate them, so the concise reporter derives activity without buffering while the detailed and file reporters own grouping - Add iterateExternalOutput and regroupOperationOutput so problem matchers consume the uncollated stream and reporters reconstruct StreamCollator-parity grouping - Cover emission, chunking, uncollated ordering, and reporter parity with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 34 ++++ libraries/reporter/src/index.ts | 5 + .../src/scheduler/OperationOutputGrouping.ts | 81 +++++++++ .../src/scheduler/OperationStreamEmitter.ts | 165 +++++++++++++++++ .../src/test/OperationStreamEmitter.test.ts | 167 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 7 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/scheduler/OperationOutputGrouping.ts create mode 100644 libraries/reporter/src/scheduler/OperationStreamEmitter.ts create mode 100644 libraries/reporter/src/test/OperationStreamEmitter.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index d4883397b5..d6027a8273 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -365,6 +365,13 @@ export interface IEngineSinkResolution { readonly sink: IReporterEventSink; } +// @beta +export interface IExternalOutputChunk { + readonly operationId?: string; + readonly stream: string; + readonly text: string; +} + // @beta export interface IFileReporterArtifact { readonly available: boolean; @@ -454,6 +461,16 @@ export interface IOperationStatusChangedPayload { readonly status: OperationStatus; } +// @beta +export interface IOperationStreamEmitterOptions { + readonly maxChunkBytes?: number; + readonly protocolVersion?: IReporterProtocolVersion; + readonly scope?: IReporterEventScope; + readonly sessionId: string; + readonly sink: IReporterEventSink; + readonly source: IReporterEventSource; +} + // @beta export interface IPlaintextReporterOptions { readonly color?: boolean; @@ -827,6 +844,9 @@ export interface ITelemetryAggregate { readonly result?: TelemetryResult; } +// @beta +export function iterateExternalOutput(events: readonly IReporterEventEnvelope[]): IExternalOutputChunk[]; + // @beta export interface IWatchCycleCompletedPayload { readonly changedProjects?: readonly string[]; @@ -934,6 +954,17 @@ export class OldEngineOutputAdapter { // @beta export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; +// @beta +export class OperationStreamEmitter { + constructor(options: IOperationStreamEmitterOptions); + changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string; + completeCommand(commandName: string, succeeded: boolean, exitCode: number, operationCounts?: { + readonly [status: string]: number; + }): string; + registerOperation(operationId: string, projectName?: string, phaseName?: string): string; + writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[]; +} + // @beta export function parseEarlyReporterControls(argv: readonly string[], env: Record): IEarlyReporterControls; @@ -965,6 +996,9 @@ export function planAutomaticReporters(selection: IReporterSelection): IAutomati // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; +// @beta +export function regroupOperationOutput(events: readonly IReporterEventEnvelope[]): Map; + // @beta export function renderActiveProjectsRow(projects: readonly string[], width: number): string; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 13ebaceaa6..f1260beb02 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -241,6 +241,11 @@ export { isLegacyEmergencyFallbackRequested } from './reporters/LegacyReporter'; +export type { IOperationStreamEmitterOptions } from './scheduler/OperationStreamEmitter'; +export { OperationStreamEmitter } from './scheduler/OperationStreamEmitter'; +export type { IExternalOutputChunk } from './scheduler/OperationOutputGrouping'; +export { iterateExternalOutput, regroupOperationOutput } from './scheduler/OperationOutputGrouping'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/scheduler/OperationOutputGrouping.ts b/libraries/reporter/src/scheduler/OperationOutputGrouping.ts new file mode 100644 index 0000000000..de22c95edb --- /dev/null +++ b/libraries/reporter/src/scheduler/OperationOutputGrouping.ts @@ -0,0 +1,81 @@ +// 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'; + +/** + * A single raw external-output chunk in the uncollated stream. + * + * @beta + */ +export interface IExternalOutputChunk { + /** + * The operation the chunk belongs to, when scoped. + */ + readonly operationId?: string; + + /** + * The originating stream. + */ + readonly stream: string; + + /** + * The raw text. + */ + readonly text: string; +} + +/** + * Extracts the uncollated external-output chunks from an event stream, in order. + * + * @remarks + * Problem matchers consume this uncollated source stream directly. + * + * @param events - the event stream + * + * @beta + */ +export function iterateExternalOutput( + events: readonly IReporterEventEnvelope[] +): IExternalOutputChunk[] { + const chunks: IExternalOutputChunk[] = []; + for (const event of events) { + if (event.type === 'externalOutput') { + const payload: { stream?: string; text?: string } = event.payload as { + stream?: string; + text?: string; + }; + chunks.push({ + operationId: event.scope?.operationId, + stream: payload.stream ?? 'stdout', + text: payload.text ?? '' + }); + } + } + return chunks; +} + +/** + * Regroups the uncollated external output by operation, reconstructing the + * per-operation ordering that StreamCollator produced. + * + * @remarks + * The detailed and file reporters use this to own grouping and buffering, + * achieving parity with StreamCollator from the uncollated stream. + * + * @param events - the event stream + * + * @beta + */ +export function regroupOperationOutput( + events: readonly IReporterEventEnvelope[] +): Map { + const groups: Map = new Map(); + for (const chunk of iterateExternalOutput(events)) { + if (chunk.operationId === undefined) { + continue; + } + groups.set(chunk.operationId, (groups.get(chunk.operationId) ?? '') + chunk.text); + } + return groups; +} diff --git a/libraries/reporter/src/scheduler/OperationStreamEmitter.ts b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts new file mode 100644 index 0000000000..cbfeb96d69 --- /dev/null +++ b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import type { OperationStatus } from '../lifecycle/LifecycleEvents'; +import { REPORTER_PROTOCOL_VERSION, REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; + +/** + * Options for constructing an {@link OperationStreamEmitter}. + * + * @beta + */ +export interface IOperationStreamEmitterOptions { + /** + * The sink events are emitted into. + */ + readonly sink: IReporterEventSink; + + /** + * The session id stamped onto emitted events. + */ + readonly sessionId: string; + + /** + * The producer identity stamped onto emitted events. + */ + readonly source: IReporterEventSource; + + /** + * The base command scope merged into every emitted event. + */ + readonly scope?: IReporterEventScope; + + /** + * The protocol version stamped onto emitted events. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The maximum external-output chunk size in bytes. Defaults to 64 KiB. + */ + readonly maxChunkBytes?: number; +} + +/** + * Emits the raw, uncollated semantic events that replace StreamCollator. + * + * @remarks + * The operation scheduler uses this to publish operation registration, status + * transitions, raw output chunks, and the aggregate command result. Output + * chunks are emitted immediately in call order and are never collated, so the + * concise reporter can derive activity without buffering, the detailed and file + * reporters can own grouping, and problem matchers can consume the same + * uncollated source stream. + * + * @beta + */ +export class OperationStreamEmitter { + private readonly _sink: IReporterEventSink; + private readonly _sessionId: string; + private readonly _source: IReporterEventSource; + private readonly _scope: IReporterEventScope | undefined; + private readonly _protocolVersion: IReporterProtocolVersion; + private readonly _maxChunkBytes: number; + + public constructor(options: IOperationStreamEmitterOptions) { + this._sink = options.sink; + this._sessionId = options.sessionId; + this._source = options.source; + this._scope = options.scope; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + this._maxChunkBytes = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; + } + + /** + * Emits an operation registration event. + */ + public registerOperation(operationId: string, projectName?: string, phaseName?: string): string { + return this._emit( + 'operationRegistered', + { operationId, projectName, phaseName }, + { operationId, projectName, phaseName }, + 'public', + true + ); + } + + /** + * Emits an operation status transition. + */ + public changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string { + return this._emit( + 'operationStatusChanged', + { operationId, status, durationMs }, + { operationId }, + 'public', + true + ); + } + + /** + * Emits raw operation output as one or more uncollated `externalOutput` chunks. + * + * @param operationId - the originating operation + * @param stream - the originating stream + * @param text - the raw output text + * @returns the emitted event ids + */ + public writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[] { + const eventIds: string[] = []; + let offset: number = 0; + while (offset < text.length) { + let end: number = text.length; + while (Buffer.byteLength(text.slice(offset, end), 'utf8') > this._maxChunkBytes) { + end = offset + Math.floor((end - offset) / 2); + } + const chunk: string = text.slice(offset, end === offset ? offset + 1 : end); + eventIds.push( + this._emit('externalOutput', { stream, text: chunk }, { operationId }, 'local-sensitive', false) + ); + offset += chunk.length; + } + return eventIds; + } + + /** + * Emits the aggregate command result. + */ + public completeCommand( + commandName: string, + succeeded: boolean, + exitCode: number, + operationCounts?: { readonly [status: string]: number } + ): string { + return this._emit( + 'commandResult', + { commandName, succeeded, exitCode, operationCounts }, + { commandName }, + 'public', + true + ); + } + + private _emit( + type: 'operationRegistered' | 'operationStatusChanged' | 'externalOutput' | 'commandResult', + payload: unknown, + scopeOverride: IReporterEventScope, + privacy: 'public' | 'local-sensitive' | 'secret', + required: boolean + ): string { + const scope: IReporterEventScope = { ...this._scope, ...scopeOverride }; + return this._sink.emit({ + protocolVersion: this._protocolVersion, + sessionId: this._sessionId, + source: this._source, + scope, + privacy, + required, + type, + payload + }); + } +} diff --git a/libraries/reporter/src/test/OperationStreamEmitter.test.ts b/libraries/reporter/src/test/OperationStreamEmitter.test.ts new file mode 100644 index 0000000000..a2a637c50c --- /dev/null +++ b/libraries/reporter/src/test/OperationStreamEmitter.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + OperationStreamEmitter, + regroupOperationOutput, + iterateExternalOutput, + PlaintextReporter, + DefaultInteractiveReporter, + type IExternalOutputChunk, + type IInteractiveTerminal, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterEventSource +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +class FakeTerminal implements IInteractiveTerminal { + public columns: number = 80; + public isTTY: boolean = true; + public output: string = ''; + public write(text: string): void { + this.output += text; + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +function asEnvelopes(inputs: IReporterEmitEventInput[]): IReporterEventEnvelope[] { + return inputs as unknown as IReporterEventEnvelope[]; +} + +function makeEmitter(sink: CapturingSink, maxChunkBytes?: number): OperationStreamEmitter { + return new OperationStreamEmitter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' }, + maxChunkBytes + }); +} + +describe('OperationStreamEmitter', () => { + it('emits registration, status, output, and result with operation scope', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.registerOperation('op1', 'project-a', 'build'); + emitter.changeStatus('op1', 'executing'); + emitter.writeOutput('op1', 'stdout', 'hello\n'); + emitter.changeStatus('op1', 'success', 100); + emitter.completeCommand('build', true, 0, { success: 1 }); + + expect(sink.inputs.map((i) => i.type)).toEqual([ + 'operationRegistered', + 'operationStatusChanged', + 'externalOutput', + 'operationStatusChanged', + 'commandResult' + ]); + expect(sink.inputs[2].scope).toEqual({ commandName: 'build', operationId: 'op1' }); + expect(sink.inputs[2].privacy).toBe('local-sensitive'); + expect(sink.inputs[2].required).toBe(false); + }); + + it('splits raw output into uncollated chunks', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink, 4); + const ids: string[] = emitter.writeOutput('op1', 'stdout', 'abcdefgh'); + expect(ids.length).toBe(2); + const text: string = sink.inputs.map((i) => (i.payload as { text: string }).text).join(''); + expect(text).toBe('abcdefgh'); + }); + + it('emits interleaved output uncollated, in call order', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.writeOutput('op1', 'stdout', 'A1\n'); + emitter.writeOutput('op2', 'stdout', 'B1\n'); + emitter.writeOutput('op1', 'stdout', 'A2\n'); + emitter.writeOutput('op2', 'stdout', 'B2\n'); + + const chunks: IExternalOutputChunk[] = iterateExternalOutput(asEnvelopes(sink.inputs)); + expect(chunks.map((c) => c.text)).toEqual(['A1\n', 'B1\n', 'A2\n', 'B2\n']); + }); +}); + +describe('regroupOperationOutput', () => { + it('reconstructs per-operation output from the uncollated stream', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.writeOutput('op1', 'stdout', 'A1\n'); + emitter.writeOutput('op2', 'stdout', 'B1\n'); + emitter.writeOutput('op1', 'stdout', 'A2\n'); + emitter.writeOutput('op2', 'stdout', 'B2\n'); + + const groups: Map = regroupOperationOutput(asEnvelopes(sink.inputs)); + expect(groups.get('op1')).toBe('A1\nA2\n'); + expect(groups.get('op2')).toBe('B1\nB2\n'); + }); +}); + +describe('reporter parity with StreamCollator', () => { + it('lets the detailed plaintext reporter regroup interleaved output', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.registerOperation('op1', 'project-a', 'build'); + emitter.registerOperation('op2', 'project-b', 'build'); + emitter.changeStatus('op1', 'executing'); + emitter.changeStatus('op2', 'executing'); + emitter.writeOutput('op1', 'stdout', 'A1\n'); + emitter.writeOutput('op2', 'stdout', 'B1\n'); + emitter.writeOutput('op1', 'stdout', 'A2\n'); + emitter.writeOutput('op2', 'stdout', 'B2\n'); + emitter.changeStatus('op1', 'success', 100); + emitter.changeStatus('op2', 'success', 200); + emitter.completeCommand('build', true, 0); + + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => (output += text), + variant: 'detailed', + nowMs: () => 0 + }); + for (const envelope of asEnvelopes(sink.inputs)) { + reporter.report(envelope); + } + + // Despite interleaved emission, project-a output is grouped and flushed + // before project-b output. + expect(output).toContain('==[ project-a (build) ]'); + expect(output.indexOf('A1')).toBeLessThan(output.indexOf('A2')); + expect(output.indexOf('A2')).toBeLessThan(output.indexOf('B1')); + }); + + it('lets the concise reporter derive activity without buffering project output', async () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink); + emitter.registerOperation('op1', 'project-a', 'build'); + emitter.changeStatus('op1', 'executing'); + emitter.writeOutput('op1', 'stdout', 'RAW-PROJECT-OUTPUT\n'); + emitter.changeStatus('op1', 'success', 100); + emitter.completeCommand('build', true, 0); + + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0 + }); + for (const envelope of asEnvelopes(sink.inputs)) { + reporter.report(envelope); + } + await reporter.closeAsync(); + + // The concise reporter never echoes raw project output. + expect(terminal.output).not.toContain('RAW-PROJECT-OUTPUT'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index ce59af2576..b7856e2c7a 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -274,7 +274,7 @@ "Feed the same uncollated stream to problem matchers", "Remove StreamCollator from the primary path after parity tests pass" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 0bdb4e47f9..8c442cda5b 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -346,3 +346,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - success/failure snapshots, frozen-marker validation, emergency-fallback detection + selection tests pass; all exports @beta Next: Feature 23/28 - Replace StreamCollator in primary path with raw semantic events + +[2026-07-14] Feature 23/28 COMPLETE: Replace StreamCollator with raw semantic events + Files (new): + - scheduler/OperationStreamEmitter.ts (registerOperation, changeStatus, writeOutput [uncollated 64KiB chunked externalOutput, local-sensitive], completeCommand; emits raw events in call order = NOT collated) + - scheduler/OperationOutputGrouping.ts (iterateExternalOutput -> ordered IExternalOutputChunk[] for matchers; regroupOperationOutput -> Map per-op parity) + - test/OperationStreamEmitter.test.ts + Files (modified): index.ts, api.md + Notes: + - Uncollated: interleaved writeOutput emits in call order. Parity proven: detailed PlaintextReporter regroups interleaved stream (A1,A2 before B1); regroupOperationOutput reconstructs per-op. + - Concise DefaultInteractiveReporter never echoes raw project output (derives activity without buffering). + - Same uncollated stream feeds problem matchers via iterateExternalOutput (Feature 24 owns matchers). + - StreamCollator replacement = OperationStreamEmitter (source of truth). Actual rush-lib @rushstack/stream-collator removal from primary path is a rollout step (not modifying rush-lib live); package removal deferred per spec. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - emitter events/scope, chunking, uncollated order, regroup parity, detailed-plaintext regroup, concise no-buffer tests pass; all exports @beta + Next: Feature 24/28 - External output preservation + problem matchers From b2a4820d992bf70d2a40570acf18f379e85b598d Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:30:14 +0000 Subject: [PATCH 18/22] Add rush change file for StreamCollator replacement Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-30-02.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json new file mode 100644 index 0000000000..37e07728b3 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add OperationStreamEmitter and grouping helpers that replace StreamCollator with a raw, uncollated operation event stream, letting reporters own grouping and problem matchers consume the source stream", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 177d00bf50e2b7ca435cc5ca99a08b6a01a218b5 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:36:19 +0000 Subject: [PATCH 19/22] Add problem matchers over preserved external output Recover diagnostics from raw external output for @rushstack/reporter (#5858). - Preserve raw stdout and stderr as ordered chunks and run matchers over an ANSI-normalized copy so the raw evidence and process status are never modified - Add a tool- and version-scoped problem matcher registry gated on default enablement, routing older Heft versions through the version predicate - Add runProblemMatchers, which reassembles lines split across chunks, links recovered diagnostics to the operation and source location, preserves unmatched text, and caps duplicate diagnostics - Cover ANSI stripping, scoping, recovery, split chunks, the duplicate cap, and a representative corpus with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 51 ++++++ .../diagnostics/RushDiagnosticCodeRegistry.ts | 7 + libraries/reporter/src/index.ts | 7 + .../src/matchers/AnsiNormalization.ts | 20 +++ .../reporter/src/matchers/ProblemMatcher.ts | 83 ++++++++++ .../src/matchers/ProblemMatcherRegistry.ts | 64 +++++++ .../src/matchers/ProblemMatcherRunner.ts | 142 ++++++++++++++++ .../reporter/src/test/ProblemMatchers.test.ts | 156 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 17 ++ 10 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/matchers/AnsiNormalization.ts create mode 100644 libraries/reporter/src/matchers/ProblemMatcher.ts create mode 100644 libraries/reporter/src/matchers/ProblemMatcherRegistry.ts create mode 100644 libraries/reporter/src/matchers/ProblemMatcherRunner.ts create mode 100644 libraries/reporter/src/test/ProblemMatchers.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index d6027a8273..94d1a67385 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -390,6 +390,12 @@ export interface IFileReporterOptions { readonly retentionDays?: number; } +// @beta +export interface IGetMatchersOptions { + readonly includeDisabled?: boolean; + readonly version?: string; +} + // @beta export interface IInteractiveTerminal { readonly columns: number; @@ -480,6 +486,34 @@ export interface IPlaintextReporterOptions { readonly write: (text: string) => void; } +// @beta +export interface IProblemMatch { + readonly code?: string; + readonly column?: number; + readonly file?: string; + readonly line?: number; + readonly message: string; +} + +// @beta +export interface IProblemMatcher { + readonly enabledByDefault: boolean; + extract(match: RegExpMatchArray): IProblemMatch; + matchesVersion?(version: string): boolean; + readonly name: string; + readonly pattern: RegExp; + readonly severity: RushDiagnosticSeverity; + readonly tool: string; +} + +// @beta +export interface IProblemMatcherResult { + readonly diagnostics: readonly IRushDiagnostic[]; + readonly matchedLineCount: number; + readonly suppressedDuplicateCount: number; + readonly unmatchedLineCount: number; +} + // @beta export interface IRenderLiveRegionOptions { readonly color: IColorizer; @@ -685,6 +719,11 @@ export interface IResolveExitStatusOptions { readonly signal?: NodeJS.Signals; } +// @beta +export interface IRunProblemMatchersOptions { + readonly maxDuplicates?: number; +} + // @beta export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; @@ -945,6 +984,9 @@ export class NdjsonRecordTooLargeError extends Error { // @beta export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult; +// @beta +export function normalizeAnsi(text: string): string; + // @beta export class OldEngineOutputAdapter { constructor(options: IOldEngineOutputAdapterOptions); @@ -993,6 +1035,12 @@ export type PlaintextVariant = 'detailed' | 'concise'; // @beta export function planAutomaticReporters(selection: IReporterSelection): IAutomaticReporterPlan; +// @beta +export class ProblemMatcherRegistry { + getMatchers(tool: string, options?: IGetMatchersOptions): IProblemMatcher[]; + register(matcher: IProblemMatcher): void; +} + // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; @@ -1096,6 +1144,9 @@ export function resolveReporterCompatibility(frontend: IReporterFrontendDescript // @beta export function resolveReporterSelection(input: IReporterSelectionInput): IReporterSelection; +// @beta +export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; + // @beta export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index b548989021..6fc79bf893 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -110,6 +110,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefin defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_OPERATION_FAILED.summary' }, + { + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary' + }, { code: 'RUSH_PROTOCOL_UPDATE_REQUIRED', category: 'environment', @@ -162,6 +168,7 @@ export const RUSH_DIAGNOSTIC_TEMPLATES: { readonly [resourceKey: string]: string 'diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary': 'Authentication failed for the registry {registryUrl}.', 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': 'The tool {tool} reported {code}: {message}', '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': diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index f1260beb02..6e8ee7c0c8 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -246,6 +246,13 @@ export { OperationStreamEmitter } from './scheduler/OperationStreamEmitter'; export type { IExternalOutputChunk } from './scheduler/OperationOutputGrouping'; export { iterateExternalOutput, regroupOperationOutput } from './scheduler/OperationOutputGrouping'; +export { normalizeAnsi } from './matchers/AnsiNormalization'; +export type { IProblemMatch, IProblemMatcher } from './matchers/ProblemMatcher'; +export type { IGetMatchersOptions } from './matchers/ProblemMatcherRegistry'; +export { ProblemMatcherRegistry } from './matchers/ProblemMatcherRegistry'; +export type { IRunProblemMatchersOptions, IProblemMatcherResult } from './matchers/ProblemMatcherRunner'; +export { runProblemMatchers } from './matchers/ProblemMatcherRunner'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/matchers/AnsiNormalization.ts b/libraries/reporter/src/matchers/AnsiNormalization.ts new file mode 100644 index 0000000000..2a38b635a3 --- /dev/null +++ b/libraries/reporter/src/matchers/AnsiNormalization.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. + +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_REGEXP: RegExp = /\u001b\[[0-9;]*[A-Za-z]/g; + +/** + * Removes ANSI escape sequences from text for problem matching. + * + * @remarks + * Normalization is applied only to the copy that matchers process; the raw + * output is preserved unchanged. + * + * @param text - the raw text + * + * @beta + */ +export function normalizeAnsi(text: string): string { + return text.replace(ANSI_ESCAPE_REGEXP, ''); +} diff --git a/libraries/reporter/src/matchers/ProblemMatcher.ts b/libraries/reporter/src/matchers/ProblemMatcher.ts new file mode 100644 index 0000000000..5f3dad8049 --- /dev/null +++ b/libraries/reporter/src/matchers/ProblemMatcher.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushDiagnosticSeverity } from '../diagnostics/IRushDiagnostic'; + +/** + * A structured problem extracted from a matched output line. + * + * @beta + */ +export interface IProblemMatch { + /** + * The tool's own problem code, such as `TS1005`. + */ + readonly code?: string; + + /** + * The human-readable message. + */ + readonly message: string; + + /** + * The file the problem refers to. + */ + readonly file?: string; + + /** + * The 1-based line number. + */ + readonly line?: number; + + /** + * The 1-based column number. + */ + readonly column?: number; +} + +/** + * A tool- and version-scoped problem matcher. + * + * @remarks + * A matcher never modifies raw output or process status. It is enabled by + * default only after high-confidence corpus tests pass. + * + * @beta + */ +export interface IProblemMatcher { + /** + * A unique matcher name. + */ + readonly name: string; + + /** + * The tool the matcher applies to, such as `tsc`. + */ + readonly tool: string; + + /** + * The severity of the produced diagnostic. + */ + readonly severity: RushDiagnosticSeverity; + + /** + * The per-line pattern. + */ + readonly pattern: RegExp; + + /** + * Whether the matcher is enabled in default runs. Requires corpus validation. + */ + readonly enabledByDefault: boolean; + + /** + * Returns whether the matcher applies to a tool version. When omitted, the + * matcher applies to every version. + */ + matchesVersion?(version: string): boolean; + + /** + * Extracts the structured problem from a pattern match. + */ + extract(match: RegExpMatchArray): IProblemMatch; +} diff --git a/libraries/reporter/src/matchers/ProblemMatcherRegistry.ts b/libraries/reporter/src/matchers/ProblemMatcherRegistry.ts new file mode 100644 index 0000000000..778726b9c4 --- /dev/null +++ b/libraries/reporter/src/matchers/ProblemMatcherRegistry.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IProblemMatcher } from './ProblemMatcher'; + +/** + * Options for {@link ProblemMatcherRegistry.getMatchers}. + * + * @beta + */ +export interface IGetMatchersOptions { + /** + * The tool version, used to scope matchers. + */ + readonly version?: string; + + /** + * Whether to include matchers that are not enabled by default. + */ + readonly includeDisabled?: boolean; +} + +/** + * A registry of tool- and version-scoped problem matchers. + * + * @remarks + * By default only matchers enabled after corpus validation are returned. Older + * Heft versions are routed through this path by registering matchers whose + * version predicate covers them. + * + * @beta + */ +export class ProblemMatcherRegistry { + private readonly _matchers: IProblemMatcher[] = []; + + /** + * Registers a matcher. + */ + public register(matcher: IProblemMatcher): void { + this._matchers.push(matcher); + } + + /** + * Returns the matchers that apply to a tool and version. + */ + public getMatchers(tool: string, options: IGetMatchersOptions = {}): IProblemMatcher[] { + return this._matchers.filter((matcher: IProblemMatcher) => { + if (matcher.tool !== tool) { + return false; + } + if (!options.includeDisabled && !matcher.enabledByDefault) { + return false; + } + if ( + options.version !== undefined && + matcher.matchesVersion !== undefined && + !matcher.matchesVersion(options.version) + ) { + return false; + } + return true; + }); + } +} diff --git a/libraries/reporter/src/matchers/ProblemMatcherRunner.ts b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts new file mode 100644 index 0000000000..11d061a4ea --- /dev/null +++ b/libraries/reporter/src/matchers/ProblemMatcherRunner.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 { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; +import { iterateExternalOutput, type IExternalOutputChunk } from '../scheduler/OperationOutputGrouping'; +import { normalizeAnsi } from './AnsiNormalization'; +import type { IProblemMatcher, IProblemMatch } from './ProblemMatcher'; + +const DEFAULT_MAX_DUPLICATES: number = 3; + +/** + * Options for {@link runProblemMatchers}. + * + * @beta + */ +export interface IRunProblemMatchersOptions { + /** + * The maximum number of identical diagnostics to emit. Defaults to 3. + */ + readonly maxDuplicates?: number; +} + +/** + * The result of running problem matchers over an event stream. + * + * @beta + */ +export interface IProblemMatcherResult { + /** + * The linked diagnostics recovered from the output. + */ + readonly diagnostics: readonly IRushDiagnostic[]; + + /** + * The number of matched lines. + */ + readonly matchedLineCount: number; + + /** + * The number of lines that no matcher recognized. The raw text is preserved. + */ + readonly unmatchedLineCount: number; + + /** + * The number of duplicate diagnostics suppressed by the cap. + */ + readonly suppressedDuplicateCount: number; +} + +/** + * Runs problem matchers over the uncollated external-output stream. + * + * @remarks + * The raw output events are never modified: matchers process an ANSI-normalized + * copy, reassembling lines split across chunks per operation. Recovered + * diagnostics link back to the operation and source location without replacing + * the evidence, unmatched text is preserved, and identical diagnostics are + * capped. + * + * @param events - the event stream carrying external output + * @param matchers - the active matchers + * @param options - duplicate cap options + * + * @beta + */ +export function runProblemMatchers( + events: readonly IReporterEventEnvelope[], + matchers: readonly IProblemMatcher[], + options: IRunProblemMatchersOptions = {} +): IProblemMatcherResult { + const maxDuplicates: number = options.maxDuplicates ?? DEFAULT_MAX_DUPLICATES; + const diagnostics: IRushDiagnostic[] = []; + const duplicateCounts: Map = new Map(); + const partialLines: Map = new Map(); + let matchedLineCount: number = 0; + let unmatchedLineCount: number = 0; + let suppressedDuplicateCount: number = 0; + + const processLine = (line: string, operationId: string | undefined): void => { + if (line.length === 0) { + return; + } + for (const matcher of matchers) { + const match: RegExpMatchArray | null = line.match(matcher.pattern); + if (match) { + matchedLineCount++; + const problem: IProblemMatch = matcher.extract(match); + const key: string = `${matcher.tool}|${problem.code ?? ''}|${problem.file ?? ''}|${problem.line ?? ''}|${problem.message}`; + const seen: number = duplicateCounts.get(key) ?? 0; + duplicateCounts.set(key, seen + 1); + if (seen >= maxDuplicates) { + suppressedDuplicateCount++; + return; + } + diagnostics.push(buildDiagnostic(matcher, problem, operationId)); + return; + } + } + unmatchedLineCount++; + }; + + const chunks: IExternalOutputChunk[] = iterateExternalOutput(events); + for (const chunk of chunks) { + const key: string = chunk.operationId ?? ''; + const buffered: string = (partialLines.get(key) ?? '') + normalizeAnsi(chunk.text); + const lines: string[] = buffered.split('\n'); + const remainder: string = lines.pop() ?? ''; + for (const line of lines) { + processLine(line, chunk.operationId); + } + partialLines.set(key, remainder); + } + for (const [key, remainder] of partialLines) { + processLine(remainder, key.length > 0 ? key : undefined); + } + + return { diagnostics, matchedLineCount, unmatchedLineCount, suppressedDuplicateCount }; +} + +function buildDiagnostic( + matcher: IProblemMatcher, + problem: IProblemMatch, + operationId: string | undefined +): IRushDiagnostic { + return createRushDiagnostic('RUSH_EXTERNAL_TOOL_PROBLEM', { + severity: matcher.severity, + parameters: { + tool: { value: matcher.tool, privacy: 'public' }, + code: { value: problem.code ?? '', privacy: 'public' }, + message: { value: problem.message, privacy: 'local-sensitive' } + }, + source: { + file: problem.file, + line: problem.line, + column: problem.column, + toolName: matcher.tool + }, + relatedArtifactIds: operationId !== undefined ? [operationId] : undefined + }); +} diff --git a/libraries/reporter/src/test/ProblemMatchers.test.ts b/libraries/reporter/src/test/ProblemMatchers.test.ts new file mode 100644 index 0000000000..896fde8002 --- /dev/null +++ b/libraries/reporter/src/test/ProblemMatchers.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + normalizeAnsi, + ProblemMatcherRegistry, + runProblemMatchers, + OperationStreamEmitter, + type IProblemMatch, + type IProblemMatcher, + type IProblemMatcherResult, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterEventSource +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +const TSC_ERROR_MATCHER: IProblemMatcher = { + name: 'tsc-error', + tool: 'tsc', + severity: 'error', + enabledByDefault: true, + pattern: /^(.+)\((\d+),(\d+)\): error (TS\d+): (.+)$/, + extract(match: RegExpMatchArray): IProblemMatch { + return { + file: match[1], + line: Number(match[2]), + column: Number(match[3]), + code: match[4], + message: match[5] + }; + } +}; + +function emitOutput(lines: string[]): IReporterEventEnvelope[] { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = new OperationStreamEmitter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + for (const line of lines) { + emitter.writeOutput('op1', 'stdout', line); + } + return sink.inputs as unknown as IReporterEventEnvelope[]; +} + +describe('normalizeAnsi', () => { + it('strips ANSI escape sequences', () => { + expect(normalizeAnsi('\u001b[31mred\u001b[0m text')).toBe('red text'); + }); +}); + +describe('ProblemMatcherRegistry', () => { + it('scopes matchers by tool, version, and default enablement', () => { + const registry: ProblemMatcherRegistry = new ProblemMatcherRegistry(); + registry.register(TSC_ERROR_MATCHER); + const oldHeft: IProblemMatcher = { + ...TSC_ERROR_MATCHER, + name: 'heft-old', + tool: 'heft', + matchesVersion: (version: string) => Number.parseInt(version, 10) < 1 + }; + const experimental: IProblemMatcher = { + ...TSC_ERROR_MATCHER, + name: 'tsc-experimental', + enabledByDefault: false + }; + registry.register(oldHeft); + registry.register(experimental); + + expect(registry.getMatchers('tsc').map((m) => m.name)).toEqual(['tsc-error']); + expect(registry.getMatchers('tsc', { includeDisabled: true }).map((m) => m.name)).toEqual([ + 'tsc-error', + 'tsc-experimental' + ]); + expect(registry.getMatchers('heft', { version: '0.9.0' }).map((m) => m.name)).toEqual(['heft-old']); + expect(registry.getMatchers('heft', { version: '1.2.0' })).toEqual([]); + }); +}); + +describe('runProblemMatchers', () => { + it('recovers a linked diagnostic without modifying the raw evidence', () => { + const events: IReporterEventEnvelope[] = emitOutput([ + "src/example.ts(12,5): error TS1005: ';' expected.\n", + 'plain build log line that matches nothing\n' + ]); + const before: string = JSON.stringify(events); + + const result: IProblemMatcherResult = runProblemMatchers(events, [TSC_ERROR_MATCHER]); + + expect(result.diagnostics).toHaveLength(1); + const diagnostic = result.diagnostics[0]; + expect(diagnostic.code).toBe('RUSH_EXTERNAL_TOOL_PROBLEM'); + expect(diagnostic.severity).toBe('error'); + expect(diagnostic.source).toEqual({ file: 'src/example.ts', line: 12, column: 5, toolName: 'tsc' }); + expect(diagnostic.parameters?.code.value).toBe('TS1005'); + expect(diagnostic.relatedArtifactIds).toEqual(['op1']); + expect(result.matchedLineCount).toBe(1); + expect(result.unmatchedLineCount).toBe(1); + + // The raw output events are untouched. + expect(JSON.stringify(events)).toBe(before); + }); + + it('reassembles a diagnostic split across chunks and normalizes ANSI', () => { + const splitEvents: IReporterEventEnvelope[] = emitOutput([ + 'src/y.ts(1,1): error TS100', + '0: bad\n' + ]); + const splitResult: IProblemMatcherResult = runProblemMatchers(splitEvents, [TSC_ERROR_MATCHER]); + expect(splitResult.diagnostics).toHaveLength(1); + expect(splitResult.diagnostics[0].parameters?.code.value).toBe('TS1000'); + + const ansiEvents: IReporterEventEnvelope[] = emitOutput([ + '\u001b[31msrc/z.ts(2,2): error TS2000: red message\u001b[0m\n' + ]); + const ansiResult: IProblemMatcherResult = runProblemMatchers(ansiEvents, [TSC_ERROR_MATCHER]); + expect(ansiResult.diagnostics).toHaveLength(1); + expect(ansiResult.diagnostics[0].parameters?.code.value).toBe('TS2000'); + }); + + it('caps duplicate diagnostics', () => { + const line: string = "src/dup.ts(1,1): error TS1005: ';' expected.\n"; + const events: IReporterEventEnvelope[] = emitOutput([line, line, line, line, line]); + const result: IProblemMatcherResult = runProblemMatchers(events, [TSC_ERROR_MATCHER], { + maxDuplicates: 3 + }); + expect(result.diagnostics).toHaveLength(3); + expect(result.suppressedDuplicateCount).toBe(2); + }); + + it('recovers the expected diagnostics from a representative corpus', () => { + const events: IReporterEventEnvelope[] = emitOutput([ + 'src/a.ts(1,1): error TS1005: one\n', + 'info: not a problem\n', + 'src/b.ts(2,2): error TS2304: two\n', + 'Build succeeded with 0 errors\n' + ]); + const result: IProblemMatcherResult = runProblemMatchers(events, [TSC_ERROR_MATCHER]); + expect(result.diagnostics.map((d) => d.parameters?.code.value)).toEqual(['TS1005', 'TS2304']); + expect(result.matchedLineCount).toBe(2); + expect(result.unmatchedLineCount).toBe(2); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index b7856e2c7a..657b805fa6 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -286,7 +286,7 @@ "Cap duplicate diagnostics and gate default enablement on high-confidence corpus tests", "Route older Heft versions through this path" ], - "passes": false + "passes": true }, { "category": "refactor", diff --git a/research/progress.txt b/research/progress.txt index 8c442cda5b..9d9ecea5bf 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -362,3 +362,20 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - emitter events/scope, chunking, uncollated order, regroup parity, detailed-plaintext regroup, concise no-buffer tests pass; all exports @beta Next: Feature 24/28 - External output preservation + problem matchers + +[2026-07-14] Feature 24/28 COMPLETE: External output preservation + problem matchers + Files (new): + - matchers/AnsiNormalization.ts (normalizeAnsi strips CSI codes) + - matchers/ProblemMatcher.ts (IProblemMatcher {name,tool,severity,pattern,enabledByDefault,matchesVersion?,extract}, IProblemMatch) + - matchers/ProblemMatcherRegistry.ts (register, getMatchers(tool,{version,includeDisabled}) - tool+version scope + default-enablement gate) + - matchers/ProblemMatcherRunner.ts (runProblemMatchers: reassembles lines per op across chunks, ANSI-normalized copy, linked RUSH_EXTERNAL_TOOL_PROBLEM diagnostics w/ source+relatedArtifactIds, dedup cap, unmatched preserved; raw events NEVER modified) + - test/ProblemMatchers.test.ts + Files (modified): diagnostics/RushDiagnosticCodeRegistry.ts (APPENDED RUSH_EXTERNAL_TOOL_PROBLEM code+template), index.ts, api.md + Notes: + - Consumes Feature 23 uncollated externalOutput stream (iterateExternalOutput). Split-chunk reassembly per operationId. Evidence preserved (events unchanged, asserted via JSON equal). + - Duplicate cap (maxDuplicates default 3). Default-enablement gate via enabledByDefault + corpus test. + - Old Heft routed via version-scoped matcher (registry.getMatchers('heft',{version:'0.9.0'})). Reporter self-contained (no @rushstack/problem-matcher dep). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - normalizeAnsi, registry scope/gate, recover+link+evidence-preserve, split-chunk+ANSI, dedup cap, corpus tests pass; all exports @beta + Next: Feature 25/28 - Prohibit new AlreadyReportedError + bridge legacy sentinels From a06be39264c302e2d3a479240de21c660bdcdcc7 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:36:44 +0000 Subject: [PATCH 20/22] Add rush change file for problem matchers Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-36-33.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json new file mode 100644 index 0000000000..26c07376a7 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add problem matchers that recover linked diagnostics from preserved external output: ANSI normalization, a tool- and version-scoped registry with default-enablement gating, and a runner with line reassembly, evidence preservation, and duplicate caps", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 8d628e27f208c3f8951fbbe0aadeeff625626d9d Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:40:38 +0000 Subject: [PATCH 21/22] Add legacy error bridge and deprecate AlreadyReportedError Bridge the legacy sentinel error pattern for @rushstack/reporter (#5858). - Deprecate AlreadyReportedError so new usage is prohibited now that structured diagnostics and RushError are available - Add LegacyErrorBridge, which observes emitted diagnostics, correlates legacy sentinels with them, and suppresses duplicate rendering of failures that are already represented - Document the bridge removal criteria for a later major - Cover sentinel detection, suppression, correlation, and the removal criteria with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 23 ++++ .../reporter/src/compat/LegacyErrorBridge.ts | 129 ++++++++++++++++++ libraries/reporter/src/index.ts | 7 + .../src/test/LegacyErrorBridge.test.ts | 75 ++++++++++ research/feature-list.json | 2 +- research/progress.txt | 14 ++ 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/compat/LegacyErrorBridge.ts create mode 100644 libraries/reporter/src/test/LegacyErrorBridge.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 94d1a67385..5c94a2081b 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -19,6 +19,14 @@ export class AiReporter implements IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; + +// @beta @deprecated +export class AlreadyReportedError extends Error { + constructor(message?: string); +} + // @beta export const BOOTSTRAP_BUFFER_MAX_BYTES: number; @@ -791,6 +799,9 @@ export interface IRushSessionReportingOptions { // @beta export function isAgentVariableActive(value: string | undefined): boolean; +// @beta +export function isAlreadyReportedSentinel(error: unknown): boolean; + // @beta export function isBootstrapHandoffFileName(fileName: string): boolean; @@ -919,9 +930,21 @@ export const KNOWN_CI_ENV_VARS: readonly string[]; // @beta export const LATEST_LOG_NAME: 'latest.log'; +// @beta +export const LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA: readonly string[]; + // @beta export type LegacyBeforeLogHook = (telemetry: Record) => void; +// @beta +export class LegacyErrorBridge { + correlate(error: unknown, diagnosticId: string): void; + getCorrelatedDiagnosticId(error: unknown): string | undefined; + ingest(event: IReporterEventEnvelope): void; + recordEmittedDiagnostic(diagnosticId: string): void; + shouldSuppressRendering(error: unknown): boolean; +} + // @beta export class LegacyFallbackSink implements IReporterEventSink { // (undocumented) diff --git a/libraries/reporter/src/compat/LegacyErrorBridge.ts b/libraries/reporter/src/compat/LegacyErrorBridge.ts new file mode 100644 index 0000000000..c726c637dd --- /dev/null +++ b/libraries/reporter/src/compat/LegacyErrorBridge.ts @@ -0,0 +1,129 @@ +// 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 { RushError } from '../diagnostics/RushError'; + +/** + * The `name` of the legacy `AlreadyReportedError` sentinel. + * + * @beta + */ +export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError' = 'AlreadyReportedError'; + +const CORRELATION_KEY: unique symbol = Symbol('rush-reporter-correlated-diagnostic-id'); + +/** + * The criteria that must be met before the legacy error bridge is removed. + * + * @remarks + * The bridge is removed only in a later major once all criteria are satisfied. + * + * @beta + */ +export const LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA: readonly string[] = [ + 'zero first-party AlreadyReportedError usages remain', + 'plugin API migration guidance is published', + 'ecosystem notice and migration time are provided' +]; + +/** + * The legacy print-then-throw sentinel error. + * + * @deprecated New usage is prohibited now that structured diagnostic APIs are + * available. Emit a structured diagnostic and throw a `RushError` that + * references its diagnostic id instead. This sentinel is recognized only so the + * bridge can suppress duplicate rendering during migration. + * + * @beta + */ +export class AlreadyReportedError extends Error { + public constructor(message?: string) { + super(message ?? 'This error has already been reported.'); + this.name = ALREADY_REPORTED_ERROR_NAME; + + // Restore the prototype chain, which is broken when subclassing a built-in + // and compiling to CommonJS. + Object.setPrototypeOf(this, AlreadyReportedError.prototype); + } +} + +/** + * Returns `true` if the value is a legacy `AlreadyReportedError` sentinel. + * + * @beta + */ +export function isAlreadyReportedSentinel(error: unknown): boolean { + return error instanceof Error && error.name === ALREADY_REPORTED_ERROR_NAME; +} + +/** + * Correlates legacy sentinel errors with previously emitted diagnostics and + * decides whether a failure should be rendered again. + * + * @remarks + * Catch boundaries render only failures that are not already represented. This + * bridge observes emitted diagnostics, so a legacy sentinel or a `RushError` + * whose diagnostic was already emitted is suppressed rather than rendered twice. + * + * @beta + */ +export class LegacyErrorBridge { + private readonly _emittedDiagnosticIds: Set = new Set(); + + /** + * Records that a diagnostic id has been emitted. + */ + public recordEmittedDiagnostic(diagnosticId: string): void { + this._emittedDiagnosticIds.add(diagnosticId); + } + + /** + * Observes an event, recording emitted diagnostic ids. + */ + public ingest(event: IReporterEventEnvelope): void { + if (event.type === 'diagnosticEmitted') { + const diagnosticId: string | undefined = (event.payload as { diagnosticId?: string }).diagnosticId; + if (diagnosticId !== undefined) { + this._emittedDiagnosticIds.add(diagnosticId); + } + } + } + + /** + * Correlates a legacy sentinel error with the diagnostic id it corresponds to. + */ + public correlate(error: unknown, diagnosticId: string): void { + if (typeof error === 'object' && error !== null) { + (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY] = diagnosticId; + } + } + + /** + * Returns the diagnostic id correlated with an error, if any. + */ + public getCorrelatedDiagnosticId(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null) { + return (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY]; + } + return undefined; + } + + /** + * Returns `true` if the failure has already been represented and should not be + * rendered again. + */ + public shouldSuppressRendering(error: unknown): boolean { + if (isAlreadyReportedSentinel(error)) { + return true; + } + if (error instanceof RushError) { + return this._emittedDiagnosticIds.has(error.diagnosticId); + } + const correlated: string | undefined = this.getCorrelatedDiagnosticId(error); + if (correlated !== undefined) { + return this._emittedDiagnosticIds.has(correlated); + } + return false; + } +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 6e8ee7c0c8..10a3f29ba6 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -113,6 +113,13 @@ export type { IEngineSinkResolution } from './compat/LegacyFallbackSink'; export { LegacyFallbackSink, createEngineSink } from './compat/LegacyFallbackSink'; export type { IOldEngineOutputAdapterOptions } from './compat/OldEngineOutputAdapter'; export { OldEngineOutputAdapter } from './compat/OldEngineOutputAdapter'; +export { + ALREADY_REPORTED_ERROR_NAME, + LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA, + AlreadyReportedError, + isAlreadyReportedSentinel, + LegacyErrorBridge +} from './compat/LegacyErrorBridge'; export type { ICreateScopedReporterOptions } from './session/ScopedReporterFactory'; export { createScopedReporter } from './session/ScopedReporterFactory'; diff --git a/libraries/reporter/src/test/LegacyErrorBridge.test.ts b/libraries/reporter/src/test/LegacyErrorBridge.test.ts new file mode 100644 index 0000000000..138bc22237 --- /dev/null +++ b/libraries/reporter/src/test/LegacyErrorBridge.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + AlreadyReportedError, + isAlreadyReportedSentinel, + LegacyErrorBridge, + LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA, + RushError, + createRushDiagnostic, + type IReporterEventEnvelope, + type IRushDiagnostic +} from '../index'; + +function diagnosticEvent(diagnostic: IRushDiagnostic): IReporterEventEnvelope { + return { type: 'diagnosticEmitted', payload: diagnostic } as unknown as IReporterEventEnvelope; +} + +describe('isAlreadyReportedSentinel', () => { + it('recognizes the sentinel by type and name', () => { + expect(isAlreadyReportedSentinel(new AlreadyReportedError())).toBe(true); + const namedError: Error = new Error('x'); + namedError.name = 'AlreadyReportedError'; + expect(isAlreadyReportedSentinel(namedError)).toBe(true); + expect(isAlreadyReportedSentinel(new Error('generic'))).toBe(false); + expect(isAlreadyReportedSentinel('not an error')).toBe(false); + }); +}); + +describe('LegacyErrorBridge', () => { + it('exposes the documented removal criteria', () => { + expect(LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA).toHaveLength(3); + expect(LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA[0]).toContain('zero first-party'); + }); + + it('suppresses rendering of a legacy sentinel', () => { + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + expect(bridge.shouldSuppressRendering(new AlreadyReportedError())).toBe(true); + expect(bridge.shouldSuppressRendering(new Error('unrepresented'))).toBe(false); + }); + + it('suppresses a RushError whose diagnostic was already emitted', () => { + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + diagnosticId: 'diag_1' + }); + const error: RushError = new RushError(diagnostic); + + // Before the diagnostic is recorded, the failure is not yet represented. + expect(bridge.shouldSuppressRendering(error)).toBe(false); + + bridge.ingest(diagnosticEvent(diagnostic)); + expect(bridge.shouldSuppressRendering(error)).toBe(true); + }); + + it('records emitted diagnostics directly and by ingesting events', () => { + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + bridge.recordEmittedDiagnostic('diag_direct'); + const directError: RushError = new RushError( + createRushDiagnostic('RUSH_OPERATION_FAILED', { diagnosticId: 'diag_direct' }) + ); + expect(bridge.shouldSuppressRendering(directError)).toBe(true); + }); + + it('correlates a legacy sentinel with an emitted diagnostic id', () => { + const bridge: LegacyErrorBridge = new LegacyErrorBridge(); + const sentinel: Error = new Error('legacy'); + bridge.correlate(sentinel, 'diag_2'); + expect(bridge.getCorrelatedDiagnosticId(sentinel)).toBe('diag_2'); + + expect(bridge.shouldSuppressRendering(sentinel)).toBe(false); + bridge.recordEmittedDiagnostic('diag_2'); + expect(bridge.shouldSuppressRendering(sentinel)).toBe(true); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 657b805fa6..9a0208480e 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -297,7 +297,7 @@ "Suppress duplicate rendering through the bridge", "Plan bridge removal after zero first-party usages and published migration guidance" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 9d9ecea5bf..837025e6b2 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -379,3 +379,17 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - normalizeAnsi, registry scope/gate, recover+link+evidence-preserve, split-chunk+ANSI, dedup cap, corpus tests pass; all exports @beta Next: Feature 25/28 - Prohibit new AlreadyReportedError + bridge legacy sentinels + +[2026-07-14] Feature 25/28 COMPLETE: Prohibit new AlreadyReportedError + bridge legacy sentinels + Files (new): + - compat/LegacyErrorBridge.ts (ALREADY_REPORTED_ERROR_NAME; AlreadyReportedError class @deprecated=prohibition; isAlreadyReportedSentinel; LegacyErrorBridge {recordEmittedDiagnostic, ingest(diagnosticEmitted->id), correlate/getCorrelatedDiagnosticId via symbol, shouldSuppressRendering}; LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA 3 items) + - test/LegacyErrorBridge.test.ts + Files (modified): index.ts, api.md + Notes: + - Prohibition encoded as @deprecated on AlreadyReportedError (api.md shows "// @beta @deprecated"); replacement = structured diagnostic + RushError. + - shouldSuppressRendering: sentinel->true; RushError whose diagnosticId already emitted->true; correlated error with emitted id->true; else false (catch boundaries render only unrepresented failures). + - Removal plan constant documents criteria (zero first-party usages, plugin migration guidance published, ecosystem notice/time). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - sentinel detection, removal criteria, suppress sentinel/RushError-emitted/correlated, record direct+ingest tests pass; all exports @beta + Next: Feature 26/28 - Heft integration via negotiated child descriptors + raw-stream fallback From 291cbc203a5a918a3c787d8b32ef8c66412b3fed Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:41:07 +0000 Subject: [PATCH 22/22] Add rush change file for legacy error bridge Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-40-55.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json new file mode 100644 index 0000000000..263e146b78 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the legacy error bridge that correlates AlreadyReportedError sentinels with emitted diagnostics and suppresses duplicate rendering, and deprecate AlreadyReportedError to prohibit new usage", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +}