diff --git a/apps/rush-lib/assets/rush-init/common/config/rush/experiments.json b/apps/rush-lib/assets/rush-init/common/config/rush/experiments.json index b871c8a33bf..68733448414 100644 --- a/apps/rush-lib/assets/rush-init/common/config/rush/experiments.json +++ b/apps/rush-lib/assets/rush-init/common/config/rush/experiments.json @@ -28,5 +28,13 @@ * If true, the chmod field in temporary project tar headers will not be normalized. * This normalization can help ensure consistent tarball integrity across platforms. */ - /*[LINE "HYPOTHETICAL"]*/ "noChmodFieldInTarHeaderNormalization": true + /*[LINE "HYPOTHETICAL"]*/ "noChmodFieldInTarHeaderNormalization": true, + + /** + * If true, the multi-phase commands feature is enabled. To use this feature, create a "phased" command + * in common/config/rush/command-line.json. + * + * See https://github.com/microsoft/rushstack/issues/2300 for details about this experimental feature. + */ + /*[LINE "HYPOTHETICAL"]*/ "multiPhaseCommands": true } diff --git a/apps/rush-lib/src/api/CommandLineConfiguration.ts b/apps/rush-lib/src/api/CommandLineConfiguration.ts index 262656fbe10..9c63a5a7eaa 100644 --- a/apps/rush-lib/src/api/CommandLineConfiguration.ts +++ b/apps/rush-lib/src/api/CommandLineConfiguration.ts @@ -7,7 +7,13 @@ import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library'; import { RushConstants } from '../logic/RushConstants'; -import { CommandJson, ICommandLineJson, ParameterJson } from './CommandLineJson'; +import { + CommandJson, + ICommandLineJson, + IPhaseJson, + ParameterJson, + IPhasedCommandJson +} from './CommandLineJson'; /** * Custom Commands and Options for the Rush Command Line @@ -17,8 +23,13 @@ export class CommandLineConfiguration { path.join(__dirname, '../schemas/command-line.schema.json') ); - public readonly commands: CommandJson[] = []; + public readonly commands: Map = new Map(); + public readonly phases: Map = new Map(); public readonly parameters: ParameterJson[] = []; + private readonly _commandNames: Set = new Set([ + RushConstants.buildCommandName, + RushConstants.rebuildCommandName + ]); public static readonly defaultBuildCommandJson: CommandJson = { commandKind: RushConstants.bulkCommandKind, @@ -63,12 +74,89 @@ export class CommandLineConfiguration { /** * Use CommandLineConfiguration.loadFromFile() + * + * @internal */ - private constructor(commandLineJson: ICommandLineJson | undefined) { + public constructor(commandLineJson: ICommandLineJson | undefined) { if (commandLineJson) { + if (commandLineJson.phases) { + for (const phase of commandLineJson.phases) { + if (this.phases.has(phase.name)) { + throw new Error( + `In ${RushConstants.commandLineFilename}, the phase "${phase.name}" is specified ` + + 'more than once.' + ); + } + + const phaseNamePrefixLength: number = RushConstants.phaseNamePrefix.length; + if (phase.name.substring(0, phaseNamePrefixLength) !== RushConstants.phaseNamePrefix) { + throw new Error( + `In ${RushConstants.commandLineFilename}, the phase "${phase.name}"'s name ` + + `does not begin with the required prefix "${RushConstants.phaseNamePrefix}".` + ); + } + + if (phase.name.length <= phaseNamePrefixLength) { + throw new Error( + `In ${RushConstants.commandLineFilename}, the phase "${phase.name}"'s name ` + + `must have characters after "${RushConstants.phaseNamePrefix}"` + ); + } + + this.phases.set(phase.name, phase); + } + } + + for (const phase of this.phases.values()) { + if (phase.dependencies?.self) { + for (const dependencyName of phase.dependencies.self) { + const dependency: IPhaseJson | undefined = this.phases.get(dependencyName); + if (!dependency) { + throw new Error( + `In ${RushConstants.commandLineFilename}, in the phase "${phase.name}", the self ` + + `dependency phase "${dependencyName}" does not exist.` + ); + } + } + } + + if (phase.dependencies?.upstream) { + for (const dependency of phase.dependencies.upstream) { + if (!this.phases.has(dependency)) { + throw new Error( + `In ${RushConstants.commandLineFilename}, in the phase "${phase.name}", the upstream ` + + `dependency phase "${dependency}" does not exist.` + ); + } + } + } + + this._checkForSelfPhaseCycles(phase); + } + if (commandLineJson.commands) { for (const command of commandLineJson.commands) { - this.commands.push(command); + if (this.commands.has(command.name)) { + throw new Error( + `In ${RushConstants.commandLineFilename}, the command "${command.name}" is specified ` + + 'more than once.' + ); + } + + if (command.commandKind === 'phased') { + const phasedCommand: IPhasedCommandJson = command as IPhasedCommandJson; + for (const phase of phasedCommand.phases) { + if (!this.phases.has(phase)) { + throw new Error( + `In ${RushConstants.commandLineFilename}, in the command "${command.name}", the ` + + `phase "${phase}" does not exist.` + ); + } + } + } + + this.commands.set(command.name, command); + this._commandNames.add(command.name); } } @@ -90,6 +178,68 @@ export class CommandLineConfiguration { } break; } + + let parameterHasAssociations: boolean = false; + + for (const associatedCommand of parameter.associatedCommands || []) { + if (!this._commandNames.has(associatedCommand)) { + throw new Error( + `${RushConstants.commandLineFilename} defines a parameter "${parameter.longName}" ` + + `that is associated with a command "${associatedCommand}" that does not exist or does ` + + 'not support custom parameters.' + ); + } else { + parameterHasAssociations = true; + } + } + + for (const associatedPhase of parameter.associatedPhases || []) { + if (!this.phases.has(associatedPhase)) { + throw new Error( + `${RushConstants.commandLineFilename} defines a parameter "${parameter.longName}" ` + + `that is associated with a phase "${associatedPhase}" that does not exist.` + ); + } else { + parameterHasAssociations = true; + } + } + + if (!parameterHasAssociations) { + throw new Error( + `${RushConstants.commandLineFilename} defines a parameter "${parameter.longName}"` + + ` that lists no associated commands or phases.` + ); + } + } + } + } + } + + private _checkForSelfPhaseCycles(phase: IPhaseJson, checkedPhases: Set = new Set()): void { + const dependencies: string[] | undefined = phase.dependencies?.self; + if (dependencies) { + for (const dependencyName of dependencies) { + if (checkedPhases.has(dependencyName)) { + throw new Error( + `In ${RushConstants.commandLineFilename}, there exists a cycle within the ` + + `set of ${dependencyName} dependencies: ${Array.from(checkedPhases).join(', ')}` + ); + } else { + checkedPhases.add(dependencyName); + const dependency: IPhaseJson | undefined = this.phases.get(dependencyName); + if (!dependency) { + return; // Ignore, we check for this separately + } else { + if (dependencies.length > 1) { + this._checkForSelfPhaseCycles( + dependency, + // Clone the set of checked phases if there are multiple branches we need to check + new Set(checkedPhases) + ); + } else { + this._checkForSelfPhaseCycles(dependency, checkedPhases); + } + } } } } diff --git a/apps/rush-lib/src/api/CommandLineJson.ts b/apps/rush-lib/src/api/CommandLineJson.ts index c2332f665cc..9f56370a45a 100644 --- a/apps/rush-lib/src/api/CommandLineJson.ts +++ b/apps/rush-lib/src/api/CommandLineJson.ts @@ -5,7 +5,7 @@ * "baseCommand" from command-line.schema.json */ export interface IBaseCommandJson { - commandKind: 'bulk' | 'global'; + commandKind: 'bulk' | 'global' | 'phased'; name: string; summary: string; /** @@ -30,6 +30,15 @@ export interface IBulkCommandJson extends IBaseCommandJson { disableBuildCache?: boolean; } +/** + * "phasedCommand" from command-line.schema.json + */ +export interface IPhasedCommandJson extends IBaseCommandJson { + commandKind: 'phased'; + phases: string[]; + disableBuildCache?: boolean; +} + /** * "globalCommand" from command-line.schema.json */ @@ -38,7 +47,24 @@ export interface IGlobalCommandJson extends IBaseCommandJson { shellCommand: string; } -export type CommandJson = IBulkCommandJson | IGlobalCommandJson; +export type CommandJson = IBulkCommandJson | IGlobalCommandJson | IPhasedCommandJson; + +export interface IPhaseDependencies { + self?: string[]; + upstream?: string[]; +} + +export interface IPhaseJson { + name: string; + summary: string; + description?: string; + dependencies?: IPhaseDependencies; + enableParallelism?: boolean; + incremental?: boolean; + + ignoreMissingScript?: boolean; + allowWarningsOnSuccess?: boolean; +} /** * "baseParameter" from command-line.schema.json @@ -48,7 +74,8 @@ export interface IBaseParameterJson { longName: string; shortName?: string; description: string; - associatedCommands: string[]; + associatedCommands?: string[]; + associatedPhases?: string[]; required?: boolean; } @@ -88,5 +115,6 @@ export type ParameterJson = IFlagParameterJson | IChoiceParameterJson | IStringP */ export interface ICommandLineJson { commands?: CommandJson[]; + phases?: IPhaseJson[]; parameters?: ParameterJson[]; } diff --git a/apps/rush-lib/src/api/ExperimentsConfiguration.ts b/apps/rush-lib/src/api/ExperimentsConfiguration.ts index c402fdeb194..a8f784ae67a 100644 --- a/apps/rush-lib/src/api/ExperimentsConfiguration.ts +++ b/apps/rush-lib/src/api/ExperimentsConfiguration.ts @@ -34,6 +34,12 @@ export interface IExperimentsJson { * This normalization can help ensure consistent tarball integrity across platforms. */ noChmodFieldInTarHeaderNormalization?: boolean; + + /** + * If true, the multi-phase commands feature is enabled. To use this feature, create a "phased" command + * in common/config/rush/command-line.json. + */ + multiPhaseCommands?: boolean; } /** diff --git a/apps/rush-lib/src/api/RushProjectConfiguration.ts b/apps/rush-lib/src/api/RushProjectConfiguration.ts index f8af21e0b04..2e3acff8edb 100644 --- a/apps/rush-lib/src/api/RushProjectConfiguration.ts +++ b/apps/rush-lib/src/api/RushProjectConfiguration.ts @@ -208,9 +208,9 @@ export class RushProjectConfiguration { RushConstants.rebuildCommandName ]); if (repoCommandLineConfiguration) { - for (const command of repoCommandLineConfiguration.commands) { + for (const [commandName, command] of repoCommandLineConfiguration.commands) { if (command.commandKind === RushConstants.bulkCommandKind) { - commandNames.add(command.name); + commandNames.add(commandName); } } } diff --git a/apps/rush-lib/src/api/test/CommandLineConfiguration.test.ts b/apps/rush-lib/src/api/test/CommandLineConfiguration.test.ts new file mode 100644 index 00000000000..2ba372f63cf --- /dev/null +++ b/apps/rush-lib/src/api/test/CommandLineConfiguration.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { CommandLineConfiguration } from '../CommandLineConfiguration'; + +describe('CommandLineConfiguration', () => { + it('Forbids a misnamed phase', () => { + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_faze:A', + summary: 'A' + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_phase:', + summary: 'A' + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('Detects a missing phase', () => { + expect( + () => + new CommandLineConfiguration({ + commands: [ + { + commandKind: 'phased', + name: 'example', + summary: 'example', + description: 'example', + safeForSimultaneousRushProcesses: false, + + phases: ['_phase:A'] + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('Detects a missing phase dependency', () => { + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_phase:A', + summary: 'A', + dependencies: { + upstream: ['_phase:B'] + } + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_phase:A', + summary: 'A', + dependencies: { + self: ['_phase:B'] + } + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + }); + + it('Detects a cycle among phases', () => { + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_phase:A', + summary: 'A', + dependencies: { + self: ['_phase:B'] + } + }, + { + name: '_phase:B', + summary: 'C', + dependencies: { + self: ['_phase:C'] + } + }, + { + name: '_phase:C', + summary: 'C', + dependencies: { + self: ['_phase:A'] + } + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + + expect( + () => + new CommandLineConfiguration({ + phases: [ + { + name: '_phase:A', + summary: 'A', + dependencies: { + upstream: ['_phase:B'] + } + }, + { + name: '_phase:B', + summary: 'C', + dependencies: { + upstream: ['_phase:C'] + } + }, + { + name: '_phase:C', + summary: 'C', + dependencies: { + upstream: ['_phase:A'] + } + } + ] + }) + ).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/apps/rush-lib/src/api/test/__snapshots__/CommandLineConfiguration.test.ts.snap b/apps/rush-lib/src/api/test/__snapshots__/CommandLineConfiguration.test.ts.snap new file mode 100644 index 00000000000..697114538a5 --- /dev/null +++ b/apps/rush-lib/src/api/test/__snapshots__/CommandLineConfiguration.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommandLineConfiguration Detects a cycle among phases 1`] = `"In command-line.json, there exists a cycle within the set of _phase:B dependencies: _phase:B, _phase:C, _phase:A"`; + +exports[`CommandLineConfiguration Detects a cycle among phases 2`] = `"In command-line.json, there exists a cycle within the set of _phase:B dependencies: _phase:B, _phase:C, _phase:A"`; + +exports[`CommandLineConfiguration Detects a missing phase 1`] = `"In command-line.json, in the command \\"example\\", the phase \\"_phase:A\\" does not exist."`; + +exports[`CommandLineConfiguration Detects a missing phase dependency 1`] = `"In command-line.json, in the phase \\"_phase:A\\", the upstream dependency phase \\"_phase:B\\" does not exist."`; + +exports[`CommandLineConfiguration Detects a missing phase dependency 2`] = `"In command-line.json, in the phase \\"_phase:A\\", the self dependency phase \\"_phase:B\\" does not exist."`; + +exports[`CommandLineConfiguration Forbids a misnamed phase 1`] = `"In command-line.json, the phase \\"_faze:A\\"'s name does not begin with the required prefix \\"_phase:\\"."`; + +exports[`CommandLineConfiguration Forbids a misnamed phase 2`] = `"In command-line.json, the phase \\"_phase:\\"'s name must have characters after \\"_phase:\\""`; diff --git a/apps/rush-lib/src/cli/RushCommandLineParser.ts b/apps/rush-lib/src/cli/RushCommandLineParser.ts index c598b662bb4..a54f2708df1 100644 --- a/apps/rush-lib/src/cli/RushCommandLineParser.ts +++ b/apps/rush-lib/src/cli/RushCommandLineParser.ts @@ -5,7 +5,7 @@ import colors from 'colors/safe'; import * as os from 'os'; import * as path from 'path'; -import { CommandLineParser, CommandLineFlagParameter, CommandLineAction } from '@rushstack/ts-command-line'; +import { CommandLineParser, CommandLineFlagParameter } from '@rushstack/ts-command-line'; import { InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; import { RushConfiguration } from '../api/RushConfiguration'; @@ -13,7 +13,6 @@ import { RushConstants } from '../logic/RushConstants'; import { CommandLineConfiguration } from '../api/CommandLineConfiguration'; import { CommandJson } from '../api/CommandLineJson'; import { Utilities } from '../utilities/Utilities'; -import { BaseScriptAction } from '../cli/scriptActions/BaseScriptAction'; import { AddAction } from './actions/AddAction'; import { ChangeAction } from './actions/ChangeAction'; @@ -35,13 +34,14 @@ import { VersionAction } from './actions/VersionAction'; import { UpdateCloudCredentialsAction } from './actions/UpdateCloudCredentialsAction'; import { WriteBuildCacheAction } from './actions/WriteBuildCacheAction'; -import { BulkScriptAction } from './scriptActions/BulkScriptAction'; +import { NonPhasedBulkScriptAction } from './scriptActions/NonPhasedBulkScriptAction'; import { GlobalScriptAction } from './scriptActions/GlobalScriptAction'; import { Telemetry } from '../logic/Telemetry'; import { RushGlobalFolder } from '../api/RushGlobalFolder'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { SetupAction } from './actions/SetupAction'; +import { PhasedBulkScriptAction } from './scriptActions/PhasedBulkScriptAction'; /** * Options for `RushCommandLineParser`. @@ -187,7 +187,7 @@ export class RushCommandLineParser extends CommandLineParser { } private _populateScriptActions(): void { - let commandLineConfiguration: CommandLineConfiguration | undefined = undefined; + let commandLineConfiguration: CommandLineConfiguration; // If there is not a rush.json file, we still want "build" and "rebuild" to appear in the // command-line help @@ -198,15 +198,16 @@ export class RushCommandLineParser extends CommandLineParser { ); commandLineConfiguration = CommandLineConfiguration.loadFromFileOrDefault(commandLineConfigFilePath); + } else { + commandLineConfiguration = new CommandLineConfiguration(undefined); } // Build actions from the command line configuration supersede default build actions. this._addCommandLineConfigActions(commandLineConfiguration); this._addDefaultBuildActions(commandLineConfiguration); - this._validateCommandLineConfigParameterAssociations(commandLineConfiguration); } - private _addDefaultBuildActions(commandLineConfiguration?: CommandLineConfiguration): void { + private _addDefaultBuildActions(commandLineConfiguration: CommandLineConfiguration): void { if (!this.tryGetAction(RushConstants.buildCommandName)) { this._addCommandLineConfigAction( commandLineConfiguration, @@ -229,13 +230,13 @@ export class RushCommandLineParser extends CommandLineParser { } // Register each custom command - for (const command of commandLineConfiguration.commands) { + for (const command of commandLineConfiguration.commands.values()) { this._addCommandLineConfigAction(commandLineConfiguration, command); } } private _addCommandLineConfigAction( - commandLineConfiguration: CommandLineConfiguration | undefined, + commandLineConfiguration: CommandLineConfiguration, command: CommandJson, commandToRun?: string ): void { @@ -249,9 +250,9 @@ export class RushCommandLineParser extends CommandLineParser { this._validateCommandLineConfigCommand(command); switch (command.commandKind) { - case RushConstants.bulkCommandKind: + case RushConstants.bulkCommandKind: { this.addAction( - new BulkScriptAction({ + new NonPhasedBulkScriptAction({ actionName: command.name, // By default, the "rebuild" action runs the "build" script. However, if the command-line.json file @@ -269,15 +270,16 @@ export class RushCommandLineParser extends CommandLineParser { ignoreMissingScript: command.ignoreMissingScript || false, ignoreDependencyOrder: command.ignoreDependencyOrder || false, incremental: command.incremental || false, - allowWarningsInSuccessfulBuild: !!command.allowWarningsInSuccessfulBuild, + allowWarningsOnSuccess: !!command.allowWarningsInSuccessfulBuild, watchForChanges: command.watchForChanges || false, disableBuildCache: command.disableBuildCache || false }) ); break; + } - case RushConstants.globalCommandKind: + case RushConstants.globalCommandKind: { this.addAction( new GlobalScriptAction({ actionName: command.name, @@ -294,39 +296,40 @@ export class RushCommandLineParser extends CommandLineParser { }) ); break; - default: - throw new Error( - `${RushConstants.commandLineFilename} defines a command "${command!.name}"` + - ` using an unsupported command kind "${command!.commandKind}"` - ); - } - } - - private _validateCommandLineConfigParameterAssociations( - commandLineConfiguration?: CommandLineConfiguration - ): void { - if (!commandLineConfiguration) { - return; - } + } - // Check for any invalid associations - for (const parameter of commandLineConfiguration.parameters) { - for (const associatedCommand of parameter.associatedCommands) { - const action: CommandLineAction | undefined = this.tryGetAction(associatedCommand); - if (!action) { + case RushConstants.phasedCommandKind: { + if (!this.rushConfiguration.experimentsConfiguration.configuration.multiPhaseCommands) { throw new Error( - `${RushConstants.commandLineFilename} defines a parameter "${parameter.longName}"` + - ` that is associated with a nonexistent command "${associatedCommand}"` - ); - } - if (!(action instanceof BaseScriptAction)) { - throw new Error( - `${RushConstants.commandLineFilename} defines a parameter "${parameter.longName}"` + - ` that is associated with a command "${associatedCommand}", but that command does not` + - ` support custom parameters` + `${RushConstants.commandLineFilename} defines a command "${command.name}" ` + + `that uses the "${RushConstants.phasedCommandKind}" command kind. To use this command kind, ` + + 'the "multiPhaseCommands" experiment must be enabled.' ); } + + this.addAction( + new PhasedBulkScriptAction({ + actionName: command.name, + summary: command.summary, + documentation: command.description || command.summary, + safeForSimultaneousRushProcesses: command.safeForSimultaneousRushProcesses, + commandPhaseNames: command.phases, + + parser: this, + commandLineConfiguration: commandLineConfiguration, + + watchForChanges: false, // Add support for this later + disableBuildCache: command.disableBuildCache || false + }) + ); + break; } + + default: + throw new Error( + `${RushConstants.commandLineFilename} defines a command "${command!.name}"` + + ` using an unsupported command kind "${command!.commandKind}"` + ); } } @@ -339,11 +342,14 @@ export class RushCommandLineParser extends CommandLineParser { return; } - if (command.commandKind === RushConstants.globalCommandKind) { + if ( + command.commandKind !== RushConstants.bulkCommandKind && + command.commandKind !== RushConstants.phasedCommandKind + ) { throw new Error( `${RushConstants.commandLineFilename} defines a command "${command.name}" using ` + `the command kind "${RushConstants.globalCommandKind}". This command can only be designated as a command ` + - `kind "${RushConstants.bulkCommandKind}".` + `kind "${RushConstants.bulkCommandKind}" or "${RushConstants.phasedCommandKind}".` ); } if (command.safeForSimultaneousRushProcesses) { diff --git a/apps/rush-lib/src/cli/actions/WriteBuildCacheAction.ts b/apps/rush-lib/src/cli/actions/WriteBuildCacheAction.ts index 030ba7d418b..ba2531a4628 100644 --- a/apps/rush-lib/src/cli/actions/WriteBuildCacheAction.ts +++ b/apps/rush-lib/src/cli/actions/WriteBuildCacheAction.ts @@ -13,7 +13,7 @@ import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration'; import { ProjectBuilder } from '../../logic/taskRunner/ProjectBuilder'; import { PackageChangeAnalyzer } from '../../logic/PackageChangeAnalyzer'; import { Utilities } from '../../utilities/Utilities'; -import { TaskSelector } from '../../logic/TaskSelector'; +import { TaskSelectorBase } from '../../logic/taskSelector/TaskSelectorBase'; import { RushConstants } from '../../logic/RushConstants'; import { CommandLineConfiguration } from '../../api/CommandLineConfiguration'; @@ -72,16 +72,18 @@ export class WriteBuildCacheAction extends BaseRushAction { ); const command: string = this._command.value!; - const commandToRun: string | undefined = TaskSelector.getScriptToRun(project, command, []); + const commandToRun: string | undefined = TaskSelectorBase.getScriptToRun(project, command, []); const packageChangeAnalyzer: PackageChangeAnalyzer = new PackageChangeAnalyzer(this.rushConfiguration); const projectBuilder: ProjectBuilder = new ProjectBuilder({ + name: ProjectBuilder.getTaskName(project), rushProject: project, rushConfiguration: this.rushConfiguration, buildCacheConfiguration, commandName: command, commandToRun: commandToRun || '', isIncrementalBuildAllowed: false, + allowWarningsOnSuccess: false, packageChangeAnalyzer, packageDepsFilename: Utilities.getPackageDepsFilenameForCommand(command) }); diff --git a/apps/rush-lib/src/cli/scriptActions/BaseScriptAction.ts b/apps/rush-lib/src/cli/scriptActions/BaseScriptAction.ts index ec992eba0a9..cd8db4f0668 100644 --- a/apps/rush-lib/src/cli/scriptActions/BaseScriptAction.ts +++ b/apps/rush-lib/src/cli/scriptActions/BaseScriptAction.ts @@ -10,7 +10,7 @@ import { RushConstants } from '../../logic/RushConstants'; * Constructor parameters for BaseScriptAction */ export interface IBaseScriptActionOptions extends IBaseRushActionOptions { - commandLineConfiguration: CommandLineConfiguration | undefined; + commandLineConfiguration: CommandLineConfiguration; } /** @@ -24,8 +24,11 @@ export interface IBaseScriptActionOptions extends IBaseRushActionOptions { * The two subclasses are BulkScriptAction and GlobalScriptAction. */ export abstract class BaseScriptAction extends BaseRushAction { - protected readonly _commandLineConfiguration: CommandLineConfiguration | undefined; - protected readonly customParameters: CommandLineParameter[] = []; + protected readonly _commandLineConfiguration: CommandLineConfiguration; + protected readonly customParameters: Map = new Map< + string, + CommandLineParameter + >(); public constructor(options: IBaseScriptActionOptions) { super(options); @@ -40,7 +43,7 @@ export abstract class BaseScriptAction extends BaseRushAction { // Find any parameters that are associated with this command for (const parameterJson of this._commandLineConfiguration.parameters) { let associated: boolean = false; - for (const associatedCommand of parameterJson.associatedCommands) { + for (const associatedCommand of parameterJson.associatedCommands || []) { if (associatedCommand === this.actionName) { associated = true; } @@ -85,7 +88,7 @@ export abstract class BaseScriptAction extends BaseRushAction { } if (customParameter) { - this.customParameters.push(customParameter); + this.customParameters.set(customParameter.longName, customParameter); } } } diff --git a/apps/rush-lib/src/cli/scriptActions/BulkScriptAction.ts b/apps/rush-lib/src/cli/scriptActions/BulkScriptActionBase.ts similarity index 85% rename from apps/rush-lib/src/cli/scriptActions/BulkScriptAction.ts rename to apps/rush-lib/src/cli/scriptActions/BulkScriptActionBase.ts index bbb7e64f264..4da8ca47997 100644 --- a/apps/rush-lib/src/cli/scriptActions/BulkScriptAction.ts +++ b/apps/rush-lib/src/cli/scriptActions/BulkScriptActionBase.ts @@ -13,11 +13,10 @@ import { import { Event } from '../../index'; import { SetupChecks } from '../../logic/SetupChecks'; -import { ITaskSelectorConstructor, TaskSelector } from '../../logic/TaskSelector'; +import { ITaskSelectorOptions, TaskSelectorBase } from '../../logic/taskSelector/TaskSelectorBase'; import { Stopwatch, StopwatchState } from '../../utilities/Stopwatch'; import { BaseScriptAction, IBaseScriptActionOptions } from './BaseScriptAction'; import { ITaskRunnerOptions, TaskRunner } from '../../logic/taskRunner/TaskRunner'; -import { Utilities } from '../../utilities/Utilities'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; import { LastLinkFlag, LastLinkFlagFactory } from '../../api/LastLinkFlag'; @@ -30,23 +29,23 @@ import { CommandLineConfiguration } from '../../api/CommandLineConfiguration'; /** * Constructor parameters for BulkScriptAction. */ -export interface IBulkScriptActionOptions extends IBaseScriptActionOptions { +export interface IBulkScriptActionBaseOptions extends IBaseScriptActionOptions { enableParallelism: boolean; - ignoreMissingScript: boolean; ignoreDependencyOrder: boolean; incremental: boolean; - allowWarningsInSuccessfulBuild: boolean; watchForChanges: boolean; disableBuildCache: boolean; +} - /** - * Optional command to run. Otherwise, use the `actionName` as the command to run. - */ - commandToRun?: string; +interface IRunWatchOptions extends IExecuteInternalOptions { + taskSelectorOptions: ITaskSelectorOptions; +} + +interface IRunOnceOptions extends IExecuteInternalOptions { + taskSelector: TaskSelectorBase; } interface IExecuteInternalOptions { - taskSelectorOptions: ITaskSelectorConstructor; taskRunnerOptions: ITaskRunnerOptions; stopwatch: Stopwatch; ignoreHooks?: boolean; @@ -62,16 +61,13 @@ interface IExecuteInternalOptions { * and "rebuild" commands are also modeled as bulk commands, because they essentially just * execute scripts from package.json in the same as any custom command. */ -export class BulkScriptAction extends BaseScriptAction { +export abstract class BulkScriptActionBase extends BaseScriptAction { + protected readonly _isIncrementalBuildAllowed: boolean; + protected readonly _ignoreDependencyOrder: boolean; private readonly _enableParallelism: boolean; - private readonly _ignoreMissingScript: boolean; - private readonly _isIncrementalBuildAllowed: boolean; - private readonly _commandToRun: string; private readonly _watchForChanges: boolean; private readonly _disableBuildCache: boolean; private readonly _repoCommandLineConfiguration: CommandLineConfiguration | undefined; - private readonly _ignoreDependencyOrder: boolean; - private readonly _allowWarningsInSuccessfulBuild: boolean; private _changedProjectsOnly!: CommandLineFlagParameter; private _selectionParameters!: SelectionParameterSet; @@ -80,14 +76,11 @@ export class BulkScriptAction extends BaseScriptAction { private _ignoreHooksParameter!: CommandLineFlagParameter; private _disableBuildCacheFlag: CommandLineFlagParameter | undefined; - public constructor(options: IBulkScriptActionOptions) { + protected constructor(options: IBulkScriptActionBaseOptions) { super(options); this._enableParallelism = options.enableParallelism; - this._ignoreMissingScript = options.ignoreMissingScript; this._isIncrementalBuildAllowed = options.incremental; - this._commandToRun = options.commandToRun || options.actionName; this._ignoreDependencyOrder = options.ignoreDependencyOrder; - this._allowWarningsInSuccessfulBuild = options.allowWarningsInSuccessfulBuild; this._watchForChanges = options.watchForChanges; this._disableBuildCache = options.disableBuildCache; this._repoCommandLineConfiguration = options.commandLineConfiguration; @@ -116,12 +109,6 @@ export class BulkScriptAction extends BaseScriptAction { // if parallelism is not enabled, then restrict to 1 core const parallelism: string | undefined = this._enableParallelism ? this._parallelismParameter!.value : '1'; - // Collect all custom parameter values - const customParameterValues: string[] = []; - for (const customParameter of this.customParameters) { - customParameter.appendToArgList(customParameterValues); - } - const changedProjectsOnly: boolean = this._isIncrementalBuildAllowed && this._changedProjectsOnly.value; const terminal: Terminal = new Terminal(new ConsoleTerminalProvider()); @@ -132,42 +119,39 @@ export class BulkScriptAction extends BaseScriptAction { const selection: Set = this._selectionParameters.getSelectedProjects(); - const taskSelectorOptions: ITaskSelectorConstructor = { + const taskSelectorOptions: ITaskSelectorOptions = { + commandName: this.actionName, rushConfiguration: this.rushConfiguration, buildCacheConfiguration, selection, - commandName: this.actionName, - commandToRun: this._commandToRun, - customParameterValues, - isQuietMode: isQuietMode, - isIncrementalBuildAllowed: this._isIncrementalBuildAllowed, - ignoreMissingScript: this._ignoreMissingScript, - ignoreDependencyOrder: this._ignoreDependencyOrder, - packageDepsFilename: Utilities.getPackageDepsFilenameForCommand(this._commandToRun) + isQuietMode: isQuietMode }; const taskRunnerOptions: ITaskRunnerOptions = { quietMode: isQuietMode, parallelism: parallelism, changedProjectsOnly: changedProjectsOnly, - allowWarningsInSuccessfulBuild: this._allowWarningsInSuccessfulBuild, repoCommandLineConfiguration: this._repoCommandLineConfiguration }; const executeOptions: IExecuteInternalOptions = { - taskSelectorOptions, taskRunnerOptions, stopwatch, terminal }; if (this._watchForChanges) { - await this._runWatch(executeOptions); + await this._runWatch({ ...executeOptions, taskSelectorOptions }); } else { - await this._runOnce(executeOptions); + await this._runOnce({ + ...executeOptions, + taskSelector: this._getTaskSelector(taskSelectorOptions) + }); } } + protected abstract _getTaskSelector(taskSelectorOptions: ITaskSelectorOptions): TaskSelectorBase; + /** * Runs the command in watch mode. Fundamentally is a simple loop: * 1) Wait for a change to one or more projects in the selection (skipped initially) @@ -175,7 +159,7 @@ export class BulkScriptAction extends BaseScriptAction { * Uses the same algorithm as --impacted-by * 3) Goto (1) */ - private async _runWatch(options: IExecuteInternalOptions): Promise { + private async _runWatch(options: IRunWatchOptions): Promise { const { taskSelectorOptions: { buildCacheConfiguration: initialBuildCacheConfiguration, @@ -229,8 +213,8 @@ export class BulkScriptAction extends BaseScriptAction { selection = Selection.intersection(Selection.expandAllConsumers(selection), projectsToWatch); } - const executeOptions: IExecuteInternalOptions = { - taskSelectorOptions: { + const executeOptions: IRunOnceOptions = { + taskSelector: this._getTaskSelector({ ...options.taskSelectorOptions, // Current implementation of the build cache deletes output folders before repopulating them; // this tends to break `webpack --watch`, etc. @@ -240,7 +224,7 @@ export class BulkScriptAction extends BaseScriptAction { selection, // Pass the PackageChangeAnalyzer from the state differ to save a bit of overhead packageChangeAnalyzer: state - }, + }), taskRunnerOptions: options.taskRunnerOptions, stopwatch, // For now, don't run pre-build or post-build in watch mode @@ -314,13 +298,10 @@ export class BulkScriptAction extends BaseScriptAction { /** * Runs a single invocation of the command */ - private async _runOnce(options: IExecuteInternalOptions): Promise { - const taskSelector: TaskSelector = new TaskSelector(options.taskSelectorOptions); - - // Register all tasks with the task collection - + private async _runOnce(options: IRunOnceOptions): Promise { const taskRunner: TaskRunner = new TaskRunner( - taskSelector.registerTasks().getOrderedTasks(), + // Register all tasks with the task collection + options.taskSelector.registerTasks().getOrderedTasks(), options.taskRunnerOptions ); @@ -389,7 +370,7 @@ export class BulkScriptAction extends BaseScriptAction { private _collectTelemetry(stopwatch: Stopwatch, success: boolean): void { const extraData: { [key: string]: string } = this._selectionParameters.getTelemetry(); - for (const customParameter of this.customParameters) { + for (const customParameter of this.customParameters.values()) { switch (customParameter.kind) { case CommandLineParameterKind.Flag: case CommandLineParameterKind.Choice: diff --git a/apps/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts b/apps/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts index 40e85797a28..1f662fc37f2 100644 --- a/apps/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts +++ b/apps/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts @@ -167,7 +167,7 @@ export class GlobalScriptAction extends BaseScriptAction { // Collect all custom parameter values const customParameterValues: string[] = []; - for (const customParameter of this.customParameters) { + for (const customParameter of this.customParameters.values()) { customParameter.appendToArgList(customParameterValues); } diff --git a/apps/rush-lib/src/cli/scriptActions/NonPhasedBulkScriptAction.ts b/apps/rush-lib/src/cli/scriptActions/NonPhasedBulkScriptAction.ts new file mode 100644 index 00000000000..3c61a269673 --- /dev/null +++ b/apps/rush-lib/src/cli/scriptActions/NonPhasedBulkScriptAction.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + NonPhasedCommandTaskSelector, + INonPhasedCommandTaskSelectorOptions +} from '../../logic/taskSelector/NonPhasedCommandTaskSelector'; +import { ITaskSelectorOptions } from '../../logic/taskSelector/TaskSelectorBase'; +import { Utilities } from '../../utilities/Utilities'; +import { BulkScriptActionBase, IBulkScriptActionBaseOptions } from './BulkScriptActionBase'; + +export interface INonPhasedBulkScriptActionOptions extends IBulkScriptActionBaseOptions { + allowWarningsOnSuccess: boolean; + ignoreMissingScript: boolean; + + /** + * Optional command to run. Otherwise, use the `actionName` as the command to run. + */ + commandToRun?: string; +} + +export class NonPhasedBulkScriptAction extends BulkScriptActionBase { + private readonly _ignoreMissingScript: boolean; + private readonly _commandToRun: string; + private readonly _allowWarningsOnSuccess: boolean; + + public constructor(options: INonPhasedBulkScriptActionOptions) { + super(options); + + this._commandToRun = options.commandToRun || options.actionName; + this._ignoreMissingScript = options.ignoreMissingScript; + this._allowWarningsOnSuccess = options.allowWarningsOnSuccess; + } + + public _getTaskSelector(taskSelectorOptions: ITaskSelectorOptions): NonPhasedCommandTaskSelector { + // Collect all custom parameter values + const customParameterValues: string[] = []; + for (const customParameter of this.customParameters.values()) { + customParameter.appendToArgList(customParameterValues); + } + + const nonPhasedCommandTaskSelectorOptions: INonPhasedCommandTaskSelectorOptions = { + commandToRun: this._commandToRun, + customParameterValues, + allowWarningsOnSuccess: this._allowWarningsOnSuccess, + isIncrementalBuildAllowed: this._isIncrementalBuildAllowed, + ignoreMissingScript: this._ignoreMissingScript, + ignoreDependencyOrder: this._ignoreDependencyOrder, + packageDepsFilename: Utilities.getPackageDepsFilenameForCommand(this._commandToRun) + }; + + return new NonPhasedCommandTaskSelector(taskSelectorOptions, nonPhasedCommandTaskSelectorOptions); + } +} diff --git a/apps/rush-lib/src/cli/scriptActions/PhasedBulkScriptAction.ts b/apps/rush-lib/src/cli/scriptActions/PhasedBulkScriptAction.ts new file mode 100644 index 00000000000..51d08f7a9d2 --- /dev/null +++ b/apps/rush-lib/src/cli/scriptActions/PhasedBulkScriptAction.ts @@ -0,0 +1,129 @@ +// 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 { CommandLineParameter } from '@rushstack/ts-command-line'; +import { IPhaseJson, ParameterJson } from '../../api/CommandLineJson'; +import { + IPhaseToRun, + IPhasedCommandTaskSelectorOptions, + PhasedCommandTaskSelector +} from '../../logic/taskSelector/PhasedCommandTaskSelector'; +import { ITaskSelectorOptions } from '../../logic/taskSelector/TaskSelectorBase'; +import { BulkScriptActionBase, IBulkScriptActionBaseOptions } from './BulkScriptActionBase'; + +export interface IPhasedBulkScriptActionOptions + extends Omit { + commandPhaseNames: string[]; +} + +export class PhasedBulkScriptAction extends BulkScriptActionBase { + private readonly _phases: Map; + private readonly _selectedPhaseNames: Set; + + public constructor(options: IPhasedBulkScriptActionOptions) { + const selectedPhaseNames: Set = new Set(); + const phases: Map = options.commandLineConfiguration.phases; + for (const phaseName of options.commandPhaseNames) { + PhasedBulkScriptAction._collectPhaseDependencies(phases, phaseName, selectedPhaseNames); + } + const incremental: boolean = Array.from(selectedPhaseNames).some( + (selectedPhaseName) => phases.get(selectedPhaseName)?.incremental + ); + const enableParallelism: boolean = Array.from(selectedPhaseNames).some( + (selectedPhaseName) => phases.get(selectedPhaseName)?.enableParallelism + ); + + super({ + ...options, + ignoreDependencyOrder: false, + incremental, + enableParallelism + }); + + this._phases = phases; + this._selectedPhaseNames = selectedPhaseNames; + } + + public _getTaskSelector(taskSelectorOptions: ITaskSelectorOptions): PhasedCommandTaskSelector { + const commandLineParametersByPhase: Map = new Map(); + for (const commandLineParameter of this._commandLineConfiguration.parameters) { + if (commandLineParameter.associatedPhases) { + for (const associatedPhaseName of commandLineParameter.associatedPhases) { + let commandLineParametersForPhase: ParameterJson[] | undefined = commandLineParametersByPhase.get( + associatedPhaseName + ); + if (!commandLineParametersForPhase) { + commandLineParametersForPhase = []; + commandLineParametersByPhase.set(associatedPhaseName, commandLineParametersForPhase); + } + + commandLineParametersForPhase.push(commandLineParameter); + } + } + } + + const phasesToRun: Map = new Map(); + for (const selectedPhaseName of this._selectedPhaseNames) { + const phase: IPhaseJson = this._phases.get(selectedPhaseName)!; + + const customParameterValues: string[] = []; + const commandLineParametersForPhase: ParameterJson[] | undefined = commandLineParametersByPhase.get( + selectedPhaseName + ); + if (commandLineParametersForPhase) { + for (const commandLineParameterForPhase of commandLineParametersForPhase) { + const customParameter: CommandLineParameter | undefined = this.customParameters.get( + commandLineParameterForPhase.longName + ); + if (customParameter) { + customParameter.appendToArgList(customParameterValues); + } + } + } + + phasesToRun.set(selectedPhaseName, { + phase, + customParameterValues + }); + } + + const nonPhasedCommandTaskSelectorOptions: IPhasedCommandTaskSelectorOptions = { + phases: phasesToRun, + selectedPhases: this._selectedPhaseNames + }; + + return new PhasedCommandTaskSelector(taskSelectorOptions, nonPhasedCommandTaskSelectorOptions); + } + + private static _collectPhaseDependencies( + phases: Map, + phaseName: string, + collectedPhaseNames: Set + ): void { + const phase: IPhaseJson | undefined = phases.get(phaseName); + if (!phase) { + throw new InternalError( + `Expected to find phase "${phaseName}", but it was not present in the ` + + `list of phases provided to the ${PhasedBulkScriptAction.name}. This is unexpected.` + ); + } + + collectedPhaseNames.add(phase.name); + if (phase.dependencies?.self) { + for (const dependencyPhaseName of phase.dependencies.self) { + if (!collectedPhaseNames.has(dependencyPhaseName)) { + PhasedBulkScriptAction._collectPhaseDependencies(phases, dependencyPhaseName, collectedPhaseNames); + } + } + } + + if (phase.dependencies?.upstream) { + for (const dependencyPhaseName of phase.dependencies.upstream) { + if (!collectedPhaseNames.has(dependencyPhaseName)) { + PhasedBulkScriptAction._collectPhaseDependencies(phases, dependencyPhaseName, collectedPhaseNames); + } + } + } + } +} diff --git a/apps/rush-lib/src/logic/RushConstants.ts b/apps/rush-lib/src/logic/RushConstants.ts index d027b1b3849..9f42bae9a1c 100644 --- a/apps/rush-lib/src/logic/RushConstants.ts +++ b/apps/rush-lib/src/logic/RushConstants.ts @@ -196,6 +196,11 @@ export class RushConstants { */ public static readonly globalCommandKind: 'global' = 'global'; + /** + * The value of the "commandKind" property for a phased command in command-line.json + */ + public static readonly phasedCommandKind: 'phased' = 'phased'; + /** * The name of the incremental build command. */ @@ -219,4 +224,9 @@ export class RushConstants { * The name of the per-user Rush configuration data folder. */ public static readonly rushUserConfigurationFolderName: string = '.rush-user'; + + /** + * A prefix that is required before all phase names. + */ + public static readonly phaseNamePrefix: '_phase:' = '_phase:'; } diff --git a/apps/rush-lib/src/logic/TaskSelector.ts b/apps/rush-lib/src/logic/TaskSelector.ts deleted file mode 100644 index cc23e727478..00000000000 --- a/apps/rush-lib/src/logic/TaskSelector.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { BuildCacheConfiguration } from '../api/BuildCacheConfiguration'; -import { RushConfiguration } from '../api/RushConfiguration'; -import { RushConfigurationProject } from '../api/RushConfigurationProject'; -import { ProjectBuilder, convertSlashesForWindows } from '../logic/taskRunner/ProjectBuilder'; -import { PackageChangeAnalyzer } from './PackageChangeAnalyzer'; -import { TaskCollection } from './taskRunner/TaskCollection'; - -export interface ITaskSelectorConstructor { - rushConfiguration: RushConfiguration; - buildCacheConfiguration: BuildCacheConfiguration | undefined; - selection: ReadonlySet; - commandName: string; - commandToRun: string; - customParameterValues: string[]; - isQuietMode: boolean; - isIncrementalBuildAllowed: boolean; - ignoreMissingScript: boolean; - ignoreDependencyOrder: boolean; - packageDepsFilename: string; - packageChangeAnalyzer?: PackageChangeAnalyzer; -} - -/** - * This class is responsible for: - * - based on to/from flags, solving the dependency graph and figuring out which projects need to be run - * - creating a ProjectBuilder for each project that needs to be built - * - registering the necessary ProjectBuilders with the TaskRunner, which actually orchestrates execution - */ -export class TaskSelector { - private _options: ITaskSelectorConstructor; - private _packageChangeAnalyzer: PackageChangeAnalyzer; - - public constructor(options: ITaskSelectorConstructor) { - this._options = options; - - const { packageChangeAnalyzer = new PackageChangeAnalyzer(options.rushConfiguration) } = options; - - this._packageChangeAnalyzer = packageChangeAnalyzer; - } - - public static getScriptToRun( - rushProject: RushConfigurationProject, - commandToRun: string, - customParameterValues: string[] - ): string | undefined { - const script: string | undefined = TaskSelector._getScriptCommand(rushProject, commandToRun); - - if (script === undefined) { - return undefined; - } - - if (!script) { - return ''; - } else { - const taskCommand: string = `${script} ${customParameterValues.join(' ')}`; - return process.platform === 'win32' ? convertSlashesForWindows(taskCommand) : taskCommand; - } - } - - public registerTasks(): TaskCollection { - const selectedProjects: ReadonlySet = this._computeSelectedProjects(); - - return this._createTaskCollection(selectedProjects); - } - - private _computeSelectedProjects(): ReadonlySet { - const { selection } = this._options; - - if (selection.size) { - return selection; - } - - // Default to all projects - return new Set(this._options.rushConfiguration.projects); - } - - private _createTaskCollection(projects: ReadonlySet): TaskCollection { - const taskCollection: TaskCollection = new TaskCollection(); - - // Register all tasks - for (const rushProject of projects) { - this._registerTask(rushProject, taskCollection); - } - - if (!this._options.ignoreDependencyOrder) { - const dependencyMap: Map> = new Map(); - - // Generate the filtered dependency graph for selected projects - function getDependencyTaskNames(project: RushConfigurationProject): Set { - const cached: Set | undefined = dependencyMap.get(project); - if (cached) { - return cached; - } - - const dependencyTaskNames: Set = new Set(); - dependencyMap.set(project, dependencyTaskNames); - - for (const dep of project.dependencyProjects) { - if (projects.has(dep)) { - // Add direct relationships for projects in the set - dependencyTaskNames.add(ProjectBuilder.getTaskName(dep)); - } else { - // Add indirect relationships for projects not in the set - for (const indirectDep of getDependencyTaskNames(dep)) { - dependencyTaskNames.add(indirectDep); - } - } - } - - return dependencyTaskNames; - } - - // Add ordering relationships for each dependency - for (const project of projects) { - taskCollection.addDependencies(ProjectBuilder.getTaskName(project), getDependencyTaskNames(project)); - } - } - - return taskCollection; - } - - private _registerTask(project: RushConfigurationProject | undefined, taskCollection: TaskCollection): void { - if (!project || taskCollection.hasTask(ProjectBuilder.getTaskName(project))) { - return; - } - - const commandToRun: string | undefined = TaskSelector.getScriptToRun( - project, - this._options.commandToRun, - this._options.customParameterValues - ); - if (commandToRun === undefined && !this._options.ignoreMissingScript) { - throw new Error( - `The project [${project.packageName}] does not define a '${this._options.commandToRun}' command in the 'scripts' section of its package.json` - ); - } - - taskCollection.addTask( - new ProjectBuilder({ - rushProject: project, - rushConfiguration: this._options.rushConfiguration, - buildCacheConfiguration: this._options.buildCacheConfiguration, - commandToRun: commandToRun || '', - commandName: this._options.commandName, - isIncrementalBuildAllowed: this._options.isIncrementalBuildAllowed, - packageChangeAnalyzer: this._packageChangeAnalyzer, - packageDepsFilename: this._options.packageDepsFilename - }) - ); - } - - private static _getScriptCommand( - rushProject: RushConfigurationProject, - script: string - ): string | undefined { - if (!rushProject.packageJson.scripts) { - return undefined; - } - - const rawCommand: string = rushProject.packageJson.scripts[script]; - - if (rawCommand === undefined || rawCommand === null) { - return undefined; - } - - return rawCommand; - } -} diff --git a/apps/rush-lib/src/logic/taskRunner/BaseBuilder.ts b/apps/rush-lib/src/logic/taskRunner/BaseBuilder.ts index acb21bb752a..ae32d8d65c8 100644 --- a/apps/rush-lib/src/logic/taskRunner/BaseBuilder.ts +++ b/apps/rush-lib/src/logic/taskRunner/BaseBuilder.ts @@ -30,6 +30,11 @@ export abstract class BaseBuilder { */ abstract isIncrementalBuildAllowed: boolean; + /** + * If set to true, warnings are ignored. + */ + abstract allowWarningsOnSuccess: boolean; + /** * Assigned by execute(). True if the build script was an empty string. Operationally an empty string is * like a shell command that succeeds instantly, but e.g. it would be odd to report build time statistics for it. diff --git a/apps/rush-lib/src/logic/taskRunner/ProjectBuilder.ts b/apps/rush-lib/src/logic/taskRunner/ProjectBuilder.ts index c322a570c8f..eed52df417e 100644 --- a/apps/rush-lib/src/logic/taskRunner/ProjectBuilder.ts +++ b/apps/rush-lib/src/logic/taskRunner/ProjectBuilder.ts @@ -42,12 +42,14 @@ export interface IProjectBuildDeps { } export interface IProjectBuilderOptions { + name: string; rushProject: RushConfigurationProject; rushConfiguration: RushConfiguration; buildCacheConfiguration: BuildCacheConfiguration | undefined; commandToRun: string; commandName: string; isIncrementalBuildAllowed: boolean; + allowWarningsOnSuccess: boolean; packageChangeAnalyzer: PackageChangeAnalyzer; packageDepsFilename: string; } @@ -74,11 +76,9 @@ type UNINITIALIZED = 'UNINITIALIZED'; * incremental state. */ export class ProjectBuilder extends BaseBuilder { - public get name(): string { - return ProjectBuilder.getTaskName(this._rushProject); - } - + public readonly name: string; public readonly isIncrementalBuildAllowed: boolean; + public readonly allowWarningsOnSuccess: boolean; public hadEmptyScript: boolean = false; private readonly _rushProject: RushConfigurationProject; @@ -97,12 +97,14 @@ export class ProjectBuilder extends BaseBuilder { public constructor(options: IProjectBuilderOptions) { super(); + this.name = options.name; this._rushProject = options.rushProject; this._rushConfiguration = options.rushConfiguration; this._buildCacheConfiguration = options.buildCacheConfiguration; this._commandName = options.commandName; this._commandToRun = options.commandToRun; this.isIncrementalBuildAllowed = options.isIncrementalBuildAllowed; + this.allowWarningsOnSuccess = options.allowWarningsOnSuccess; this._packageChangeAnalyzer = options.packageChangeAnalyzer; this._packageDepsFilename = options.packageDepsFilename; } @@ -111,8 +113,12 @@ export class ProjectBuilder extends BaseBuilder { * A helper method to determine the task name of a ProjectBuilder. Used when the task * name is required before a task is created. */ - public static getTaskName(rushProject: RushConfigurationProject): string { - return rushProject.packageName; + public static getTaskName(rushProject: RushConfigurationProject, phaseName?: string): string { + if (phaseName) { + return `${rushProject.packageName} (${phaseName})`; + } else { + return rushProject.packageName; + } } public async executeAsync(context: IBuilderContext): Promise { diff --git a/apps/rush-lib/src/logic/taskRunner/Task.ts b/apps/rush-lib/src/logic/taskRunner/Task.ts index ddb17bbeb63..76f2496d135 100644 --- a/apps/rush-lib/src/logic/taskRunner/Task.ts +++ b/apps/rush-lib/src/logic/taskRunner/Task.ts @@ -89,6 +89,8 @@ export class Task { */ public stopwatch!: Stopwatch; + public allowWarningsOnSuccess: boolean | undefined; + public constructor(builder: BaseBuilder, initialStatus: TaskStatus) { this.builder = builder; this.status = initialStatus; diff --git a/apps/rush-lib/src/logic/taskRunner/TaskCollection.ts b/apps/rush-lib/src/logic/taskRunner/TaskCollection.ts index 659ce50543b..e660f02c876 100644 --- a/apps/rush-lib/src/logic/taskRunner/TaskCollection.ts +++ b/apps/rush-lib/src/logic/taskRunner/TaskCollection.ts @@ -29,6 +29,7 @@ export class TaskCollection { const task: Task = new Task(builder, TaskStatus.Ready); task.criticalPathLength = undefined; + task.allowWarningsOnSuccess = builder.allowWarningsOnSuccess; this._tasks.set(task.name, task); } diff --git a/apps/rush-lib/src/logic/taskRunner/TaskRunner.ts b/apps/rush-lib/src/logic/taskRunner/TaskRunner.ts index c5a185e5767..12bc4f5709a 100644 --- a/apps/rush-lib/src/logic/taskRunner/TaskRunner.ts +++ b/apps/rush-lib/src/logic/taskRunner/TaskRunner.ts @@ -23,7 +23,6 @@ export interface ITaskRunnerOptions { quietMode: boolean; parallelism: string | undefined; changedProjectsOnly: boolean; - allowWarningsInSuccessfulBuild: boolean; repoCommandLineConfiguration: CommandLineConfiguration | undefined; destination?: TerminalWritable; } @@ -40,13 +39,12 @@ export class TaskRunner { private readonly _tasks: Task[]; private readonly _changedProjectsOnly: boolean; - private readonly _allowWarningsInSuccessfulBuild: boolean; private readonly _buildQueue: Task[]; private readonly _quietMode: boolean; private readonly _parallelism: number; private readonly _repoCommandLineConfiguration: CommandLineConfiguration | undefined; private _hasAnyFailures: boolean; - private _hasAnyWarnings: boolean; + private _hasAnyDisallowedWarnings: boolean; private _currentActiveTasks!: number; private _totalTasks!: number; private _completedTasks!: number; @@ -58,20 +56,13 @@ export class TaskRunner { private _terminal: CollatedTerminal; public constructor(orderedTasks: Task[], options: ITaskRunnerOptions) { - const { - quietMode, - parallelism, - changedProjectsOnly, - allowWarningsInSuccessfulBuild, - repoCommandLineConfiguration - } = options; + const { quietMode, parallelism, changedProjectsOnly, repoCommandLineConfiguration } = options; this._tasks = orderedTasks; this._buildQueue = orderedTasks.slice(0); this._quietMode = quietMode; this._hasAnyFailures = false; - this._hasAnyWarnings = false; + this._hasAnyDisallowedWarnings = false; this._changedProjectsOnly = changedProjectsOnly; - this._allowWarningsInSuccessfulBuild = allowWarningsInSuccessfulBuild; this._repoCommandLineConfiguration = repoCommandLineConfiguration; // TERMINAL PIPELINE: @@ -184,7 +175,7 @@ export class TaskRunner { if (this._hasAnyFailures) { this._terminal.writeStderrLine(colors.red('Projects failed to build.') + '\n'); throw new AlreadyReportedError(); - } else if (this._hasAnyWarnings && !this._allowWarningsInSuccessfulBuild) { + } else if (this._hasAnyDisallowedWarnings) { this._terminal.writeStderrLine(colors.yellow('Projects succeeded with warnings.') + '\n'); throw new AlreadyReportedError(); } @@ -250,23 +241,35 @@ export class TaskRunner { this._currentActiveTasks--; switch (result) { - case TaskStatus.Success: + case TaskStatus.Success: { this._markTaskAsSuccess(task); break; - case TaskStatus.SuccessWithWarning: - this._hasAnyWarnings = true; + } + + case TaskStatus.SuccessWithWarning: { + if (!task.allowWarningsOnSuccess) { + this._hasAnyDisallowedWarnings = true; + } + this._markTaskAsSuccessWithWarning(task); break; - case TaskStatus.FromCache: + } + + case TaskStatus.FromCache: { this._markTaskAsFromCache(task); break; - case TaskStatus.Skipped: + } + + case TaskStatus.Skipped: { this._markTaskAsSkipped(task); break; - case TaskStatus.Failure: + } + + case TaskStatus.Failure: { this._hasAnyFailures = true; this._markTaskAsFailed(task); break; + } } } catch (error) { task.stdioSummarizer.close(); diff --git a/apps/rush-lib/src/logic/taskRunner/test/MockBuilder.ts b/apps/rush-lib/src/logic/taskRunner/test/MockBuilder.ts index a13c9fa02b3..116d6d58669 100644 --- a/apps/rush-lib/src/logic/taskRunner/test/MockBuilder.ts +++ b/apps/rush-lib/src/logic/taskRunner/test/MockBuilder.ts @@ -9,6 +9,7 @@ import { BaseBuilder, IBuilderContext } from '../BaseBuilder'; export class MockBuilder extends BaseBuilder { private readonly _action: ((terminal: CollatedTerminal) => Promise) | undefined; public readonly name: string; + public allowWarningsOnSuccess: boolean; public readonly hadEmptyScript: boolean = false; public readonly isIncrementalBuildAllowed: boolean = false; @@ -17,6 +18,7 @@ export class MockBuilder extends BaseBuilder { this.name = name; this._action = action; + this.allowWarningsOnSuccess = false; } public async executeAsync(context: IBuilderContext): Promise { diff --git a/apps/rush-lib/src/logic/taskRunner/test/TaskRunner.test.ts b/apps/rush-lib/src/logic/taskRunner/test/TaskRunner.test.ts index bea87d35d23..18ad10eaaf6 100644 --- a/apps/rush-lib/src/logic/taskRunner/test/TaskRunner.test.ts +++ b/apps/rush-lib/src/logic/taskRunner/test/TaskRunner.test.ts @@ -54,6 +54,14 @@ describe('TaskRunner', () => { }); beforeEach(() => { + taskRunnerOptions = { + quietMode: false, + parallelism: '1', + changedProjectsOnly: false, + destination: mockWritable, + repoCommandLineConfiguration: undefined + }; + mockWritable.reset(); }); @@ -66,7 +74,6 @@ describe('TaskRunner', () => { parallelism: 'tequila', changedProjectsOnly: false, destination: mockWritable, - allowWarningsInSuccessfulBuild: false, repoCommandLineConfiguration: undefined }) ).toThrowErrorMatchingSnapshot(); @@ -74,17 +81,6 @@ describe('TaskRunner', () => { }); describe('Error logging', () => { - beforeEach(() => { - taskRunnerOptions = { - quietMode: false, - parallelism: '1', - changedProjectsOnly: false, - destination: mockWritable, - allowWarningsInSuccessfulBuild: false, - repoCommandLineConfiguration: undefined - }; - }); - it('printedStderrAfterError', async () => { taskRunner = createTaskRunner( taskRunnerOptions, @@ -131,17 +127,6 @@ describe('TaskRunner', () => { describe('Warning logging', () => { describe('Fail on warning', () => { - beforeEach(() => { - taskRunnerOptions = { - quietMode: false, - parallelism: '1', - changedProjectsOnly: false, - destination: mockWritable, - allowWarningsInSuccessfulBuild: false, - repoCommandLineConfiguration: undefined - }; - }); - it('Logs warnings correctly', async () => { taskRunner = createTaskRunner( taskRunnerOptions, @@ -166,26 +151,18 @@ describe('TaskRunner', () => { }); describe('Success on warning', () => { - beforeEach(() => { - taskRunnerOptions = { - quietMode: false, - parallelism: '1', - changedProjectsOnly: false, - destination: mockWritable, - allowWarningsInSuccessfulBuild: true, - repoCommandLineConfiguration: undefined - }; - }); - it('Logs warnings correctly', async () => { - taskRunner = createTaskRunner( - taskRunnerOptions, - new MockBuilder('success with warnings (success)', async (terminal: CollatedTerminal) => { + const builder: MockBuilder = new MockBuilder( + 'success with warnings (success)', + async (terminal: CollatedTerminal) => { terminal.writeStdoutLine('Build step 1' + EOL); terminal.writeStdoutLine('Warning: step 1 succeeded with warnings' + EOL); return TaskStatus.SuccessWithWarning; - }) + } ); + builder.allowWarningsOnSuccess = true; + + taskRunner = createTaskRunner(taskRunnerOptions, builder); await taskRunner.executeAsync(); const allMessages: string = mockWritable.getAllOutput(); diff --git a/apps/rush-lib/src/logic/taskSelector/NonPhasedCommandTaskSelector.ts b/apps/rush-lib/src/logic/taskSelector/NonPhasedCommandTaskSelector.ts new file mode 100644 index 00000000000..1d36d74b94b --- /dev/null +++ b/apps/rush-lib/src/logic/taskSelector/NonPhasedCommandTaskSelector.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import { ProjectBuilder } from '../taskRunner/ProjectBuilder'; +import { TaskCollection } from '../taskRunner/TaskCollection'; +import { ITaskSelectorOptions, TaskSelectorBase } from './TaskSelectorBase'; + +export interface INonPhasedCommandTaskSelectorOptions { + commandToRun: string; + customParameterValues: string[]; + isIncrementalBuildAllowed: boolean; + allowWarningsOnSuccess: boolean; + ignoreMissingScript: boolean; + ignoreDependencyOrder: boolean; + packageDepsFilename: string; +} + +export class NonPhasedCommandTaskSelector extends TaskSelectorBase { + private _nonPhasedCommandTaskSelectorOptions: INonPhasedCommandTaskSelectorOptions; + + public constructor( + options: ITaskSelectorOptions, + nonPhasedCommandTaskSelectorOptions: INonPhasedCommandTaskSelectorOptions + ) { + super(options); + + this._nonPhasedCommandTaskSelectorOptions = nonPhasedCommandTaskSelectorOptions; + } + + protected _createTaskCollection(projects: ReadonlySet): TaskCollection { + const taskCollection: TaskCollection = new TaskCollection(); + + // Register all tasks + for (const rushProject of projects) { + this._registerProjectTask(rushProject, taskCollection); + } + + if (!this._nonPhasedCommandTaskSelectorOptions.ignoreDependencyOrder) { + const dependencyMap: Map> = new Map(); + + // Generate the filtered dependency graph for selected projects + function getDependencyTaskNames(project: RushConfigurationProject): Set { + let dependencyTaskNames: Set | undefined = dependencyMap.get(project); + if (!dependencyTaskNames) { + dependencyTaskNames = new Set(); + dependencyMap.set(project, dependencyTaskNames); + + for (const dep of project.dependencyProjects) { + if (projects.has(dep)) { + // Add direct relationships for projects in the set + dependencyTaskNames.add(ProjectBuilder.getTaskName(dep)); + } else { + // Add indirect relationships for projects not in the set + for (const indirectDep of getDependencyTaskNames(dep)) { + dependencyTaskNames.add(indirectDep); + } + } + } + } + + return dependencyTaskNames; + } + + // Add ordering relationships for each dependency + for (const project of projects) { + taskCollection.addDependencies(ProjectBuilder.getTaskName(project), getDependencyTaskNames(project)); + } + } + + return taskCollection; + } + + private _registerProjectTask(project: RushConfigurationProject, taskCollection: TaskCollection): void { + const taskName: string = ProjectBuilder.getTaskName(project); + if (!project || taskCollection.hasTask(taskName)) { + return; + } + + const commandToRun: string | undefined = TaskSelectorBase.getScriptToRun( + project, + this._nonPhasedCommandTaskSelectorOptions.commandToRun, + this._nonPhasedCommandTaskSelectorOptions.customParameterValues + ); + if (commandToRun === undefined && !this._nonPhasedCommandTaskSelectorOptions.ignoreMissingScript) { + throw new Error( + `The project [${project.packageName}] does not define a '${this._nonPhasedCommandTaskSelectorOptions.commandToRun}' command in the 'scripts' section of its package.json` + ); + } + + taskCollection.addTask( + new ProjectBuilder({ + name: taskName, + rushProject: project, + rushConfiguration: this._options.rushConfiguration, + buildCacheConfiguration: this._options.buildCacheConfiguration, + commandToRun: commandToRun || '', + commandName: this._options.commandName, + isIncrementalBuildAllowed: this._nonPhasedCommandTaskSelectorOptions.isIncrementalBuildAllowed, + allowWarningsOnSuccess: this._nonPhasedCommandTaskSelectorOptions.allowWarningsOnSuccess, + packageChangeAnalyzer: this._packageChangeAnalyzer, + packageDepsFilename: this._nonPhasedCommandTaskSelectorOptions.packageDepsFilename + }) + ); + } +} diff --git a/apps/rush-lib/src/logic/taskSelector/PhasedCommandTaskSelector.ts b/apps/rush-lib/src/logic/taskSelector/PhasedCommandTaskSelector.ts new file mode 100644 index 00000000000..6904ec77950 --- /dev/null +++ b/apps/rush-lib/src/logic/taskSelector/PhasedCommandTaskSelector.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { InternalError } from '@rushstack/node-core-library'; + +import { IPhaseJson } from '../../api/CommandLineJson'; +import { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import { RushConstants } from '../RushConstants'; +import { ProjectBuilder } from '../taskRunner/ProjectBuilder'; +import { TaskCollection } from '../taskRunner/TaskCollection'; +import { ITaskSelectorOptions, TaskSelectorBase } from './TaskSelectorBase'; +import { Utilities } from '../../utilities/Utilities'; + +export interface IPhaseToRun { + phase: IPhaseJson; + customParameterValues: string[]; +} + +export interface IPhasedCommandTaskSelectorOptions { + phases: Map; + selectedPhases: Set; +} + +export class PhasedCommandTaskSelector extends TaskSelectorBase { + private _phasedCommandTaskSelectorOptions: IPhasedCommandTaskSelectorOptions; + + public constructor( + options: ITaskSelectorOptions, + phasedCommandTaskSelectorOptions: IPhasedCommandTaskSelectorOptions + ) { + super(options); + + this._phasedCommandTaskSelectorOptions = phasedCommandTaskSelectorOptions; + } + + protected _createTaskCollection(projects: ReadonlySet): TaskCollection { + const taskCollection: TaskCollection = new TaskCollection(); + + const selectedPhases: Set = this._phasedCommandTaskSelectorOptions.selectedPhases; + const phases: Map = this._phasedCommandTaskSelectorOptions.phases; + + const friendlyPhaseNames: Map = new Map(); + // Register all tasks + for (const phaseName of selectedPhases) { + const phaseToRun: IPhaseToRun | undefined = phases.get(phaseName); + if (!phaseToRun) { + throw new InternalError( + `Expected to find phase "${phaseName}", but it was not present in the ` + + `list of phases provided to the ${PhasedCommandTaskSelector.name}. This is unexpected.` + ); + } + + const friendlyPhaseName: string = phaseName.substring(RushConstants.phaseNamePrefix.length); + friendlyPhaseNames.set(phaseName, friendlyPhaseName); + const packageDepsFilename: string = Utilities.getPackageDepsFilenameForCommand(friendlyPhaseName); + + for (const project of projects) { + const commandToRun: string | undefined = TaskSelectorBase.getScriptToRun( + project, + phaseToRun.phase.name, + phaseToRun.customParameterValues + ); + + if (commandToRun === undefined && !phaseToRun.phase.ignoreMissingScript) { + throw new Error( + `The project [${project.packageName}] does not define a '${phaseToRun.phase.name}' command in the 'scripts' section of its package.json` + ); + } + + const taskName: string = ProjectBuilder.getTaskName(project, friendlyPhaseName); + if (!taskCollection.hasTask(taskName)) { + taskCollection.addTask( + new ProjectBuilder({ + name: taskName, + rushProject: project, + rushConfiguration: this._options.rushConfiguration, + buildCacheConfiguration: this._options.buildCacheConfiguration, + commandToRun: commandToRun || '', + commandName: this._options.commandName, + isIncrementalBuildAllowed: !!phaseToRun.phase.incremental, + allowWarningsOnSuccess: !!phaseToRun.phase.allowWarningsOnSuccess, + packageChangeAnalyzer: this._packageChangeAnalyzer, + packageDepsFilename: packageDepsFilename + }) + ); + } + } + } + + const dependencyMap: Map> = new Map>(); + + // Generate the filtered dependency graph + function getDependencyTaskNames( + project: RushConfigurationProject, + phaseName: string, + taskName: string = ProjectBuilder.getTaskName(project, friendlyPhaseNames.get(phaseName)!) + ): Set { + let dependencyTaskNames: Set | undefined = dependencyMap.get(taskName); + if (!dependencyTaskNames) { + dependencyTaskNames = new Set(); + dependencyMap.set(taskName, dependencyTaskNames); + + const phase: IPhaseJson = phases.get(phaseName)!.phase; + if (phase.dependencies?.self) { + for (const selfDependencyPhaseName of phase.dependencies.self) { + dependencyTaskNames.add( + ProjectBuilder.getTaskName(project, friendlyPhaseNames.get(selfDependencyPhaseName)) + ); + } + } + + if (phase.dependencies?.upstream) { + for (const upstreamDependencyPhaseName of phase.dependencies.upstream) { + for (const dep of project.dependencyProjects) { + if (projects.has(dep)) { + // Add direct relationships for projects in the set + dependencyTaskNames.add( + ProjectBuilder.getTaskName(dep, friendlyPhaseNames.get(upstreamDependencyPhaseName)) + ); + } else { + // Add indirect relationships for projects not in the set + for (const indirectDep of getDependencyTaskNames(dep, upstreamDependencyPhaseName)) { + dependencyTaskNames.add(indirectDep); + } + } + } + } + } + } + + return dependencyTaskNames; + } + + // Add ordering relationships for each dependency + for (const phaseName of selectedPhases) { + for (const project of projects) { + const friendlyPhaseName: string = friendlyPhaseNames.get(phaseName)!; + const taskName: string = ProjectBuilder.getTaskName(project, friendlyPhaseName); + taskCollection.addDependencies(taskName, getDependencyTaskNames(project, phaseName, taskName)); + } + } + + return taskCollection; + } +} diff --git a/apps/rush-lib/src/logic/taskSelector/TaskSelectorBase.ts b/apps/rush-lib/src/logic/taskSelector/TaskSelectorBase.ts new file mode 100644 index 00000000000..4c6bcbd4936 --- /dev/null +++ b/apps/rush-lib/src/logic/taskSelector/TaskSelectorBase.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import { convertSlashesForWindows } from '../taskRunner/ProjectBuilder'; +import { PackageChangeAnalyzer } from '../PackageChangeAnalyzer'; +import { TaskCollection } from '../taskRunner/TaskCollection'; + +export interface ITaskSelectorOptions { + rushConfiguration: RushConfiguration; + commandName: string; + buildCacheConfiguration: BuildCacheConfiguration | undefined; + selection: ReadonlySet; + isQuietMode: boolean; + packageChangeAnalyzer?: PackageChangeAnalyzer; +} + +/** + * This class is responsible for: + * - based on to/from flags, solving the dependency graph and figuring out which projects need to be run + * - creating a ProjectBuilder for each project that needs to be built + * - registering the necessary ProjectBuilders with the TaskRunner, which actually orchestrates execution + */ +export abstract class TaskSelectorBase { + protected _options: ITaskSelectorOptions; + protected _packageChangeAnalyzer: PackageChangeAnalyzer; + + public constructor(options: ITaskSelectorOptions) { + this._options = options; + + const { packageChangeAnalyzer = new PackageChangeAnalyzer(options.rushConfiguration) } = options; + + this._packageChangeAnalyzer = packageChangeAnalyzer; + } + + public static getScriptToRun( + rushProject: RushConfigurationProject, + commandToRun: string, + customParameterValues: string[] + ): string | undefined { + const script: string | undefined = TaskSelectorBase._getScriptCommand(rushProject, commandToRun); + + if (script === undefined) { + return undefined; + } + + if (!script) { + return ''; + } else { + const taskCommand: string = `${script} ${customParameterValues.join(' ')}`; + return process.platform === 'win32' ? convertSlashesForWindows(taskCommand) : taskCommand; + } + } + + public registerTasks(): TaskCollection { + const selectedProjects: ReadonlySet = this._computeSelectedProjects(); + + return this._createTaskCollection(selectedProjects); + } + + private _computeSelectedProjects(): ReadonlySet { + const { selection } = this._options; + + if (selection.size) { + return selection; + } + + // Default to all projects + return new Set(this._options.rushConfiguration.projects); + } + + protected abstract _createTaskCollection(projects: ReadonlySet): TaskCollection; + + private static _getScriptCommand( + rushProject: RushConfigurationProject, + script: string + ): string | undefined { + if (!rushProject.packageJson.scripts) { + return undefined; + } + + const rawCommand: string = rushProject.packageJson.scripts[script]; + + if (rawCommand === undefined || rawCommand === null) { + return undefined; + } + + return rawCommand; + } +} diff --git a/apps/rush-lib/src/schemas/command-line.schema.json b/apps/rush-lib/src/schemas/command-line.schema.json index 3da94b31fdf..8d0a544cbf9 100644 --- a/apps/rush-lib/src/schemas/command-line.schema.json +++ b/apps/rush-lib/src/schemas/command-line.schema.json @@ -18,7 +18,7 @@ "title": "Command Kind", "description": "Indicates the kind of command: \"bulk\" commands are run separately for each project; \"global\" commands are run once for the entire repository.", "type": "string", - "enum": ["bulk", "global"] + "enum": ["bulk", "global", "phased"] }, "name": { "title": "Custom Command Name", @@ -156,6 +156,132 @@ } ] }, + "phasedCommand": { + "title": "Phased Command", + "description": "A command that contains multiple phases, that are run separately for each project", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/baseCommand" }, + { + "type": "object", + "additionalProperties": true, + "required": ["phases"], + "properties": { + "commandKind": { + "enum": ["phased"] + }, + + "phases": { + "title": "Phases", + "description": "TBA", + "type": "array", + "items": { + "type": "string" + } + }, + + "disableBuildCache ": { + "title": "Disable build cache.", + "description": "Disable build cache for this action. This may be useful if this command affects state outside of projects' own folders.", + "type": "boolean" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "commandKind": { "$ref": "#/definitions/anything" }, + "name": { "$ref": "#/definitions/anything" }, + "summary": { "$ref": "#/definitions/anything" }, + "description": { "$ref": "#/definitions/anything" }, + "safeForSimultaneousRushProcesses": { "$ref": "#/definitions/anything" }, + "allowWarningsInSuccessfulBuild": { "$ref": "#/definitions/anything" }, + + "phases": { "$ref": "#/definitions/anything" }, + "disableBuildCache": { "$ref": "#/definitions/anything" } + } + } + ] + }, + + "phase": { + "title": "Phase", + "description": "TBA", + "type": "object", + "additionalProperties": false, + "required": ["name", "summary"], + "properties": { + "name": { + "title": "Name", + "description": "TBA", + "type": "string" + }, + + "summary": { + "title": "Summary", + "description": "TBA", + "type": "string" + }, + + "description": { + "title": "Description", + "description": "TBA", + "type": "string" + }, + + "dependencies": { + "title": "Dependencies", + "description": "TBA", + "type": "object", + "additionalProperties": false, + "properties": { + "self": { + "title": "Self", + "description": "TBA", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "upstream": { + "title": "Upstream", + "description": "TBA", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + + "enableParallelism": { + "title": "enableParallelism", + "description": "TBA", + "type": "boolean" + }, + + "incremental": { + "title": "Incremental", + "description": "If true then this command will be incremental like the built-in \"build\" and \"rebuild\" commands", + "type": "boolean" + }, + + "ignoreMissingScript": { + "title": "Ignore Missing Script", + "description": "TBA", + "type": "boolean" + }, + + "allowWarningsOnSuccess": { + "title": "Allow Warnings in Successful Build", + "description": "TBA", + "type": "boolean" + } + } + }, "baseParameter": { "type": "object", @@ -189,7 +315,14 @@ "title": "Associated Commands", "description": "A list of custom commands and/or built-in Rush commands that this parameter may be used with", "type": "array", - "minItems": 1, + "items": { + "type": "string" + } + }, + "associatedPhases": { + "title": "Associated Phases", + "description": "TBA", + "type": "array", "items": { "type": "string" } @@ -225,6 +358,7 @@ "shortName": { "$ref": "#/definitions/anything" }, "description": { "$ref": "#/definitions/anything" }, "associatedCommands": { "$ref": "#/definitions/anything" }, + "associatedPhases": { "$ref": "#/definitions/anything" }, "required": { "$ref": "#/definitions/anything" } } } @@ -260,6 +394,7 @@ "shortName": { "$ref": "#/definitions/anything" }, "description": { "$ref": "#/definitions/anything" }, "associatedCommands": { "$ref": "#/definitions/anything" }, + "associatedPhases": { "$ref": "#/definitions/anything" }, "required": { "$ref": "#/definitions/anything" }, "argumentName": { "$ref": "#/definitions/anything" } @@ -320,6 +455,7 @@ "shortName": { "$ref": "#/definitions/anything" }, "description": { "$ref": "#/definitions/anything" }, "associatedCommands": { "$ref": "#/definitions/anything" }, + "associatedPhases": { "$ref": "#/definitions/anything" }, "required": { "$ref": "#/definitions/anything" }, "alternatives": { "$ref": "#/definitions/anything" }, @@ -345,7 +481,20 @@ "type": "array", "items": { "type": "object", - "oneOf": [{ "$ref": "#/definitions/bulkCommand" }, { "$ref": "#/definitions/globalCommand" }] + "oneOf": [ + { "$ref": "#/definitions/bulkCommand" }, + { "$ref": "#/definitions/globalCommand" }, + { "$ref": "#/definitions/phasedCommand" } + ] + } + }, + + "phases": { + "title": "Phases", + "description": "TBA", + "type": "array", + "items": { + "$ref": "#/definitions/phase" } }, diff --git a/apps/rush-lib/src/schemas/experiments.schema.json b/apps/rush-lib/src/schemas/experiments.schema.json index 6567fe353c0..fc087dfe61c 100644 --- a/apps/rush-lib/src/schemas/experiments.schema.json +++ b/apps/rush-lib/src/schemas/experiments.schema.json @@ -25,6 +25,10 @@ "noChmodFieldInTarHeaderNormalization": { "description": "If true, the chmod field in temporary project tar headers will not be normalized. This normalization can help ensure consistent tarball integrity across platforms.", "type": "boolean" + }, + "multiPhaseCommands": { + "description": "If true, the multi-phase commands feature is enabled. To use this feature, create a \"phased\" command in common/config/rush/command-line.json.", + "type": "boolean" } }, "additionalProperties": false diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 67a3c552a54..77f98ae04df 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -143,6 +143,7 @@ export interface IConfigurationEnvironmentVariable { // @beta export interface IExperimentsJson { + multiPhaseCommands?: boolean; noChmodFieldInTarHeaderNormalization?: boolean; omitImportersFromPreventManualShrinkwrapChanges?: boolean; usePnpmFrozenLockfileForRushInstall?: boolean;