diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json new file mode 100644 index 0000000000..782bb0f729 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add Heft integration over a negotiated inherited descriptor: HeftChildEmitter and HeftDescriptorHost with parent/child event correlation, a raw-stream fallback for older Heft, and descriptor allocation helpers", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 5c94a2081b..c345fe9fe6 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -19,6 +19,9 @@ export class AiReporter implements IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export function allocateChildDescriptor(fdNumber?: number): IChildDescriptorPlan; + // @beta export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; @@ -167,6 +170,25 @@ export function getPrivacyClassificationRank(classification: ReporterPrivacyClas // @beta export function getSignalExitCode(signal: NodeJS.Signals): number; +// @beta +export class HeftChildEmitter { + constructor(options: IHeftChildEmitterOptions); + emitEvent(input: IHeftChildEventInput): string | undefined; + readonly mode: HeftChildReporterMode; + sendHello(): boolean; + writeRaw(stream: 'stdout' | 'stderr', text: string): void; +} + +// @beta +export type HeftChildReporterMode = 'structured' | 'raw-fallback'; + +// @beta +export class HeftDescriptorHost { + constructor(options: IHeftDescriptorHostOptions); + processChildNdjson(ndjson: string): IHeftChildResult; + processChildRecords(records: readonly unknown[]): IHeftChildResult; +} + // @beta export interface IAiDiagnostic { // (undocumented) @@ -284,6 +306,13 @@ export interface IBootstrapTruncation { readonly truncated: boolean; } +// @beta +export interface IChildDescriptorPlan { + readonly env: Record; + readonly fdNumber: number; + readonly stdio: (string | number)[]; +} + // @beta export interface IClassifiedDiagnosticValue { readonly privacy: ReporterPrivacyClassification; @@ -404,6 +433,52 @@ export interface IGetMatchersOptions { readonly version?: string; } +// @beta +export interface IHeftChildEmitterOptions { + readonly capabilities?: readonly string[]; + readonly childSessionId: string; + readonly env: Record; + readonly now?: () => string; + readonly producerVersion: string; + readonly protocolVersion?: IReporterProtocolVersion; + readonly requiredFeatures?: readonly string[]; + readonly source: IReporterEventSource; + readonly writeDescriptor?: (text: string) => void; + readonly writeStderr?: (text: string) => void; + readonly writeStdout?: (text: string) => void; +} + +// @beta +export interface IHeftChildEventInput { + // (undocumented) + readonly payload?: unknown; + // (undocumented) + readonly privacy?: 'public' | 'local-sensitive' | 'secret'; + // (undocumented) + readonly required: boolean; + // (undocumented) + readonly scope?: IReporterEventScope; + // (undocumented) + readonly type: string; +} + +// @beta +export interface IHeftChildResult { + readonly accepted: boolean; + readonly ack?: IReporterHelloAck; + readonly diagnostic?: IRushDiagnostic; + readonly eventCount: number; +} + +// @beta +export interface IHeftDescriptorHostOptions { + readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + readonly parentOperationId?: string; + readonly parentSessionId: string; + readonly supportedCapabilities?: readonly string[]; + readonly supportedProtocolVersion: IReporterProtocolVersion; +} + // @beta export interface IInteractiveTerminal { readonly columns: number; @@ -1067,6 +1142,9 @@ export class ProblemMatcherRegistry { // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise; +// @beta +export function readChildDescriptorFd(env: Record): number | undefined; + // @beta export function regroupOperationOutput(events: readonly IReporterEventEnvelope[]): Map; @@ -1193,6 +1271,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_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; + // @beta export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; diff --git a/libraries/reporter/src/heft/HeftChildEmitter.ts b/libraries/reporter/src/heft/HeftChildEmitter.ts new file mode 100644 index 0000000000..56f3aa7138 --- /dev/null +++ b/libraries/reporter/src/heft/HeftChildEmitter.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 type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import { encodeNdjsonRecord } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; +import type { IReporterHello } from '../protocol/ReporterHandshake'; +import { readChildDescriptorFd } from './HeftDescriptor'; + +/** + * The mode a Heft child reporter operates in. + * + * @beta + */ +export type HeftChildReporterMode = 'structured' | 'raw-fallback'; + +/** + * An event a Heft child emits. + * + * @beta + */ +export interface IHeftChildEventInput { + readonly type: string; + readonly required: boolean; + readonly privacy?: 'public' | 'local-sensitive' | 'secret'; + readonly scope?: IReporterEventScope; + readonly payload?: unknown; +} + +/** + * Options for {@link HeftChildEmitter}. + * + * @beta + */ +export interface IHeftChildEmitterOptions { + /** + * The environment variables, consulted for the inherited descriptor. + */ + readonly env: Record; + + /** + * The child session id stamped onto emitted events. + */ + readonly childSessionId: string; + + /** + * The producer identity stamped onto emitted events. + */ + readonly source: IReporterEventSource; + + /** + * The producer version advertised in the hello. + */ + readonly producerVersion: string; + + /** + * The protocol version. Defaults to {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The capabilities advertised in the hello. + */ + readonly capabilities?: readonly string[]; + + /** + * The required features advertised in the hello. + */ + readonly requiredFeatures?: readonly string[]; + + /** + * Writes NDJSON to the inherited descriptor. Required for structured mode. + */ + readonly writeDescriptor?: (text: string) => void; + + /** + * Writes raw text to stdout, used in fallback mode. + */ + readonly writeStdout?: (text: string) => void; + + /** + * Writes raw text to stderr, used in fallback mode. + */ + readonly writeStderr?: (text: string) => void; + + /** + * Returns the current timestamp. Injectable for testing. + */ + readonly now?: () => string; +} + +/** + * The child side of the Heft reporter descriptor negotiation. + * + * @remarks + * When the inherited descriptor is present, the child emits structured NDJSON + * events over it, stamping its child session id. When the descriptor is + * unavailable, it falls back to normal stdout and stderr, which Rush preserves + * and runs through problem matchers. + * + * @beta + */ +export class HeftChildEmitter { + /** + * Whether the child emits structured events or falls back to raw streams. + */ + public readonly mode: HeftChildReporterMode; + + private readonly _writeDescriptor: ((text: string) => void) | undefined; + private readonly _writeStdout: ((text: string) => void) | undefined; + private readonly _writeStderr: ((text: string) => void) | undefined; + private readonly _childSessionId: string; + private readonly _source: IReporterEventSource; + private readonly _producerVersion: string; + private readonly _protocolVersion: IReporterProtocolVersion; + private readonly _capabilities: readonly string[]; + private readonly _requiredFeatures: readonly string[]; + private readonly _now: () => string; + private _sequence: number; + private _nextEventId: number; + + public constructor(options: IHeftChildEmitterOptions) { + const fd: number | undefined = readChildDescriptorFd(options.env); + this.mode = fd !== undefined && options.writeDescriptor !== undefined ? 'structured' : 'raw-fallback'; + + this._writeDescriptor = options.writeDescriptor; + this._writeStdout = options.writeStdout; + this._writeStderr = options.writeStderr; + this._childSessionId = options.childSessionId; + this._source = options.source; + this._producerVersion = options.producerVersion; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + this._capabilities = options.capabilities ?? []; + this._requiredFeatures = options.requiredFeatures ?? []; + this._now = options.now ?? (() => new Date().toISOString()); + this._sequence = 1; + this._nextEventId = 1; + } + + /** + * Sends the hello handshake over the descriptor. Returns `false` in fallback mode. + */ + public sendHello(): boolean { + if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + return false; + } + const hello: IReporterHello = { + kind: 'hello', + protocolVersion: this._protocolVersion, + producerVersion: this._producerVersion, + capabilities: [...this._capabilities], + requiredFeatures: [...this._requiredFeatures] + }; + this._writeDescriptor(encodeNdjsonRecord(hello)); + return true; + } + + /** + * Emits a structured event over the descriptor. Returns the event id, or + * `undefined` in fallback mode. + */ + public emitEvent(input: IHeftChildEventInput): string | undefined { + if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + return undefined; + } + const eventId: string = `child_${this._nextEventId++}`; + const envelope: Record = { + protocolVersion: this._protocolVersion, + eventId, + sessionId: this._childSessionId, + sequence: this._sequence++, + timestamp: this._now(), + source: this._source, + scope: input.scope, + privacy: input.privacy ?? 'public', + required: input.required, + type: input.type, + payload: input.payload ?? {} + }; + this._writeDescriptor(encodeNdjsonRecord(envelope)); + return eventId; + } + + /** + * Writes raw output to stdout or stderr, preserved for problem matchers. + */ + public writeRaw(stream: 'stdout' | 'stderr', text: string): void { + if (stream === 'stderr') { + this._writeStderr?.(text); + } else { + this._writeStdout?.(text); + } + } +} diff --git a/libraries/reporter/src/heft/HeftDescriptor.ts b/libraries/reporter/src/heft/HeftDescriptor.ts new file mode 100644 index 0000000000..1e58d782cf --- /dev/null +++ b/libraries/reporter/src/heft/HeftDescriptor.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The private environment variable that communicates the inherited reporter file + * descriptor number to a child process. + * + * @beta + */ +export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; + +/** + * A plan for launching a child with an inherited reporter descriptor. + * + * @beta + */ +export interface IChildDescriptorPlan { + /** + * The inherited file descriptor number the child writes NDJSON to. + */ + readonly fdNumber: number; + + /** + * The environment additions that communicate the descriptor to the child. + */ + readonly env: Record; + + /** + * The stdio configuration for spawning the child. stdout and stderr remain + * normal process streams; the reporter descriptor is an additional pipe. + */ + readonly stdio: (string | number)[]; +} + +/** + * Allocates a dynamic inherited descriptor for a child reporter. + * + * @remarks + * stdout and stderr stay as inherited streams; the reporter descriptor is an + * additional pipe at `fdNumber`, whose number is communicated through the + * private environment variable. + * + * @param fdNumber - the descriptor number; defaults to 3 + * + * @beta + */ +export function allocateChildDescriptor(fdNumber: number = 3): IChildDescriptorPlan { + const stdio: (string | number)[] = ['inherit', 'inherit', 'inherit']; + while (stdio.length < fdNumber) { + stdio.push('ignore'); + } + stdio[fdNumber] = 'pipe'; + return { + fdNumber, + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber) }, + stdio + }; +} + +/** + * Reads the inherited reporter descriptor number from the environment. + * + * @remarks + * Returns `undefined` when descriptor negotiation is unavailable, in which case + * the child falls back to normal stdout and stderr. + * + * @param env - the environment variables + * + * @beta + */ +export function readChildDescriptorFd(env: Record): number | undefined { + const raw: string | undefined = env[RUSH_REPORTER_CHILD_FD_ENV_VAR]; + if (raw === undefined) { + return undefined; + } + const parsed: number = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts new file mode 100644 index 0000000000..c7572dcd60 --- /dev/null +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { NdjsonDecoder } from '../protocol/Ndjson'; +import { + negotiateReporterHello, + type IReporterHello, + type IReporterHelloAck, + type IReporterHandshakeResult +} from '../protocol/ReporterHandshake'; + +/** + * Options for constructing a {@link HeftDescriptorHost}. + * + * @beta + */ +export interface IHeftDescriptorHostOptions { + /** + * The parent session id used to correlate child events. + */ + readonly parentSessionId: string; + + /** + * The parent operation id used to correlate child events. + */ + readonly parentOperationId?: string; + + /** + * The protocol version the parent supports. + */ + readonly supportedProtocolVersion: IReporterProtocolVersion; + + /** + * The capabilities the parent supports. + */ + readonly supportedCapabilities?: readonly string[]; + + /** + * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. + */ + readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; +} + +/** + * The result of consuming a child reporter stream. + * + * @beta + */ +export interface IHeftChildResult { + /** + * Whether the child's protocol was accepted. + */ + readonly accepted: boolean; + + /** + * The number of events forwarded. + */ + readonly eventCount: number; + + /** + * The acknowledgement, when a hello was received. + */ + readonly ack?: IReporterHelloAck; + + /** + * An update-global-Rush diagnostic, when the child was rejected. + */ + readonly diagnostic?: IRushDiagnostic; +} + +/** + * The parent side of the Heft reporter descriptor negotiation. + * + * @remarks + * The host negotiates the child's hello, and, on acceptance, correlates each + * child event with the parent session and operation ids before forwarding it. + * When the child is rejected it surfaces an update-global-Rush diagnostic. + * + * @beta + */ +export class HeftDescriptorHost { + private readonly _parentSessionId: string; + private readonly _parentOperationId: string | undefined; + private readonly _supportedProtocolVersion: IReporterProtocolVersion; + private readonly _supportedCapabilities: readonly string[] | undefined; + private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + + public constructor(options: IHeftDescriptorHostOptions) { + this._parentSessionId = options.parentSessionId; + this._parentOperationId = options.parentOperationId; + this._supportedProtocolVersion = options.supportedProtocolVersion; + this._supportedCapabilities = options.supportedCapabilities; + this._forwardEnvelope = options.forwardEnvelope; + } + + /** + * Processes decoded child records: a hello followed by event envelopes. + */ + public processChildRecords(records: readonly unknown[]): IHeftChildResult { + if (records.length === 0 || (records[0] as { kind?: string }).kind !== 'hello') { + return { accepted: false, eventCount: 0 }; + } + + const negotiation: IReporterHandshakeResult = negotiateReporterHello(records[0] as IReporterHello, { + supportedProtocolVersion: this._supportedProtocolVersion, + supportedCapabilities: this._supportedCapabilities + }); + if (!negotiation.accepted) { + return { + accepted: false, + eventCount: 0, + ack: negotiation.ack, + diagnostic: negotiation.diagnostic + }; + } + + let eventCount: number = 0; + for (let index: number = 1; index < records.length; index++) { + const childEnvelope: IReporterEventEnvelope = records[ + index + ] as IReporterEventEnvelope; + const correlated: IReporterEventEnvelope = { + ...childEnvelope, + parentSessionId: this._parentSessionId, + parentOperationId: this._parentOperationId + }; + this._forwardEnvelope(correlated); + eventCount++; + } + + return { accepted: true, eventCount, ack: negotiation.ack }; + } + + /** + * Decodes and processes a child's NDJSON stream. + */ + public processChildNdjson(ndjson: string): IHeftChildResult { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + const records: unknown[] = [...decoder.decode(ndjson), ...decoder.flush()]; + return this.processChildRecords(records); + } +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 10a3f29ba6..0791b9a963 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -260,6 +260,21 @@ export { ProblemMatcherRegistry } from './matchers/ProblemMatcherRegistry'; export type { IRunProblemMatchersOptions, IProblemMatcherResult } from './matchers/ProblemMatcherRunner'; export { runProblemMatchers } from './matchers/ProblemMatcherRunner'; +export type { IChildDescriptorPlan } from './heft/HeftDescriptor'; +export { + RUSH_REPORTER_CHILD_FD_ENV_VAR, + allocateChildDescriptor, + readChildDescriptorFd +} from './heft/HeftDescriptor'; +export type { + HeftChildReporterMode, + IHeftChildEventInput, + IHeftChildEmitterOptions +} from './heft/HeftChildEmitter'; +export { HeftChildEmitter } from './heft/HeftChildEmitter'; +export type { IHeftDescriptorHostOptions, IHeftChildResult } from './heft/HeftDescriptorHost'; +export { HeftDescriptorHost } from './heft/HeftDescriptorHost'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts new file mode 100644 index 0000000000..06b2e7957a --- /dev/null +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + allocateChildDescriptor, + readChildDescriptorFd, + RUSH_REPORTER_CHILD_FD_ENV_VAR, + HeftChildEmitter, + HeftDescriptorHost, + ReporterManager, + runProblemMatchers, + type IChildDescriptorPlan, + type IHeftChildResult, + type IProblemMatch, + type IProblemMatcher, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventSource +} from '../index'; + +const SOURCE: IReporterEventSource = { packageName: '@rushstack/heft', packageVersion: '1.2.19' }; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + public async initializeAsync(): Promise { + /* no-op */ + } + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + public async flushAsync(): Promise { + /* no-op */ + } + public async closeAsync(): Promise { + /* no-op */ + } +} + +const TSC_MATCHER: IProblemMatcher = { + name: 'tsc-error', + tool: 'tsc', + severity: 'error', + enabledByDefault: true, + pattern: /^(.+)\((\d+),(\d+)\): error (TS\d+): (.+)$/, + extract(match: RegExpMatchArray): IProblemMatch { + return { + file: match[1], + line: Number(match[2]), + column: Number(match[3]), + code: match[4], + message: match[5] + }; + } +}; + +describe('Heft descriptor allocation', () => { + it('allocates an inherited descriptor and communicates it by env var', () => { + const plan: IChildDescriptorPlan = allocateChildDescriptor(); + expect(plan.fdNumber).toBe(3); + expect(plan.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBe('3'); + expect(plan.stdio[3]).toBe('pipe'); + expect(plan.stdio.slice(0, 3)).toEqual(['inherit', 'inherit', 'inherit']); + }); + + it('reads or rejects the descriptor number from the environment', () => { + expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' })).toBe(3); + expect(readChildDescriptorFd({})).toBeUndefined(); + expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: 'abc' })).toBeUndefined(); + }); +}); + +describe('HeftChildEmitter', () => { + it('emits structured NDJSON when the descriptor is present', () => { + let descriptor: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + now: () => '2026-01-01T00:00:00.000Z', + writeDescriptor: (text: string) => (descriptor += text) + }); + expect(emitter.mode).toBe('structured'); + expect(emitter.sendHello()).toBe(true); + const eventId: string | undefined = emitter.emitEvent({ + type: 'commandStarted', + required: true, + payload: {} + }); + expect(eventId).toBe('child_1'); + + const records: Record[] = descriptor + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records[0].kind).toBe('hello'); + expect(records[1].sessionId).toBe('child-sess'); + expect(records[1].type).toBe('commandStarted'); + }); + + it('falls back to raw streams when descriptor negotiation is unavailable', () => { + let stdout: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: {}, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeStdout: (text: string) => (stdout += text) + }); + expect(emitter.mode).toBe('raw-fallback'); + expect(emitter.sendHello()).toBe(false); + expect(emitter.emitEvent({ type: 'commandStarted', required: true })).toBeUndefined(); + emitter.writeRaw('stdout', 'raw heft log\n'); + expect(stdout).toBe('raw heft log\n'); + }); +}); + +describe('HeftDescriptorHost new descriptor path', () => { + it('negotiates the hello and correlates forwarded child events', async () => { + // Child produces a structured stream. + let descriptor: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + now: () => '2026-01-01T00:00:00.000Z', + writeDescriptor: (text: string) => (descriptor += text) + }); + child.sendHello(); + child.emitEvent({ + type: 'operationStatusChanged', + required: true, + payload: { operationId: 'c1', status: 'success' } + }); + + // Parent host forwards into a manager. + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(recording); + await manager.initializeAsync(); + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + parentOperationId: 'op-42', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope) + }); + const result: IHeftChildResult = host.processChildNdjson(descriptor); + await manager.flushAsync(); + + expect(result.accepted).toBe(true); + expect(result.eventCount).toBe(1); + + const forwarded: IReporterEventEnvelope = recording.reported[0]; + expect(forwarded.sessionId).toBe('child-sess'); + expect(forwarded.parentSessionId).toBe('parent-sess'); + expect(forwarded.parentOperationId).toBe('op-42'); + // ingestForeignEnvelope assigns a new global sequence and preserves the child's. + expect(forwarded.sourceSequence).toBe(1); + }); + + it('rejects an unsupported child protocol with an update-global-Rush diagnostic', () => { + let descriptor: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 2.0.0', + protocolVersion: { major: 2, minor: 0 }, + writeDescriptor: (text: string) => (descriptor += text) + }); + child.sendHello(); + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => undefined + }); + const result: IHeftChildResult = host.processChildNdjson(descriptor); + expect(result.accepted).toBe(false); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_UPDATE_REQUIRED'); + }); +}); + +describe('Heft old raw-stream path', () => { + it('recovers diagnostics from an old Heft version through problem matchers', () => { + // Old Heft writes raw output to stdout; Rush captures it as external output. + let stdout: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: {}, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 0.60.0', + writeStdout: (text: string) => (stdout += text) + }); + expect(child.mode).toBe('raw-fallback'); + child.writeRaw('stdout', 'src/legacy.ts(3,7): error TS2551: old heft problem\n'); + + const capturedEvents: IReporterEventEnvelope[] = [ + { + type: 'externalOutput', + scope: { operationId: 'heft-op' }, + payload: { stream: 'stdout', text: stdout } + } as unknown as IReporterEventEnvelope + ]; + const diagnostics = runProblemMatchers(capturedEvents, [TSC_MATCHER]).diagnostics; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].parameters?.code.value).toBe('TS2551'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 9a0208480e..6f66647972 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -309,7 +309,7 @@ "Keep the raw-stream and problem-matcher path for older Heft versions", "Add old and new Heft descriptor path tests" ], - "passes": false + "passes": true }, { "category": "performance", diff --git a/research/progress.txt b/research/progress.txt index 837025e6b2..97a9e153f4 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -393,3 +393,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - sentinel detection, removal criteria, suppress sentinel/RushError-emitted/correlated, record direct+ingest tests pass; all exports @beta Next: Feature 26/28 - Heft integration via negotiated child descriptors + raw-stream fallback + +[2026-07-14] Feature 26/28 COMPLETE: Heft integration via negotiated child descriptors + raw-stream fallback + Files (new): + - heft/HeftDescriptor.ts (RUSH_REPORTER_CHILD_FD_ENV_VAR='_RUSH_REPORTER_CHILD_FD'; allocateChildDescriptor -> {fdNumber 3, env var, stdio [inherit,inherit,inherit,pipe]}; readChildDescriptorFd) + - heft/HeftChildEmitter.ts (child side; mode structured if fd+writeDescriptor else raw-fallback; sendHello/emitEvent NDJSON to descriptor stamping childSessionId; writeRaw stdout/stderr fallback) + - heft/HeftDescriptorHost.ts (parent side; processChildRecords/Ndjson: negotiate hello (feature 5), correlate each child event with parentSessionId+parentOperationId, forwardEnvelope; reject->RUSH_PROTOCOL_UPDATE_REQUIRED diagnostic) + - test/HeftIntegration.test.ts + Files (modified): index.ts, api.md + Notes: + - New path: child structured NDJSON over inherited fd -> host negotiate+correlate -> manager.ingestForeignEnvelope (sourceSequence preserved). stdout/stderr stay normal streams. + - Old path: no fd -> raw-fallback writeRaw -> Rush captures as externalOutput -> runProblemMatchers (feature 24) recovers diagnostics. + - Streams injected (writeDescriptor/writeStdout) for testability instead of real spawned FDs. + Verify: + - 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