diff --git a/common/changes/@microsoft/rush/reporter-plugin-rush-version-range_2026-08-24-22-30.json b/common/changes/@microsoft/rush/reporter-plugin-rush-version-range_2026-08-24-22-30.json new file mode 100644 index 0000000000..3e8e8ee818 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-plugin-rush-version-range_2026-08-24-22-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow Rush plugin manifests to declare an optional supported Rush version range.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json new file mode 100644 index 0000000000..0acd358d2a --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-18-26.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add scoped session reporting (createScopedReporter, RushSessionReporting, IScopedLogger, execution context) and Rush version range compatibility with a migration diagnostic", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json new file mode 100644 index 0000000000..bf4e381f20 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-24-15.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add shadow-phase lifecycle emission: typed lifecycle payloads, a LifecycleEmitter for session/command/operation and diagnostic events, and exit-code and result parity helpers", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json new file mode 100644 index 0000000000..f479e5635f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-30-21.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add the telemetry projection subscriber that produces an allowlisted aggregate from canonical events, a reporter adapter to observe events before filtering, and a beforeLog adapter", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json new file mode 100644 index 0000000000..12634bb3b8 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-35-03.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add reporter-independent exit-code semantics (resolveExitStatus, resolveExitStatusFromEvents, getSignalExitCode) and separateJsonControls to keep command-specific --json distinct from the json reporter", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json new file mode 100644 index 0000000000..be0da565f8 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-41-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json new file mode 100644 index 0000000000..3cf085f54f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-45-15.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json new file mode 100644 index 0000000000..d1b949aa7b --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-50-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json new file mode 100644 index 0000000000..5c3e725503 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json new file mode 100644 index 0000000000..b4dab98c79 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-05-03.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json new file mode 100644 index 0000000000..8b2cb66a2c --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-12-29.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json new file mode 100644 index 0000000000..fc1ff7e477 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-18-53.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json new file mode 100644 index 0000000000..4d7d09e0a7 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-24-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json new file mode 100644 index 0000000000..cb3845fe49 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-30-02.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json new file mode 100644 index 0000000000..722bd4c786 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-36-33.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json new file mode 100644 index 0000000000..ed74ada433 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-40-55.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-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/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 1194185085..a8350a95e1 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4050,10 +4050,17 @@ importers: version: 9.37.0 ../../../libraries/reporter: + dependencies: + semver: + specifier: ~7.7.4 + version: 7.7.4 devDependencies: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 eslint: specifier: ~9.37.0 version: 9.37.0 diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index deb4b25863..50053644d2 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -4,6 +4,29 @@ ```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 ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; + +// @beta @deprecated +export class AlreadyReportedError extends Error { + constructor(message?: string); +} + // @beta export const BOOTSTRAP_BUFFER_MAX_BYTES: number; @@ -38,12 +61,33 @@ 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) => Record; + +// @beta +export function createColorizer(enabled: boolean): IColorizer; + // @beta export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution; +// @beta +export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest, rushVersion: string): IRushDiagnostic; + // @beta export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; +// @beta +export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; + +// @beta +export function createScopedReporter(options: ICreateScopedReporterOptions): IScopedReporter; + +// @beta +export function createTelemetryReporter(subscriber: TelemetrySubscriber): IReporter; + // @beta export const DEFAULT_FLUSH_TIMEOUT_MS: number; @@ -53,15 +97,152 @@ 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; +// @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; + // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; +// @beta +export const EXIT_CODE_FAILURE: 1; + +// @beta +export const EXIT_CODE_SUCCESS: 0; + +// @beta +export 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[]; + +// @beta +export function getEventMinimumLogLevel(event: IReporterEventEnvelope): ReporterLogLevel; + +// @beta +export function getLogLevelRank(level: ReporterLogLevel): number; + // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; +// @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'; + 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; @@ -122,6 +303,45 @@ 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; + readonly durationMs?: number; + readonly exitCode: number; +} + +// @beta +export interface ICommandResultPayload { + readonly commandName: string; + readonly exitCode: number; + readonly operationCounts?: { + readonly [status: string]: number; + }; + readonly succeeded: boolean; +} + +// @beta +export interface ICommandStartedPayload { + readonly argv?: readonly string[]; + readonly commandName: string; +} + // @beta export interface ICreateRushDiagnosticOptions { readonly causeDiagnosticIds?: readonly string[]; @@ -136,6 +356,25 @@ export interface ICreateRushDiagnosticOptions { readonly source?: IRushDiagnosticSource; } +// @beta +export interface ICreateScopedReporterOptions { + readonly protocolVersion?: IReporterProtocolVersion; + readonly scope?: IReporterEventScope; + readonly sessionId: string; + readonly sink: IReporterEventSink; + readonly source: IReporterEventSource; +} + +// @beta +export interface IDefaultInteractiveReporterOptions { + readonly color?: boolean; + readonly env?: Record; + readonly logPath?: string; + readonly minRefreshIntervalMs?: number; + readonly nowMs?: () => number; + readonly terminal: IInteractiveTerminal; +} + // @beta export interface IEarlyReporterControls { readonly logLevel?: string; @@ -148,6 +387,81 @@ 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; + 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 IGetMatchersOptions { + readonly includeDisabled?: boolean; + readonly version?: string; +} + +// @beta +export interface IInteractiveTerminal { + readonly columns: number; + readonly isTTY: boolean; + write(text: string): void; +} + +// @beta +export interface IJsonControls { + readonly commandJson: boolean; + readonly reporterJson: boolean; +} + +// @beta +export interface IJsonReporterOptions { + readonly maxRecordBytes?: number; + readonly write: (text: string) => void; +} + +// @beta +export interface ILegacyReporterOptions { + readonly maxParallelism?: number; + readonly write: (text: string) => void; +} + +// @beta +export interface ILifecycleEmitterOptions { + readonly protocolVersion?: IReporterProtocolVersion; + readonly scope?: IReporterEventScope; + readonly sessionId: string; + readonly sink: IReporterEventSink; + 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 IMessageEmittedPayload { readonly privacy?: ReporterPrivacyClassification; @@ -174,6 +488,74 @@ export interface IOldEngineOutputAdapterOptions { readonly source: IReporterEventSource; } +// @beta +export interface IOperationRegisteredPayload { + readonly operationId: string; + readonly phaseName?: string; + readonly projectName?: string; +} + +// @beta +export interface IOperationStatusChangedPayload { + readonly durationMs?: number; + readonly operationId: string; + 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; + readonly heartbeatIntervalMs?: number; + readonly nowMs?: () => number; + readonly variant?: PlaintextVariant; + 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; + readonly spinnerFrame: string; + readonly width: number; +} + // @beta export interface IReporter { closeAsync(): Promise; @@ -245,6 +627,12 @@ export interface IReporterEventSource { readonly packageVersion: string; } +// @beta +export interface IReporterExecutionContext { + readonly reporter: IScopedReporter; + readonly sink: IReporterEventSink; +} + // @beta export interface IReporterFrontendDescriptor { readonly hasManager: boolean; @@ -298,6 +686,24 @@ 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 IReporterPlanEntry { + readonly destination: string; + readonly machine: boolean; + readonly reporter: ReporterName; + readonly role: 'primary' | 'additional'; + readonly variant?: PlaintextVariant; +} + // @beta export interface IReporterProtocolLimits { readonly bootstrapBufferBytes: number; @@ -317,6 +723,42 @@ 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; + readonly signal?: NodeJS.Signals; +} + +// @beta +export interface IResolveExitStatusOptions { + readonly cancelled?: boolean; + readonly hasFailures?: boolean; + readonly signal?: NodeJS.Signals; +} + +// @beta +export interface IRunProblemMatchersOptions { + readonly maxDuplicates?: number; +} + // @beta export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; @@ -347,6 +789,13 @@ export interface IRushDiagnosticCodeDefinition { // @beta export type IRushDiagnosticSource = IRushFileDiagnosticSource | IRushToolDiagnosticSource; +// @beta +export interface IRushExitStatus { + readonly exitCode: number; + readonly outcome: RushCommandOutcome; + readonly signal?: NodeJS.Signals; +} + // @beta export interface IRushFileDiagnosticSource { readonly column?: number; @@ -356,6 +805,12 @@ export interface IRushFileDiagnosticSource { readonly toolName?: string; } +// @beta +export interface IRushPluginManifest { + readonly pluginName: string; + readonly rushVersionRange: string; +} + // @beta export interface IRushRemediationAction { readonly automatedExecutionSafety: RushRemediationSafety; @@ -364,15 +819,40 @@ export interface IRushRemediationAction { readonly documentationUrl?: string; } +// @beta +export interface IRushSessionReportingOptions { + readonly protocolVersion?: IReporterProtocolVersion; + readonly sessionId: string; + readonly sink: IReporterEventSink; + readonly source: IReporterEventSource; +} + // @beta export interface IRushToolDiagnosticSource { readonly kind: 'tool'; readonly toolName: string; } +// @beta +export function isAgentVariableActive(value: string | undefined): boolean; + +// @beta +export function isAlreadyReportedSentinel(error: unknown): boolean; + // @beta export function isBootstrapHandoffFileName(fileName: string): boolean; +// @beta +export function isCiDetected(env: Record): boolean; + +// @beta +export interface IScopedLogger { + writeDebugLine(text: string): string; + writeErrorLine(text: string): string; + writeLine(text: string): string; + writeWarningLine(text: string): string; +} + // @beta export interface IScopedMessageOptions { readonly privacy?: ReporterPrivacyClassification; @@ -387,6 +867,34 @@ export interface IScopedReporter { emitMessage(options: IScopedMessageOptions): string; } +// @beta +export interface ISessionCompletedPayload { + readonly durationMs?: number; + readonly exitCode: number; +} + +// @beta +export interface ISessionStartedPayload { + readonly cwd?: string; + readonly rushVersion: string; +} + +// @beta +export interface IShadowResultSummary { + readonly commandName?: string; + readonly exitCode: number; + readonly operationCounts: { + readonly [status: string]: number; + }; + readonly succeeded: boolean; +} + +// @beta +export function isLegacyEmergencyFallbackRequested(env: Record): boolean; + +// @beta +export function isMachineReporter(reporter: ReporterName): boolean; + // @beta export function isReporterEventRequired(type: ReporterEventType): boolean; @@ -396,24 +904,136 @@ export function isReporterExtensionEventName(name: string): name is ReporterExte // @beta export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, producer: IReporterProtocolVersion): boolean; +// @beta +export function isRushVersionSupported(rushVersionRange: string, rushVersion: string): 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; +// @beta +export interface ITelemetryAggregate { + readonly commandName?: string; + readonly diagnosticCategoryCounts: { + readonly [category: string]: number; + }; + readonly diagnosticCodes: readonly string[]; + readonly durationMs?: number; + readonly exitCode?: number; + readonly operationStatusCounts: { + readonly [status: string]: number; + }; + readonly producerVersions: readonly string[]; + readonly protocolVersion?: IReporterProtocolVersion; + readonly reporterMode?: string; + readonly result?: TelemetryResult; +} + +// @beta +export function iterateExternalOutput(events: readonly IReporterEventEnvelope[]): IExternalOutputChunk[]; + +// @beta +export interface IWatchCycleCompletedPayload { + readonly changedProjects?: readonly string[]; + readonly succeeded: boolean; +} + // @beta export interface IWriteBootstrapHandoffOptions { readonly directory?: string; 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[]; + // @beta export type KnownRushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; +// @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) 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); + // (undocumented) + emitCommandCompleted(payload: ICommandCompletedPayload): string; + // (undocumented) + emitCommandResult(payload: ICommandResultPayload): string; + // (undocumented) + emitCommandStarted(payload: ICommandStartedPayload): string; + emitDiagnostic(diagnostic: IRushDiagnostic): string; + // (undocumented) + emitOperationRegistered(payload: IOperationRegisteredPayload): string; + // (undocumented) + emitOperationStatusChanged(payload: IOperationStatusChangedPayload): string; + // (undocumented) + emitSessionCompleted(payload: ISessionCompletedPayload): string; + // (undocumented) + emitSessionStarted(payload: ISessionStartedPayload): string; + // (undocumented) + emitWatchCycleCompleted(payload: IWatchCycleCompletedPayload): string; +} + +// @beta +export const MIN_REFRESH_INTERVAL_MS: number; + // @beta export class NdjsonDecoder { constructor(options?: INdjsonOptions); @@ -438,6 +1058,9 @@ export class NdjsonRecordTooLargeError extends Error { // @beta export function negotiateReporterHello(helloValue: unknown, options: IReporterHandshakeOptions): IReporterHandshakeResult; +// @beta +export function normalizeAnsi(text: string): string; + // @beta export class OldEngineOutputAdapter { constructor(options: IOldEngineOutputAdapterOptions); @@ -447,15 +1070,60 @@ export class OldEngineOutputAdapter { // @beta export type OneOrMoreRushDiagnosticCodeSegments = string extends TSegments ? `_${Uppercase}` : TSegments extends `_${infer Segments}` ? Segments extends '' ? never : TSegments extends Uppercase ? TSegments : never : never; +// @beta +export type OperationStatus = 'ready' | 'waiting' | 'queued' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp' | 'aborted'; + +// @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; +// @beta +export function parseOutputControl(value: string): IReporterOutputTarget; + // @beta export function parseReporterExtensionEventName(name: string): ReporterExtensionEventName; // @beta export function parseReporterHello(value: unknown): IReporterHello; +// @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'; + +// @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<{ header: IBootstrapHandoffHeader | undefined; @@ -463,6 +1131,15 @@ export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ discardedRecordCount: number; }>; +// @beta +export function regroupOperationOutput(events: readonly IReporterEventEnvelope[]): Map; + +// @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 ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; @@ -509,6 +1186,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); @@ -538,12 +1218,30 @@ 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'; +// @beta +export function resolveColorEnabled(env: Record, isTTY: boolean): boolean; + +// @beta +export function resolveExitStatus(options: IResolveExitStatusOptions): IRushExitStatus; + +// @beta +export function resolveExitStatusFromEvents(events: readonly IReporterEventEnvelope[], options?: IResolveExitStatusFromEventsOptions): IRushExitStatus; + // @beta export function resolveReporterCompatibility(frontend: IReporterFrontendDescriptor, engine: IReporterEngineDescriptor): IReporterCompatibilityDecision; +// @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 [{ readonly code: "RUSH_CONFIG_INVALID_JSON"; @@ -593,6 +1291,18 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.summary"; readonly detailKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.detail"; +}, { + readonly code: "RUSH_PLUGIN_API_INCOMPATIBLE"; + readonly category: "configuration"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_EXTERNAL_TOOL_PROBLEM"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; + readonly detailKey: undefined; }]; // @beta @@ -604,12 +1314,21 @@ export const RUSH_DIAGNOSTIC_TEMPLATES: Readonly): boolean; + +// @beta +export const SPINNER_FRAMES: readonly string[]; + +// @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[]; + +// @beta +export type TelemetryResult = 'succeeded' | 'failed'; + +// @beta +export class TelemetrySubscriber { + constructor(); + buildAggregate(): ITelemetryAggregate; + ingest(event: IReporterEventEnvelope): void; + 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/package.json b/libraries/reporter/package.json index 55b3e8e78d..0dac948f58 100644 --- a/libraries/reporter/package.json +++ b/libraries/reporter/package.json @@ -49,7 +49,8 @@ "devDependencies": { "@rushstack/heft": "workspace:*", "eslint": "~9.37.0", - "local-node-rig": "workspace:*" + "local-node-rig": "workspace:*", + "@types/semver": "7.7.1" }, "peerDependencies": { "@types/node": "*" @@ -59,5 +60,8 @@ "optional": true } }, - "sideEffects": false + "sideEffects": false, + "dependencies": { + "semver": "~7.7.4" + } } diff --git a/libraries/reporter/src/compat/LegacyErrorBridge.ts b/libraries/reporter/src/compat/LegacyErrorBridge.ts new file mode 100644 index 0000000000..69c84e6c18 --- /dev/null +++ b/libraries/reporter/src/compat/LegacyErrorBridge.ts @@ -0,0 +1,132 @@ +// 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 || error.constructor?.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/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/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/config/LogLevelFilter.ts b/libraries/reporter/src/config/LogLevelFilter.ts new file mode 100644 index 0000000000..f644a0edda --- /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 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': + case 'sessionCompleted': + case 'commandCompleted': + return 'quiet'; + case 'diagnosticEmitted': { + const severity: string | undefined = (event.payload as { severity?: string }).severity; + if (severity === 'error') { + return 'quiet'; + } + return 'normal'; + } + case 'sessionStarted': + case 'commandStarted': + case 'operationStatusChanged': + case 'watchCycleCompleted': + case 'artifactAvailable': + return 'normal'; + case 'messageEmitted': { + const severity: string | undefined = (event.payload as { severity?: string }).severity; + if (severity === 'error' || severity === 'warning') { + return 'quiet'; + } + if (severity === 'debug') { + return 'debug'; + } + return 'normal'; + } + case 'activityChanged': + case 'operationRegistered': + return 'normal'; + case 'externalProcessStarted': + case 'externalProcessCompleted': + return 'verbose'; + case 'externalOutput': + return 'debug'; + case 'extension': + return 'normal'; + 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/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..6c8a1a1fea --- /dev/null +++ b/libraries/reporter/src/config/ReporterSelection.ts @@ -0,0 +1,207 @@ +// 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 envReporterRaw: string | undefined = input.env.RUSH_REPORTER; + if (envReporterRaw !== undefined && envReporterRaw.length > 0) { + const envReporter: string = envReporterRaw.trim().toLowerCase(); + if (!isSupportedReporterName(envReporter)) { + throw new Error(`Unsupported reporter requested with RUSH_REPORTER: ${JSON.stringify(envReporterRaw)}`); + } + 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/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 08bea76689..f67e2bf659 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -212,6 +212,20 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.summary', detailKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.detail' + }, + { + code: 'RUSH_PLUGIN_API_INCOMPATIBLE', + category: 'configuration', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary', + detailKey: undefined + }, + { + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + detailKey: undefined } ]); diff --git a/libraries/reporter/src/diagnostics/templates/configuration.ts b/libraries/reporter/src/diagnostics/templates/configuration.ts index 44a65bd672..9eb6d12247 100644 --- a/libraries/reporter/src/diagnostics/templates/configuration.ts +++ b/libraries/reporter/src/diagnostics/templates/configuration.ts @@ -10,5 +10,7 @@ */ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const CONFIGURATION_DIAGNOSTIC_TEMPLATES = { - 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary': 'The configuration file {file} contains invalid JSON.' + 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary': 'The configuration file {file} contains invalid JSON.', + 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary': + 'The plugin {pluginName} supports Rush {rushVersionRange}, which does not include the running Rush version {rushVersion}.' } as const; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 9af6003da1..3210766838 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -10,5 +10,6 @@ */ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { - 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.' + 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' } as const; diff --git a/libraries/reporter/src/events/ReporterEventType.ts b/libraries/reporter/src/events/ReporterEventType.ts index e904a55ab4..f0c99004fc 100644 --- a/libraries/reporter/src/events/ReporterEventType.ts +++ b/libraries/reporter/src/events/ReporterEventType.ts @@ -18,11 +18,11 @@ * | `sessionCompleted` | yes | `quiet` | * | `commandStarted` | yes | `normal` | * | `commandCompleted` | yes | `quiet` | - * | `operationRegistered` | yes | `verbose` | + * | `operationRegistered` | yes | `normal` | * | `operationStatusChanged` | yes | `normal` | * | `activityChanged` | **no โ€” coalescible** | `normal` | * | `watchCycleCompleted` | yes | `normal` | - * | `diagnosticEmitted` | yes | `quiet` | + * | `diagnosticEmitted` | yes | by severity: error `quiet`, warning `normal` | * | `messageEmitted` | yes | by severity: error/warning `quiet`, info `normal`, debug `debug` | * | `externalProcessStarted` | yes | `verbose` | * | `externalOutput` | yes | `debug` | diff --git a/libraries/reporter/src/exit/CommandJson.ts b/libraries/reporter/src/exit/CommandJson.ts new file mode 100644 index 0000000000..2e3d14cbb9 --- /dev/null +++ b/libraries/reporter/src/exit/CommandJson.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The independently-resolved JSON controls on a command line. + * + * @beta + */ +export interface IJsonControls { + /** + * Whether the command-specific `--json` flag was requested. This selects the + * command's own JSON schema and is unchanged by the reporter system. + */ + readonly commandJson: boolean; + + /** + * Whether the `json` reporter was requested through `--reporter`. + */ + readonly reporterJson: boolean; +} + +/** + * Separates the command-specific `--json` flag from the `json` reporter selection. + * + * @remarks + * The command-specific `--json` behavior is preserved and is never an alias for + * `--reporter=json`. Both may be requested independently, and each keeps its own + * output schema. + * + * @param argv - the command-line arguments, excluding the node executable and script + * + * @beta + */ +export function separateJsonControls(argv: readonly string[]): IJsonControls { + let commandJson: boolean = false; + let reporterJson: boolean = false; + + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg === '--json') { + commandJson = true; + } else if (arg === '--reporter=json') { + reporterJson = true; + } else if (arg === '--reporter' && argv[index + 1] === 'json') { + reporterJson = true; + } + } + + return { commandJson, reporterJson }; +} diff --git a/libraries/reporter/src/exit/ExitStatus.ts b/libraries/reporter/src/exit/ExitStatus.ts new file mode 100644 index 0000000000..ca7d7e236c --- /dev/null +++ b/libraries/reporter/src/exit/ExitStatus.ts @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; + +/** + * The exit code returned for success, including warning-only success. + * + * @beta + */ +export const EXIT_CODE_SUCCESS: 0 = 0; + +/** + * The exit code returned for a Rush or operation failure and for logical cancellation. + * + * @beta + */ +export const EXIT_CODE_FAILURE: 1 = 1; + +/** + * The logical outcome of a command, independent of presentation. + * + * @beta + */ +export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; + +/** + * The resolved exit status of a command. + * + * @beta + */ +export interface IRushExitStatus { + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The logical outcome. + */ + readonly outcome: RushCommandOutcome; + + /** + * The terminating OS signal, when the outcome is `signal`. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Returns the conventional exit code for an OS signal, `128 + signalNumber`. + * + * @param signal - the terminating signal + * + * @beta + */ +export function getSignalExitCode(signal: NodeJS.Signals): number { + const signalNumber: number | undefined = (os.constants.signals as Record)[signal]; + return 128 + (signalNumber ?? 0); +} + +/** + * Options for {@link resolveExitStatus}. + * + * @beta + */ +export interface IResolveExitStatusOptions { + /** + * Whether the command had a Rush or operation failure. Warnings alone are not failures. + */ + readonly hasFailures?: boolean; + + /** + * Whether the command was logically cancelled or aborted. + */ + readonly cancelled?: boolean; + + /** + * The terminating OS signal, if any. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Resolves a command's exit status from its outcome. + * + * @remarks + * A signal termination yields the conventional signal-derived status. + * Cancellation and failure yield `1`. Everything else, including warning-only + * success, yields `0`. The reporter mode and diagnostic categories are never + * inputs, so they can never select the exit code. + * + * @param options - the command outcome + * + * @beta + */ +export function resolveExitStatus(options: IResolveExitStatusOptions): IRushExitStatus { + if (options.signal) { + return { exitCode: getSignalExitCode(options.signal), outcome: 'signal', signal: options.signal }; + } + if (options.cancelled) { + return { exitCode: EXIT_CODE_FAILURE, outcome: 'cancelled' }; + } + if (options.hasFailures) { + return { exitCode: EXIT_CODE_FAILURE, outcome: 'failed' }; + } + return { exitCode: EXIT_CODE_SUCCESS, outcome: 'succeeded' }; +} + +/** + * Options for {@link resolveExitStatusFromEvents}. + * + * @beta + */ +export interface IResolveExitStatusFromEventsOptions { + /** + * Whether the command was logically cancelled or aborted. + */ + readonly cancelled?: boolean; + + /** + * The terminating OS signal, if any. + */ + readonly signal?: NodeJS.Signals; +} + +/** + * Resolves a command's exit status from its structured event stream. + * + * @remarks + * A failure is any failed command result, nonzero root completion code, + * error-severity diagnostic, or failed or aborted operation. Diagnostic + * categories and the selected reporter are never consulted, so they cannot + * influence the exit code. Warning-severity diagnostics never cause failure. + * + * @param events - the structured events emitted during the command + * @param options - cancellation and signal state + * + * @beta + */ +export function resolveExitStatusFromEvents( + events: readonly IReporterEventEnvelope[], + options: IResolveExitStatusFromEventsOptions = {} +): IRushExitStatus { + let hasFailures: boolean = false; + for (const event of events) { + if (event.parentSessionId !== undefined) { + continue; + } + if (event.type === 'commandResult') { + if ((event.payload as { succeeded: boolean }).succeeded === false) { + hasFailures = true; + } + } else if (event.type === 'commandCompleted' || event.type === 'sessionCompleted') { + if ((event.payload as { exitCode: number }).exitCode !== 0) { + hasFailures = true; + } + } else if (event.type === 'diagnosticEmitted') { + if ((event.payload as { severity?: string }).severity === 'error') { + hasFailures = true; + } + } else if (event.type === 'operationStatusChanged') { + const status: string | undefined = (event.payload as { status?: string }).status; + if (status === 'failure' || status === 'aborted') { + hasFailures = true; + } + } + } + + return resolveExitStatus({ hasFailures, cancelled: options.cancelled, signal: options.signal }); +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 346c7a775a..3be5d668fb 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -152,6 +152,148 @@ 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'; +export type { IScopedLogger } from './session/ScopedLogger'; +export { createScopedLogger } from './session/ScopedLogger'; +export type { IRushSessionReportingOptions, IReporterExecutionContext } from './session/RushSessionReporting'; +export { RushSessionReporting } from './session/RushSessionReporting'; +export type { IRushPluginManifest } from './session/PluginApi'; +export { isRushVersionSupported, createPluginApiIncompatibleDiagnostic } from './session/PluginApi'; + +export type { + OperationStatus, + ISessionStartedPayload, + ISessionCompletedPayload, + ICommandStartedPayload, + ICommandCompletedPayload, + IOperationRegisteredPayload, + IOperationStatusChangedPayload, + ICommandResultPayload, + IWatchCycleCompletedPayload +} from './lifecycle/LifecycleEvents'; +export type { ILifecycleEmitterOptions } from './lifecycle/LifecycleEmitter'; +export { LifecycleEmitter } from './lifecycle/LifecycleEmitter'; +export type { IShadowResultSummary } from './lifecycle/ShadowParity'; +export { deriveExitCodeFromEvents, summarizeShadowResult } from './lifecycle/ShadowParity'; + +export type { TelemetryResult, ITelemetryAggregate } from './telemetry/TelemetryAggregate'; +export { TELEMETRY_AGGREGATE_KEYS } from './telemetry/TelemetryAggregate'; +export { TelemetrySubscriber, createTelemetryReporter } from './telemetry/TelemetrySubscriber'; +export type { LegacyBeforeLogHook } from './telemetry/BeforeLogAdapter'; +export { createBeforeLogAdapter } from './telemetry/BeforeLogAdapter'; + +export type { + RushCommandOutcome, + IRushExitStatus, + IResolveExitStatusOptions, + IResolveExitStatusFromEventsOptions +} from './exit/ExitStatus'; +export { + EXIT_CODE_SUCCESS, + EXIT_CODE_FAILURE, + getSignalExitCode, + resolveExitStatus, + resolveExitStatusFromEvents +} from './exit/ExitStatus'; +export type { IJsonControls } from './exit/CommandJson'; +export { separateJsonControls } from './exit/CommandJson'; + +export type { 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 { + FILE_REPORTER_DEFAULT_LOG_LEVEL, + getLogLevelRank, + getEventMinimumLogLevel, + shouldRenderAtLogLevel, + filterEventsForLogLevel +} from './config/LogLevelFilter'; +export type { + PlaintextVariant, + IReporterPlanEntry, + IAutomaticReporterPlan +} from './config/AutomaticReporterMatrix'; +export { + isMachineReporter, + planAutomaticReporters, + 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 { 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 { 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 { IOperationStreamEmitterOptions } from './scheduler/OperationStreamEmitter'; +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 { diff --git a/libraries/reporter/src/lifecycle/LifecycleEmitter.ts b/libraries/reporter/src/lifecycle/LifecycleEmitter.ts new file mode 100644 index 0000000000..00497c69ec --- /dev/null +++ b/libraries/reporter/src/lifecycle/LifecycleEmitter.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 type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { computeEnvelopePrivacyFloor } from '../diagnostics/DiagnosticPrivacy'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; +import type { + ISessionStartedPayload, + ISessionCompletedPayload, + ICommandStartedPayload, + ICommandCompletedPayload, + IOperationRegisteredPayload, + IOperationStatusChangedPayload, + ICommandResultPayload, + IWatchCycleCompletedPayload +} from './LifecycleEvents'; + +/** + * Options for constructing a {@link LifecycleEmitter}. + * + * @beta + */ +export interface ILifecycleEmitterOptions { + /** + * The sink events are emitted into. + */ + readonly sink: IReporterEventSink; + + /** + * The session id stamped onto emitted events. + */ + readonly sessionId: string; + + /** + * The producer identity stamped onto emitted events. + */ + readonly source: IReporterEventSource; + + /** + * The base scope merged into every emitted event. + */ + readonly scope?: IReporterEventScope; + + /** + * The protocol version stamped onto emitted events. Defaults to + * {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; +} + +/** + * Emits the canonical first-party lifecycle and diagnostic events. + * + * @remarks + * Actions, the operation scheduler, and plugins use this to publish structured + * events. During the shadow phase these events flow to subscribers while legacy + * rendering remains the sole visible output; the emitter itself writes nothing + * to stdout or stderr. Every lifecycle, result, and diagnostic event is marked + * required so the manager never drops it. + * + * @beta + */ +export class LifecycleEmitter { + private readonly _sink: IReporterEventSink; + private readonly _sessionId: string; + private readonly _source: IReporterEventSource; + private readonly _scope: IReporterEventScope | undefined; + private readonly _protocolVersion: IReporterProtocolVersion; + + public constructor(options: ILifecycleEmitterOptions) { + this._sink = options.sink; + this._sessionId = options.sessionId; + this._source = options.source; + this._scope = options.scope; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + } + + public emitSessionStarted(payload: ISessionStartedPayload): string { + return this._emit('sessionStarted', payload, 'public'); + } + + public emitSessionCompleted(payload: ISessionCompletedPayload): string { + return this._emit('sessionCompleted', payload, 'public'); + } + + public emitCommandStarted(payload: ICommandStartedPayload): string { + return this._emit('commandStarted', payload, 'public', { commandName: payload.commandName }); + } + + public emitCommandCompleted(payload: ICommandCompletedPayload): string { + return this._emit('commandCompleted', payload, 'public', { commandName: payload.commandName }); + } + + public emitOperationRegistered(payload: IOperationRegisteredPayload): string { + return this._emit('operationRegistered', payload, 'public', { + operationId: payload.operationId, + ...(payload.projectName === undefined ? {} : { projectName: payload.projectName }), + ...(payload.phaseName === undefined ? {} : { phaseName: payload.phaseName }) + }); + } + + public emitOperationStatusChanged(payload: IOperationStatusChangedPayload): string { + return this._emit('operationStatusChanged', payload, 'public', { + operationId: payload.operationId + }); + } + + public emitCommandResult(payload: ICommandResultPayload): string { + return this._emit('commandResult', payload, 'public', { commandName: payload.commandName }); + } + + public emitWatchCycleCompleted(payload: IWatchCycleCompletedPayload): string { + return this._emit('watchCycleCompleted', payload, 'public'); + } + + /** + * Emits a structured diagnostic alongside the existing legacy rendering. + */ + public emitDiagnostic(diagnostic: IRushDiagnostic): string { + const classifications: ReadonlyArray<'public' | 'local-sensitive' | 'secret'> = diagnostic.parameters + ? Object.values(diagnostic.parameters).map((value) => value.privacy) + : []; + return this._emit('diagnosticEmitted', diagnostic, computeEnvelopePrivacyFloor(classifications)); + } + + private _emit( + type: + | 'sessionStarted' + | 'sessionCompleted' + | 'commandStarted' + | 'commandCompleted' + | 'operationRegistered' + | 'operationStatusChanged' + | 'commandResult' + | 'watchCycleCompleted' + | 'diagnosticEmitted', + payload: unknown, + privacy: 'public' | 'local-sensitive' | 'secret', + scopeOverride?: IReporterEventScope + ): string { + const scope: IReporterEventScope | undefined = + this._scope || scopeOverride ? { ...this._scope, ...scopeOverride } : undefined; + return this._sink.emit({ + protocolVersion: this._protocolVersion, + sessionId: this._sessionId, + source: this._source, + scope, + privacy, + type, + payload + }); + } +} diff --git a/libraries/reporter/src/lifecycle/LifecycleEvents.ts b/libraries/reporter/src/lifecycle/LifecycleEvents.ts new file mode 100644 index 0000000000..e142e9ef32 --- /dev/null +++ b/libraries/reporter/src/lifecycle/LifecycleEvents.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The terminal or transient status of a scheduled operation. + * + * @beta + */ +export type OperationStatus = + | 'ready' + | 'waiting' + | 'queued' + | 'executing' + | 'success' + | 'successWithWarnings' + | 'failure' + | 'blocked' + | 'skipped' + | 'fromCache' + | 'noOp' + | 'aborted'; + +/** + * The payload of a `sessionStarted` event. + * + * @beta + */ +export interface ISessionStartedPayload { + /** + * The Rush version that started the session. + */ + readonly rushVersion: string; + + /** + * The working directory, when recorded. + */ + readonly cwd?: string; +} + +/** + * The payload of a `sessionCompleted` event. + * + * @beta + */ +export interface ISessionCompletedPayload { + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The total session duration in milliseconds. + */ + readonly durationMs?: number; +} + +/** + * The payload of a `commandStarted` event. + * + * @beta + */ +export interface ICommandStartedPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * The command arguments. + */ + readonly argv?: readonly string[]; +} + +/** + * The payload of a `commandCompleted` event. + * + * @beta + */ +export interface ICommandCompletedPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * The command duration in milliseconds. + */ + readonly durationMs?: number; +} + +/** + * The payload of an `operationRegistered` event. + * + * @beta + */ +export interface IOperationRegisteredPayload { + /** + * The operation id. + */ + readonly operationId: string; + + /** + * The project the operation belongs to. + */ + readonly projectName?: string; + + /** + * The phase the operation belongs to. + */ + readonly phaseName?: string; +} + +/** + * The payload of an `operationStatusChanged` event. + * + * @beta + */ +export interface IOperationStatusChangedPayload { + /** + * The operation id. + */ + readonly operationId: string; + + /** + * The new status. + */ + readonly status: OperationStatus; + + /** + * The operation duration in milliseconds when known. + */ + readonly durationMs?: number; +} + +/** + * The payload of a `commandResult` event. + * + * @beta + */ +export interface ICommandResultPayload { + /** + * The command name. + */ + readonly commandName: string; + + /** + * Whether the command succeeded, including warning-only success. + */ + readonly succeeded: boolean; + + /** + * The process exit code. + */ + readonly exitCode: number; + + /** + * Operation counts keyed by status. + */ + readonly operationCounts?: { readonly [status: string]: number }; +} + +/** + * The payload of a `watchCycleCompleted` event. + * + * @beta + */ +export interface IWatchCycleCompletedPayload { + /** + * Whether the watch cycle succeeded. + */ + readonly succeeded: boolean; + + /** + * The projects that changed to trigger the cycle. + */ + readonly changedProjects?: readonly string[]; +} diff --git a/libraries/reporter/src/lifecycle/ShadowParity.ts b/libraries/reporter/src/lifecycle/ShadowParity.ts new file mode 100644 index 0000000000..6aea5f08dd --- /dev/null +++ b/libraries/reporter/src/lifecycle/ShadowParity.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { ICommandResultPayload, IOperationStatusChangedPayload } from './LifecycleEvents'; + +/** + * A shadow-phase summary derived from the structured event stream, used to + * validate parity with legacy behavior. + * + * @beta + */ +export interface IShadowResultSummary { + /** + * The command name, when a command result was present. + */ + readonly commandName?: string; + + /** + * Whether the command succeeded. + */ + readonly succeeded: boolean; + + /** + * The derived process exit code. + */ + readonly exitCode: number; + + /** + * The number of operations that reached each status. + */ + readonly operationCounts: { readonly [status: string]: number }; +} + +/** + * Derives the process exit code from a structured event stream. + * + * @remarks + * This shadow-phase helper validates exit-code parity: a `commandResult` maps a + * successful command, including warning-only success, to `0` and a failure to + * its non-zero code. A `sessionCompleted` code is used as a fallback. The + * authoritative exit-code semantics are defined separately. + * + * @param events - the structured events emitted during the command + * + * @beta + */ +export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number { + let commandResult: ICommandResultPayload | undefined; + for (const event of events) { + if (event.parentSessionId === undefined && event.type === 'commandResult') { + commandResult = event.payload as ICommandResultPayload; + } + } + if (commandResult !== undefined) { + if (commandResult.succeeded) { + return 0; + } + return commandResult.exitCode !== 0 ? commandResult.exitCode : 1; + } + + let sessionExitCode: number | undefined; + for (const event of events) { + if (event.parentSessionId === undefined && event.type === 'sessionCompleted') { + sessionExitCode = (event.payload as { exitCode: number }).exitCode; + } + } + + return sessionExitCode ?? 0; +} + +/** + * Summarizes a command's structured event stream for parity validation. + * + * @remarks + * The returned counts and result are shadow-phase parity data, not the + * allowlisted telemetry projection. + * + * @param events - the structured events emitted during the command + * + * @beta + */ +export function summarizeShadowResult( + events: readonly IReporterEventEnvelope[] +): IShadowResultSummary { + const operationStatuses: Map = new Map(); + let commandName: string | undefined; + let commandSucceeded: boolean | undefined; + + for (const event of events) { + if (event.parentSessionId !== undefined) { + continue; + } + if (event.type === 'operationStatusChanged') { + const payload: IOperationStatusChangedPayload = event.payload as IOperationStatusChangedPayload; + operationStatuses.set(payload.operationId, payload.status); + } else if (event.type === 'commandResult') { + const payload: ICommandResultPayload = event.payload as ICommandResultPayload; + commandName = payload.commandName; + commandSucceeded = payload.succeeded; + } + } + + const operationCounts: { [status: string]: number } = {}; + for (const status of operationStatuses.values()) { + operationCounts[status] = (operationCounts[status] ?? 0) + 1; + } + + const exitCode: number = deriveExitCodeFromEvents(events); + return { + commandName, + succeeded: commandSucceeded ?? exitCode === 0, + exitCode, + operationCounts + }; +} 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..2cd272ecc8 --- /dev/null +++ b/libraries/reporter/src/matchers/ProblemMatcherRegistry.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { 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 (matcher.matchesVersion !== undefined) { + if (options.version === 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..582b48ead3 --- /dev/null +++ b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import 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 => { + const normalizedLine: string = normalizeAnsi(line); + if (normalizedLine.length === 0) { + return; + } + for (const matcher of matchers) { + const match: RegExpMatchArray | null = normalizedLine.match(matcher.pattern); + if (match) { + matchedLineCount++; + const problem: IProblemMatch = matcher.extract(match); + const key: string = + `${operationId ?? ''}|${matcher.tool}|${problem.code ?? ''}|${problem.file ?? ''}|` + + `${problem.line ?? ''}|${problem.column ?? ''}|${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) ?? '') + chunk.text; + const lines: string[] = buffered.split('\n'); + const remainder: string = lines.pop() ?? ''; + for (const line of lines) { + processLine(line.endsWith('\r') ? line.slice(0, -1) : line, chunk.operationId); + } + partialLines.set(key, remainder); + } + for (const [key, remainder] of partialLines) { + processLine( + remainder.endsWith('\r') ? remainder.slice(0, -1) : 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: + problem.file !== undefined + ? { + kind: 'file', + file: problem.file, + line: problem.line, + column: problem.column, + toolName: matcher.tool + } + : { kind: 'tool', toolName: matcher.tool }, + relatedArtifactIds: operationId !== undefined ? [operationId] : undefined + }); +} diff --git a/libraries/reporter/src/reporters/AiReporter.ts b/libraries/reporter/src/reporters/AiReporter.ts new file mode 100644 index 0000000000..f6df4fc6a7 --- /dev/null +++ b/libraries/reporter/src/reporters/AiReporter.ts @@ -0,0 +1,356 @@ +// 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 MIN_AI_MAX_BYTES: number = 512; +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 _errorCodes: Set; + private readonly _diagnosticCategoryCounts: { [category: string]: number }; + private _errorDiagnosticsTruncated: boolean; + private _warningDiagnosticsTruncated: boolean; + 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; + if (!Number.isInteger(this._maxBytes) || this._maxBytes < MIN_AI_MAX_BYTES) { + throw new RangeError(`maxBytes must be an integer of at least ${MIN_AI_MAX_BYTES}`); + } + if (!Number.isInteger(this._maxDetailedDiagnostics) || this._maxDetailedDiagnostics < 0) { + throw new RangeError('maxDetailedDiagnostics must be a nonnegative integer'); + } + + this._protocolVersion = REPORTER_PROTOCOL_VERSION; + this._commandName = undefined; + this._projectByOperation = new Map(); + this._operationCounts = {}; + this._failedProjects = []; + this._errorDiagnostics = []; + this._warningDiagnostics = []; + this._errorCodes = new Set(); + this._diagnosticCategoryCounts = {}; + this._errorDiagnosticsTruncated = false; + this._warningDiagnosticsTruncated = false; + 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(false, 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._errorCodes.add(diagnostic.code); + if (this._errorDiagnostics.length < this._maxDetailedDiagnostics) { + this._errorDiagnostics.push({ + code: diagnostic.code, + category: diagnostic.category, + severity: 'error', + remediation: diagnostic.remediation + }); + } else { + this._errorDiagnosticsTruncated = true; + } + } else if (diagnostic.severity === 'warning') { + this._warningCount++; + if (this._warningDiagnostics.length < this._maxDetailedDiagnostics) { + this._warningDiagnostics.push({ + code: diagnostic.code, + category: diagnostic.category, + severity: 'warning', + remediation: diagnostic.remediation + }); + } else { + this._warningDiagnosticsTruncated = true; + } + } + } + + 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: [...this._errorCodes].sort(), + diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, + diagnostics: detailedSource.slice(0, this._maxDetailedDiagnostics), + errorCount: this._errorCount, + warningCount: this._warningCount, + operationCounts: { ...this._operationCounts }, + truncated: hasFailures ? this._errorDiagnosticsTruncated : this._warningDiagnosticsTruncated + }; + + 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; + } + } + + let serialized: string = JSON.stringify(record); + if (Buffer.byteLength(serialized, 'utf8') > this._maxBytes) { + record.scope = { failedProjects: [] }; + record.errorCodes = []; + record.diagnosticCategoryCounts = {}; + record.diagnostics = []; + record.operationCounts = {}; + delete record.log; + record.truncated = true; + serialized = JSON.stringify(record); + } + if (Buffer.byteLength(serialized, 'utf8') > this._maxBytes) { + throw new Error(`The minimal AI final record exceeds maxBytes=${this._maxBytes}`); + } + this._write(`${serialized}\n`); + } +} diff --git a/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts new file mode 100644 index 0000000000..708322f381 --- /dev/null +++ b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts @@ -0,0 +1,334 @@ +// 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, + resolveColorEnabled, + 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; + + /** + * Environment variables used to resolve `NO_COLOR` and `FORCE_COLOR`. + * Defaults to `process.env`. + */ + readonly env?: Record; + + /** + * 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 _projectByOperation: Map; + 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 ?? resolveColorEnabled(options.env ?? process.env, 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._projectByOperation = new Map(); + 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': { + const payload: { operationId: string; projectName?: string } = event.payload as { + operationId: string; + projectName?: string; + }; + this._totalOperations++; + this._projectByOperation.set( + payload.operationId, + payload.projectName ?? event.scope?.projectName ?? payload.operationId + ); + 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 ?? + this._projectByOperation.get(payload.operationId) ?? + 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?.succeeded ?? false; + 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/FileReporter.ts b/libraries/reporter/src/reporters/FileReporter.ts new file mode 100644 index 0000000000..017d85e9a4 --- /dev/null +++ b/libraries/reporter/src/reporters/FileReporter.ts @@ -0,0 +1,377 @@ +// 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'; +import { redactReporterEvent } from './ReporterRedaction'; + +/** + * 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 OWNER_ONLY_DIRECTORY_MODE: number = 0o700; +const MS_PER_DAY: number = 24 * 60 * 60 * 1000; + +function getUserTempDirectoryName(): string { + const identity: string = + typeof process.getuid === 'function' ? String(process.getuid()) : os.userInfo().username; + return `${RUSH_LOGS_DIR_NAME}-${identity.replace(/[^a-zA-Z0-9_.-]/g, '_')}`; +} + +/** + * 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 streams events 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 _fileDescriptor: number | undefined; + private _targetResolved: boolean; + private _available: boolean; + private _targetPath: string | undefined; + private _latestCopyPath: 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._fileDescriptor = undefined; + this._targetResolved = false; + this._available = false; + this._targetPath = undefined; + this._latestCopyPath = undefined; + + const timestamp: string = new Date(this._nowMs()).toISOString().replace(/[:.]/g, '-'); + this._fileName = `${timestamp}-${this._pid}-${this._actionName}.log`; + } + + public async initializeAsync(): Promise { + await this._ensureTargetAsync(); + this._writeBufferedLines(); + } + + public report(event: IReporterEventEnvelope): void { + const line: string = this._formatLine(event); + if (this._fileDescriptor === undefined) { + if (!this._targetResolved) { + this._lines.push(line); + } + return; + } + this._writeLine(line); + } + + public async flushAsync(): Promise { + await this._ensureTargetAsync(); + this._writeBufferedLines(); + if (this._fileDescriptor !== undefined) { + try { + fs.fsyncSync(this._fileDescriptor); + } catch (error) { + this._markUnavailable(error as Error); + } + } + await this._refreshLatestCopyAsync(); + } + + public async closeAsync(): Promise { + await this.flushAsync(); + if (this._fileDescriptor !== undefined) { + try { + fs.closeSync(this._fileDescriptor); + } catch (error) { + this._available = false; + this._emergencyWarn( + `[reporter] Unable to close the full-detail log; the artifact is unavailable: ${(error as Error).message}` + ); + } finally { + this._fileDescriptor = undefined; + } + } + await this._refreshLatestCopyAsync(); + } + + /** + * 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 { + return `${JSON.stringify(redactReporterEvent(event))}\n`; + } + + private async _ensureTargetAsync(): Promise { + if (!this._targetResolved) { + this._targetResolved = true; + await this._resolveTargetAsync(); + } + } + + private _writeBufferedLines(): void { + if (this._fileDescriptor === undefined) { + this._lines.length = 0; + return; + } + const newLines: string[] = this._lines.splice(0); + for (const line of newLines) { + if (!this._writeLine(line)) { + break; + } + } + } + + private _writeLine(line: string): boolean { + if (this._fileDescriptor === undefined) { + return false; + } + try { + fs.writeSync(this._fileDescriptor, line, null, 'utf8'); + return true; + } catch (error) { + this._markUnavailable(error as Error); + return false; + } + } + + private async _refreshLatestCopyAsync(): Promise { + if (this._latestCopyPath === undefined || this._targetPath === undefined || !this._available) { + return; + } + try { + await fs.promises.copyFile(this._targetPath, this._latestCopyPath); + } catch { + /* latest.log is best-effort. */ + } + } + + private _markUnavailable(error: Error): void { + if (!this._available) { + return; + } + this._available = false; + this._lines.length = 0; + if (this._fileDescriptor !== undefined) { + try { + fs.closeSync(this._fileDescriptor); + } catch { + /* The original write failure is more useful. */ + } + this._fileDescriptor = undefined; + } + this._emergencyWarn( + `[reporter] Unable to write the full-detail log; the artifact is unavailable: ${error.message}` + ); + } + + private async _resolveTargetAsync(): Promise { + const candidateDirs: Array<{ path: string; ownerOnly: boolean }> = []; + if (this._commonTempFolder !== undefined) { + candidateDirs.push({ path: path.join(this._commonTempFolder, RUSH_LOGS_DIR_NAME), ownerOnly: false }); + } + candidateDirs.push({ + path: path.join(this._osTempFolder, getUserTempDirectoryName()), + ownerOnly: true + }); + + let lastError: Error | undefined; + for (const candidate of candidateDirs) { + const dir: string = candidate.path; + try { + await fs.promises.mkdir(dir, { + recursive: true, + mode: candidate.ownerOnly ? OWNER_ONLY_DIRECTORY_MODE : undefined + }); + if (candidate.ownerOnly) { + await fs.promises.chmod(dir, OWNER_ONLY_DIRECTORY_MODE); + } + 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); + const fileDescriptor: number = fs.openSync(filePath, 'a'); + this._fileDescriptor = fileDescriptor; + 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._lines.length = 0; + 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); + this._latestCopyPath = undefined; + } catch { + this._latestCopyPath = latestPath; + } + } + + 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/reporters/InteractiveRendering.ts b/libraries/reporter/src/reporters/InteractiveRendering.ts new file mode 100644 index 0000000000..f9374bc074 --- /dev/null +++ b/libraries/reporter/src/reporters/InteractiveRendering.ts @@ -0,0 +1,309 @@ +// 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) + }; +} + +const COMBINING_MARK_REGEXP: RegExp = /\p{Mark}/u; +const EXTENDED_PICTOGRAPHIC_REGEXP: RegExp = /\p{Extended_Pictographic}/u; + +function splitGraphemes(text: string): string[] { + const graphemes: string[] = []; + let current: string = ''; + let regionalIndicatorCount: number = 0; + for (const symbol of text) { + const codePoint: number = symbol.codePointAt(0)!; + const isRegionalIndicator: boolean = codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff; + const extendsCurrent: boolean = + current.length > 0 && + (COMBINING_MARK_REGEXP.test(symbol) || + codePoint === 0xfe0f || + (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) || + current.endsWith('\u200d') || + symbol === '\u200d' || + (isRegionalIndicator && regionalIndicatorCount === 1)); + if (!extendsCurrent && current.length > 0) { + graphemes.push(current); + current = ''; + regionalIndicatorCount = 0; + } + current += symbol; + regionalIndicatorCount = isRegionalIndicator ? regionalIndicatorCount + 1 : 0; + } + if (current.length > 0) { + graphemes.push(current); + } + return graphemes; +} + +function isFullWidthCodePoint(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) + ); +} + +function getGraphemeWidth(grapheme: string): number { + if (EXTENDED_PICTOGRAPHIC_REGEXP.test(grapheme)) { + return 2; + } + let width: number = 0; + for (const symbol of grapheme) { + if (COMBINING_MARK_REGEXP.test(symbol) || symbol === '\u200d' || symbol === '\ufe0f') { + continue; + } + width += isFullWidthCodePoint(symbol.codePointAt(0)!) ? 2 : 1; + } + return width; +} + +function getDisplayWidth(text: string): number { + let width: number = 0; + for (const grapheme of splitGraphemes(text)) { + width += getGraphemeWidth(grapheme); + } + return width; +} + +/** + * 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 (getDisplayWidth(text) <= width) { + return text; + } + if (width === 1) { + return 'โ€ฆ'; + } + let result: string = ''; + let usedWidth: number = 0; + for (const grapheme of splitGraphemes(text)) { + const segmentWidth: number = getGraphemeWidth(grapheme); + if (usedWidth + segmentWidth + 1 > width) { + break; + } + result += grapheme; + usedWidth += segmentWidth; + } + return `${result}โ€ฆ`; +} + +/** + * 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 (getDisplayWidth(tentative) + getDisplayWidth(suffix) > 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/reporters/JsonReporter.ts b/libraries/reporter/src/reporters/JsonReporter.ts new file mode 100644 index 0000000000..55d08ffdfb --- /dev/null +++ b/libraries/reporter/src/reporters/JsonReporter.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 type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporter } from '../manager/IReporter'; +import { encodeNdjsonRecord, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; +import { redactReporterEvent } from './ReporterRedaction'; + +/** + * 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(redactReporterEvent(event), { maxRecordBytes: this._maxRecordBytes }) + ); + } catch (error) { + if (error instanceof NdjsonRecordTooLargeError) { + this._write( + encodeNdjsonRecord( + { + ...event, + privacy: 'public', + type: 'extension', + payload: { + name: 'rush.reporter.record-too-large', + payload: { originalType: event.type } + } + }, + { maxRecordBytes: this._maxRecordBytes } + ) + ); + return; + } + throw error; + } + } + + public async flushAsync(): Promise { + /* NDJSON is written eagerly. */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} diff --git a/libraries/reporter/src/reporters/LegacyReporter.ts b/libraries/reporter/src/reporters/LegacyReporter.ts new file mode 100644 index 0000000000..743b33cf38 --- /dev/null +++ b/libraries/reporter/src/reporters/LegacyReporter.ts @@ -0,0 +1,253 @@ +// 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 _outputBuffers: Map; + private readonly _recordsByStatus: Map; + + 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._outputBuffers = new Map(); + this._recordsByStatus = 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._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._outputBuffers.set(payload.operationId, []); + this._total++; + break; + } + case 'operationStatusChanged': { + this._onStatusChanged(event); + break; + } + case 'externalOutput': { + const text: string = (event.payload as { text?: string }).text ?? ''; + const operationId: string | undefined = event.scope?.operationId; + const buffer: string[] | undefined = + operationId === undefined ? undefined : this._outputBuffers.get(operationId); + if (buffer) { + buffer.push(text); + } else { + this._write(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 (TERMINAL_STATUSES.has(payload.status)) { + this._ordinal++; + this._write(`\n${this._header(title, this._ordinal, this._total)}\n`); + const output: string = this._outputBuffers.get(payload.operationId)?.join('') ?? ''; + this._write(output); + if (output.length > 0 && !output.endsWith('\n')) { + this._write('\n'); + } + this._outputBuffers.delete(payload.operationId); + + const record: ILegacyOperationRecord = { + title, + durationMs: payload.durationMs ?? 0, + status: payload.status + }; + const records: ILegacyOperationRecord[] = this._recordsByStatus.get(payload.status) ?? []; + records.push(record); + this._recordsByStatus.set(payload.status, records); + } + } + + private _onResult(payload: { succeeded: boolean }): void { + const commandName: string = this._commandName ?? 'rush'; + if (payload.succeeded) { + const count: number = + (this._recordsByStatus.get('success')?.length ?? 0) + + (this._recordsByStatus.get('successWithWarnings')?.length ?? 0); + this._write(`\n\n${this._summaryHeader(`SUCCESS: ${count} operations`)}\n\n`); + } else { + const count: number = this._recordsByStatus.get('failure')?.length ?? 0; + this._write(`\n\n${this._summaryHeader(`FAILURE: ${count} operation`)}\n\n`); + } + this._writeStatusGroup('skipped', 'These operations were already up to date:'); + this._writeStatusGroup('noOp', 'These operations did not define any work:'); + this._writeStatusGroup('fromCache', 'These operations were restored from the build cache:'); + this._writeStatusGroup('success', 'These operations completed successfully:'); + this._writeStatusGroup('successWithWarnings', 'These operations succeeded with warnings:'); + this._writeStatusGroup('blocked', 'These operations were blocked by dependencies that failed:'); + this._writeStatusGroup('failure', 'The following projects failed to build:'); + + const suffix: string = payload.succeeded ? '' : ' ==> ERROR: Project(s) failed to build'; + this._write(`rush ${commandName} (${this._seconds(this._totalDurationMs)} seconds)${suffix}\n`); + } + + private _writeStatusGroup(status: string, heading: string): void { + const records: readonly ILegacyOperationRecord[] | undefined = this._recordsByStatus.get(status); + if (!records || records.length === 0) { + return; + } + this._write(`${heading}\n`); + for (const record of records) { + this._write(` ${record.title} ${this._seconds(record.durationMs)} seconds\n`); + } + this._write('\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); + } + +} diff --git a/libraries/reporter/src/reporters/PlaintextReporter.ts b/libraries/reporter/src/reporters/PlaintextReporter.ts new file mode 100644 index 0000000000..0508f15bcc --- /dev/null +++ b/libraries/reporter/src/reporters/PlaintextReporter.ts @@ -0,0 +1,278 @@ +// 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 _atLineStart: boolean; + 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._atLineStart = true; + 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) { + this._writeRaw(record.buffer.join('')); + record.buffer.length = 0; + } + this._writeLine(this._formatStatus(projectName, payload.status)); + } else { + this._writeLine(this._formatStatus(projectName, payload.status)); + } + this._operations.delete(payload.operationId); + } + + 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 { + if (!this._atLineStart) { + this._write('\n'); + } + this._write(`${text}\n`); + this._atLineStart = true; + this._lastOutputMs = this._nowMs(); + } + + private _writeRaw(text: string): void { + this._write(text); + if (text.length > 0) { + this._atLineStart = text.endsWith('\n'); + } + this._lastOutputMs = this._nowMs(); + } +} diff --git a/libraries/reporter/src/reporters/ReporterRedaction.ts b/libraries/reporter/src/reporters/ReporterRedaction.ts new file mode 100644 index 0000000000..5532c8006b --- /dev/null +++ b/libraries/reporter/src/reporters/ReporterRedaction.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; + +interface IClassifiedValue { + readonly value: unknown; + readonly privacy: string; +} + +export function redactReporterEvent( + event: IReporterEventEnvelope +): IReporterEventEnvelope { + let payload: unknown = event.payload; + if (event.privacy === 'secret') { + payload = '[secret]'; + } else if (event.type === 'diagnosticEmitted') { + const diagnostic: { readonly parameters?: Readonly> } = + event.payload as { + readonly parameters?: Readonly>; + }; + if (diagnostic.parameters) { + const parameters: Record = {}; + for (const [name, classified] of Object.entries(diagnostic.parameters)) { + parameters[name] = + classified.privacy === 'secret' ? { value: '[secret]', privacy: 'secret' } : classified; + } + payload = { ...diagnostic, parameters }; + } + } + return { ...event, payload }; +} 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..259c935a1e --- /dev/null +++ b/libraries/reporter/src/scheduler/OperationStreamEmitter.ts @@ -0,0 +1,183 @@ +// 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; + const maxChunkBytes: number = + options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; + if ( + !Number.isInteger(maxChunkBytes) || + maxChunkBytes < 4 || + maxChunkBytes > REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + ) { + throw new RangeError( + `maxChunkBytes must be an integer between 4 and ${REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes}` + ); + } + this._maxChunkBytes = maxChunkBytes; + } + + /** + * 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' + ); + } + + /** + * Emits an operation status transition. + */ + public changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string { + return this._emit( + 'operationStatusChanged', + { operationId, status, durationMs }, + { operationId }, + 'public' + ); + } + + /** + * 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 = offset; + let byteLength: number = 0; + while (end < text.length) { + const codePoint: number = text.codePointAt(end)!; + const codeUnitCount: number = codePoint > 0xffff ? 2 : 1; + const codePointByteLength: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (end > offset && byteLength + codePointByteLength > this._maxChunkBytes) { + break; + } + byteLength += codePointByteLength; + end += codeUnitCount; + if (byteLength >= this._maxChunkBytes) { + break; + } + } + const chunk: string = text.slice(offset, end); + eventIds.push( + this._emit('externalOutput', { stream, text: chunk }, { operationId }, 'local-sensitive') + ); + offset = end; + } + 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' + ); + } + + private _emit( + type: 'operationRegistered' | 'operationStatusChanged' | 'externalOutput' | 'commandResult', + payload: unknown, + scopeOverride: IReporterEventScope, + privacy: 'public' | 'local-sensitive' | 'secret' + ): string { + const scope: IReporterEventScope = { ...this._scope, ...scopeOverride }; + return this._sink.emit({ + protocolVersion: this._protocolVersion, + sessionId: this._sessionId, + source: this._source, + scope, + privacy, + type, + payload + }); + } +} diff --git a/libraries/reporter/src/session/PluginApi.ts b/libraries/reporter/src/session/PluginApi.ts new file mode 100644 index 0000000000..be7b40e4d5 --- /dev/null +++ b/libraries/reporter/src/session/PluginApi.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as semver from 'semver'; + +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; + +/** + * The reporting-relevant fields of a Rush plugin manifest. + * + * @beta + */ +export interface IRushPluginManifest { + /** + * The plugin's name. + */ + readonly pluginName: string; + + /** + * The semver range of Rush versions supported by the plugin. + */ + readonly rushVersionRange: string; +} + +/** + * Returns `true` if the running Rush version satisfies a plugin's declared range. + * + * @param rushVersionRange - the semver range declared by the plugin manifest + * @param rushVersion - the running Rush version + * + * @beta + */ +export function isRushVersionSupported(rushVersionRange: string, rushVersion: string): boolean { + if (rushVersionRange.trim().length === 0) { + return false; + } + const validRushVersion: string | null = semver.valid(rushVersion); + const validRushVersionRange: string | null = semver.validRange(rushVersionRange); + return ( + validRushVersion !== null && + validRushVersionRange !== null && + semver.satisfies(validRushVersion, validRushVersionRange, { includePrerelease: true }) + ); +} + +/** + * Creates the structured migration diagnostic for an incompatible plugin. + * + * @remarks + * An incompatible plugin fails before its `apply()` runs. This diagnostic is + * emitted at that boundary. + * + * @param manifest - the incompatible plugin's manifest + * @param rushVersion - the running Rush version + * + * @beta + */ +export function createPluginApiIncompatibleDiagnostic( + manifest: IRushPluginManifest, + rushVersion: string +): IRushDiagnostic { + return createRushDiagnostic('RUSH_PLUGIN_API_INCOMPATIBLE', { + parameters: { + pluginName: { value: manifest.pluginName, privacy: 'public' }, + rushVersionRange: { value: manifest.rushVersionRange, privacy: 'public' }, + rushVersion: { value: rushVersion, privacy: 'public' } + } + }); +} diff --git a/libraries/reporter/src/session/RushSessionReporting.ts b/libraries/reporter/src/session/RushSessionReporting.ts new file mode 100644 index 0000000000..525d6b49be --- /dev/null +++ b/libraries/reporter/src/session/RushSessionReporting.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 type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import type { IScopedReporter } from '../producers/IScopedReporter'; +import { createScopedReporter } from './ScopedReporterFactory'; +import { createScopedLogger, type IScopedLogger } from './ScopedLogger'; + +/** + * Options for constructing a {@link RushSessionReporting}. + * + * @beta + */ +export interface IRushSessionReportingOptions { + /** + * 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 protocol version stamped onto emitted events. + */ + readonly protocolVersion?: IReporterProtocolVersion; +} + +/** + * The reporting context handed to an action. + * + * @remarks + * Actions receive both the raw sink and a scoped reporter through their + * execution context. Neither exposes reporter instances, destinations, modes, or + * thresholds. + * + * @beta + */ +export interface IReporterExecutionContext { + /** + * The sink the action may emit into directly. + */ + readonly sink: IReporterEventSink; + + /** + * A scoped reporter bound to the action's scope. + */ + readonly reporter: IScopedReporter; +} + +/** + * The reporting surface exposed by `RushSession` to actions and plugins. + * + * @remarks + * `RushSession` composes this to create scoped reporters and loggers. Plugins + * receive scoped reporters and loggers only, so they cannot inspect active + * modes, destinations, or thresholds. Actions additionally receive the sink + * through an execution context. + * + * @beta + */ +export class RushSessionReporting { + private readonly _sink: IReporterEventSink; + private readonly _sessionId: string; + private readonly _source: IReporterEventSource; + private readonly _protocolVersion: IReporterProtocolVersion | undefined; + + public constructor(options: IRushSessionReportingOptions) { + this._sink = options.sink; + this._sessionId = options.sessionId; + this._source = options.source; + this._protocolVersion = options.protocolVersion; + } + + /** + * Creates a scoped reporter bound to the given scope. + */ + public createScopedReporter(scope?: IReporterEventScope): IScopedReporter { + return createScopedReporter({ + sink: this._sink, + sessionId: this._sessionId, + source: this._source, + scope, + protocolVersion: this._protocolVersion + }); + } + + /** + * Creates a scoped logger bound to the given scope. + */ + public createScopedLogger(scope?: IReporterEventScope): IScopedLogger { + return createScopedLogger(this.createScopedReporter(scope)); + } + + /** + * Returns the raw sink handed to actions through the execution context. + */ + public getSink(): IReporterEventSink { + return this._sink; + } + + /** + * Creates the execution context an action receives. + */ + public createExecutionContext(scope?: IReporterEventScope): IReporterExecutionContext { + return { + sink: this._sink, + reporter: this.createScopedReporter(scope) + }; + } +} diff --git a/libraries/reporter/src/session/ScopedLogger.ts b/libraries/reporter/src/session/ScopedLogger.ts new file mode 100644 index 0000000000..12457d2492 --- /dev/null +++ b/libraries/reporter/src/session/ScopedLogger.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IScopedReporter } from '../producers/IScopedReporter'; + +/** + * A minimal, presentation-free logger backed by a scoped reporter. + * + * @remarks + * This replaces the terminal-coupled `ILogger`. It exposes no `terminal` or + * terminal-provider handle; every method emits a structured message through the + * scoped reporter and returns the assigned event id. + * + * @beta + */ +export interface IScopedLogger { + /** + * Emits an informational message. + */ + writeLine(text: string): string; + + /** + * Emits a debug message. + */ + writeDebugLine(text: string): string; + + /** + * Emits a warning message. + */ + writeWarningLine(text: string): string; + + /** + * Emits an error message. + */ + writeErrorLine(text: string): string; +} + +/** + * Creates a scoped logger that forwards to a scoped reporter. + * + * @param reporter - the scoped reporter that receives the messages + * + * @beta + */ +export function createScopedLogger(reporter: IScopedReporter): IScopedLogger { + return { + writeLine(text: string): string { + return reporter.emitMessage({ severity: 'info', text }); + }, + writeDebugLine(text: string): string { + return reporter.emitMessage({ severity: 'debug', text }); + }, + writeWarningLine(text: string): string { + return reporter.emitMessage({ severity: 'warning', text }); + }, + writeErrorLine(text: string): string { + return reporter.emitMessage({ severity: 'error', text }); + } + }; +} diff --git a/libraries/reporter/src/session/ScopedReporterFactory.ts b/libraries/reporter/src/session/ScopedReporterFactory.ts new file mode 100644 index 0000000000..ebf3161ee7 --- /dev/null +++ b/libraries/reporter/src/session/ScopedReporterFactory.ts @@ -0,0 +1,112 @@ +// 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 { IScopedReporter, IScopedMessageOptions } from '../producers/IScopedReporter'; +import { isReporterExtensionEventName } from '../producers/ReporterExtensionEventName'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { computeEnvelopePrivacyFloor } from '../diagnostics/DiagnosticPrivacy'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; + +/** + * Options for {@link createScopedReporter}. + * + * @beta + */ +export interface ICreateScopedReporterOptions { + /** + * 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 scope bound to every emitted event. + */ + readonly scope?: IReporterEventScope; + + /** + * The protocol version stamped onto emitted events. Defaults to + * {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; +} + +/** + * Creates a scoped reporter bound to a scope and backed by a sink. + * + * @remarks + * The returned reporter exposes only emit methods. It never exposes reporter + * instances, destinations, active modes, or thresholds, so producers and plugins + * cannot inspect them. Human messages are carried on the `activityChanged` + * channel; warning and error messages are marked required so they are never + * coalesced. + * + * @param options - the sink, identity, and scope to bind + * + * @beta + */ +export function createScopedReporter(options: ICreateScopedReporterOptions): IScopedReporter { + const protocolVersion: IReporterProtocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + const sink: IReporterEventSink = options.sink; + const sessionId: string = options.sessionId; + const source: IReporterEventSource = options.source; + const scope: IReporterEventScope | undefined = options.scope; + + return { + emitMessage(messageOptions: IScopedMessageOptions): string { + return sink.emit({ + protocolVersion, + sessionId, + source, + scope, + // Message text fails safe at local-sensitive by default. + privacy: messageOptions.privacy ?? 'local-sensitive', + type: 'messageEmitted', + payload: { severity: messageOptions.severity, text: messageOptions.text } + }); + }, + + emitDiagnostic(diagnostic: IRushDiagnostic): string { + const classifications: ReadonlyArray<'public' | 'local-sensitive' | 'secret'> = diagnostic.parameters + ? Object.values(diagnostic.parameters).map((value) => value.privacy) + : []; + return sink.emit({ + protocolVersion, + sessionId, + source, + scope, + privacy: computeEnvelopePrivacyFloor(classifications), + type: 'diagnosticEmitted', + payload: diagnostic + }); + }, + + emitExtension(name: string, payload: TPayload): string { + if (!isReporterExtensionEventName(name)) { + throw new Error(`Invalid extension event name: ${JSON.stringify(name)}`); + } + return sink.emit({ + protocolVersion, + sessionId, + source, + scope, + // Free-form extension payloads have no field-level classifications. + privacy: 'local-sensitive', + type: 'extension', + payload: { name, payload } + }); + } + }; +} diff --git a/libraries/reporter/src/telemetry/BeforeLogAdapter.ts b/libraries/reporter/src/telemetry/BeforeLogAdapter.ts new file mode 100644 index 0000000000..fe5f4876f5 --- /dev/null +++ b/libraries/reporter/src/telemetry/BeforeLogAdapter.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ITelemetryAggregate } from './TelemetryAggregate'; + +/** + * The legacy `beforeLog` telemetry hook signature. + * + * @remarks + * Existing telemetry consumers register a `beforeLog` hook that runs with the + * telemetry record before it is written. The hook receives a plain object. + * + * @beta + */ +export type LegacyBeforeLogHook = (telemetry: Record) => void; + +/** + * Adapts the allowlisted telemetry aggregate to the legacy `beforeLog` hook. + * + * @remarks + * During migration the existing `beforeLog` hook is preserved: the adapter runs + * each legacy hook with an allowlisted summary projection matching Rush's + * legacy `ITelemetryData` field names and units. Detailed operation records are + * intentionally unavailable at this privacy boundary. No hook mutates the + * allowlisted aggregate, and the returned record preserves hook augmentations + * for the legacy telemetry writer. + * + * @param hooks - the legacy hooks to preserve + * + * @beta + */ +export function createBeforeLogAdapter( + hooks: readonly LegacyBeforeLogHook[] +): (aggregate: ITelemetryAggregate) => Record { + return (aggregate: ITelemetryAggregate): Record => { + if (aggregate.commandName === undefined || aggregate.result === undefined) { + throw new Error('A completed telemetry aggregate is required by the legacy beforeLog adapter.'); + } + + const counts: { readonly [status: string]: number } = aggregate.operationStatusCounts; + const record: Record = { + name: aggregate.commandName, + durationInSeconds: (aggregate.durationMs ?? 0) / 1000, + result: aggregate.result === 'succeeded' ? 'Succeeded' : 'Failed', + operationResults: {}, + extraData: { + countAll: Object.values(counts).reduce((total: number, count: number) => total + count, 0), + countSuccess: counts.success ?? 0, + countSuccessWithWarnings: counts.successWithWarnings ?? 0, + countFailure: counts.failure ?? 0, + countBlocked: counts.blocked ?? 0, + countFromCache: counts.fromCache ?? 0, + countSkipped: counts.skipped ?? 0, + countNoOp: counts.noOp ?? 0, + countAborted: counts.aborted ?? 0 + } + }; + for (const hook of hooks) { + hook(record); + } + return record; + }; +} diff --git a/libraries/reporter/src/telemetry/TelemetryAggregate.ts b/libraries/reporter/src/telemetry/TelemetryAggregate.ts new file mode 100644 index 0000000000..d54fa16977 --- /dev/null +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; + +/** + * The command result recorded by telemetry. + * + * @beta + */ +export type TelemetryResult = 'succeeded' | 'failed'; + +/** + * The allowlisted telemetry aggregate produced at command completion. + * + * @remarks + * Only these fields ever leave the machine. Messages, templates, paths, raw + * stdout/stderr, command arguments, remediation parameters, stack traces, and + * any local-sensitive or secret values are excluded by construction. + * + * @beta + */ +export interface ITelemetryAggregate { + /** + * The command name. + */ + readonly commandName?: string; + + /** + * Whether the command succeeded or failed. + */ + readonly result?: TelemetryResult; + + /** + * The process exit code. + */ + readonly exitCode?: number; + + /** + * The command or session duration in milliseconds. + */ + readonly durationMs?: number; + + /** + * The number of operations that reached each status. + */ + readonly operationStatusCounts: { readonly [status: string]: number }; + + /** + * The distinct diagnostic codes emitted, sorted. + */ + readonly diagnosticCodes: readonly string[]; + + /** + * The number of diagnostics emitted in each category. + */ + readonly diagnosticCategoryCounts: { readonly [category: string]: number }; + + /** + * The selected reporter mode. + */ + readonly reporterMode?: string; + + /** + * The reporter protocol version. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The distinct `packageName@packageVersion` producers observed, sorted. + */ + readonly producerVersions: readonly string[]; +} + +/** + * The complete set of allowlisted telemetry aggregate keys. + * + * @remarks + * Used to assert that no non-allowlisted field ever appears in the aggregate. + * + * @beta + */ +export const TELEMETRY_AGGREGATE_KEYS: readonly string[] = [ + 'commandName', + 'result', + 'exitCode', + 'durationMs', + 'operationStatusCounts', + 'diagnosticCodes', + 'diagnosticCategoryCounts', + 'reporterMode', + 'protocolVersion', + 'producerVersions' +]; diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts new file mode 100644 index 0000000000..48b45f5098 --- /dev/null +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -0,0 +1,217 @@ +// 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 { IOperationStatusChangedPayload } from '../lifecycle/LifecycleEvents'; +import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate'; + +/** + * Consumes canonical events and produces the allowlisted telemetry aggregate. + * + * @remarks + * The subscriber runs before reporter filtering, so it observes every event. It + * extracts only allowlisted values: from a diagnostic it keeps the code and + * category but never the parameters, remediation, or templates; it ignores + * messages, raw external output, and command arguments entirely. + * + * @beta + */ +export class TelemetrySubscriber { + private _commandName: string | undefined; + private _result: TelemetryResult | undefined; + private _exitCode: number | undefined; + private _durationMs: number | undefined; + private _reporterMode: string | undefined; + private _protocolVersion: IReporterProtocolVersion | undefined; + private readonly _operationStatuses: Map; + private readonly _diagnosticCategoryCounts: { [category: string]: number }; + private readonly _diagnosticCodes: Set; + private readonly _producerVersions: Set; + + public constructor() { + this._operationStatuses = new Map(); + this._diagnosticCategoryCounts = {}; + this._diagnosticCodes = new Set(); + this._producerVersions = new Set(); + } + + /** + * Records the selected reporter mode. + */ + public setReporterMode(reporterMode: string): void { + this._reporterMode = reporterMode; + } + + /** + * Ingests one event, extracting only allowlisted values. + */ + public ingest(event: IReporterEventEnvelope): void { + this._protocolVersion = event.protocolVersion; + this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`); + + switch (event.type) { + case 'commandStarted': { + if (event.parentSessionId !== undefined) { + break; + } + // Deliberately ignores argv. + this._commandName = (event.payload as { commandName: string }).commandName; + break; + } + case 'commandResult': { + if (event.parentSessionId !== undefined) { + break; + } + const payload: { commandName: string; succeeded: boolean; exitCode: number } = event.payload as { + commandName: string; + succeeded: boolean; + exitCode: number; + }; + this._commandName = payload.commandName; + this._result = payload.succeeded ? 'succeeded' : 'failed'; + this._exitCode = payload.exitCode; + break; + } + case 'commandCompleted': { + if (event.parentSessionId !== undefined) { + break; + } + const payload: { commandName: string; exitCode: number; durationMs?: number } = event.payload as { + commandName: string; + exitCode: number; + durationMs?: number; + }; + this._commandName = payload.commandName; + this._exitCode = payload.exitCode; + this._result = payload.exitCode === 0 ? 'succeeded' : 'failed'; + if (payload.durationMs !== undefined) { + this._durationMs = payload.durationMs; + } + break; + } + case 'sessionCompleted': { + if (event.parentSessionId !== undefined) { + break; + } + const payload: { exitCode: number; durationMs?: number } = event.payload as { + exitCode: number; + durationMs?: number; + }; + this._exitCode = payload.exitCode; + this._result = payload.exitCode === 0 ? 'succeeded' : 'failed'; + if (payload.durationMs !== undefined) { + this._durationMs = payload.durationMs; + } + break; + } + case 'operationStatusChanged': { + if (event.parentSessionId !== undefined) { + break; + } + const payload: IOperationStatusChangedPayload = event.payload as IOperationStatusChangedPayload; + this._operationStatuses.set(payload.operationId, payload.status); + break; + } + case 'diagnosticEmitted': { + // Keeps only the code and category, never parameters, remediation, or templates. + const payload: { code?: string; category?: string } = event.payload as { + code?: string; + category?: string; + }; + if (payload.code !== undefined) { + this._diagnosticCodes.add(payload.code); + } + if (payload.category !== undefined) { + this._diagnosticCategoryCounts[payload.category] = + (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; + } + break; + } + default: { + // Messages, raw external output, artifacts, and extension events are not + // telemetry. + break; + } + } + } + + /** + * Builds the allowlisted aggregate. + */ + public buildAggregate(): ITelemetryAggregate { + const operationStatusCounts: { [status: string]: number } = {}; + for (const status of this._operationStatuses.values()) { + operationStatusCounts[status] = (operationStatusCounts[status] ?? 0) + 1; + } + + const aggregate: { + commandName?: string; + result?: TelemetryResult; + exitCode?: number; + durationMs?: number; + operationStatusCounts: { [status: string]: number }; + diagnosticCodes: string[]; + diagnosticCategoryCounts: { [category: string]: number }; + reporterMode?: string; + protocolVersion?: IReporterProtocolVersion; + producerVersions: string[]; + } = { + operationStatusCounts, + diagnosticCodes: [...this._diagnosticCodes].sort(), + diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, + producerVersions: [...this._producerVersions].sort() + }; + + if (this._commandName !== undefined) { + aggregate.commandName = this._commandName; + } + if (this._result !== undefined) { + aggregate.result = this._result; + } + if (this._exitCode !== undefined) { + aggregate.exitCode = this._exitCode; + } + if (this._durationMs !== undefined) { + aggregate.durationMs = this._durationMs; + } + if (this._reporterMode !== undefined) { + aggregate.reporterMode = this._reporterMode; + } + if (this._protocolVersion !== undefined) { + aggregate.protocolVersion = this._protocolVersion; + } + + return aggregate; + } +} + +/** + * Wraps a telemetry subscriber as a reporter so it can be registered with the + * manager and observe every event before reporter filtering. + * + * @remarks + * The returned reporter owns no destination and renders nothing. + * + * @param subscriber - the telemetry subscriber to feed + * + * @beta + */ +export function createTelemetryReporter(subscriber: TelemetrySubscriber): IReporter { + return { + name: 'telemetry', + async initializeAsync(): Promise { + /* no-op */ + }, + report(event: IReporterEventEnvelope): void { + subscriber.ingest(event); + }, + async flushAsync(): Promise { + /* no-op */ + }, + async closeAsync(): Promise { + /* no-op */ + } + }; +} diff --git a/libraries/reporter/src/test/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/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts new file mode 100644 index 0000000000..ff92d80b29 --- /dev/null +++ b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts @@ -0,0 +1,249 @@ +// 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(''); + expect(truncateToWidth('a๐Ÿ˜€b', 4)).toBe('a๐Ÿ˜€b'); + expect(truncateToWidth('a๐Ÿ˜€b', 3)).toBe('aโ€ฆ'); + expect(truncateToWidth('็•Œab', 3)).toBe('็•Œโ€ฆ'); + expect(truncateToWidth('e\u0301x', 2)).toBe('e\u0301x'); + }); + + 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('honors NO_COLOR and FORCE_COLOR when color is not explicit', async () => { + const noColorTerminal: FakeTerminal = new FakeTerminal(); + const noColorReporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal: noColorTerminal, + env: { NO_COLOR: '' } + }); + noColorReporter.report(ev('commandResult', { succeeded: false, exitCode: 1 })); + await noColorReporter.closeAsync(); + expect(noColorTerminal.output).not.toContain('\u001b[31m'); + + const forceColorTerminal: FakeTerminal = new FakeTerminal(80, false); + const forceColorReporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal: forceColorTerminal, + env: { FORCE_COLOR: '1' } + }); + forceColorReporter.report(ev('commandResult', { succeeded: false, exitCode: 1 })); + await forceColorReporter.closeAsync(); + expect(forceColorTerminal.output).toContain('\u001b[31m'); + }); + + 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('uses registration metadata for status events', async () => { + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ + terminal, + color: false, + nowMs: () => 0 + }); + reporter.report(ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + await reporter.flushAsync(); + + expect(terminal.output).toContain('project-a'); + expect(terminal.output).not.toContain('executing op1'); + }); + + 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('fails closed when commandResult is missing', 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' })); + await reporter.closeAsync(); + + expect(terminal.output).toContain('โœ–'); + expect(terminal.output).toContain('build failed'); + expect(terminal.output).not.toContain('build succeeded'); + }); + + 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/libraries/reporter/src/test/ExitStatus.test.ts b/libraries/reporter/src/test/ExitStatus.test.ts new file mode 100644 index 0000000000..d8424effed --- /dev/null +++ b/libraries/reporter/src/test/ExitStatus.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + resolveExitStatus, + resolveExitStatusFromEvents, + getSignalExitCode, + separateJsonControls, + EXIT_CODE_SUCCESS, + EXIT_CODE_FAILURE, + type IJsonControls, + type IReporterEventEnvelope, + type IRushExitStatus +} from '../index'; + +function ev(type: string, payload: unknown): IReporterEventEnvelope { + return { type, payload } as unknown as IReporterEventEnvelope; +} + +describe('resolveExitStatus', () => { + it('returns success for a clean run', () => { + expect(resolveExitStatus({})).toEqual({ exitCode: EXIT_CODE_SUCCESS, outcome: 'succeeded' }); + }); + + it('treats warning-only success as success', () => { + // Warnings are not failures, so hasFailures stays false. + expect(resolveExitStatus({ hasFailures: false }).exitCode).toBe(0); + }); + + it('returns failure for failures and for logical cancellation', () => { + expect(resolveExitStatus({ hasFailures: true })).toEqual({ + exitCode: EXIT_CODE_FAILURE, + outcome: 'failed' + }); + expect(resolveExitStatus({ cancelled: true })).toEqual({ + exitCode: EXIT_CODE_FAILURE, + outcome: 'cancelled' + }); + }); + + it('returns the signal-derived status, which takes precedence over failures', () => { + const status: IRushExitStatus = resolveExitStatus({ hasFailures: true, signal: 'SIGINT' }); + expect(status.outcome).toBe('signal'); + expect(status.signal).toBe('SIGINT'); + expect(status.exitCode).toBe(130); + }); +}); + +describe('getSignalExitCode', () => { + it('uses the conventional 128 + signal number', () => { + expect(getSignalExitCode('SIGINT')).toBe(130); + expect(getSignalExitCode('SIGTERM')).toBe(143); + }); +}); + +describe('resolveExitStatusFromEvents', () => { + it('maps warning-only diagnostics with a successful result to success', () => { + const status: IRushExitStatus = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'RUSH_X', category: 'operation', severity: 'warning' }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]); + expect(status.exitCode).toBe(0); + expect(status.outcome).toBe('succeeded'); + }); + + it('fails on an error diagnostic, a failed operation, or a failed result', () => { + expect( + resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'E', category: 'internal', severity: 'error' }) + ]).exitCode + ).toBe(1); + expect( + resolveExitStatusFromEvents([ev('operationStatusChanged', { operationId: 'a', status: 'failure' })]) + .exitCode + ).toBe(1); + expect( + resolveExitStatusFromEvents([ev('commandResult', { commandName: 'b', succeeded: false, exitCode: 1 })]) + .exitCode + ).toBe(1); + expect( + resolveExitStatusFromEvents([ev('operationStatusChanged', { operationId: 'b', status: 'aborted' })]) + .exitCode + ).toBe(1); + }); + + it('fails on nonzero root command and session completion codes', () => { + expect( + resolveExitStatusFromEvents([ev('commandCompleted', { commandName: 'build', exitCode: 1 })]) + ).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(resolveExitStatusFromEvents([ev('sessionCompleted', { exitCode: 2 })])).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + expect( + resolveExitStatusFromEvents([ + { + ...ev('sessionCompleted', { exitCode: 1 }), + parentSessionId: 'root' + } + ]) + ).toEqual({ exitCode: 0, outcome: 'succeeded' }); + }); + + it('never lets the diagnostic category select the exit code', () => { + const errorInternal: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'A', category: 'internal', severity: 'error' }) + ]).exitCode; + const errorConfig: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'B', category: 'configuration', severity: 'error' }) + ]).exitCode; + expect(errorInternal).toBe(errorConfig); + + const warnInternal: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'C', category: 'internal', severity: 'warning' }) + ]).exitCode; + const warnConfig: number = resolveExitStatusFromEvents([ + ev('diagnosticEmitted', { code: 'D', category: 'network-auth', severity: 'warning' }) + ]).exitCode; + expect(warnInternal).toBe(0); + expect(warnConfig).toBe(0); + }); + + it('honors cancellation and signal state', () => { + expect(resolveExitStatusFromEvents([], { cancelled: true }).outcome).toBe('cancelled'); + expect(resolveExitStatusFromEvents([], { signal: 'SIGTERM' }).exitCode).toBe(143); + }); +}); + +describe('separateJsonControls', () => { + it('keeps command-specific --json separate from the json reporter', () => { + expect(separateJsonControls(['list', '--json'])).toEqual({ + commandJson: true, + reporterJson: false + }); + expect(separateJsonControls(['build', '--reporter=json'])).toEqual({ + commandJson: false, + reporterJson: true + }); + expect(separateJsonControls(['build', '--reporter', 'json'])).toEqual({ + commandJson: false, + reporterJson: true + }); + expect(separateJsonControls(['list', '--json', '--reporter=json'])).toEqual({ + commandJson: true, + reporterJson: true + }); + expect(separateJsonControls(['build'])).toEqual({ + commandJson: false, + reporterJson: false + }); + }); +}); diff --git a/libraries/reporter/src/test/FileReporter.test.ts b/libraries/reporter/src/test/FileReporter.test.ts new file mode 100644 index 0000000000..9d5b0b7e75 --- /dev/null +++ b/libraries/reporter/src/test/FileReporter.test.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 * 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('streams events after initialization instead of retaining the full log', async () => { + await withTempDir(async (base: string) => { + const reporter: FileReporter = new FileReporter({ commonTempFolder: base, nowMs: () => FIXED_NOW }); + await reporter.initializeAsync(); + reporter.report(ev('externalOutput', { text: 'streamed immediately' })); + + const content: string = await fs.promises.readFile(reporter.getArtifact().path!, 'utf8'); + expect(content).toContain('streamed immediately'); + await reporter.closeAsync(); + }); + }); + + 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('refreshes the latest.log copy when symlinks are unavailable', async () => { + await withTempDir(async (base: string) => { + const symlinkSpy: jest.SpiedFunction = jest + .spyOn(fs.promises, 'symlink') + .mockRejectedValueOnce(new Error('symlinks unavailable')); + try { + 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(); + + const latestPath: string = path.join(base, RUSH_LOGS_DIR_NAME, LATEST_LOG_NAME); + expect(await fs.promises.readFile(latestPath, 'utf8')).toContain('"type":"commandResult"'); + } finally { + symlinkSpy.mockRestore(); + } + }); + }); + + 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(path.dirname(artifact.path!).startsWith(path.join(osTemp, `${RUSH_LOGS_DIR_NAME}-`))).toBe(true); + if (process.platform !== 'win32') { + const directoryStats: fs.Stats = await fs.promises.stat(path.dirname(artifact.path!)); + expect(directoryStats.mode % 0o1000).toBe(0o700); + } + }); + }); + + 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/libraries/reporter/src/test/JsonAiReporter.test.ts b/libraries/reporter/src/test/JsonAiReporter.test.ts new file mode 100644 index 0000000000..f17a31e482 --- /dev/null +++ b/libraries/reporter/src/test/JsonAiReporter.test.ts @@ -0,0 +1,279 @@ +// 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: 512 + }); + 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.record-too-large'); + expect(records[0]).toMatchObject({ + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type: 'extension' + }); + expect(Buffer.byteLength(output.trim(), 'utf8')).toBeLessThanOrEqual(512); + }); + + it('redacts secret diagnostic fields from stdout', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ write: (text: string) => (output += text) }); + reporter.report( + ev('diagnosticEmitted', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + parameters: { + token: { value: 'sk-secret-value', privacy: 'secret' }, + path: { value: '/tmp/log', privacy: 'local-sensitive' } + } + }) + ); + + expect(output).not.toContain('sk-secret-value'); + expect(output).toContain('[secret]'); + expect(output).toContain('/tmp/log'); + }); +}); + +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('fails closed when commandResult is missing', async () => { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + await reporter.closeAsync(); + + const final: Record = parseLines(output).at(-1)!; + expect(final.result).toBe('failed'); + expect(final.exitCode).toBe(1); + }); + + 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: 512 }); + for (const event of events) { + reporter.report(event); + } + const finalLine: string = output.trim().split('\n').pop() ?? ''; + expect(Buffer.byteLength(finalLine, 'utf8')).toBeLessThanOrEqual(512); + expect((JSON.parse(finalLine) as IAiFinalRecord).truncated).toBe(true); + }); + + it('falls back to a minimal bounded record when fixed fields are oversized', () => { + const events: IReporterEventEnvelope[] = [ + ev('commandStarted', { commandName: 'x'.repeat(5000) }), + ev('artifactAvailable', { role: 'log', path: `/tmp/${'y'.repeat(5000)}`, complete: true }), + ev('commandResult', { succeeded: false, exitCode: 1 }) + ]; + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text), maxBytes: 512 }); + for (const event of events) { + reporter.report(event); + } + + const finalLine: string = output.trim().split('\n').pop()!; + const final: IAiFinalRecord = JSON.parse(finalLine) as IAiFinalRecord; + expect(Buffer.byteLength(finalLine, 'utf8')).toBeLessThanOrEqual(512); + expect(final.truncated).toBe(true); + expect(final.scope.commandName).toBeUndefined(); + expect(final.log).toBeUndefined(); + }); + + it('rejects an impossible final-record byte limit', () => { + expect(() => new AiReporter({ write: () => {}, maxBytes: 511 })).toThrow(/at least 512/); + }); + + 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('reports truncation only for the diagnostic bucket included in the final record', () => { + const events: IReporterEventEnvelope[] = []; + for (let i: number = 0; i < 5; i++) { + events.push(ev('diagnosticEmitted', { code: `RUSH_W_${i}`, category: 'input', severity: 'warning' })); + } + events.push(ev('commandResult', { succeeded: false, exitCode: 1 })); + + const { final } = run(events, { maxBytes: 1024 }); + expect(final.diagnostics).toEqual([]); + expect(final.truncated).toBe(false); + }); + + 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/libraries/reporter/src/test/LegacyErrorBridge.test.ts b/libraries/reporter/src/test/LegacyErrorBridge.test.ts new file mode 100644 index 0000000000..d230cb6080 --- /dev/null +++ b/libraries/reporter/src/test/LegacyErrorBridge.test.ts @@ -0,0 +1,89 @@ +// 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); + }); + + it('recognizes the node-core-library sentinel shape', () => { + class LegacyAlreadyReportedError extends Error { + public constructor() { + super('An error occurred.'); + Object.setPrototypeOf(this, LegacyAlreadyReportedError.prototype); + } + } + Object.defineProperty(LegacyAlreadyReportedError, 'name', { value: 'AlreadyReportedError' }); + + const error: Error = new LegacyAlreadyReportedError(); + expect(error.name).toBe('Error'); + expect(isAlreadyReportedSentinel(error)).toBe(true); + }); +}); + +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/libraries/reporter/src/test/LegacyReporter.test.ts b/libraries/reporter/src/test/LegacyReporter.test.ts new file mode 100644 index 0000000000..27fd14969d --- /dev/null +++ b/libraries/reporter/src/test/LegacyReporter.test.ts @@ -0,0 +1,172 @@ +// 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)'); + }); + + it('groups interleaved output by operation without adding chunk newlines', () => { + let output: string = ''; + const reporter: LegacyReporter = new LegacyReporter({ write: (text: string) => (output += text) }); + reporter.report(ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' })); + reporter.report(ev('operationRegistered', { operationId: 'op2', projectName: 'project-b' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op2', status: 'executing' })); + reporter.report(ev('externalOutput', { text: 'A' }, { operationId: 'op1' })); + reporter.report(ev('externalOutput', { text: 'B' }, { operationId: 'op2' })); + reporter.report(ev('externalOutput', { text: '1' }, { operationId: 'op1' })); + reporter.report(ev('externalOutput', { text: '2' }, { operationId: 'op2' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + reporter.report(ev('operationStatusChanged', { operationId: 'op2', status: 'success' })); + + expect(output).toContain('==[ project-a ]'); + expect(output).toContain('A1\n'); + expect(output).toContain('==[ project-b ]'); + expect(output).toContain('B2\n'); + expect(output).not.toContain('A\n1'); + expect(output.indexOf('A1')).toBeLessThan(output.indexOf('B2')); + }); + + it('preserves separate summaries for non-success terminal statuses', () => { + let output: string = ''; + const reporter: LegacyReporter = new LegacyReporter({ write: (text: string) => (output += text) }); + for (const [operationId, status] of [ + ['skipped', 'skipped'], + ['cached', 'fromCache'], + ['empty', 'noOp'], + ['blocked', 'blocked'] + ] as const) { + reporter.report(ev('operationRegistered', { operationId, projectName: operationId })); + reporter.report(ev('operationStatusChanged', { operationId, status })); + } + reporter.report(ev('commandResult', { succeeded: false })); + + expect(output).toContain('already up to date'); + expect(output).toContain('restored from the build cache'); + expect(output).toContain('did not define any work'); + expect(output).toContain('blocked by dependencies that failed'); + expect(output).not.toContain('These operations completed successfully:\n blocked'); + }); +}); + +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'); + }); + + it('selects the legacy reporter case-insensitively', () => { + const selection = resolveReporterSelection({ + argv: ['build'], + env: { RUSH_REPORTER: ' LEGACY ' }, + isTTY: true + }); + expect(selection.primaryReporter).toBe('legacy'); + }); +}); diff --git a/libraries/reporter/src/test/Lifecycle.test.ts b/libraries/reporter/src/test/Lifecycle.test.ts new file mode 100644 index 0000000000..1c64ea9d9f --- /dev/null +++ b/libraries/reporter/src/test/Lifecycle.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + LifecycleEmitter, + deriveExitCodeFromEvents, + summarizeShadowResult, + createRushDiagnostic, + ReporterManager, + isReporterEventRequired, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventSink, + type IReporterEventSource, + type IShadowResultSummary +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: Record[] = []; + + public emit(event: Record): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +function ev( + type: string, + payload: unknown, + envelope: Partial> = {} +): IReporterEventEnvelope { + return { type, payload, ...envelope } as unknown as IReporterEventEnvelope; +} + +describe('LifecycleEmitter', () => { + it('emits required lifecycle events with merged scope', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + + emitter.emitOperationRegistered({ operationId: 'op1', projectName: 'p', phaseName: '_phase:build' }); + + expect(sink.inputs[0].type).toBe('operationRegistered'); + // Lifecycle events are protected; the manager derives `required` from the type. + expect(isReporterEventRequired('operationRegistered')).toBe(true); + expect(sink.inputs[0].scope).toEqual({ + commandName: 'build', + operationId: 'op1', + projectName: 'p', + phaseName: '_phase:build' + }); + }); + + it('preserves inherited scope when operation fields are omitted', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build', projectName: 'p', phaseName: '_phase:build' } + }); + + emitter.emitOperationRegistered({ operationId: 'op1' }); + + expect(sink.inputs[0].scope).toEqual({ + commandName: 'build', + operationId: 'op1', + projectName: 'p', + phaseName: '_phase:build' + }); + }); + + it('emits diagnostics on the diagnosticEmitted channel with the privacy floor', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ sink, sessionId: 'sess', source: SOURCE }); + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { logPath: { value: '/tmp/x.log', privacy: 'local-sensitive' } } + }) + ); + expect(sink.inputs[0].type).toBe('diagnosticEmitted'); + expect(sink.inputs[0].privacy).toBe('local-sensitive'); + expect(isReporterEventRequired('diagnosticEmitted')).toBe(true); + }); + + it('writes nothing to stdout or stderr while events flow (shadow mode)', () => { + const stdoutSpy: jest.SpyInstance = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink: CapturingSink = new CapturingSink(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ sink, sessionId: 'sess', source: SOURCE }); + emitter.emitSessionStarted({ rushVersion: '5.177.2' }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED')); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + + expect(sink.inputs).toHaveLength(5); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); +}); + +describe('deriveExitCodeFromEvents', () => { + it('maps a successful command, including warning-only success, to zero', () => { + expect( + deriveExitCodeFromEvents([ + ev('operationStatusChanged', { operationId: 'op1', status: 'successWithWarnings' }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]) + ).toBe(0); + }); + + it('maps a failed command to its non-zero exit code', () => { + expect( + deriveExitCodeFromEvents([ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })]) + ).toBe(1); + }); + + it('falls back to the sessionCompleted exit code', () => { + expect(deriveExitCodeFromEvents([ev('sessionCompleted', { exitCode: 1 })])).toBe(1); + }); + + it('defaults to zero when no result is present', () => { + expect(deriveExitCodeFromEvents([ev('commandStarted', { commandName: 'build' })])).toBe(0); + }); +}); + +describe('summarizeShadowResult', () => { + it('aggregates operation statuses and the command result', () => { + const summary: IShadowResultSummary = summarizeShadowResult([ + ev('operationStatusChanged', { operationId: 'a', status: 'success' }), + ev('operationStatusChanged', { operationId: 'b', status: 'success' }), + ev('operationStatusChanged', { operationId: 'c', status: 'fromCache' }), + ev('operationStatusChanged', { operationId: 'd', status: 'failure' }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(summary.commandName).toBe('build'); + expect(summary.succeeded).toBe(false); + expect(summary.exitCode).toBe(1); + expect(summary.operationCounts).toEqual({ success: 2, fromCache: 1, failure: 1 }); + }); + + it('derives failure from the session exit code when no command result exists', () => { + const summary: IShadowResultSummary = summarizeShadowResult([ + ev('operationStatusChanged', { operationId: 'a', status: 'failure' }), + ev('sessionCompleted', { exitCode: 1 }) + ]); + + expect(summary.succeeded).toBe(false); + expect(summary.exitCode).toBe(1); + }); + + it('uses the final command result consistently', () => { + const events: IReporterEventEnvelope[] = [ + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }), + ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }) + ]; + + expect(deriveExitCodeFromEvents(events)).toBe(0); + expect(summarizeShadowResult(events)).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + }); + + it('ignores child session results and counts only final root operation statuses', () => { + const events: IReporterEventEnvelope[] = [ + ev('operationStatusChanged', { operationId: 'a', status: 'waiting' }), + ev('operationStatusChanged', { operationId: 'a', status: 'queued' }), + ev('operationStatusChanged', { operationId: 'a', status: 'executing' }), + ev('operationStatusChanged', { operationId: 'a', status: 'success' }), + ev('operationStatusChanged', { operationId: 'child-op', status: 'failure' }, { parentSessionId: 'root' }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }), + ev( + 'commandResult', + { commandName: 'child-command', succeeded: true, exitCode: 0 }, + { parentSessionId: 'root' } + ), + ev('sessionCompleted', { exitCode: 0 }, { parentSessionId: 'root' }) + ]; + + expect(deriveExitCodeFromEvents(events)).toBe(1); + expect(summarizeShadowResult(events)).toMatchObject({ + commandName: 'build', + succeeded: false, + exitCode: 1, + operationCounts: { success: 1 } + }); + }); +}); + +describe('shadow emission parity through the manager', () => { + it('reproduces the exit code and result summary from delivered events', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + + emitter.emitSessionStarted({ rushVersion: '5.177.2' }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationStatusChanged({ operationId: 'op1', status: 'success' }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0 }); + await manager.flushAsync(); + + expect(deriveExitCodeFromEvents(reporter.reported)).toBe(0); + const summary: IShadowResultSummary = summarizeShadowResult(reporter.reported); + expect(summary.succeeded).toBe(true); + expect(summary.operationCounts).toEqual({ success: 1 }); + }); +}); diff --git a/libraries/reporter/src/test/LogLevelFilter.test.ts b/libraries/reporter/src/test/LogLevelFilter.test.ts new file mode 100644 index 0000000000..b1c6569ffe --- /dev/null +++ b/libraries/reporter/src/test/LogLevelFilter.test.ts @@ -0,0 +1,139 @@ +// 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 and results as quiet', () => { + expect(getEventMinimumLogLevel(ev('commandResult', { succeeded: false, exitCode: 1 }))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('sessionCompleted'))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('commandCompleted'))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'error' }))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('diagnosticEmitted', { severity: 'warning' }, true))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('messageEmitted', { severity: 'error' }))).toBe('quiet'); + expect(getEventMinimumLogLevel(ev('messageEmitted', { severity: 'warning' }))).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 raw output as debug', () => { + expect(getEventMinimumLogLevel(ev('operationRegistered', {}))).toBe('normal'); + expect(getEventMinimumLogLevel(ev('externalProcessStarted', {}))).toBe('verbose'); + expect(getEventMinimumLogLevel(ev('externalOutput', { stream: 'stdout', text: 'x' }))).toBe('debug'); + expect(getEventMinimumLogLevel(ev('messageEmitted', { severity: 'debug', text: 'd' }))).toBe('debug'); + expect(getEventMinimumLogLevel(ev('extension', { name: 'a.b' }, false))).toBe('normal'); + }); +}); + +describe('shouldRenderAtLogLevel', () => { + it('renders only failures and the result at quiet', () => { + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'error' }))).toBe(true); + expect(shouldRenderAtLogLevel('quiet', ev('diagnosticEmitted', { severity: 'warning' }, true))).toBe( + false + ); + 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('operationRegistered', {}))).toBe(true); + expect(shouldRenderAtLogLevel('normal', ev('diagnosticEmitted', { severity: 'warning' }, false))).toBe( + true + ); + expect(shouldRenderAtLogLevel('normal', ev('externalOutput', {}))).toBe(false); + expect(shouldRenderAtLogLevel('normal', ev('messageEmitted', { severity: 'debug' }))).toBe(false); + }); + + it('adds external activity at verbose and raw output at debug', () => { + expect(shouldRenderAtLogLevel('verbose', ev('externalProcessStarted', {}))).toBe(true); + expect(shouldRenderAtLogLevel('verbose', ev('externalOutput', {}))).toBe(false); + expect(shouldRenderAtLogLevel('debug', ev('externalOutput', {}))).toBe(true); + expect(shouldRenderAtLogLevel('debug', ev('messageEmitted', { severity: 'debug' }))).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('messageEmitted', { 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', + 'commandResult' + ]); + expect(filterEventsForLogLevel('debug', 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/libraries/reporter/src/test/OperationStreamEmitter.test.ts b/libraries/reporter/src/test/OperationStreamEmitter.test.ts new file mode 100644 index 0000000000..a6b47eaf74 --- /dev/null +++ b/libraries/reporter/src/test/OperationStreamEmitter.test.ts @@ -0,0 +1,189 @@ +// 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, + isReporterEventRequired, + 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('rejects chunk sizes that cannot preserve protocol boundaries', () => { + const sink: CapturingSink = new CapturingSink(); + expect(() => makeEmitter(sink, 3)).toThrow(/between 4/); + expect(() => makeEmitter(sink, 64 * 1024 + 1)).toThrow(/between 4/); + expect(() => makeEmitter(sink, 4.5)).toThrow(/integer/); + }); + + 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[3].payload).toMatchObject({ operationId: 'op1', status: 'success', durationMs: 100 }); + // externalOutput is protected (never coalesced/dropped); the manager derives `required`. + expect(isReporterEventRequired('externalOutput')).toBe(true); + }); + + 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('never splits a UTF-16 surrogate pair', () => { + const sink: CapturingSink = new CapturingSink(); + const emitter: OperationStreamEmitter = makeEmitter(sink, 4); + emitter.writeOutput('op1', 'stdout', 'abc๐Ÿ˜€def'); + + const chunks: string[] = sink.inputs.map((input) => (input.payload as { text: string }).text); + expect(chunks.join('')).toBe('abc๐Ÿ˜€def'); + expect(chunks).not.toContain('\ud83d'); + expect(chunks).not.toContain('\ude00'); + expect(chunks.every((chunk: string) => Buffer.byteLength(chunk, 'utf8') <= 4)).toBe(true); + }); + + 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/libraries/reporter/src/test/PlaintextReporter.test.ts b/libraries/reporter/src/test/PlaintextReporter.test.ts new file mode 100644 index 0000000000..aa97c7e474 --- /dev/null +++ b/libraries/reporter/src/test/PlaintextReporter.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 { 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('preserves partial-line chunks within grouped output', () => { + const capture: ICapture = makeDetailed(); + capture.reporter.report( + ev('operationRegistered', { operationId: 'op1', projectName: 'project-a', phaseName: 'build' }) + ); + capture.reporter.report(ev('externalOutput', { text: 'Building ' }, { operationId: 'op1' })); + capture.reporter.report(ev('externalOutput', { text: 'project-a' }, { operationId: 'op1' })); + capture.reporter.report(ev('operationStatusChanged', { operationId: 'op1', status: 'success' })); + + expect(capture.getOutput()).toContain('Building project-a\nproject-a: success'); + expect(capture.getOutput()).not.toContain('Building \nproject-a'); + }); + + 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/ProblemMatchers.test.ts b/libraries/reporter/src/test/ProblemMatchers.test.ts new file mode 100644 index 0000000000..462421e77c --- /dev/null +++ b/libraries/reporter/src/test/ProblemMatchers.test.ts @@ -0,0 +1,204 @@ +// 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[], operationId: string = 'op1'): 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(operationId, '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([]); + expect(registry.getMatchers('heft')).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({ + kind: 'file', + 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'); + + const splitAnsiEvents: IReporterEventEnvelope[] = emitOutput([ + '\u001b[31', + "msrc/split.ts(4,5): error TS2001: split escape\u001b[0m\n" + ]); + const splitAnsiResult: IProblemMatcherResult = runProblemMatchers(splitAnsiEvents, [TSC_ERROR_MATCHER]); + expect(splitAnsiResult.diagnostics).toHaveLength(1); + expect(splitAnsiResult.diagnostics[0].parameters?.code.value).toBe('TS2001'); + }); + + it('matches diagnostics with CRLF line endings', () => { + const result: IProblemMatcherResult = runProblemMatchers( + emitOutput(['src/windows.ts(3,4): error TS3000: bad\r\n']), + [TSC_ERROR_MATCHER] + ); + + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].parameters?.code.value).toBe('TS3000'); + }); + + 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('scopes duplicate caps by operation and full source position', () => { + const firstOperation: IReporterEventEnvelope[] = emitOutput( + ['src/index.ts(1,1): error TS1005: same\n'], + 'op1' + ); + const secondOperation: IReporterEventEnvelope[] = emitOutput( + ['src/index.ts(1,1): error TS1005: same\n'], + 'op2' + ); + const secondColumn: IReporterEventEnvelope[] = emitOutput( + ['src/index.ts(1,2): error TS1005: same\n'], + 'op1' + ); + const result: IProblemMatcherResult = runProblemMatchers( + [...firstOperation, ...secondOperation, ...secondColumn], + [TSC_ERROR_MATCHER], + { maxDuplicates: 1 } + ); + + expect(result.diagnostics).toHaveLength(3); + expect(result.suppressedDuplicateCount).toBe(0); + }); + + 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/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/libraries/reporter/src/test/Session.test.ts b/libraries/reporter/src/test/Session.test.ts new file mode 100644 index 0000000000..43c90197e8 --- /dev/null +++ b/libraries/reporter/src/test/Session.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 { + createScopedReporter, + createScopedLogger, + createRushDiagnostic, + RushSessionReporting, + ReporterManager, + isRushVersionSupported, + createPluginApiIncompatibleDiagnostic, + isReporterEventRequired, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IReporterExecutionContext, + type IRushDiagnostic, + type IScopedLogger, + type IScopedReporter +} from '../index'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: Record[] = []; + + public emit(event: Record): string { + this.inputs.push(event); + return `evt_${this.inputs.length}`; + } +} + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +describe('createScopedReporter', () => { + it('emits scoped messages on the activityChanged channel', () => { + const sink: CapturingSink = new CapturingSink(); + const scope: IReporterEventScope = { commandName: 'build', projectName: '@my/project' }; + const reporter: IScopedReporter = createScopedReporter({ + sink, + sessionId: 'sess', + source: SOURCE, + scope + }); + + reporter.emitMessage({ severity: 'info', text: 'hello' }); + expect(sink.inputs[0].type).toBe('messageEmitted'); + expect(sink.inputs[0].scope).toEqual(scope); + // The manager derives `required`; messages are never coalescible. + expect(isReporterEventRequired('messageEmitted')).toBe(true); + // Message text fails safe at local-sensitive by default. + expect(sink.inputs[0].privacy).toBe('local-sensitive'); + expect(sink.inputs[0].payload).toEqual({ severity: 'info', text: 'hello' }); + }); + + it('emits messages on the messageEmitted channel for every severity', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + reporter.emitMessage({ severity: 'warning', text: 'careful' }); + reporter.emitMessage({ severity: 'error', text: 'boom' }); + expect(sink.inputs[0].type).toBe('messageEmitted'); + expect(sink.inputs[0].payload).toEqual({ severity: 'warning', text: 'careful' }); + expect(sink.inputs[1].type).toBe('messageEmitted'); + expect(sink.inputs[1].payload).toEqual({ severity: 'error', text: 'boom' }); + }); + + it('emits diagnostics with the envelope privacy floor', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: 'p', privacy: 'public' }, + logPath: { value: '/tmp/x.log', privacy: 'secret' } + } + }); + reporter.emitDiagnostic(diagnostic); + expect(sink.inputs[0].type).toBe('diagnosticEmitted'); + // Least sensitive field is the floor. + expect(sink.inputs[0].privacy).toBe('public'); + expect(isReporterEventRequired('diagnosticEmitted')).toBe(true); + expect(sink.inputs[0].payload).toBe(diagnostic); + }); + + it('validates and wraps extension events, rejecting non-namespaced names', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + reporter.emitExtension('acme.cache-warmed' as Parameters[0], { hits: 3 }); + expect(sink.inputs[0].type).toBe('extension'); + expect(sink.inputs[0].privacy).toBe('local-sensitive'); + expect(sink.inputs[0].payload).toEqual({ name: 'acme.cache-warmed', payload: { hits: 3 } }); + expect(() => reporter.emitExtension('notnamespaced' as Parameters[0], {})).toThrow( + /Invalid extension event name/ + ); + }); + + it('exposes only emit methods, hiding modes, destinations, and thresholds', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + expect(Object.keys(reporter).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + }); +}); + +describe('createScopedLogger', () => { + it('maps log methods to message severities and has no terminal handle', () => { + const sink: CapturingSink = new CapturingSink(); + const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); + const logger: IScopedLogger = createScopedLogger(reporter); + + logger.writeLine('a'); + logger.writeDebugLine('b'); + logger.writeWarningLine('c'); + logger.writeErrorLine('d'); + + const severities: unknown[] = sink.inputs.map( + (input: Record) => (input.payload as { severity: string }).severity + ); + expect(severities).toEqual(['info', 'debug', 'warning', 'error']); + expect(Object.keys(logger)).not.toContain('terminal'); + }); +}); + +describe('RushSessionReporting', () => { + it('creates scoped reporters, loggers, and an execution context that reach reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(recording); + await manager.initializeAsync(); + + const reporting: RushSessionReporting = new RushSessionReporting({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + + expect(reporting.getSink()).toBe(manager); + + const context: IReporterExecutionContext = reporting.createExecutionContext({ commandName: 'build' }); + context.reporter.emitMessage({ severity: 'warning', text: 'from action' }); + + const logger: IScopedLogger = reporting.createScopedLogger({ projectName: '@my/project' }); + logger.writeErrorLine('from plugin'); + + await manager.flushAsync(); + + expect(recording.reported).toHaveLength(2); + expect(recording.reported[0].scope).toEqual({ commandName: 'build' }); + expect(recording.reported[1].scope).toEqual({ projectName: '@my/project' }); + }); +}); + +describe('plugin API compatibility', () => { + it('accepts a matching Rush semver range and rejects mismatched or malformed input', () => { + expect(isRushVersionSupported('>=5 <6', '5.177.2')).toBe(true); + expect(isRushVersionSupported('^5.150.0', '5.177.2')).toBe(true); + expect(isRushVersionSupported('>=6', '5.177.2')).toBe(false); + expect(isRushVersionSupported('5garbage', '5.177.2')).toBe(false); + expect(isRushVersionSupported('', '5.177.2')).toBe(false); + expect(isRushVersionSupported('>=5 <6', 'not-a-version')).toBe(false); + }); + + it('builds a migration diagnostic for an incompatible plugin', () => { + const diagnostic: IRushDiagnostic = createPluginApiIncompatibleDiagnostic( + { + pluginName: '@acme/rush-plugin', + rushVersionRange: '>=6 <7' + }, + '5.177.2' + ); + expect(diagnostic.code).toBe('RUSH_PLUGIN_API_INCOMPATIBLE'); + expect(diagnostic.category).toBe('configuration'); + expect(diagnostic.parameters?.pluginName.value).toBe('@acme/rush-plugin'); + expect(diagnostic.parameters?.rushVersionRange.value).toBe('>=6 <7'); + expect(diagnostic.parameters?.rushVersion.value).toBe('5.177.2'); + }); +}); diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts new file mode 100644 index 0000000000..0050b3260b --- /dev/null +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + TelemetrySubscriber, + createTelemetryReporter, + createBeforeLogAdapter, + TELEMETRY_AGGREGATE_KEYS, + LifecycleEmitter, + ReporterManager, + createRushDiagnostic, + type IReporter, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type IReporterEventSource, + type ITelemetryAggregate, + type LegacyBeforeLogHook +} from '../index'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; + +function rawInput(type: string, payload: unknown): IReporterEmitEventInput { + return { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: SOURCE, + privacy: 'public', + type: type as IReporterEmitEventInput['type'], + payload + }; +} + +describe('TelemetrySubscriber', () => { + it('produces an allowlisted aggregate from the event stream before reporter filtering', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + telemetry.setReporterMode('default'); + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(createTelemetryReporter(telemetry)); + manager.addReporter(recording); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE, + scope: { commandName: 'build' } + }); + emitter.emitCommandStarted({ commandName: 'build', argv: ['--to', 'x'] }); + emitter.emitOperationStatusChanged({ operationId: 'op1', status: 'success' }); + emitter.emitOperationStatusChanged({ operationId: 'op2', status: 'fromCache' }); + emitter.emitDiagnostic(createRushDiagnostic('RUSH_OPERATION_FAILED')); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 0, durationMs: 1234 }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + emitter.emitSessionCompleted({ exitCode: 0, durationMs: 1500 }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.commandName).toBe('build'); + expect(aggregate.result).toBe('succeeded'); + expect(aggregate.exitCode).toBe(0); + expect(aggregate.durationMs).toBe(1500); + expect(aggregate.operationStatusCounts).toEqual({ success: 1, fromCache: 1 }); + expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); + expect(aggregate.reporterMode).toBe('default'); + expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 0 }); + expect(aggregate.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); + + // The subscriber runs alongside a rendering reporter and does not consume events from it. + expect(recording.reported.length).toBeGreaterThan(0); + }); + + it('only ever contains allowlisted keys', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + emitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + await manager.flushAsync(); + + for (const key of Object.keys(telemetry.buildAggregate())) { + expect(TELEMETRY_AGGREGATE_KEYS).toContain(key); + } + }); + + it('never leaks messages, paths, arguments, remediation, raw output, or secret values', async () => { + const SECRET: string = 'sk-super-secret-value'; + const LOG_PATH: string = '/home/user/secret/install.log'; + const ARG: string = '--auth-token=abc123'; + const MESSAGE: string = 'verbose diagnostic message text'; + const REMEDIATION_COMMAND: string = 'rush update --purge-and-leak'; + + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: SOURCE + }); + emitter.emitCommandStarted({ commandName: 'build', argv: [ARG] }); + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_DEPENDENCY_TOOL_FAILED', { + parameters: { + token: { value: SECRET, privacy: 'secret' }, + logPath: { value: LOG_PATH, privacy: 'local-sensitive' } + }, + remediation: [ + { descriptionKey: 'r', command: REMEDIATION_COMMAND, automatedExecutionSafety: 'unsafe' } + ] + }) + ); + manager.emit(rawInput('externalOutput', { stream: 'stdout', text: `${SECRET} raw output` })); + manager.emit(rawInput('activityChanged', { kind: 'message', severity: 'info', text: MESSAGE })); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [SECRET, LOG_PATH, ARG, MESSAGE, REMEDIATION_COMMAND]) { + expect(serialized).not.toContain(forbidden); + } + // But the allowlisted diagnostic code and category are retained. + expect(aggregate.diagnosticCodes).toEqual(['RUSH_DEPENDENCY_TOOL_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ 'dependency-tool': 1 }); + expect(aggregate.result).toBe('failed'); + for (const key of Object.keys(aggregate)) { + expect(TELEMETRY_AGGREGATE_KEYS).toContain(key); + } + }); + + it('uses the root session completion as the final process result', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.emit(rawInput('sessionCompleted', { exitCode: 1, durationMs: 2000 })); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + durationMs: 2000 + }); + }); + + it('records command completion before later lifecycle results arrive', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('commandCompleted', { commandName: 'build', exitCode: 1, durationMs: 1500 })); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + durationMs: 1500 + }); + }); + + it('counts each operation once using its final status', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('operationStatusChanged', { operationId: 'op1', status: 'ready' })); + manager.emit(rawInput('operationStatusChanged', { operationId: 'op1', status: 'queued' })); + manager.emit(rawInput('operationStatusChanged', { operationId: 'op1', status: 'executing' })); + manager.emit(rawInput('operationStatusChanged', { operationId: 'op1', status: 'success' })); + manager.emit(rawInput('operationStatusChanged', { operationId: 'op2', status: 'aborted' })); + await manager.flushAsync(); + + expect(telemetry.buildAggregate().operationStatusCounts).toEqual({ success: 1, aborted: 1 }); + }); + + it('does not let child session lifecycle events overwrite root command state', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 })); + manager.emit(rawInput('sessionCompleted', { exitCode: 1, durationMs: 2000 })); + manager.emit(rawInput('operationStatusChanged', { operationId: 'root-op', status: 'success' })); + manager.emit({ + ...rawInput('commandResult', { commandName: 'child-command', succeeded: true, exitCode: 0 }), + sessionId: 'child', + parentSessionId: 'sess' + }); + manager.emit({ + ...rawInput('sessionCompleted', { exitCode: 0, durationMs: 25 }), + sessionId: 'child', + parentSessionId: 'sess' + }); + manager.emit({ + ...rawInput('operationStatusChanged', { operationId: 'child-op', status: 'failure' }), + sessionId: 'child', + parentSessionId: 'sess' + }); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + }); +}); + +describe('createBeforeLogAdapter', () => { + it('projects the legacy telemetry shape and returns hook augmentations without mutating the aggregate', () => { + const hook: LegacyBeforeLogHook = (telemetry: Record) => { + telemetry.customField = 'custom-value'; + (telemetry.extraData as Record).countSuccess = 99; + }; + const adapter: (aggregate: ITelemetryAggregate) => Record = + createBeforeLogAdapter([hook]); + + const aggregate: ITelemetryAggregate = { + commandName: 'build', + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 2 }, + diagnosticCodes: [], + diagnosticCategoryCounts: {}, + producerVersions: ['@microsoft/rush-lib@5.177.2'] + }; + const record: Record = adapter(aggregate); + + expect(record).toMatchObject({ + name: 'build', + durationInSeconds: 0, + result: 'Succeeded', + customField: 'custom-value', + operationResults: {}, + extraData: { + countAll: 2, + countSuccess: 99, + countSuccessWithWarnings: 0, + countFailure: 0 + } + }); + expect(aggregate.result).toBe('succeeded'); + expect(aggregate.operationStatusCounts).toEqual({ success: 2 }); + expect(record).not.toBe(aggregate); + }); + + it('rejects an aggregate built before command completion', () => { + const adapter: (aggregate: ITelemetryAggregate) => Record = + createBeforeLogAdapter([]); + expect(() => + adapter({ + operationStatusCounts: {}, + diagnosticCodes: [], + diagnosticCategoryCounts: {}, + producerVersions: [] + }) + ).toThrow(/completed telemetry aggregate/); + }); +}); 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/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/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index 1c56731bdb..e2b235d113 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -27,6 +27,7 @@ export interface IRushPluginManifest { optionsSchema?: string; associatedCommands?: string[]; commandLineJsonFilePath?: string; + rushVersionRange?: string; } export interface IRushPluginManifestJson { diff --git a/libraries/rush-lib/src/schemas/rush-plugin-manifest.schema.json b/libraries/rush-lib/src/schemas/rush-plugin-manifest.schema.json index 293aaf6d69..43cfaf13b7 100644 --- a/libraries/rush-lib/src/schemas/rush-plugin-manifest.schema.json +++ b/libraries/rush-lib/src/schemas/rush-plugin-manifest.schema.json @@ -41,6 +41,11 @@ "commandLineJsonFilePath": { "description": "Specifies a command line config file path. The path is resolved relative to package folder. It defines custom command line commands, mostly same as command-line.json in Rush", "type": "string" + }, + "rushVersionRange": { + "description": "Specifies the semver range of Rush versions supported by this plugin.", + "type": "string", + "minLength": 1 } } } diff --git a/research/feature-list.json b/research/feature-list.json index bfa63a3831..4bfbc1f77c 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -129,9 +129,9 @@ "Expose scoped reporter and logger creation from RushSession", "Pass the sink to actions through execution context", "Prevent plugins from inspecting modes, destinations, or thresholds", - "Declare the supported Rush plugin API version in plugin manifests" + "Declare the supported Rush version range in plugin manifests" ], - "passes": false + "passes": true }, { "category": "functional", @@ -142,7 +142,7 @@ "Keep legacy output unchanged while events flow", "Validate telemetry and exit-code parity against the frozen baselines" ], - "passes": false + "passes": true }, { "category": "functional", @@ -154,7 +154,7 @@ "Preserve the existing beforeLog hook through an adapter", "Add allowlist schema and leakage tests" ], - "passes": false + "passes": true }, { "category": "functional", @@ -166,7 +166,7 @@ "Ensure reporters and diagnostic categories never select exit codes", "Preserve command-specific JSON schemas" ], - "passes": false + "passes": true }, { "category": "functional", @@ -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", @@ -190,7 +190,7 @@ "Default the full-detail file reporter to debug", "Add log-level filtering tests" ], - "passes": false + "passes": true }, { "category": "functional", @@ -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", @@ -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", @@ -227,7 +227,7 @@ "Retain StreamCollator-like operation grouping in detailed CI mode", "Add stable plaintext snapshot tests" ], - "passes": false + "passes": true }, { "category": "functional", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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 dd74a6456c..b01eeea72a 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -165,3 +165,231 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - resolver (5 combos incl newer-major), engine sink (structured + fallback), adapter bridge + chunk-split tests pass; all exports @beta Next: Feature 11/28 - Expose RushSession event sink and scoped producers to actions and plugins + +[2026-07-14] Feature 11/28 COMPLETE: RushSession event sink + scoped producers for actions/plugins + Files (new): + - session/ScopedReporterFactory.ts (createScopedReporter -> IScopedReporter; emitMessage->activityChanged {kind:'message',severity,text}, required for warning/error; emitDiagnostic->diagnosticEmitted with computeEnvelopePrivacyFloor; emitExtension validates namespaced name, wraps {name,payload}) + - session/ScopedLogger.ts (IScopedLogger writeLine/writeDebugLine/writeWarningLine/writeErrorLine; NO .terminal handle) + - session/RushSessionReporting.ts (facade: createScopedReporter/createScopedLogger; getSink(); createExecutionContext()->IReporterExecutionContext {sink, reporter}) + - session/PluginApi.ts (IRushPluginManifest.rushVersionRange, isRushVersionSupported semver validation, createPluginApiIncompatibleDiagnostic migration diagnostic) + - test/Session.test.ts + Files (modified): diagnostics/RushDiagnosticCodeRegistry.ts (APPENDED RUSH_PLUGIN_API_INCOMPATIBLE code+templates), index.ts, api.md + Notes: + - Human messages map to activityChanged (closed union has no 'message' type; golden freezes the 15). required-by-severity so warnings/errors aren't coalesced. + - Step 3 (no modes/destinations/thresholds) satisfied structurally: scoped reporter/logger + sink are emit-only. Test asserts Object.keys == 3 emit methods. + - Same source-of-truth scoping (RushSession itself lives in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - scoped message/diagnostic/extension, privacy floor, emit-only surface, logger severities, session facade end-to-end via ReporterManager, plugin API compat + migration diagnostic tests pass; all exports @beta + Next: Feature 12/28 - Emit shadow structured lifecycle + diagnostic events without changing output + +[2026-07-14] Feature 12/28 COMPLETE: Shadow structured lifecycle + diagnostic emission + Files (new): + - lifecycle/LifecycleEvents.ts (OperationStatus + payload interfaces: session/command started+completed, operation registered+statusChanged, commandResult, watchCycleCompleted) + - lifecycle/LifecycleEmitter.ts (emit* methods, all required=true; merges base scope + per-event scope override; emitDiagnostic uses privacy floor; writes nothing to stdout/stderr) + - lifecycle/ShadowParity.ts (deriveExitCodeFromEvents: commandResult succeeded->0 else exitCode/1, sessionCompleted fallback; summarizeShadowResult: operationCounts + result) + - test/Lifecycle.test.ts + Files (modified): index.ts, api.md + Notes: + - Step 3 "keep legacy output unchanged": emitter is output-neutral (only sink.emit). Test spies process.stdout/stderr.write and asserts not called. + - Step 4 parity: exit-code parity helper + result summary. Full telemetry allowlist deferred to Feature 13; authoritative exit-code semantics to Feature 14 (this is shadow parity derivation). + - Same source-of-truth scoping (actions/scheduler/plugins live in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - lifecycle emit + scope merge, diagnostic privacy floor, shadow output-neutrality, exit-code parity (4 cases), result summary, manager integration tests pass; all exports @beta + Next: Feature 13/28 - Telemetry projection subscriber (allowlist + beforeLog adapter) + +[2026-07-14] Feature 13/28 COMPLETE: Telemetry projection subscriber (allowlist + beforeLog adapter) + Files (new): + - telemetry/TelemetryAggregate.ts (ITelemetryAggregate allowlist: commandName/result/exitCode/durationMs/operationStatusCounts/diagnosticCodes/diagnosticCategoryCounts/reporterMode/protocolVersion/producerVersions; TELEMETRY_AGGREGATE_KEYS) + - telemetry/TelemetrySubscriber.ts (ingest extracts only allowlisted values - diagnostics keep code+category only, ignores argv/messages/external/artifacts; buildAggregate; setReporterMode; createTelemetryReporter adapter = IReporter with no destination, feeds before reporter filtering) + - telemetry/BeforeLogAdapter.ts (LegacyBeforeLogHook + createBeforeLogAdapter runs hooks with plain copy of aggregate) + - test/Telemetry.test.ts + Files (modified): index.ts, api.md + Notes: + - "Before reporter filtering": manager fans full events to all reporters (each filters internally), so telemetry-as-reporter sees unfiltered events. + - Leakage test emits secret param/path/argv/message/remediation-command/raw-output; asserts JSON.stringify(aggregate) contains none; keys subset of allowlist. + - Same source-of-truth scoping (Rush telemetry lives in rush-lib; not wired live). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - aggregate build, allowlist schema, leakage (5 forbidden strings absent), beforeLog adapter (plain copy) tests pass; all exports @beta + Next: Feature 14/28 - Preserve command success + exit-code semantics independent of reporters + +[2026-07-14] Feature 14/28 COMPLETE: Authoritative command success + exit-code semantics + Files (new): + - exit/ExitStatus.ts (EXIT_CODE_SUCCESS=0/FAILURE=1; RushCommandOutcome succeeded|failed|cancelled|signal; getSignalExitCode=128+os.constants.signals[sig]; resolveExitStatus(hasFailures/cancelled/signal); resolveExitStatusFromEvents - failure = failed commandResult OR error diagnostic OR failed operation; category & reporter never inputs; precedence signal>cancelled>failure) + - exit/CommandJson.ts (separateJsonControls -> {commandJson (--json), reporterJson (--reporter=json/--reporter json)} independent) + - test/ExitStatus.test.ts + Files (modified): index.ts, api.md + Notes: + - Warning-only success -> 0 (warnings not failures). Signal-derived status conventional (SIGINT->130, SIGTERM->143). + - Category-independence proven: error diagnostics of any category ->1; warnings of any category ->0. + - Command-specific --json preserved and NOT an alias for --reporter=json. + - Distinct from Feature 12 shadow deriveExitCodeFromEvents (this is authoritative full outcome incl cancel/signal). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - resolveExitStatus (success/warning-only/failure/cancel/signal precedence), signal codes, from-events (warning/error/op-failure/failed-result/category-independence/cancel/signal), separateJsonControls (5 cases) tests pass; all exports @beta + Next: Feature 15/28 - Reporter selection + configuration controls with precedence + +[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 + +[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 + +[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) + +[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 + +[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 + +[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) + +[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) + +[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 + +[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 + +[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 + +[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