From 93a999f96869d2cf70fea157cb70cb2e3649a285 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:57:30 +0000 Subject: [PATCH 1/4] Add reporter performance and capacity budgets (feature 27/28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the specification §7.3 blocking budgets (3% wall-time regression, 32 MiB peak memory, 10 Hz interactive refresh, 64 KiB AI output, 20 AI detailed diagnostics) as shared data in a new perf module, with helpers for benchmark harnesses and capacity tests. Add a getPendingEventCount observability hook to ReporterManager to prove bounded streaming, and a Performance test suite covering the budgets, bounded streaming, a high-volume benchmark, queue-pressure protected-event preservation, and status coalescing. Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 22 ++ libraries/reporter/src/index.ts | 8 + .../reporter/src/manager/ReporterManager.ts | 18 ++ .../reporter/src/perf/PerformanceBudgets.ts | 130 ++++++++++++ .../reporter/src/test/Performance.test.ts | 195 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 25 +++ 7 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/perf/PerformanceBudgets.ts create mode 100644 libraries/reporter/src/test/Performance.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index c345fe9fe6..fc5af63a95 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -64,6 +64,9 @@ 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'; @@ -743,6 +746,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; @@ -951,6 +963,12 @@ export function isSupportedReporterName(name: string): name is ReporterName; // @beta export function isValidRushDiagnosticCode(code: string): boolean; +// @beta +export function isWithinMemoryBudget(additionalPeakBytes: number, budgets?: IReporterPerformanceBudgets): boolean; + +// @beta +export function isWithinWallTimeBudget(baselineMs: number, candidateMs: number, budgets?: IReporterPerformanceBudgets): boolean; + // @beta export interface ITelemetryAggregate { readonly commandName?: string; @@ -1160,6 +1178,9 @@ export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; // @beta export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; +// @beta +export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets; + // @beta export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; @@ -1202,6 +1223,7 @@ export class ReporterManager implements IReporterEventSink { closeAsync(timeoutMs?: number): Promise; emit(event: IReporterEmitEventInput): string; flushAsync(timeoutMs?: number): Promise; + getPendingEventCount(): number; ingestForeignEnvelope(envelope: IReporterEventEnvelope): string; initializeAsync(): Promise; signalFlushAsync(timeoutMs?: number): Promise; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 0791b9a963..143648661c 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -283,3 +283,11 @@ export type { } from './producers/IScopedReporter'; export type { ReporterExtensionEventName } from './producers/ReporterExtensionEventName'; export { isReporterExtensionEventName } from './producers/ReporterExtensionEventName'; + +export type { IReporterPerformanceBudgets } from './perf/PerformanceBudgets'; +export { + REPORTER_PERFORMANCE_BUDGETS, + computeWallTimeRegressionPercent, + isWithinWallTimeBudget, + isWithinMemoryBudget +} from './perf/PerformanceBudgets'; diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 63e58974d1..4691ceee78 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -209,6 +209,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: because each + * queue drains incrementally and coalesces replaceable status events, the + * pending count stays bounded rather than growing to the whole-build event + * total. 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. * 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/test/Performance.test.ts b/libraries/reporter/src/test/Performance.test.ts new file mode 100644 index 0000000000..bf70c16183 --- /dev/null +++ b/libraries/reporter/src/test/Performance.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +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 = {}, + required: boolean = false +): IReporterEmitEventInput { + return { + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required, + type, + payload + }; +} + +// 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' +]; + +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); + }); +}); + +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, so the queue holds every un-drained event. If the + // manager buffered the whole build it would hold ~5000; coalescing keeps it + // near the threshold instead, proving bounded rather than whole-build memory. + const pendingDuringBurst: number = manager.getPendingEventCount(); + expect(pendingDuringBurst).toBeLessThan(200); + + await manager.flushAsync(); + expect(manager.getPendingEventCount()).toBe(0); + }); + + it('completes a high-volume benchmark within the wall-time smoke ceiling', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: CountingReporter = new CountingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const volume: number = 50000; + const startMs: number = Date.now(); + for (let i: number = 0; i < volume; i++) { + manager.emit(makeInput('activityChanged', { i })); + } + await manager.flushAsync(); + const elapsedMs: number = Date.now() - startMs; + + // Generous smoke ceiling: the harness must sustain many thousands of events + // per second so a real build's per-event overhead stays negligible. + expect(elapsedMs).toBeLessThan(10000); + expect(reporter.total).toBeGreaterThan(0); + expect(manager.getPendingEventCount()).toBe(0); + }); +}); + +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 requiredCount: number = 250; + for (let i: number = 0; i < requiredCount; i++) { + manager.emit(makeInput('activityChanged', { i }, /* required */ true)); + } + for (let i: number = 0; i < 2000; i++) { + manager.emit(makeInput('activityChanged', { i }, /* required */ false)); + } + await manager.flushAsync(); + + // Required events are protected, so all of them survive even under pressure, + // while the optional ones are coalesced. + expect(reporter.counts.get('activityChanged') ?? 0).toBeGreaterThanOrEqual(requiredCount); + expect(reporter.counts.get('activityChanged') ?? 0).toBeLessThan(requiredCount + 2000); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 6f66647972..94edca2501 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -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 97a9e153f4..312b7e6e88 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -409,3 +409,28 @@ - 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 — + sums all reporter queue lengths; proves bounded streaming (stays near coalesceThreshold, ==0 after flushAsync). + - libraries/reporter/src/index.ts (EDIT): barrel exports for the perf module (all @beta). + - libraries/reporter/src/test/Performance.test.ts (NEW, 7 tests): budget-constant assertions; wall-time & memory budget + helper checks; bounded-streaming test (5000-event synchronous burst keeps pending queue <200, ==0 after flush); + high-volume benchmark smoke ceiling (50000 events under 10s, queue drained); queue-pressure test asserting EVERY + protected outcome category (lifecycle/diagnostics/results/artifacts/external-output) is delivered exactly once per batch + while replaceable activityChanged noise coalesces; required-status-never-coalesced test. + - common/reviews/api/reporter.api.md (regenerated): new @beta perf exports + getPendingEventCount(). +Design notes: + - Feature is primarily validation (like F7): the bounded queue + coalescing already exist in ReporterManager (F6); this + feature encodes the budgets as shared data and proves the P0 capacity guarantees with benchmark/queue-pressure/ + status-coalescing tests. No live tooling touched — budgets are simulated/injected, not measured against real Rush builds. + - Coalescible = non-required 'activityChanged' only; every other type (and required activityChanged) is protected and + never dropped, satisfying "no loss of lifecycle, diagnostics, results, artifacts, or external output". +Verify: rush build --to @rushstack/reporter -> SUCCESS (clean API report on rebuild); rush test --only @rushstack/reporter + -> SUCCESS; Performance.test.js 7 passed / 0 failed; all exports @beta (no @public/@alpha/@internal). +Next: Feature 28/28 - Flip defaults in Rush daemon-aligned major (category: migration, spec §8.1 phase 6). From 61fb304b4d253f316e12c8436bd6178c1eb14ebe Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:58:07 +0000 Subject: [PATCH 2/4] Add rush change file for reporter performance budgets Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-57-43.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json new file mode 100644 index 0000000000..587c7b30e3 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/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/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 6f3c41f6c4ce04591ccf29b6016a4f4da162545b Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 02:06:05 +0000 Subject: [PATCH 3/4] Add daemon-aligned major default-flip migration model (feature 28/28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the specification §8.1 phase 6 default flip as revertible data in a new migration module: the seven migration phases (each independently releasable and revertible), pre-flip and daemon-aligned major default sets (automatic selection on by default, legacy terminal APIs removed, incompatible plugins gated before apply, legacy renderer/aliases/sentinel bridge retained, RUSH_REPORTER=legacy emergency fallback), and a plugin apply gate that fails incompatible plugins with a structured RUSH_PLUGIN_API_INCOMPATIBLE diagnostic. Completes the Rush Reporter Overhaul (28/28). Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 75 +++++++ libraries/reporter/src/index.ts | 17 ++ .../migration/DaemonAlignedMajorDefaults.ts | 193 ++++++++++++++++++ .../reporter/src/migration/MigrationPhase.ts | 157 ++++++++++++++ .../reporter/src/migration/PluginApplyGate.ts | 99 +++++++++ libraries/reporter/src/test/Migration.test.ts | 136 ++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 35 ++++ 8 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts create mode 100644 libraries/reporter/src/migration/MigrationPhase.ts create mode 100644 libraries/reporter/src/migration/PluginApplyGate.ts create mode 100644 libraries/reporter/src/test/Migration.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index fc5af63a95..a5c46f46d8 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -94,6 +94,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 +136,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 +167,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 +179,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; @@ -268,6 +280,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; @@ -572,6 +591,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 supportedApiVersion?: string; +} + // @beta export interface IProblemMatch { readonly code?: string; @@ -729,6 +761,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; @@ -737,6 +781,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: { @@ -889,6 +943,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; @@ -917,6 +974,9 @@ export interface IScopedReporter { emitMessage(options: IScopedMessageOptions): string; } +// @beta +export function isEmergencyLegacyFallback(env: Record, defaults?: IReporterMajorDefaults): boolean; + // @beta export interface ISessionCompletedPayload { readonly durationMs?: number; @@ -960,6 +1020,9 @@ export function isSupportedLogLevel(level: string): level is ReporterLogLevel; // @beta export function isSupportedReporterName(name: string): name is ReporterName; +// @beta +export function isTerminalApiRemoved(api: string, defaults?: IReporterMajorDefaults): boolean; + // @beta export function isValidRushDiagnosticCode(code: string): boolean; @@ -1151,6 +1214,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[]; @@ -1166,6 +1232,9 @@ export function readChildDescriptorFd(env: Record): // @beta export function regroupOperationOutput(events: readonly IReporterEventEnvelope[]): Map; +// @beta +export const REMOVED_TERMINAL_APIS: readonly string[]; + // @beta export function renderActiveProjectsRow(projects: readonly string[], width: number): string; @@ -1175,6 +1244,9 @@ export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRe // @beta export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; +// @beta +export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; + // @beta export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; @@ -1232,6 +1304,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[]); diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 143648661c..0b656c4b60 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -291,3 +291,20 @@ export { 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/migration/DaemonAlignedMajorDefaults.ts b/libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts new file mode 100644 index 0000000000..a3e2524270 --- /dev/null +++ b/libraries/reporter/src/migration/DaemonAlignedMajorDefaults.ts @@ -0,0 +1,193 @@ +// 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 { + return env[defaults.emergencyFallbackEnvVar] === defaults.emergencyFallbackReporterName; +} + +/** + * 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..31168f0d69 --- /dev/null +++ b/libraries/reporter/src/migration/MigrationPhase.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * 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/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..15462c03a6 --- /dev/null +++ b/libraries/reporter/src/migration/PluginApplyGate.ts @@ -0,0 +1,99 @@ +// 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 { + RUSH_PLUGIN_API_VERSION, + createPluginApiIncompatibleDiagnostic, + isPluginApiVersionSupported, + 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 Rush plugin API version Rush supports; defaults to + * {@link RUSH_PLUGIN_API_VERSION}. + */ + readonly supportedApiVersion?: 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; + const supportedApiVersion: string = options.supportedApiVersion ?? RUSH_PLUGIN_API_VERSION; + + return manifests.map((manifest: IRushPluginManifest): IPluginApplyDecision => { + const compatible: boolean = isPluginApiVersionSupported(manifest.pluginApiVersion, supportedApiVersion); + if (compatible || !gateEnabled) { + return { manifest, allowed: true }; + } + return { + manifest, + allowed: false, + diagnostic: createPluginApiIncompatibleDiagnostic(manifest) + }; + }); +} + +/** + * 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/test/Migration.test.ts b/libraries/reporter/src/test/Migration.test.ts new file mode 100644 index 0000000000..2d0ebf249a --- /dev/null +++ b/libraries/reporter/src/test/Migration.test.ts @@ -0,0 +1,136 @@ +// 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: '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', pluginApiVersion: '1.2.0' }; + const incompatible: IRushPluginManifest = { pluginName: 'bad', pluginApiVersion: '2.0.0' }; + + it('fails incompatible plugins with a structured migration diagnostic before apply()', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([compatible, incompatible]); + + 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 + }); + + expect(decisions.every((decision: IPluginApplyDecision) => decision.allowed)).toBe(true); + expect(getBlockedPlugins(decisions)).toHaveLength(0); + }); + + it('honors an explicit supported API version', () => { + const decisions: IPluginApplyDecision[] = evaluatePluginApplyGate([incompatible], { + supportedApiVersion: '2.4.0' + }); + expect(decisions[0].allowed).toBe(true); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 94edca2501..ebd878bcde 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -333,6 +333,6 @@ "Retain the legacy renderer, verbosity aliases, and sentinel bridge", "Keep every phase independently releasable and revertible" ], - "passes": false + "passes": true } ] diff --git a/research/progress.txt b/research/progress.txt index 312b7e6e88..e614be093a 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -434,3 +434,38 @@ Design notes: Verify: rush build --to @rushstack/reporter -> SUCCESS (clean API report on rebuild); rush test --only @rushstack/reporter -> SUCCESS; Performance.test.js 7 passed / 0 failed; all exports @beta (no @public/@alpha/@internal). Next: Feature 28/28 - Flip defaults in Rush daemon-aligned major (category: migration, spec §8.1 phase 6). + +[2026-07-15] Feature 28/28 COMPLETE: Flip defaults in the Rush daemon-aligned major release (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/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 (which APIs are removed, when automatic selection is default, when + plugins are gated, what is retained) as data + helpers inside @rushstack/reporter so rush-lib can consume a single source of truth, + and proves the independently-releasable/revertible guarantee with tests. No live tooling modified. +Verify: rush build --to @rushstack/reporter -> SUCCESS (fixed an ae-unresolved-link on a union member; clean rebuild); + rush test --only @rushstack/reporter -> SUCCESS; Migration.test.js 10 passed / 0 failed; all exports @beta. + +============================================================================ +[2026-07-15] ALL 28/28 FEATURES COMPLETE. Rush Reporter Overhaul spec fully implemented in @rushstack/reporter (public-beta). +============================================================================ From 6f4808f79c4ab5ae728461176d6ed4a59f1e4a96 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 02:06:34 +0000 Subject: [PATCH 4/4] Add rush change file for daemon-aligned major migration model Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-02-06-20.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json new file mode 100644 index 0000000000..3e0e25212d --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/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/reporter", + "email": "TheLarkInn@users.noreply.github.com" +}