From 093ada991c5ea744b2986e92ce5dde1cf3a54b0e Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:57:30 +0000 Subject: [PATCH 1/5] 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/rush-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/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index d411e021c5..89dab6efee 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-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'; @@ -769,6 +772,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; @@ -990,6 +1002,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; @@ -1211,6 +1229,9 @@ export const REPORTER_KNOWN_CAPABILITIES: readonly []; // @beta export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; +// @beta +export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets; + // @beta export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; @@ -1256,6 +1277,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 1790e94e80..d7211fe210 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -312,3 +312,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 c9e6ef2ab3..428aff9056 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -233,6 +233,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 a7afb41ba3..57e29a28f3 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 bbb3fb9f0a..2a7ffa5fde 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 a73122a7e614f89692eba74e9afa018cdc9035d7 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:58:07 +0000 Subject: [PATCH 2/5] 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/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-57-43.json 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..587c7b30e3 --- /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/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 5639a71701e0416a5926b8f2f61dd787cb805667 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 02:06:05 +0000 Subject: [PATCH 3/5] 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/rush-reporter.api.md | 272 +++++++----------- 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, 745 insertions(+), 166 deletions(-) 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/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 89dab6efee..a5c46f46d8 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1,4 +1,4 @@ -## API Report File for "@rushstack/rush-reporter" +## API Report File for "@rushstack/reporter" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). @@ -83,7 +83,7 @@ export function createEngineSink(providedSink?: IReporterEventSink): IEngineSink export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic; // @beta -export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; +export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; // @beta export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; @@ -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; @@ -188,12 +200,7 @@ export type HeftChildReporterMode = 'structured' | 'raw-fallback'; // @beta export class HeftDescriptorHost { constructor(options: IHeftDescriptorHostOptions); - createStreamProcessor(): { - write(chunk: string): void; - flush(): IHeftChildResult; - }; processChildNdjson(ndjson: string): IHeftChildResult; - processChildRecord(record: unknown): boolean; processChildRecords(records: readonly unknown[]): IHeftChildResult; } @@ -273,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; @@ -297,25 +311,12 @@ export interface IBootstrapEventSource { readonly packageVersion: string; } -// @beta -export interface IBootstrapHandoffHeader { - readonly kind: 'bootstrapHandoff'; - readonly nonce: string; -} - -// @beta -export interface IBootstrapHandoffWriteResult { - readonly handoffPath: string; - readonly nonce: string; -} - // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; readonly replayed: boolean; - readonly skipReason?: 'unreadable' | 'nonce-mismatch'; } // @beta @@ -494,7 +495,6 @@ export interface IHeftChildResult { // @beta export interface IHeftDescriptorHostOptions { readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; - readonly onNegotiation?: (result: IReporterHandshakeResult) => void; readonly parentOperationId?: string; readonly parentSessionId: string; readonly supportedCapabilities?: readonly string[]; @@ -545,13 +545,6 @@ export interface ILiveRegionState { readonly totalOperations: number; } -// @beta -export interface IMessageEmittedPayload { - readonly privacy?: ReporterPrivacyClassification; - readonly severity: ReporterMessageSeverity; - readonly text: string; -} - // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -598,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; @@ -658,7 +664,7 @@ export interface IReporterContext { } // @beta -export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'sourceSequence' | 'timestamp' | 'required'>; +export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; // @beta export interface IReporterEngineDescriptor { @@ -718,7 +724,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { - readonly supportedCapabilities?: readonly ReporterCapability[]; + readonly supportedCapabilities?: readonly string[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -731,19 +737,19 @@ export interface IReporterHandshakeResult { // @beta export interface IReporterHello { - readonly capabilities: readonly string[]; + readonly capabilities: string[]; readonly kind: 'hello'; readonly producerVersion: string; readonly protocolVersion: IReporterProtocolVersion; - readonly requiredFeatures: readonly string[]; + readonly requiredFeatures: string[]; } // @beta export interface IReporterHelloAck { - readonly acceptedCapabilities: readonly string[]; + readonly acceptedCapabilities: string[]; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; - readonly rejectedRequiredFeatures: readonly string[]; + readonly rejectedRequiredFeatures: string[]; } // @beta @@ -755,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; @@ -763,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: { @@ -849,7 +877,7 @@ export interface IRunProblemMatchersOptions { export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; readonly causeDiagnosticIds?: readonly string[]; - readonly code: RushDiagnosticCode; + readonly code: string; readonly detailKey?: string; readonly diagnosticId: string; readonly parameters?: { @@ -866,14 +894,19 @@ export interface IRushDiagnostic { // @beta export interface IRushDiagnosticCodeDefinition { readonly category: RushDiagnosticCategory; - readonly code: RushDiagnosticCode; + readonly code: string; readonly defaultSeverity: RushDiagnosticSeverity; - readonly detailKey: RushDiagnosticDetailKey | undefined; - readonly summaryKey: RushDiagnosticSummaryKey; + readonly detailKey?: string; + readonly summaryKey: string; } // @beta -export type IRushDiagnosticSource = IRushFileDiagnosticSource | IRushToolDiagnosticSource; +export interface IRushDiagnosticSource { + readonly column?: number; + readonly file?: string; + readonly line?: number; + readonly toolName?: string; +} // @beta export interface IRushExitStatus { @@ -882,15 +915,6 @@ export interface IRushExitStatus { readonly signal?: NodeJS.Signals; } -// @beta -export interface IRushFileDiagnosticSource { - readonly column?: number; - readonly file: string; - readonly kind: 'file'; - readonly line?: number; - readonly toolName?: string; -} - // @beta export interface IRushPluginManifest { readonly pluginApiVersion: string; @@ -913,18 +937,15 @@ export interface IRushSessionReportingOptions { readonly source: IReporterEventSource; } -// @beta -export interface IRushToolDiagnosticSource { - readonly kind: 'tool'; - readonly toolName: string; -} - // @beta export function isAgentVariableActive(value: string | undefined): boolean; // @beta export function isAlreadyReportedSentinel(error: unknown): boolean; +// @beta +export function isAutomaticSelectionEnabled(defaults: IReporterMajorDefaults, context?: IAutomaticSelectionContext): boolean; + // @beta export function isBootstrapHandoffFileName(fileName: string): boolean; @@ -953,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; @@ -984,9 +1008,6 @@ export function isMachineReporter(reporter: ReporterName): boolean; // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; -// @beta -export function isReporterEventRequired(type: ReporterEventType): boolean; - // @beta export function isReporterExtensionEventName(name: string): boolean; @@ -999,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; @@ -1059,9 +1083,6 @@ export class JsonReporter implements IReporter { // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; -// @beta -export type KnownRushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; - // @beta export const LATEST_LOG_NAME: 'latest.log'; @@ -1151,9 +1172,6 @@ export class OldEngineOutputAdapter { capture(stream: 'stdout' | 'stderr', text: string): string[]; } -// @beta -export type OneOrMoreRushDiagnosticCodeSegments = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never; - // @beta export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; @@ -1196,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[]; @@ -1203,10 +1224,7 @@ export class ProblemMatcherRegistry { } // @beta -export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ - header: IBootstrapHandoffHeader | undefined; - events: unknown[]; -}>; +export function readBootstrapHandoffFileAsync(filePath: string): Promise; // @beta export function readChildDescriptorFd(env: Record): number | undefined; @@ -1214,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; @@ -1221,13 +1242,13 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; +export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; // @beta -export const REPORTER_KNOWN_CAPABILITIES: readonly []; +export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; // @beta -export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; +export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; // @beta export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets; @@ -1238,17 +1259,14 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; // @beta export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; -// @beta -export type ReporterCapability = (typeof REPORTER_KNOWN_CAPABILITIES)[number] | (string & {}); - // @beta export type ReporterCompatibilityMode = 'structured' | 'new-frontend-old-engine' | 'old-frontend-new-engine' | 'legacy'; // @beta -export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; +export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; // @beta -export type ReporterExtensionEventName = `${Lowercase}.${Lowercase}`; +export type ReporterExtensionEventName = string; // @beta export class ReporterHost { @@ -1286,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[]); @@ -1325,73 +1346,15 @@ export function resolveReporterSelection(input: IReporterSelectionInput): IRepor export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; // @beta -export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ - readonly code: "RUSH_CONFIG_INVALID_JSON"; - readonly category: "configuration"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_CONFIG_INVALID_JSON.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_INPUT_UNKNOWN_PROJECT"; - readonly category: "input"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_DEPENDENCY_TOOL_FAILED"; - readonly category: "dependency-tool"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary"; - readonly detailKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail"; -}, { - readonly code: "RUSH_ENVIRONMENT_UNSUPPORTED_NODE"; - readonly category: "environment"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_NETWORK_AUTH_UNAUTHORIZED"; - readonly category: "network-auth"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_OPERATION_FAILED"; - readonly category: "operation"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_OPERATION_FAILED.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_PROTOCOL_UPDATE_REQUIRED"; - readonly category: "environment"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary"; - readonly detailKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail"; -}, { - readonly code: "RUSH_INTERNAL_UNEXPECTED"; - readonly category: "internal"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.summary"; - readonly detailKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.detail"; -}, { - readonly code: "RUSH_PLUGIN_API_INCOMPATIBLE"; - readonly category: "configuration"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_EXTERNAL_TOOL_PROBLEM"; - readonly category: "operation"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; - readonly detailKey: undefined; -}]; - -// @beta -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; - -// @beta -export const RUSH_DIAGNOSTIC_TEMPLATES: Readonly>; +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; + +// @beta +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; + +// @beta +export const RUSH_DIAGNOSTIC_TEMPLATES: { + readonly [resourceKey: string]: string; +}; // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; @@ -1405,9 +1368,6 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; -// @beta -export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; - // @beta export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; @@ -1418,29 +1378,11 @@ export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; // @beta -export type RushDiagnosticCategory = KnownRushDiagnosticCategory | (string & {}); - -// @beta -export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`; - -// @beta -export type RushDiagnosticCodes = (typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['code']; - -// @beta -export type RushDiagnosticCodeSegment = `_${Uppercase}`; - -// @beta -export type RushDiagnosticDetailKey = `diagnostic.${RushDiagnosticCode}.detail`; +export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; // @beta export type RushDiagnosticSeverity = 'warning' | 'error'; -// @beta -export type RushDiagnosticSummaryKey = `diagnostic.${RushDiagnosticCode}.summary`; - -// @beta -export type RushDiagnosticTemplateKey = NonNullable<(typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['summaryKey' | 'detailKey']>; - // @beta export class RushError extends Error { constructor(diagnostic: IRushDiagnostic, message?: string); @@ -1499,6 +1441,6 @@ export class TelemetrySubscriber { export function truncateToWidth(text: string, width: number): string; // @beta -export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; +export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; ``` diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index d7211fe210..581c877113 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -320,3 +320,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 57e29a28f3..1d7d7c0a9a 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 2a7ffa5fde..a6c0500c59 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 31fb8d90fd9904f0b72608b26433fc227341f0ef Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 02:06:34 +0000 Subject: [PATCH 4/5] 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/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-02-06-20.json 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..3e0e25212d --- /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/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" +} From dfa83df3c39f463001f7d8bbccc1277fd174dd30 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Wed, 19 Aug 2026 07:20:15 -0700 Subject: [PATCH 5/5] Align performance-budget tests with manager-derived required flag The coalescing-pressure test now proves protection with operationStatusChanged (protected by type) instead of a producer-set required flag, which the sink no longer accepts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/reviews/api/rush-reporter.api.md | 201 +++++++++++++++--- .../reporter/src/test/Performance.test.ts | 21 +- 2 files changed, 177 insertions(+), 45 deletions(-) diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index a5c46f46d8..71620623ad 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1,4 +1,4 @@ -## API Report File for "@rushstack/reporter" +## API Report File for "@rushstack/rush-reporter" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). @@ -83,7 +83,7 @@ export function createEngineSink(providedSink?: IReporterEventSink): IEngineSink export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic; // @beta -export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; +export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; // @beta export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; @@ -200,7 +200,12 @@ export type HeftChildReporterMode = 'structured' | 'raw-fallback'; // @beta export class HeftDescriptorHost { constructor(options: IHeftDescriptorHostOptions); + createStreamProcessor(): { + write(chunk: string): void; + flush(): IHeftChildResult; + }; processChildNdjson(ndjson: string): IHeftChildResult; + processChildRecord(record: unknown): boolean; processChildRecords(records: readonly unknown[]): IHeftChildResult; } @@ -311,12 +316,25 @@ export interface IBootstrapEventSource { readonly packageVersion: string; } +// @beta +export interface IBootstrapHandoffHeader { + readonly kind: 'bootstrapHandoff'; + readonly nonce: string; +} + +// @beta +export interface IBootstrapHandoffWriteResult { + readonly handoffPath: string; + readonly nonce: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; readonly replayed: boolean; + readonly skipReason?: 'unreadable' | 'nonce-mismatch'; } // @beta @@ -495,6 +513,7 @@ export interface IHeftChildResult { // @beta export interface IHeftDescriptorHostOptions { readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + readonly onNegotiation?: (result: IReporterHandshakeResult) => void; readonly parentOperationId?: string; readonly parentSessionId: string; readonly supportedCapabilities?: readonly string[]; @@ -545,6 +564,13 @@ export interface ILiveRegionState { readonly totalOperations: number; } +// @beta +export interface IMessageEmittedPayload { + readonly privacy?: ReporterPrivacyClassification; + readonly severity: ReporterMessageSeverity; + readonly text: string; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -664,7 +690,7 @@ export interface IReporterContext { } // @beta -export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; +export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'sourceSequence' | 'timestamp' | 'required'>; // @beta export interface IReporterEngineDescriptor { @@ -724,7 +750,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { - readonly supportedCapabilities?: readonly string[]; + readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -737,19 +763,19 @@ export interface IReporterHandshakeResult { // @beta export interface IReporterHello { - readonly capabilities: string[]; + readonly capabilities: readonly string[]; readonly kind: 'hello'; readonly producerVersion: string; readonly protocolVersion: IReporterProtocolVersion; - readonly requiredFeatures: string[]; + readonly requiredFeatures: readonly string[]; } // @beta export interface IReporterHelloAck { - readonly acceptedCapabilities: string[]; + readonly acceptedCapabilities: readonly string[]; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; - readonly rejectedRequiredFeatures: string[]; + readonly rejectedRequiredFeatures: readonly string[]; } // @beta @@ -877,7 +903,7 @@ export interface IRunProblemMatchersOptions { export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; readonly causeDiagnosticIds?: readonly string[]; - readonly code: string; + readonly code: RushDiagnosticCode; readonly detailKey?: string; readonly diagnosticId: string; readonly parameters?: { @@ -894,19 +920,14 @@ export interface IRushDiagnostic { // @beta export interface IRushDiagnosticCodeDefinition { readonly category: RushDiagnosticCategory; - readonly code: string; + readonly code: RushDiagnosticCode; readonly defaultSeverity: RushDiagnosticSeverity; - readonly detailKey?: string; - readonly summaryKey: string; + readonly detailKey: RushDiagnosticDetailKey | undefined; + readonly summaryKey: RushDiagnosticSummaryKey; } // @beta -export interface IRushDiagnosticSource { - readonly column?: number; - readonly file?: string; - readonly line?: number; - readonly toolName?: string; -} +export type IRushDiagnosticSource = IRushFileDiagnosticSource | IRushToolDiagnosticSource; // @beta export interface IRushExitStatus { @@ -915,6 +936,15 @@ export interface IRushExitStatus { readonly signal?: NodeJS.Signals; } +// @beta +export interface IRushFileDiagnosticSource { + readonly column?: number; + readonly file: string; + readonly kind: 'file'; + readonly line?: number; + readonly toolName?: string; +} + // @beta export interface IRushPluginManifest { readonly pluginApiVersion: string; @@ -937,6 +967,12 @@ export interface IRushSessionReportingOptions { readonly source: IReporterEventSource; } +// @beta +export interface IRushToolDiagnosticSource { + readonly kind: 'tool'; + readonly toolName: string; +} + // @beta export function isAgentVariableActive(value: string | undefined): boolean; @@ -1008,6 +1044,9 @@ export function isMachineReporter(reporter: ReporterName): boolean; // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; +// @beta +export function isReporterEventRequired(type: ReporterEventType): boolean; + // @beta export function isReporterExtensionEventName(name: string): boolean; @@ -1083,6 +1122,9 @@ export class JsonReporter implements IReporter { // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; +// @beta +export type KnownRushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; + // @beta export const LATEST_LOG_NAME: 'latest.log'; @@ -1172,6 +1214,9 @@ export class OldEngineOutputAdapter { capture(stream: 'stdout' | 'stderr', text: string): string[]; } +// @beta +export type OneOrMoreRushDiagnosticCodeSegments = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never; + // @beta export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; @@ -1224,7 +1269,10 @@ export class ProblemMatcherRegistry { } // @beta -export function readBootstrapHandoffFileAsync(filePath: string): Promise; +export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ + header: IBootstrapHandoffHeader | undefined; + events: unknown[]; +}>; // @beta export function readChildDescriptorFd(env: Record): number | undefined; @@ -1242,13 +1290,16 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; +export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; + +// @beta +export const REPORTER_KNOWN_CAPABILITIES: readonly []; // @beta export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; // @beta -export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; +export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; // @beta export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets; @@ -1259,14 +1310,17 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; // @beta export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; +// @beta +export type ReporterCapability = (typeof REPORTER_KNOWN_CAPABILITIES)[number] | (string & {}); + // @beta export type ReporterCompatibilityMode = 'structured' | 'new-frontend-old-engine' | 'old-frontend-new-engine' | 'legacy'; // @beta -export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; +export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; // @beta -export type ReporterExtensionEventName = string; +export type ReporterExtensionEventName = `${Lowercase}.${Lowercase}`; // @beta export class ReporterHost { @@ -1346,15 +1400,73 @@ export function resolveReporterSelection(input: IReporterSelectionInput): IRepor export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; // @beta -export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; - -// @beta -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; - -// @beta -export const RUSH_DIAGNOSTIC_TEMPLATES: { - readonly [resourceKey: string]: string; -}; +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ + readonly code: "RUSH_CONFIG_INVALID_JSON"; + readonly category: "configuration"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_CONFIG_INVALID_JSON.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_INPUT_UNKNOWN_PROJECT"; + readonly category: "input"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_DEPENDENCY_TOOL_FAILED"; + readonly category: "dependency-tool"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary"; + readonly detailKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail"; +}, { + readonly code: "RUSH_ENVIRONMENT_UNSUPPORTED_NODE"; + readonly category: "environment"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_NETWORK_AUTH_UNAUTHORIZED"; + readonly category: "network-auth"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_OPERATION_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_OPERATION_FAILED.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_PROTOCOL_UPDATE_REQUIRED"; + readonly category: "environment"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary"; + readonly detailKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail"; +}, { + readonly code: "RUSH_INTERNAL_UNEXPECTED"; + readonly category: "internal"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.summary"; + readonly detailKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.detail"; +}, { + readonly code: "RUSH_PLUGIN_API_INCOMPATIBLE"; + readonly category: "configuration"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_EXTERNAL_TOOL_PROBLEM"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; + readonly detailKey: undefined; +}]; + +// @beta +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; + +// @beta +export const RUSH_DIAGNOSTIC_TEMPLATES: Readonly>; // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; @@ -1368,6 +1480,9 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; +// @beta +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + // @beta export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; @@ -1378,11 +1493,29 @@ export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; // @beta -export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; +export type RushDiagnosticCategory = KnownRushDiagnosticCategory | (string & {}); + +// @beta +export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`; + +// @beta +export type RushDiagnosticCodes = (typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['code']; + +// @beta +export type RushDiagnosticCodeSegment = `_${Uppercase}`; + +// @beta +export type RushDiagnosticDetailKey = `diagnostic.${RushDiagnosticCode}.detail`; // @beta export type RushDiagnosticSeverity = 'warning' | 'error'; +// @beta +export type RushDiagnosticSummaryKey = `diagnostic.${RushDiagnosticCode}.summary`; + +// @beta +export type RushDiagnosticTemplateKey = NonNullable<(typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['summaryKey' | 'detailKey']>; + // @beta export class RushError extends Error { constructor(diagnostic: IRushDiagnostic, message?: string); @@ -1441,6 +1574,6 @@ export class TelemetrySubscriber { export function truncateToWidth(text: string, width: number): string; // @beta -export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; +export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; ``` diff --git a/libraries/reporter/src/test/Performance.test.ts b/libraries/reporter/src/test/Performance.test.ts index bf70c16183..49b06053dc 100644 --- a/libraries/reporter/src/test/Performance.test.ts +++ b/libraries/reporter/src/test/Performance.test.ts @@ -43,15 +43,13 @@ class CountingReporter implements IReporter { function makeInput( type: ReporterEventType, - payload: ReporterJsonValue = {}, - required: boolean = false + payload: ReporterJsonValue = {} ): IReporterEmitEventInput { return { protocolVersion: { major: 1, minor: 0 }, sessionId: 'sess', source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, privacy: 'public', - required, type, payload }; @@ -178,18 +176,19 @@ describe('reporter queue pressure', () => { 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)); + 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 }, /* required */ false)); + manager.emit(makeInput('activityChanged', { i })); } 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); + // 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); }); });