From 7148020f356a6870fc29e9181f16af931018e4a6 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 09:11:49 +0000 Subject: [PATCH 1/8] Integrate negotiated Heft child reporting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/heft/src/cli/HeftActionRunner.ts | 11 +- apps/heft/src/cli/HeftCommandLineParser.ts | 38 +- .../logging/HeftChildReporter.test.ts | 137 +++++++ .../logging/HeftChildReporter.ts | 337 ++++++++++++++++++ .../pluginFramework/logging/LoggingManager.ts | 7 +- .../pluginFramework/logging/ScopedLogger.ts | 13 +- apps/rush/src/RushFrontend.ts | 25 +- .../src/test/sandbox/reporter-demo/run.mjs | 40 +++ ...r-r7a-heft-reporting_2026-08-28-08-40.json | 11 + ...r-r7a-heft-reporting_2026-08-28-08-40.json | 11 + ...r-r7a-heft-reporting_2026-08-28-08-40.json | 11 + common/reviews/api/rush-lib.api.md | 24 ++ common/reviews/api/rush-reporter.api.md | 56 ++- .../src/events/IReporterEventEnvelope.ts | 5 + .../reporter/src/heft/HeftChildEmitter.ts | 154 +++++++- libraries/reporter/src/heft/HeftDescriptor.ts | 62 +++- .../reporter/src/heft/HeftDescriptorHost.ts | 79 +++- libraries/reporter/src/index.ts | 7 +- .../src/matchers/ProblemMatcherRunner.ts | 272 +++++++++++--- .../src/protocol/ReporterHandshake.ts | 139 +++++++- .../reporter/src/protocol/ReporterProtocol.ts | 2 +- libraries/reporter/src/test/Goldens.test.ts | 2 +- .../reporter/src/test/HeftIntegration.test.ts | 308 ++++++++++++++-- .../reporter/src/test/ProblemMatchers.test.ts | 58 ++- libraries/reporter/src/test/Protocol.test.ts | 65 +++- libraries/reporter/src/test/Telemetry.test.ts | 2 +- .../test/__snapshots__/Goldens.test.ts.snap | 2 +- libraries/rush-lib/src/index.ts | 1 + .../operations/HeftChildProcessReporter.ts | 113 ++++++ .../src/logic/operations/IOperationRunner.ts | 8 + .../logic/operations/OperationEventSink.ts | 22 ++ .../operations/OperationExecutionRecord.ts | 9 +- .../operations/ReporterOperationEventSink.ts | 110 +++++- .../logic/operations/ShellOperationRunner.ts | 55 ++- .../test/HeftChildProcessReporter.test.ts | 231 ++++++++++++ .../test/OperationGraphEventSink.test.ts | 1 + .../src/pluginFramework/RushSession.ts | 52 +++ libraries/rush-lib/src/utilities/Utilities.ts | 27 +- 38 files changed, 2338 insertions(+), 169 deletions(-) create mode 100644 apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts create mode 100644 apps/heft/src/pluginFramework/logging/HeftChildReporter.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json create mode 100644 common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json create mode 100644 libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts create mode 100644 libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index b9d5c7f7f26..6c6880dd0ef 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -26,6 +26,7 @@ import type { import type { InternalHeftSession } from '../pluginFramework/InternalHeftSession'; import type { HeftConfiguration } from '../configuration/HeftConfiguration'; import type { LoggingManager } from '../pluginFramework/logging/LoggingManager'; +import type { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; import type { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftParameterManager } from '../pluginFramework/HeftParameterManager'; import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner'; @@ -71,9 +72,13 @@ export function initializeHeft( ): void { // Ensure that verbose is enabled on the terminal if requested. terminalProvider.verboseEnabled // should already be `true` if the `--debug` flag was provided. This is set in HeftCommandLineParser - if (heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider) { - heftConfiguration.terminalProvider.verboseEnabled = - heftConfiguration.terminalProvider.verboseEnabled || isVerbose; + if ( + heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider || + 'verboseEnabled' in heftConfiguration.terminalProvider + ) { + const terminalProvider: ConsoleTerminalProvider | HeftChildReporter = + heftConfiguration.terminalProvider as ConsoleTerminalProvider | HeftChildReporter; + terminalProvider.verboseEnabled = terminalProvider.verboseEnabled || isVerbose; } // Log some information about the execution diff --git a/apps/heft/src/cli/HeftCommandLineParser.ts b/apps/heft/src/cli/HeftCommandLineParser.ts index 67389a7d053..cd44efd08b0 100644 --- a/apps/heft/src/cli/HeftCommandLineParser.ts +++ b/apps/heft/src/cli/HeftCommandLineParser.ts @@ -10,7 +10,12 @@ import { type CommandLineAction } from '@rushstack/ts-command-line'; import { InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; -import { Terminal, ConsoleTerminalProvider, type ITerminal } from '@rushstack/terminal'; +import { + Terminal, + ConsoleTerminalProvider, + type ITerminal, + type ITerminalProvider +} from '@rushstack/terminal'; import { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftConfiguration } from '../configuration/HeftConfiguration'; @@ -23,6 +28,7 @@ import type { IHeftActionOptions } from './actions/IHeftAction'; import { AliasAction } from './actions/AliasAction'; import { getToolParameterNamesFromArgs } from '../utilities/CliUtilities'; import { Constants } from '../utilities/Constants'; +import { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; /** * This interfaces specifies values for parameters that must be parsed before the CLI @@ -41,7 +47,8 @@ export class HeftCommandLineParser extends CommandLineParser { private readonly _debugFlag: CommandLineFlagParameter; private readonly _unmanagedFlag: CommandLineFlagParameter; private readonly _debug: boolean; - private readonly _terminalProvider: ConsoleTerminalProvider; + private readonly _terminalProvider: ITerminalProvider; + private readonly _childReporter: HeftChildReporter | undefined; private readonly _loggingManager: LoggingManager; private readonly _metricsCollector: MetricsCollector; private readonly _heftConfiguration: HeftConfiguration; @@ -77,12 +84,22 @@ export class HeftCommandLineParser extends CommandLineParser { this._debug = !!preInitializationArgumentValues.debug; // Enable debug and verbose logging if the "--debug" flag is set - this._terminalProvider = new ConsoleTerminalProvider({ - debugEnabled: this._debug, - verboseEnabled: this._debug - }); + this._childReporter = HeftChildReporter.tryInitialize(); + this._terminalProvider = + this._childReporter ?? + new ConsoleTerminalProvider({ + debugEnabled: this._debug, + verboseEnabled: this._debug + }); + if (this._debug && this._childReporter) { + this._childReporter.debugEnabled = true; + this._childReporter.verboseEnabled = true; + } this.globalTerminal = new Terminal(this._terminalProvider); - this._loggingManager = new LoggingManager({ terminalProvider: this._terminalProvider }); + this._loggingManager = new LoggingManager({ + terminalProvider: this._terminalProvider, + childReporter: this._childReporter + }); if (this._debug) { // Enable printing stacktraces if the "--debug" flag is set this._loggingManager.enablePrintStacks(); @@ -197,6 +214,7 @@ export class HeftCommandLineParser extends CommandLineParser { commandName, unaliasedCommandName }; + this._childReporter?.setCommandName(commandName); await super.onExecuteAsync(); } catch (e) { await this._reportErrorAndSetExitCodeAsync(e as Error); @@ -241,7 +259,11 @@ export class HeftCommandLineParser extends CommandLineParser { private async _reportErrorAndSetExitCodeAsync(error: Error): Promise { if (!(error instanceof AlreadyReportedError)) { - this.globalTerminal.writeErrorLine(error.toString()); + if (this._childReporter) { + this._childReporter.emitDiagnostic(Constants.heftPackageName, error, 'error'); + } else { + this.globalTerminal.writeErrorLine(error.toString()); + } } if (this._debug) { diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts new file mode 100644 index 00000000000..06ea9e56a48 --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; + +import { HeftChildReporter } from './HeftChildReporter'; + +describe(HeftChildReporter.name, () => { + it('preserves standalone behavior when no parent descriptors are present', () => { + expect(HeftChildReporter.tryInitialize({})).toBeUndefined(); + }); + + it('negotiates context and emits ordered structured output and diagnostics', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) { + process.stdout.write('fallback'); + process.exit(2); + } + if (reporter.parentReporterName !== 'json' || reporter.terminalWidth !== 132) process.exit(3); + reporter.setCommandName('build'); + reporter.write('visible output\\n', 0); + reporter.write('hidden verbose output\\n', 3); + reporter.emitDiagnostic('typescript', new Error('structured failure'), 'error'); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 132 + } + })}\n` + ); + } + }); + + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const records: Array> = descriptorText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records[0].kind).toBe('hello'); + expect(records.slice(1).map((record) => record.type)).toEqual(['externalOutput', 'diagnosticEmitted']); + expect(records.slice(1).map((record) => record.sequence)).toEqual([1, 2]); + expect((records[1].scope as { commandName?: string }).commandName).toBe('build'); + expect((records[1].payload as { text?: string }).text).toBe('visible output\n'); + expect((records[2].payload as { severity?: string }).severity).toBe('error'); + }); + + it('uses safe context defaults when the accepted context capability has no payload', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) process.exit(2); + if (reporter.parentReporterName !== 'plaintext' || reporter.terminalWidth !== 80) process.exit(3); + reporter.write('structured with defaults\\n', 0); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (descriptorText.split('\n').length === 2) { + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [] + })}\n` + ); + } + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(descriptorText).toContain('structured with defaults'); + }); +}); diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts new file mode 100644 index 00000000000..e1c9f8c378d --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { EOL } from 'node:os'; + +import { FileError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; + +import { Constants } from '../../utilities/Constants'; + +const CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; +const CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD' = '_RUSH_REPORTER_CHILD_ACK_FD'; +interface IProtocolVersion { + readonly major: number; + readonly minor: number; +} +const PROTOCOL_VERSION: IProtocolVersion = { major: 1, minor: 2 }; +const MAX_RECORD_BYTES: number = 1024 * 1024; +const MAX_OUTPUT_CHUNK_BYTES: number = 64 * 1024; +const CAPABILITIES: readonly string[] = ['heft-child-events-v1', 'reporter-context-v1']; +const REPORTER_NAMES: ReadonlySet = new Set(['default', 'ai', 'json', 'plaintext', 'file', 'legacy']); +const LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +interface IReporterChildContext { + readonly reporter: string; + readonly logLevel: 'quiet' | 'normal' | 'verbose' | 'debug'; + readonly color: boolean; + readonly terminalWidth: number; +} + +interface IReporterEventScope { + readonly commandName?: string; +} + +function readDescriptorFd(env: Record, name: string): number | undefined { + const raw: string | undefined = env[name]; + if (raw === undefined || !/^\d+$/.test(raw)) { + return undefined; + } + const parsed: number = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; +} + +function encodeRecord(value: unknown): string { + const json: string = JSON.stringify(value); + if (Buffer.byteLength(json, 'utf8') > MAX_RECORD_BYTES) { + throw new Error(`The reporter record exceeds the ${MAX_RECORD_BYTES}-byte protocol limit.`); + } + return `${json}\n`; +} + +function chunkUtf8Text(text: string): string[] { + const chunks: string[] = []; + let offset: number = 0; + while (offset < text.length) { + let end: number = offset; + let byteLength: number = 0; + while (end < text.length) { + const codePoint: number = text.codePointAt(end)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (end > offset && byteLength + codePointBytes > MAX_OUTPUT_CHUNK_BYTES) { + break; + } + byteLength += codePointBytes; + end += codeUnits; + } + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} + +function parseAck(text: string): IReporterChildContext | undefined { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + const ack: Record = value as Record; + const version: Record | undefined = + typeof ack.protocolVersion === 'object' && ack.protocolVersion !== null + ? (ack.protocolVersion as Record) + : undefined; + const acceptedCapabilities: unknown = ack.acceptedCapabilities; + const rejectedRequiredFeatures: unknown = ack.rejectedRequiredFeatures; + if ( + ack.kind !== 'helloAck' || + version?.major !== PROTOCOL_VERSION.major || + !Array.isArray(acceptedCapabilities) || + !acceptedCapabilities.every((item: unknown) => typeof item === 'string') || + !acceptedCapabilities.includes('heft-child-events-v1') || + !Array.isArray(rejectedRequiredFeatures) || + rejectedRequiredFeatures.length > 0 + ) { + return undefined; + } + + if (!acceptedCapabilities.includes('reporter-context-v1')) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + const context: unknown = ack.context; + if (context === undefined) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + if (typeof context !== 'object' || context === null || Array.isArray(context)) { + return undefined; + } + const record: Record = context as Record; + if ( + typeof record.reporter !== 'string' || + !REPORTER_NAMES.has(record.reporter) || + typeof record.logLevel !== 'string' || + !LOG_LEVELS.has(record.logLevel) || + typeof record.color !== 'boolean' || + typeof record.terminalWidth !== 'number' || + !Number.isSafeInteger(record.terminalWidth) || + record.terminalWidth < 1 + ) { + return undefined; + } + const logLevel: IReporterChildContext['logLevel'] = + record.logLevel === 'quiet' || + record.logLevel === 'normal' || + record.logLevel === 'verbose' || + record.logLevel === 'debug' + ? record.logLevel + : 'normal'; + return { + reporter: record.reporter, + logLevel, + color: record.color, + terminalWidth: record.terminalWidth + }; +} + +/** + * Bridges Heft terminal and diagnostic output onto an inherited Rush reporter channel. + * + * @internal + */ +export class HeftChildReporter implements ITerminalProvider { + public readonly supportsColor: boolean; + public readonly eolCharacter: string = EOL; + public readonly parentReporterName: string; + public readonly terminalWidth: number; + public verboseEnabled: boolean; + public debugEnabled: boolean; + + private readonly _descriptorFd: number; + private readonly _sourceVersion: string; + private readonly _sessionId: string; + private _commandName: string | undefined; + private _sequence: number = 1; + private _nextEventId: number = 1; + + private constructor(descriptorFd: number, sourceVersion: string, context: IReporterChildContext) { + this._descriptorFd = descriptorFd; + this._sourceVersion = sourceVersion; + this._sessionId = crypto.randomUUID(); + this.parentReporterName = context.reporter; + this.terminalWidth = context.terminalWidth; + this.supportsColor = context.color; + this.verboseEnabled = context.logLevel === 'verbose' || context.logLevel === 'debug'; + this.debugEnabled = context.logLevel === 'debug'; + } + + public static tryInitialize( + env: Record = process.env + ): HeftChildReporter | undefined { + const descriptorFd: number | undefined = readDescriptorFd(env, CHILD_FD_ENV_VAR); + const ackDescriptorFd: number | undefined = readDescriptorFd(env, CHILD_ACK_FD_ENV_VAR); + delete env[CHILD_FD_ENV_VAR]; + delete env[CHILD_ACK_FD_ENV_VAR]; + if (descriptorFd === undefined || ackDescriptorFd === undefined || descriptorFd === ackDescriptorFd) { + return undefined; + } + + const packageJson: IPackageJson | undefined = PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); + const version: string = packageJson?.version ?? 'unknown'; + let reporter: HeftChildReporter | undefined; + try { + fs.writeSync( + descriptorFd, + encodeRecord({ + kind: 'hello', + protocolVersion: PROTOCOL_VERSION, + producerVersion: `${Constants.heftPackageName} ${version}`, + capabilities: CAPABILITIES, + requiredFeatures: [] + }) + ); + + const chunks: Buffer[] = []; + let totalBytes: number = 0; + const buffer: Buffer = Buffer.allocUnsafe(4096); + for (;;) { + const byteCount: number = fs.readSync(ackDescriptorFd, buffer, 0, buffer.length, null); + if (byteCount === 0) { + break; + } + totalBytes += byteCount; + if (totalBytes > MAX_RECORD_BYTES + 1) { + break; + } + chunks.push(Buffer.from(buffer.subarray(0, byteCount))); + const text: string = Buffer.concat(chunks).toString('utf8'); + const newlineIndex: number = text.indexOf('\n'); + if (newlineIndex >= 0) { + const context: IReporterChildContext | undefined = parseAck(text.slice(0, newlineIndex)); + reporter = context ? new HeftChildReporter(descriptorFd, version, context) : undefined; + break; + } + } + } catch (error) { + if (typeof (error as NodeJS.ErrnoException).code !== 'string') { + throw error; + } + } + try { + fs.closeSync(ackDescriptorFd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EBADF') { + throw error; + } + } + return reporter; + } + + public setCommandName(commandName: string): void { + this._commandName = commandName; + } + + public write(data: string, severity: TerminalProviderSeverity): void { + if (severity === TerminalProviderSeverity.verbose && !this.verboseEnabled) { + return; + } + if (severity === TerminalProviderSeverity.debug && !this.debugEnabled) { + return; + } + const stream: 'stdout' | 'stderr' = + severity === TerminalProviderSeverity.warning || severity === TerminalProviderSeverity.error + ? 'stderr' + : 'stdout'; + for (const chunk of chunkUtf8Text(data)) { + this._emit('externalOutput', 'local-sensitive', { stream, text: chunk }); + } + } + + public emitDiagnostic(loggerName: string, error: Error, severity: 'warning' | 'error'): void { + const source: + | { + readonly kind: 'file'; + readonly file: string; + readonly line: number | undefined; + readonly column: number | undefined; + readonly toolName: string; + } + | { readonly kind: 'tool'; readonly toolName: string } = + error instanceof FileError + ? { + kind: 'file', + file: error.absolutePath, + line: error.line, + column: error.column, + toolName: loggerName + } + : { kind: 'tool', toolName: loggerName }; + const diagnostic: Record = { + diagnosticId: crypto.randomUUID(), + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity, + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: loggerName, privacy: 'public' }, + code: { value: error.name, privacy: 'public' }, + message: { value: error.message, privacy: 'local-sensitive' } + }, + source + }; + + try { + this._emit('diagnosticEmitted', 'local-sensitive', diagnostic); + } catch (emitError) { + if (!(emitError instanceof Error) || !emitError.message.includes('protocol limit')) { + throw emitError; + } + this.write(`[${loggerName}] ${severity}: ${error.message}${EOL}`, TerminalProviderSeverity.error); + } + } + + private _emit( + type: 'diagnosticEmitted' | 'externalOutput', + privacy: 'local-sensitive', + payload: unknown + ): void { + const scope: IReporterEventScope | undefined = + this._commandName === undefined ? undefined : { commandName: this._commandName }; + fs.writeSync( + this._descriptorFd, + encodeRecord({ + protocolVersion: PROTOCOL_VERSION, + eventId: `child_${this._nextEventId++}`, + sessionId: this._sessionId, + sequence: this._sequence++, + timestamp: new Date().toISOString(), + source: { + packageName: Constants.heftPackageName, + packageVersion: this._sourceVersion + }, + scope, + privacy, + required: type === 'diagnosticEmitted', + type, + payload + }) + ); + } +} diff --git a/apps/heft/src/pluginFramework/logging/LoggingManager.ts b/apps/heft/src/pluginFramework/logging/LoggingManager.ts index 36c243a8084..8124a362e53 100644 --- a/apps/heft/src/pluginFramework/logging/LoggingManager.ts +++ b/apps/heft/src/pluginFramework/logging/LoggingManager.ts @@ -8,9 +8,11 @@ import { } from '@rushstack/node-core-library'; import type { ITerminalProvider } from '@rushstack/terminal'; +import type { HeftChildReporter } from './HeftChildReporter'; import { ScopedLogger } from './ScopedLogger'; export interface ILoggingManagerOptions { terminalProvider: ITerminalProvider; + childReporter?: HeftChildReporter; } export class LoggingManager { @@ -54,7 +56,10 @@ export class LoggingManager { terminalProvider: this._options.terminalProvider, getShouldPrintStacks: () => this._shouldPrintStacks, errorHasBeenEmittedCallback: () => (this._hasAnyErrors = true), - warningHasBeenEmittedCallback: () => (this._hasAnyWarnings = true) + warningHasBeenEmittedCallback: () => (this._hasAnyWarnings = true), + structuredDiagnosticCallback: this._options.childReporter + ? (error, severity) => this._options.childReporter!.emitDiagnostic(loggerName, error, severity) + : undefined }); this._scopedLoggers.set(loggerName, scopedLogger); return scopedLogger; diff --git a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts index 357289ecc5d..42b5283093b 100644 --- a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts +++ b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts @@ -54,6 +54,7 @@ export interface IScopedLoggerOptions { getShouldPrintStacks: () => boolean; errorHasBeenEmittedCallback: () => void; warningHasBeenEmittedCallback: () => void; + structuredDiagnosticCallback?: (error: Error, severity: 'warning' | 'error') => void; } export class ScopedLogger implements IScopedLogger { @@ -107,7 +108,11 @@ export class ScopedLogger implements IScopedLogger { public emitError(error: Error): void { this._options.errorHasBeenEmittedCallback(); this._errors.push(error); - this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + if (this._options.structuredDiagnosticCallback) { + this._options.structuredDiagnosticCallback(error, 'error'); + } else { + this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + } if (this._shouldPrintStacks && error.stack) { this.terminal.writeErrorLine(error.stack); } @@ -119,7 +124,11 @@ export class ScopedLogger implements IScopedLogger { public emitWarning(warning: Error): void { this._options.warningHasBeenEmittedCallback(); this._warnings.push(warning); - this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + if (this._options.structuredDiagnosticCallback) { + this._options.structuredDiagnosticCallback(warning, 'warning'); + } else { + this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + } if (this._shouldPrintStacks && warning.stack) { this.terminal.writeWarningLine(warning.stack); } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 48e04551a5d..311ceec5739 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'; import type { ILaunchOptions } from '@microsoft/rush-lib'; -import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; +import { + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, + REPORTER_PROTOCOL_VERSION, + resolveColorEnabled +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -163,6 +167,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); + const requestId: string = sessionId; if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { reporterHost.sink.emit({ protocolVersion: REPORTER_PROTOCOL_VERSION, @@ -183,7 +188,23 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr reporter: { eventSink: reporterHost.sink, sessionId, - operationStreamEnabled: reporterHost.selection.enabled + operationStreamEnabled: reporterHost.selection.enabled, + childProcessReporter: reporterHost.selection.enabled + ? { + requestId, + context: { + reporter: reporterHost.selection.reporter, + logLevel: reporterHost.selection.logLevel, + color: + reporterHost.selection.reporter === 'default' + ? resolveColorEnabled(process.env, process.stdout.isTTY === true) + : false, + terminalWidth: process.stdout.columns ?? 80 + }, + ingestForeignEnvelope: (envelope) => + reporterHost.host.manager.ingestForeignEnvelope(envelope) + } + : undefined }, reporterCloseAsync }; diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs index d61c29f4dad..ae7220e270f 100644 --- a/apps/rush/src/test/sandbox/reporter-demo/run.mjs +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -82,6 +82,13 @@ const purgeLogMatch = tempPurge.stderr.match(/^Rush full log: (.+)$/m); if (!purgeLogMatch || purgeLogMatch[1].startsWith(tempOverride) || !fs.existsSync(purgeLogMatch[1])) { throw new Error('The active purge reporter log was not preserved outside RUSH_TEMP_FOLDER.'); } +const heftChild = run('heft-child', [ + 'rebuild', + '--only', + '@rushstack/rush-reporter', + '--reporter=json', + '--log-level=debug' +]).stdout; function parseNdjson(text, name) { if (text.includes('\u001b')) { @@ -128,6 +135,39 @@ for (const [name, events] of [ } } } +const heftChildEvents = heftChild + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +const correlatedChildEvents = heftChildEvents.filter((event) => event.parentSessionId); +if (correlatedChildEvents.length === 0) { + throw new Error('The current Heft child did not negotiate structured reporting.'); +} +if ( + correlatedChildEvents.some( + (event, index) => index > 0 && event.sourceSequence <= correlatedChildEvents[index - 1].sourceSequence + ) +) { + throw new Error('The current Heft child source sequence was not preserved in order.'); +} +if ( + correlatedChildEvents.some( + (event) => + event.source.packageName !== '@rushstack/heft' || + !event.parentRequestId || + !event.parentOperationId || + event.scope?.operationId !== event.parentOperationId + ) +) { + throw new Error('The current Heft child events were not correlated to their parent operation.'); +} +if ( + correlatedChildEvents.some( + (event) => event.type === 'externalOutput' && Buffer.byteLength(event.payload.text, 'utf8') > 64 * 1024 + ) +) { + throw new Error('The current Heft child exceeded the external output chunk limit.'); +} const logMatch = plaintext.match(/^Full log: (.+)$/m); if (!logMatch || !path.isAbsolute(logMatch[1]) || !fs.existsSync(logMatch[1])) { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 00000000000..465b34aaf21 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Relay compatible Heft child events through the selected Rush reporter with ordered raw fallback and problem matcher diagnostics.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 00000000000..42fc090934f --- /dev/null +++ b/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Emit negotiated structured child output and diagnostics when Rush provides a reporter descriptor, while preserving standalone terminal behavior.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 00000000000..84702875659 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add bidirectional Heft child negotiation, parent-owned rendering context, bounded structured output, and streaming problem matcher recovery.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 0a0b849e956..de1a68200a2 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -10,6 +10,7 @@ import { AsyncParallelHook } from 'tapable'; import { AsyncSeriesBailHook } from 'tapable'; import { AsyncSeriesHook } from 'tapable'; import { AsyncSeriesWaterfallHook } from 'tapable'; +import type * as child_process from 'node:child_process'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; @@ -23,6 +24,8 @@ import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterChildContext } from '@rushstack/rush-reporter'; +import { IReporterEventEnvelope } from '@rushstack/rush-reporter'; import { IReporterEventScope } from '@rushstack/rush-reporter'; import { IReporterEventSink } from '@rushstack/rush-reporter'; import { IRushDiagnostic } from '@rushstack/rush-reporter'; @@ -636,6 +639,18 @@ export interface _IOperationBuildCacheOptions { useDirectFileTransfersForBuildCache: boolean; } +// @internal +export interface _IOperationChildProcessReporter { + // (undocumented) + attachAsync(child: child_process.ChildProcess): Promise; + // (undocumented) + readonly environment: Readonly>; + // (undocumented) + readonly hasWarningOrError: boolean; + // (undocumented) + readonly stdio: child_process.StdioOptions; +} + // @alpha export interface IOperationExecutionResult extends IBaseOperationExecutionResult, IOperationLastState { readonly enabled: boolean; @@ -683,6 +698,7 @@ export interface IOperationGraphContext extends ICreateOperationsContext { // @internal export interface _IOperationGraphEventSink { + createChildProcessReporter?(operationId: string): _IOperationChildProcessReporter | undefined; onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk, iterationId: number): void; onOperationCompleted?(result: IOperationExecutionResult): void; @@ -753,6 +769,8 @@ export interface IOperationRunner { // @beta export interface IOperationRunnerContext { collatedWriter: CollatedWriter; + // @internal + createChildProcessReporter(): _IOperationChildProcessReporter | undefined; debugMode: boolean; environment: IEnvironment | undefined; error?: Error; @@ -1017,6 +1035,12 @@ export interface IRushSessionOptions { // @beta export interface IRushSessionReporterOptions { + // @internal + readonly childProcessReporter?: { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + }; readonly eventSink: IReporterEventSink; // @internal readonly flushAsync?: () => Promise; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index a11c6d1b877..adf5fabc13c 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -23,7 +23,7 @@ export class AiReporter implements IReporter { } // @beta -export function allocateChildDescriptor(fdNumber?: number): IChildDescriptorPlan; +export function allocateChildDescriptor(fdNumber?: number, ackFdNumber?: number): IChildDescriptorPlan; // @beta export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; @@ -191,14 +191,18 @@ export function getSignalExitCode(signal: NodeJS.Signals): number; // @beta export class HeftChildEmitter { constructor(options: IHeftChildEmitterOptions); + acceptHelloAck(value: unknown): boolean; + get context(): IReporterChildContext | undefined; emitEvent(input: IHeftChildEventInput): string | undefined; - readonly mode: HeftChildReporterMode; + emitOutput(stream: 'stdout' | 'stderr', text: string, scope?: IReporterEventScope): readonly string[]; + handleAckDescriptorClose(): void; + get mode(): HeftChildReporterMode; sendHello(): boolean; writeRaw(stream: 'stdout' | 'stderr', text: string): void; } // @beta -export type HeftChildReporterMode = 'structured' | 'raw-fallback'; +export type HeftChildReporterMode = 'negotiation-pending' | 'structured' | 'raw-fallback'; // @beta export class HeftDescriptorHost { @@ -353,6 +357,7 @@ export interface IBootstrapTruncation { // @beta export interface IChildDescriptorPlan { + readonly ackFdNumber: number; readonly env: Record; readonly fdNumber: number; readonly stdio: (string | number)[]; @@ -530,11 +535,14 @@ export interface IHeftChildResult { // @beta export interface IHeftDescriptorHostOptions { + readonly context?: IReporterChildContext; readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; readonly onNegotiation?: (result: IReporterHandshakeResult) => void; readonly parentOperationId?: string; + readonly parentRequestId?: string; readonly parentSessionId: string; - readonly supportedCapabilities?: readonly string[]; + readonly sendHelloAck?: (ack: IReporterHelloAck) => void; + readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -595,6 +603,11 @@ export interface INdjsonOptions { readonly maxRecordBytes?: number; } +// @beta +export class InvalidReporterHelloAckError extends Error { + constructor(reason: string); +} + // @beta export class InvalidReporterHelloError extends Error { constructor(reason: string); @@ -718,6 +731,14 @@ export interface IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export interface IReporterChildContext { + readonly color: boolean; + readonly logLevel: ReporterLogLevel; + readonly reporter: ReporterName; + readonly terminalWidth: number; +} + // @beta export interface IReporterCompatibilityDecision { readonly engineRendersLegacy: boolean; @@ -746,6 +767,7 @@ export interface IReporterEngineDescriptor { export interface IReporterEventEnvelope { readonly eventId: string; readonly parentOperationId?: string; + readonly parentRequestId?: string; readonly parentSessionId?: string; readonly payload: TPayload; readonly privacy: ReporterPrivacyClassification; @@ -794,6 +816,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { + readonly context?: IReporterChildContext; readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -817,6 +840,7 @@ export interface IReporterHello { // @beta export interface IReporterHelloAck { readonly acceptedCapabilities: readonly string[]; + readonly context?: IReporterChildContext; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; readonly rejectedRequiredFeatures: readonly string[]; @@ -942,6 +966,7 @@ export interface IResolveExitStatusOptions { // @beta export interface IRunProblemMatchersOptions { readonly maxDuplicates?: number; + readonly maxPartialLineBytes?: number; } // @beta @@ -1301,6 +1326,9 @@ export function parseReporterExtensionEventName(name: string): ReporterExtension // @beta export function parseReporterHello(value: unknown): IReporterHello; +// @beta +export function parseReporterHelloAck(value: unknown): IReporterHelloAck; + // @beta export class PlaintextReporter implements IReporter { constructor(options: IPlaintextReporterOptions); @@ -1332,6 +1360,18 @@ export class ProblemMatcherRegistry { register(matcher: IProblemMatcher): void; } +// @beta +export class ProblemMatcherRunner { + constructor(matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions); + flush(): readonly IRushDiagnostic[]; + get matchedLineCount(): number; + get result(): IProblemMatcherResult; + get suppressedDuplicateCount(): number; + get unmatchedLineCount(): number; + write(event: IReporterEventEnvelope): readonly IRushDiagnostic[]; + writeOutput(text: string, operationId?: string, stream?: string): readonly IRushDiagnostic[]; +} + // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ header: IBootstrapHandoffHeader | undefined; @@ -1339,6 +1379,9 @@ export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ discardedRecordCount: number; }>; +// @beta +export function readChildAckDescriptorFd(env: Record): number | undefined; + // @beta export function readChildDescriptorFd(env: Record): number | undefined; @@ -1361,7 +1404,7 @@ export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRe export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension", "operationStreamClosed", "operationCompleted"]; // @beta -export const REPORTER_KNOWN_CAPABILITIES: readonly []; +export const REPORTER_KNOWN_CAPABILITIES: readonly ["heft-child-events-v1", "reporter-context-v1"]; // @beta export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; @@ -1564,6 +1607,9 @@ export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_ // @beta export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; +// @beta +export const RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD'; + // @beta export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; diff --git a/libraries/reporter/src/events/IReporterEventEnvelope.ts b/libraries/reporter/src/events/IReporterEventEnvelope.ts index f0cd5495f7b..8b548a59d71 100644 --- a/libraries/reporter/src/events/IReporterEventEnvelope.ts +++ b/libraries/reporter/src/events/IReporterEventEnvelope.ts @@ -96,6 +96,11 @@ export interface IReporterEventEnvelope { */ readonly parentSessionId?: string; + /** + * The identifier of the parent request that spawned this child session. + */ + readonly parentRequestId?: string; + /** * The identifier of the parent operation that spawned the child session, when applicable. */ diff --git a/libraries/reporter/src/heft/HeftChildEmitter.ts b/libraries/reporter/src/heft/HeftChildEmitter.ts index 0178e5cefa1..fc8e704c30d 100644 --- a/libraries/reporter/src/heft/HeftChildEmitter.ts +++ b/libraries/reporter/src/heft/HeftChildEmitter.ts @@ -5,16 +5,32 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; import { isReporterEventRequired, type ReporterEventType } from '../events/ReporterEventType'; import { encodeNdjsonRecord } from '../protocol/Ndjson'; -import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; -import type { IReporterHello } from '../protocol/ReporterHandshake'; -import { readChildDescriptorFd, RUSH_REPORTER_CHILD_FD_ENV_VAR } from './HeftDescriptor'; +import { + isReporterProtocolCompatible, + REPORTER_PROTOCOL_LIMITS, + REPORTER_PROTOCOL_VERSION +} from '../protocol/ReporterProtocol'; +import { + parseReporterHelloAck, + REPORTER_KNOWN_CAPABILITIES, + type IReporterChildContext, + type IReporterHello, + type IReporterHelloAck +} from '../protocol/ReporterHandshake'; +import { chunkUtf8Text } from '../utilities/chunkUtf8Text'; +import { + readChildAckDescriptorFd, + readChildDescriptorFd, + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, + RUSH_REPORTER_CHILD_FD_ENV_VAR +} from './HeftDescriptor'; /** * The mode a Heft child reporter operates in. * * @beta */ -export type HeftChildReporterMode = 'structured' | 'raw-fallback'; +export type HeftChildReporterMode = 'negotiation-pending' | 'structured' | 'raw-fallback'; /** * An event a Heft child emits. @@ -105,9 +121,18 @@ export interface IHeftChildEmitterOptions { */ export class HeftChildEmitter { /** - * Whether the child emits structured events or falls back to raw streams. + * Whether the child is negotiating, emitting structured events, or using raw streams. */ - public readonly mode: HeftChildReporterMode; + public get mode(): HeftChildReporterMode { + return this._mode; + } + + /** + * Parent-owned rendering and filtering context accepted during negotiation. + */ + public get context(): IReporterChildContext | undefined { + return this._context; + } private readonly _writeDescriptor: ((text: string) => void) | undefined; private readonly _writeStdout: ((text: string) => void) | undefined; @@ -119,13 +144,21 @@ export class HeftChildEmitter { private readonly _capabilities: readonly string[]; private readonly _requiredFeatures: readonly string[]; private readonly _now: () => string; + private _mode: HeftChildReporterMode; + private _context: IReporterChildContext | undefined; + private _helloSent: boolean; private _sequence: number; private _nextEventId: number; public constructor(options: IHeftChildEmitterOptions) { const fd: number | undefined = readChildDescriptorFd(options.env); + const ackFd: number | undefined = readChildAckDescriptorFd(options.env); delete options.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]; - this.mode = fd !== undefined && options.writeDescriptor !== undefined ? 'structured' : 'raw-fallback'; + delete options.env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]; + this._mode = + fd !== undefined && ackFd !== undefined && fd !== ackFd && options.writeDescriptor !== undefined + ? 'negotiation-pending' + : 'raw-fallback'; this._writeDescriptor = options.writeDescriptor; this._writeStdout = options.writeStdout; @@ -134,18 +167,24 @@ export class HeftChildEmitter { this._source = options.source; this._producerVersion = options.producerVersion; this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; - this._capabilities = options.capabilities ?? []; - this._requiredFeatures = options.requiredFeatures ?? []; + this._capabilities = [...(options.capabilities ?? REPORTER_KNOWN_CAPABILITIES)]; + this._requiredFeatures = [...(options.requiredFeatures ?? [])]; this._now = options.now ?? (() => new Date().toISOString()); + this._helloSent = false; this._sequence = 1; this._nextEventId = 1; } /** - * Sends the hello handshake over the descriptor. Returns `false` in fallback mode. + * Sends the hello handshake over the descriptor. + * + * @remarks + * Structured events remain disabled until {@link HeftChildEmitter.acceptHelloAck} + * accepts a compatible acknowledgement containing `heft-child-events-v1`. + * Returns `false` in fallback mode or after the hello was already sent. */ public sendHello(): boolean { - if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + if (this._mode !== 'negotiation-pending' || this._writeDescriptor === undefined || this._helloSent) { return false; } const hello: IReporterHello = { @@ -155,16 +194,70 @@ export class HeftChildEmitter { capabilities: [...this._capabilities], requiredFeatures: [...this._requiredFeatures] }; - this._writeDescriptor(encodeNdjsonRecord(hello)); + try { + this._writeDescriptor(encodeNdjsonRecord(hello)); + this._helloSent = true; + return true; + } catch { + this._switchToRawFallback(); + return false; + } + } + + /** + * Accepts a decoded hello acknowledgement from the parent. + * + * @remarks + * Unsupported, rejected, malformed, or missing acknowledgements fail closed + * to raw output. This method never throws for untrusted acknowledgement data. + */ + public acceptHelloAck(value: unknown): boolean { + if (this._mode !== 'negotiation-pending' || !this._helloSent) { + return false; + } + + let ack: IReporterHelloAck; + try { + ack = parseReporterHelloAck(value); + } catch { + this._switchToRawFallback(); + return false; + } + + const advertisedCapabilities: ReadonlySet = new Set(this._capabilities); + const acceptedCapabilitiesAreValid: boolean = ack.acceptedCapabilities.every((capability: string) => + advertisedCapabilities.has(capability) + ); + if ( + !isReporterProtocolCompatible(this._protocolVersion, ack.protocolVersion) || + ack.rejectedRequiredFeatures.length > 0 || + !acceptedCapabilitiesAreValid || + !ack.acceptedCapabilities.includes('heft-child-events-v1') + ) { + this._switchToRawFallback(); + return false; + } + + this._context = ack.context === undefined ? undefined : Object.freeze({ ...ack.context }); + this._mode = 'structured'; return true; } + /** + * Reports that the acknowledgement descriptor closed without an accepted acknowledgement. + */ + public handleAckDescriptorClose(): void { + if (this._mode === 'negotiation-pending') { + this._switchToRawFallback(); + } + } + /** * 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) { + if (this._mode !== 'structured' || this._writeDescriptor === undefined) { return undefined; } const eventId: string = `child_${this._nextEventId++}`; @@ -185,6 +278,36 @@ export class HeftChildEmitter { return eventId; } + /** + * Emits raw output as bounded structured events, or writes it to the raw fallback stream. + * + * @returns the structured event ids, or an empty array in raw fallback mode + */ + public emitOutput( + stream: 'stdout' | 'stderr', + text: string, + scope?: IReporterEventScope + ): readonly string[] { + if (this._mode !== 'structured') { + this.writeRaw(stream, text); + return []; + } + + const eventIds: string[] = []; + for (const chunk of chunkUtf8Text(text, REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes)) { + const eventId: string | undefined = this.emitEvent({ + type: 'externalOutput', + privacy: 'local-sensitive', + scope, + payload: { stream, text: chunk } + }); + if (eventId !== undefined) { + eventIds.push(eventId); + } + } + return eventIds; + } + /** * Writes raw output to stdout or stderr, preserved for problem matchers. */ @@ -195,4 +318,9 @@ export class HeftChildEmitter { this._writeStdout?.(text); } } + + private _switchToRawFallback(): void { + this._mode = 'raw-fallback'; + this._context = undefined; + } } diff --git a/libraries/reporter/src/heft/HeftDescriptor.ts b/libraries/reporter/src/heft/HeftDescriptor.ts index 2e6e9b921c9..23cf9001eb6 100644 --- a/libraries/reporter/src/heft/HeftDescriptor.ts +++ b/libraries/reporter/src/heft/HeftDescriptor.ts @@ -11,6 +11,15 @@ import type { Readable, Writable } from 'node:stream'; */ export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; +/** + * The private environment variable that communicates the inherited reporter + * acknowledgement file descriptor number to a child process. + * + * @beta + */ +export const RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD' = + '_RUSH_REPORTER_CHILD_ACK_FD'; + /** * A plan for launching a child with an inherited reporter descriptor. * @@ -22,6 +31,11 @@ export interface IChildDescriptorPlan { */ readonly fdNumber: number; + /** + * The inherited file descriptor number the child reads the hello acknowledgement from. + */ + readonly ackFdNumber: number; + /** * The environment additions that communicate the descriptor to the child. */ @@ -76,28 +90,47 @@ export interface IHeftChildOutputTargets { * @remarks * stdout and stderr are piped so the parent can preserve and inspect old-Heft * fallback output before relaying it to the normal output streams. The reporter - * descriptor is an additional pipe at `fdNumber`, whose number is communicated - * through the private environment variable. + * descriptors are additional pipes at `fdNumber` and `ackFdNumber`, whose + * numbers are communicated through private environment variables. * * @param fdNumber - the descriptor number; defaults to 3 + * @param ackFdNumber - the acknowledgement descriptor number; defaults to the + * next descriptor after `fdNumber` * * @beta */ -export function allocateChildDescriptor(fdNumber: number = 3): IChildDescriptorPlan { +export function allocateChildDescriptor( + fdNumber: number = 3, + ackFdNumber: number = fdNumber + 1 +): IChildDescriptorPlan { if (!Number.isSafeInteger(fdNumber) || fdNumber < 3) { throw new RangeError( 'The reporter file descriptor number must be an integer greater than or equal to 3.' ); } + if (!Number.isSafeInteger(ackFdNumber) || ackFdNumber < 3) { + throw new RangeError( + 'The reporter acknowledgement file descriptor number must be an integer greater than or equal to 3.' + ); + } + if (fdNumber === ackFdNumber) { + throw new RangeError('The reporter event and acknowledgement file descriptors must be different.'); + } const stdio: (string | number)[] = ['inherit', 'pipe', 'pipe']; - while (stdio.length < fdNumber) { + const highestFdNumber: number = Math.max(fdNumber, ackFdNumber); + while (stdio.length <= highestFdNumber) { stdio.push('ignore'); } stdio[fdNumber] = 'pipe'; + stdio[ackFdNumber] = 'pipe'; return { fdNumber, - env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber) }, + ackFdNumber, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber), + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: String(ackFdNumber) + }, stdio }; } @@ -138,3 +171,22 @@ export function readChildDescriptorFd(env: Record): const parsed: number = Number(raw); return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; } + +/** + * Reads the inherited reporter acknowledgement descriptor number from the environment. + * + * @remarks + * Returns `undefined` when acknowledgement negotiation is unavailable. + * + * @param env - the environment variables + * + * @beta + */ +export function readChildAckDescriptorFd(env: Record): number | undefined { + const raw: string | undefined = env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]; + if (raw === undefined || !/^\d+$/.test(raw)) { + return undefined; + } + const parsed: number = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; +} diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index 52ad8feb4a3..bafac45eb78 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -10,9 +10,12 @@ import { } from '../events/ReporterEventType'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; -import { NdjsonDecoder } from '../protocol/Ndjson'; +import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; import { negotiateReporterHello, + REPORTER_KNOWN_CAPABILITIES, + type ReporterCapability, + type IReporterChildContext, type IReporterHello, type IReporterHelloAck, type IReporterHandshakeResult @@ -112,6 +115,11 @@ export interface IHeftDescriptorHostOptions { */ readonly parentSessionId: string; + /** + * The parent request id used to correlate child events. + */ + readonly parentRequestId?: string; + /** * The parent operation id used to correlate child events. */ @@ -125,7 +133,12 @@ export interface IHeftDescriptorHostOptions { /** * The capabilities the parent supports. */ - readonly supportedCapabilities?: readonly string[]; + readonly supportedCapabilities?: readonly ReporterCapability[]; + + /** + * Parent-owned rendering and filtering context offered to the child. + */ + readonly context?: IReporterChildContext; /** * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. @@ -137,6 +150,11 @@ export interface IHeftDescriptorHostOptions { * Called once when the first record is accepted or rejected. */ readonly onNegotiation?: (result: IReporterHandshakeResult) => void; + + /** + * Sends the acknowledgement to the child immediately after processing its hello. + */ + readonly sendHelloAck?: (ack: IReporterHelloAck) => void; } /** @@ -184,11 +202,14 @@ export interface IHeftChildResult { */ export class HeftDescriptorHost { private readonly _parentSessionId: string; + private readonly _parentRequestId: string | undefined; private readonly _parentOperationId: string | undefined; private readonly _supportedProtocolVersion: IReporterProtocolVersion; - private readonly _supportedCapabilities: readonly string[] | undefined; + private readonly _supportedCapabilities: readonly ReporterCapability[]; + private readonly _reporterContext: IReporterChildContext | undefined; private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; private readonly _onNegotiation: ((result: IReporterHandshakeResult) => void) | undefined; + private readonly _sendHelloAck: ((ack: IReporterHelloAck) => void) | undefined; private _negotiation: IReporterHandshakeResult | undefined; private _protocolFailure: IRushDiagnostic | undefined; @@ -196,11 +217,14 @@ export class HeftDescriptorHost { public constructor(options: IHeftDescriptorHostOptions) { this._parentSessionId = options.parentSessionId; + this._parentRequestId = options.parentRequestId; this._parentOperationId = options.parentOperationId; this._supportedProtocolVersion = options.supportedProtocolVersion; - this._supportedCapabilities = options.supportedCapabilities; + this._supportedCapabilities = options.supportedCapabilities ?? REPORTER_KNOWN_CAPABILITIES; + this._reporterContext = options.context; this._forwardEnvelope = options.forwardEnvelope; this._onNegotiation = options.onNegotiation; + this._sendHelloAck = options.sendHelloAck; } /** @@ -223,10 +247,10 @@ export class HeftDescriptorHost { } const result: IReporterHandshakeResult = negotiateReporterHello(record, { supportedProtocolVersion: this._supportedProtocolVersion, - supportedCapabilities: this._supportedCapabilities + supportedCapabilities: this._supportedCapabilities, + context: this._reporterContext }); - this._negotiation = result; - this._onNegotiation?.(result); + this._setNegotiation(result); return result.accepted; } if (!this._negotiation.accepted) { @@ -236,6 +260,11 @@ export class HeftDescriptorHost { if (!isReporterEventRecord(record)) { return this._rejectMalformedStream('an event record did not contain a valid reporter envelope'); } + if (!this._negotiation.ack.acceptedCapabilities.includes('heft-child-events-v1')) { + return this._rejectMalformedStream( + 'an event record was received without negotiating "heft-child-events-v1"' + ); + } if (record.protocolVersion.major !== this._negotiation.ack.protocolVersion.major) { return this._rejectMalformedStream( 'an event record used a protocol major different from the negotiated stream' @@ -251,7 +280,12 @@ export class HeftDescriptorHost { const correlated: IReporterEventEnvelope = { ...record, parentSessionId: this._parentSessionId, + parentRequestId: this._parentRequestId, parentOperationId: this._parentOperationId, + scope: + this._parentOperationId !== undefined && record.scope?.operationId === undefined + ? { ...record.scope, operationId: this._parentOperationId } + : record.scope, required: isReporterEventRequired(record.type), type: record.type }; @@ -279,7 +313,16 @@ export class HeftDescriptorHost { let records: unknown[]; try { records = decoder.decode(chunk); - } catch { + } catch (error) { + const decodedRecords: readonly unknown[] = + error instanceof NdjsonInvalidRecordError || error instanceof NdjsonRecordTooLargeError + ? error.decodedRecords + : []; + for (const record of decodedRecords) { + if (!this.processChildRecord(record)) { + break; + } + } this._rejectMalformedStream('its NDJSON could not be decoded within the protocol limits'); return; } @@ -292,7 +335,16 @@ export class HeftDescriptorHost { let records: unknown[]; try { records = decoder.flush(); - } catch { + } catch (error) { + const decodedRecords: readonly unknown[] = + error instanceof NdjsonInvalidRecordError || error instanceof NdjsonRecordTooLargeError + ? error.decodedRecords + : []; + for (const record of decodedRecords) { + if (!this.processChildRecord(record)) { + break; + } + } this._rejectMalformedStream('its trailing NDJSON record was invalid'); return this._result(); } @@ -366,9 +418,14 @@ export class HeftDescriptorHost { }, diagnostic: this._protocolFailure }; - this._negotiation = result; - this._onNegotiation?.(result); + this._setNegotiation(result); } return false; } + + private _setNegotiation(result: IReporterHandshakeResult): void { + this._negotiation = result; + this._sendHelloAck?.(result.ack); + this._onNegotiation?.(result); + } } diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 21672628495..68cb27a66e8 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -84,6 +84,7 @@ export { NdjsonDecoder } from './protocol/Ndjson'; export type { + IReporterChildContext, IReporterHello, IReporterHelloAck, IReporterHandshakeOptions, @@ -91,8 +92,10 @@ export type { ReporterCapability } from './protocol/ReporterHandshake'; export { + InvalidReporterHelloAckError, InvalidReporterHelloError, negotiateReporterHello, + parseReporterHelloAck, parseReporterHello, REPORTER_KNOWN_CAPABILITIES } from './protocol/ReporterHandshake'; @@ -295,7 +298,7 @@ export type { IProblemMatch, IProblemMatcher } from './matchers/ProblemMatcher'; export type { IGetMatchersOptions } from './matchers/ProblemMatcherRegistry'; export { ProblemMatcherRegistry } from './matchers/ProblemMatcherRegistry'; export type { IRunProblemMatchersOptions, IProblemMatcherResult } from './matchers/ProblemMatcherRunner'; -export { runProblemMatchers } from './matchers/ProblemMatcherRunner'; +export { ProblemMatcherRunner, runProblemMatchers } from './matchers/ProblemMatcherRunner'; export type { IChildDescriptorPlan, @@ -303,8 +306,10 @@ export type { IHeftChildOutputTargets } from './heft/HeftDescriptor'; export { + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, RUSH_REPORTER_CHILD_FD_ENV_VAR, allocateChildDescriptor, + readChildAckDescriptorFd, readChildDescriptorFd, relayHeftChildOutput } from './heft/HeftDescriptor'; diff --git a/libraries/reporter/src/matchers/ProblemMatcherRunner.ts b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts index 582b48ead3c..63f28057c26 100644 --- a/libraries/reporter/src/matchers/ProblemMatcherRunner.ts +++ b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts @@ -4,14 +4,21 @@ import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; -import { iterateExternalOutput, type IExternalOutputChunk } from '../scheduler/OperationOutputGrouping'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; import { normalizeAnsi } from './AnsiNormalization'; import type { IProblemMatcher, IProblemMatch } from './ProblemMatcher'; const DEFAULT_MAX_DUPLICATES: number = 3; +interface IPartialLine { + text: string; + byteLength: number; + overflowed: boolean; + operationId: string | undefined; +} + /** - * Options for {@link runProblemMatchers}. + * Options for {@link ProblemMatcherRunner} and {@link runProblemMatchers}. * * @beta */ @@ -20,6 +27,14 @@ export interface IRunProblemMatchersOptions { * The maximum number of identical diagnostics to emit. Defaults to 3. */ readonly maxDuplicates?: number; + + /** + * The maximum number of bytes retained for a partial line. Longer lines are + * counted as unmatched and discarded through their next newline. + * + * @defaultValue The reporter protocol NDJSON record limit. + */ + readonly maxPartialLineBytes?: number; } /** @@ -50,79 +65,230 @@ export interface IProblemMatcherResult { } /** - * Runs problem matchers over the uncollated external-output stream. + * Incrementally runs problem matchers over uncollated external output. * * @remarks - * The raw output events are never modified: matchers process an ANSI-normalized - * copy, reassembling lines split across chunks per operation. Recovered - * diagnostics link back to the operation and source location without replacing - * the evidence, unmatched text is preserved, and identical diagnostics are - * capped. - * - * @param events - the event stream carrying external output - * @param matchers - the active matchers - * @param options - duplicate cap options + * Only `externalOutput` events are inspected. Raw events are never modified or + * reordered: matchers receive ANSI-normalized copies of complete lines, with + * partial lines retained independently per operation under a fixed byte cap. + * Callers can skip this runner entirely after structured child negotiation. * * @beta */ -export function runProblemMatchers( - events: readonly IReporterEventEnvelope[], - matchers: readonly IProblemMatcher[], - options: IRunProblemMatchersOptions = {} -): IProblemMatcherResult { - const maxDuplicates: number = options.maxDuplicates ?? DEFAULT_MAX_DUPLICATES; - const diagnostics: IRushDiagnostic[] = []; - const duplicateCounts: Map = new Map(); - const partialLines: Map = new Map(); - let matchedLineCount: number = 0; - let unmatchedLineCount: number = 0; - let suppressedDuplicateCount: number = 0; - - const processLine = (line: string, operationId: string | undefined): void => { +export class ProblemMatcherRunner { + private readonly _matchers: readonly IProblemMatcher[]; + private readonly _maxDuplicates: number; + private readonly _maxPartialLineBytes: number; + private readonly _diagnostics: IRushDiagnostic[] = []; + private readonly _duplicateCounts: Map = new Map(); + private readonly _partialLines: Map = new Map(); + private _matchedLineCount: number = 0; + private _unmatchedLineCount: number = 0; + private _suppressedDuplicateCount: number = 0; + private _flushed: boolean = false; + + public constructor(matchers: readonly IProblemMatcher[], options: IRunProblemMatchersOptions = {}) { + const maxDuplicates: number = options.maxDuplicates ?? DEFAULT_MAX_DUPLICATES; + if (!Number.isSafeInteger(maxDuplicates) || maxDuplicates < 0) { + throw new RangeError('maxDuplicates must be a nonnegative safe integer.'); + } + const maxPartialLineBytes: number = + options.maxPartialLineBytes ?? REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes; + if (!Number.isSafeInteger(maxPartialLineBytes) || maxPartialLineBytes < 1) { + throw new RangeError('maxPartialLineBytes must be a positive safe integer.'); + } + + this._matchers = [...matchers]; + this._maxDuplicates = maxDuplicates; + this._maxPartialLineBytes = maxPartialLineBytes; + } + + /** + * The diagnostics and counters observed so far. + */ + public get result(): IProblemMatcherResult { + return { + diagnostics: [...this._diagnostics], + matchedLineCount: this._matchedLineCount, + unmatchedLineCount: this._unmatchedLineCount, + suppressedDuplicateCount: this._suppressedDuplicateCount + }; + } + + /** + * The number of matched lines observed so far. + */ + public get matchedLineCount(): number { + return this._matchedLineCount; + } + + /** + * The number of unmatched lines observed so far. + */ + public get unmatchedLineCount(): number { + return this._unmatchedLineCount; + } + + /** + * The number of duplicate diagnostics suppressed so far. + */ + public get suppressedDuplicateCount(): number { + return this._suppressedDuplicateCount; + } + + /** + * Processes one event and returns diagnostics recovered by that event. + */ + public write(event: IReporterEventEnvelope): readonly IRushDiagnostic[] { + if (this._flushed) { + throw new Error('Cannot write problem matcher events after flush().'); + } + if (event.type !== 'externalOutput') { + return []; + } + + const payload: { stream?: unknown; text?: unknown } = event.payload as { + stream?: unknown; + text?: unknown; + }; + const text: string = typeof payload.text === 'string' ? payload.text : ''; + const stream: string = typeof payload.stream === 'string' ? payload.stream : 'stdout'; + return this.writeOutput(text, event.scope?.operationId, stream); + } + + /** + * Processes one raw source chunk and returns diagnostics recovered by it. + * + * @remarks + * This is the direct integration surface for process runners that publish + * the raw event independently before invoking the matcher. + */ + public writeOutput( + text: string, + operationId?: string, + stream: string = 'stdout' + ): readonly IRushDiagnostic[] { + if (this._flushed) { + throw new Error('Cannot write problem matcher output after flush().'); + } + const key: string = `${operationId ?? ''}\0${stream}`; + const partial: IPartialLine = this._partialLines.get(key) ?? { + text: '', + byteLength: 0, + overflowed: false, + operationId + }; + const diagnosticStart: number = this._diagnostics.length; + + let offset: number = 0; + let newlineIndex: number = text.indexOf('\n'); + while (newlineIndex >= 0) { + this._appendLineFragment(partial, text.slice(offset, newlineIndex)); + this._finishLine(partial); + offset = newlineIndex + 1; + newlineIndex = text.indexOf('\n', offset); + } + this._appendLineFragment(partial, text.slice(offset)); + this._partialLines.set(key, partial); + + return this._diagnostics.slice(diagnosticStart); + } + + /** + * Processes all remaining partial lines and returns diagnostics recovered by them. + */ + public flush(): readonly IRushDiagnostic[] { + if (this._flushed) { + return []; + } + this._flushed = true; + const diagnosticStart: number = this._diagnostics.length; + for (const partial of this._partialLines.values()) { + this._finishLine(partial); + } + this._partialLines.clear(); + return this._diagnostics.slice(diagnosticStart); + } + + private _appendLineFragment(partial: IPartialLine, fragment: string): void { + if (fragment.length === 0 || partial.overflowed) { + return; + } + const fragmentBytes: number = Buffer.byteLength(fragment, 'utf8'); + if (partial.byteLength + fragmentBytes > this._maxPartialLineBytes) { + partial.text = ''; + partial.byteLength = 0; + partial.overflowed = true; + return; + } + partial.text += fragment; + partial.byteLength += fragmentBytes; + } + + private _finishLine(partial: IPartialLine): void { + if (partial.overflowed) { + this._unmatchedLineCount++; + } else { + this._processLine( + partial.text.endsWith('\r') ? partial.text.slice(0, -1) : partial.text, + partial.operationId + ); + } + partial.text = ''; + partial.byteLength = 0; + partial.overflowed = false; + } + + private _processLine(line: string, operationId: string | undefined): void { const normalizedLine: string = normalizeAnsi(line); if (normalizedLine.length === 0) { return; } - for (const matcher of matchers) { + for (const matcher of this._matchers) { const match: RegExpMatchArray | null = normalizedLine.match(matcher.pattern); if (match) { - matchedLineCount++; + this._matchedLineCount++; const problem: IProblemMatch = matcher.extract(match); const key: string = `${operationId ?? ''}|${matcher.tool}|${problem.code ?? ''}|${problem.file ?? ''}|` + `${problem.line ?? ''}|${problem.column ?? ''}|${problem.message}`; - const seen: number = duplicateCounts.get(key) ?? 0; - duplicateCounts.set(key, seen + 1); - if (seen >= maxDuplicates) { - suppressedDuplicateCount++; + const seen: number = this._duplicateCounts.get(key) ?? 0; + this._duplicateCounts.set(key, seen + 1); + if (seen >= this._maxDuplicates) { + this._suppressedDuplicateCount++; return; } - diagnostics.push(buildDiagnostic(matcher, problem, operationId)); + this._diagnostics.push(buildDiagnostic(matcher, problem, operationId)); return; } } - unmatchedLineCount++; - }; - - const chunks: IExternalOutputChunk[] = iterateExternalOutput(events); - for (const chunk of chunks) { - const key: string = chunk.operationId ?? ''; - const buffered: string = (partialLines.get(key) ?? '') + chunk.text; - const lines: string[] = buffered.split('\n'); - const remainder: string = lines.pop() ?? ''; - for (const line of lines) { - processLine(line.endsWith('\r') ? line.slice(0, -1) : line, chunk.operationId); - } - partialLines.set(key, remainder); - } - for (const [key, remainder] of partialLines) { - processLine( - remainder.endsWith('\r') ? remainder.slice(0, -1) : remainder, - key.length > 0 ? key : undefined - ); + this._unmatchedLineCount++; } +} - return { diagnostics, matchedLineCount, unmatchedLineCount, suppressedDuplicateCount }; +/** + * Runs problem matchers over the uncollated external-output stream. + * + * @remarks + * This convenience batch API delegates to {@link ProblemMatcherRunner}. + * + * @param events - the event stream carrying external output + * @param matchers - the active matchers + * @param options - duplicate and buffering cap options + * + * @beta + */ +export function runProblemMatchers( + events: readonly IReporterEventEnvelope[], + matchers: readonly IProblemMatcher[], + options: IRunProblemMatchersOptions = {} +): IProblemMatcherResult { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner(matchers, options); + for (const event of events) { + runner.write(event); + } + runner.flush(); + return runner.result; } function buildDiagnostic( diff --git a/libraries/reporter/src/protocol/ReporterHandshake.ts b/libraries/reporter/src/protocol/ReporterHandshake.ts index 89f485f543a..f3eee10f141 100644 --- a/libraries/reporter/src/protocol/ReporterHandshake.ts +++ b/libraries/reporter/src/protocol/ReporterHandshake.ts @@ -4,6 +4,8 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; +import type { ReporterName, ReporterLogLevel } from '../config/ReporterNames'; +import { isSupportedReporterName, isSupportedLogLevel } from '../config/ReporterNames'; import { isReporterProtocolCompatible } from './ReporterProtocol'; /** @@ -51,6 +53,37 @@ export class InvalidReporterHelloError extends Error { } } +/** + * Parent-owned rendering and filtering context shared with a child producer. + * + * @remarks + * This context is advisory and read-only. A child must not use it to select or + * create the parent's reporters. + * + * @beta + */ +export interface IReporterChildContext { + /** + * The parent's selected primary reporter. + */ + readonly reporter: ReporterName; + + /** + * The parent's selected log level. + */ + readonly logLevel: ReporterLogLevel; + + /** + * Whether the parent renders color. + */ + readonly color: boolean; + + /** + * The parent's terminal width in columns. + */ + readonly terminalWidth: number; +} + /** * The consumer's reply that accepts capabilities and reports unsupported required features. * @@ -76,6 +109,25 @@ export interface IReporterHelloAck { * The producer's required features the consumer does not support. */ readonly rejectedRequiredFeatures: readonly string[]; + + /** + * Parent-owned rendering and filtering context, present only when the + * `reporter-context-v1` capability was accepted. + */ + readonly context?: IReporterChildContext; +} + +/** + * Thrown when an untrusted wire value is not a valid reporter hello acknowledgement. + * + * @beta + */ +export class InvalidReporterHelloAckError extends Error { + public constructor(reason: string) { + super(`Invalid reporter hello acknowledgement: ${reason}`); + this.name = 'InvalidReporterHelloAckError'; + Object.setPrototypeOf(this, InvalidReporterHelloAckError.prototype); + } } /** @@ -88,12 +140,10 @@ export interface IReporterHelloAck { * ignored and unknown required features cause rejection, per protocol rules. * This registry makes the Rush-known set explicit; additions go through API * review like every other contract change. The registry is intentionally - * empty until the first negotiated capability ships. - * * @beta */ // eslint-disable-next-line @typescript-eslint/typedef -- literal inference feeds the derived ReporterCapability type -export const REPORTER_KNOWN_CAPABILITIES = [] as const; +export const REPORTER_KNOWN_CAPABILITIES = ['heft-child-events-v1', 'reporter-context-v1'] as const; /** * A wire capability name. Known members keep autocomplete; unknown members @@ -120,6 +170,11 @@ export interface IReporterHandshakeOptions { * an unknown optional capability and is simply not accepted. */ readonly supportedCapabilities?: readonly ReporterCapability[]; + + /** + * Parent-owned context to include when `reporter-context-v1` is accepted. + */ + readonly context?: IReporterChildContext; } /** @@ -168,6 +223,34 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); } +function parseReporterChildContext(value: unknown): IReporterChildContext { + if (!isRecord(value)) { + throw new InvalidReporterHelloAckError('context must be an object.'); + } + if (typeof value.reporter !== 'string' || !isSupportedReporterName(value.reporter)) { + throw new InvalidReporterHelloAckError('context.reporter must be a supported reporter name.'); + } + if (typeof value.logLevel !== 'string' || !isSupportedLogLevel(value.logLevel)) { + throw new InvalidReporterHelloAckError('context.logLevel must be a supported reporter log level.'); + } + if (typeof value.color !== 'boolean') { + throw new InvalidReporterHelloAckError('context.color must be a boolean.'); + } + if ( + typeof value.terminalWidth !== 'number' || + !Number.isSafeInteger(value.terminalWidth) || + value.terminalWidth < 1 + ) { + throw new InvalidReporterHelloAckError('context.terminalWidth must be a positive integer.'); + } + return { + reporter: value.reporter, + logLevel: value.logLevel, + color: value.color, + terminalWidth: value.terminalWidth + }; +} + /** * Validates and parses an untrusted wire value as a reporter hello message. * @@ -205,6 +288,49 @@ export function parseReporterHello(value: unknown): IReporterHello { }; } +/** + * Validates and parses an untrusted wire value as a reporter hello acknowledgement. + * + * @param value - the decoded NDJSON value + * @throws {@link InvalidReporterHelloAckError} if the value is malformed + * + * @beta + */ +export function parseReporterHelloAck(value: unknown): IReporterHelloAck { + if (!isRecord(value) || value.kind !== 'helloAck') { + throw new InvalidReporterHelloAckError('expected kind "helloAck".'); + } + if (!isProtocolVersion(value.protocolVersion)) { + throw new InvalidReporterHelloAckError( + 'protocolVersion must contain nonnegative integer major and minor.' + ); + } + if (!isStringArray(value.acceptedCapabilities)) { + throw new InvalidReporterHelloAckError('acceptedCapabilities must be an array of strings.'); + } + if (!isStringArray(value.rejectedRequiredFeatures)) { + throw new InvalidReporterHelloAckError('rejectedRequiredFeatures must be an array of strings.'); + } + const context: IReporterChildContext | undefined = + value.context === undefined ? undefined : parseReporterChildContext(value.context); + if (context !== undefined && !value.acceptedCapabilities.includes('reporter-context-v1')) { + throw new InvalidReporterHelloAckError( + 'context requires the "reporter-context-v1" capability to be accepted.' + ); + } + + return { + kind: 'helloAck', + protocolVersion: { + major: value.protocolVersion.major, + minor: value.protocolVersion.minor + }, + acceptedCapabilities: [...value.acceptedCapabilities], + rejectedRequiredFeatures: [...value.rejectedRequiredFeatures], + ...(context === undefined ? {} : { context }) + }; +} + /** * Negotiates a producer's hello against the consumer's supported protocol. * @@ -237,12 +363,17 @@ export function negotiateReporterHello( const majorSupported: boolean = isReporterProtocolCompatible(consumerVersion, hello.protocolVersion); const accepted: boolean = majorSupported && rejectedRequiredFeatures.length === 0; + const context: IReporterChildContext | undefined = + acceptedCapabilities.includes('reporter-context-v1') && options.context !== undefined + ? parseReporterChildContext(options.context) + : undefined; const ack: IReporterHelloAck = { kind: 'helloAck', protocolVersion: consumerVersion, acceptedCapabilities, - rejectedRequiredFeatures + rejectedRequiredFeatures, + ...(context === undefined ? {} : { context }) }; if (accepted) { diff --git a/libraries/reporter/src/protocol/ReporterProtocol.ts b/libraries/reporter/src/protocol/ReporterProtocol.ts index 15dc630563d..a36935eec81 100644 --- a/libraries/reporter/src/protocol/ReporterProtocol.ts +++ b/libraries/reporter/src/protocol/ReporterProtocol.ts @@ -15,7 +15,7 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion */ export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion = { major: 1, - minor: 1 + minor: 2 }; /** diff --git a/libraries/reporter/src/test/Goldens.test.ts b/libraries/reporter/src/test/Goldens.test.ts index ce0eb3badc0..6324ad85f6a 100644 --- a/libraries/reporter/src/test/Goldens.test.ts +++ b/libraries/reporter/src/test/Goldens.test.ts @@ -171,7 +171,7 @@ describe('compatibility goldens', () => { // full-detail reporter can retain the complete record. const forwardCompatible: Record = { ...({ - protocolVersion: { major: 1, minor: 1 }, + protocolVersion: { major: 1, minor: 3 }, eventId: 'evt_future', sessionId: 'sess_root', sequence: 9, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index bd1f0d0eed6..66d39e2d591 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -2,11 +2,15 @@ // See LICENSE in the project root for license information. import * as childProcess from 'node:child_process'; -import { PassThrough, type Readable } from 'node:stream'; +import { PassThrough, type Readable, type Writable } from 'node:stream'; import { allocateChildDescriptor, + encodeNdjsonRecord, + readChildAckDescriptorFd, readChildDescriptorFd, + REPORTER_PROTOCOL_LIMITS, + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, RUSH_REPORTER_CHILD_FD_ENV_VAR, HeftChildEmitter, HeftDescriptorHost, @@ -59,31 +63,42 @@ const TSC_MATCHER: IProblemMatcher = { }; describe('Heft descriptor allocation', () => { - it('allocates an inherited descriptor and communicates it by env var', () => { + it('allocates inherited event and acknowledgement descriptors', () => { const plan: IChildDescriptorPlan = allocateChildDescriptor(); expect(plan.fdNumber).toBe(3); + expect(plan.ackFdNumber).toBe(4); expect(plan.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBe('3'); + expect(plan.env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]).toBe('4'); expect(plan.stdio[3]).toBe('pipe'); + expect(plan.stdio[4]).toBe('pipe'); expect(plan.stdio.slice(0, 3)).toEqual(['inherit', 'pipe', 'pipe']); }); - it('reads or rejects the descriptor number from the environment', () => { + it('reads or rejects descriptor numbers from the environment', () => { expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' })).toBe(3); + expect(readChildAckDescriptorFd({ [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' })).toBe(4); expect(readChildDescriptorFd({})).toBeUndefined(); + expect(readChildAckDescriptorFd({})).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: 'abc' })).toBeUndefined(); + expect(readChildAckDescriptorFd({ [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4abc' })).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3abc' })).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '2' })).toBeUndefined(); }); it('rejects descriptor numbers that would replace standard streams', () => { expect(() => allocateChildDescriptor(2)).toThrow(/greater than or equal to 3/); + expect(() => allocateChildDescriptor(3, 2)).toThrow(/greater than or equal to 3/); + expect(() => allocateChildDescriptor(3, 3)).toThrow(/must be different/); }); }); describe('HeftChildEmitter', () => { - it('emits structured NDJSON when the descriptor is present', () => { + it('emits structured NDJSON only after a compatible acknowledgement', () => { let descriptor: string = ''; - const env: Record = { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }; + const env: Record = { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }; const emitter: HeftChildEmitter = new HeftChildEmitter({ env, childSessionId: 'child-sess', @@ -92,9 +107,32 @@ describe('HeftChildEmitter', () => { now: () => '2026-01-01T00:00:00.000Z', writeDescriptor: (text: string) => (descriptor += text) }); - expect(emitter.mode).toBe('structured'); + expect(emitter.mode).toBe('negotiation-pending'); expect(env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]).toBeUndefined(); expect(emitter.sendHello()).toBe(true); + expect(emitter.emitEvent({ type: 'commandStarted', payload: {} })).toBeUndefined(); + expect( + emitter.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'plaintext', + logLevel: 'verbose', + color: false, + terminalWidth: 100 + } + }) + ).toBe(true); + expect(emitter.mode).toBe('structured'); + expect(emitter.context).toEqual({ + reporter: 'plaintext', + logLevel: 'verbose', + color: false, + terminalWidth: 100 + }); const eventId: string | undefined = emitter.emitEvent({ type: 'commandStarted', payload: {} @@ -113,6 +151,85 @@ describe('HeftChildEmitter', () => { expect(records[2].required).toBe(false); }); + it('falls back for unsupported, malformed, or missing acknowledgements', () => { + const makeEmitter = (): HeftChildEmitter => { + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeDescriptor: () => undefined + }); + emitter.sendHello(); + return emitter; + }; + + const unsupported: HeftChildEmitter = makeEmitter(); + expect( + unsupported.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: [], + rejectedRequiredFeatures: [] + }) + ).toBe(false); + expect(unsupported.mode).toBe('raw-fallback'); + + const malformed: HeftChildEmitter = makeEmitter(); + expect(malformed.acceptHelloAck({ kind: 'helloAck' })).toBe(false); + expect(malformed.mode).toBe('raw-fallback'); + + const missing: HeftChildEmitter = makeEmitter(); + missing.handleAckDescriptorClose(); + expect(missing.mode).toBe('raw-fallback'); + }); + + it('chunks UTF-8 output into local-sensitive external output events', () => { + let descriptor: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeDescriptor: (text: string) => (descriptor += text) + }); + emitter.sendHello(); + emitter.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1'], + rejectedRequiredFeatures: [] + }); + const text: string = '😀'.repeat(Math.floor(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes / 4) + 1); + const eventIds: readonly string[] = emitter.emitOutput('stderr', text, { + operationId: 'op-1' + }); + const records: Record[] = descriptor + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + const outputRecords: Record[] = records.slice(1); + + expect(eventIds).toHaveLength(2); + expect(outputRecords.map((record) => (record.payload as { text: string }).text).join('')).toBe(text); + expect( + outputRecords.every( + (record) => + record.type === 'externalOutput' && + record.privacy === 'local-sensitive' && + (record.scope as { operationId: string }).operationId === 'op-1' && + Buffer.byteLength((record.payload as { text: string }).text, 'utf8') <= + REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + ) + ).toBe(true); + }); + it('falls back to raw streams when descriptor negotiation is unavailable', () => { let stdout: string = ''; const emitter: HeftChildEmitter = new HeftChildEmitter({ @@ -132,23 +249,19 @@ describe('HeftChildEmitter', () => { 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' }, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, 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', - payload: { operationId: 'c1', status: 'success' } - }); - // Parent host forwards into a manager. const manager: ReporterManager = new ReporterManager(); const recording: RecordingReporter = new RecordingReporter(); manager.addReporter(recording); @@ -156,22 +269,48 @@ describe('HeftDescriptorHost new descriptor path', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', + parentRequestId: 'parent-request', parentOperationId: 'op-42', supportedProtocolVersion: { major: 1, minor: 0 }, - forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope) + context: { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 120 + }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope), + sendHelloAck: (ack) => child.acceptHelloAck(ack) + }); + + child.sendHello(); + host.processChildNdjson(descriptor); + descriptor = ''; + expect(child.mode).toBe('structured'); + expect(child.context?.terminalWidth).toBe(120); + child.emitEvent({ + type: 'operationStatusChanged', + privacy: 'local-sensitive', + payload: { operationId: 'c1', status: 'success' } + }); + child.emitEvent({ + type: 'activityChanged', + payload: { operationId: 'c1' } }); const result: IHeftChildResult = host.processChildNdjson(descriptor); await manager.flushAsync(); expect(result.accepted).toBe(true); - expect(result.eventCount).toBe(1); + expect(result.eventCount).toBe(2); const forwarded: IReporterEventEnvelope = recording.reported[0]; expect(forwarded.sessionId).toBe('child-sess'); expect(forwarded.parentSessionId).toBe('parent-sess'); + expect(forwarded.parentRequestId).toBe('parent-request'); expect(forwarded.parentOperationId).toBe('op-42'); - // ingestForeignEnvelope assigns a new global sequence and preserves the child's. expect(forwarded.sourceSequence).toBe(1); + expect(forwarded.privacy).toBe('local-sensitive'); + expect(recording.reported[1].sourceSequence).toBe(2); + expect(recording.reported[1].sequence).toBeGreaterThan(recording.reported[0].sequence); }); it('drains a spawned child descriptor before the child exits and exceeds pipe capacity', async () => { @@ -182,28 +321,24 @@ describe('HeftDescriptorHost new descriptor path', () => { let childExited: boolean = false; let forwardedBeforeExit: boolean = false; - const host: HeftDescriptorHost = new HeftDescriptorHost({ - parentSessionId: 'parent-sess', - supportedProtocolVersion: { major: 1, minor: 0 }, - forwardEnvelope: (envelope: IReporterEventEnvelope) => { - forwardedBeforeExit ||= !childExited; - manager.ingestForeignEnvelope(envelope); - } - }); - const processor = host.createStreamProcessor(); const plan: IChildDescriptorPlan = allocateChildDescriptor(); const eventCount: number = 2_000; const script: string = ` const fs = require('node:fs'); const fd = Number(process.env.${RUSH_REPORTER_CHILD_FD_ENV_VAR}); + const ackFd = Number(process.env.${RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR}); const source = ${JSON.stringify(SOURCE)}; fs.writeSync(fd, JSON.stringify({ kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) + '\\n'); + const ack = JSON.parse(fs.readFileSync(ackFd, 'utf8').trim()); + if (!ack.acceptedCapabilities.includes('heft-child-events-v1')) { + process.exit(2); + } for (let i = 0; i < ${eventCount}; i++) { fs.writeSync(fd, JSON.stringify({ protocolVersion: { major: 1, minor: 0 }, @@ -223,6 +358,17 @@ describe('HeftDescriptorHost new descriptor path', () => { env: { ...process.env, ...plan.env }, stdio: plan.stdio as childProcess.StdioOptions }); + const acknowledgementDescriptor: Writable = spawned.stdio[plan.ackFdNumber] as Writable; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => { + forwardedBeforeExit ||= !childExited; + manager.ingestForeignEnvelope(envelope); + }, + sendHelloAck: (ack) => acknowledgementDescriptor.end(encodeNdjsonRecord(ack)) + }); + const processor = host.createStreamProcessor(); const descriptor: Readable = spawned.stdio[plan.fdNumber] as Readable; descriptor.setEncoding('utf8'); descriptor.on('data', (chunk: string) => processor.write(chunk)); @@ -250,7 +396,10 @@ describe('HeftDescriptorHost new descriptor path', () => { 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' }, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, childSessionId: 'child-sess', source: SOURCE, producerVersion: '@rushstack/heft 2.0.0', @@ -342,6 +491,49 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(negotiationResults).toEqual([false]); }); + it.each([ + ['malformed', 'not-json\n'], + ['truncated', '{"eventId":'], + ['oversized', `${'x'.repeat(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes + 1)}\n`] + ])('forwards a valid prefix before rejecting a %s record', (kind: string, suffix: string) => { + expect(kind.length).toBeGreaterThan(0); + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => forwarded.push(envelope) + }); + const hello: string = encodeNdjsonRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + const event: string = encodeNdjsonRecord({ + protocolVersion: { major: 1, minor: 0 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'valid\n' } + }); + const processor = host.createStreamProcessor(); + + processor.write(hello + event + suffix); + const result: IHeftChildResult = processor.flush(); + + expect(forwarded).toHaveLength(1); + expect(forwarded[0].eventId).toBe('child_1'); + expect(result.eventCount).toBe(1); + expect(result.accepted).toBe(false); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + it('rejects an incomplete hello instead of dereferencing missing fields', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', @@ -368,7 +560,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -408,7 +600,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -428,6 +620,61 @@ describe('HeftDescriptorHost new descriptor path', () => { ).toBe(false); expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + + it('drops unknown optional events and rejects unknown required events or invalid privacy', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => { + throw new Error('Unknown events must not be forwarded.'); + } + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + const unknownEvent = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + type: 'futureEvent', + payload: {} + }; + expect(host.processChildRecord({ ...unknownEvent, required: false })).toBe(true); + expect(host.processChildRecord({ ...unknownEvent, eventId: 'child_2', required: true })).toBe(false); + + const invalidPrivacyHost: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => { + throw new Error('Invalid privacy must not be forwarded.'); + } + }); + invalidPrivacyHost.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + invalidPrivacyHost.processChildRecord({ + ...unknownEvent, + type: 'commandStarted', + required: true, + privacy: 'private' + }) + ).toBe(false); + }); }); describe('Heft old raw-stream path', () => { @@ -463,6 +710,7 @@ describe('Heft old raw-stream path', () => { expect(stdoutText).toBe('old stdout'); expect(stderrText).toBe('old stderr'); expect(spawned.stdio[plan.fdNumber]).not.toBeNull(); + expect(spawned.stdio[plan.ackFdNumber]).not.toBeNull(); }); it('recovers diagnostics from an old Heft version through problem matchers', () => { diff --git a/libraries/reporter/src/test/ProblemMatchers.test.ts b/libraries/reporter/src/test/ProblemMatchers.test.ts index 462421e77c7..20a43fbab2e 100644 --- a/libraries/reporter/src/test/ProblemMatchers.test.ts +++ b/libraries/reporter/src/test/ProblemMatchers.test.ts @@ -3,6 +3,7 @@ import { normalizeAnsi, + ProblemMatcherRunner, ProblemMatcherRegistry, runProblemMatchers, OperationStreamEmitter, @@ -139,7 +140,7 @@ describe('runProblemMatchers', () => { const splitAnsiEvents: IReporterEventEnvelope[] = emitOutput([ '\u001b[31', - "msrc/split.ts(4,5): error TS2001: split escape\u001b[0m\n" + 'msrc/split.ts(4,5): error TS2001: split escape\u001b[0m\n' ]); const splitAnsiResult: IProblemMatcherResult = runProblemMatchers(splitAnsiEvents, [TSC_ERROR_MATCHER]); expect(splitAnsiResult.diagnostics).toHaveLength(1); @@ -201,4 +202,59 @@ describe('runProblemMatchers', () => { expect(result.matchedLineCount).toBe(2); expect(result.unmatchedLineCount).toBe(2); }); + + it('streams per-operation partial lines and ignores non-output events', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER]); + const op1: IReporterEventEnvelope[] = emitOutput(['src/a.ts(1,1): error TS10'], 'op1'); + const op2: IReporterEventEnvelope[] = emitOutput(['src/b.ts(2,2): error TS2000: two\n'], 'op2'); + expect(runner.write(op1[0])).toEqual([]); + expect(runner.write({ ...op1[0], type: 'activityChanged' })).toEqual([]); + expect(runner.write(op2[0])).toHaveLength(1); + const completion: IReporterEventEnvelope[] = emitOutput(['00: one\n'], 'op1'); + expect(runner.write(completion[0])).toHaveLength(1); + expect(runner.flush()).toEqual([]); + expect(runner.result.diagnostics.map((diagnostic) => diagnostic.parameters?.code.value)).toEqual([ + 'TS2000', + 'TS1000' + ]); + }); + + it('keeps interleaved stdout and stderr partial lines independent', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER]); + const stdoutStart: IReporterEventEnvelope = emitOutput( + ['src/a.ts(1,2): error TS1005: broken'], + 'op-1' + )[0]; + const stderr: IReporterEventEnvelope = { + ...emitOutput(['unrelated stderr\n'], 'op-1')[0], + payload: { stream: 'stderr', text: 'unrelated stderr\n' } + }; + const stdoutEnd: IReporterEventEnvelope = emitOutput([' output\n'], 'op-1')[0]; + + expect(runner.write(stdoutStart)).toEqual([]); + expect(runner.write(stderr)).toEqual([]); + expect(runner.write(stdoutEnd)).toHaveLength(1); + expect(runner.result.diagnostics[0].parameters?.message.value).toBe('broken output'); + expect(runner.result.unmatchedLineCount).toBe(1); + }); + + it('caps streaming duplicates and recovers after an oversized partial line', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER], { + maxDuplicates: 1, + maxPartialLineBytes: 64 + }); + const events: IReporterEventEnvelope[] = emitOutput([ + 'x'.repeat(80), + '\n', + 'src/dup.ts(1,1): error TS1005: duplicate\n', + 'src/dup.ts(1,1): error TS1005: duplicate\n' + ]); + const emitted = events.flatMap((event) => runner.write(event)); + runner.flush(); + + expect(emitted).toHaveLength(1); + expect(runner.matchedLineCount).toBe(2); + expect(runner.unmatchedLineCount).toBe(1); + expect(runner.suppressedDuplicateCount).toBe(1); + }); }); diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts index 587de7ae8bc..3fc3548edaa 100644 --- a/libraries/reporter/src/test/Protocol.test.ts +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -10,8 +10,12 @@ import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError, + InvalidReporterHelloAckError, InvalidReporterHelloError, negotiateReporterHello, + parseReporterHelloAck, + REPORTER_KNOWN_CAPABILITIES, + type IReporterHelloAck, type IReporterHello, type IReporterHandshakeResult } from '../index'; @@ -19,7 +23,7 @@ import { describe('ReporterProtocol', () => { it('advertises protocol major 1 and the specified byte limits', () => { expect(REPORTER_PROTOCOL_VERSION.major).toBe(1); - expect(REPORTER_PROTOCOL_VERSION.minor).toBe(1); + expect(REPORTER_PROTOCOL_VERSION.minor).toBe(2); expect(REPORTER_PROTOCOL_LIMITS.bootstrapBufferBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes).toBe(64 * 1024); @@ -139,6 +143,35 @@ describe('negotiateReporterHello', () => { expect(result.diagnostic).toBeUndefined(); }); + it('governs Heft event and reporter context capabilities', () => { + expect(REPORTER_KNOWN_CAPABILITIES).toEqual(['heft-child-events-v1', 'reporter-context-v1']); + }); + + it('includes validated parent context only when its capability is accepted', () => { + const context = { + reporter: 'plaintext' as const, + logLevel: 'verbose' as const, + color: false, + terminalWidth: 120 + }; + const accepted: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ capabilities: ['reporter-context-v1'] }), + { + supportedProtocolVersion: { major: 1, minor: 0 }, + supportedCapabilities: REPORTER_KNOWN_CAPABILITIES, + context + } + ); + expect(accepted.ack.context).toEqual(context); + + const notAccepted: IReporterHandshakeResult = negotiateReporterHello(makeHello(), { + supportedProtocolVersion: { major: 1, minor: 0 }, + supportedCapabilities: REPORTER_KNOWN_CAPABILITIES, + context + }); + expect(notAccepted.ack.context).toBeUndefined(); + }); + it('accepts across an additive minor difference', () => { const result: IReporterHandshakeResult = negotiateReporterHello( makeHello({ protocolVersion: { major: 1, minor: 7 } }), @@ -195,4 +228,34 @@ describe('negotiateReporterHello', () => { ) ).toThrow(/capabilities must be an array of strings/); }); + + it('validates untrusted acknowledgements and reporter context', () => { + const ack: IReporterHelloAck = parseReporterHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'ai', + logLevel: 'debug', + color: true, + terminalWidth: 80 + } + }); + expect(ack.context?.reporter).toBe('ai'); + + expect(() => + parseReporterHelloAck({ + ...ack, + context: { ...ack.context, terminalWidth: 0 } + }) + ).toThrow(InvalidReporterHelloAckError); + expect(() => + parseReporterHelloAck({ + ...ack, + acceptedCapabilities: ['heft-child-events-v1'] + }) + ).toThrow(/context requires/); + expect(() => parseReporterHelloAck(undefined)).toThrow(InvalidReporterHelloAckError); + }); }); diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index fed71783a89..cf191b879d7 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -85,7 +85,7 @@ describe('TelemetrySubscriber', () => { expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); expect(aggregate.reporterMode).toBe('default'); - expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 1 }); + expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 2 }); expect(aggregate.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); // The subscriber runs alongside a rendering reporter and does not consume events from it. diff --git a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap index e46cd3c517c..776b7beca2d 100644 --- a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap +++ b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap @@ -3,7 +3,7 @@ exports[`compatibility goldens advertises the current protocol version as the negotiation baseline 1`] = ` Object { "major": 1, - "minor": 1, + "minor": 2, } `; diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 6f0bb4c5e67..6fd9a612016 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -203,6 +203,7 @@ export { } from './pluginFramework/PhasedCommandHooks'; export type { IOperationGraph, IOperationGraphIterationOptions } from './logic/operations/IOperationGraph'; export type { + IOperationChildProcessReporter as _IOperationChildProcessReporter, IOperationGraphEventSink as _IOperationGraphEventSink, IOperationActivityOptions as _IOperationActivityOptions } from './logic/operations/OperationEventSink'; diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts new file mode 100644 index 00000000000..301d2a6787d --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as child_process from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; + +import { + allocateChildDescriptor, + encodeNdjsonRecord, + HeftDescriptorHost, + REPORTER_PROTOCOL_VERSION, + type IChildDescriptorPlan, + type IHeftChildResult, + type IReporterChildContext, + type IReporterEventEnvelope, + type IRushDiagnostic +} from '@rushstack/rush-reporter'; + +import type { IOperationChildProcessReporter } from './OperationEventSink'; + +export interface IHeftChildProcessReporterOptions { + readonly parentSessionId: string; + readonly parentRequestId: string; + readonly parentOperationId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + readonly onDiagnostic: (diagnostic: IRushDiagnostic) => void; + readonly onStructuredNegotiated: () => void; +} + +/** + * Owns the private reporter descriptors for one operation child process. + * + * @internal + */ +export class HeftChildProcessReporter implements IOperationChildProcessReporter { + public readonly environment: Readonly>; + public readonly stdio: child_process.StdioOptions; + + private readonly _plan: IChildDescriptorPlan; + private readonly _options: IHeftChildProcessReporterOptions; + private _hasWarningOrError: boolean = false; + + public get hasWarningOrError(): boolean { + return this._hasWarningOrError; + } + + public constructor(options: IHeftChildProcessReporterOptions) { + this._options = options; + this._plan = allocateChildDescriptor(); + this.environment = this._plan.env; + this.stdio = ['ignore', ...this._plan.stdio.slice(1)] as child_process.StdioOptions; + } + + public async attachAsync(child: child_process.ChildProcess): Promise { + const eventStream: Readable | null = child.stdio[this._plan.fdNumber] as Readable | null; + const ackStream: Writable | null = child.stdio[this._plan.ackFdNumber] as Writable | null; + if (!eventStream || !ackStream) { + throw new Error('The child reporter descriptors were not created by the process launcher.'); + } + + let diagnosticEmitted: boolean = false; + const emitDiagnostic = (diagnostic: IRushDiagnostic | undefined): void => { + if (diagnostic !== undefined && !diagnosticEmitted) { + diagnosticEmitted = true; + this._options.onDiagnostic(diagnostic); + } + }; + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: this._options.parentSessionId, + parentRequestId: this._options.parentRequestId, + parentOperationId: this._options.parentOperationId, + supportedProtocolVersion: REPORTER_PROTOCOL_VERSION, + context: this._options.context, + forwardEnvelope: (envelope: IReporterEventEnvelope) => { + if (envelope.type === 'diagnosticEmitted') { + const payload: { severity?: unknown } = envelope.payload as { severity?: unknown }; + this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; + } else if (envelope.type === 'externalOutput') { + const payload: { stream?: unknown } = envelope.payload as { stream?: unknown }; + this._hasWarningOrError ||= payload.stream === 'stderr'; + } + this._options.ingestForeignEnvelope(envelope); + }, + sendHelloAck: (ack) => { + ackStream.end(encodeNdjsonRecord(ack)); + }, + onNegotiation: (result) => { + if (result.accepted) { + this._options.onStructuredNegotiated(); + } else { + emitDiagnostic(result.diagnostic); + } + } + }); + const processor: { write(chunk: string): void; flush(): IHeftChildResult } = host.createStreamProcessor(); + + await new Promise((resolve, reject) => { + eventStream.setEncoding('utf8'); + eventStream.on('data', (chunk: string) => processor.write(chunk)); + eventStream.once('error', reject); + eventStream.once('end', () => { + const result: IHeftChildResult = processor.flush(); + emitDiagnostic(result.diagnostic); + if (!ackStream.destroyed) { + ackStream.end(); + } + resolve(); + }); + }); + } +} diff --git a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts index 911f893f884..f45d67816aa 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts @@ -8,6 +8,7 @@ import type { OperationStatus } from './OperationStatus'; import type { OperationMetadataManager } from './OperationMetadataManager'; import type { IStopwatchResult } from '../../utilities/Stopwatch'; import type { IEnvironment } from '../../utilities/Utilities'; +import type { IOperationChildProcessReporter } from './OperationEventSink'; /** * A snapshot of a previous operation execution, passed to runners to inform incremental behavior. @@ -81,6 +82,13 @@ export interface IOperationRunnerContext { */ getInvalidateCallback(): (reason: string) => void; + /** + * Allocates a negotiated reporter channel for a child process, when enabled. + * + * @internal + */ + createChildProcessReporter(): IOperationChildProcessReporter | undefined; + /** * Invokes the specified callback with a terminal that is associated with this operation. * diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 75f1bfce585..9e716b2c0a9 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type * as child_process from 'node:child_process'; + import type { ITerminalChunk } from '@rushstack/terminal'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; @@ -22,6 +24,18 @@ export interface IOperationActivityOptions { readonly stderr?: boolean; } +/** + * A negotiated reporter channel allocated for one operation child process. + * + * @internal + */ +export interface IOperationChildProcessReporter { + readonly environment: Readonly>; + readonly hasWarningOrError: boolean; + readonly stdio: child_process.StdioOptions; + attachAsync(child: child_process.ChildProcess): Promise; +} + /** * A structured, presentation-free event sink for the operation graph. * @@ -78,4 +92,12 @@ export interface IOperationGraphEventSink { * carrying the plain (pre-colorization) text. */ onActivity?(text: string, options?: IOperationActivityOptions): void; + + /** + * Allocates a reporter channel for a child spawned by the specified operation. + */ + createChildProcessReporter?( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined; } diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 5c0209bdfde..4f99c5954d2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -20,7 +20,7 @@ import { CollatedTerminal, type CollatedWriter, type StreamCollator } from '@rus import { coerceParallelism } from './ParseParallelism'; import { OperationStatus, TERMINAL_STATUSES } from './OperationStatus'; -import type { IOperationGraphEventSink } from './OperationEventSink'; +import type { IOperationChildProcessReporter, IOperationGraphEventSink } from './OperationEventSink'; import { OperationChunkTap } from './OperationChunkTap'; import type { IOperationRunner, IOperationRunnerContext } from './IOperationRunner'; import type { Operation } from './Operation'; @@ -286,6 +286,13 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return this._context.eventSink; } + /** + * {@inheritdoc IOperationRunnerContext.createChildProcessReporter} + */ + public createChildProcessReporter(): IOperationChildProcessReporter | undefined { + return this._context.eventSink?.createChildProcessReporter?.(this.name, this.iterationId); + } + public get silent(): boolean { return !this.enabled || this.runner.silent; } diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 056e480a6a5..e28952eae6f 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -3,24 +3,36 @@ import { createRushDiagnostic, + ProblemMatcherRegistry, + ProblemMatcherRunner, type IRushDiagnostic, + type IProblemMatch, + type IProblemMatcher, type LifecycleEmitter, type OperationStreamEmitter, type OperationStatus as ReporterOperationStatus } from '@rushstack/rush-reporter'; +import { FileError } from '@rushstack/node-core-library'; import { TerminalChunkKind, type ITerminalChunk } from '@rushstack/terminal'; -import type { RushSession } from '../../pluginFramework/RushSession'; import { _correlateRushSessionError, + _getRushSessionChildProcessReporter, _getRushSessionLifecycleEmitter, - _getRushSessionOperationStreamEmitter + _getRushSessionOperationStreamEmitter, + type IRushSessionChildProcessReporter, + type RushSession } from '../../pluginFramework/RushSession'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; -import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { + IOperationChildProcessReporter, + IOperationGraphEventSink, + IOperationActivityOptions +} from './OperationEventSink'; import type { Operation } from './Operation'; import { OperationStatus, SUCCESS_STATUSES } from './OperationStatus'; import type { OperationGraph } from './OperationGraph'; +import { HeftChildProcessReporter } from './HeftChildProcessReporter'; interface IReporterOperation { readonly emitter: LifecycleEmitter; @@ -28,6 +40,7 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + readonly problemMatcherRunnersByLegacyId: Map; readonly streamEmitter: OperationStreamEmitter | undefined; readonly cycles: Map; } @@ -42,6 +55,40 @@ interface IReporterOperationCycle { streamClosed: boolean; } +function createFileErrorMatcher( + name: string, + format: 'Unix' | 'VisualStudio', + severity: 'error' | 'warning' +): IProblemMatcher { + const definition: { readonly regexp: string } = FileError.getProblemMatcher({ format }); + const severityWord: string = severity === 'error' ? 'Error' : 'Warning'; + return { + name, + tool: 'heft', + severity, + enabledByDefault: true, + pattern: new RegExp(definition.regexp.replace('(Error|Warning)', `(${severityWord})`)), + extract(match: RegExpMatchArray): IProblemMatch { + return { + file: match[2], + line: Number(match[3]), + column: Number(match[4]), + code: match[5], + message: match[6] + }; + } + }; +} + +function createProblemMatcherRunner(): ProblemMatcherRunner { + const registry: ProblemMatcherRegistry = new ProblemMatcherRegistry(); + registry.register(createFileErrorMatcher('heft-file-error-unix', 'Unix', 'error')); + registry.register(createFileErrorMatcher('heft-file-warning-unix', 'Unix', 'warning')); + registry.register(createFileErrorMatcher('heft-file-error-visualstudio', 'VisualStudio', 'error')); + registry.register(createFileErrorMatcher('heft-file-warning-visualstudio', 'VisualStudio', 'warning')); + return new ProblemMatcherRunner(registry.getMatchers('heft')); +} + class ReporterOperationEventSink implements IOperationGraphEventSink { public readonly onOperationChunk: | ((operationId: string, chunk: ITerminalChunk, iterationId: number) => void) @@ -90,6 +137,7 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { operationId, phaseName, projectName, + problemMatcherRunnersByLegacyId: new Map(), streamEmitter, cycles: new Map() }; @@ -127,8 +175,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!operation) { return; } - const cycle: IReporterOperationCycle = this._getCycle(operation, iterationId); + const matcherKey: string = `${iterationId}:${operationId}`; + operation.problemMatcherRunnersByLegacyId.set( + matcherKey, + operation.streamEmitter ? createProblemMatcherRunner() : undefined + ); cycle.registeredOperationIds.add(operationId); cycle.silent &&= silent; @@ -234,6 +286,33 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { }); } + public createChildProcessReporter( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + const childProcessReporter: IRushSessionChildProcessReporter | undefined = + _getRushSessionChildProcessReporter(this._rushSession); + if (!operation?.streamEmitter || !childProcessReporter) { + return undefined; + } + + const matcherKey: string = `${iterationId}:${operationId}`; + operation.problemMatcherRunnersByLegacyId.set(matcherKey, createProblemMatcherRunner()); + return new HeftChildProcessReporter({ + parentSessionId: childProcessReporter.parentSessionId, + parentRequestId: childProcessReporter.parentRequestId, + parentOperationId: operation.operationId, + context: childProcessReporter.context, + ingestForeignEnvelope: childProcessReporter.ingestForeignEnvelope, + onDiagnostic: (diagnostic: IRushDiagnostic) => + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId }), + onStructuredNegotiated: () => { + operation.problemMatcherRunnersByLegacyId.set(matcherKey, undefined); + } + }); + } + private _onOperationChunk(operationId: string, chunk: ITerminalChunk, iterationId: number): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); if (!operation?.streamEmitter) { @@ -245,6 +324,14 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } else if (chunk.kind === TerminalChunkKind.Stderr) { operation.streamEmitter.writeOutput(operation.operationId, 'stderr', chunk.text, iterationId); } + + const stream: 'stdout' | 'stderr' = chunk.kind === TerminalChunkKind.Stderr ? 'stderr' : 'stdout'; + const matcherKey: string = `${iterationId}:${operationId}`; + for (const diagnostic of operation.problemMatcherRunnersByLegacyId + .get(matcherKey) + ?.writeOutput(chunk.text, operation.operationId, stream) ?? []) { + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId }); + } } private _onOperationStreamClosed(operationId: string, iterationId: number): void { @@ -256,6 +343,11 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (cycle.streamClosed) { return; } + const matcherKey: string = `${iterationId}:${operationId}`; + for (const diagnostic of operation.problemMatcherRunnersByLegacyId.get(matcherKey)?.flush() ?? []) { + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId }); + } + operation.problemMatcherRunnersByLegacyId.delete(matcherKey); cycle.closedOperationIds.add(operationId); if (cycle.closedOperationIds.size === operation.legacyOperationIds.size) { cycle.streamClosed = true; @@ -356,6 +448,16 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { this._first.onActivity?.(text, options); this._second.onActivity?.(text, options); } + + public createChildProcessReporter( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined { + return ( + this._second.createChildProcessReporter?.(operationId, iterationId) ?? + this._first.createChildProcessReporter?.(operationId, iterationId) + ); + } } /** diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index a9de80dde28..c585f842607 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -11,6 +11,7 @@ import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { Utilities } from '../../utilities/Utilities'; import type { IOperationRunner, IOperationRunnerContext, IOperationLastState } from './IOperationRunner'; +import type { IOperationChildProcessReporter } from './OperationEventSink'; import { OperationError } from './OperationError'; import { OperationStatus } from './OperationStatus'; @@ -96,6 +97,8 @@ export class ShellOperationRunner implements IOperationRunner { const { rushConfiguration, projectFolder } = this._rushProject; const { environment: initialEnvironment } = context; + const childProcessReporter: IOperationChildProcessReporter | undefined = + context.createChildProcessReporter(); const subProcess: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { rushConfiguration: rushConfiguration, @@ -105,8 +108,12 @@ export class ShellOperationRunner implements IOperationRunner { environmentPathOptions: { includeProjectBin: true }, - initialEnvironment + initialEnvironment, + additionalEnvironment: childProcessReporter?.environment, + stdio: childProcessReporter?.stdio }); + const reporterDrainPromise: Promise = + childProcessReporter?.attachAsync(subProcess) ?? Promise.resolve(); // Hook into events, in order to get live streaming of the log subProcess.stdout?.on('data', (data: Buffer) => { @@ -119,22 +126,20 @@ export class ShellOperationRunner implements IOperationRunner { hasWarningOrError = true; }); - const status: OperationStatus = await new Promise( - (resolve: (status: OperationStatus) => void, reject: (error: OperationError) => void) => { + const closePromise: Promise<{ + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + }> = new Promise( + ( + resolve: (result: { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + }) => void, + reject: (error: OperationError) => void + ) => { subProcess.on('close', (exitCode: number | null, signal: NodeJS.Signals | null) => { try { - // Do NOT reject here immediately, give a chance for other logic to suppress the error - if (signal) { - context.error = new OperationError('error', `Terminated by signal: ${signal}`); - resolve(OperationStatus.Failure); - } else if (exitCode !== 0) { - context.error = new OperationError('error', `Returned error code: ${exitCode}`); - resolve(OperationStatus.Failure); - } else if (hasWarningOrError) { - resolve(OperationStatus.SuccessWithWarning); - } else { - resolve(OperationStatus.Success); - } + resolve({ exitCode, signal }); } catch (error) { context.error = error as OperationError; reject(error as OperationError); @@ -142,8 +147,24 @@ export class ShellOperationRunner implements IOperationRunner { }); } ); - - return status; + const [{ exitCode, signal }]: [ + { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null }, + void + ] = await Promise.all([closePromise, reporterDrainPromise]); + + if (signal) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', `Terminated by signal: ${signal}`); + return OperationStatus.Failure; + } else if (exitCode !== 0) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', `Returned error code: ${exitCode}`); + return OperationStatus.Failure; + } else if (hasWarningOrError || childProcessReporter?.hasWarningOrError) { + return OperationStatus.SuccessWithWarning; + } else { + return OperationStatus.Success; + } }, { createLogFile: true diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts new file mode 100644 index 00000000000..4488715888d --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; + +import type { IReporterEventEnvelope, IRushDiagnostic } from '@rushstack/rush-reporter'; + +import { HeftChildProcessReporter } from '../HeftChildProcessReporter'; + +const CONTEXT = { + reporter: 'json', + logLevel: 'debug', + color: false, + terminalWidth: 120 +} as const; + +// eslint-disable-next-line @rushstack/no-new-null -- ChildProcess.close uses null for signal exits. +function waitForCloseAsync(child: childProcess.ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); +} + +describe(HeftChildProcessReporter.name, () => { + it('negotiates structured child events and parent context', async () => { + const envelopes: IReporterEventEnvelope[] = []; + let structuredNegotiated: boolean = false; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + envelopes.push(envelope); + return envelope.eventId; + }, + onDiagnostic: () => { + throw new Error('A compatible child must not emit a protocol diagnostic.'); + }, + onStructuredNegotiated: () => { + structuredNegotiated = true; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1', 'reporter-context-v1'], + requiredFeatures: [] + }) + '\\n'); + const ack = JSON.parse(fs.readFileSync(ackFd, 'utf8').trim()); + if (ack.context.reporter !== 'json' || ack.context.terminalWidth !== 120) process.exit(3); + for (let sequence = 1; sequence <= 2; sequence++) { + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_' + sequence, + sessionId: 'child-session', + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: sequence === 1 ? 'stdout' : 'stderr', text: String(sequence) } + }) + '\\n'); + } + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child) + ]); + + expect(exitCode).toBe(0); + expect(structuredNegotiated).toBe(true); + expect(reporter.hasWarningOrError).toBe(true); + expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2]); + expect(envelopes.map((envelope) => envelope.parentSessionId)).toEqual([ + 'parent-session', + 'parent-session' + ]); + expect(envelopes.map((envelope) => envelope.parentRequestId)).toEqual([ + 'parent-request', + 'parent-request' + ]); + expect(envelopes.map((envelope) => envelope.parentOperationId)).toEqual([ + 'project#build', + 'project#build' + ]); + expect(envelopes.map((envelope) => envelope.scope?.operationId)).toEqual([ + 'project#build', + 'project#build' + ]); + }); + + it('preserves old child stdout and stderr when no hello is sent', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => { + throw new Error('An old child must remain on raw fallback.'); + } + }); + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', "process.stdout.write('old stdout'); process.stderr.write('old stderr');"], + { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('old stdout'); + expect(stderr).toBe('old stderr'); + expect(diagnostics).toEqual([]); + }); + + it('surfaces unsupported requirements and retains fallback output', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => { + throw new Error('An unsupported child must not negotiate structured reporting.'); + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 2, minor: 0 }, + producerVersion: '@rushstack/heft 2.0.0', + capabilities: ['heft-child-events-v1'], + requiredFeatures: ['future-required-feature'] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + process.stdout.write('fallback after rejection'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('fallback after rejection'); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_UPDATE_REQUIRED']); + }); + + it('reports a truncated accepted descriptor stream without hanging after child crash', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, '{"eventId":'); + process.exit(7); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child) + ]); + + expect(exitCode).toBe(7); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_INVALID_CHILD_STREAM']); + }); +}); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 138a6ab82c8..75c8b68d58a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -526,6 +526,7 @@ describe('OperationGraph event sink (dual-emit)', () => { operationStreamEnabled: true } }); + const graph: OperationGraph = new OperationGraph( new Set([ createOperation('visible', new MockOperationRunner('visible'), mockPhase, '@scope/visible'), diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 4483f6a3115..e34a7c6ea58 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -11,6 +11,7 @@ import { isReporterEventRequired, resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, + type IReporterChildContext, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, @@ -62,6 +63,17 @@ export interface IRushSessionReporterOptions { */ readonly operationStreamEnabled?: boolean; + /** + * Enables negotiated reporting for compatible child processes. + * + * @internal + */ + readonly childProcessReporter?: { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + }; + /** * Flushes and closes the frontend-owned reporters before an explicit process exit. * @@ -117,6 +129,18 @@ interface IRushSessionReportingState { readonly observer: IRushSessionShadowEventObserver; } +/** + * Parent-owned state used to correlate a negotiated child reporter stream. + * + * @internal + */ +export interface IRushSessionChildProcessReporter { + readonly parentSessionId: string; + readonly parentRequestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; +} + interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; @@ -510,6 +534,34 @@ export function _isRushSessionOperationStreamEnabled(rushSession: RushSession): return _getSessionState(rushSession).options.reporter?.operationStreamEnabled === true; } +/** + * Returns the parent-owned child reporter channel for the opt-in operation stream. + * + * @internal + */ +export function _getRushSessionChildProcessReporter( + rushSession: RushSession +): IRushSessionChildProcessReporter | undefined { + const reporterOptions: IRushSessionReporterOptions | undefined = + _getSessionState(rushSession).options.reporter; + const childProcessReporter: + | { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + } + | undefined = reporterOptions?.childProcessReporter; + if (!reporterOptions?.operationStreamEnabled || !childProcessReporter) { + return undefined; + } + return { + parentSessionId: reporterOptions.sessionId, + parentRequestId: childProcessReporter.requestId, + context: childProcessReporter.context, + ingestForeignEnvelope: childProcessReporter.ingestForeignEnvelope + }; +} + /** * Flushes the frontend-owned reporter host, when available. * diff --git a/libraries/rush-lib/src/utilities/Utilities.ts b/libraries/rush-lib/src/utilities/Utilities.ts index 1b4ca37cc41..8f1a490fe63 100644 --- a/libraries/rush-lib/src/utilities/Utilities.ts +++ b/libraries/rush-lib/src/utilities/Utilities.ts @@ -113,6 +113,20 @@ export interface ILifecycleCommandOptions { * If true, wire up SubprocessTerminator to the child process. */ connectSubprocessTerminator?: boolean; + + /** + * Additional private environment variables for inherited child channels. + * + * @internal + */ + additionalEnvironment?: IEnvironment; + + /** + * An explicit stdio plan for private inherited child channels. + * + * @internal + */ + stdio?: child_process.StdioOptions; } export interface IEnvironmentPathOptions { @@ -678,7 +692,9 @@ function _executeLifecycleCommandInternal( workingDirectory, handleOutput, ipc, - connectSubprocessTerminator + connectSubprocessTerminator, + additionalEnvironment, + stdio: explicitStdio } = options; const environment: IEnvironment = _createEnvironmentForRushCommand({ initCwd, @@ -691,10 +707,15 @@ function _executeLifecycleCommandInternal( } }); - const stdio: child_process.StdioOptions = handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]; + let stdio: child_process.StdioOptions = + explicitStdio ?? (handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]); if (ipc) { - stdio.push('ipc'); + if (!Array.isArray(stdio)) { + throw new Error('An IPC lifecycle command requires an array stdio configuration.'); + } + stdio = [...stdio, 'ipc']; } + Object.assign(environment, additionalEnvironment); const spawnOptions: child_process.SpawnOptions = { cwd: workingDirectory, From df75a248fa1d03dcb67990cc4990fe9653afe37c Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 09:29:32 +0000 Subject: [PATCH 2/8] Harden Heft negotiation fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../reporter/src/heft/HeftDescriptorHost.ts | 38 ++++++--------- .../reporter/src/test/HeftIntegration.test.ts | 19 ++++++++ .../operations/HeftChildProcessReporter.ts | 2 +- .../test/HeftChildProcessReporter.test.ts | 46 +++++++++++++++++++ 4 files changed, 79 insertions(+), 26 deletions(-) diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index bafac45eb78..2159a15de8e 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -12,11 +12,11 @@ import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; import { + InvalidReporterHelloError, negotiateReporterHello, REPORTER_KNOWN_CAPABILITIES, type ReporterCapability, type IReporterChildContext, - type IReporterHello, type IReporterHelloAck, type IReporterHandshakeResult } from '../protocol/ReporterHandshake'; @@ -42,23 +42,6 @@ function isProtocolVersion(value: unknown): value is IReporterProtocolVersion { return isNonNegativeInteger(value.major) && isNonNegativeInteger(value.minor); } -function isStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); -} - -function isReporterHello(value: unknown): value is IReporterHello { - if (!isObjectRecord(value)) { - return false; - } - return ( - value.kind === 'hello' && - isProtocolVersion(value.protocolVersion) && - typeof value.producerVersion === 'string' && - isStringArray(value.capabilities) && - isStringArray(value.requiredFeatures) - ); -} - function isReporterEventType(value: string): value is ReporterEventType { return REPORTER_EVENT_TYPE_SET.has(value); } @@ -242,14 +225,19 @@ export class HeftDescriptorHost { } if (this._negotiation === undefined) { - if (!isReporterHello(record)) { - return this._rejectMalformedStream('the first record was not a valid hello'); + let result: IReporterHandshakeResult; + try { + result = negotiateReporterHello(record, { + supportedProtocolVersion: this._supportedProtocolVersion, + supportedCapabilities: this._supportedCapabilities, + context: this._reporterContext + }); + } catch (error) { + if (error instanceof InvalidReporterHelloError) { + return this._rejectMalformedStream('the first record was not a valid hello'); + } + throw error; } - const result: IReporterHandshakeResult = negotiateReporterHello(record, { - supportedProtocolVersion: this._supportedProtocolVersion, - supportedCapabilities: this._supportedCapabilities, - context: this._reporterContext - }); this._setNegotiation(result); return result.accepted; } diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index 66d39e2d591..e8668b22b19 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -547,6 +547,25 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + it('rejects a semantically invalid hello without throwing from the live host', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => undefined + }); + + expect(() => + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '', + capabilities: [], + requiredFeatures: [] + }) + ).not.toThrow(); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + it('rejects malformed envelopes and derives required at the host boundary', () => { const forwarded: IReporterEventEnvelope[] = []; const host: HeftDescriptorHost = new HeftDescriptorHost({ diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index 301d2a6787d..cae36970e47 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -87,7 +87,7 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter ackStream.end(encodeNdjsonRecord(ack)); }, onNegotiation: (result) => { - if (result.accepted) { + if (result.accepted && result.ack.acceptedCapabilities.includes('heft-child-events-v1')) { this._options.onStructuredNegotiated(); } else { emitDiagnostic(result.diagnostic); diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 4488715888d..9c32907316d 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -143,6 +143,52 @@ describe(HeftChildProcessReporter.name, () => { expect(diagnostics).toEqual([]); }); + it('keeps raw fallback active when the child event capability is not negotiated', async () => { + let structuredNegotiated: boolean = false; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => { + structuredNegotiated = true; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: [], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + process.stdout.write('capability fallback'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('capability fallback'); + expect(structuredNegotiated).toBe(false); + }); + it('surfaces unsupported requirements and retains fallback output', async () => { const diagnostics: IRushDiagnostic[] = []; const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ From 7d324666d684f11d5783dab716f0acda67ef0f24 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 10:22:25 +0000 Subject: [PATCH 3/8] Fix structured Heft output archival Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../logging/HeftChildReporter.test.ts | 25 +++++++++ .../logging/HeftChildReporter.ts | 17 +++++- common/reviews/api/rush-lib.api.md | 4 +- .../reporter/src/heft/HeftDescriptorHost.ts | 25 +++++++++ .../reporter/src/test/HeftIntegration.test.ts | 52 +++++++++++++++++++ .../operations/HeftChildProcessReporter.ts | 21 +++++++- .../src/logic/operations/IOperationRunner.ts | 6 ++- .../logic/operations/OperationEventSink.ts | 7 ++- .../operations/OperationExecutionRecord.ts | 16 ++++-- .../operations/ReporterOperationEventSink.ts | 4 +- .../logic/operations/ShellOperationRunner.ts | 9 +++- .../test/HeftChildProcessReporter.test.ts | 14 +++-- 12 files changed, 181 insertions(+), 19 deletions(-) diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts index 06ea9e56a48..8180609dace 100644 --- a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts @@ -2,6 +2,9 @@ // See LICENSE in the project root for license information. import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { Readable, Writable } from 'node:stream'; import { HeftChildReporter } from './HeftChildReporter'; @@ -11,6 +14,28 @@ describe(HeftChildReporter.name, () => { expect(HeftChildReporter.tryInitialize({})).toBeUndefined(); }); + it('does not write to or close descriptors that are not pipes', () => { + const folderPath: string = fs.mkdtempSync(path.join(os.tmpdir(), 'heft-child-reporter-')); + const eventPath: string = path.join(folderPath, 'event'); + const acknowledgementPath: string = path.join(folderPath, 'ack'); + const eventFd: number = fs.openSync(eventPath, 'w+'); + const acknowledgementFd: number = fs.openSync(acknowledgementPath, 'w+'); + try { + expect( + HeftChildReporter.tryInitialize({ + _RUSH_REPORTER_CHILD_FD: String(eventFd), + _RUSH_REPORTER_CHILD_ACK_FD: String(acknowledgementFd) + }) + ).toBeUndefined(); + expect(fs.readFileSync(eventPath, 'utf8')).toBe(''); + expect(() => fs.writeSync(acknowledgementFd, 'still open')).not.toThrow(); + } finally { + fs.closeSync(eventFd); + fs.closeSync(acknowledgementFd); + fs.rmSync(folderPath, { recursive: true }); + } + }); + it('negotiates context and emits ordered structured output and diagnostics', async () => { const modulePath: string = require.resolve('./HeftChildReporter'); const childScript: string = ` diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts index e1c9f8c378d..d41e1418979 100644 --- a/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts @@ -43,6 +43,15 @@ function readDescriptorFd(env: Record, name: string) return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; } +function isReporterPipe(fd: number): boolean { + try { + const stats: fs.Stats = fs.fstatSync(fd); + return stats.isFIFO() || stats.isSocket(); + } catch { + return false; + } +} + function encodeRecord(value: unknown): string { const json: string = JSON.stringify(value); if (Buffer.byteLength(json, 'utf8') > MAX_RECORD_BYTES) { @@ -189,7 +198,13 @@ export class HeftChildReporter implements ITerminalProvider { const ackDescriptorFd: number | undefined = readDescriptorFd(env, CHILD_ACK_FD_ENV_VAR); delete env[CHILD_FD_ENV_VAR]; delete env[CHILD_ACK_FD_ENV_VAR]; - if (descriptorFd === undefined || ackDescriptorFd === undefined || descriptorFd === ackDescriptorFd) { + if ( + descriptorFd === undefined || + ackDescriptorFd === undefined || + descriptorFd === ackDescriptorFd || + !isReporterPipe(descriptorFd) || + !isReporterPipe(ackDescriptorFd) + ) { return undefined; } diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index de1a68200a2..e607636ceec 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -642,7 +642,7 @@ export interface _IOperationBuildCacheOptions { // @internal export interface _IOperationChildProcessReporter { // (undocumented) - attachAsync(child: child_process.ChildProcess): Promise; + attachAsync(child: child_process.ChildProcess, structuredOutputTerminalProvider: ITerminalProvider): Promise; // (undocumented) readonly environment: Readonly>; // (undocumented) @@ -778,7 +778,7 @@ export interface IOperationRunnerContext { // @internal _operationMetadataManager: _OperationMetadataManager; quietMode: boolean; - runWithTerminalAsync(callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, options: { + runWithTerminalAsync(callback: (terminal: ITerminal, terminalProvider: ITerminalProvider, structuredChildOutputTerminalProvider: ITerminalProvider) => Promise, options: { createLogFile: boolean; logFileSuffix?: string; }): Promise; diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index 2159a15de8e..af4e2ff4fbb 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -11,6 +11,7 @@ import { import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; import { InvalidReporterHelloError, negotiateReporterHello, @@ -197,6 +198,8 @@ export class HeftDescriptorHost { private _negotiation: IReporterHandshakeResult | undefined; private _protocolFailure: IRushDiagnostic | undefined; private _eventCount: number = 0; + private _childSessionId: string | undefined; + private _lastSourceSequence: number = -1; public constructor(options: IHeftDescriptorHostOptions) { this._parentSessionId = options.parentSessionId; @@ -258,12 +261,34 @@ export class HeftDescriptorHost { 'an event record used a protocol major different from the negotiated stream' ); } + if (this._childSessionId !== undefined && record.sessionId !== this._childSessionId) { + return this._rejectMalformedStream('the child session id changed within one reporter stream'); + } + if (record.sequence <= this._lastSourceSequence) { + return this._rejectMalformedStream('the child event sequence was not strictly increasing'); + } + this._childSessionId ??= record.sessionId; + this._lastSourceSequence = record.sequence; if (!isReporterEventType(record.type)) { if (record.required) { return this._rejectMalformedStream('a required event type was not recognized'); } return true; } + if (record.type === 'externalOutput') { + if ( + !isObjectRecord(record.payload) || + (record.payload.stream !== 'stdout' && record.payload.stream !== 'stderr') || + typeof record.payload.text !== 'string' + ) { + return this._rejectMalformedStream('an external output event contained an invalid payload'); + } + if ( + Buffer.byteLength(record.payload.text, 'utf8') > REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + ) { + return this._rejectMalformedStream('an external output event exceeded the protocol chunk limit'); + } + } const correlated: IReporterEventEnvelope = { ...record, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index e8668b22b19..32a91abd0ec 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -640,6 +640,58 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + it('rejects child session changes, non-monotonic sequence, and oversized output chunks', () => { + const makeHost = (): HeftDescriptorHost => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => undefined + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + return host; + }; + const makeOutput = (sessionId: string, sequence: number, text: string): Record => ({ + protocolVersion: { major: 1, minor: 0 }, + eventId: `child_${sequence}`, + sessionId, + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text } + }); + + const changedSessionHost: HeftDescriptorHost = makeHost(); + expect(changedSessionHost.processChildRecord(makeOutput('child-a', 1, 'a'))).toBe(true); + expect(changedSessionHost.processChildRecord(makeOutput('child-b', 2, 'b'))).toBe(false); + expect(changedSessionHost.processChildRecords([]).diagnostic?.code).toBe( + 'RUSH_PROTOCOL_INVALID_CHILD_STREAM' + ); + + const reorderedHost: HeftDescriptorHost = makeHost(); + expect(reorderedHost.processChildRecord(makeOutput('child-a', 2, 'a'))).toBe(true); + expect(reorderedHost.processChildRecord(makeOutput('child-a', 1, 'b'))).toBe(false); + expect(reorderedHost.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + + const oversizedHost: HeftDescriptorHost = makeHost(); + expect( + oversizedHost.processChildRecord( + makeOutput('child-a', 1, 'x'.repeat(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + 1)) + ) + ).toBe(false); + expect(oversizedHost.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + it('drops unknown optional events and rejects unknown required events or invalid privacy', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index cae36970e47..a58e46b0a72 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -4,6 +4,7 @@ import type * as child_process from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; +import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; import { allocateChildDescriptor, encodeNdjsonRecord, @@ -52,7 +53,10 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter this.stdio = ['ignore', ...this._plan.stdio.slice(1)] as child_process.StdioOptions; } - public async attachAsync(child: child_process.ChildProcess): Promise { + public async attachAsync( + child: child_process.ChildProcess, + structuredOutputTerminalProvider: ITerminalProvider + ): Promise { const eventStream: Readable | null = child.stdio[this._plan.fdNumber] as Readable | null; const ackStream: Writable | null = child.stdio[this._plan.ackFdNumber] as Writable | null; if (!eventStream || !ackStream) { @@ -78,8 +82,21 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter const payload: { severity?: unknown } = envelope.payload as { severity?: unknown }; this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; } else if (envelope.type === 'externalOutput') { - const payload: { stream?: unknown } = envelope.payload as { stream?: unknown }; + const payload: { stream?: unknown; text?: unknown } = envelope.payload as { + stream?: unknown; + text?: unknown; + }; + if ( + (payload.stream !== 'stdout' && payload.stream !== 'stderr') || + typeof payload.text !== 'string' + ) { + throw new Error('The validated child output envelope contained an invalid payload.'); + } this._hasWarningOrError ||= payload.stream === 'stderr'; + structuredOutputTerminalProvider.write( + payload.text, + payload.stream === 'stderr' ? TerminalProviderSeverity.error : TerminalProviderSeverity.log + ); } this._options.ingestForeignEnvelope(envelope); }, diff --git a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts index f45d67816aa..1fcc937053b 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts @@ -95,7 +95,11 @@ export interface IOperationRunnerContext { * Will write to a log file corresponding to the phase and project, and clean it up upon completion. */ runWithTerminalAsync( - callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, + callback: ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise, options: { createLogFile: boolean; logFileSuffix?: string; diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 9e716b2c0a9..dace2fdacaa 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -3,7 +3,7 @@ import type * as child_process from 'node:child_process'; -import type { ITerminalChunk } from '@rushstack/terminal'; +import type { ITerminalChunk, ITerminalProvider } from '@rushstack/terminal'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { OperationStatus } from './OperationStatus'; @@ -33,7 +33,10 @@ export interface IOperationChildProcessReporter { readonly environment: Readonly>; readonly hasWarningOrError: boolean; readonly stdio: child_process.StdioOptions; - attachAsync(child: child_process.ChildProcess): Promise; + attachAsync( + child: child_process.ChildProcess, + structuredOutputTerminalProvider: ITerminalProvider + ): Promise; } /** diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 4f99c5954d2..274eae74008 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -410,10 +410,14 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera * {@inheritdoc IOperationRunnerContext.runWithTerminalAsync} */ public async runWithTerminalAsync( - callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, + callback: ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise, options: { createLogFile: boolean; - logFileSuffix: string; + logFileSuffix?: string; } ): Promise { const { associatedProject, stdioSummarizer, problemCollector } = this; @@ -486,10 +490,16 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera const terminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider(collatedTerminal, { debugEnabled: this.debugMode }); + const structuredChildOutputTerminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider( + new CollatedTerminal(normalizeNewlineTransform), + { + debugEnabled: this.debugMode + } + ); const terminal: Terminal = new Terminal(terminalProvider); //#endregion - const result: T = await callback(terminal, terminalProvider); + const result: T = await callback(terminal, terminalProvider, structuredChildOutputTerminalProvider); normalizeNewlineTransform.close(); diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index e28952eae6f..77e694ff223 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -23,6 +23,7 @@ import { type IRushSessionChildProcessReporter, type RushSession } from '../../pluginFramework/RushSession'; +import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { IOperationChildProcessReporter, @@ -293,7 +294,8 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); const childProcessReporter: IRushSessionChildProcessReporter | undefined = _getRushSessionChildProcessReporter(this._rushSession); - if (!operation?.streamEmitter || !childProcessReporter) { + // Windows lifecycle commands run through a shell that does not preserve Node's fd mapping. + if (IS_WINDOWS || !operation?.streamEmitter || !childProcessReporter) { return undefined; } diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index c585f842607..fef41e0e6f9 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -76,7 +76,11 @@ export class ShellOperationRunner implements IOperationRunner { lastState?: IOperationLastState ): Promise { return await context.runWithTerminalAsync( - async (terminal: ITerminal, terminalProvider: ITerminalProvider) => { + async ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => { let hasWarningOrError: boolean = false; // Log any ignored parameters @@ -113,7 +117,8 @@ export class ShellOperationRunner implements IOperationRunner { stdio: childProcessReporter?.stdio }); const reporterDrainPromise: Promise = - childProcessReporter?.attachAsync(subProcess) ?? Promise.resolve(); + childProcessReporter?.attachAsync(subProcess, structuredChildOutputTerminalProvider) ?? + Promise.resolve(); // Hook into events, in order to get live streaming of the log subProcess.stdout?.on('data', (data: Buffer) => { diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 9c32907316d..47df12e7c88 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -4,6 +4,7 @@ import * as childProcess from 'node:child_process'; import type { IReporterEventEnvelope, IRushDiagnostic } from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { HeftChildProcessReporter } from '../HeftChildProcessReporter'; @@ -74,10 +75,11 @@ describe(HeftChildProcessReporter.name, () => { env: { ...process.env, ...reporter.environment }, stdio: reporter.stdio }); + const structuredOutputTerminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); const [exitCode]: [number | null, void] = await Promise.all([ waitForCloseAsync(child), - reporter.attachAsync(child) + reporter.attachAsync(child, structuredOutputTerminalProvider) ]); expect(exitCode).toBe(0); @@ -100,6 +102,8 @@ describe(HeftChildProcessReporter.name, () => { 'project#build', 'project#build' ]); + expect(structuredOutputTerminalProvider.getOutput()).toBe('1'); + expect(structuredOutputTerminalProvider.getErrorOutput()).toBe('2'); }); it('preserves old child stdout and stderr when no hello is sent', async () => { @@ -134,7 +138,7 @@ describe(HeftChildProcessReporter.name, () => { const [exitCode]: [number | null, void] = await Promise.all([ waitForCloseAsync(child), - reporter.attachAsync(child) + reporter.attachAsync(child, new StringBufferTerminalProvider()) ]); expect(exitCode).toBe(0); @@ -181,7 +185,7 @@ describe(HeftChildProcessReporter.name, () => { const [exitCode]: [number | null, void] = await Promise.all([ waitForCloseAsync(child), - reporter.attachAsync(child) + reporter.attachAsync(child, new StringBufferTerminalProvider()) ]); expect(exitCode).toBe(0); @@ -227,7 +231,7 @@ describe(HeftChildProcessReporter.name, () => { const [exitCode]: [number | null, void] = await Promise.all([ waitForCloseAsync(child), - reporter.attachAsync(child) + reporter.attachAsync(child, new StringBufferTerminalProvider()) ]); expect(exitCode).toBe(0); @@ -268,7 +272,7 @@ describe(HeftChildProcessReporter.name, () => { const [exitCode]: [number | null, void] = await Promise.all([ waitForCloseAsync(child), - reporter.attachAsync(child) + reporter.attachAsync(child, new StringBufferTerminalProvider()) ]); expect(exitCode).toBe(7); From 6c3dc62c113125b3d7b8dbef2e17dcd93b459cb6 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 22:48:11 +0000 Subject: [PATCH 4/8] Fix Heft child reporter review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../logging/HeftChildReporter.test.ts | 62 ++++ apps/rush/src/RushFrontend.ts | 10 +- .../src/test/sandbox/reporter-demo/run.mjs | 14 +- common/reviews/api/rush-lib.api.md | 2 +- common/reviews/api/rush-reporter.api.md | 2 + .../reporter/src/heft/HeftDescriptorHost.ts | 39 ++- .../src/protocol/ReporterHandshake.ts | 6 +- .../reporter/src/test/HeftIntegration.test.ts | 82 +++++- .../operations/HeftChildProcessReporter.ts | 187 +++++++++--- .../operations/ReporterOperationEventSink.ts | 1 + .../logic/operations/ShellOperationRunner.ts | 76 ++++- .../test/HeftChildProcessReporter.test.ts | 265 ++++++++++++++++++ .../test/ShellOperationRunner.test.ts | 99 ++++++- 13 files changed, 788 insertions(+), 57 deletions(-) diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts index 8180609dace..686ee86c9e6 100644 --- a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts @@ -159,4 +159,66 @@ describe(HeftChildReporter.name, () => { expect(exitCode).toBe(0); expect(descriptorText).toContain('structured with defaults'); }); + + it.each([0, 'wide'])( + 'falls back safely for invalid parent terminal width %p', + async (terminalWidth: unknown) => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (reporter) process.exit(2); + process.stdout.write('context fallback'); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth + } + })}\n` + ); + } + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(stdout).toBe('context fallback'); + } + ); }); diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 311ceec5739..84e23cc72f0 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -168,6 +168,11 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); const requestId: string = sessionId; + const stdoutColumns: number | undefined = process.stdout.columns; + const terminalWidth: number = + stdoutColumns !== undefined && Number.isSafeInteger(stdoutColumns) && stdoutColumns > 0 + ? stdoutColumns + : 80; if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { reporterHost.sink.emit({ protocolVersion: REPORTER_PROTOCOL_VERSION, @@ -199,10 +204,9 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr reporterHost.selection.reporter === 'default' ? resolveColorEnabled(process.env, process.stdout.isTTY === true) : false, - terminalWidth: process.stdout.columns ?? 80 + terminalWidth }, - ingestForeignEnvelope: (envelope) => - reporterHost.host.manager.ingestForeignEnvelope(envelope) + ingestForeignEnvelope: (envelope) => reporterHost.host.manager.ingestForeignEnvelope(envelope) } : undefined }, diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs index ae7220e270f..86550a3dd12 100644 --- a/apps/rush/src/test/sandbox/reporter-demo/run.mjs +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -62,6 +62,13 @@ const flagOffHelp = run('help-flag-off', ['--help']).stdout; const help = run('help', ['--help', '--reporter=json'], { RUSH_REPORTER: 'legacy' }).stdout; const commandJson = run('command-json', ['list', '--json', '--reporter=file']); const commandJsonConflict = run('command-json-conflict', ['list', '--json', '--reporter=json'], {}, 1); +const heftChild = run('heft-child', [ + 'rebuild', + '--only', + '@rushstack/rush-reporter', + '--reporter=json', + '--log-level=debug' +]).stdout; const tempOverride = path.join(outputFolder, 'rush-temp-override'); const tempOverrideFile = run('temp-override', [...commonArgs, '--reporter=file'], { RUSH_TEMP_FOLDER: tempOverride @@ -82,13 +89,6 @@ const purgeLogMatch = tempPurge.stderr.match(/^Rush full log: (.+)$/m); if (!purgeLogMatch || purgeLogMatch[1].startsWith(tempOverride) || !fs.existsSync(purgeLogMatch[1])) { throw new Error('The active purge reporter log was not preserved outside RUSH_TEMP_FOLDER.'); } -const heftChild = run('heft-child', [ - 'rebuild', - '--only', - '@rushstack/rush-reporter', - '--reporter=json', - '--log-level=debug' -]).stdout; function parseNdjson(text, name) { if (text.includes('\u001b')) { diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index e607636ceec..5176061aa17 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -698,7 +698,7 @@ export interface IOperationGraphContext extends ICreateOperationsContext { // @internal export interface _IOperationGraphEventSink { - createChildProcessReporter?(operationId: string): _IOperationChildProcessReporter | undefined; + createChildProcessReporter?(operationId: string, iterationId: number): _IOperationChildProcessReporter | undefined; onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk, iterationId: number): void; onOperationCompleted?(result: IOperationExecutionResult): void; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index adf5fabc13c..da2502c2b9e 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -544,6 +544,8 @@ export interface IHeftDescriptorHostOptions { readonly sendHelloAck?: (ack: IReporterHelloAck) => void; readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; + readonly trustedPrivacy?: ReporterPrivacyClassification; + readonly trustedSource?: IReporterEventSource; } // @beta diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index af4e2ff4fbb..4cd373be49d 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -2,7 +2,8 @@ // See LICENSE in the project root for license information. import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; -import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporterEventEnvelope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; import { REPORTER_EVENT_TYPES, isReporterEventRequired, @@ -16,6 +17,7 @@ import { InvalidReporterHelloError, negotiateReporterHello, REPORTER_KNOWN_CAPABILITIES, + validateReporterChildContext, type ReporterCapability, type IReporterChildContext, type IReporterHelloAck, @@ -88,6 +90,19 @@ function isReporterEventRecord(value: unknown): value is IWireReporterEventEnvel ); } +function applyPrivacyFloor( + privacy: ReporterPrivacyClassification, + floor: ReporterPrivacyClassification | undefined +): ReporterPrivacyClassification { + if (floor === undefined || privacy === 'secret' || privacy === floor) { + return privacy; + } + if (floor === 'secret' || privacy === 'public') { + return floor; + } + return privacy; +} + /** * Options for constructing a {@link HeftDescriptorHost}. * @@ -124,6 +139,17 @@ export interface IHeftDescriptorHostOptions { */ readonly context?: IReporterChildContext; + /** + * A parent-trusted source identity that replaces the child-provided source. + */ + readonly trustedSource?: IReporterEventSource; + + /** + * A parent-trusted privacy floor that prevents child events from claiming a less restrictive + * classification. + */ + readonly trustedPrivacy?: ReporterPrivacyClassification; + /** * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. */ @@ -191,6 +217,8 @@ export class HeftDescriptorHost { private readonly _supportedProtocolVersion: IReporterProtocolVersion; private readonly _supportedCapabilities: readonly ReporterCapability[]; private readonly _reporterContext: IReporterChildContext | undefined; + private readonly _trustedSource: IReporterEventSource | undefined; + private readonly _trustedPrivacy: ReporterPrivacyClassification | undefined; private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; private readonly _onNegotiation: ((result: IReporterHandshakeResult) => void) | undefined; private readonly _sendHelloAck: ((ack: IReporterHelloAck) => void) | undefined; @@ -207,7 +235,10 @@ export class HeftDescriptorHost { this._parentOperationId = options.parentOperationId; this._supportedProtocolVersion = options.supportedProtocolVersion; this._supportedCapabilities = options.supportedCapabilities ?? REPORTER_KNOWN_CAPABILITIES; - this._reporterContext = options.context; + this._reporterContext = + options.context === undefined ? undefined : validateReporterChildContext(options.context); + this._trustedSource = options.trustedSource; + this._trustedPrivacy = options.trustedPrivacy; this._forwardEnvelope = options.forwardEnvelope; this._onNegotiation = options.onNegotiation; this._sendHelloAck = options.sendHelloAck; @@ -295,10 +326,12 @@ export class HeftDescriptorHost { parentSessionId: this._parentSessionId, parentRequestId: this._parentRequestId, parentOperationId: this._parentOperationId, + source: this._trustedSource ?? record.source, scope: - this._parentOperationId !== undefined && record.scope?.operationId === undefined + this._parentOperationId !== undefined ? { ...record.scope, operationId: this._parentOperationId } : record.scope, + privacy: applyPrivacyFloor(record.privacy, this._trustedPrivacy), required: isReporterEventRequired(record.type), type: record.type }; diff --git a/libraries/reporter/src/protocol/ReporterHandshake.ts b/libraries/reporter/src/protocol/ReporterHandshake.ts index f3eee10f141..5dbdab9f500 100644 --- a/libraries/reporter/src/protocol/ReporterHandshake.ts +++ b/libraries/reporter/src/protocol/ReporterHandshake.ts @@ -223,7 +223,7 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); } -function parseReporterChildContext(value: unknown): IReporterChildContext { +export function validateReporterChildContext(value: unknown): IReporterChildContext { if (!isRecord(value)) { throw new InvalidReporterHelloAckError('context must be an object.'); } @@ -312,7 +312,7 @@ export function parseReporterHelloAck(value: unknown): IReporterHelloAck { throw new InvalidReporterHelloAckError('rejectedRequiredFeatures must be an array of strings.'); } const context: IReporterChildContext | undefined = - value.context === undefined ? undefined : parseReporterChildContext(value.context); + value.context === undefined ? undefined : validateReporterChildContext(value.context); if (context !== undefined && !value.acceptedCapabilities.includes('reporter-context-v1')) { throw new InvalidReporterHelloAckError( 'context requires the "reporter-context-v1" capability to be accepted.' @@ -365,7 +365,7 @@ export function negotiateReporterHello( const accepted: boolean = majorSupported && rejectedRequiredFeatures.length === 0; const context: IReporterChildContext | undefined = acceptedCapabilities.includes('reporter-context-v1') && options.context !== undefined - ? parseReporterChildContext(options.context) + ? validateReporterChildContext(options.context) : undefined; const ack: IReporterHelloAck = { diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index 32a91abd0ec..8447f3213e6 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -25,6 +25,7 @@ import { type IReporterEventEnvelope, type IReporterEventSource } from '../index'; +import { validateReporterChildContext } from '../protocol/ReporterHandshake'; const SOURCE: IReporterEventSource = { packageName: '@rushstack/heft', packageVersion: '1.2.19' }; @@ -272,6 +273,11 @@ describe('HeftDescriptorHost new descriptor path', () => { parentRequestId: 'parent-request', parentOperationId: 'op-42', supportedProtocolVersion: { major: 1, minor: 0 }, + trustedSource: { + packageName: '@rushstack/heft', + packageVersion: 'trusted' + }, + trustedPrivacy: 'local-sensitive', context: { reporter: 'plaintext', logLevel: 'normal', @@ -289,7 +295,8 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(child.context?.terminalWidth).toBe(120); child.emitEvent({ type: 'operationStatusChanged', - privacy: 'local-sensitive', + privacy: 'public', + scope: { operationId: 'child-selected-operation' }, payload: { operationId: 'c1', status: 'success' } }); child.emitEvent({ @@ -307,6 +314,11 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(forwarded.parentSessionId).toBe('parent-sess'); expect(forwarded.parentRequestId).toBe('parent-request'); expect(forwarded.parentOperationId).toBe('op-42'); + expect(forwarded.scope?.operationId).toBe('op-42'); + expect(forwarded.source).toEqual({ + packageName: '@rushstack/heft', + packageVersion: 'trusted' + }); expect(forwarded.sourceSequence).toBe(1); expect(forwarded.privacy).toBe('local-sensitive'); expect(recording.reported[1].sourceSequence).toBe(2); @@ -431,7 +443,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 1 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -692,6 +704,72 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(oversizedHost.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + it('uses parent-trusted source and a parent privacy floor for forwarded child events', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + trustedSource: { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope: (envelope: IReporterEventEnvelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: 'spoofed producer', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + const makeEvent = (sequence: number, privacy: 'public' | 'secret'): Record => ({ + protocolVersion: { major: 1, minor: 0 }, + eventId: `child_${sequence}`, + sessionId: 'child-sess', + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@malicious/spoof', packageVersion: '999.0.0' }, + privacy, + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: `${sequence}\n` } + }); + + expect(host.processChildRecord(makeEvent(1, 'public'))).toBe(true); + expect(host.processChildRecord(makeEvent(2, 'secret'))).toBe(true); + expect(forwarded.map(({ source }) => source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + { packageName: '@rushstack/heft', packageVersion: 'trusted' } + ]); + expect(forwarded.map(({ privacy }) => privacy)).toEqual(['local-sensitive', 'secret']); + }); + + it('validates parent reporter context once and rejects zero terminal width', () => { + expect( + () => + new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 0 + }, + forwardEnvelope: () => undefined + }) + ).toThrow(/terminalWidth must be a positive integer/); + }); + + it('rejects malformed parent reporter context', () => { + expect(() => + validateReporterChildContext({ + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 'wide' + }) + ).toThrow(/terminalWidth must be a positive integer/); + }); + it('drops unknown optional events and rejects unknown required events or invalid privacy', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index a58e46b0a72..946be05bf4d 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -14,6 +14,7 @@ import { type IHeftChildResult, type IReporterChildContext, type IReporterEventEnvelope, + type IReporterHandshakeResult, type IRushDiagnostic } from '@rushstack/rush-reporter'; @@ -23,6 +24,7 @@ export interface IHeftChildProcessReporterOptions { readonly parentSessionId: string; readonly parentRequestId: string; readonly parentOperationId: string; + readonly iterationId: number; readonly context: IReporterChildContext; readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; readonly onDiagnostic: (diagnostic: IRushDiagnostic) => void; @@ -62,7 +64,8 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter if (!eventStream || !ackStream) { throw new Error('The child reporter descriptors were not created by the process launcher.'); } - + const reporterEventStream: Readable = eventStream; + const reporterAckStream: Writable = ackStream; let diagnosticEmitted: boolean = false; const emitDiagnostic = (diagnostic: IRushDiagnostic | undefined): void => { if (diagnostic !== undefined && !diagnosticEmitted) { @@ -71,16 +74,78 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter } }; - const host: HeftDescriptorHost = new HeftDescriptorHost({ - parentSessionId: this._options.parentSessionId, - parentRequestId: this._options.parentRequestId, - parentOperationId: this._options.parentOperationId, - supportedProtocolVersion: REPORTER_PROTOCOL_VERSION, - context: this._options.context, - forwardEnvelope: (envelope: IReporterEventEnvelope) => { + await new Promise((resolve, reject) => { + let settled: boolean = false; + let eventEnded: boolean = false; + let acknowledgementStarted: boolean = false; + let acknowledgementCompleted: boolean = false; + let negotiationResult: IReporterHandshakeResult | undefined; + let structuredNegotiationConfirmed: boolean = false; + + const cleanup = (): void => { + reporterEventStream.off('data', onData); + reporterEventStream.off('end', onEnd); + }; + const complete = (): void => { + if (!settled) { + settled = true; + cleanup(); + resolve(); + } + }; + const fail = (error: Error): void => { + if (!settled) { + settled = true; + cleanup(); + reporterEventStream.destroy(); + if (!reporterAckStream.destroyed) { + reporterAckStream.destroy(); + } + reject(error); + } + }; + const confirmNegotiation = (): void => { + if ( + !structuredNegotiationConfirmed && + acknowledgementCompleted && + negotiationResult?.accepted && + negotiationResult.ack.acceptedCapabilities.includes('heft-child-events-v1') + ) { + structuredNegotiationConfirmed = true; + this._options.onStructuredNegotiated(); + } + }; + const maybeComplete = (): void => { + if (eventEnded && (!acknowledgementStarted || acknowledgementCompleted)) { + complete(); + } + }; + function onAcknowledgementError(error: Error): void { + fail(new Error(`The Heft reporter acknowledgement failed: ${error.message}`)); + } + function onAcknowledgementClose(): void { + if (acknowledgementStarted && !acknowledgementCompleted) { + fail(new Error('The Heft reporter acknowledgement closed before it was delivered.')); + } + reporterAckStream.off('error', onAcknowledgementError); + } + function onEventClose(): void { + if (!eventEnded && !settled) { + fail(new Error('The Heft reporter event stream closed before it completed.')); + } + reporterEventStream.off('error', onEventError); + } + const forwardEnvelope = (envelope: IReporterEventEnvelope): void => { + let forwardedEnvelope: IReporterEventEnvelope = envelope; if (envelope.type === 'diagnosticEmitted') { const payload: { severity?: unknown } = envelope.payload as { severity?: unknown }; this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; + if (typeof envelope.payload === 'object' && envelope.payload !== null) { + forwardedEnvelope = { + ...envelope, + payload: { ...envelope.payload, iterationId: this._options.iterationId } + }; + } } else if (envelope.type === 'externalOutput') { const payload: { stream?: unknown; text?: unknown } = envelope.payload as { stream?: unknown; @@ -97,34 +162,92 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter payload.text, payload.stream === 'stderr' ? TerminalProviderSeverity.error : TerminalProviderSeverity.log ); + forwardedEnvelope = { + ...envelope, + payload: { ...payload, iterationId: this._options.iterationId } + }; } - this._options.ingestForeignEnvelope(envelope); - }, - sendHelloAck: (ack) => { - ackStream.end(encodeNdjsonRecord(ack)); - }, - onNegotiation: (result) => { - if (result.accepted && result.ack.acceptedCapabilities.includes('heft-child-events-v1')) { - this._options.onStructuredNegotiated(); - } else { - emitDiagnostic(result.diagnostic); - } + this._options.ingestForeignEnvelope(forwardedEnvelope); + }; + + reporterAckStream.once('error', onAcknowledgementError); + reporterAckStream.once('close', onAcknowledgementClose); + reporterEventStream.on('error', onEventError); + reporterEventStream.once('close', onEventClose); + + let processor: { write(chunk: string): void; flush(): IHeftChildResult }; + try { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: this._options.parentSessionId, + parentRequestId: this._options.parentRequestId, + parentOperationId: this._options.parentOperationId, + supportedProtocolVersion: REPORTER_PROTOCOL_VERSION, + context: this._options.context, + trustedSource: { + packageName: '@rushstack/heft', + packageVersion: 'unknown' + }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope, + sendHelloAck: (ack) => { + if (acknowledgementStarted) { + throw new Error('The Heft reporter acknowledgement was sent more than once.'); + } + acknowledgementStarted = true; + if (reporterAckStream.destroyed) { + throw new Error('The Heft reporter acknowledgement stream closed before negotiation.'); + } + reporterAckStream.end(encodeNdjsonRecord(ack), (error?: Error | null) => { + if (error) { + onAcknowledgementError(error); + } else if (!settled) { + acknowledgementCompleted = true; + confirmNegotiation(); + maybeComplete(); + } + }); + }, + onNegotiation: (result) => { + negotiationResult = result; + if (result.accepted) { + confirmNegotiation(); + } else { + emitDiagnostic(result.diagnostic); + } + } + }); + processor = host.createStreamProcessor(); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter context was invalid.')); + return; } - }); - const processor: { write(chunk: string): void; flush(): IHeftChildResult } = host.createStreamProcessor(); - await new Promise((resolve, reject) => { - eventStream.setEncoding('utf8'); - eventStream.on('data', (chunk: string) => processor.write(chunk)); - eventStream.once('error', reject); - eventStream.once('end', () => { - const result: IHeftChildResult = processor.flush(); - emitDiagnostic(result.diagnostic); - if (!ackStream.destroyed) { - ackStream.end(); + function onData(chunk: string): void { + try { + processor.write(chunk); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter stream failed.')); } - resolve(); - }); + } + function onEventError(error: Error): void { + fail(error); + } + function onEnd(): void { + try { + const result: IHeftChildResult = processor.flush(); + emitDiagnostic(result.diagnostic); + eventEnded = true; + if (!acknowledgementStarted && !reporterAckStream.destroyed) { + reporterAckStream.destroy(); + } + maybeComplete(); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter stream failed.')); + } + } + reporterEventStream.setEncoding('utf8'); + reporterEventStream.on('data', onData); + reporterEventStream.once('end', onEnd); }); } } diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 77e694ff223..ef92e59046a 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -305,6 +305,7 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { parentSessionId: childProcessReporter.parentSessionId, parentRequestId: childProcessReporter.parentRequestId, parentOperationId: operation.operationId, + iterationId, context: childProcessReporter.context, ingestForeignEnvelope: childProcessReporter.ingestForeignEnvelope, onDiagnostic: (diagnostic: IRushDiagnostic) => diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index fef41e0e6f9..d3a4156ff61 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type * as child_process from 'node:child_process'; +import * as path from 'node:path'; import { Path } from '@rushstack/node-core-library'; import { type ITerminal, type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; @@ -10,6 +11,7 @@ import type { IPhase } from '../../api/CommandLineConfiguration'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { Utilities } from '../../utilities/Utilities'; +import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { IOperationRunner, IOperationRunnerContext, IOperationLastState } from './IOperationRunner'; import type { IOperationChildProcessReporter } from './OperationEventSink'; import { OperationError } from './OperationError'; @@ -102,7 +104,7 @@ export class ShellOperationRunner implements IOperationRunner { const { environment: initialEnvironment } = context; const childProcessReporter: IOperationChildProcessReporter | undefined = - context.createChildProcessReporter(); + !IS_WINDOWS && isHeftCommand(commandToRun) ? context.createChildProcessReporter() : undefined; const subProcess: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { rushConfiguration: rushConfiguration, @@ -116,9 +118,15 @@ export class ShellOperationRunner implements IOperationRunner { additionalEnvironment: childProcessReporter?.environment, stdio: childProcessReporter?.stdio }); - const reporterDrainPromise: Promise = - childProcessReporter?.attachAsync(subProcess, structuredChildOutputTerminalProvider) ?? - Promise.resolve(); + let reporterError: Error | undefined; + const reporterDrainPromise: Promise = childProcessReporter + ? childProcessReporter + .attachAsync(subProcess, structuredChildOutputTerminalProvider) + .catch((error) => { + reporterError = + error instanceof Error ? error : new Error('The Heft child reporter channel failed.'); + }) + : Promise.resolve(); // Hook into events, in order to get live streaming of the log subProcess.stdout?.on('data', (data: Buffer) => { @@ -157,7 +165,11 @@ export class ShellOperationRunner implements IOperationRunner { void ] = await Promise.all([closePromise, reporterDrainPromise]); - if (signal) { + if (reporterError) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', reporterError.message); + return OperationStatus.Failure; + } else if (signal) { // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. context.error = new OperationError('error', `Terminated by signal: ${signal}`); return OperationStatus.Failure; @@ -182,6 +194,60 @@ export class ShellOperationRunner implements IOperationRunner { } } +/** + * Returns whether a lifecycle command directly launches Heft. + * + * @internal + */ +export function isHeftCommand(command: string): boolean { + let quote: "'" | '"' | undefined; + let escaped: boolean = false; + for (const character of command) { + if (escaped) { + escaped = false; + continue; + } + if (character === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ('&|;<>()`$\r\n'.includes(character)) { + return false; + } + } + if (quote || escaped) { + return false; + } + + const tokens: string[] = + command + .match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) + ?.map((token: string) => token.replace(/^(['"])(.*)\1$/, '$2')) ?? []; + if (tokens.length === 0) { + return false; + } + + const executableName: string = path.basename(tokens[0].replace(/\\/g, '/')).toLowerCase(); + if (executableName === 'heft' || executableName === 'heft.cmd' || executableName === 'heft.exe') { + return true; + } + if ((executableName === 'node' || executableName === 'node.exe') && tokens.length > 1) { + const scriptName: string = path.basename(tokens[1].replace(/\\/g, '/')).toLowerCase(); + return scriptName === 'heft' || scriptName === 'heft.js'; + } + return false; +} + /** * When running a command from the "scripts" block in package.json, if the command * contains Unix-style path slashes and the OS is Windows, the package managers will diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 47df12e7c88..0b021cb8c6b 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import * as childProcess from 'node:child_process'; +import { PassThrough, Writable } from 'node:stream'; import type { IReporterEventEnvelope, IRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; @@ -31,6 +32,7 @@ describe(HeftChildProcessReporter.name, () => { parentSessionId: 'parent-session', parentRequestId: 'parent-request', parentOperationId: 'project#build', + iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => { envelopes.push(envelope); @@ -75,6 +77,15 @@ describe(HeftChildProcessReporter.name, () => { env: { ...process.env, ...reporter.environment }, stdio: reporter.stdio }); + const eventStream = child.stdio[3]; + const acknowledgementStream = child.stdio[4]; + const initialListenerCounts = { + eventData: eventStream?.listenerCount('data'), + eventError: eventStream?.listenerCount('error'), + eventEnd: eventStream?.listenerCount('end'), + acknowledgementError: acknowledgementStream?.listenerCount('error'), + acknowledgementClose: acknowledgementStream?.listenerCount('close') + }; const structuredOutputTerminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); const [exitCode]: [number | null, void] = await Promise.all([ @@ -102,8 +113,21 @@ describe(HeftChildProcessReporter.name, () => { 'project#build', 'project#build' ]); + expect(envelopes.map((envelope) => envelope.source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, + { packageName: '@rushstack/heft', packageVersion: 'unknown' } + ]); + expect(envelopes.map((envelope) => envelope.privacy)).toEqual(['local-sensitive', 'local-sensitive']); + expect(envelopes.map((envelope) => (envelope.payload as { iterationId?: number }).iterationId)).toEqual([ + 7, 7 + ]); expect(structuredOutputTerminalProvider.getOutput()).toBe('1'); expect(structuredOutputTerminalProvider.getErrorOutput()).toBe('2'); + expect(eventStream?.listenerCount('data')).toBe(initialListenerCounts.eventData); + expect(eventStream?.listenerCount('error')).toBe(initialListenerCounts.eventError); + expect(eventStream?.listenerCount('end')).toBe(initialListenerCounts.eventEnd); + expect(acknowledgementStream?.listenerCount('error')).toBe(initialListenerCounts.acknowledgementError); + expect(acknowledgementStream?.listenerCount('close')).toBe(initialListenerCounts.acknowledgementClose); }); it('preserves old child stdout and stderr when no hello is sent', async () => { @@ -112,6 +136,7 @@ describe(HeftChildProcessReporter.name, () => { parentSessionId: 'parent-session', parentRequestId: 'parent-request', parentOperationId: 'project#build', + iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => envelope.eventId, onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), @@ -153,6 +178,7 @@ describe(HeftChildProcessReporter.name, () => { parentSessionId: 'parent-session', parentRequestId: 'parent-request', parentOperationId: 'project#build', + iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => envelope.eventId, onDiagnostic: () => undefined, @@ -199,6 +225,7 @@ describe(HeftChildProcessReporter.name, () => { parentSessionId: 'parent-session', parentRequestId: 'parent-request', parentOperationId: 'project#build', + iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => envelope.eventId, onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), @@ -239,12 +266,250 @@ describe(HeftChildProcessReporter.name, () => { expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_UPDATE_REQUIRED']); }); + it('rejects exactly once when the acknowledgement pipe closes before delivery', async () => { + let structuredNegotiationCount: number = 0; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => { + structuredNegotiationCount++; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.closeSync(ackFd); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + process.stdout.write('raw fallback after acknowledgement close'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + let rejectionCount: number = 0; + const attachPromise: Promise = reporter + .attachAsync(child, new StringBufferTerminalProvider()) + .catch((error) => { + rejectionCount++; + throw error; + }); + + await expect(attachPromise).rejects.toThrow(/acknowledgement/); + expect(await waitForCloseAsync(child)).toBe(0); + expect(stdout).toBe('raw fallback after acknowledgement close'); + expect(rejectionCount).toBe(1); + expect(structuredNegotiationCount).toBe(0); + }); + + it('handles an acknowledgement write callback error followed by an EPIPE event', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: Writable = new Writable({ + write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + void chunk; + void encoding; + const error: NodeJS.ErrnoException = new Error('write EPIPE'); + error.code = 'EPIPE'; + callback(error); + } + }); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + let rejectionCount: number = 0; + const attachPromise: Promise = reporter + .attachAsync(child, new StringBufferTerminalProvider()) + .catch((error) => { + rejectionCount++; + throw error; + }); + + eventStream.end( + `${JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + })}\n` + ); + + await expect(attachPromise).rejects.toThrow(/acknowledgement failed: write EPIPE/); + await new Promise((resolve) => setImmediate(resolve)); + expect(rejectionCount).toBe(1); + }); + + it('rejects stream callback failures without an uncaught process error', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'trigger callback' } + }) + '\\n'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const provider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + jest.spyOn(provider, 'write').mockImplementation(() => { + throw new Error('archive write failed'); + }); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(reporter.attachAsync(child, provider)).rejects.toThrow('archive write failed'); + expect(await closePromise).not.toBeNull(); + }); + + it('rejects attach when the validated envelope callback fails', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: () => { + throw new Error('foreign envelope rejected'); + }, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: 'untrusted', packageVersion: '0.0.0' }, + privacy: 'public', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'output' } + }) + '\\n'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(attachPromise).rejects.toThrow('foreign envelope rejected'); + expect(await closePromise).toBe(0); + }); + + it('rejects attach instead of emitting an unhandled acknowledgement pipe error', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.closeSync(ackFd); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + setTimeout(() => process.exit(0), 50); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(attachPromise).rejects.toThrow(); + expect(await closePromise).toBe(0); + }); + it('reports a truncated accepted descriptor stream without hanging after child crash', async () => { const diagnostics: IRushDiagnostic[] = []; const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ parentSessionId: 'parent-session', parentRequestId: 'parent-request', parentOperationId: 'project#build', + iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => envelope.eventId, onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts index 686f82c83f7..ce8dd452ad5 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts @@ -1,7 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { convertSlashesForWindows } from '../ShellOperationRunner'; +import type * as childProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +import { + StringBufferTerminalProvider, + Terminal, + type ITerminal, + type ITerminalProvider +} from '@rushstack/terminal'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import { Utilities } from '../../../utilities/Utilities'; +import type { IOperationRunnerContext } from '../IOperationRunner'; +import { OperationStatus } from '../OperationStatus'; +import { ShellOperationRunner, convertSlashesForWindows, isHeftCommand } from '../ShellOperationRunner'; describe(convertSlashesForWindows.name, () => { it('converted inputs', () => { @@ -15,6 +31,87 @@ describe(convertSlashesForWindows.name, () => { expect(convertSlashesForWindows('/blah/bleep { + it.each([ + ['heft run --only build', true], + ['./node_modules/.bin/heft build', true], + ['node ./node_modules/@rushstack/heft/bin/heft build', true], + ['"C:\\repo\\node_modules\\.bin\\heft.cmd" build', true], + ['heft build --message "safe && quoted"', true], + ['eslint .', false], + ['node scripts/build.js', false], + ['cross-env NODE_ENV=test heft build', false], + ['heft build && node spawn-grandchild.js', false], + ['heft build $(node spawn-grandchild.js)', false], + ['heft build &', false], + ['heft build > output.log', false], + ['heft build `node spawn-grandchild.js`', false], + ['heft build "unterminated', false] + ])('classifies %s', (command: string, expected: boolean) => { + expect(isHeftCommand(command)).toBe(expected); + }); + + it('does not allocate Unix reporter pipes for a shell child that can spawn a grandchild', async () => { + if (process.platform === 'win32') { + return; + } + const stdout: PassThrough = new PassThrough(); + const stderr: PassThrough = new PassThrough(); + const child: childProcess.ChildProcess = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdio: [] + }) as unknown as childProcess.ChildProcess; + const executeSpy = jest + .spyOn(Utilities, 'executeLifecycleCommandAsync') + .mockImplementation((command, options) => { + expect(command).toBe('node spawn-grandchild.js'); + expect(options.additionalEnvironment).toBeUndefined(); + expect(options.stdio).toBeUndefined(); + queueMicrotask(() => { + stdout.end(); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }); + const createChildProcessReporter = jest.fn(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const context = { + environment: undefined, + createChildProcessReporter, + async runWithTerminalAsync( + callback: ( + terminal: ITerminal, + operationTerminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise + ): Promise { + return await callback(new Terminal(terminalProvider), terminalProvider, terminalProvider); + } + } as unknown as IOperationRunnerContext; + const runner: ShellOperationRunner = new ShellOperationRunner({ + phase: { allowWarningsOnSuccess: false } as IPhase, + rushProject: { + projectFolder: process.cwd(), + rushConfiguration: { commonTempFolder: process.cwd() } + } as RushConfigurationProject, + displayName: 'grandchild retention', + initialCommand: 'node spawn-grandchild.js', + incrementalCommand: undefined, + commandForHash: 'node spawn-grandchild.js', + ignoredParameterValues: [] + }); + + try { + await expect(runner.executeAsync(context)).resolves.toBe(OperationStatus.Success); + expect(createChildProcessReporter).not.toHaveBeenCalled(); + } finally { + executeSpy.mockRestore(); + } + }); + }); it('ignored inputs', () => { expect(convertSlashesForWindows('/blah\\bleep && /bloop')).toEqual('/blah\\bleep && /bloop'); expect(convertSlashesForWindows('cmd.exe /c blah')).toEqual('cmd.exe /c blah'); From 0c3bc50bf4052ce501e844d6bdaf03ebb8f4c0f1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 22:51:54 +0000 Subject: [PATCH 5/8] Fix acknowledgement close test race Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../test/HeftChildProcessReporter.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 0b021cb8c6b..23e498fe41b 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -7,7 +7,7 @@ import { PassThrough, Writable } from 'node:stream'; import type { IReporterEventEnvelope, IRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; -import { HeftChildProcessReporter } from '../HeftChildProcessReporter'; +import { HeftChildProcessReporter, HeftChildReporterNonFatalError } from '../HeftChildProcessReporter'; const CONTEXT = { reporter: 'json', @@ -268,6 +268,7 @@ describe(HeftChildProcessReporter.name, () => { it('rejects exactly once when the acknowledgement pipe closes before delivery', async () => { let structuredNegotiationCount: number = 0; + const diagnostics: IRushDiagnostic[] = []; const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ parentSessionId: 'parent-session', parentRequestId: 'parent-request', @@ -275,7 +276,7 @@ describe(HeftChildProcessReporter.name, () => { iterationId: 7, context: CONTEXT, ingestForeignEnvelope: (envelope) => envelope.eventId, - onDiagnostic: () => undefined, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), onStructuredNegotiated: () => { structuredNegotiationCount++; } @@ -302,6 +303,7 @@ describe(HeftChildProcessReporter.name, () => { child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk; }); + const closePromise: Promise = waitForCloseAsync(child); let rejectionCount: number = 0; const attachPromise: Promise = reporter .attachAsync(child, new StringBufferTerminalProvider()) @@ -310,11 +312,16 @@ describe(HeftChildProcessReporter.name, () => { throw error; }); - await expect(attachPromise).rejects.toThrow(/acknowledgement/); - expect(await waitForCloseAsync(child)).toBe(0); + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); + expect(await closePromise).toBe(0); expect(stdout).toBe('raw fallback after acknowledgement close'); expect(rejectionCount).toBe(1); expect(structuredNegotiationCount).toBe(0); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'RUSH_PROTOCOL_INVALID_CHILD_STREAM', + severity: 'warning' + }); }); it('handles an acknowledgement write callback error followed by an EPIPE event', async () => { @@ -499,7 +506,7 @@ describe(HeftChildProcessReporter.name, () => { const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); const closePromise: Promise = waitForCloseAsync(child); - await expect(attachPromise).rejects.toThrow(); + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); expect(await closePromise).toBe(0); }); From 3a89633dd8f45c8788020af2712763660fb527c0 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 22:59:56 +0000 Subject: [PATCH 6/8] Preserve fallback on acknowledgement failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../operations/HeftChildProcessReporter.ts | 39 +++++- .../logic/operations/ShellOperationRunner.ts | 11 +- .../test/HeftChildProcessReporter.test.ts | 116 ++++++++++++++++++ .../test/ShellOperationRunner.test.ts | 65 ++++++++++ 4 files changed, 221 insertions(+), 10 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index 946be05bf4d..b14a31d9624 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -7,6 +7,7 @@ import type { Readable, Writable } from 'node:stream'; import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; import { allocateChildDescriptor, + createRushDiagnostic, encodeNdjsonRecord, HeftDescriptorHost, REPORTER_PROTOCOL_VERSION, @@ -20,6 +21,18 @@ import { import type { IOperationChildProcessReporter } from './OperationEventSink'; +/** + * An acknowledgement transport failure that must not change the child process result. + * + * @internal + */ +export class HeftChildReporterNonFatalError extends Error { + public constructor(message: string) { + super(message); + this.name = HeftChildReporterNonFatalError.name; + } +} + export interface IHeftChildProcessReporterOptions { readonly parentSessionId: string; readonly parentRequestId: string; @@ -120,12 +133,26 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter complete(); } }; - function onAcknowledgementError(error: Error): void { - fail(new Error(`The Heft reporter acknowledgement failed: ${error.message}`)); + function onAcknowledgementError(error: Error): HeftChildReporterNonFatalError { + const transportError: HeftChildReporterNonFatalError = new HeftChildReporterNonFatalError( + `The Heft reporter acknowledgement failed: ${error.message}` + ); + if (!settled) { + emitDiagnostic( + createRushDiagnostic('RUSH_PROTOCOL_INVALID_CHILD_STREAM', { + severity: 'warning', + parameters: { + reason: { value: transportError.message, privacy: 'public' } + } + }) + ); + fail(transportError); + } + return transportError; } function onAcknowledgementClose(): void { - if (acknowledgementStarted && !acknowledgementCompleted) { - fail(new Error('The Heft reporter acknowledgement closed before it was delivered.')); + if (!settled && acknowledgementStarted && !acknowledgementCompleted) { + onAcknowledgementError(new Error('The acknowledgement stream closed before it was delivered.')); } reporterAckStream.off('error', onAcknowledgementError); } @@ -195,7 +222,9 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter } acknowledgementStarted = true; if (reporterAckStream.destroyed) { - throw new Error('The Heft reporter acknowledgement stream closed before negotiation.'); + throw onAcknowledgementError( + new Error('The acknowledgement stream closed before negotiation.') + ); } reporterAckStream.end(encodeNdjsonRecord(ack), (error?: Error | null) => { if (error) { diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index d3a4156ff61..bf125a38d2a 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -14,6 +14,7 @@ import { Utilities } from '../../utilities/Utilities'; import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { IOperationRunner, IOperationRunnerContext, IOperationLastState } from './IOperationRunner'; import type { IOperationChildProcessReporter } from './OperationEventSink'; +import { HeftChildReporterNonFatalError } from './HeftChildProcessReporter'; import { OperationError } from './OperationError'; import { OperationStatus } from './OperationStatus'; @@ -165,11 +166,7 @@ export class ShellOperationRunner implements IOperationRunner { void ] = await Promise.all([closePromise, reporterDrainPromise]); - if (reporterError) { - // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. - context.error = new OperationError('error', reporterError.message); - return OperationStatus.Failure; - } else if (signal) { + if (signal) { // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. context.error = new OperationError('error', `Terminated by signal: ${signal}`); return OperationStatus.Failure; @@ -177,6 +174,10 @@ export class ShellOperationRunner implements IOperationRunner { // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. context.error = new OperationError('error', `Returned error code: ${exitCode}`); return OperationStatus.Failure; + } else if (reporterError && !(reporterError instanceof HeftChildReporterNonFatalError)) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', reporterError.message); + return OperationStatus.Failure; } else if (hasWarningOrError || childProcessReporter?.hasWarningOrError) { return OperationStatus.SuccessWithWarning; } else { diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 23e498fe41b..6806e8de728 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -375,6 +375,122 @@ describe(HeftChildProcessReporter.name, () => { expect(rejectionCount).toBe(1); }); + it('does not forward records after a preclosed acknowledgement stream rejects negotiation', async () => { + const envelopes: IReporterEventEnvelope[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + envelopes.push(envelope); + return envelope.eventId; + }, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: PassThrough = new PassThrough(); + acknowledgementStream.destroy(); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + const provider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const attachPromise: Promise = reporter.attachAsync(child, provider); + + eventStream.end( + [ + { + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }, + { + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'must not forward' } + } + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n' + ); + + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); + expect(envelopes).toEqual([]); + expect(provider.getOutput()).toBe(''); + }); + + it('does not replace a fatal forwarding failure with an acknowledgement warning', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: () => { + throw new Error('foreign envelope rejected'); + }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: Writable = new Writable({ + write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + void chunk; + void encoding; + setImmediate(callback); + } + }); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + + eventStream.end( + [ + { + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }, + { + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'trigger forwarding failure' } + } + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n' + ); + + await expect(attachPromise).rejects.toThrow('foreign envelope rejected'); + expect(diagnostics).toEqual([]); + }); + it('rejects stream callback failures without an uncaught process error', async () => { const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ parentSessionId: 'parent-session', diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts index ce8dd452ad5..4f0c3ab3b19 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts @@ -16,6 +16,8 @@ import type { IPhase } from '../../../api/CommandLineConfiguration'; import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; import { Utilities } from '../../../utilities/Utilities'; import type { IOperationRunnerContext } from '../IOperationRunner'; +import type { IOperationChildProcessReporter } from '../OperationEventSink'; +import { HeftChildReporterNonFatalError } from '../HeftChildProcessReporter'; import { OperationStatus } from '../OperationStatus'; import { ShellOperationRunner, convertSlashesForWindows, isHeftCommand } from '../ShellOperationRunner'; @@ -111,6 +113,69 @@ describe(convertSlashesForWindows.name, () => { executeSpy.mockRestore(); } }); + + it('does not fail a successful Heft operation for a nonfatal acknowledgement error', async () => { + if (process.platform === 'win32') { + return; + } + const stdout: PassThrough = new PassThrough(); + const stderr: PassThrough = new PassThrough(); + const child: childProcess.ChildProcess = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdio: [] + }) as unknown as childProcess.ChildProcess; + const executeSpy = jest.spyOn(Utilities, 'executeLifecycleCommandAsync').mockImplementation(() => { + queueMicrotask(() => { + stdout.end(); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }); + const childReporter: IOperationChildProcessReporter = { + environment: {}, + hasWarningOrError: false, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + attachAsync: async () => { + throw new HeftChildReporterNonFatalError('acknowledgement failed'); + } + }; + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const context = { + environment: undefined, + error: undefined, + createChildProcessReporter: () => childReporter, + async runWithTerminalAsync( + callback: ( + terminal: ITerminal, + operationTerminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise + ): Promise { + return await callback(new Terminal(terminalProvider), terminalProvider, terminalProvider); + } + } as unknown as IOperationRunnerContext; + const runner: ShellOperationRunner = new ShellOperationRunner({ + phase: { allowWarningsOnSuccess: false } as IPhase, + rushProject: { + projectFolder: process.cwd(), + rushConfiguration: { commonTempFolder: process.cwd() } + } as RushConfigurationProject, + displayName: 'nonfatal reporter error', + initialCommand: 'heft build', + incrementalCommand: undefined, + commandForHash: 'heft build', + ignoredParameterValues: [] + }); + + try { + await expect(runner.executeAsync(context)).resolves.toBe(OperationStatus.Success); + expect(context.error).toBeUndefined(); + } finally { + executeSpy.mockRestore(); + } + }); }); it('ignored inputs', () => { expect(convertSlashesForWindows('/blah\\bleep && /bloop')).toEqual('/blah\\bleep && /bloop'); From 80f53079764dff0c68908226ae574f11cc221af9 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 23:07:37 +0000 Subject: [PATCH 7/8] Archive structured Heft diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../operations/HeftChildProcessReporter.ts | 29 ++++++++++++- .../test/HeftChildProcessReporter.test.ts | 43 +++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index b14a31d9624..55bbb99f160 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -21,6 +21,29 @@ import { import type { IOperationChildProcessReporter } from './OperationEventSink'; +function formatDiagnosticForOperationLog(diagnostic: IRushDiagnostic): string { + const toolParameter: unknown = diagnostic.parameters?.tool?.value; + const codeParameter: unknown = diagnostic.parameters?.code?.value; + const messageParameter: unknown = diagnostic.parameters?.message?.value; + const toolName: string = + typeof toolParameter === 'string' ? toolParameter : (diagnostic.source?.toolName ?? '@rushstack/heft'); + const code: string = typeof codeParameter === 'string' && codeParameter ? ` (${codeParameter})` : ''; + const message: string = + typeof messageParameter === 'string' && messageParameter ? messageParameter : diagnostic.code; + let location: string = ''; + if (diagnostic.source?.kind === 'file') { + location = diagnostic.source.file; + if (diagnostic.source.line !== undefined) { + location += `:${diagnostic.source.line}`; + if (diagnostic.source.column !== undefined) { + location += `:${diagnostic.source.column}`; + } + } + location += ' - '; + } + return `[${toolName}] ${diagnostic.severity}${code}: ${location}${message}\n`; +} + /** * An acknowledgement transport failure that must not change the child process result. * @@ -165,8 +188,12 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter const forwardEnvelope = (envelope: IReporterEventEnvelope): void => { let forwardedEnvelope: IReporterEventEnvelope = envelope; if (envelope.type === 'diagnosticEmitted') { - const payload: { severity?: unknown } = envelope.payload as { severity?: unknown }; + const payload: IRushDiagnostic = envelope.payload as IRushDiagnostic; this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; + structuredOutputTerminalProvider.write( + formatDiagnosticForOperationLog(payload), + payload.severity === 'warning' ? TerminalProviderSeverity.warning : TerminalProviderSeverity.error + ); if (typeof envelope.payload === 'object' && envelope.payload !== null) { forwardedEnvelope = { ...envelope, diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 6806e8de728..4b155cc5af2 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -72,6 +72,30 @@ describe(HeftChildProcessReporter.name, () => { payload: { stream: sequence === 1 ? 'stdout' : 'stderr', text: String(sequence) } }) + '\\n'); } + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_3', + sessionId: 'child-session', + sequence: 3, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'public', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: 'typescript', privacy: 'public' }, + code: { value: 'TS1005', privacy: 'public' }, + message: { value: 'semicolon expected', privacy: 'local-sensitive' } + }, + source: { kind: 'file', file: 'src/index.ts', line: 4, column: 2, toolName: 'typescript' } + } + }) + '\\n'); `; const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { env: { ...process.env, ...reporter.environment }, @@ -96,33 +120,44 @@ describe(HeftChildProcessReporter.name, () => { expect(exitCode).toBe(0); expect(structuredNegotiated).toBe(true); expect(reporter.hasWarningOrError).toBe(true); - expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2]); + expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2, 3]); expect(envelopes.map((envelope) => envelope.parentSessionId)).toEqual([ + 'parent-session', 'parent-session', 'parent-session' ]); expect(envelopes.map((envelope) => envelope.parentRequestId)).toEqual([ + 'parent-request', 'parent-request', 'parent-request' ]); expect(envelopes.map((envelope) => envelope.parentOperationId)).toEqual([ + 'project#build', 'project#build', 'project#build' ]); expect(envelopes.map((envelope) => envelope.scope?.operationId)).toEqual([ + 'project#build', 'project#build', 'project#build' ]); expect(envelopes.map((envelope) => envelope.source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, { packageName: '@rushstack/heft', packageVersion: 'unknown' }, { packageName: '@rushstack/heft', packageVersion: 'unknown' } ]); - expect(envelopes.map((envelope) => envelope.privacy)).toEqual(['local-sensitive', 'local-sensitive']); + expect(envelopes.map((envelope) => envelope.privacy)).toEqual([ + 'local-sensitive', + 'local-sensitive', + 'local-sensitive' + ]); expect(envelopes.map((envelope) => (envelope.payload as { iterationId?: number }).iterationId)).toEqual([ - 7, 7 + 7, 7, 7 ]); expect(structuredOutputTerminalProvider.getOutput()).toBe('1'); - expect(structuredOutputTerminalProvider.getErrorOutput()).toBe('2'); + expect(structuredOutputTerminalProvider.getErrorOutput({ normalizeSpecialCharacters: false })).toBe( + '2[typescript] error (TS1005): src/index.ts:4:2 - semicolon expected\n' + ); expect(eventStream?.listenerCount('data')).toBe(initialListenerCounts.eventData); expect(eventStream?.listenerCount('error')).toBe(initialListenerCounts.eventError); expect(eventStream?.listenerCount('end')).toBe(initialListenerCounts.eventEnd); From 928b60ee572c3b5edf64f2ba6243e207c8a87bce Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Sat, 29 Aug 2026 00:27:14 +0000 Subject: [PATCH 8/8] Harden Heft child trust boundary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../reporter/src/heft/HeftDescriptorHost.ts | 215 +++++++++++++- .../reporter/src/test/HeftIntegration.test.ts | 264 +++++++++++++++++- .../operations/HeftChildProcessReporter.ts | 31 +- .../logic/operations/ShellOperationRunner.ts | 3 + .../test/HeftChildProcessReporter.test.ts | 25 +- .../test/ShellOperationRunner.test.ts | 3 + 6 files changed, 504 insertions(+), 37 deletions(-) diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index 4cd373be49d..89320a5da58 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -2,7 +2,11 @@ // See LICENSE in the project root for license information. import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; -import type { IReporterEventEnvelope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { + IReporterEventEnvelope, + IReporterEventScope, + IReporterEventSource +} from '../events/IReporterEventEnvelope'; import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; import { REPORTER_EVENT_TYPES, @@ -11,6 +15,7 @@ import { } from '../events/ReporterEventType'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; +import { isValidRushDiagnosticCode } from '../diagnostics/RushDiagnosticCode'; import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; import { @@ -25,6 +30,10 @@ import { } from '../protocol/ReporterHandshake'; const REPORTER_EVENT_TYPE_SET: ReadonlySet = new Set(REPORTER_EVENT_TYPES); +const HEFT_CHILD_EVENT_TYPES: ReadonlySet = new Set([ + 'diagnosticEmitted', + 'externalOutput' +]); type IWireReporterEventEnvelope = Omit, 'type'> & { readonly type: string; @@ -90,6 +99,148 @@ function isReporterEventRecord(value: unknown): value is IWireReporterEventEnvel ); } +function isDiagnosticRecord(value: unknown): boolean { + if (!isObjectRecord(value)) { + return false; + } + if ( + typeof value.diagnosticId !== 'string' || + value.diagnosticId.length === 0 || + typeof value.code !== 'string' || + !isValidRushDiagnosticCode(value.code) || + typeof value.category !== 'string' || + value.category.length === 0 || + (value.severity !== 'warning' && value.severity !== 'error') || + typeof value.summaryKey !== 'string' || + value.summaryKey.length === 0 || + (value.detailKey !== undefined && typeof value.detailKey !== 'string') || + (value.retryable !== undefined && typeof value.retryable !== 'boolean') + ) { + return false; + } + if (value.parameters !== undefined) { + if (!isObjectRecord(value.parameters)) { + return false; + } + for (const parameter of Object.values(value.parameters)) { + if ( + !isObjectRecord(parameter) || + !Object.prototype.hasOwnProperty.call(parameter, 'value') || + (parameter.privacy !== 'public' && + parameter.privacy !== 'local-sensitive' && + parameter.privacy !== 'secret') + ) { + return false; + } + } + } + for (const key of ['causeDiagnosticIds', 'relatedArtifactIds']) { + const identifiers: unknown = value[key]; + if ( + identifiers !== undefined && + (!Array.isArray(identifiers) || + !identifiers.every((identifier: unknown) => typeof identifier === 'string')) + ) { + return false; + } + } + if (value.remediation !== undefined) { + if (!Array.isArray(value.remediation)) { + return false; + } + for (const action of value.remediation) { + if ( + !isObjectRecord(action) || + typeof action.descriptionKey !== 'string' || + action.descriptionKey.length === 0 || + (action.command !== undefined && typeof action.command !== 'string') || + (action.documentationUrl !== undefined && typeof action.documentationUrl !== 'string') || + (action.automatedExecutionSafety !== 'safe' && + action.automatedExecutionSafety !== 'requires-confirmation' && + action.automatedExecutionSafety !== 'unsafe') + ) { + return false; + } + } + } + if (value.source !== undefined) { + if (!isObjectRecord(value.source)) { + return false; + } + if (value.source.kind === 'file') { + if ( + typeof value.source.file !== 'string' || + (value.source.line !== undefined && !isNonNegativeInteger(value.source.line)) || + (value.source.column !== undefined && !isNonNegativeInteger(value.source.column)) || + (value.source.toolName !== undefined && typeof value.source.toolName !== 'string') + ) { + return false; + } + } else if (value.source.kind === 'tool') { + if (typeof value.source.toolName !== 'string') { + return false; + } + } else { + return false; + } + } + return true; +} + +function sanitizeDiagnosticRecord( + value: Record, + envelopePrivacy: ReporterPrivacyClassification +): Record { + const parameters: Record | undefined = isObjectRecord(value.parameters) + ? Object.fromEntries( + Object.entries(value.parameters).map(([name, parameter]: [string, unknown]) => { + const classified: Record = parameter as Record; + const redact: boolean = envelopePrivacy === 'secret' || classified.privacy === 'secret'; + return [ + name, + { + privacy: classified.privacy, + value: redact ? '[secret]' : classified.value + } + ]; + }) + ) + : undefined; + let source: Record | undefined; + if (envelopePrivacy !== 'secret' && isObjectRecord(value.source)) { + source = + value.source.kind === 'file' + ? { + kind: 'file', + file: value.source.file, + ...(value.source.line === undefined ? {} : { line: value.source.line }), + ...(value.source.column === undefined ? {} : { column: value.source.column }), + ...(value.source.toolName === undefined ? {} : { toolName: value.source.toolName }) + } + : { + kind: 'tool', + toolName: value.source.toolName + }; + } + return { + diagnosticId: value.diagnosticId, + code: value.code, + category: value.category, + severity: value.severity, + summaryKey: value.summaryKey, + ...(value.detailKey === undefined ? {} : { detailKey: value.detailKey }), + ...(parameters === undefined ? {} : { parameters }), + ...(source === undefined ? {} : { source }), + ...(value.causeDiagnosticIds === undefined + ? {} + : { causeDiagnosticIds: [...(value.causeDiagnosticIds as string[])] }), + ...(value.retryable === undefined ? {} : { retryable: value.retryable }), + ...(value.relatedArtifactIds === undefined + ? {} + : { relatedArtifactIds: [...(value.relatedArtifactIds as string[])] }) + }; +} + function applyPrivacyFloor( privacy: ReporterPrivacyClassification, floor: ReporterPrivacyClassification | undefined @@ -306,6 +457,12 @@ export class HeftDescriptorHost { } return true; } + if (!HEFT_CHILD_EVENT_TYPES.has(record.type)) { + if (isReporterEventRequired(record.type)) { + return this._rejectMalformedStream('a required event type is not permitted from a Heft child'); + } + return true; + } if (record.type === 'externalOutput') { if ( !isObjectRecord(record.payload) || @@ -319,21 +476,61 @@ export class HeftDescriptorHost { ) { return this._rejectMalformedStream('an external output event exceeded the protocol chunk limit'); } + } else if (record.type === 'diagnosticEmitted' && !isDiagnosticRecord(record.payload)) { + return this._rejectMalformedStream('a diagnostic event contained an invalid payload'); } + const privacy: ReporterPrivacyClassification = applyPrivacyFloor(record.privacy, this._trustedPrivacy); + const payload: unknown = + record.type === 'externalOutput' + ? { + stream: (record.payload as Record).stream, + text: + privacy === 'secret' + ? '[secret child output omitted]' + : (record.payload as Record).text + } + : record.type === 'diagnosticEmitted' + ? sanitizeDiagnosticRecord(record.payload as Record, privacy) + : record.payload; + const source: IReporterEventSource = this._trustedSource + ? { ...this._trustedSource } + : { + packageName: record.source.packageName, + packageVersion: record.source.packageVersion, + ...(record.source.component === undefined ? {} : { component: record.source.component }) + }; + const scope: IReporterEventScope | undefined = + record.scope === undefined && this._parentOperationId === undefined + ? undefined + : { + ...(record.scope?.commandName === undefined ? {} : { commandName: record.scope.commandName }), + ...(record.scope?.projectName === undefined ? {} : { projectName: record.scope.projectName }), + ...(record.scope?.phaseName === undefined ? {} : { phaseName: record.scope.phaseName }), + ...(this._parentOperationId !== undefined + ? { operationId: this._parentOperationId } + : record.scope?.operationId === undefined + ? {} + : { operationId: record.scope.operationId }) + }; const correlated: IReporterEventEnvelope = { - ...record, + protocolVersion: { + major: record.protocolVersion.major, + minor: record.protocolVersion.minor + }, + eventId: record.eventId, + sessionId: record.sessionId, parentSessionId: this._parentSessionId, parentRequestId: this._parentRequestId, parentOperationId: this._parentOperationId, - source: this._trustedSource ?? record.source, - scope: - this._parentOperationId !== undefined - ? { ...record.scope, operationId: this._parentOperationId } - : record.scope, - privacy: applyPrivacyFloor(record.privacy, this._trustedPrivacy), + sequence: record.sequence, + timestamp: record.timestamp, + source, + scope, + privacy, required: isReporterEventRequired(record.type), - type: record.type + type: record.type, + payload }; this._forwardEnvelope(correlated); this._eventCount++; diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index 8447f3213e6..a061baf9f6c 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -294,14 +294,14 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(child.mode).toBe('structured'); expect(child.context?.terminalWidth).toBe(120); child.emitEvent({ - type: 'operationStatusChanged', + type: 'externalOutput', privacy: 'public', scope: { operationId: 'child-selected-operation' }, - payload: { operationId: 'c1', status: 'success' } + payload: { stream: 'stdout', text: 'one' } }); child.emitEvent({ - type: 'activityChanged', - payload: { operationId: 'c1' } + type: 'externalOutput', + payload: { stream: 'stderr', text: 'two' } }); const result: IHeftChildResult = host.processChildNdjson(descriptor); await manager.flushAsync(); @@ -360,9 +360,9 @@ describe('HeftDescriptorHost new descriptor path', () => { timestamp: '2026-01-01T00:00:00.000Z', source, privacy: 'public', - required: true, - type: 'operationStatusChanged', - payload: { operationId: 'operation-' + i, status: 'success', padding: 'x'.repeat(128) } + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'operation-' + i + ' ' + 'x'.repeat(128) } }) + '\\n'); } `; @@ -470,9 +470,9 @@ describe('HeftDescriptorHost new descriptor path', () => { timestamp: '2026-01-01T00:00:00.001Z', source: SOURCE, privacy: 'public', - required: true, - type: 'commandCompleted', - payload: { commandName: 'build', exitCode: 0 } + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'known after future' } }) ).toBe(true); @@ -605,11 +605,11 @@ describe('HeftDescriptorHost new descriptor path', () => { source: SOURCE, privacy: 'public', required: true, - type: 'activityChanged', - payload: {} + type: 'externalOutput', + payload: { stream: 'stdout', text: 'valid' } }) ).toBe(true); - expect(forwarded[0].required).toBe(false); + expect(forwarded[0].required).toBe(true); expect(host.processChildRecord(null)).toBe(false); const result: IHeftChildResult = host.processChildRecords([]); @@ -734,12 +734,126 @@ describe('HeftDescriptorHost new descriptor path', () => { }); expect(host.processChildRecord(makeEvent(1, 'public'))).toBe(true); - expect(host.processChildRecord(makeEvent(2, 'secret'))).toBe(true); + expect( + host.processChildRecord({ + ...makeEvent(2, 'secret'), + leaked: 'TOP_LEVEL_SECRET', + scope: { operationId: 'child-operation', leaked: 'SCOPE_SECRET' }, + payload: { stream: 'stdout', text: 'TOP_SECRET_OUTPUT', leaked: 'TOP_SECRET_EXTRA' } + }) + ).toBe(true); expect(forwarded.map(({ source }) => source)).toEqual([ { packageName: '@rushstack/heft', packageVersion: 'trusted' }, { packageName: '@rushstack/heft', packageVersion: 'trusted' } ]); expect(forwarded.map(({ privacy }) => privacy)).toEqual(['local-sensitive', 'secret']); + expect((forwarded[1].payload as { text?: string }).text).toBe('[secret child output omitted]'); + expect((forwarded[1].payload as { leaked?: string }).leaked).toBeUndefined(); + expect((forwarded[1] as IReporterEventEnvelope & { leaked?: string }).leaked).toBeUndefined(); + expect((forwarded[1].scope as { leaked?: string } | undefined)?.leaked).toBeUndefined(); + }); + + it('redacts output when the parent raises the effective privacy to secret', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + trustedPrivacy: 'secret', + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'TOP_SECRET_OUTPUT' } + }) + ).toBe(true); + expect(forwarded[0].privacy).toBe('secret'); + expect((forwarded[0].payload as { text?: string }).text).toBe('[secret child output omitted]'); + }); + + it('redacts secret diagnostic parameters and removes child remediation commands', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + trustedSource: { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + message: { value: 'TOP_SECRET_MESSAGE', privacy: 'secret', leaked: 'TOP_SECRET_EXTRA' } + }, + source: { + kind: 'file', + file: 'src/index.ts', + line: 1, + column: 2, + toolName: 'typescript', + leaked: 'TOP_SECRET_SOURCE' + }, + remediation: [ + { + descriptionKey: 'malicious.action', + command: 'run-untrusted-command', + automatedExecutionSafety: 'safe' + } + ] + } + }) + ).toBe(true); + + const diagnostic: { + parameters?: { message?: { value?: string; leaked?: string } }; + source?: { leaked?: string }; + remediation?: unknown; + } = forwarded[0].payload as { + parameters?: { message?: { value?: string; leaked?: string } }; + source?: { leaked?: string }; + remediation?: unknown; + }; + expect(diagnostic.parameters?.message?.value).toBe('[secret]'); + expect(diagnostic.parameters?.message?.leaked).toBeUndefined(); + expect(diagnostic.source?.leaked).toBeUndefined(); + expect(diagnostic.remediation).toBeUndefined(); }); it('validates parent reporter context once and rejects zero terminal width', () => { @@ -778,6 +892,7 @@ describe('HeftDescriptorHost new descriptor path', () => { throw new Error('Unknown events must not be forwarded.'); } }); + expect( host.processChildRecord({ kind: 'hello', @@ -824,6 +939,127 @@ describe('HeftDescriptorHost new descriptor path', () => { }) ).toBe(false); }); + + it('rejects a malformed structured diagnostic before forwarding it', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => { + throw new Error('Malformed diagnostics must not be forwarded.'); + } + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: null + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + + it('rejects malformed diagnostic identifier arrays without parameters', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => { + throw new Error('Malformed diagnostics must not be forwarded.'); + } + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + causeDiagnosticIds: [123] + } + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + + it('does not let child events drive parent lifecycle presentation', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + required: false, + type: 'activityChanged', + payload: { text: 'child activity' } + }) + ).toBe(true); + expect(forwarded).toEqual([]); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_2', + sessionId: 'child-sess', + sequence: 2, + timestamp: '2026-01-01T00:00:00.001Z', + source: SOURCE, + privacy: 'public', + required: true, + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); }); describe('Heft old raw-stream path', () => { diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts index 55bbb99f160..864d431a51e 100644 --- a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -22,9 +22,12 @@ import { import type { IOperationChildProcessReporter } from './OperationEventSink'; function formatDiagnosticForOperationLog(diagnostic: IRushDiagnostic): string { - const toolParameter: unknown = diagnostic.parameters?.tool?.value; - const codeParameter: unknown = diagnostic.parameters?.code?.value; - const messageParameter: unknown = diagnostic.parameters?.message?.value; + const toolParameter: unknown = + diagnostic.parameters?.tool?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.tool?.value; + const codeParameter: unknown = + diagnostic.parameters?.code?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.code?.value; + const messageParameter: unknown = + diagnostic.parameters?.message?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.message?.value; const toolName: string = typeof toolParameter === 'string' ? toolParameter : (diagnostic.source?.toolName ?? '@rushstack/heft'); const code: string = typeof codeParameter === 'string' && codeParameter ? ` (${codeParameter})` : ''; @@ -190,10 +193,14 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter if (envelope.type === 'diagnosticEmitted') { const payload: IRushDiagnostic = envelope.payload as IRushDiagnostic; this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; - structuredOutputTerminalProvider.write( - formatDiagnosticForOperationLog(payload), - payload.severity === 'warning' ? TerminalProviderSeverity.warning : TerminalProviderSeverity.error - ); + if (envelope.privacy !== 'secret') { + structuredOutputTerminalProvider.write( + formatDiagnosticForOperationLog(payload), + payload.severity === 'warning' + ? TerminalProviderSeverity.warning + : TerminalProviderSeverity.error + ); + } if (typeof envelope.payload === 'object' && envelope.payload !== null) { forwardedEnvelope = { ...envelope, @@ -212,10 +219,12 @@ export class HeftChildProcessReporter implements IOperationChildProcessReporter throw new Error('The validated child output envelope contained an invalid payload.'); } this._hasWarningOrError ||= payload.stream === 'stderr'; - structuredOutputTerminalProvider.write( - payload.text, - payload.stream === 'stderr' ? TerminalProviderSeverity.error : TerminalProviderSeverity.log - ); + if (envelope.privacy !== 'secret') { + structuredOutputTerminalProvider.write( + payload.text, + payload.stream === 'stderr' ? TerminalProviderSeverity.error : TerminalProviderSeverity.log + ); + } forwardedEnvelope = { ...envelope, payload: { ...payload, iterationId: this._options.iterationId } diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index bf125a38d2a..681f2ba3ca9 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -213,6 +213,9 @@ export function isHeftCommand(command: string): boolean { continue; } if (quote) { + if (quote === '"' && (character === '$' || character === '`')) { + return false; + } if (character === quote) { quote = undefined; } diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts index 4b155cc5af2..686ed808fed 100644 --- a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -96,6 +96,18 @@ describe(HeftChildProcessReporter.name, () => { source: { kind: 'file', file: 'src/index.ts', line: 4, column: 2, toolName: 'typescript' } } }) + '\\n'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_4', + sessionId: 'child-session', + sequence: 4, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'secret', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'TOP_SECRET_CHILD_OUTPUT' } + }) + '\\n'); `; const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { env: { ...process.env, ...reporter.environment }, @@ -120,28 +132,33 @@ describe(HeftChildProcessReporter.name, () => { expect(exitCode).toBe(0); expect(structuredNegotiated).toBe(true); expect(reporter.hasWarningOrError).toBe(true); - expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2, 3]); + expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2, 3, 4]); expect(envelopes.map((envelope) => envelope.parentSessionId)).toEqual([ + 'parent-session', 'parent-session', 'parent-session', 'parent-session' ]); expect(envelopes.map((envelope) => envelope.parentRequestId)).toEqual([ + 'parent-request', 'parent-request', 'parent-request', 'parent-request' ]); expect(envelopes.map((envelope) => envelope.parentOperationId)).toEqual([ + 'project#build', 'project#build', 'project#build', 'project#build' ]); expect(envelopes.map((envelope) => envelope.scope?.operationId)).toEqual([ + 'project#build', 'project#build', 'project#build', 'project#build' ]); expect(envelopes.map((envelope) => envelope.source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, { packageName: '@rushstack/heft', packageVersion: 'unknown' }, { packageName: '@rushstack/heft', packageVersion: 'unknown' }, { packageName: '@rushstack/heft', packageVersion: 'unknown' } @@ -149,11 +166,13 @@ describe(HeftChildProcessReporter.name, () => { expect(envelopes.map((envelope) => envelope.privacy)).toEqual([ 'local-sensitive', 'local-sensitive', - 'local-sensitive' + 'local-sensitive', + 'secret' ]); expect(envelopes.map((envelope) => (envelope.payload as { iterationId?: number }).iterationId)).toEqual([ - 7, 7, 7 + 7, 7, 7, 7 ]); + expect((envelopes[3].payload as { text?: string }).text).toBe('[secret child output omitted]'); expect(structuredOutputTerminalProvider.getOutput()).toBe('1'); expect(structuredOutputTerminalProvider.getErrorOutput({ normalizeSpecialCharacters: false })).toBe( '2[typescript] error (TS1005): src/index.ts:4:2 - semicolon expected\n' diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts index 4f0c3ab3b19..410dfdf90cc 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts @@ -49,6 +49,9 @@ describe(convertSlashesForWindows.name, () => { ['heft build &', false], ['heft build > output.log', false], ['heft build `node spawn-grandchild.js`', false], + ['heft build "$(node spawn-grandchild.js)"', false], + ['heft build "`node spawn-grandchild.js`"', false], + ["heft build '$(literal)'", true], ['heft build "unterminated', false] ])('classifies %s', (command: string, expected: boolean) => { expect(isHeftCommand(command)).toBe(expected);