Skip to content

Commit 8d628e2

Browse files
TheLarkInnCopilot
andcommitted
Add legacy error bridge and deprecate AlreadyReportedError
Bridge the legacy sentinel error pattern for @rushstack/reporter (#5858). - Deprecate AlreadyReportedError so new usage is prohibited now that structured diagnostics and RushError are available - Add LegacyErrorBridge, which observes emitted diagnostics, correlates legacy sentinels with them, and suppresses duplicate rendering of failures that are already represented - Document the bridge removal criteria for a later major - Cover sentinel detection, suppression, correlation, and the removal criteria with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3
1 parent a06be39 commit 8d628e2

6 files changed

Lines changed: 249 additions & 1 deletion

File tree

common/reviews/api/reporter.api.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export class AiReporter implements IReporter {
1919
report(event: IReporterEventEnvelope<unknown>): void;
2020
}
2121

22+
// @beta
23+
export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError';
24+
25+
// @beta @deprecated
26+
export class AlreadyReportedError extends Error {
27+
constructor(message?: string);
28+
}
29+
2230
// @beta
2331
export const BOOTSTRAP_BUFFER_MAX_BYTES: number;
2432

@@ -791,6 +799,9 @@ export interface IRushSessionReportingOptions {
791799
// @beta
792800
export function isAgentVariableActive(value: string | undefined): boolean;
793801

802+
// @beta
803+
export function isAlreadyReportedSentinel(error: unknown): boolean;
804+
794805
// @beta
795806
export function isBootstrapHandoffFileName(fileName: string): boolean;
796807

@@ -919,9 +930,21 @@ export const KNOWN_CI_ENV_VARS: readonly string[];
919930
// @beta
920931
export const LATEST_LOG_NAME: 'latest.log';
921932

933+
// @beta
934+
export const LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA: readonly string[];
935+
922936
// @beta
923937
export type LegacyBeforeLogHook = (telemetry: Record<string, unknown>) => void;
924938

939+
// @beta
940+
export class LegacyErrorBridge {
941+
correlate(error: unknown, diagnosticId: string): void;
942+
getCorrelatedDiagnosticId(error: unknown): string | undefined;
943+
ingest(event: IReporterEventEnvelope<unknown>): void;
944+
recordEmittedDiagnostic(diagnosticId: string): void;
945+
shouldSuppressRendering(error: unknown): boolean;
946+
}
947+
925948
// @beta
926949
export class LegacyFallbackSink implements IReporterEventSink {
927950
// (undocumented)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope';
5+
import { RushError } from '../diagnostics/RushError';
6+
7+
/**
8+
* The `name` of the legacy `AlreadyReportedError` sentinel.
9+
*
10+
* @beta
11+
*/
12+
export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError' = 'AlreadyReportedError';
13+
14+
const CORRELATION_KEY: unique symbol = Symbol('rush-reporter-correlated-diagnostic-id');
15+
16+
/**
17+
* The criteria that must be met before the legacy error bridge is removed.
18+
*
19+
* @remarks
20+
* The bridge is removed only in a later major once all criteria are satisfied.
21+
*
22+
* @beta
23+
*/
24+
export const LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA: readonly string[] = [
25+
'zero first-party AlreadyReportedError usages remain',
26+
'plugin API migration guidance is published',
27+
'ecosystem notice and migration time are provided'
28+
];
29+
30+
/**
31+
* The legacy print-then-throw sentinel error.
32+
*
33+
* @deprecated New usage is prohibited now that structured diagnostic APIs are
34+
* available. Emit a structured diagnostic and throw a `RushError` that
35+
* references its diagnostic id instead. This sentinel is recognized only so the
36+
* bridge can suppress duplicate rendering during migration.
37+
*
38+
* @beta
39+
*/
40+
export class AlreadyReportedError extends Error {
41+
public constructor(message?: string) {
42+
super(message ?? 'This error has already been reported.');
43+
this.name = ALREADY_REPORTED_ERROR_NAME;
44+
45+
// Restore the prototype chain, which is broken when subclassing a built-in
46+
// and compiling to CommonJS.
47+
Object.setPrototypeOf(this, AlreadyReportedError.prototype);
48+
}
49+
}
50+
51+
/**
52+
* Returns `true` if the value is a legacy `AlreadyReportedError` sentinel.
53+
*
54+
* @beta
55+
*/
56+
export function isAlreadyReportedSentinel(error: unknown): boolean {
57+
return error instanceof Error && error.name === ALREADY_REPORTED_ERROR_NAME;
58+
}
59+
60+
/**
61+
* Correlates legacy sentinel errors with previously emitted diagnostics and
62+
* decides whether a failure should be rendered again.
63+
*
64+
* @remarks
65+
* Catch boundaries render only failures that are not already represented. This
66+
* bridge observes emitted diagnostics, so a legacy sentinel or a `RushError`
67+
* whose diagnostic was already emitted is suppressed rather than rendered twice.
68+
*
69+
* @beta
70+
*/
71+
export class LegacyErrorBridge {
72+
private readonly _emittedDiagnosticIds: Set<string> = new Set();
73+
74+
/**
75+
* Records that a diagnostic id has been emitted.
76+
*/
77+
public recordEmittedDiagnostic(diagnosticId: string): void {
78+
this._emittedDiagnosticIds.add(diagnosticId);
79+
}
80+
81+
/**
82+
* Observes an event, recording emitted diagnostic ids.
83+
*/
84+
public ingest(event: IReporterEventEnvelope<unknown>): void {
85+
if (event.type === 'diagnosticEmitted') {
86+
const diagnosticId: string | undefined = (event.payload as { diagnosticId?: string }).diagnosticId;
87+
if (diagnosticId !== undefined) {
88+
this._emittedDiagnosticIds.add(diagnosticId);
89+
}
90+
}
91+
}
92+
93+
/**
94+
* Correlates a legacy sentinel error with the diagnostic id it corresponds to.
95+
*/
96+
public correlate(error: unknown, diagnosticId: string): void {
97+
if (typeof error === 'object' && error !== null) {
98+
(error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY] = diagnosticId;
99+
}
100+
}
101+
102+
/**
103+
* Returns the diagnostic id correlated with an error, if any.
104+
*/
105+
public getCorrelatedDiagnosticId(error: unknown): string | undefined {
106+
if (typeof error === 'object' && error !== null) {
107+
return (error as { [CORRELATION_KEY]?: string })[CORRELATION_KEY];
108+
}
109+
return undefined;
110+
}
111+
112+
/**
113+
* Returns `true` if the failure has already been represented and should not be
114+
* rendered again.
115+
*/
116+
public shouldSuppressRendering(error: unknown): boolean {
117+
if (isAlreadyReportedSentinel(error)) {
118+
return true;
119+
}
120+
if (error instanceof RushError) {
121+
return this._emittedDiagnosticIds.has(error.diagnosticId);
122+
}
123+
const correlated: string | undefined = this.getCorrelatedDiagnosticId(error);
124+
if (correlated !== undefined) {
125+
return this._emittedDiagnosticIds.has(correlated);
126+
}
127+
return false;
128+
}
129+
}

libraries/reporter/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ export type { IEngineSinkResolution } from './compat/LegacyFallbackSink';
113113
export { LegacyFallbackSink, createEngineSink } from './compat/LegacyFallbackSink';
114114
export type { IOldEngineOutputAdapterOptions } from './compat/OldEngineOutputAdapter';
115115
export { OldEngineOutputAdapter } from './compat/OldEngineOutputAdapter';
116+
export {
117+
ALREADY_REPORTED_ERROR_NAME,
118+
LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA,
119+
AlreadyReportedError,
120+
isAlreadyReportedSentinel,
121+
LegacyErrorBridge
122+
} from './compat/LegacyErrorBridge';
116123

117124
export type { ICreateScopedReporterOptions } from './session/ScopedReporterFactory';
118125
export { createScopedReporter } from './session/ScopedReporterFactory';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import {
5+
AlreadyReportedError,
6+
isAlreadyReportedSentinel,
7+
LegacyErrorBridge,
8+
LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA,
9+
RushError,
10+
createRushDiagnostic,
11+
type IReporterEventEnvelope,
12+
type IRushDiagnostic
13+
} from '../index';
14+
15+
function diagnosticEvent(diagnostic: IRushDiagnostic): IReporterEventEnvelope<unknown> {
16+
return { type: 'diagnosticEmitted', payload: diagnostic } as unknown as IReporterEventEnvelope<unknown>;
17+
}
18+
19+
describe('isAlreadyReportedSentinel', () => {
20+
it('recognizes the sentinel by type and name', () => {
21+
expect(isAlreadyReportedSentinel(new AlreadyReportedError())).toBe(true);
22+
const namedError: Error = new Error('x');
23+
namedError.name = 'AlreadyReportedError';
24+
expect(isAlreadyReportedSentinel(namedError)).toBe(true);
25+
expect(isAlreadyReportedSentinel(new Error('generic'))).toBe(false);
26+
expect(isAlreadyReportedSentinel('not an error')).toBe(false);
27+
});
28+
});
29+
30+
describe('LegacyErrorBridge', () => {
31+
it('exposes the documented removal criteria', () => {
32+
expect(LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA).toHaveLength(3);
33+
expect(LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA[0]).toContain('zero first-party');
34+
});
35+
36+
it('suppresses rendering of a legacy sentinel', () => {
37+
const bridge: LegacyErrorBridge = new LegacyErrorBridge();
38+
expect(bridge.shouldSuppressRendering(new AlreadyReportedError())).toBe(true);
39+
expect(bridge.shouldSuppressRendering(new Error('unrepresented'))).toBe(false);
40+
});
41+
42+
it('suppresses a RushError whose diagnostic was already emitted', () => {
43+
const bridge: LegacyErrorBridge = new LegacyErrorBridge();
44+
const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', {
45+
diagnosticId: 'diag_1'
46+
});
47+
const error: RushError = new RushError(diagnostic);
48+
49+
// Before the diagnostic is recorded, the failure is not yet represented.
50+
expect(bridge.shouldSuppressRendering(error)).toBe(false);
51+
52+
bridge.ingest(diagnosticEvent(diagnostic));
53+
expect(bridge.shouldSuppressRendering(error)).toBe(true);
54+
});
55+
56+
it('records emitted diagnostics directly and by ingesting events', () => {
57+
const bridge: LegacyErrorBridge = new LegacyErrorBridge();
58+
bridge.recordEmittedDiagnostic('diag_direct');
59+
const directError: RushError = new RushError(
60+
createRushDiagnostic('RUSH_OPERATION_FAILED', { diagnosticId: 'diag_direct' })
61+
);
62+
expect(bridge.shouldSuppressRendering(directError)).toBe(true);
63+
});
64+
65+
it('correlates a legacy sentinel with an emitted diagnostic id', () => {
66+
const bridge: LegacyErrorBridge = new LegacyErrorBridge();
67+
const sentinel: Error = new Error('legacy');
68+
bridge.correlate(sentinel, 'diag_2');
69+
expect(bridge.getCorrelatedDiagnosticId(sentinel)).toBe('diag_2');
70+
71+
expect(bridge.shouldSuppressRendering(sentinel)).toBe(false);
72+
bridge.recordEmittedDiagnostic('diag_2');
73+
expect(bridge.shouldSuppressRendering(sentinel)).toBe(true);
74+
});
75+
});

research/feature-list.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@
297297
"Suppress duplicate rendering through the bridge",
298298
"Plan bridge removal after zero first-party usages and published migration guidance"
299299
],
300-
"passes": false
300+
"passes": true
301301
},
302302
{
303303
"category": "functional",

research/progress.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,17 @@
379379
- rush test --only @rushstack/reporter: clean SUCCESS (build + jest)
380380
- normalizeAnsi, registry scope/gate, recover+link+evidence-preserve, split-chunk+ANSI, dedup cap, corpus tests pass; all exports @beta
381381
Next: Feature 25/28 - Prohibit new AlreadyReportedError + bridge legacy sentinels
382+
383+
[2026-07-14] Feature 25/28 COMPLETE: Prohibit new AlreadyReportedError + bridge legacy sentinels
384+
Files (new):
385+
- compat/LegacyErrorBridge.ts (ALREADY_REPORTED_ERROR_NAME; AlreadyReportedError class @deprecated=prohibition; isAlreadyReportedSentinel; LegacyErrorBridge {recordEmittedDiagnostic, ingest(diagnosticEmitted->id), correlate/getCorrelatedDiagnosticId via symbol, shouldSuppressRendering}; LEGACY_ERROR_BRIDGE_REMOVAL_CRITERIA 3 items)
386+
- test/LegacyErrorBridge.test.ts
387+
Files (modified): index.ts, api.md
388+
Notes:
389+
- Prohibition encoded as @deprecated on AlreadyReportedError (api.md shows "// @beta @deprecated"); replacement = structured diagnostic + RushError.
390+
- shouldSuppressRendering: sentinel->true; RushError whose diagnosticId already emitted->true; correlated error with emitted id->true; else false (catch boundaries render only unrepresented failures).
391+
- Removal plan constant documents criteria (zero first-party usages, plugin migration guidance published, ecosystem notice/time).
392+
Verify:
393+
- rush test --only @rushstack/reporter: clean SUCCESS (build + jest)
394+
- sentinel detection, removal criteria, suppress sentinel/RushError-emitted/correlated, record direct+ingest tests pass; all exports @beta
395+
Next: Feature 26/28 - Heft integration via negotiated child descriptors + raw-stream fallback

0 commit comments

Comments
 (0)