diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a6..920ae96235 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ILaunchOptions } from '@microsoft/rush-lib'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rush-lib'; /** * The cross-version launch contract owned by the Rush frontend. @@ -13,6 +12,6 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; * options, so an older engine can safely ignore the new property. */ export interface IRushFrontendLaunchOptions extends ILaunchOptions { - readonly reporterEventSink: IReporterEventSink; + readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f0..044a060d6b 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.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 { randomUUID } from 'node:crypto'; + import type { ILaunchOptions } from '@microsoft/rush-lib'; import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS } from '@rushstack/rush-reporter'; @@ -30,6 +32,7 @@ export interface IRushFrontendOptions { currentRushLib: typeof import('@microsoft/rush-lib'), launchOptions: IRushFrontendLaunchOptions ) => void | Promise; + readonly createSessionId?: () => string; readonly processLifecycle?: IRushFrontendProcessLifecycle; } @@ -132,6 +135,7 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr initializeReporterHostAsync = initializeRushReporterHostAsync, createVersionSelector = (version: string) => new RushVersionSelector(version), executeCurrentRush = RushCommandSelector.execute, + createSessionId = randomUUID, processLifecycle = createProcessLifecycle() } = options; @@ -152,9 +156,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); + const sessionId: string = createSessionId(); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, - reporterEventSink: reporterHost.sink, + reporter: { + eventSink: reporterHost.sink, + sessionId + }, reporterCloseAsync }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 30b4081f40..233b3d7e2d 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; +import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; import { @@ -18,6 +19,7 @@ import { } from '@rushstack/rush-reporter'; import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../RushFrontend'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, type IInitializedRushReporterHost, @@ -178,7 +180,7 @@ function emitCommandStarted(sink: IReporterEventSink): void { describe(launchRushFrontendAsync.name, () => { it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; - let receivedOptions: Record | undefined; + let receivedOptions: IRushFrontendLaunchOptions | undefined; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); const originalArgv: string[] = process.argv; process.argv = ['node', 'rush', 'build', '--reporter=legacy', '--json']; @@ -195,7 +197,7 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; order.push('engine'); - receivedOptions = launchOptions as unknown as Record; + receivedOptions = launchOptions; return launchOptions.reporterCloseAsync(); }, processLifecycle @@ -203,9 +205,10 @@ describe(launchRushFrontendAsync.name, () => { expect(order).toEqual(['host', 'engine', 'close']); expect(process.argv).toEqual(['node', 'rush', 'build', '--json']); - expect(receivedOptions?.reporterEventSink).toEqual( - expect.objectContaining({ emit: expect.any(Function) }) as IReporterEventSink - ); + expect(receivedOptions?.reporter).toEqual({ + eventSink: expect.objectContaining({ emit: expect.any(Function) }), + sessionId: expect.any(String) + }); expect(receivedOptions).not.toHaveProperty('selection'); expect(receivedOptions).not.toHaveProperty('host'); expect(receivedOptions).not.toHaveProperty('manager'); @@ -216,6 +219,46 @@ describe(launchRushFrontendAsync.name, () => { } }); + it('passes one typed reporter session through the real Rush launch boundary', async () => { + const order: string[] = []; + const initialized: IInitializedRushReporterHost = await createInitializedHostAsync(order); + const createSessionId: jest.Mock = jest.fn(() => 'session-from-frontend'); + let receivedOptions: ILaunchOptions | undefined; + const launchSpy: jest.SpyInstance = jest + .spyOn(rushLib.Rush, 'launch') + .mockImplementation((version, launchOptions) => { + void version; + receivedOptions = launchOptions; + }); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build']; + + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => initialized, + createSessionId, + processLifecycle: createTestProcessLifecycle() + }); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect(createSessionId).toHaveBeenCalledTimes(1); + expect(receivedOptions?.reporter).toEqual({ + eventSink: initialized.sink, + sessionId: 'session-from-frontend' + }); + await initialized.closeAsync(); + expect(order).toEqual(['host', 'close']); + } finally { + launchSpy.mockRestore(); + process.argv = originalArgv; + } + }); + it('rejects an explicit reporter before initializing an incompatible selected engine', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-old-engine-')); const outputPath: string = path.join(directory, 'events.jsonl'); @@ -603,7 +646,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); return launchOptions.reporterCloseAsync(); }, processLifecycle: createTestProcessLifecycle() @@ -644,7 +687,7 @@ describe(launchRushFrontendAsync.name, () => { executeCurrentRush: (version, selectedRushLib, launchOptions) => { void version; void selectedRushLib; - emitCommandStarted(launchOptions.reporterEventSink); + emitCommandStarted(launchOptions.reporter.eventSink); const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); Object.defineProperty(parser, '_rushOptions', { diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json new file mode 100644 index 0000000000..fa12adb823 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3a-session-sink_2026-08-28-02-38.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose an optional scoped reporter producer API to Rush actions and plugins while preserving legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 5a14a3f4f8..52b4949bd4 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -4990,6 +4990,7 @@ snapshots: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@20.17.19) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@20.17.19) '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@20.17.19) + '@rushstack/rush-reporter': file:../../../libraries/reporter(@types/node@20.17.19) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@20.17.19) tapable: 2.2.1 transitivePeerDependencies: diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 4555e68e18..c12a624186 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "2f7908424d103b2f677e95bcd5d85a385b75eda2", + "pnpmShrinkwrapHash": "e3fd56b3094928b8856da3821af80ef4deee0529", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "e8fe4109038ad6e9b1e97cbb83e63d9094d37fe4" + "packageJsonInjectedDependenciesHash": "b0634100322878d7a992fa589326473bc3965ab6" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 30f755f0c5..271e566016 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4404,6 +4404,9 @@ importers: '@rushstack/package-deps-hash': specifier: workspace:* version: link:../package-deps-hash + '@rushstack/rush-reporter': + specifier: workspace:* + version: link:../reporter '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 278cbccd79..bf52161bba 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -13,14 +13,22 @@ import { AsyncSeriesWaterfallHook } from 'tapable'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { CredentialCache } from '@rushstack/credential-cache'; import { HookMap } from 'tapable'; +import { ICreateRushDiagnosticOptions } from '@rushstack/rush-reporter'; import { ICredentialCacheEntry } from '@rushstack/credential-cache'; import { ICredentialCacheOptions } from '@rushstack/credential-cache'; 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 { IReporterEventScope } from '@rushstack/rush-reporter'; +import { IReporterEventSink } from '@rushstack/rush-reporter'; +import { IRushDiagnostic } from '@rushstack/rush-reporter'; +import { IScopedLogger } from '@rushstack/rush-reporter'; +import { IScopedMessageOptions } from '@rushstack/rush-reporter'; +import { IScopedReporter } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +36,11 @@ import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; +import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; import type { PerformanceEntry as PerformanceEntry_2 } from 'node:perf_hooks'; +import { ReporterExtensionEventName } from '@rushstack/rush-reporter'; +import { ReporterJsonValue } from '@rushstack/rush-reporter'; +import { ReporterPrivacyClassification } from '@rushstack/rush-reporter'; import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; @@ -148,6 +160,8 @@ export class CommonVersionsConfiguration { saveAsync(): Promise; } +export { createRushDiagnostic } + export { CredentialCache } // @beta @@ -439,6 +453,8 @@ export interface ICreateOperationsContext { readonly rushConfiguration: RushConfiguration; } +export { ICreateRushDiagnosticOptions } + export { ICredentialCacheEntry } export { ICredentialCacheOptions } @@ -555,6 +571,8 @@ export interface ILaunchOptions { // @internal builtInPluginConfigurations?: _IBuiltInPluginConfiguration[]; isManaged: boolean; + // @internal + reporter?: IRushSessionReporterOptions; terminalProvider?: ITerminalProvider; } @@ -909,6 +927,10 @@ export type _IProjectBuildCacheOptions = _IOperationBuildCacheOptions & { phaseName: string; }; +export { IReporterEventScope } + +export { IReporterEventSink } + // @beta export interface IRushCommand { readonly actionName: string; @@ -941,6 +963,8 @@ export interface IRushCommandLineSpec { // @beta (undocumented) export type IRushConfigurationProjectForSnapshot = Pick; +export { IRushDiagnostic } + // @alpha (undocumented) export interface IRushPhaseSharding { count: number; @@ -981,10 +1005,23 @@ export interface IRushReportingConfiguration { export interface IRushSessionOptions { // (undocumented) getIsDebugMode: () => boolean; + reporter?: IRushSessionReporterOptions; // (undocumented) terminalProvider: ITerminalProvider; } +// @beta +export interface IRushSessionReporterOptions { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; +} + +export { IScopedLogger } + +export { IScopedMessageOptions } + +export { IScopedReporter } + // @beta export interface IStopwatchResult { get duration(): number; @@ -1286,6 +1323,8 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @beta export type Parallelism = number | IParallelismScalar; +export { parseReporterExtensionEventName } + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1363,6 +1402,12 @@ export class ProjectChangeAnalyzer { _tryGetSnapshotProviderAsync(projectConfigurations: ReadonlyMap, terminal: ITerminal, projectSelection?: ReadonlySet): Promise; } +export { ReporterExtensionEventName } + +export { ReporterJsonValue } + +export { ReporterPrivacyClassification } + // @public export class RepoStateFile { readonly filePath: string; @@ -1700,6 +1745,8 @@ export class RushSession { getCobuildLockProviderFactory(cobuildLockProviderName: string): CobuildLockProviderFactory | undefined; // (undocumented) getLogger(name: string): ILogger; + getReporter(scope?: IReporterEventScope): IScopedReporter | undefined; + getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined; // (undocumented) readonly hooks: RushLifecycleHooks; // (undocumented) diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index a51af8b093..e75815484d 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -14,6 +14,7 @@ import { RushXCommandLine } from '../cli/RushXCommandLine'; import { CommandLineMigrationAdvisor } from '../cli/CommandLineMigrationAdvisor'; import { EnvironmentVariableNames } from './EnvironmentConfiguration'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; +import type { IRushSessionReporterOptions } from '../pluginFramework/RushSession'; import { RushPnpmCommandLine } from '../cli/RushPnpmCommandLine'; import { measureAsyncFn } from '../utilities/performance'; @@ -58,6 +59,17 @@ export interface ILaunchOptions { * @internal */ builtInPluginConfigurations?: IBuiltInPluginConfiguration[]; + + /** + * Supplies the structured event sink owned by the Rush frontend. + * + * @remarks + * This is an internal cross-version frontend-to-engine handoff. Reporter + * selection and concrete reporter instances remain owned by the frontend. + * + * @internal + */ + reporter?: IRushSessionReporterOptions; } let _rushLibPackageJsonCache: IPackageJson | undefined = undefined; @@ -98,6 +110,7 @@ export class Rush { const parser: RushCommandLineParser = new RushCommandLineParser({ alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError, builtInPluginConfigurations: options.builtInPluginConfigurations, + reporter: options.reporter, reporterCloseAsync: frontendOptions.reporterCloseAsync }); // CommandLineParser.executeAsync() should never reject the promise diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index c293ca5fa0..7f22adc16b 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -57,7 +57,7 @@ import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; import { type ICustomCommandLineConfigurationInfo, PluginManager } from '../pluginFramework/PluginManager'; -import { RushSession } from '../pluginFramework/RushSession'; +import { type IRushSessionReporterOptions, RushSession } from '../pluginFramework/RushSession'; import type { IBuiltInPluginConfiguration } from '../pluginFramework/PluginLoader/BuiltInPluginLoader'; import { InitSubspaceAction } from './actions/InitSubspaceAction'; import { RushAlerts } from '../utilities/RushAlerts'; @@ -72,6 +72,7 @@ export interface IRushCommandLineParserOptions { cwd: string; // Defaults to `cwd` alreadyReportedNodeTooNewError: boolean; builtInPluginConfigurations: IBuiltInPluginConfiguration[]; + reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; } @@ -129,7 +130,7 @@ export class RushCommandLineParser extends CommandLineParser { const terminal: Terminal = new Terminal(this._terminalProvider); this._terminal = terminal; this._rushOptions = this._normalizeOptions(options || {}); - const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations } = this._rushOptions; + const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this._rushOptions; let rushJsonFilePath: string | undefined; try { @@ -157,7 +158,8 @@ export class RushCommandLineParser extends CommandLineParser { this.rushSession = new RushSession({ getIsDebugMode: () => this.isDebug, - terminalProvider + terminalProvider, + reporter }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, @@ -323,6 +325,7 @@ export class RushCommandLineParser extends CommandLineParser { cwd: options.cwd || process.cwd(), alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], + reporter: options.reporter, reporterCloseAsync: options.reporterCloseAsync }; } diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index 256224d10f..62222ba7d7 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { CommandLineAction, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; import { LockFile } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; +import type { IScopedReporter } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; @@ -44,6 +45,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme protected readonly rushConfiguration: RushConfiguration | undefined; protected readonly terminal: ITerminal; protected readonly rushSession: RushSession; + protected readonly reporter: IScopedReporter | undefined; protected readonly rushGlobalFolder: RushGlobalFolder; protected readonly parser: RushCommandLineParser; @@ -57,6 +59,7 @@ export abstract class BaseConfiglessRushAction extends CommandLineAction impleme this.rushConfiguration = rushConfiguration; this.terminal = terminal; this.rushSession = rushSession; + this.reporter = rushSession.getReporter({ commandName: this.actionName }); this.rushGlobalFolder = rushGlobalFolder; } @@ -115,7 +118,7 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { return this._eventHooksManager; } - protected declare readonly rushConfiguration: RushConfiguration; + declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { if (!this.rushConfiguration) { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 0fdd200e77..6f0bb4c5e6 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -168,10 +168,26 @@ export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { RushSession, type IRushSessionOptions, + type IRushSessionReporterOptions, type CloudBuildCacheProviderFactory, type CobuildLockProviderFactory } from './pluginFramework/RushSession'; +export { + createRushDiagnostic, + parseReporterExtensionEventName, + type ICreateRushDiagnosticOptions, + type IReporterEventScope, + type IReporterEventSink, + type IRushDiagnostic, + type IScopedLogger, + type IScopedMessageOptions, + type IScopedReporter, + type ReporterExtensionEventName, + type ReporterJsonValue, + type ReporterPrivacyClassification +} from '@rushstack/rush-reporter'; + export { type IRushCommand, type IGlobalCommand, diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index e2b235d113..adbc3ab26d 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -7,6 +7,8 @@ import { FileSystem, InternalError, JsonFile, + PackageJsonLookup, + type IPackageJson, type JsonObject, JsonSchema } from '@rushstack/node-core-library'; @@ -51,6 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; + private _packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -84,6 +87,20 @@ export abstract class PluginLoaderBase< return this._getRushPluginManifest(); } + public get packageVersion(): string { + if (!this._packageVersionCache) { + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( + path.join(this.packageFolder, 'package.json') + ); + if (!packageJson.version) { + throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); + } + this._packageVersionCache = packageJson.version; + } + + return this._packageVersionCache; + } + public getCommandLineConfiguration(): CommandLineConfiguration | undefined { const commandLineJsonFilePath: string | undefined = this._getCommandLineJsonFilePath(); if (!commandLineJsonFilePath) { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index 9a5181e078..0e353f3a57 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -9,7 +9,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import { BuiltInPluginLoader, type IBuiltInPluginConfiguration } from './PluginLoader/BuiltInPluginLoader'; import type { IRushPlugin } from './IRushPlugin'; import { AutoinstallerPluginLoader } from './PluginLoader/AutoinstallerPluginLoader'; -import type { RushSession } from './RushSession'; +import { _createRushSessionForPlugin, type RushSession } from './RushSession'; import type { PluginLoaderBase } from './PluginLoader/PluginLoaderBase'; import { Rush } from '../api/Rush'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; @@ -205,7 +205,7 @@ export class PluginManager { const plugin: IRushPlugin | undefined = pluginLoader.load(); this._loadedPluginNames.add(pluginName); if (plugin) { - this._applyPlugin(plugin, pluginName); + this._applyPlugin(plugin, pluginLoader); } } } @@ -227,9 +227,15 @@ export class PluginManager { }); } - private _applyPlugin(plugin: IRushPlugin, pluginName: string): void { + private _applyPlugin(plugin: IRushPlugin, pluginLoader: PluginLoaderBase): void { + const { packageName, pluginName } = pluginLoader; try { - plugin.apply(this._rushSession, this._rushConfiguration); + const pluginSession: RushSession = _createRushSessionForPlugin(this._rushSession, () => ({ + packageName, + packageVersion: pluginLoader.packageVersion, + component: pluginName + })); + plugin.apply(pluginSession, this._rushConfiguration); } catch (e) { throw new InternalError(`Error applying "${pluginName}": ${e}`); } diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts new file mode 100644 index 0000000000..26a4816073 --- /dev/null +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { + IReporterEmitEventInput, + IReporterEventSource, + IReporterEventSink +} from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { Rush } from '../api/Rush'; +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + +function createSession(reporter?: IRushSessionReporterOptions): RushSession { + return new RushSession({ + getIsDebugMode: () => false, + terminalProvider: new StringBufferTerminalProvider(), + reporter + }); +} + +describe(RushSession.name, () => { + it('preserves legacy APIs and returns undefined when no event sink is supplied', () => { + const session: RushSession = createSession(); + + expect(session.getReporter()).toBeUndefined(); + expect(session.getScopedLogger()).toBeUndefined(); + expect(session.getLogger('legacy')).toBeDefined(); + expect(session.terminalProvider).toBeInstanceOf(StringBufferTerminalProvider); + }); + + it('binds session and rush-lib source identity without exposing the sink or concrete reporters', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-1' }); + const scope = { commandName: 'build', projectName: '@scope/project' }; + const reporter = session.getReporter(scope); + + expect(reporter).toBeDefined(); + expect(Object.keys(reporter!).sort()).toEqual(['emitDiagnostic', 'emitExtension', 'emitMessage']); + expect('getSink' in reporter!).toBe(false); + expect('reporters' in reporter!).toBe(false); + expect(Object.keys(session)).not.toContain('reporter'); + + scope.commandName = 'spoofed'; + reporter!.emitMessage({ severity: 'info', text: 'hello' }); + + expect(sink.inputs).toHaveLength(1); + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-1', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { + commandName: 'build', + projectName: '@scope/project' + } + }); + expect(sink.inputs[0]).not.toHaveProperty('eventId'); + expect(sink.inputs[0]).not.toHaveProperty('sequence'); + expect(sink.inputs[0]).not.toHaveProperty('timestamp'); + expect(sink.inputs[0]).not.toHaveProperty('required'); + }); + + it('isolates plugin sources while sharing session state', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-2' }); + const pluginSource: IReporterEventSource = { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }; + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => pluginSource); + + expect(pluginSession.hooks).toBe(session.hooks); + (pluginSource as { packageName: string }).packageName = '@acme/spoofed'; + pluginSession.getReporter({ projectName: '@scope/a' })!.emitMessage({ + severity: 'info', + text: 'plugin' + }); + session.getReporter({ projectName: '@scope/b' })!.emitMessage({ + severity: 'info', + text: 'rush' + }); + + expect(sink.inputs[0]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@acme/rush-plugin', + packageVersion: '1.2.3', + component: 'acme-plugin' + }, + scope: { projectName: '@scope/a' } + }); + expect(sink.inputs[1]).toMatchObject({ + sessionId: 'session-2', + source: { + packageName: '@microsoft/rush-lib', + packageVersion: Rush.version + }, + scope: { projectName: '@scope/b' } + }); + }); + + it('rejects invalid explicitly supplied reporter options', () => { + expect(() => + createSession({ + eventSink: {} as IReporterEventSink, + sessionId: 'session-3' + }) + ).toThrow(/eventSink/); + + expect(() => createSession({ eventSink: new CapturingSink(), sessionId: ' ' })).toThrow(/sessionId/); + }); + + it('does not resolve plugin identity when reporting is disabled', () => { + const session: RushSession = createSession(); + const getSource = jest.fn((): IReporterEventSource => { + throw new Error('should not resolve source'); + }); + + expect(_createRushSessionForPlugin(session, getSource)).toBe(session); + expect(getSource).not.toHaveBeenCalled(); + }); + + it('binds built-in action reporters to their command name', () => { + const sink: CapturingSink = new CapturingSink(); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: os.tmpdir(), + reporter: { eventSink: sink, sessionId: 'session-4' } + }); + const action = parser.actions.find(({ actionName }) => actionName === 'list') as unknown as + | { reporter?: ReturnType } + | undefined; + + expect(action?.reporter).toBeDefined(); + action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); + expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); + }); +}); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 0e51276443..e017a9a8cb 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -1,7 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { + RushSessionReporting, + type IReporterEventScope, + type IReporterEventSink, + type IReporterEventSource, + type IScopedLogger, + type IScopedReporter +} from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import { type ILogger, type ILoggerOptions, Logger } from './logging/Logger'; @@ -11,12 +19,43 @@ import type { ICloudBuildCacheProvider } from '../logic/buildCache/ICloudBuildCa import type { ICobuildJson } from '../api/CobuildConfiguration'; import type { ICobuildLockProvider } from '../logic/cobuild/ICobuildLockProvider'; +/** + * The reporter channel supplied by the Rush frontend for a single Rush session. + * + * @remarks + * The frontend owns reporter selection and the concrete reporter instances. Rush + * only receives this presentation-free sink and binds producer identities before + * exposing scoped reporters to actions and plugins. + * + * @beta + */ +export interface IRushSessionReporterOptions { + /** + * The typed event sink owned by the Rush frontend. + */ + readonly eventSink: IReporterEventSink; + + /** + * The identifier assigned to this Rush session by the frontend. + */ + readonly sessionId: string; +} + /** * @beta */ export interface IRushSessionOptions { terminalProvider: ITerminalProvider; getIsDebugMode: () => boolean; + + /** + * The optional structured reporter channel for this session. + * + * @remarks + * When omitted, scoped reporter APIs return `undefined` and legacy terminal + * behavior remains unchanged. + */ + reporter?: IRushSessionReporterOptions; } /** @@ -33,20 +72,85 @@ export type CobuildLockProviderFactory = ( cobuildJson: ICobuildJson ) => ICobuildLockProvider | Promise; +interface IRushSessionState { + readonly options: IRushSessionOptions; + readonly cloudBuildCacheProviderFactories: Map; + readonly cobuildLockProviderFactories: Map; + readonly hooks: RushLifecycleHooks; + readonly reporting: RushSessionReporting | undefined; +} + +let _rushLibSource: IReporterEventSource | undefined; +const _rushSessionStates: WeakMap = new WeakMap(); + +function _getRushLibSource(): IReporterEventSource { + if (!_rushLibSource) { + const packageJsonFilePath: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!packageJsonFilePath) { + throw new InternalError('Unable to locate the package.json file for @microsoft/rush-lib'); + } + + const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson(packageJsonFilePath); + if (!packageJson.version) { + throw new InternalError('The @microsoft/rush-lib package.json file does not specify a version'); + } + + _rushLibSource = { + packageName: '@microsoft/rush-lib', + packageVersion: packageJson.version + }; + } + + return _rushLibSource; +} + +function _createReporting( + reporterOptions: IRushSessionReporterOptions | undefined, + source: IReporterEventSource +): RushSessionReporting | undefined { + if (!reporterOptions) { + return undefined; + } + + const { eventSink, sessionId } = reporterOptions; + if (!eventSink || typeof eventSink.emit !== 'function') { + throw new TypeError('RushSession reporter.eventSink must implement IReporterEventSink'); + } + if (typeof sessionId !== 'string' || sessionId.trim().length === 0) { + throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); + } + + return new RushSessionReporting({ + sink: eventSink, + sessionId, + source: { ...source } + }); +} + +function _getSessionState(rushSession: RushSession): IRushSessionState { + const state: IRushSessionState | undefined = _rushSessionStates.get(rushSession); + if (!state) { + throw new InternalError('RushSession state was not initialized'); + } + return state; +} + /** * @beta */ export class RushSession { - private readonly _options: IRushSessionOptions; - private readonly _cloudBuildCacheProviderFactories: Map = new Map(); - private readonly _cobuildLockProviderFactories: Map = new Map(); - public readonly hooks: RushLifecycleHooks; public constructor(options: IRushSessionOptions) { - this._options = options; - this.hooks = new RushLifecycleHooks(); + _rushSessionStates.set(this, { + options, + cloudBuildCacheProviderFactories: new Map(), + cobuildLockProviderFactories: new Map(), + hooks: this.hooks, + reporting: options.reporter ? _createReporting(options.reporter, _getRushLibSource()) : undefined + }); } public getLogger(name: string): ILogger { @@ -54,51 +158,113 @@ export class RushSession { throw new InternalError('RushSession.getLogger(name) called without a name'); } - const terminalProvider: ITerminalProvider = this._options.terminalProvider; + const { options } = _getSessionState(this); + const terminalProvider: ITerminalProvider = options.terminalProvider; const loggerOptions: ILoggerOptions = { loggerName: name, - getShouldPrintStacks: () => this._options.getIsDebugMode(), + getShouldPrintStacks: () => options.getIsDebugMode(), terminalProvider }; return new Logger(loggerOptions); } public get terminalProvider(): ITerminalProvider { - return this._options.terminalProvider; + return _getSessionState(this).options.terminalProvider; + } + + /** + * Creates a structured reporter bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * The returned API cannot access concrete reporters or override the session and + * source identity bound by Rush. + */ + public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { + return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + } + + /** + * Creates a structured logger bound to this producer and the specified scope. + * + * @remarks + * Returns `undefined` when the frontend did not provide a reporter event sink. + * This API is additive; {@link RushSession.getLogger} and terminal output remain + * available during the pre-major compatibility period. + */ + public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { + return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); } public registerCloudBuildCacheProviderFactory( cacheProviderName: string, factory: CloudBuildCacheProviderFactory ): void { - if (this._cloudBuildCacheProviderFactories.has(cacheProviderName)) { + const { cloudBuildCacheProviderFactories } = _getSessionState(this); + if (cloudBuildCacheProviderFactories.has(cacheProviderName)) { throw new Error(`A build cache provider factory for ${cacheProviderName} has already been registered`); } - this._cloudBuildCacheProviderFactories.set(cacheProviderName, factory); + cloudBuildCacheProviderFactories.set(cacheProviderName, factory); } public getCloudBuildCacheProviderFactory( cacheProviderName: string ): CloudBuildCacheProviderFactory | undefined { - return this._cloudBuildCacheProviderFactories.get(cacheProviderName); + return _getSessionState(this).cloudBuildCacheProviderFactories.get(cacheProviderName); } public registerCobuildLockProviderFactory( cobuildLockProviderName: string, factory: CobuildLockProviderFactory ): void { - if (this._cobuildLockProviderFactories.has(cobuildLockProviderName)) { + const { cobuildLockProviderFactories } = _getSessionState(this); + if (cobuildLockProviderFactories.has(cobuildLockProviderName)) { throw new Error( `A cobuild lock provider factory for ${cobuildLockProviderName} has already been registered` ); } - this._cobuildLockProviderFactories.set(cobuildLockProviderName, factory); + cobuildLockProviderFactories.set(cobuildLockProviderName, factory); } public getCobuildLockProviderFactory( cobuildLockProviderName: string ): CobuildLockProviderFactory | undefined { - return this._cobuildLockProviderFactories.get(cobuildLockProviderName); + return _getSessionState(this).cobuildLockProviderFactories.get(cobuildLockProviderName); + } +} + +/** + * Creates the RushSession facade passed to one plugin. + * + * @remarks + * This function is internal to rush-lib. PluginManager derives the source from + * trusted loader metadata so the plugin cannot choose another producer identity. + * + * @internal + */ +export function _createRushSessionForPlugin( + rushSession: RushSession, + getSource: () => IReporterEventSource +): RushSession { + const state: IRushSessionState = _getSessionState(rushSession); + if (!state.options.reporter) { + return rushSession; } + + const pluginSession: RushSession = Object.create(RushSession.prototype) as RushSession; + Object.defineProperty(pluginSession, 'hooks', { + configurable: false, + enumerable: true, + value: state.hooks, + writable: false + }); + _rushSessionStates.set(pluginSession, { + options: state.options, + cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, + cobuildLockProviderFactories: state.cobuildLockProviderFactories, + hooks: state.hooks, + reporting: _createReporting(state.options.reporter, getSource()) + }); + return pluginSession; } diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index 801c66f20f..f42e357738 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -50,6 +50,7 @@ "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", "@rushstack/package-deps-hash": "workspace:*", + "@rushstack/rush-reporter": "workspace:*", "@rushstack/terminal": "workspace:*", "tapable": "2.2.1" }, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 573fa555e2..80fc60cee1 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -63,7 +63,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_OperationStateFile', '_RushGlobalFolder', '_RushInternals', - '_rushSdk_loadInternalModule' + '_rushSdk_loadInternalModule', + 'createRushDiagnostic', + 'parseReporterExtensionEventName' ]" `;