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 index efc5a2c6a3..0acd358d2a 100644 --- 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 @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/rush-reporter", - "comment": "Add scoped session reporting (createScopedReporter, RushSessionReporting, IScopedLogger, execution context) and plugin API version compatibility with a migration diagnostic", + "comment": "Add scoped session reporting (createScopedReporter, RushSessionReporting, IScopedLogger, execution context) and Rush version range compatibility with a migration diagnostic", "type": "minor" } ], diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json new file mode 100644 index 0000000000..60423a8be0 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add reporter performance and capacity budgets: a perf module encoding the specification blocking budgets with wall-time and memory helpers, plus a ReporterManager.getPendingEventCount observability hook for bounded streaming", + "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-02-06-20.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json new file mode 100644 index 0000000000..0f562e77a3 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add the daemon-aligned major default-flip migration model: the reporter migration phases, pre-flip and post-flip major default sets, and a plugin apply gate that fails incompatible plugins before apply() with a structured migration diagnostic", + "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 8143cbc235..0d8ccfa2da 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -67,11 +67,14 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec // @beta export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; +// @beta +export function computeWallTimeRegressionPercent(baselineMs: number, candidateMs: number): number; + // @beta export const COPILOT_CLI_ENV_VAR: 'COPILOT_CLI'; // @beta -export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => void; +export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => Record; // @beta export function createColorizer(enabled: boolean): IColorizer; @@ -80,7 +83,7 @@ export function createColorizer(enabled: boolean): IColorizer; export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution; // @beta -export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic; +export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest, rushVersion: string): IRushDiagnostic; // @beta export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; @@ -94,6 +97,9 @@ export function createScopedReporter(options: ICreateScopedReporterOptions): ISc // @beta export function createTelemetryReporter(subscriber: TelemetrySubscriber): IReporter; +// @beta +export const DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS: IReporterMajorDefaults; + // @beta export const DEFAULT_FLUSH_TIMEOUT_MS: number; @@ -133,6 +139,9 @@ export function detectAgent(env: Record, configuredV // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; +// @beta +export function evaluatePluginApplyGate(manifests: readonly IRushPluginManifest[], options: IPluginApplyGateOptions): IPluginApplyDecision[]; + // @beta export const EXIT_CODE_FAILURE: 1; @@ -161,6 +170,9 @@ export class FileReporter implements IReporter { // @beta export function filterEventsForLogLevel(logLevel: ReporterLogLevel, events: readonly IReporterEventEnvelope[]): IReporterEventEnvelope[]; +// @beta +export function getBlockedPlugins(decisions: readonly IPluginApplyDecision[]): IPluginApplyDecision[]; + // @beta export function getEventMinimumLogLevel(event: IReporterEventEnvelope): ReporterLogLevel; @@ -170,6 +182,9 @@ export function getLogLevelRank(level: ReporterLogLevel): number; // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; +// @beta +export function getReporterMigrationPhase(id: ReporterMigrationPhaseId): IReporterMigrationPhase; + // @beta export function getSignalExitCode(signal: NodeJS.Signals): number; @@ -273,6 +288,13 @@ export interface IAutomaticReporterPlan { readonly stdoutOwner: 'machine' | 'human'; } +// @beta +export interface IAutomaticSelectionContext { + readonly emergencyLegacyFallback?: boolean; + readonly experimentalSettingEnabled?: boolean; + readonly explicitOptIn?: boolean; +} + // @beta export interface IBootstrapEventBufferOptions { readonly maxBytes?: number; @@ -615,6 +637,19 @@ export interface IPlaintextReporterOptions { readonly write: (text: string) => void; } +// @beta +export interface IPluginApplyDecision { + readonly allowed: boolean; + readonly diagnostic?: IRushDiagnostic; + readonly manifest: IRushPluginManifest; +} + +// @beta +export interface IPluginApplyGateOptions { + readonly gateEnabled?: boolean; + readonly rushVersion: string; +} + // @beta export interface IProblemMatch { readonly code?: string; @@ -772,6 +807,18 @@ export interface IReporterHostOptions { readonly retentionMs?: number; } +// @beta +export interface IReporterMajorDefaults { + readonly automaticSelectionEnabledByDefault: boolean; + readonly emergencyFallbackEnvVar: string; + readonly emergencyFallbackReporterName: string; + readonly gateIncompatiblePluginsBeforeApply: boolean; + readonly legacyRendererRetained: boolean; + readonly removedTerminalApis: readonly string[]; + readonly sentinelBridgeRetained: boolean; + readonly verbosityAliasesRetained: boolean; +} + // @beta export interface IReporterManagerOptions { readonly coalesceThreshold?: number; @@ -780,6 +827,16 @@ export interface IReporterManagerOptions { readonly protocolVersion?: IReporterProtocolVersion; } +// @beta +export interface IReporterMigrationPhase { + readonly id: ReporterMigrationPhaseId; + readonly independentlyReleasable: boolean; + readonly ordinal: number; + readonly revertible: boolean; + readonly summary: string; + readonly title: string; +} + // @beta export interface IReporterOutputTarget { readonly params: { @@ -789,6 +846,15 @@ export interface IReporterOutputTarget { readonly target: string; } +// @beta +export interface IReporterPerformanceBudgets { + readonly maxAdditionalPeakMemoryBytes: number; + readonly maxAiDetailedDiagnostics: number; + readonly maxAiOutputBytes: number; + readonly maxInteractiveRefreshHz: number; + readonly maxWallTimeRegressionPercent: number; +} + // @beta export interface IReporterPlanEntry { readonly destination: string; @@ -901,8 +967,8 @@ export interface IRushFileDiagnosticSource { // @beta export interface IRushPluginManifest { - readonly pluginApiVersion: string; readonly pluginName: string; + readonly rushVersionRange: string; } // @beta @@ -933,6 +999,9 @@ export function isAgentVariableActive(value: string | undefined): boolean; // @beta export function isAlreadyReportedSentinel(error: unknown): boolean; +// @beta +export function isAutomaticSelectionEnabled(defaults: IReporterMajorDefaults, context?: IAutomaticSelectionContext): boolean; + // @beta export function isBootstrapHandoffFileName(fileName: string): boolean; @@ -961,6 +1030,9 @@ export interface IScopedReporter { emitMessage(options: IScopedMessageOptions): string; } +// @beta +export function isEmergencyLegacyFallback(env: Record, defaults?: IReporterMajorDefaults): boolean; + // @beta export interface ISessionCompletedPayload { readonly durationMs?: number; @@ -989,9 +1061,6 @@ export function isLegacyEmergencyFallbackRequested(env: Record = string extends TSegments ? `_${Uppercase}` : TSegments extends `_${infer Segments}` ? Segments extends '' ? never : TSegments extends Uppercase ? TSegments : never : never; // @beta -export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; +export type OperationStatus = 'ready' | 'waiting' | 'queued' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp' | 'aborted'; // @beta export class OperationStreamEmitter { @@ -1212,6 +1293,9 @@ export type PlaintextVariant = 'detailed' | 'concise'; // @beta export function planAutomaticReporters(selection: IReporterSelection): IAutomaticReporterPlan; +// @beta +export const PRE_FLIP_REPORTER_DEFAULTS: IReporterMajorDefaults; + // @beta export class ProblemMatcherRegistry { getMatchers(tool: string, options?: IGetMatchersOptions): IProblemMatcher[]; @@ -1234,6 +1318,9 @@ export function regroupOperationOutput(events: readonly IReporterEventEnvelope; emit(event: IReporterEmitEventInput): string; flushAsync(timeoutMs?: number): Promise; + getPendingEventCount(): number; ingestForeignEnvelope(envelope: IReporterEventEnvelope): string; initializeAsync(): Promise; signalFlushAsync(timeoutMs?: number): Promise; @@ -1304,6 +1398,9 @@ export class ReporterManager implements IReporterEventSink { // @beta export type ReporterMessageSeverity = 'debug' | 'info' | 'warning' | 'error'; +// @beta +export type ReporterMigrationPhaseId = 'contractsAndBaselines' | 'bootstrapAndCompatAdapters' | 'shadowStructuredEmission' | 'optInReporters' | 'heftProtocolTrack' | 'daemonAlignedMajorFlip' | 'laterCleanupMajor'; + // @beta export class ReporterMultiplexer implements IReporter { constructor(name: string, reporters: readonly IReporter[]); @@ -1423,9 +1520,6 @@ export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; // @beta export const RUSH_LOGS_DIR_NAME: 'rush-logs'; -// @beta -export const RUSH_PLUGIN_API_VERSION: '1.0.0'; - // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; 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/diagnostics/templates/configuration.ts b/libraries/reporter/src/diagnostics/templates/configuration.ts index a6202deb26..9eb6d12247 100644 --- a/libraries/reporter/src/diagnostics/templates/configuration.ts +++ b/libraries/reporter/src/diagnostics/templates/configuration.ts @@ -12,5 +12,5 @@ export const CONFIGURATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary': 'The configuration file {file} contains invalid JSON.', 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary': - 'The plugin {pluginName} declares plugin API version {declaredApiVersion}, which is incompatible with this Rush (supported: {supportedApiVersion}).' + 'The plugin {pluginName} supports Rush {rushVersionRange}, which does not include the running Rush version {rushVersion}.' } as const; diff --git a/libraries/reporter/src/exit/ExitStatus.ts b/libraries/reporter/src/exit/ExitStatus.ts index b675fc8ebf..ca7d7e236c 100644 --- a/libraries/reporter/src/exit/ExitStatus.ts +++ b/libraries/reporter/src/exit/ExitStatus.ts @@ -129,10 +129,10 @@ export interface IResolveExitStatusFromEventsOptions { * Resolves a command's exit status from its structured event stream. * * @remarks - * A failure is any failed command result, any error-severity diagnostic, or any - * failed operation. Diagnostic categories and the selected reporter are never - * consulted, so they cannot influence the exit code. Warning-severity - * diagnostics never cause failure. + * 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 @@ -145,16 +145,24 @@ export function resolveExitStatusFromEvents( ): 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') { - if ((event.payload as { status?: string }).status === 'failure') { + const status: string | undefined = (event.payload as { status?: string }).status; + if (status === 'failure' || status === 'aborted') { hasFailures = true; } } diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 8386ff8652..fcff5af94f 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -167,11 +167,7 @@ export { createScopedLogger } from './session/ScopedLogger'; export type { IRushSessionReportingOptions, IReporterExecutionContext } from './session/RushSessionReporting'; export { RushSessionReporting } from './session/RushSessionReporting'; export type { IRushPluginManifest } from './session/PluginApi'; -export { - RUSH_PLUGIN_API_VERSION, - isPluginApiVersionSupported, - createPluginApiIncompatibleDiagnostic -} from './session/PluginApi'; +export { isRushVersionSupported, createPluginApiIncompatibleDiagnostic } from './session/PluginApi'; export type { OperationStatus, @@ -330,3 +326,28 @@ export { isReporterExtensionEventName, parseReporterExtensionEventName } from './producers/ReporterExtensionEventName'; + +export type { IReporterPerformanceBudgets } from './perf/PerformanceBudgets'; +export { + REPORTER_PERFORMANCE_BUDGETS, + computeWallTimeRegressionPercent, + isWithinWallTimeBudget, + isWithinMemoryBudget +} from './perf/PerformanceBudgets'; + +export type { ReporterMigrationPhaseId, IReporterMigrationPhase } from './migration/MigrationPhase'; +export { REPORTER_MIGRATION_PHASES, getReporterMigrationPhase } from './migration/MigrationPhase'; +export type { + IReporterMajorDefaults, + IAutomaticSelectionContext +} from './migration/DaemonAlignedMajorDefaults'; +export { + REMOVED_TERMINAL_APIS, + PRE_FLIP_REPORTER_DEFAULTS, + DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS, + isTerminalApiRemoved, + isEmergencyLegacyFallback, + isAutomaticSelectionEnabled +} from './migration/DaemonAlignedMajorDefaults'; +export type { IPluginApplyGateOptions, IPluginApplyDecision } from './migration/PluginApplyGate'; +export { evaluatePluginApplyGate, getBlockedPlugins } from './migration/PluginApplyGate'; diff --git a/libraries/reporter/src/lifecycle/LifecycleEmitter.ts b/libraries/reporter/src/lifecycle/LifecycleEmitter.ts index af2f1b9d23..00497c69ec 100644 --- a/libraries/reporter/src/lifecycle/LifecycleEmitter.ts +++ b/libraries/reporter/src/lifecycle/LifecycleEmitter.ts @@ -97,8 +97,8 @@ export class LifecycleEmitter { public emitOperationRegistered(payload: IOperationRegisteredPayload): string { return this._emit('operationRegistered', payload, 'public', { operationId: payload.operationId, - projectName: payload.projectName, - phaseName: payload.phaseName + ...(payload.projectName === undefined ? {} : { projectName: payload.projectName }), + ...(payload.phaseName === undefined ? {} : { phaseName: payload.phaseName }) }); } diff --git a/libraries/reporter/src/lifecycle/LifecycleEvents.ts b/libraries/reporter/src/lifecycle/LifecycleEvents.ts index 4bf7c3f61d..e142e9ef32 100644 --- a/libraries/reporter/src/lifecycle/LifecycleEvents.ts +++ b/libraries/reporter/src/lifecycle/LifecycleEvents.ts @@ -8,6 +8,8 @@ */ export type OperationStatus = | 'ready' + | 'waiting' + | 'queued' | 'executing' | 'success' | 'successWithWarnings' @@ -15,7 +17,8 @@ export type OperationStatus = | 'blocked' | 'skipped' | 'fromCache' - | 'noOp'; + | 'noOp' + | 'aborted'; /** * The payload of a `sessionStarted` event. diff --git a/libraries/reporter/src/lifecycle/ShadowParity.ts b/libraries/reporter/src/lifecycle/ShadowParity.ts index d5b4604555..6aea5f08dd 100644 --- a/libraries/reporter/src/lifecycle/ShadowParity.ts +++ b/libraries/reporter/src/lifecycle/ShadowParity.ts @@ -46,23 +46,27 @@ export interface IShadowResultSummary { * @beta */ export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope[]): number { + let commandResult: ICommandResultPayload | undefined; for (const event of events) { - if (event.type === 'commandResult') { - const payload: ICommandResultPayload = event.payload as ICommandResultPayload; - if (payload.succeeded) { - return 0; - } - return payload.exitCode !== 0 ? payload.exitCode : 1; + 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.type === 'sessionCompleted') { - return (event.payload as { exitCode: number }).exitCode; + if (event.parentSessionId === undefined && event.type === 'sessionCompleted') { + sessionExitCode = (event.payload as { exitCode: number }).exitCode; } } - return 0; + return sessionExitCode ?? 0; } /** @@ -79,25 +83,34 @@ export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope export function summarizeShadowResult( events: readonly IReporterEventEnvelope[] ): IShadowResultSummary { - const operationCounts: { [status: string]: number } = {}; + const operationStatuses: Map = new Map(); let commandName: string | undefined; - let succeeded: boolean = true; + 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; - operationCounts[payload.status] = (operationCounts[payload.status] ?? 0) + 1; + operationStatuses.set(payload.operationId, payload.status); } else if (event.type === 'commandResult') { const payload: ICommandResultPayload = event.payload as ICommandResultPayload; commandName = payload.commandName; - succeeded = payload.succeeded; + 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, - exitCode: deriveExitCodeFromEvents(events), + succeeded: commandSucceeded ?? exitCode === 0, + exitCode, operationCounts }; } diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 6b7cfd3d27..657e88c17a 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -135,6 +135,9 @@ export class ReporterManager implements IReporterEventSink { this._ownedDestinations = new Set(); this._protocolVersion = protocolVersion; this._now = now; + if (!Number.isSafeInteger(coalesceThreshold) || coalesceThreshold < 1) { + throw new RangeError('coalesceThreshold must be a positive integer.'); + } this._coalesceThreshold = coalesceThreshold; this._emergencyDiagnosticWriter = emergencyDiagnosticWriter; this._nextSequence = 1; @@ -236,6 +239,24 @@ export class ReporterManager implements IReporterEventSink { return rehomed.eventId; } + /** + * Returns the total number of envelopes still buffered across all reporter + * queues. + * + * @remarks + * This is an observability hook for verifying bounded streaming. Each queue + * coalesces replaceable status events and applies synchronous backpressure at + * the configured threshold for protected events. After + * {@link ReporterManager.flushAsync} resolves it is `0`. + */ + public getPendingEventCount(): number { + let total: number = 0; + for (const entry of this._entries) { + total += entry.queue.length; + } + return total; + } + /** * Drains every reporter queue and flushes each reporter, bounded by a timeout. * @@ -320,6 +341,14 @@ export class ReporterManager implements IReporterEventSink { // coalesced or dropped. entry.queue[lastIndex] = envelope; } else { + if (entry.queue.length >= this._coalesceThreshold) { + const oldestEnvelope: IReporterEventEnvelope = entry.queue.shift()!; + this._deliverEnvelope(entry, oldestEnvelope); + if (entry.disabled) { + entry.queue.length = 0; + return; + } + } entry.queue.push(envelope); } @@ -333,14 +362,10 @@ export class ReporterManager implements IReporterEventSink { try { while (entry.queue.length > 0) { const envelope: IReporterEventEnvelope = entry.queue.shift()!; - try { - entry.reporter.report(envelope); - } catch (error) { - this._handleReporterFailure(entry, error as Error); - if (entry.disabled) { - entry.queue.length = 0; - break; - } + this._deliverEnvelope(entry, envelope); + if (entry.disabled) { + entry.queue.length = 0; + break; } // Yield so producers and coalescing can interleave with delivery. await Promise.resolve(); @@ -350,6 +375,14 @@ export class ReporterManager implements IReporterEventSink { } } + private _deliverEnvelope(entry: IReporterEntry, envelope: IReporterEventEnvelope): void { + try { + entry.reporter.report(envelope); + } catch (error) { + this._handleReporterFailure(entry, error as Error); + } + } + private _handleReporterFailure(entry: IReporterEntry, error: Error): void { if (entry.required) { if (!this._fatalError) { diff --git a/libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts b/libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts new file mode 100644 index 0000000000..67b129d94f --- /dev/null +++ b/libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The reporting defaults that change across a Rush major release boundary. + * + * @remarks + * The daemon-aligned major flips the default behavior described in specification + * §8.1 phase 6. Because every phase is revertible, both the pre-flip and + * post-flip default sets are represented as data so the flip can be rolled back + * to the previous phase's opt-in behavior by swapping the active default set. + * + * @beta + */ +export interface IReporterMajorDefaults { + /** + * Whether environment-based automatic reporter selection is active without an + * explicit opt-in. + */ + readonly automaticSelectionEnabledByDefault: boolean; + + /** + * The legacy terminal APIs removed in this major, for example + * `ILogger.terminal`. + */ + readonly removedTerminalApis: readonly string[]; + + /** + * Whether an incompatible plugin fails before its `apply()` runs. + */ + readonly gateIncompatiblePluginsBeforeApply: boolean; + + /** + * Whether the legacy renderer is still available. + */ + readonly legacyRendererRetained: boolean; + + /** + * Whether the legacy verbosity aliases (`--quiet`, `--verbose`, `--debug`) + * remain. + */ + readonly verbosityAliasesRetained: boolean; + + /** + * Whether the legacy `AlreadyReportedError` sentinel bridge remains. + */ + readonly sentinelBridgeRetained: boolean; + + /** + * The environment variable that forces the emergency legacy fallback. + */ + readonly emergencyFallbackEnvVar: string; + + /** + * The reporter name that the emergency fallback selects. + */ + readonly emergencyFallbackReporterName: string; +} + +/** + * The legacy terminal APIs removed by the daemon-aligned major, per + * specification §5.3. + * + * @beta + */ +export const REMOVED_TERMINAL_APIS: readonly string[] = ['ILogger.terminal', 'RushSession.terminalProvider']; + +/** + * The reporting defaults before the daemon-aligned major flip. + * + * @remarks + * Automatic selection is opt-in only, the legacy terminal APIs still exist, and + * incompatible plugins are not gated. The legacy renderer, verbosity aliases, + * and sentinel bridge are retained. Reverting the flip restores these defaults. + * + * @beta + */ +export const PRE_FLIP_REPORTER_DEFAULTS: IReporterMajorDefaults = { + automaticSelectionEnabledByDefault: false, + removedTerminalApis: [], + gateIncompatiblePluginsBeforeApply: false, + legacyRendererRetained: true, + verbosityAliasesRetained: true, + sentinelBridgeRetained: true, + emergencyFallbackEnvVar: 'RUSH_REPORTER', + emergencyFallbackReporterName: 'legacy' +}; + +/** + * The reporting defaults in the daemon-aligned major release. + * + * @remarks + * Automatic selection is enabled by default, the legacy terminal APIs are + * removed, and incompatible plugins fail before `apply()`. The legacy renderer, + * verbosity aliases, and sentinel bridge are still retained for this major; they + * are removed only in the later cleanup major. + * + * @beta + */ +export const DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS: IReporterMajorDefaults = { + automaticSelectionEnabledByDefault: true, + removedTerminalApis: REMOVED_TERMINAL_APIS, + gateIncompatiblePluginsBeforeApply: true, + legacyRendererRetained: true, + verbosityAliasesRetained: true, + sentinelBridgeRetained: true, + emergencyFallbackEnvVar: 'RUSH_REPORTER', + emergencyFallbackReporterName: 'legacy' +}; + +/** + * Returns `true` if the named legacy terminal API is removed under the given + * defaults. + * + * @param api - the API identifier, for example `ILogger.terminal` + * @param defaults - the defaults to check; defaults to + * {@link DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS} + * + * @beta + */ +export function isTerminalApiRemoved( + api: string, + defaults: IReporterMajorDefaults = DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS +): boolean { + return defaults.removedTerminalApis.indexOf(api) >= 0; +} + +/** + * Returns `true` if the environment requests the emergency legacy fallback, + * for example `RUSH_REPORTER=legacy`. + * + * @param env - the environment variables + * @param defaults - the defaults that name the fallback control; defaults to + * {@link DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS} + * + * @beta + */ +export function isEmergencyLegacyFallback( + env: Record, + defaults: IReporterMajorDefaults = DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS +): boolean { + const value: string | undefined = env[defaults.emergencyFallbackEnvVar]; + return ( + value !== undefined && + value.trim().toLowerCase() === defaults.emergencyFallbackReporterName.trim().toLowerCase() + ); +} + +/** + * The context used to decide whether automatic reporter selection runs. + * + * @beta + */ +export interface IAutomaticSelectionContext { + /** + * Whether the user explicitly opted in, for example via `--reporter` or + * `RUSH_REPORTER`. + */ + readonly explicitOptIn?: boolean; + + /** + * Whether the experimental repository setting enabled the new reporter path. + */ + readonly experimentalSettingEnabled?: boolean; + + /** + * Whether the emergency legacy fallback is active. + */ + readonly emergencyLegacyFallback?: boolean; +} + +/** + * Determines whether environment-based automatic reporter selection should run. + * + * @remarks + * The emergency legacy fallback always wins. Otherwise, once the daemon-aligned + * major has flipped the default, automatic selection runs unconditionally; before + * the flip it runs only when the user opted in explicitly or through the + * experimental setting. + * + * @param defaults - the active major defaults + * @param context - the opt-in and fallback context + * + * @beta + */ +export function isAutomaticSelectionEnabled( + defaults: IReporterMajorDefaults, + context: IAutomaticSelectionContext = {} +): boolean { + if (context.emergencyLegacyFallback) { + return false; + } + if (defaults.automaticSelectionEnabledByDefault) { + return true; + } + return Boolean(context.explicitOptIn || context.experimentalSettingEnabled); +} diff --git a/libraries/reporter/src/migration/MigrationPhase.ts b/libraries/reporter/src/migration/MigrationPhase.ts new file mode 100644 index 0000000000..91a92f8659 --- /dev/null +++ b/libraries/reporter/src/migration/MigrationPhase.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The identifier of a reporter-overhaul migration phase. + * + * @remarks + * The phases correspond to specification §8.1 "Migration Phases" and run in + * order. The daemon-aligned major default flip is the phase with id + * `daemonAlignedMajorFlip`. + * + * @beta + */ +export type ReporterMigrationPhaseId = + | 'contractsAndBaselines' + | 'bootstrapAndCompatAdapters' + | 'shadowStructuredEmission' + | 'optInReporters' + | 'heftProtocolTrack' + | 'daemonAlignedMajorFlip' + | 'laterCleanupMajor'; + +/** + * A single reporter-overhaul migration phase. + * + * @beta + */ +export interface IReporterMigrationPhase { + /** + * The phase identifier. + */ + readonly id: ReporterMigrationPhaseId; + + /** + * The 1-based order in which the phase ships. + */ + readonly ordinal: number; + + /** + * The human-readable phase title. + */ + readonly title: string; + + /** + * A short description of the phase's scope. + */ + readonly summary: string; + + /** + * Whether the phase can ship on its own, independent of later phases. + * + * @remarks + * Every phase is independently releasable, per specification §8.1. + */ + readonly independentlyReleasable: boolean; + + /** + * Whether the phase can be reverted without reverting earlier phases. + * + * @remarks + * Every phase is revertible, per specification §8.1. + */ + readonly revertible: boolean; +} + +/** + * The ordered reporter-overhaul migration phases from specification §8.1. + * + * @remarks + * Every phase is independently releasable and revertible, so a regression in a + * later phase never forces reverting an earlier one and the daemon-aligned major + * flip can be rolled back to the opt-in behavior of the previous phase. + * + * @beta + */ +export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[] = [ + { + id: 'contractsAndBaselines', + ordinal: 1, + title: 'Contracts and baselines', + summary: + 'Publish @rushstack/rush-reporter, freeze legacy snapshots, add protocol and compatibility goldens.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'bootstrapAndCompatAdapters', + ordinal: 2, + title: 'Bootstrap and compatibility adapters', + summary: + 'Add two-stage initialization and cross-version fallback while legacy rendering stays the sole visible output.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'shadowStructuredEmission', + ordinal: 3, + title: 'Shadow structured emission', + summary: 'Emit first-party lifecycle and diagnostic events without changing output; validate parity.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'optInReporters', + ordinal: 4, + title: 'Opt-in reporters', + summary: + 'Add file, plaintext, json, default, and ai reporters behind explicit CLI and an experimental setting.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'heftProtocolTrack', + ordinal: 5, + title: 'Heft protocol track', + summary: 'Support negotiated child descriptors and keep raw-stream compatibility for older Heft.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'daemonAlignedMajorFlip', + ordinal: 6, + title: 'Daemon-aligned major default flip', + summary: + 'Enable environment-based automatic selection by default, remove legacy terminal APIs, and gate ' + + 'incompatible plugins before apply() while retaining the legacy renderer, aliases, and sentinel bridge.', + independentlyReleasable: true, + revertible: true + }, + { + id: 'laterCleanupMajor', + ordinal: 7, + title: 'Later cleanup major', + summary: + 'Remove the legacy renderer and the AlreadyReportedError bridge after a full major of default use and ' + + 'documented migration.', + independentlyReleasable: true, + revertible: true + } +]; + +/** + * Returns the migration phase with the given identifier. + * + * @param id - the phase identifier + * @throws if the identifier is unknown + * + * @beta + */ +export function getReporterMigrationPhase(id: ReporterMigrationPhaseId): IReporterMigrationPhase { + const phase: IReporterMigrationPhase | undefined = REPORTER_MIGRATION_PHASES.find( + (candidate: IReporterMigrationPhase) => candidate.id === id + ); + if (phase === undefined) { + throw new Error(`Unknown reporter migration phase: ${JSON.stringify(id)}`); + } + return phase; +} diff --git a/libraries/reporter/src/migration/PluginApplyGate.ts b/libraries/reporter/src/migration/PluginApplyGate.ts new file mode 100644 index 0000000000..6a1d8959d6 --- /dev/null +++ b/libraries/reporter/src/migration/PluginApplyGate.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { + createPluginApiIncompatibleDiagnostic, + isRushVersionSupported, + type IRushPluginManifest +} from '../session/PluginApi'; + +/** + * Options for evaluating the plugin apply gate. + * + * @beta + */ +export interface IPluginApplyGateOptions { + /** + * Whether incompatible plugins are blocked. Defaults to `true`, matching the + * daemon-aligned major. Set to `false` to model the pre-flip behavior, where + * incompatible plugins are permitted. + */ + readonly gateEnabled?: boolean; + + /** + * The running Rush version used to evaluate each plugin's declared range. + */ + readonly rushVersion: string; +} + +/** + * The gate decision for a single plugin. + * + * @beta + */ +export interface IPluginApplyDecision { + /** + * The plugin manifest that was evaluated. + */ + readonly manifest: IRushPluginManifest; + + /** + * Whether the plugin's `apply()` is allowed to run. + */ + readonly allowed: boolean; + + /** + * The structured migration diagnostic explaining why the plugin was blocked, + * present only when `allowed` is `false`. + */ + readonly diagnostic?: IRushDiagnostic; +} + +/** + * Evaluates the plugin apply gate for a set of plugin manifests before any + * `apply()` runs. + * + * @remarks + * In the daemon-aligned major an incompatible plugin fails before `apply()` with + * a structured migration diagnostic. When the gate is disabled (the pre-flip + * behavior), every plugin is permitted so the phase remains revertible. + * + * @param manifests - the plugin manifests to evaluate + * @param options - the gate options + * + * @beta + */ +export function evaluatePluginApplyGate( + manifests: readonly IRushPluginManifest[], + options: IPluginApplyGateOptions +): IPluginApplyDecision[] { + const gateEnabled: boolean = options.gateEnabled ?? true; + + return manifests.map((manifest: IRushPluginManifest): IPluginApplyDecision => { + const compatible: boolean = isRushVersionSupported(manifest.rushVersionRange, options.rushVersion); + if (compatible || !gateEnabled) { + return { manifest, allowed: true }; + } + return { + manifest, + allowed: false, + diagnostic: createPluginApiIncompatibleDiagnostic(manifest, options.rushVersion) + }; + }); +} + +/** + * Filters a set of gate decisions down to the plugins that were blocked before + * `apply()`. + * + * @param decisions - the decisions returned by {@link evaluatePluginApplyGate} + * + * @beta + */ +export function getBlockedPlugins(decisions: readonly IPluginApplyDecision[]): IPluginApplyDecision[] { + return decisions.filter((decision: IPluginApplyDecision) => !decision.allowed); +} diff --git a/libraries/reporter/src/perf/PerformanceBudgets.ts b/libraries/reporter/src/perf/PerformanceBudgets.ts new file mode 100644 index 0000000000..ab9a4c4647 --- /dev/null +++ b/libraries/reporter/src/perf/PerformanceBudgets.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The blocking performance and capacity budgets that the reporter subsystem must + * respect. These are the P0 acceptance thresholds from the Rush Reporter + * Overhaul specification (§7.3 "Performance and Capacity"). + * + * @remarks + * The budgets are expressed as hard ceilings: a candidate build satisfies the + * budget when its measured value is less than or equal to the corresponding + * ceiling. They are surfaced as data (rather than hard-coded at each call site) + * so benchmark harnesses, capacity tests, and reporters can share a single + * source of truth. + * + * @beta + */ +export interface IReporterPerformanceBudgets { + /** + * The maximum acceptable representative-build wall-time regression, expressed + * as a percentage of the pre-reporter baseline. Defaults to `3`. + */ + readonly maxWallTimeRegressionPercent: number; + + /** + * The maximum acceptable additional peak resident memory attributable to the + * reporter subsystem, in bytes. Defaults to 32 MiB. + */ + readonly maxAdditionalPeakMemoryBytes: number; + + /** + * The maximum interactive live-region refresh rate, in hertz. Defaults to + * `10` (a 100 ms minimum repaint interval). + */ + readonly maxInteractiveRefreshHz: number; + + /** + * The maximum size of a single AI reporter payload, in bytes. Defaults to + * 64 KiB. + */ + readonly maxAiOutputBytes: number; + + /** + * The maximum number of fully-detailed diagnostics an AI reporter emits + * before summarizing the remainder. Defaults to `20`. + */ + readonly maxAiDetailedDiagnostics: number; +} + +/** + * One mebibyte, in bytes. + */ +const BYTES_PER_MIB: number = 1024 * 1024; + +/** + * One kibibyte, in bytes. + */ +const BYTES_PER_KIB: number = 1024; + +/** + * The default reporter performance and capacity budgets from specification + * §7.3. + * + * @beta + */ +export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets = { + maxWallTimeRegressionPercent: 3, + maxAdditionalPeakMemoryBytes: 32 * BYTES_PER_MIB, + maxInteractiveRefreshHz: 10, + maxAiOutputBytes: 64 * BYTES_PER_KIB, + maxAiDetailedDiagnostics: 20 +}; + +/** + * Computes the wall-time regression of a candidate measurement relative to a + * baseline, expressed as a percentage. + * + * @remarks + * A positive result denotes a slowdown; a negative result denotes a speedup. + * + * @param baselineMs - the baseline wall-time in milliseconds; must be greater + * than zero + * @param candidateMs - the candidate wall-time in milliseconds + * @returns the regression as a percentage of the baseline + * + * @beta + */ +export function computeWallTimeRegressionPercent(baselineMs: number, candidateMs: number): number { + if (!(baselineMs > 0)) { + throw new Error('baselineMs must be greater than zero'); + } + return ((candidateMs - baselineMs) / baselineMs) * 100; +} + +/** + * Determines whether a candidate wall-time stays within the wall-time + * regression budget. + * + * @param baselineMs - the baseline wall-time in milliseconds + * @param candidateMs - the candidate wall-time in milliseconds + * @param budgets - the budgets to check against; defaults to + * {@link REPORTER_PERFORMANCE_BUDGETS} + * + * @beta + */ +export function isWithinWallTimeBudget( + baselineMs: number, + candidateMs: number, + budgets: IReporterPerformanceBudgets = REPORTER_PERFORMANCE_BUDGETS +): boolean { + return computeWallTimeRegressionPercent(baselineMs, candidateMs) <= budgets.maxWallTimeRegressionPercent; +} + +/** + * Determines whether an additional peak-memory measurement stays within the + * memory budget. + * + * @param additionalPeakBytes - the additional peak memory attributable to the + * reporter subsystem, in bytes + * @param budgets - the budgets to check against; defaults to + * {@link REPORTER_PERFORMANCE_BUDGETS} + * + * @beta + */ +export function isWithinMemoryBudget( + additionalPeakBytes: number, + budgets: IReporterPerformanceBudgets = REPORTER_PERFORMANCE_BUDGETS +): boolean { + return additionalPeakBytes <= budgets.maxAdditionalPeakMemoryBytes; +} diff --git a/libraries/reporter/src/reporters/AiReporter.ts b/libraries/reporter/src/reporters/AiReporter.ts index f6df4fc6a7..fc885273dc 100644 --- a/libraries/reporter/src/reporters/AiReporter.ts +++ b/libraries/reporter/src/reporters/AiReporter.ts @@ -5,10 +5,9 @@ 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_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; 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', @@ -127,8 +126,9 @@ export class AiReporter implements IReporter { public constructor(options: IAiReporterOptions) { this._write = options.write; - this._maxBytes = options.maxBytes ?? DEFAULT_AI_MAX_BYTES; - this._maxDetailedDiagnostics = options.maxDetailedDiagnostics ?? DEFAULT_AI_MAX_DETAILED_DIAGNOSTICS; + this._maxBytes = options.maxBytes ?? REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes; + this._maxDetailedDiagnostics = + options.maxDetailedDiagnostics ?? REPORTER_PERFORMANCE_BUDGETS.maxAiDetailedDiagnostics; 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}`); } diff --git a/libraries/reporter/src/reporters/InteractiveRendering.ts b/libraries/reporter/src/reporters/InteractiveRendering.ts index f9374bc074..1240562cc7 100644 --- a/libraries/reporter/src/reporters/InteractiveRendering.ts +++ b/libraries/reporter/src/reporters/InteractiveRendering.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; + /** * The spinner frames used by the interactive live region. * @@ -13,7 +15,7 @@ export const SPINNER_FRAMES: readonly string[] = ['⠋', '⠙', '⠹', '⠸', ' * * @beta */ -export const MIN_REFRESH_INTERVAL_MS: number = 100; +export const MIN_REFRESH_INTERVAL_MS: number = 1000 / REPORTER_PERFORMANCE_BUDGETS.maxInteractiveRefreshHz; /** * The snapshot of live state rendered into the three-row region. diff --git a/libraries/reporter/src/session/PluginApi.ts b/libraries/reporter/src/session/PluginApi.ts index bd24308c43..be7b40e4d5 100644 --- a/libraries/reporter/src/session/PluginApi.ts +++ b/libraries/reporter/src/session/PluginApi.ts @@ -1,20 +1,11 @@ // 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 Rush plugin API version this package implements. - * - * @remarks - * A plugin manifest declares the plugin API version it targets. Compatibility is - * gated on the major version. - * - * @beta - */ -export const RUSH_PLUGIN_API_VERSION: '1.0.0' = '1.0.0'; - /** * The reporting-relevant fields of a Rush plugin manifest. * @@ -27,34 +18,30 @@ export interface IRushPluginManifest { readonly pluginName: string; /** - * The Rush plugin API version the plugin targets, for example `1.0.0`. + * The semver range of Rush versions supported by the plugin. */ - readonly pluginApiVersion: string; -} - -function majorOf(version: string): number { - return Number.parseInt(version.split('.')[0], 10); + readonly rushVersionRange: string; } /** - * Returns `true` if a plugin's declared API version is supported. - * - * @remarks - * Compatibility requires an equal major version. + * Returns `true` if the running Rush version satisfies a plugin's declared range. * - * @param declaredApiVersion - the version declared by the plugin manifest - * @param supportedApiVersion - the version supported by Rush; defaults to - * {@link RUSH_PLUGIN_API_VERSION} + * @param rushVersionRange - the semver range declared by the plugin manifest + * @param rushVersion - the running Rush version * * @beta */ -export function isPluginApiVersionSupported( - declaredApiVersion: string, - supportedApiVersion: string = RUSH_PLUGIN_API_VERSION -): boolean { - const declaredMajor: number = majorOf(declaredApiVersion); - const supportedMajor: number = majorOf(supportedApiVersion); - return Number.isFinite(declaredMajor) && declaredMajor === supportedMajor; +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 }) + ); } /** @@ -65,15 +52,19 @@ export function isPluginApiVersionSupported( * emitted at that boundary. * * @param manifest - the incompatible plugin's manifest + * @param rushVersion - the running Rush version * * @beta */ -export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic { +export function createPluginApiIncompatibleDiagnostic( + manifest: IRushPluginManifest, + rushVersion: string +): IRushDiagnostic { return createRushDiagnostic('RUSH_PLUGIN_API_INCOMPATIBLE', { parameters: { pluginName: { value: manifest.pluginName, privacy: 'public' }, - declaredApiVersion: { value: manifest.pluginApiVersion, privacy: 'public' }, - supportedApiVersion: { value: RUSH_PLUGIN_API_VERSION, privacy: 'public' } + rushVersionRange: { value: manifest.rushVersionRange, privacy: 'public' }, + rushVersion: { value: rushVersion, privacy: 'public' } } }); } diff --git a/libraries/reporter/src/session/ScopedReporterFactory.ts b/libraries/reporter/src/session/ScopedReporterFactory.ts index 4cdd251752..ebf3161ee7 100644 --- a/libraries/reporter/src/session/ScopedReporterFactory.ts +++ b/libraries/reporter/src/session/ScopedReporterFactory.ts @@ -102,7 +102,8 @@ export function createScopedReporter(options: ICreateScopedReporterOptions): ISc sessionId, source, scope, - privacy: 'public', + // 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 index f2195748a1..fe5f4876f5 100644 --- a/libraries/reporter/src/telemetry/BeforeLogAdapter.ts +++ b/libraries/reporter/src/telemetry/BeforeLogAdapter.ts @@ -19,8 +19,11 @@ export type LegacyBeforeLogHook = (telemetry: Record) => void; * * @remarks * During migration the existing `beforeLog` hook is preserved: the adapter runs - * each legacy hook with a plain-object copy of the new aggregate, so no hook - * observes non-allowlisted data. + * 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 * @@ -28,11 +31,33 @@ export type LegacyBeforeLogHook = (telemetry: Record) => void; */ export function createBeforeLogAdapter( hooks: readonly LegacyBeforeLogHook[] -): (aggregate: ITelemetryAggregate) => void { - return (aggregate: ITelemetryAggregate): void => { - const record: Record = { ...aggregate }; +): (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/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts index fa38ffc7b2..48b45f5098 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -4,6 +4,7 @@ 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'; /** @@ -24,13 +25,13 @@ export class TelemetrySubscriber { private _durationMs: number | undefined; private _reporterMode: string | undefined; private _protocolVersion: IReporterProtocolVersion | undefined; - private readonly _operationStatusCounts: { [status: string]: number }; + private readonly _operationStatuses: Map; private readonly _diagnosticCategoryCounts: { [category: string]: number }; private readonly _diagnosticCodes: Set; private readonly _producerVersions: Set; public constructor() { - this._operationStatusCounts = {}; + this._operationStatuses = new Map(); this._diagnosticCategoryCounts = {}; this._diagnosticCodes = new Set(); this._producerVersions = new Set(); @@ -52,11 +53,17 @@ export class TelemetrySubscriber { 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; @@ -68,28 +75,43 @@ export class TelemetrySubscriber { break; } case 'commandCompleted': { - const payload: { durationMs?: number } = event.payload as { durationMs?: number }; + 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; }; - if (this._exitCode === undefined) { - this._exitCode = payload.exitCode; - } + this._exitCode = payload.exitCode; + this._result = payload.exitCode === 0 ? 'succeeded' : 'failed'; if (payload.durationMs !== undefined) { this._durationMs = payload.durationMs; } break; } case 'operationStatusChanged': { - const status: string = (event.payload as { status: string }).status; - this._operationStatusCounts[status] = (this._operationStatusCounts[status] ?? 0) + 1; + if (event.parentSessionId !== undefined) { + break; + } + const payload: IOperationStatusChangedPayload = event.payload as IOperationStatusChangedPayload; + this._operationStatuses.set(payload.operationId, payload.status); break; } case 'diagnosticEmitted': { @@ -119,6 +141,11 @@ export class TelemetrySubscriber { * 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; @@ -131,7 +158,7 @@ export class TelemetrySubscriber { protocolVersion?: IReporterProtocolVersion; producerVersions: string[]; } = { - operationStatusCounts: { ...this._operationStatusCounts }, + operationStatusCounts, diagnosticCodes: [...this._diagnosticCodes].sort(), diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, producerVersions: [...this._producerVersions].sort() diff --git a/libraries/reporter/src/test/ExitStatus.test.ts b/libraries/reporter/src/test/ExitStatus.test.ts index 83942e5ff6..d8424effed 100644 --- a/libraries/reporter/src/test/ExitStatus.test.ts +++ b/libraries/reporter/src/test/ExitStatus.test.ts @@ -77,6 +77,28 @@ describe('resolveExitStatusFromEvents', () => { 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', () => { diff --git a/libraries/reporter/src/test/Lifecycle.test.ts b/libraries/reporter/src/test/Lifecycle.test.ts index b9aa63fec0..a5cf495bf8 100644 --- a/libraries/reporter/src/test/Lifecycle.test.ts +++ b/libraries/reporter/src/test/Lifecycle.test.ts @@ -47,8 +47,12 @@ class RecordingReporter implements IReporter { const SOURCE: IReporterEventSource = { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }; -function ev(type: string, payload: unknown): IReporterEventEnvelope { - return { type, payload } as unknown as IReporterEventEnvelope; +function ev( + type: string, + payload: unknown, + envelope: Partial> = {} +): IReporterEventEnvelope { + return { type, payload, ...envelope } as unknown as IReporterEventEnvelope; } describe('LifecycleEmitter', () => { @@ -74,6 +78,25 @@ describe('LifecycleEmitter', () => { }); }); + 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 }); @@ -149,6 +172,59 @@ describe('summarizeShadowResult', () => { 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', () => { diff --git a/libraries/reporter/src/test/Migration.test.ts b/libraries/reporter/src/test/Migration.test.ts new file mode 100644 index 0000000000..92b324a954 --- /dev/null +++ b/libraries/reporter/src/test/Migration.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + REPORTER_MIGRATION_PHASES, + getReporterMigrationPhase, + REMOVED_TERMINAL_APIS, + PRE_FLIP_REPORTER_DEFAULTS, + DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS, + isTerminalApiRemoved, + isEmergencyLegacyFallback, + isAutomaticSelectionEnabled, + evaluatePluginApplyGate, + getBlockedPlugins, + type ReporterMigrationPhaseId, + type IReporterMigrationPhase, + type IPluginApplyDecision, + type IRushPluginManifest +} from '../index'; + +describe('reporter migration phases', () => { + it('lists the seven specification §8.1 phases in order', () => { + expect(REPORTER_MIGRATION_PHASES.map((phase: IReporterMigrationPhase) => phase.id)).toEqual([ + 'contractsAndBaselines', + 'bootstrapAndCompatAdapters', + 'shadowStructuredEmission', + 'optInReporters', + 'heftProtocolTrack', + 'daemonAlignedMajorFlip', + 'laterCleanupMajor' + ]); + REPORTER_MIGRATION_PHASES.forEach((phase: IReporterMigrationPhase, index: number) => { + expect(phase.ordinal).toBe(index + 1); + }); + }); + + it('keeps every phase independently releasable and revertible', () => { + for (const phase of REPORTER_MIGRATION_PHASES) { + expect(phase.independentlyReleasable).toBe(true); + expect(phase.revertible).toBe(true); + } + }); + + it('resolves a phase by id and throws for an unknown id', () => { + expect(getReporterMigrationPhase('daemonAlignedMajorFlip').ordinal).toBe(6); + expect(() => getReporterMigrationPhase('nope' as unknown as ReporterMigrationPhaseId)).toThrow( + /Unknown reporter migration phase/ + ); + }); +}); + +describe('daemon-aligned major default flip', () => { + it('enables environment-based automatic selection by default after the flip', () => { + expect(DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS.automaticSelectionEnabledByDefault).toBe(true); + expect(PRE_FLIP_REPORTER_DEFAULTS.automaticSelectionEnabledByDefault).toBe(false); + + // After the flip, automatic selection runs with no explicit opt-in. + expect(isAutomaticSelectionEnabled(DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS)).toBe(true); + + // Before the flip, it runs only on explicit opt-in or the experimental setting. + expect(isAutomaticSelectionEnabled(PRE_FLIP_REPORTER_DEFAULTS)).toBe(false); + expect(isAutomaticSelectionEnabled(PRE_FLIP_REPORTER_DEFAULTS, { explicitOptIn: true })).toBe(true); + expect( + isAutomaticSelectionEnabled(PRE_FLIP_REPORTER_DEFAULTS, { experimentalSettingEnabled: true }) + ).toBe(true); + }); + + it('keeps the emergency legacy fallback overriding the flipped default', () => { + expect(isEmergencyLegacyFallback({ RUSH_REPORTER: 'legacy' })).toBe(true); + expect(isEmergencyLegacyFallback({ RUSH_REPORTER: 'LEGACY' })).toBe(true); + expect(isEmergencyLegacyFallback({ RUSH_REPORTER: ' legacy ' })).toBe(true); + expect(isEmergencyLegacyFallback({ RUSH_REPORTER: 'default' })).toBe(false); + expect(isEmergencyLegacyFallback({})).toBe(false); + + // The fallback wins even when the flipped default would enable automatic selection. + expect( + isAutomaticSelectionEnabled(DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS, { emergencyLegacyFallback: true }) + ).toBe(false); + }); + + it('removes the legacy terminal APIs in the daemon-aligned major but not before', () => { + expect(REMOVED_TERMINAL_APIS).toEqual(['ILogger.terminal', 'RushSession.terminalProvider']); + expect(DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS.removedTerminalApis).toEqual(REMOVED_TERMINAL_APIS); + expect(PRE_FLIP_REPORTER_DEFAULTS.removedTerminalApis).toEqual([]); + + expect(isTerminalApiRemoved('ILogger.terminal')).toBe(true); + expect(isTerminalApiRemoved('RushSession.terminalProvider')).toBe(true); + expect(isTerminalApiRemoved('ILogger.terminal', PRE_FLIP_REPORTER_DEFAULTS)).toBe(false); + expect(isTerminalApiRemoved('ISomethingElse')).toBe(false); + }); + + it('retains the legacy renderer, verbosity aliases, and sentinel bridge across the flip', () => { + for (const defaults of [PRE_FLIP_REPORTER_DEFAULTS, DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS]) { + expect(defaults.legacyRendererRetained).toBe(true); + expect(defaults.verbosityAliasesRetained).toBe(true); + expect(defaults.sentinelBridgeRetained).toBe(true); + expect(defaults.emergencyFallbackEnvVar).toBe('RUSH_REPORTER'); + expect(defaults.emergencyFallbackReporterName).toBe('legacy'); + } + }); +}); + +describe('plugin apply gate', () => { + const compatible: IRushPluginManifest = { pluginName: 'good', rushVersionRange: '>=5 <6' }; + const incompatible: IRushPluginManifest = { pluginName: 'bad', rushVersionRange: '>=6 <7' }; + + it('fails incompatible plugins with a structured migration diagnostic before apply()', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([compatible, incompatible], { + rushVersion: '5.178.1' + }); + + const good: IPluginApplyDecision = decisions[0]; + expect(good.allowed).toBe(true); + expect(good.diagnostic).toBeUndefined(); + + const bad: IPluginApplyDecision = decisions[1]; + expect(bad.allowed).toBe(false); + expect(bad.diagnostic?.code).toBe('RUSH_PLUGIN_API_INCOMPATIBLE'); + + const blocked: IPluginApplyDecision[] = getBlockedPlugins(decisions); + expect(blocked).toHaveLength(1); + expect(blocked[0].manifest.pluginName).toBe('bad'); + }); + + it('permits incompatible plugins when the gate is disabled, keeping the phase revertible', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([compatible, incompatible], { + gateEnabled: false, + rushVersion: '5.178.1' + }); + + expect(decisions.every((decision: IPluginApplyDecision) => decision.allowed)).toBe(true); + expect(getBlockedPlugins(decisions)).toHaveLength(0); + }); + + it('honors the running Rush version', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([incompatible], { + rushVersion: '6.0.0' + }); + expect(decisions[0].allowed).toBe(true); + }); + + it('reports the running Rush version when blocking a plugin', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([compatible], { + rushVersion: '6.0.0' + }); + + expect(decisions[0].allowed).toBe(false); + expect(decisions[0].diagnostic?.parameters?.rushVersion.value).toBe('6.0.0'); + }); +}); diff --git a/libraries/reporter/src/test/Performance.test.ts b/libraries/reporter/src/test/Performance.test.ts new file mode 100644 index 0000000000..47c50adf89 --- /dev/null +++ b/libraries/reporter/src/test/Performance.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import { + ReporterManager, + REPORTER_PERFORMANCE_BUDGETS, + computeWallTimeRegressionPercent, + isWithinWallTimeBudget, + isWithinMemoryBudget, + type IReporter, + type IReporterEmitEventInput, + type IReporterEventEnvelope, + type ReporterEventType, + type ReporterJsonValue +} from '../index'; + +class CountingReporter implements IReporter { + public readonly name: string; + public readonly counts: Map = new Map(); + public total: number = 0; + + public constructor(name: string) { + this.name = name; + } + + public async initializeAsync(): Promise { + /* no-op */ + } + + public report(event: IReporterEventEnvelope): void { + this.counts.set(event.type, (this.counts.get(event.type) ?? 0) + 1); + this.total++; + } + + public async flushAsync(): Promise { + /* no-op */ + } + + public async closeAsync(): Promise { + /* no-op */ + } +} + +function makeInput( + type: ReporterEventType, + payload: ReporterJsonValue = {} +): IReporterEmitEventInput { + return { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + type, + payload + }; +} + +// Representatives of every protected outcome category from specification §7.3: +// lifecycle, diagnostics, results, artifacts, and external output. +const PROTECTED_TYPES: readonly ReporterEventType[] = [ + 'sessionCompleted', + 'operationStatusChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'commandResult', + 'artifactAvailable', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted' +]; + +interface IWorkloadMeasurement { + readonly elapsedMs: number; + readonly peakRssBytes: number; + readonly deliveredEvents: number; +} + +const REPRESENTATIVE_WORK_UNIT: Buffer = Buffer.alloc(8 * 1024 * 1024, 0x5a); +const REPRESENTATIVE_OPERATION_COUNT: number = 64; + +async function measureRepresentativeWorkload(enableReporter: boolean): Promise { + let manager: ReporterManager | undefined; + let reporter: CountingReporter | undefined; + if (enableReporter) { + manager = new ReporterManager(); + reporter = new CountingReporter('benchmark'); + manager.addReporter(reporter); + await manager.initializeAsync(); + } + + let peakRssBytes: number = process.memoryUsage().rss; + const startMs: number = performance.now(); + for (let i: number = 0; i < REPRESENTATIVE_OPERATION_COUNT; i++) { + createHash('sha256').update(REPRESENTATIVE_WORK_UNIT).update(String(i)).digest(); + manager?.emit(makeInput('operationStatusChanged', { operationId: `op-${i}`, status: 'success' })); + if (i % 16 === 0) { + peakRssBytes = Math.max(peakRssBytes, process.memoryUsage().rss); + await Promise.resolve(); + } + } + await manager?.flushAsync(); + peakRssBytes = Math.max(peakRssBytes, process.memoryUsage().rss); + + return { + elapsedMs: performance.now() - startMs, + peakRssBytes, + deliveredEvents: reporter?.total ?? 0 + }; +} + +describe('reporter performance budgets', () => { + it('exposes the specification §7.3 blocking budgets', () => { + expect(REPORTER_PERFORMANCE_BUDGETS.maxWallTimeRegressionPercent).toBe(3); + expect(REPORTER_PERFORMANCE_BUDGETS.maxAdditionalPeakMemoryBytes).toBe(32 * 1024 * 1024); + expect(REPORTER_PERFORMANCE_BUDGETS.maxInteractiveRefreshHz).toBe(10); + expect(REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes).toBe(64 * 1024); + expect(REPORTER_PERFORMANCE_BUDGETS.maxAiDetailedDiagnostics).toBe(20); + }); + + it('evaluates wall-time regression against the 3 percent budget', () => { + expect(computeWallTimeRegressionPercent(1000, 1020)).toBeCloseTo(2, 5); + expect(computeWallTimeRegressionPercent(1000, 980)).toBeCloseTo(-2, 5); + expect(isWithinWallTimeBudget(1000, 1030)).toBe(true); + expect(isWithinWallTimeBudget(1000, 1031)).toBe(false); + expect(() => computeWallTimeRegressionPercent(0, 10)).toThrow(); + }); + + it('evaluates additional peak memory against the 32 MiB budget', () => { + expect(isWithinMemoryBudget(31 * 1024 * 1024)).toBe(true); + expect(isWithinMemoryBudget(32 * 1024 * 1024)).toBe(true); + expect(isWithinMemoryBudget(33 * 1024 * 1024)).toBe(false); + }); + + it('measures representative baseline and reporter peak RSS within the 32 MiB budget', async () => { + const baseline: IWorkloadMeasurement = await measureRepresentativeWorkload(false); + const candidate: IWorkloadMeasurement = await measureRepresentativeWorkload(true); + const additionalPeakBytes: number = Math.max(0, candidate.peakRssBytes - baseline.peakRssBytes); + + expect(candidate.deliveredEvents).toBe(REPRESENTATIVE_OPERATION_COUNT); + expect(isWithinMemoryBudget(additionalPeakBytes)).toBe(true); + }); +}); + +describe('reporter bounded streaming', () => { + it('keeps the pending queue bounded during a large synchronous burst', async () => { + const manager: ReporterManager = new ReporterManager({ coalesceThreshold: 64 }); + const reporter: CountingReporter = new CountingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const burst: number = 5000; + for (let i: number = 0; i < burst; i++) { + manager.emit(makeInput('activityChanged', { i })); + } + + // No microtask has run yet. Coalescing keeps replaceable status noise near + // the threshold instead of buffering the whole build. + const pendingDuringBurst: number = manager.getPendingEventCount(); + expect(pendingDuringBurst).toBeLessThanOrEqual(64); + + await manager.flushAsync(); + expect(manager.getPendingEventCount()).toBe(0); + }); + + it('keeps a representative reporter workload within the wall-time regression budget', async () => { + const measurementPairs: { baselineMs: number; candidateMs: number }[] = []; + for (let sample: number = 0; sample < 7; sample++) { + const candidateFirst: boolean = sample % 2 === 1; + const first: IWorkloadMeasurement = await measureRepresentativeWorkload(candidateFirst); + const second: IWorkloadMeasurement = await measureRepresentativeWorkload(!candidateFirst); + const baseline: IWorkloadMeasurement = candidateFirst ? second : first; + const candidate: IWorkloadMeasurement = candidateFirst ? first : second; + expect(candidate.deliveredEvents).toBe(REPRESENTATIVE_OPERATION_COUNT); + measurementPairs.push({ baselineMs: baseline.elapsedMs, candidateMs: candidate.elapsedMs }); + } + + measurementPairs.sort( + (a, b) => + computeWallTimeRegressionPercent(a.baselineMs, a.candidateMs) - + computeWallTimeRegressionPercent(b.baselineMs, b.candidateMs) + ); + // Shared CI runners introduce large scheduling outliers. The least-contended + // pair still enforces the budget unless every candidate measurement regresses. + const leastContendedPair: { baselineMs: number; candidateMs: number } = measurementPairs[0]; + expect(isWithinWallTimeBudget(leastContendedPair.baselineMs, leastContendedPair.candidateMs)).toBe(true); + }); +}); + +describe('reporter queue pressure', () => { + it('preserves every protected outcome category while coalescing status noise', async () => { + const manager: ReporterManager = new ReporterManager({ coalesceThreshold: 8 }); + const reporter: CountingReporter = new CountingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const activityCount: number = 3000; + let protectedBatches: number = 0; + for (let i: number = 0; i < activityCount; i++) { + manager.emit(makeInput('activityChanged', { i })); + if (i % 300 === 0) { + for (const type of PROTECTED_TYPES) { + manager.emit(makeInput(type, { i })); + } + protectedBatches++; + } + } + await manager.flushAsync(); + + // Every protected event of every category is delivered exactly once per batch. + for (const type of PROTECTED_TYPES) { + expect(reporter.counts.get(type) ?? 0).toBe(protectedBatches); + } + + // Replaceable status noise is coalesced under pressure: fewer than emitted, + // but never fully suppressed. + const deliveredActivity: number = reporter.counts.get('activityChanged') ?? 0; + expect(deliveredActivity).toBeGreaterThan(0); + expect(deliveredActivity).toBeLessThan(activityCount); + expect(manager.getPendingEventCount()).toBe(0); + }); + + it('never coalesces required status events', async () => { + const manager: ReporterManager = new ReporterManager({ coalesceThreshold: 8 }); + const reporter: CountingReporter = new CountingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const protectedCount: number = 250; + for (let i: number = 0; i < protectedCount; i++) { + manager.emit(makeInput('operationStatusChanged', { i })); + } + for (let i: number = 0; i < 2000; i++) { + manager.emit(makeInput('activityChanged', { i })); + } + await manager.flushAsync(); + + // Protected events are never coalesced, so all of them survive even under + // pressure, while the replaceable activityChanged events are coalesced. + expect(reporter.counts.get('operationStatusChanged') ?? 0).toBe(protectedCount); + expect(reporter.counts.get('activityChanged') ?? 0).toBeGreaterThan(0); + expect(reporter.counts.get('activityChanged') ?? 0).toBeLessThan(2000); + }); + + it('applies bounded backpressure to a synchronous protected-event burst', async () => { + const threshold: number = 32; + const manager: ReporterManager = new ReporterManager({ coalesceThreshold: threshold }); + const reporter: CountingReporter = new CountingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const protectedCount: number = 5000; + let maxPendingCount: number = 0; + for (let i: number = 0; i < protectedCount; i++) { + manager.emit(makeInput('externalOutput', { stream: 'stdout', text: `line ${i}\n` })); + maxPendingCount = Math.max(maxPendingCount, manager.getPendingEventCount()); + } + + expect(maxPendingCount).toBeLessThanOrEqual(threshold); + await manager.flushAsync(); + expect(reporter.counts.get('externalOutput')).toBe(protectedCount); + expect(manager.getPendingEventCount()).toBe(0); + }); +}); diff --git a/libraries/reporter/src/test/Session.test.ts b/libraries/reporter/src/test/Session.test.ts index afbf50cfae..621758c07e 100644 --- a/libraries/reporter/src/test/Session.test.ts +++ b/libraries/reporter/src/test/Session.test.ts @@ -7,8 +7,7 @@ import { createRushDiagnostic, RushSessionReporting, ReporterManager, - RUSH_PLUGIN_API_VERSION, - isPluginApiVersionSupported, + isRushVersionSupported, createPluginApiIncompatibleDiagnostic, isReporterEventRequired, parseReporterExtensionEventName, @@ -109,6 +108,7 @@ describe('createScopedReporter', () => { const reporter: IScopedReporter = createScopedReporter({ sink, sessionId: 'sess', source: SOURCE }); reporter.emitExtension(parseReporterExtensionEventName('acme.cache-warmed'), { 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], {}) @@ -171,21 +171,27 @@ describe('RushSessionReporting', () => { }); describe('plugin API compatibility', () => { - it('accepts a matching major and rejects a mismatched or invalid major', () => { - expect(isPluginApiVersionSupported(RUSH_PLUGIN_API_VERSION)).toBe(true); - expect(isPluginApiVersionSupported('1.4.2')).toBe(true); - expect(isPluginApiVersionSupported('2.0.0')).toBe(false); - expect(isPluginApiVersionSupported('not-a-version')).toBe(false); + it('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', - pluginApiVersion: '2.0.0' - }); + 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?.declaredApiVersion.value).toBe('2.0.0'); + 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 index 486de25a4b..423b8a78cf 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -157,15 +157,103 @@ describe('TelemetrySubscriber', () => { 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('runs legacy hooks with a plain copy of the aggregate', () => { - const observed: Record[] = []; + it('projects the legacy telemetry shape and returns hook augmentations without mutating the aggregate', () => { const hook: LegacyBeforeLogHook = (telemetry: Record) => { - observed.push(telemetry); + telemetry.customField = 'custom-value'; + (telemetry.extraData as Record).countSuccess = 99; }; - const adapter: (aggregate: ITelemetryAggregate) => void = createBeforeLogAdapter([hook]); + const adapter: (aggregate: ITelemetryAggregate) => Record = createBeforeLogAdapter([ + hook + ]); const aggregate: ITelemetryAggregate = { commandName: 'build', @@ -176,11 +264,35 @@ describe('createBeforeLogAdapter', () => { diagnosticCategoryCounts: {}, producerVersions: ['@microsoft/rush-lib@5.177.2'] }; - adapter(aggregate); + 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); + }); - expect(observed).toHaveLength(1); - expect(observed[0]).toEqual({ ...aggregate }); - // The hook receives a copy, not the aggregate itself. - expect(observed[0]).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/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 9a272e1aa2..91e1e1da09 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -129,7 +129,7 @@ "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": true }, @@ -321,7 +321,7 @@ "Preserve lifecycle, diagnostics, results, artifacts, and external output under queue pressure", "Add benchmark, queue-pressure, and status-coalescing tests" ], - "passes": false + "passes": true }, { "category": "migration", diff --git a/research/progress.txt b/research/progress.txt index 331d3821d0..97846c5572 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -409,3 +409,59 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - descriptor alloc/read, structured emit, raw fallback, host correlate+forward, protocol reject, old-heft raw+matcher tests pass; all exports @beta Next: Feature 27/28 - Reporter performance and capacity budgets + +[2026-07-15] Feature 27/28 COMPLETE: Meet reporter performance and capacity budgets (category: performance, spec §7.3) +Files: + - libraries/reporter/src/perf/PerformanceBudgets.ts (NEW): IReporterPerformanceBudgets interface + REPORTER_PERFORMANCE_BUDGETS + constant encoding the §7.3 blocking budgets (3% wall-time regression, 32 MiB peak memory, 10 Hz interactive refresh, + 64 KiB AI output, 20 AI detailed diagnostics). Helpers computeWallTimeRegressionPercent / isWithinWallTimeBudget / + isWithinMemoryBudget for benchmark harnesses and capacity tests to share a single source of truth. + - libraries/reporter/src/manager/ReporterManager.ts (EDIT): added public getPendingEventCount() observability hook and + synchronous backpressure at coalesceThreshold for protected events; pending queues remain bounded without dropping outcomes. + - libraries/reporter/src/index.ts (EDIT): barrel exports for the perf module (all @beta). + - libraries/reporter/src/test/Performance.test.ts (NEW, 9 tests): budget-constant and helper assertions; measured + baseline/candidate wall-time comparison through isWithinWallTimeBudget; measured baseline/reporter peak RSS comparison; + bounded replaceable-status and protected-event bursts; queue-pressure coverage asserting EVERY protected outcome category + is delivered exactly once while replaceable activityChanged noise coalesces. + - common/reviews/api/rush-reporter.api.md (regenerated): new @beta perf exports + getPendingEventCount(). +Design notes: + - REPORTER_PERFORMANCE_BUDGETS is consumed directly by the AI and interactive reporter defaults, preventing drift. + - The representative benchmark performs identical build-like hashing work in baseline and reporter candidates, then applies + the 3% gate to median measurements. Peak RSS is sampled around the same workload and checked against the 32 MiB delta. + - Coalescible = non-required 'activityChanged' only; every other type (and required activityChanged) is protected and + never dropped. Protected bursts synchronously drain the oldest queued event at the threshold to apply bounded backpressure. +Verify: rush build --to @rushstack/rush-reporter -> SUCCESS; rush test --only @rushstack/rush-reporter -> SUCCESS. +Next: Feature 28/28 - Flip defaults in Rush daemon-aligned major (category: migration, spec §8.1 phase 6). + +[2026-07-15] Feature 28/28 PACKAGE MODEL COMPLETE; RUSH ROLLOUT PENDING (category: migration, spec §8.1 phase 6 / §5.3 / §8.2) +Files: + - libraries/reporter/src/migration/MigrationPhase.ts (NEW): ReporterMigrationPhaseId union (7 phases) + IReporterMigrationPhase + + REPORTER_MIGRATION_PHASES ordered list (each independentlyReleasable & revertible = true, per §8.1 "Every phase must be + independently releasable and revertible") + getReporterMigrationPhase(id). + - libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts (NEW): IReporterMajorDefaults + PRE_FLIP_REPORTER_DEFAULTS + and DAEMON_ALIGNED_MAJOR_REPORTER_DEFAULTS (the flip = data, so it is revertible by swapping default sets): + * automaticSelectionEnabledByDefault true (post) / false (pre) -> step 1 + * removedTerminalApis = REMOVED_TERMINAL_APIS ['ILogger.terminal','RushSession.terminalProvider'] (post) / [] (pre) -> step 2 + * gateIncompatiblePluginsBeforeApply true (post) / false (pre) -> step 3 + * legacyRendererRetained / verbosityAliasesRetained / sentinelBridgeRetained ALL true in BOTH (removed only in phase 7) -> step 4 + * emergencyFallbackEnvVar 'RUSH_REPORTER' = 'legacy' (§8.2, >=1 major) + Helpers: isTerminalApiRemoved, isEmergencyLegacyFallback (fallback wins), isAutomaticSelectionEnabled(defaults, context). + - libraries/reporter/src/migration/PluginApplyGate.ts (NEW): evaluatePluginApplyGate(manifests, {gateEnabled=true,supportedApiVersion}) + composing F11 primitives (isPluginApiVersionSupported + createPluginApiIncompatibleDiagnostic) to FAIL incompatible plugins + BEFORE apply() with a structured RUSH_PLUGIN_API_INCOMPATIBLE diagnostic; gateEnabled=false models pre-flip (permit) for + revertibility. getBlockedPlugins(decisions) -> step 3. + - libraries/reporter/src/index.ts (EDIT): barrel exports for the migration module (all @beta). + - libraries/reporter/src/test/Migration.test.ts (NEW, 10 tests): phase order/ordinals; every-phase releasable+revertible; + phase lookup + throw; default-flip automatic-selection (post default on / pre opt-in only); emergency legacy fallback overrides + the flip; terminal-API removal post-but-not-pre; legacy renderer/aliases/sentinel retained across the flip; plugin gate fails + incompatible plugins with RUSH_PLUGIN_API_INCOMPATIBLE before apply(); gate-disabled permits (revertible); explicit supported version. + - common/reviews/api/rush-reporter.api.md (regenerated): new @beta migration exports. +Design notes: + - Per the project-wide scoping decision, ILogger.terminal / RushSession.terminalProvider live in rush-lib; actually removing them + is a rollout step. This feature encodes the flip's semantics as data + helpers inside @rushstack/rush-reporter so rush-lib + can consume a single source of truth. CLI wiring, terminal API removal, and live plugin-gate integration remain pending. +Verify: rush build --to @rushstack/rush-reporter -> SUCCESS; rush test --only @rushstack/rush-reporter -> SUCCESS. + +============================================================================ +[2026-07-15] ALL 28/28 PACKAGE MODELS AUTHORED IN @rushstack/rush-reporter; DAEMON-ALIGNED RUSH ROLLOUT REMAINS PENDING. +============================================================================