-
Notifications
You must be signed in to change notification settings - Fork 609
/
Copy pathProjectBuilder.ts
466 lines (414 loc) · 17.8 KB
/
ProjectBuilder.ts
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
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as child_process from 'child_process';
import * as path from 'path';
import {
JsonFile,
Text,
FileSystem,
JsonObject,
NewlineKind,
InternalError,
Terminal
} from '@rushstack/node-core-library';
import {
TerminalChunkKind,
TextRewriterTransform,
StderrLineTransform,
SplitterTransform,
DiscardStdoutTransform
} from '@rushstack/terminal';
import { CollatedTerminal } from '@rushstack/stream-collator';
import { RushConfiguration } from '../../api/RushConfiguration';
import { RushConfigurationProject } from '../../api/RushConfigurationProject';
import { Utilities } from '../../utilities/Utilities';
import { TaskStatus } from './TaskStatus';
import { TaskError } from './TaskError';
import { PackageChangeAnalyzer } from '../PackageChangeAnalyzer';
import { BaseBuilder, IBuilderContext } from './BaseBuilder';
import { ProjectLogWritable } from './ProjectLogWritable';
import { ProjectBuildCache } from '../buildCache/ProjectBuildCache';
import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration';
import { ICacheOptionsForCommand, RushProjectConfiguration } from '../../api/RushProjectConfiguration';
import { CollatedTerminalProvider } from '../../utilities/CollatedTerminalProvider';
import { CommandLineConfiguration } from '../../api/CommandLineConfiguration';
import { RushConstants } from '../RushConstants';
export interface IProjectBuildDeps {
files: { [filePath: string]: string };
arguments: string;
}
export interface IProjectBuilderOptions {
rushProject: RushConfigurationProject;
rushConfiguration: RushConfiguration;
buildCacheConfiguration: BuildCacheConfiguration | undefined;
commandToRun: string;
commandName: string;
isIncrementalBuildAllowed: boolean;
packageChangeAnalyzer: PackageChangeAnalyzer;
packageDepsFilename: string;
}
function _areShallowEqual(object1: JsonObject, object2: JsonObject): boolean {
for (const n in object1) {
if (!(n in object2) || object1[n] !== object2[n]) {
return false;
}
}
for (const n in object2) {
if (!(n in object1)) {
return false;
}
}
return true;
}
const UNINITIALIZED: 'UNINITIALIZED' = 'UNINITIALIZED';
type UNINITIALIZED = 'UNINITIALIZED';
/**
* A `BaseBuilder` subclass that builds a Rush project and updates its package-deps-hash
* incremental state.
*/
export class ProjectBuilder extends BaseBuilder {
public get name(): string {
return ProjectBuilder.getTaskName(this._rushProject);
}
public readonly isIncrementalBuildAllowed: boolean;
public hadEmptyScript: boolean = false;
private readonly _rushProject: RushConfigurationProject;
private readonly _rushConfiguration: RushConfiguration;
private readonly _buildCacheConfiguration: BuildCacheConfiguration | undefined;
private readonly _commandName: string;
private readonly _commandToRun: string;
private readonly _packageChangeAnalyzer: PackageChangeAnalyzer;
private readonly _packageDepsFilename: string;
/**
* UNINITIALIZED === we haven't tried to initialize yet
* undefined === we didn't create one because the feature is not enabled
*/
private _projectBuildCache: ProjectBuildCache | undefined | UNINITIALIZED = UNINITIALIZED;
public constructor(options: IProjectBuilderOptions) {
super();
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._packageChangeAnalyzer = options.packageChangeAnalyzer;
this._packageDepsFilename = options.packageDepsFilename;
}
/**
* 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 async executeAsync(context: IBuilderContext): Promise<TaskStatus> {
try {
if (!this._commandToRun) {
this.hadEmptyScript = true;
}
return await this._executeTaskAsync(context);
} catch (error) {
throw new TaskError('executing', error.message);
}
}
public async tryWriteCacheEntryAsync(
terminal: Terminal,
trackedFilePaths: string[] | undefined,
repoCommandLineConfiguration: CommandLineConfiguration | undefined
): Promise<boolean | undefined> {
const projectBuildCache: ProjectBuildCache | undefined = await this._getProjectBuildCacheAsync(
terminal,
trackedFilePaths,
repoCommandLineConfiguration
);
return projectBuildCache?.trySetCacheEntryAsync(terminal);
}
private async _executeTaskAsync(context: IBuilderContext): Promise<TaskStatus> {
// TERMINAL PIPELINE:
//
// +--> quietModeTransform? --> collatedWriter
// |
// normalizeNewlineTransform --1--> stderrLineTransform --2--> removeColorsTransform --> projectLogWritable
// |
// +--> stdioSummarizer
const projectLogWritable: ProjectLogWritable = new ProjectLogWritable(
this._rushProject,
context.collatedWriter.terminal
);
try {
const removeColorsTransform: TextRewriterTransform = new TextRewriterTransform({
destination: projectLogWritable,
removeColors: true,
normalizeNewlines: NewlineKind.OsDefault
});
const splitterTransform2: SplitterTransform = new SplitterTransform({
destinations: [removeColorsTransform, context.stdioSummarizer]
});
const stderrLineTransform: StderrLineTransform = new StderrLineTransform({
destination: splitterTransform2,
newlineKind: NewlineKind.Lf // for StdioSummarizer
});
const quietModeTransform: DiscardStdoutTransform = new DiscardStdoutTransform({
destination: context.collatedWriter
});
const splitterTransform1: SplitterTransform = new SplitterTransform({
destinations: [context.quietMode ? quietModeTransform : context.collatedWriter, stderrLineTransform]
});
const normalizeNewlineTransform: TextRewriterTransform = new TextRewriterTransform({
destination: splitterTransform1,
normalizeNewlines: NewlineKind.Lf,
ensureNewlineAtEnd: true
});
const collatedTerminal: CollatedTerminal = new CollatedTerminal(normalizeNewlineTransform);
const terminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider(collatedTerminal);
const terminal: Terminal = new Terminal(terminalProvider);
let hasWarningOrError: boolean = false;
const projectFolder: string = this._rushProject.projectFolder;
let lastProjectBuildDeps: IProjectBuildDeps | undefined = undefined;
const currentDepsPath: string = path.join(
this._rushProject.projectRushTempFolder,
this._packageDepsFilename
);
if (FileSystem.exists(currentDepsPath)) {
try {
lastProjectBuildDeps = JsonFile.load(currentDepsPath);
} catch (e) {
// Warn and ignore - treat failing to load the file as the project being not built.
terminal.writeWarningLine(
`Warning: error parsing ${this._packageDepsFilename}: ${e}. Ignoring and ` +
`treating the command "${this._commandToRun}" as not run.`
);
}
}
let projectBuildDeps: IProjectBuildDeps | undefined;
let trackedFiles: string[] | undefined;
try {
const fileHashes: Map<string, string> | undefined = await this._packageChangeAnalyzer.getPackageDeps(
this._rushProject.packageName,
terminal
);
if (fileHashes) {
const files: { [filePath: string]: string } = {};
trackedFiles = [];
for (const [filePath, fileHash] of fileHashes) {
files[filePath] = fileHash;
trackedFiles.push(filePath);
}
projectBuildDeps = {
files,
arguments: this._commandToRun
};
} else {
terminal.writeLine(
'Unable to calculate incremental build state. Instead running full rebuild. Ensure Git is present.'
);
}
} catch (error) {
terminal.writeLine(
'Error calculating incremental build state. Instead running full rebuild. ' + error.toString()
);
}
const isPackageUnchanged: boolean = !!(
lastProjectBuildDeps &&
projectBuildDeps &&
projectBuildDeps.arguments === lastProjectBuildDeps.arguments &&
_areShallowEqual(projectBuildDeps.files, lastProjectBuildDeps.files)
);
const projectBuildCache: ProjectBuildCache | undefined = await this._getProjectBuildCacheAsync(
terminal,
trackedFiles,
context.repoCommandLineConfiguration
);
const restoreFromCacheSuccess: boolean | undefined = await projectBuildCache?.tryRestoreFromCacheAsync(
terminal
);
if (restoreFromCacheSuccess) {
return TaskStatus.FromCache;
} else if (isPackageUnchanged && this.isIncrementalBuildAllowed) {
return TaskStatus.Skipped;
} else {
// If the deps file exists, remove it before starting a build.
FileSystem.deleteFile(currentDepsPath);
// TODO: Remove legacyDepsPath with the next major release of Rush
const legacyDepsPath: string = path.join(this._rushProject.projectFolder, 'package-deps.json');
// Delete the legacy package-deps.json
FileSystem.deleteFile(legacyDepsPath);
if (!this._commandToRun) {
// Write deps on success.
if (projectBuildDeps) {
JsonFile.save(projectBuildDeps, currentDepsPath, {
ensureFolderExists: true
});
}
return TaskStatus.Success;
}
// Run the task
terminal.writeLine('Invoking: ' + this._commandToRun);
const task: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(this._commandToRun, {
rushConfiguration: this._rushConfiguration,
workingDirectory: projectFolder,
initCwd: this._rushConfiguration.commonTempFolder,
handleOutput: true,
environmentPathOptions: {
includeProjectBin: true
}
});
// Hook into events, in order to get live streaming of build log
if (task.stdout !== null) {
task.stdout.on('data', (data: Buffer) => {
const text: string = data.toString();
collatedTerminal.writeChunk({ text, kind: TerminalChunkKind.Stdout });
});
}
if (task.stderr !== null) {
task.stderr.on('data', (data: Buffer) => {
const text: string = data.toString();
collatedTerminal.writeChunk({ text, kind: TerminalChunkKind.Stderr });
hasWarningOrError = true;
});
}
let status: TaskStatus = await new Promise(
(resolve: (status: TaskStatus) => void, reject: (error: TaskError) => void) => {
task.on('close', (code: number) => {
try {
if (code !== 0) {
reject(new TaskError('error', `Returned error code: ${code}`));
} else if (hasWarningOrError) {
resolve(TaskStatus.SuccessWithWarning);
} else {
resolve(TaskStatus.Success);
}
} catch (error) {
reject(error);
}
});
}
);
if (status === TaskStatus.Success && projectBuildDeps) {
// Write deps on success.
const writeProjectStatePromise: Promise<boolean> = JsonFile.saveAsync(
projectBuildDeps,
currentDepsPath,
{
ensureFolderExists: true
}
);
const setCacheEntryPromise: Promise<boolean | undefined> = this.tryWriteCacheEntryAsync(
terminal,
trackedFiles,
context.repoCommandLineConfiguration
);
const [, cacheWriteSuccess] = await Promise.all([writeProjectStatePromise, setCacheEntryPromise]);
if (terminalProvider.hasErrors) {
status = TaskStatus.Failure;
} else if (cacheWriteSuccess === false) {
status = TaskStatus.SuccessWithWarning;
}
}
normalizeNewlineTransform.close();
// If the pipeline is wired up correctly, then closing normalizeNewlineTransform should
// have closed projectLogWritable.
if (projectLogWritable.isOpen) {
throw new InternalError('The output file handle was not closed');
}
return status;
}
} finally {
projectLogWritable.close();
}
}
private async _getProjectBuildCacheAsync(
terminal: Terminal,
trackedProjectFiles: string[] | undefined,
commandLineConfiguration: CommandLineConfiguration | undefined
): Promise<ProjectBuildCache | undefined> {
if (this._projectBuildCache === UNINITIALIZED) {
this._projectBuildCache = undefined;
if (this._buildCacheConfiguration && this._buildCacheConfiguration.buildCacheEnabled) {
const projectConfiguration: RushProjectConfiguration | undefined =
await RushProjectConfiguration.tryLoadForProjectAsync(
this._rushProject,
commandLineConfiguration,
terminal
);
if (projectConfiguration) {
if (projectConfiguration.cacheOptions?.disableBuildCache) {
terminal.writeVerboseLine('Caching has been disabled for this project.');
} else {
const commandOptions: ICacheOptionsForCommand | undefined =
projectConfiguration.cacheOptions.optionsForCommandsByName.get(this._commandName);
if (commandOptions?.disableBuildCache) {
terminal.writeVerboseLine(
`Caching has been disabled for this project's "${this._commandName}" command.`
);
} else {
this._projectBuildCache = await ProjectBuildCache.tryGetProjectBuildCache({
projectConfiguration,
buildCacheConfiguration: this._buildCacheConfiguration,
terminal,
command: this._commandToRun,
trackedProjectFiles: trackedProjectFiles,
packageChangeAnalyzer: this._packageChangeAnalyzer
});
}
}
} else {
terminal.writeVerboseLine(
`Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` +
'or one provided by a rig, so it does not support caching.'
);
}
}
}
return this._projectBuildCache;
}
}
/**
* When running a command from the "scripts" block in package.json, if the command
* contains Unix-style path slashes and the OS is Windows, the package managers will
* convert slashes to backslashes. This is a complicated undertaking. For example, they
* need to convert "node_modules/bin/this && ./scripts/that --name keep/this"
* to "node_modules\bin\this && .\scripts\that --name keep/this", and they don't want to
* convert ANY of the slashes in "cmd.exe /c echo a/b". NPM and PNPM use npm-lifecycle for this,
* but it unfortunately has a dependency on the entire node-gyp kitchen sink. Yarn has a
* simplified implementation in fix-cmd-win-slashes.js, but it's not exposed as a library.
*
* Fundamentally NPM's whole feature seems misguided: They start by inviting people to write
* shell scripts that will be executed by wildly different shell languages (e.g. cmd.exe and Bash).
* It's very tricky for a developer to guess what's safe to do without testing every OS.
* Even simple path separators are not portable, so NPM added heuristics to figure out which
* slashes are part of a path or not, and convert them. These workarounds end up having tons
* of special cases. They probably could have implemented their own entire minimal cross-platform
* shell language with less code and less confusion than npm-lifecycle's approach.
*
* We've deprecated shell operators inside package.json. Instead, we advise people to move their
* scripts into conventional script files, and put only a file path in package.json. So, for
* Rush's workaround here, we really only care about supporting the small set of cases seen in the
* unit tests. For anything that doesn't fit those patterns, we leave the string untouched
* (i.e. err on the side of not breaking anything). We could revisit this later if someone
* complains about it, but so far nobody has. :-)
*/
export function convertSlashesForWindows(command: string): string {
// The first group will match everything up to the first space, "&", "|", "<", ">", or quote.
// The second group matches the remainder.
const commandRegExp: RegExp = /^([^\s&|<>"]+)(.*)$/;
const match: RegExpMatchArray | null = commandRegExp.exec(command);
if (match) {
// Example input: "bin/blarg --path ./config/blah.json && a/b"
// commandPart="bin/blarg"
// remainder=" --path ./config/blah.json && a/b"
const commandPart: string = match[1];
const remainder: string = match[2];
// If the command part already contains a backslash, then leave it alone
if (commandPart.indexOf('\\') < 0) {
// Replace all the slashes with backslashes, e.g. to produce:
// "bin\blarg --path ./config/blah.json && a/b"
//
// NOTE: we don't attempt to process the path parameter or stuff after "&&"
return Text.replaceAll(commandPart, '/', '\\') + remainder;
}
}
// Don't change anything
return command;
}