forked from microsoft/rushstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRushProjectConfiguration.ts
More file actions
614 lines (544 loc) · 24.2 KB
/
RushProjectConfiguration.ts
File metadata and controls
614 lines (544 loc) · 24.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { AlreadyReportedError, Async, Path } from '@rushstack/node-core-library';
import type { ITerminal } from '@rushstack/terminal';
import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file';
import { RigConfig } from '@rushstack/rig-package';
import type { RushConfigurationProject } from './RushConfigurationProject';
import { RushConstants } from '../logic/RushConstants';
import type { IPhase } from './CommandLineConfiguration';
import { OverlappingPathAnalyzer } from '../utilities/OverlappingPathAnalyzer';
import schemaJson from '../schemas/rush-project.schema.json';
import anythingSchemaJson from '../schemas/rush-project.schema.json';
import { RushConnect } from '../utilities/RushConnect';
import type { RushConfiguration } from './RushConfiguration';
/**
* Describes the file structure for the `<project root>/config/rush-project.json` config file.
* @internal
*/
export interface IRushProjectJson {
/**
* The incremental analyzer can skip Rush commands for projects whose input files have
* not changed since the last build. Normally, every Git-tracked file under the project
* folder is assumed to be an input. Set incrementalBuildIgnoredGlobs to ignore specific
* files, specified as globs relative to the project folder. The list of file globs will
* be interpreted the same way your .gitignore file is.
*/
incrementalBuildIgnoredGlobs?: string[];
/**
* Disable caching for this project. The project will never be restored from cache.
* This may be useful if this project affects state outside of its folder.
*
* This option is only used when the build cache is enabled for the repo. You can set
* disableBuildCacheForProject=true to disable caching for a specific project. This is a useful workaround
* if that project's build scripts violate the assumptions of the cache, for example by writing
* files outside the project folder. Where possible, a better solution is to improve the build scripts
* to be compatible with caching.
*/
disableBuildCacheForProject?: boolean;
operationSettings?: IOperationSettings[];
}
/** @alpha */
export interface IRushPhaseSharding {
/**
* The number of shards to create.
*/
count: number;
/**
* The format of the argument to pass to the command to indicate the shard index and count.
*
* @defaultValue `--shard={shardIndex}/{shardCount}`
*/
shardArgumentFormat?: string;
/**
* An optional argument to pass to the command to indicate the output folder for the shard.
* It must end with `{shardIndex}`.
*
* @defaultValue `--shard-output-folder=.rush/operations/{phaseName}/shards/{shardIndex}`.
*/
outputFolderArgumentFormat?: string;
/**
* @deprecated Create a separate operation settings object for the shard operation settings with the name `{operationName}:shard`.
*/
shardOperationSettings?: unknown;
}
/**
* @alpha
*/
export interface IOperationSettings {
/**
* The name of the operation. This should be a key in the `package.json`'s `scripts` object.
*/
operationName: string;
/**
* Specify the folders where this operation writes its output files. If enabled, the Rush build
* cache will restore these folders from the cache. The strings are folder names under the project
* root folder.
*
* These folders should not be tracked by Git. They must not contain symlinks.
*/
outputFolderNames?: string[];
/**
* Disable caching for this operation. The operation will never be restored from cache.
* This may be useful if this operation affects state outside of its folder.
*
* This option is only used when the build cache is enabled for the repo. You can set
* disableBuildCacheForOperation=true to disable caching for a specific project operation.
* This is a useful workaround if that project's build scripts violate the assumptions of the cache,
* for example by writing files outside the project folder. Where possible, a better solution is to improve
* the build scripts to be compatible with caching.
*/
disableBuildCacheForOperation?: boolean;
/**
* An optional list of environment variables that can affect this operation. The values of
* these environment variables will become part of the hash when reading and writing the build cache.
*
* Note: generally speaking, all environment variables available to Rush are also available to any
* operations performed -- Rush assumes that environment variables do not affect build outputs unless
* you list them here.
*/
dependsOnEnvVars?: string[];
/**
* An optional list of glob (minimatch) patterns pointing to files that can affect this operation.
* The hash values of the contents of these files will become part of the final hash when reading
* and writing the build cache.
*
* Note: if a particular file will be matched by patterns provided by both `incrementalBuildIgnoredGlobs` and
* `dependsOnAdditionalFiles` options - `dependsOnAdditionalFiles` will win and the file will be included
* calculating final hash value when reading and writing the build cache
*/
dependsOnAdditionalFiles?: string[];
/**
* An optional config object for sharding the operation. If specified, the operation will be sharded
* into multiple invocations. The `count` property specifies the number of shards to create. The
* `shardArgumentFormat` property specifies the format of the argument to pass to the command to
* indicate the shard index and count. The default value is `--shard={shardIndex}/{shardCount}`.
*/
sharding?: IRushPhaseSharding;
/**
* How many concurrency units this operation should take up during execution. The maximum concurrent units is
* determined by the -p flag.
*/
weight?: number;
/**
* If true, this operation can use cobuilds for orchestration without restoring build cache entries.
*/
allowCobuildWithoutCache?: boolean;
}
interface IOldRushProjectJson {
projectOutputFolderNames?: unknown;
phaseOptions?: unknown;
buildCacheOptions?: unknown;
}
const RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile<IRushProjectJson> =
new ProjectConfigurationFile<IRushProjectJson>({
projectRelativeFilePath: `config/${RushConstants.rushProjectConfigFilename}`,
jsonSchemaObject: schemaJson,
propertyInheritance: {
operationSettings: {
inheritanceType: InheritanceType.custom,
inheritanceFunction: (
child: IOperationSettings[] | undefined,
parent: IOperationSettings[] | undefined
) => {
if (!child) {
return parent;
} else if (!parent) {
return child;
} else {
// Merge any properties that need to be merged
const resultOperationSettingsByOperationName: Map<string, IOperationSettings> = new Map();
for (const parentOperationSettings of parent) {
resultOperationSettingsByOperationName.set(
parentOperationSettings.operationName,
parentOperationSettings
);
}
const childEncounteredOperationNames: Set<string> = new Set();
for (const childOperationSettings of child) {
const operationName: string = childOperationSettings.operationName;
if (childEncounteredOperationNames.has(operationName)) {
// If the operation settings already exist, but didn't come from the parent, then
// it shows up multiple times in the child.
const childSourceFilePath: string =
RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(child)!;
throw new Error(
`The operation "${operationName}" occurs multiple times in the "operationSettings" array ` +
`in "${childSourceFilePath}".`
);
}
childEncounteredOperationNames.add(operationName);
let mergedOperationSettings: IOperationSettings | undefined =
resultOperationSettingsByOperationName.get(operationName);
if (mergedOperationSettings) {
// The parent operation settings object already exists
const outputFolderNames: string[] | undefined =
mergedOperationSettings.outputFolderNames && childOperationSettings.outputFolderNames
? [
...mergedOperationSettings.outputFolderNames,
...childOperationSettings.outputFolderNames
]
: mergedOperationSettings.outputFolderNames || childOperationSettings.outputFolderNames;
const dependsOnEnvVars: string[] | undefined =
mergedOperationSettings.dependsOnEnvVars && childOperationSettings.dependsOnEnvVars
? [
...mergedOperationSettings.dependsOnEnvVars,
...childOperationSettings.dependsOnEnvVars
]
: mergedOperationSettings.dependsOnEnvVars || childOperationSettings.dependsOnEnvVars;
mergedOperationSettings = {
...mergedOperationSettings,
...childOperationSettings,
...(outputFolderNames ? { outputFolderNames } : {}),
...(dependsOnEnvVars ? { dependsOnEnvVars } : {})
};
resultOperationSettingsByOperationName.set(operationName, mergedOperationSettings);
} else {
resultOperationSettingsByOperationName.set(operationName, childOperationSettings);
}
}
return Array.from(resultOperationSettingsByOperationName.values());
}
}
},
incrementalBuildIgnoredGlobs: {
inheritanceType: InheritanceType.replace
}
}
});
const OLD_RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile<IOldRushProjectJson> =
new ProjectConfigurationFile<IOldRushProjectJson>({
projectRelativeFilePath: RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath,
jsonSchemaObject: anythingSchemaJson
});
/**
* Use this class to load the "config/rush-project.json" config file.
*
* This file provides project-specific configuration options.
* @alpha
*/
export class RushProjectConfiguration {
private static readonly _configCache: Map<RushConfigurationProject, RushProjectConfiguration | false> =
new Map();
public readonly project: RushConfigurationProject;
/**
* {@inheritdoc _IRushProjectJson.incrementalBuildIgnoredGlobs}
*/
public readonly incrementalBuildIgnoredGlobs: ReadonlyArray<string>;
/**
* {@inheritdoc _IRushProjectJson.disableBuildCacheForProject}
*/
public readonly disableBuildCacheForProject: boolean;
public readonly operationSettingsByOperationName: ReadonlyMap<string, Readonly<IOperationSettings>>;
private readonly _validationCache: WeakSet<object> = new WeakSet();
private constructor(
project: RushConfigurationProject,
rushProjectJson: IRushProjectJson,
operationSettingsByOperationName: ReadonlyMap<string, IOperationSettings>
) {
this.project = project;
this.incrementalBuildIgnoredGlobs = rushProjectJson.incrementalBuildIgnoredGlobs || [];
this.disableBuildCacheForProject = rushProjectJson.disableBuildCacheForProject || false;
this.operationSettingsByOperationName = operationSettingsByOperationName;
}
/**
* Validates that the requested phases are compatible.
* Deferral of this logic to its own method means that Rush no longer eagerly validates
* all defined commands in command-line.json. As such, while validation will be run for a given
* command upon invoking that command, defining overlapping phases in "rush custom-command"
* that are not used by "rush build" will not cause "rush build" to exit with an error.
*/
public validatePhaseConfiguration(phases: Iterable<IPhase>, terminal: ITerminal): void {
// Don't repeatedly validate the same set of phases for the same project.
if (this._validationCache.has(phases)) {
return;
}
const overlappingPathAnalyzer: OverlappingPathAnalyzer<string> = new OverlappingPathAnalyzer<string>();
const { operationSettingsByOperationName, project } = this;
let hasErrors: boolean = false;
for (const phase of phases) {
const operationName: string = phase.name;
const operationSettings: IOperationSettings | undefined =
operationSettingsByOperationName.get(operationName);
if (operationSettings) {
if (operationSettings.outputFolderNames) {
for (const outputFolderName of operationSettings.outputFolderNames) {
const otherOverlappingOperationNames: string[] | undefined =
overlappingPathAnalyzer.addPathAndGetFirstEncounteredLabels(outputFolderName, operationName);
if (otherOverlappingOperationNames) {
const overlapsWithOwnOperation: boolean =
otherOverlappingOperationNames?.includes(operationName);
if (overlapsWithOwnOperation) {
terminal.writeErrorLine(
`The project "${project.packageName}" has a ` +
`"${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath}" configuration that defines an ` +
`operation with overlapping paths in the "outputFolderNames" list. The operation is ` +
`"${operationName}", and the conflicting path is "${outputFolderName}".`
);
} else {
terminal.writeErrorLine(
`The project "${project.packageName}" has a ` +
`"${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath}" configuration that defines ` +
'two operations in the same command whose "outputFolderNames" would overlap. ' +
'Operations outputs in the same command must be disjoint so that they can be independently cached. ' +
`The "${outputFolderName}" path overlaps between these operations: ` +
`"${operationName}", "${otherOverlappingOperationNames.join('", "')}"`
);
}
hasErrors = true;
}
}
}
}
}
this._validationCache.add(phases);
if (hasErrors) {
throw new AlreadyReportedError();
}
}
/**
* Examines the list of source files for the project and the target phase and returns a reason
* why the project cannot enable the build cache for that phase, or undefined if it is safe to so do.
*/
public getCacheDisabledReason(
trackedFileNames: Iterable<string>,
phaseName: string,
isNoOp: boolean
): string | undefined {
const rushConfiguration: RushConfiguration | undefined = this.project.rushConfiguration;
if (rushConfiguration) {
const rushConnect: RushConnect = RushConnect.loadFromLinkStateFile(rushConfiguration);
const subspaceNameList: string[] = Object.keys(rushConnect.rushLinkState ?? {});
if (subspaceNameList.includes(this.project.subspace.subspaceName)) {
return 'Caching has been disabled for this project because it is a linked package.';
}
}
// Skip no-op operations as they won't have any output/cacheable things.
if (isNoOp) {
return undefined;
}
if (this.disableBuildCacheForProject) {
return 'Caching has been disabled for this project.';
}
const operationSettings: IOperationSettings | undefined =
this.operationSettingsByOperationName.get(phaseName);
if (!operationSettings) {
return `This project does not define the caching behavior of the "${phaseName}" command, so caching has been disabled.`;
}
if (operationSettings.disableBuildCacheForOperation) {
return `Caching has been disabled for this project's "${phaseName}" command.`;
}
const { outputFolderNames } = operationSettings;
if (!outputFolderNames) {
return;
}
const normalizedProjectRelativeFolder: string = Path.convertToSlashes(this.project.projectRelativeFolder);
const normalizedOutputFolders: string[] = outputFolderNames.map(
(outputFolderName) => `${normalizedProjectRelativeFolder}/${outputFolderName}/`
);
const inputOutputFiles: string[] = [];
for (const file of trackedFileNames) {
for (const outputFolder of normalizedOutputFolders) {
if (file.startsWith(outputFolder)) {
inputOutputFiles.push(file);
}
}
}
if (inputOutputFiles.length > 0) {
return (
'The following files are used to calculate project state ' +
`and are considered project output: ${inputOutputFiles.join(', ')}`
);
}
}
/**
* Source of truth for whether a project is unable to use the build cache for a given phase.
* As some operations may not have a rush-project.json file defined at all, but may be no-op operations
* we'll want to ignore those completely.
*/
public static getCacheDisabledReasonForProject(options: {
projectConfiguration: RushProjectConfiguration | undefined;
trackedFileNames: Iterable<string>;
phaseName: string;
isNoOp: boolean;
}): string | undefined {
const { projectConfiguration, trackedFileNames, phaseName, isNoOp } = options;
if (isNoOp) {
return undefined;
}
if (!projectConfiguration) {
return (
`Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` +
'or one provided by a rig, so it does not support caching.'
);
}
return projectConfiguration.getCacheDisabledReason(trackedFileNames, phaseName, isNoOp);
}
/**
* Loads the rush-project.json data for the specified project.
*/
public static async tryLoadForProjectAsync(
project: RushConfigurationProject,
terminal: ITerminal
): Promise<RushProjectConfiguration | undefined> {
// false is a signal that the project config does not exist
const cacheEntry: RushProjectConfiguration | false | undefined =
RushProjectConfiguration._configCache.get(project);
if (cacheEntry !== undefined) {
return cacheEntry || undefined;
}
const rushProjectJson: IRushProjectJson | undefined = await this._tryLoadJsonForProjectAsync(
project,
terminal
);
if (rushProjectJson) {
const result: RushProjectConfiguration = RushProjectConfiguration._getRushProjectConfiguration(
project,
rushProjectJson,
terminal
);
RushProjectConfiguration._configCache.set(project, result);
return result;
} else {
RushProjectConfiguration._configCache.set(project, false);
return undefined;
}
}
/**
* Load only the `incrementalBuildIgnoredGlobs` property from the rush-project.json file, skipping
* validation of other parts of the config file.
*
* @remarks
* This function exists to allow the ProjectChangeAnalyzer to load just the ignore globs without
* having to validate the rest of the `rush-project.json` file against the repo's command-line configuration.
*/
public static async tryLoadIgnoreGlobsForProjectAsync(
project: RushConfigurationProject,
terminal: ITerminal
): Promise<ReadonlyArray<string> | undefined> {
const rushProjectJson: IRushProjectJson | undefined = await this._tryLoadJsonForProjectAsync(
project,
terminal
);
return rushProjectJson?.incrementalBuildIgnoredGlobs;
}
/**
* Load the rush-project.json data for all selected projects.
* Validate compatibility of output folders across all selected phases.
*/
public static async tryLoadForProjectsAsync(
projects: Iterable<RushConfigurationProject>,
terminal: ITerminal
): Promise<ReadonlyMap<RushConfigurationProject, RushProjectConfiguration>> {
const result: Map<RushConfigurationProject, RushProjectConfiguration> = new Map();
await Async.forEachAsync(
projects,
async (project: RushConfigurationProject) => {
const projectConfig: RushProjectConfiguration | undefined =
await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal);
if (projectConfig) {
result.set(project, projectConfig);
}
},
{ concurrency: 50 }
);
return result;
}
private static async _tryLoadJsonForProjectAsync(
project: RushConfigurationProject,
terminal: ITerminal
): Promise<IRushProjectJson | undefined> {
const rigConfig: RigConfig = await RigConfig.loadForProjectFolderAsync({
projectFolderPath: project.projectFolder
});
try {
return await RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync(
terminal,
project.projectFolder,
rigConfig
);
} catch (e1) {
// Detect if the project is using the old rush-project.json schema
let oldRushProjectJson: IOldRushProjectJson | undefined;
try {
oldRushProjectJson =
await OLD_RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync(
terminal,
project.projectFolder,
rigConfig
);
} catch (e2) {
// Ignore
}
if (
oldRushProjectJson?.projectOutputFolderNames ||
oldRushProjectJson?.phaseOptions ||
oldRushProjectJson?.buildCacheOptions
) {
throw new Error(
`The ${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file appears to be ` +
'in an outdated format. Please see the UPGRADING.md notes for details. ' +
'Quick link: https://rushjs.io/link/upgrading'
);
} else {
throw e1;
}
}
}
private static _getRushProjectConfiguration(
project: RushConfigurationProject,
rushProjectJson: IRushProjectJson,
terminal: ITerminal
): RushProjectConfiguration {
const operationSettingsByOperationName: Map<string, IOperationSettings> = new Map<
string,
IOperationSettings
>();
let hasErrors: boolean = false;
if (rushProjectJson.operationSettings) {
for (const operationSettings of rushProjectJson.operationSettings) {
const operationName: string = operationSettings.operationName;
const existingOperationSettings: IOperationSettings | undefined =
operationSettingsByOperationName.get(operationName);
if (existingOperationSettings) {
const existingOperationSettingsJsonPath: string | undefined =
RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(existingOperationSettings);
const operationSettingsJsonPath: string | undefined =
RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(operationSettings);
hasErrors = true;
let errorMessage: string =
`The operation "${operationName}" appears multiple times in the "${project.packageName}" project's ` +
`${RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath} file's ` +
'operationSettings property.';
if (existingOperationSettingsJsonPath && operationSettingsJsonPath) {
if (existingOperationSettingsJsonPath !== operationSettingsJsonPath) {
errorMessage +=
` It first appears in "${existingOperationSettingsJsonPath}" and again ` +
`in "${operationSettingsJsonPath}".`;
} else if (
!Path.convertToSlashes(existingOperationSettingsJsonPath).startsWith(
Path.convertToSlashes(project.projectFolder)
)
) {
errorMessage += ` It appears multiple times in "${operationSettingsJsonPath}".`;
}
}
terminal.writeErrorLine(errorMessage);
} else {
operationSettingsByOperationName.set(operationName, operationSettings);
}
}
for (const [operationName, operationSettings] of operationSettingsByOperationName) {
if (operationSettings.sharding?.shardOperationSettings) {
terminal.writeWarningLine(
`DEPRECATED: The "sharding.shardOperationSettings" field is deprecated. Please create a new operation, '${operationName}:shard' to track shard operation settings.`
);
}
}
}
if (hasErrors) {
throw new AlreadyReportedError();
}
return new RushProjectConfiguration(project, rushProjectJson, operationSettingsByOperationName);
}
}