Skip to content

Commit 719230e

Browse files
TheLarkInnCopilot
andcommitted
Address reporter contract review follow-up
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5daa0b-7839-42f0-9d63-4ca84c7d2b5c
1 parent 51180be commit 719230e

14 files changed

Lines changed: 415 additions & 47 deletions

common/reviews/api/rush-reporter.api.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export interface INdjsonOptions {
5454
readonly maxRecordBytes?: number;
5555
}
5656

57+
// @beta
58+
export class InvalidReporterHelloError extends Error {
59+
constructor(reason: string);
60+
}
61+
5762
// @beta
5863
export interface IReporter {
5964
closeAsync(): Promise<void>;
@@ -230,15 +235,15 @@ export interface IScopedMessageOptions {
230235
// @beta
231236
export interface IScopedReporter {
232237
emitDiagnostic(diagnostic: IRushDiagnostic): string;
233-
emitExtension<TPayload>(name: ReporterExtensionEventName, payload: TPayload): string;
238+
emitExtension(name: ReporterExtensionEventName, payload: ReporterJsonValue): string;
234239
emitMessage(options: IScopedMessageOptions): string;
235240
}
236241

237242
// @beta
238243
export function isReporterEventRequired(type: ReporterEventType): boolean;
239244

240245
// @beta
241-
export function isReporterExtensionEventName(name: string): boolean;
246+
export function isReporterExtensionEventName(name: string): name is ReporterExtensionEventName;
242247

243248
// @beta
244249
export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, producer: IReporterProtocolVersion): boolean;
@@ -263,10 +268,16 @@ export class NdjsonRecordTooLargeError extends Error {
263268
}
264269

265270
// @beta
266-
export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult;
271+
export function negotiateReporterHello(helloValue: unknown, options: IReporterHandshakeOptions): IReporterHandshakeResult;
272+
273+
// @beta
274+
export type OneOrMoreRushDiagnosticCodeSegments<TSegments extends string = string> = string extends TSegments ? `_${Uppercase<string>}` : TSegments extends `_${infer Segments}` ? Segments extends '' ? never : TSegments extends Uppercase<TSegments> ? TSegments : never : never;
275+
276+
// @beta
277+
export function parseReporterExtensionEventName(name: string): ReporterExtensionEventName;
267278

268279
// @beta
269-
export type OneOrMoreRushDiagnosticCodeSegments<S extends string = RushDiagnosticCodeSegment> = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never;
280+
export function parseReporterHello(value: unknown): IReporterHello;
270281

271282
// @beta
272283
export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"];
@@ -290,7 +301,9 @@ export type ReporterCapability = (typeof REPORTER_KNOWN_CAPABILITIES)[number] |
290301
export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number];
291302

292303
// @beta
293-
export type ReporterExtensionEventName = `${Lowercase<string>}.${Lowercase<string>}`;
304+
export type ReporterExtensionEventName = `${string}.${string}` & {
305+
readonly __reporterExtensionEventNameBrand: 'ReporterExtensionEventName';
306+
};
294307

295308
// @beta
296309
export type ReporterJsonNull = null;
@@ -396,13 +409,13 @@ export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED';
396409
export type RushDiagnosticCategory = KnownRushDiagnosticCategory | (string & {});
397410

398411
// @beta
399-
export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`;
412+
export type RushDiagnosticCode<TCode extends string = string> = string extends TCode ? `RUSH_${Uppercase<string>}_${Uppercase<string>}` : TCode extends `RUSH_${infer Domain}_${infer Name}` ? Domain extends '' ? never : Name extends '' ? never : TCode extends Uppercase<TCode> ? TCode : never : never;
400413

401414
// @beta
402415
export type RushDiagnosticCodes = (typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['code'];
403416

404417
// @beta
405-
export type RushDiagnosticCodeSegment = `_${Uppercase<string>}`;
418+
export type RushDiagnosticCodeSegment<TSegment extends string = string> = string extends TSegment ? `_${Uppercase<string>}` : TSegment extends `_${infer Segment}` ? Segment extends '' ? never : TSegment extends Uppercase<TSegment> ? TSegment : never : never;
406419

407420
// @beta
408421
export type RushDiagnosticDetailKey = `diagnostic.${RushDiagnosticCode}.detail`;

libraries/reporter/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Change Log - @rushstack/rush-reporter
2+
3+
This log was last generated on Tue, 18 Aug 2026 18:00:00 GMT and should not be manually modified.
4+
5+
## 0.1.0
6+
Tue, 18 Aug 2026 18:00:00 GMT
7+
8+
### Minor changes
9+
10+
- Introduce the public-beta Rush reporter contracts, diagnostics, NDJSON protocol, and reporter manager.

libraries/reporter/src/diagnostics/RushDiagnosticCode.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,62 @@
22
// See LICENSE in the project root for license information.
33

44
/**
5-
* A single underscore-prefixed uppercase segment of a Rush diagnostic code,
6-
* for example `_CONFIG`.
5+
* A single underscore-prefixed uppercase alphanumeric segment of a Rush
6+
* diagnostic code, for example `_CONFIG`.
77
*
88
* @beta
99
*/
10-
export type RushDiagnosticCodeSegment = `_${Uppercase<string>}`;
10+
export type RushDiagnosticCodeSegment<TSegment extends string = string> = string extends TSegment
11+
? `_${Uppercase<string>}`
12+
: TSegment extends `_${infer Segment}`
13+
? Segment extends ''
14+
? never
15+
: TSegment extends Uppercase<TSegment>
16+
? TSegment
17+
: never
18+
: never;
1119

1220
/**
13-
* One or more {@link RushDiagnosticCodeSegment} values, for example
21+
* One or more valid {@link RushDiagnosticCodeSegment} values, for example
1422
* `_INVALID_JSON`.
1523
*
1624
* @beta
1725
*/
18-
export type OneOrMoreRushDiagnosticCodeSegments<
19-
S extends string = RushDiagnosticCodeSegment
20-
> = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never;
26+
export type OneOrMoreRushDiagnosticCodeSegments<TSegments extends string = string> =
27+
string extends TSegments
28+
? `_${Uppercase<string>}`
29+
: TSegments extends `_${infer Segments}`
30+
? Segments extends ''
31+
? never
32+
: TSegments extends Uppercase<TSegments>
33+
? TSegments
34+
: never
35+
: never;
2136

2237
/**
2338
* The shape of a stable, never-reused Rush diagnostic code:
2439
* `RUSH_<DOMAIN>_<NAME>`, for example `RUSH_DEPENDENCY_TOOL_FAILED`.
2540
*
2641
* @remarks
27-
* The type system enforces the naming convention for Rush-owned code.
42+
* Supplying a string literal as the type parameter enforces every segment at
43+
* compile time. The central registry applies this validation to every authored
44+
* code before it can be registered.
2845
* {@link isValidRushDiagnosticCode} performs the equivalent runtime check for
2946
* untrusted wire data, where types have been erased.
3047
*
3148
* @beta
3249
*/
33-
export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`;
50+
export type RushDiagnosticCode<TCode extends string = string> = string extends TCode
51+
? `RUSH_${Uppercase<string>}_${Uppercase<string>}`
52+
: TCode extends `RUSH_${infer Domain}_${infer Name}`
53+
? Domain extends ''
54+
? never
55+
: Name extends ''
56+
? never
57+
: TCode extends Uppercase<TCode>
58+
? TCode
59+
: never
60+
: never;
3461

3562
const CODE_PREFIX: 'RUSH_' = 'RUSH_';
3663

libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,88 @@ export interface IRushDiagnosticCodeDefinition {
5454
readonly detailKey: RushDiagnosticDetailKey | undefined;
5555
}
5656

57+
type UppercaseAlphanumeric =
58+
| 'A'
59+
| 'B'
60+
| 'C'
61+
| 'D'
62+
| 'E'
63+
| 'F'
64+
| 'G'
65+
| 'H'
66+
| 'I'
67+
| 'J'
68+
| 'K'
69+
| 'L'
70+
| 'M'
71+
| 'N'
72+
| 'O'
73+
| 'P'
74+
| 'Q'
75+
| 'R'
76+
| 'S'
77+
| 'T'
78+
| 'U'
79+
| 'V'
80+
| 'W'
81+
| 'X'
82+
| 'Y'
83+
| 'Z'
84+
| '0'
85+
| '1'
86+
| '2'
87+
| '3'
88+
| '4'
89+
| '5'
90+
| '6'
91+
| '7'
92+
| '8'
93+
| '9';
94+
95+
type IsValidRushDiagnosticCodeSegment<TSegment extends string> = TSegment extends ''
96+
? false
97+
: TSegment extends `${UppercaseAlphanumeric}${infer Rest}`
98+
? Rest extends ''
99+
? true
100+
: IsValidRushDiagnosticCodeSegment<Rest>
101+
: false;
102+
103+
type AreValidRushDiagnosticCodeSegments<
104+
TSegments extends string,
105+
THasPriorSegment extends boolean = false
106+
> = TSegments extends `${infer Segment}_${infer Rest}`
107+
? IsValidRushDiagnosticCodeSegment<Segment> extends true
108+
? AreValidRushDiagnosticCodeSegments<Rest, true>
109+
: false
110+
: THasPriorSegment extends true
111+
? IsValidRushDiagnosticCodeSegment<TSegments>
112+
: false;
113+
114+
type ValidateRushDiagnosticCode<TCode extends string> =
115+
TCode extends `RUSH_${infer Segments}`
116+
? AreValidRushDiagnosticCodeSegments<Segments> extends true
117+
? TCode
118+
: never
119+
: never;
120+
121+
type ValidatedRushDiagnosticCodeDefinitions<
122+
TDefinitions extends readonly IRushDiagnosticCodeDefinition[]
123+
> = {
124+
readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition
125+
? TDefinitions[K] & {
126+
readonly code: ValidateRushDiagnosticCode<TDefinitions[K]['code']>;
127+
}
128+
: TDefinitions[K];
129+
};
130+
131+
function defineRushDiagnosticCodeDefinitions<
132+
const TDefinitions extends readonly IRushDiagnosticCodeDefinition[]
133+
>(
134+
definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions<TDefinitions>
135+
): TDefinitions {
136+
return definitions;
137+
}
138+
57139
/**
58140
* The stable code used for unexpected internal (programmer) failures.
59141
*
@@ -68,13 +150,13 @@ export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED' = 'RUSH_INTERN
68150
* Codes are append-only. A code is never removed or repurposed, so consumers can
69151
* rely on a code always meaning the same thing. The `as const satisfies` typing
70152
* lets {@link RushDiagnosticCodes} and {@link RushDiagnosticTemplateKey} be
71-
* derived from this list, so adding a code without its templates is a
72-
* compile-time error.
153+
* derived from this list, so malformed codes and missing templates are
154+
* compile-time errors.
73155
*
74156
* @beta
75157
*/
76-
// eslint-disable-next-line @typescript-eslint/typedef -- literal inference feeds the derived RushDiagnosticCodes/RushDiagnosticTemplateKey unions
77-
export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = [
158+
// eslint-disable-next-line @typescript-eslint/typedef -- literal inference feeds the validated code/template unions
159+
export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefinitions([
78160
{
79161
code: 'RUSH_CONFIG_INVALID_JSON',
80162
category: 'configuration',
@@ -131,7 +213,7 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = [
131213
summaryKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.summary',
132214
detailKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.detail'
133215
}
134-
] as const satisfies readonly IRushDiagnosticCodeDefinition[];
216+
]);
135217

136218
/**
137219
* The union of every registered Rush diagnostic code.

libraries/reporter/src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ export type {
8585
IReporterHandshakeResult,
8686
ReporterCapability
8787
} from './protocol/ReporterHandshake';
88-
export { negotiateReporterHello, REPORTER_KNOWN_CAPABILITIES } from './protocol/ReporterHandshake';
88+
export {
89+
InvalidReporterHelloError,
90+
negotiateReporterHello,
91+
parseReporterHello,
92+
REPORTER_KNOWN_CAPABILITIES
93+
} from './protocol/ReporterHandshake';
8994

9095
export type { IReporter, IReporterContext } from './manager/IReporter';
9196
export type { IReporterRegistrationOptions, IReporterManagerOptions } from './manager/ReporterManager';
@@ -103,4 +108,7 @@ export type {
103108
IScopedReporter
104109
} from './producers/IScopedReporter';
105110
export type { ReporterExtensionEventName } from './producers/ReporterExtensionEventName';
106-
export { isReporterExtensionEventName } from './producers/ReporterExtensionEventName';
111+
export {
112+
isReporterExtensionEventName,
113+
parseReporterExtensionEventName
114+
} from './producers/ReporterExtensionEventName';

libraries/reporter/src/manager/ReporterManager.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ interface IReporterEntry {
9494
readonly queue: IReporterEventEnvelope<unknown>[];
9595
draining: boolean;
9696
drainPromise: Promise<void>;
97+
lifecyclePromise: Promise<void>;
9798
}
9899

99100
/**
@@ -169,7 +170,8 @@ export class ReporterManager implements IReporterEventSink {
169170
failureNotified: false,
170171
queue: [],
171172
draining: false,
172-
drainPromise: Promise.resolve()
173+
drainPromise: Promise.resolve(),
174+
lifecyclePromise: Promise.resolve()
173175
});
174176
}
175177

@@ -226,6 +228,7 @@ export class ReporterManager implements IReporterEventSink {
226228
this._ensureInitialized();
227229
const rehomed: IReporterEventEnvelope<unknown> = {
228230
...envelope,
231+
required: isReporterEventRequired(envelope.type),
229232
sequence: this._nextSequence++,
230233
sourceSequence: envelope.sequence
231234
};
@@ -280,13 +283,14 @@ export class ReporterManager implements IReporterEventSink {
280283
flushError = error as Error;
281284
}
282285
await this._settleAsync(async (entry: IReporterEntry): Promise<void> => {
283-
if (!entry.disabled) {
284-
await entry.reporter.closeAsync();
285-
}
286+
await entry.reporter.closeAsync();
286287
}, timeoutMs);
287288
if (flushError) {
288289
throw flushError;
289290
}
291+
if (this._fatalError) {
292+
throw this._fatalError;
293+
}
290294
}
291295

292296
private _fanOut(envelope: IReporterEventEnvelope<unknown>): void {
@@ -378,11 +382,7 @@ export class ReporterManager implements IReporterEventSink {
378382
timeoutMs: number
379383
): Promise<void> {
380384
const work: Promise<void> = Promise.all(
381-
this._entries.map((entry: IReporterEntry) =>
382-
action(entry).catch((error: Error) => {
383-
this._handleReporterFailure(entry, error);
384-
})
385-
)
385+
this._entries.map((entry: IReporterEntry) => this._scheduleLifecycleAction(entry, action))
386386
).then(() => undefined);
387387

388388
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -398,4 +398,16 @@ export class ReporterManager implements IReporterEventSink {
398398
}
399399
}
400400
}
401+
402+
private _scheduleLifecycleAction(
403+
entry: IReporterEntry,
404+
action: (entry: IReporterEntry) => Promise<void>
405+
): Promise<void> {
406+
const scheduled: Promise<void> = entry.lifecyclePromise.then(() => action(entry));
407+
const settled: Promise<void> = scheduled.catch((error: Error) => {
408+
this._handleReporterFailure(entry, error);
409+
});
410+
entry.lifecyclePromise = settled;
411+
return settled;
412+
}
401413
}

libraries/reporter/src/producers/IScopedReporter.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// See LICENSE in the project root for license information.
33

44
import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification';
5+
import type { ReporterJsonValue } from '../events/ReporterJsonValue';
56
import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic';
67
import type { ReporterExtensionEventName } from './ReporterExtensionEventName';
78

@@ -68,8 +69,8 @@ export interface IScopedReporter {
6869
* Emits a namespaced extension event with a JSON-serializable payload and
6970
* returns its assigned event id.
7071
*
71-
* @param name - a namespaced beta identifier, see {@link ReporterExtensionEventName}
72+
* @param name - a runtime-validated namespaced beta identifier
7273
* @param payload - a JSON-serializable payload
7374
*/
74-
emitExtension<TPayload>(name: ReporterExtensionEventName, payload: TPayload): string;
75+
emitExtension(name: ReporterExtensionEventName, payload: ReporterJsonValue): string;
7576
}

0 commit comments

Comments
 (0)