@laurence79/wireit 0.14.13-shared-cache.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1062 -0
  3. package/bin/wireit.js +9 -0
  4. package/lib/analyzer.js +1600 -0
  5. package/lib/caching/cache.js +7 -0
  6. package/lib/caching/github-actions-cache.js +832 -0
  7. package/lib/caching/local-cache.js +78 -0
  8. package/lib/caching/shared-cache.js +256 -0
  9. package/lib/cli-options.js +495 -0
  10. package/lib/cli.js +177 -0
  11. package/lib/config.js +18 -0
  12. package/lib/error.js +160 -0
  13. package/lib/event.js +7 -0
  14. package/lib/execution/base.js +108 -0
  15. package/lib/execution/no-command.js +32 -0
  16. package/lib/execution/service.js +1017 -0
  17. package/lib/execution/standard.js +683 -0
  18. package/lib/executor.js +249 -0
  19. package/lib/fingerprint.js +164 -0
  20. package/lib/ide.js +583 -0
  21. package/lib/language-server.js +135 -0
  22. package/lib/logging/combination-logger.js +41 -0
  23. package/lib/logging/debug-logger.js +43 -0
  24. package/lib/logging/logger.js +38 -0
  25. package/lib/logging/metrics-logger.js +108 -0
  26. package/lib/logging/quiet/run-tracker.js +597 -0
  27. package/lib/logging/quiet/stack-map.js +41 -0
  28. package/lib/logging/quiet/writeover-line.js +197 -0
  29. package/lib/logging/quiet-logger.js +78 -0
  30. package/lib/logging/simple-logger.js +296 -0
  31. package/lib/logging/watch-logger.js +81 -0
  32. package/lib/script-child-process.js +270 -0
  33. package/lib/util/ast.js +71 -0
  34. package/lib/util/async-cache.js +24 -0
  35. package/lib/util/copy.js +120 -0
  36. package/lib/util/deferred.js +35 -0
  37. package/lib/util/delete.js +120 -0
  38. package/lib/util/dispose.js +16 -0
  39. package/lib/util/fs.js +258 -0
  40. package/lib/util/glob.js +255 -0
  41. package/lib/util/line-monitor.js +69 -0
  42. package/lib/util/manifest.js +31 -0
  43. package/lib/util/optimize-mkdirs.js +55 -0
  44. package/lib/util/package-json-reader.js +61 -0
  45. package/lib/util/package-json.js +179 -0
  46. package/lib/util/script-data-dir.js +19 -0
  47. package/lib/util/shuffle.js +16 -0
  48. package/lib/util/unreachable.js +12 -0
  49. package/lib/util/windows.js +87 -0
  50. package/lib/util/worker-pool.js +61 -0
  51. package/lib/watcher.js +396 -0
  52. package/package.json +470 -0
  53. package/schema.json +132 -0
  54. package/wireit.svg +1 -0
@@ -0,0 +1,1600 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import * as pathlib from 'path';
7
+ import { scriptReferenceToString } from './config.js';
8
+ import { findNodeAtLocation } from './util/ast.js';
9
+ import * as fs from './util/fs.js';
10
+ import { CachingPackageJsonReader, } from './util/package-json-reader.js';
11
+ import { IS_WINDOWS } from './util/windows.js';
12
+ /**
13
+ * Globs that will be injected into both `files` and `output`, unless
14
+ * `allowUsuallyExcludedPaths` is `true`.
15
+ *
16
+ * See https://docs.npmjs.com/cli/v9/configuring-npm/package-json#files for the
17
+ * similar list of paths that npm ignores.
18
+ */
19
+ const DEFAULT_EXCLUDE_PATHS = [
20
+ '!.git/',
21
+ '!.hg/',
22
+ '!.svn/',
23
+ '!.wireit/',
24
+ '!.yarn/',
25
+ '!CVS/',
26
+ '!node_modules/',
27
+ ];
28
+ const DEFAULT_LOCKFILES = {
29
+ npm: ['package-lock.json'],
30
+ nodeRun: ['package-lock.json'],
31
+ yarnClassic: ['yarn.lock'],
32
+ yarnBerry: ['yarn.lock'],
33
+ pnpm: ['pnpm-lock.yaml'],
34
+ };
35
+ function isValidWireitScriptCommand(command) {
36
+ return (command === 'wireit' ||
37
+ command === 'yarn run -TB wireit' ||
38
+ // This form is useful when using package managers like yarn or pnpm which
39
+ // do not automatically add all parent directory `node_modules/.bin`
40
+ // folders to PATH.
41
+ /^(\.\.\/)+node_modules\/\.bin\/wireit$/.test(command) ||
42
+ (IS_WINDOWS && /^(\.\.\\)+node_modules\\\.bin\\wireit\.cmd$/.test(command)));
43
+ }
44
+ /**
45
+ * Analyzes and validates a script along with all of its transitive
46
+ * dependencies, producing a build graph that is ready to be executed.
47
+ */
48
+ export class Analyzer {
49
+ #packageJsonReader;
50
+ #placeholders = new Map();
51
+ #ongoingWorkPromises = [];
52
+ #relevantConfigFilePaths = new Set();
53
+ #agent;
54
+ #logger;
55
+ constructor(agent, logger, filesystem) {
56
+ this.#agent = agent;
57
+ this.#logger = logger;
58
+ this.#packageJsonReader = new CachingPackageJsonReader(filesystem);
59
+ }
60
+ /**
61
+ * Analyze every script in each given file and return all diagnostics found.
62
+ */
63
+ async analyzeFiles(files) {
64
+ await Promise.all(files.map(async (f) => {
65
+ const packageDir = pathlib.dirname(f);
66
+ const fileResult = await this.getPackageJson(packageDir);
67
+ if (!fileResult.ok) {
68
+ return; // will get this error below.
69
+ }
70
+ for (const script of fileResult.value.scripts) {
71
+ // This starts analysis of each of the scripts in our root files.
72
+ this.#getPlaceholder({ name: script.name, packageDir });
73
+ }
74
+ }));
75
+ await this.#waitForAnalysisToComplete();
76
+ // Check for cycles.
77
+ for (const info of this.#placeholders.values()) {
78
+ if (info.placeholder.state === 'unvalidated') {
79
+ continue;
80
+ }
81
+ // We don't care about the result, if there's a cycle error it'll
82
+ // be added to the scripts' diagnostics.
83
+ this.#checkForCyclesAndSortDependencies(info.placeholder, new Set(), true);
84
+ }
85
+ return this.#getDiagnostics();
86
+ }
87
+ /**
88
+ * Load the Wireit configuration from the `package.json` corresponding to the
89
+ * given script, repeat for all transitive dependencies, and return a build
90
+ * graph that is ready to be executed.
91
+ *
92
+ * Returns a Failure if the given script or any of its transitive
93
+ * dependencies don't exist, are configured in an invalid way, or if there is
94
+ * a cycle in the dependency graph.
95
+ */
96
+ async analyze(root, extraArgs) {
97
+ this.#logger?.log({
98
+ type: 'info',
99
+ detail: 'analysis-started',
100
+ script: root,
101
+ });
102
+ const analyzeResult = await this.#actuallyAnalyze(root, extraArgs);
103
+ this.#logger?.log({
104
+ type: 'info',
105
+ detail: 'analysis-completed',
106
+ script: root,
107
+ rootScriptConfig: analyzeResult.config.ok
108
+ ? analyzeResult.config.value
109
+ : undefined,
110
+ });
111
+ return analyzeResult;
112
+ }
113
+ async #actuallyAnalyze(root, extraArgs) {
114
+ // We do 2 walks through the dependency graph:
115
+ //
116
+ // 1. A non-deterministically ordered walk, where we traverse edges as soon
117
+ // as they are known, to maximize the parallelism of package.json file
118
+ // read operations.
119
+ //
120
+ // 2. A depth-first walk to detect cycles.
121
+ //
122
+ // We can't check for cycles in the 1st walk because its non-deterministic
123
+ // traversal order means that we could miss certain cycle configurations.
124
+ // Plus by doing a separate DFS walk, we'll always return the exact same
125
+ // trail in the error message for any given graph, instead of an arbitrary
126
+ // one.
127
+ //
128
+ // The way we avoid getting stuck in cycles during the 1st walk is by
129
+ // allocating an initial placeholder object for each script, and caching it
130
+ // by package + name. Then, instead of blocking each script on its
131
+ // dependencies (which would lead to a promise cycle if there was a cycle in
132
+ // the configuration), we wait for all placeholders to upgrade to full
133
+ // configs asynchronously.
134
+ const rootPlaceholder = this.#getPlaceholder(root);
135
+ // Note we can't use Promise.all here, because new promises can be added to
136
+ // the promises array as long as any promise is pending.
137
+ await this.#waitForAnalysisToComplete();
138
+ {
139
+ const errors = await this.#getDiagnostics();
140
+ if (errors.size > 0) {
141
+ return {
142
+ config: { ok: false, error: [...errors] },
143
+ relevantConfigFilePaths: this.#relevantConfigFilePaths,
144
+ };
145
+ }
146
+ }
147
+ // We can safely assume all placeholders have now been upgraded to full
148
+ // configs.
149
+ const rootConfig = rootPlaceholder.placeholder;
150
+ if (rootConfig.state === 'unvalidated') {
151
+ throw new Error(`Internal error: script ${root.name} in ${root.packageDir} is still unvalidated but had no failures`);
152
+ }
153
+ const cycleResult = this.#checkForCyclesAndSortDependencies(rootConfig, new Set(), true);
154
+ if (!cycleResult.ok) {
155
+ return {
156
+ config: { ok: false, error: [cycleResult.error.dependencyFailure] },
157
+ relevantConfigFilePaths: this.#relevantConfigFilePaths,
158
+ };
159
+ }
160
+ const validRootConfig = cycleResult.value;
161
+ validRootConfig.extraArgs = extraArgs;
162
+ return {
163
+ config: { ok: true, value: validRootConfig },
164
+ relevantConfigFilePaths: this.#relevantConfigFilePaths,
165
+ };
166
+ }
167
+ async analyzeIgnoringErrors(scriptReference) {
168
+ await this.analyze(scriptReference, []);
169
+ return this.#getPlaceholder(scriptReference).placeholder;
170
+ }
171
+ async #getDiagnostics() {
172
+ const failures = new Set();
173
+ for await (const failure of this.#packageJsonReader.getFailures()) {
174
+ failures.add(failure);
175
+ }
176
+ for (const info of this.#placeholders.values()) {
177
+ for (const failure of info.placeholder.failures) {
178
+ failures.add(failure);
179
+ }
180
+ }
181
+ for (const failure of failures) {
182
+ const supercedes = failure
183
+ .supercedes;
184
+ if (supercedes !== undefined) {
185
+ failures.delete(supercedes);
186
+ }
187
+ }
188
+ return failures;
189
+ }
190
+ async #waitForAnalysisToComplete() {
191
+ while (this.#ongoingWorkPromises.length > 0) {
192
+ const promise = this.#ongoingWorkPromises[this.#ongoingWorkPromises.length - 1];
193
+ await promise;
194
+ // Need to be careful here. The contract of this method is that it does
195
+ // not return until all pending analysis work is completed.
196
+ // If there are multiple concurrent callers to this method, we want to
197
+ // make sure that none of them hide any of the pending work from each
198
+ // other by removing a promise from the array before it has settled.
199
+ // So we first await the promise, and then remove it from the array if
200
+ // it's still the final element.
201
+ // It might not be the final element because another caller removed it,
202
+ // or because more work was added onto the end of the array. Either
203
+ // case is fine.
204
+ if (promise ===
205
+ this.#ongoingWorkPromises[this.#ongoingWorkPromises.length - 1]) {
206
+ void this.#ongoingWorkPromises.pop();
207
+ }
208
+ }
209
+ }
210
+ async getPackageJson(packageDir) {
211
+ this.#relevantConfigFilePaths.add(pathlib.join(packageDir, 'package.json'));
212
+ return this.#packageJsonReader.read(packageDir);
213
+ }
214
+ /**
215
+ * Adds the given package.json files to the known set, and analyzes all
216
+ * scripts reachable from any of them, recursively.
217
+ *
218
+ * Useful for whole program analysis, e.g. for "find all references" in the
219
+ * IDE.
220
+ */
221
+ async analyzeAllScripts(packageJsonPaths) {
222
+ const done = new Set();
223
+ const todo = [];
224
+ for (const file of packageJsonPaths) {
225
+ const packageDir = pathlib.dirname(file);
226
+ const packageJsonResult = await this.getPackageJson(packageDir);
227
+ if (!packageJsonResult.ok) {
228
+ continue;
229
+ }
230
+ for (const script of packageJsonResult.value.scripts) {
231
+ todo.push({ name: script.name, packageDir });
232
+ }
233
+ }
234
+ while (true) {
235
+ await Promise.all(todo.map(async (ref) => {
236
+ await this.analyze(ref, undefined);
237
+ done.add(scriptReferenceToString(ref));
238
+ }));
239
+ todo.length = 0;
240
+ for (const info of this.#placeholders.values()) {
241
+ if (info.placeholder.state === 'unvalidated' &&
242
+ !done.has(scriptReferenceToString(info.placeholder))) {
243
+ todo.push(info.placeholder);
244
+ }
245
+ }
246
+ if (todo.length === 0) {
247
+ break;
248
+ }
249
+ }
250
+ return this.#placeholders.values();
251
+ }
252
+ /**
253
+ * Create or return a cached placeholder script configuration object for the
254
+ * given script reference.
255
+ */
256
+ #getPlaceholder(reference) {
257
+ const scriptKey = scriptReferenceToString(reference);
258
+ let placeholderInfo = this.#placeholders.get(scriptKey);
259
+ if (placeholderInfo === undefined) {
260
+ const placeholder = {
261
+ ...reference,
262
+ state: 'unvalidated',
263
+ failures: [],
264
+ };
265
+ placeholderInfo = {
266
+ placeholder: placeholder,
267
+ upgradeComplete: this.#upgradePlaceholder(placeholder),
268
+ };
269
+ this.#placeholders.set(scriptKey, placeholderInfo);
270
+ this.#ongoingWorkPromises.push(placeholderInfo.upgradeComplete);
271
+ }
272
+ return placeholderInfo;
273
+ }
274
+ /**
275
+ * In-place upgrade the given placeholder script configuration object to a
276
+ * full configuration, by reading its package.json file.
277
+ *
278
+ * Note this method does not block on the script's dependencies being
279
+ * upgraded; dependencies are upgraded asynchronously.
280
+ */
281
+ async #upgradePlaceholder(placeholder) {
282
+ const packageJsonResult = await this.getPackageJson(placeholder.packageDir);
283
+ if (!packageJsonResult.ok) {
284
+ placeholder.failures.push(packageJsonResult.error);
285
+ return undefined;
286
+ }
287
+ const packageJson = packageJsonResult.value;
288
+ placeholder.failures.push(...packageJson.failures);
289
+ const syntaxInfo = packageJson.getScriptInfo(placeholder.name);
290
+ if (syntaxInfo?.wireitConfigNode !== undefined) {
291
+ await this.#handleWireitScript(placeholder, packageJson, syntaxInfo, syntaxInfo.wireitConfigNode);
292
+ }
293
+ else if (syntaxInfo?.scriptNode !== undefined) {
294
+ this.#handlePlainNpmScript(placeholder, packageJson, syntaxInfo.scriptNode);
295
+ }
296
+ else {
297
+ placeholder.failures.push({
298
+ type: 'failure',
299
+ reason: 'script-not-found',
300
+ script: placeholder,
301
+ diagnostic: {
302
+ severity: 'error',
303
+ message: `Script "${placeholder.name}" not found in the scripts section of this package.json.`,
304
+ location: {
305
+ file: packageJson.jsonFile,
306
+ range: { offset: 0, length: 0 },
307
+ },
308
+ },
309
+ });
310
+ }
311
+ return undefined;
312
+ }
313
+ #handlePlainNpmScript(placeholder, packageJson, scriptCommand) {
314
+ if (isValidWireitScriptCommand(scriptCommand.value)) {
315
+ placeholder.failures.push({
316
+ type: 'failure',
317
+ reason: 'invalid-config-syntax',
318
+ script: placeholder,
319
+ diagnostic: {
320
+ severity: 'error',
321
+ message: `This script is configured to run wireit but it has no config in the wireit section of this package.json file`,
322
+ location: {
323
+ file: packageJson.jsonFile,
324
+ range: {
325
+ length: scriptCommand.length,
326
+ offset: scriptCommand.offset,
327
+ },
328
+ },
329
+ },
330
+ });
331
+ }
332
+ // It's important to in-place update the placeholder object, instead of
333
+ // creating a new object, because other configs may be referencing this
334
+ // exact object in their dependencies.
335
+ const remainingConfig = {
336
+ ...placeholder,
337
+ state: 'locally-valid',
338
+ failures: placeholder.failures,
339
+ command: scriptCommand,
340
+ extraArgs: undefined,
341
+ dependencies: [],
342
+ files: undefined,
343
+ output: undefined,
344
+ clean: false,
345
+ service: undefined,
346
+ scriptAstNode: scriptCommand,
347
+ configAstNode: undefined,
348
+ declaringFile: packageJson.jsonFile,
349
+ services: [],
350
+ env: {},
351
+ };
352
+ Object.assign(placeholder, remainingConfig);
353
+ }
354
+ async #handleWireitScript(placeholder, packageJson, syntaxInfo, wireitConfig) {
355
+ const scriptCommand = syntaxInfo.scriptNode;
356
+ if (scriptCommand !== undefined &&
357
+ !isValidWireitScriptCommand(scriptCommand.value)) {
358
+ {
359
+ const configName = wireitConfig.name;
360
+ placeholder.failures.push({
361
+ type: 'failure',
362
+ reason: 'script-not-wireit',
363
+ script: placeholder,
364
+ diagnostic: {
365
+ message: `This command should just be "wireit", ` +
366
+ `as this script is configured in the wireit section.`,
367
+ severity: 'warning',
368
+ location: {
369
+ file: packageJson.jsonFile,
370
+ range: {
371
+ length: scriptCommand.length,
372
+ offset: scriptCommand.offset,
373
+ },
374
+ },
375
+ supplementalLocations: [
376
+ {
377
+ message: `The wireit config is here.`,
378
+ location: {
379
+ file: packageJson.jsonFile,
380
+ range: {
381
+ length: configName.length,
382
+ offset: configName.offset,
383
+ },
384
+ },
385
+ },
386
+ ],
387
+ },
388
+ });
389
+ }
390
+ }
391
+ const { dependencies, encounteredError: dependenciesErrored } = this.#processDependencies(placeholder, packageJson, syntaxInfo);
392
+ let command;
393
+ let commandError = false;
394
+ const commandAst = findNodeAtLocation(wireitConfig, ['command']);
395
+ if (commandAst !== undefined) {
396
+ const result = failUnlessNonBlankString(commandAst, packageJson.jsonFile);
397
+ if (result.ok) {
398
+ command = result.value;
399
+ }
400
+ else {
401
+ commandError = true;
402
+ placeholder.failures.push(result.error);
403
+ }
404
+ }
405
+ const allowUsuallyExcludedPaths = this.#processAllowUsuallyExcludedPaths(placeholder, packageJson, syntaxInfo);
406
+ const files = this.#processFiles(placeholder, packageJson, syntaxInfo, allowUsuallyExcludedPaths);
407
+ if (dependencies.length === 0 &&
408
+ !dependenciesErrored &&
409
+ command === undefined &&
410
+ !commandError &&
411
+ (files === undefined || files.values.length === 0)) {
412
+ placeholder.failures.push({
413
+ type: 'failure',
414
+ reason: 'invalid-config-syntax',
415
+ script: placeholder,
416
+ diagnostic: {
417
+ severity: 'error',
418
+ message: `A wireit config must set at least one of "command", "dependencies", or "files". Otherwise there is nothing for wireit to do.`,
419
+ location: {
420
+ file: packageJson.jsonFile,
421
+ range: {
422
+ length: wireitConfig.name.length,
423
+ offset: wireitConfig.name.offset,
424
+ },
425
+ },
426
+ },
427
+ });
428
+ }
429
+ const output = this.#processOutput(placeholder, packageJson, syntaxInfo, command, allowUsuallyExcludedPaths);
430
+ const clean = this.#processClean(placeholder, packageJson, syntaxInfo);
431
+ const service = this.#processService(placeholder, packageJson, syntaxInfo, command, output);
432
+ await this.#processPackageLocks(placeholder, packageJson, syntaxInfo, files);
433
+ const env = this.#processEnv(placeholder, packageJson, syntaxInfo, command);
434
+ if (placeholder.failures.length > 0) {
435
+ // A script with locally-determined errors doesn't get upgraded to
436
+ // locally-valid.
437
+ return;
438
+ }
439
+ // It's important to in-place update the placeholder object, instead of
440
+ // creating a new object, because other configs may be referencing this
441
+ // exact object in their dependencies.
442
+ const remainingConfig = {
443
+ ...placeholder,
444
+ state: 'locally-valid',
445
+ failures: placeholder.failures,
446
+ command,
447
+ extraArgs: undefined,
448
+ dependencies,
449
+ files,
450
+ output,
451
+ clean,
452
+ service,
453
+ scriptAstNode: scriptCommand,
454
+ configAstNode: wireitConfig,
455
+ declaringFile: packageJson.jsonFile,
456
+ services: [],
457
+ env,
458
+ };
459
+ Object.assign(placeholder, remainingConfig);
460
+ }
461
+ #processDependencies(placeholder, packageJson, scriptInfo) {
462
+ const dependencies = [];
463
+ const dependenciesAst = scriptInfo.wireitConfigNode &&
464
+ findNodeAtLocation(scriptInfo.wireitConfigNode, ['dependencies']);
465
+ let encounteredError = false;
466
+ if (dependenciesAst === undefined) {
467
+ return { dependencies, encounteredError };
468
+ }
469
+ const result = failUnlessArray(dependenciesAst, packageJson.jsonFile);
470
+ if (!result.ok) {
471
+ encounteredError = true;
472
+ placeholder.failures.push(result.error);
473
+ return { dependencies, encounteredError };
474
+ }
475
+ // Error if the same dependency is declared multiple times. Duplicate
476
+ // dependencies aren't necessarily a serious problem (since we already
477
+ // prevent double-analysis here, and double-analysis in the Executor), but
478
+ // they may indicate that the user has made a mistake (e.g. maybe they
479
+ // meant a different dependency).
480
+ const uniqueDependencies = new Map();
481
+ const children = dependenciesAst.children ?? [];
482
+ for (const maybeUnresolved of children) {
483
+ // A dependency can be either a plain string, or an object with a "script"
484
+ // property plus optional extra annotations.
485
+ let specifierResult;
486
+ let cascade = true; // Default;
487
+ if (maybeUnresolved.type === 'string') {
488
+ specifierResult = failUnlessNonBlankString(maybeUnresolved, packageJson.jsonFile);
489
+ if (!specifierResult.ok) {
490
+ encounteredError = true;
491
+ placeholder.failures.push(specifierResult.error);
492
+ continue;
493
+ }
494
+ }
495
+ else if (maybeUnresolved.type === 'object') {
496
+ specifierResult = findNodeAtLocation(maybeUnresolved, ['script']);
497
+ if (specifierResult === undefined) {
498
+ encounteredError = true;
499
+ placeholder.failures.push({
500
+ type: 'failure',
501
+ reason: 'invalid-config-syntax',
502
+ script: { packageDir: pathlib.dirname(packageJson.jsonFile.path) },
503
+ diagnostic: {
504
+ severity: 'error',
505
+ message: `Dependency object must set a "script" property.`,
506
+ location: {
507
+ file: packageJson.jsonFile,
508
+ range: {
509
+ offset: maybeUnresolved.offset,
510
+ length: maybeUnresolved.length,
511
+ },
512
+ },
513
+ },
514
+ });
515
+ continue;
516
+ }
517
+ specifierResult = failUnlessNonBlankString(specifierResult, packageJson.jsonFile);
518
+ if (!specifierResult.ok) {
519
+ encounteredError = true;
520
+ placeholder.failures.push(specifierResult.error);
521
+ continue;
522
+ }
523
+ const cascadeResult = findNodeAtLocation(maybeUnresolved, ['cascade']);
524
+ if (cascadeResult !== undefined) {
525
+ if (cascadeResult.value === true || cascadeResult.value === false) {
526
+ cascade = cascadeResult.value;
527
+ }
528
+ else {
529
+ encounteredError = true;
530
+ placeholder.failures.push({
531
+ type: 'failure',
532
+ reason: 'invalid-config-syntax',
533
+ script: { packageDir: pathlib.dirname(packageJson.jsonFile.path) },
534
+ diagnostic: {
535
+ severity: 'error',
536
+ message: `The "cascade" property must be either true or false.`,
537
+ location: {
538
+ file: packageJson.jsonFile,
539
+ range: {
540
+ offset: cascadeResult.offset,
541
+ length: cascadeResult.length,
542
+ },
543
+ },
544
+ },
545
+ });
546
+ continue;
547
+ }
548
+ }
549
+ }
550
+ else {
551
+ encounteredError = true;
552
+ placeholder.failures.push({
553
+ type: 'failure',
554
+ reason: 'invalid-config-syntax',
555
+ script: { packageDir: pathlib.dirname(packageJson.jsonFile.path) },
556
+ diagnostic: {
557
+ severity: 'error',
558
+ message: `Expected a string or object, but was ${maybeUnresolved.type}.`,
559
+ location: {
560
+ file: packageJson.jsonFile,
561
+ range: {
562
+ offset: maybeUnresolved.offset,
563
+ length: maybeUnresolved.length,
564
+ },
565
+ },
566
+ },
567
+ });
568
+ continue;
569
+ }
570
+ const unresolved = specifierResult.value;
571
+ const result = this.#resolveDependency(unresolved, placeholder, packageJson.jsonFile);
572
+ if (!result.ok) {
573
+ encounteredError = true;
574
+ placeholder.failures.push(result.error);
575
+ continue;
576
+ }
577
+ for (const resolved of result.value) {
578
+ const uniqueKey = scriptReferenceToString(resolved);
579
+ const duplicate = uniqueDependencies.get(uniqueKey);
580
+ if (duplicate !== undefined) {
581
+ encounteredError = true;
582
+ placeholder.failures.push({
583
+ type: 'failure',
584
+ reason: 'duplicate-dependency',
585
+ script: placeholder,
586
+ dependency: resolved,
587
+ diagnostic: {
588
+ severity: 'error',
589
+ message: `This dependency is listed multiple times`,
590
+ location: {
591
+ file: packageJson.jsonFile,
592
+ range: {
593
+ offset: unresolved.offset,
594
+ length: unresolved.length,
595
+ },
596
+ },
597
+ supplementalLocations: [
598
+ {
599
+ message: `The dependency was first listed here.`,
600
+ location: {
601
+ file: packageJson.jsonFile,
602
+ range: {
603
+ offset: duplicate.offset,
604
+ length: duplicate.length,
605
+ },
606
+ },
607
+ },
608
+ ],
609
+ },
610
+ });
611
+ }
612
+ uniqueDependencies.set(uniqueKey, unresolved);
613
+ const placeHolderInfo = this.#getPlaceholder(resolved);
614
+ dependencies.push({
615
+ specifier: unresolved,
616
+ config: placeHolderInfo.placeholder,
617
+ cascade,
618
+ });
619
+ this.#ongoingWorkPromises.push((async () => {
620
+ await placeHolderInfo.upgradeComplete;
621
+ const failures = placeHolderInfo.placeholder.failures;
622
+ for (const failure of failures) {
623
+ if (failure.reason === 'script-not-found') {
624
+ const hasColon = unresolved.value.includes(':');
625
+ let offset;
626
+ let length;
627
+ if (!hasColon ||
628
+ resolved.packageDir === placeholder.packageDir) {
629
+ offset = unresolved.offset;
630
+ length = unresolved.length;
631
+ }
632
+ else {
633
+ // Skip past the colon
634
+ const colonOffsetInString = packageJson.jsonFile.contents
635
+ .slice(unresolved.offset)
636
+ .indexOf(':');
637
+ offset = unresolved.offset + colonOffsetInString + 1;
638
+ length = unresolved.length - colonOffsetInString - 2;
639
+ }
640
+ placeholder.failures.push({
641
+ type: 'failure',
642
+ reason: 'dependency-on-missing-script',
643
+ script: placeholder,
644
+ supercedes: failure,
645
+ diagnostic: {
646
+ severity: 'error',
647
+ message: `Cannot find script named ${JSON.stringify(resolved.name)} in package "${resolved.packageDir}"`,
648
+ location: {
649
+ file: packageJson.jsonFile,
650
+ range: { offset, length },
651
+ },
652
+ },
653
+ });
654
+ }
655
+ else if (failure.reason === 'missing-package-json') {
656
+ // Skip the opening "
657
+ const offset = unresolved.offset + 1;
658
+ // Take everything up to the first colon, but look in
659
+ // the original source, to avoid getting confused by escape
660
+ // sequences, which have a different length before and after
661
+ // encoding.
662
+ const length = packageJson.jsonFile.contents
663
+ .slice(offset)
664
+ .indexOf(':');
665
+ const range = { offset, length };
666
+ placeholder.failures.push({
667
+ type: 'failure',
668
+ reason: 'dependency-on-missing-package-json',
669
+ script: placeholder,
670
+ supercedes: failure,
671
+ diagnostic: {
672
+ severity: 'error',
673
+ message: `package.json file missing: "${pathlib.join(resolved.packageDir, 'package.json')}"`,
674
+ location: { file: packageJson.jsonFile, range },
675
+ },
676
+ });
677
+ }
678
+ }
679
+ return undefined;
680
+ })());
681
+ }
682
+ }
683
+ return { dependencies, encounteredError };
684
+ }
685
+ #processAllowUsuallyExcludedPaths(placeholder, packageJson, syntaxInfo) {
686
+ const defaultValue = false;
687
+ if (syntaxInfo.wireitConfigNode == null) {
688
+ return defaultValue;
689
+ }
690
+ const node = findNodeAtLocation(syntaxInfo.wireitConfigNode, [
691
+ 'allowUsuallyExcludedPaths',
692
+ ]);
693
+ if (node === undefined) {
694
+ return defaultValue;
695
+ }
696
+ if (node.value === true || node.value === false) {
697
+ return node.value;
698
+ }
699
+ placeholder.failures.push({
700
+ type: 'failure',
701
+ reason: 'invalid-config-syntax',
702
+ script: placeholder,
703
+ diagnostic: {
704
+ severity: 'error',
705
+ message: `Must be true or false`,
706
+ location: {
707
+ file: packageJson.jsonFile,
708
+ range: { length: node.length, offset: node.offset },
709
+ },
710
+ },
711
+ });
712
+ return defaultValue;
713
+ }
714
+ #processFiles(placeholder, packageJson, syntaxInfo, allowUsuallyExcludedPaths) {
715
+ if (syntaxInfo.wireitConfigNode === undefined) {
716
+ return;
717
+ }
718
+ const filesNode = findNodeAtLocation(syntaxInfo.wireitConfigNode, [
719
+ 'files',
720
+ ]);
721
+ if (filesNode === undefined) {
722
+ return;
723
+ }
724
+ const values = [];
725
+ const result = failUnlessArray(filesNode, packageJson.jsonFile);
726
+ if (!result.ok) {
727
+ placeholder.failures.push(result.error);
728
+ return;
729
+ }
730
+ const children = filesNode.children ?? [];
731
+ for (const file of children) {
732
+ const result = failUnlessNonBlankString(file, packageJson.jsonFile);
733
+ if (!result.ok) {
734
+ placeholder.failures.push(result.error);
735
+ continue;
736
+ }
737
+ values.push(result.value.value);
738
+ }
739
+ if (!allowUsuallyExcludedPaths && values.length > 0) {
740
+ values.push(...DEFAULT_EXCLUDE_PATHS);
741
+ }
742
+ return { node: filesNode, values };
743
+ }
744
+ #processOutput(placeholder, packageJson, syntaxInfo, command, allowUsuallyExcludedPaths) {
745
+ if (syntaxInfo.wireitConfigNode === undefined) {
746
+ return;
747
+ }
748
+ const outputNode = findNodeAtLocation(syntaxInfo.wireitConfigNode, [
749
+ 'output',
750
+ ]);
751
+ if (outputNode === undefined) {
752
+ return;
753
+ }
754
+ if (command === undefined) {
755
+ placeholder.failures.push({
756
+ type: 'failure',
757
+ reason: 'invalid-config-syntax',
758
+ script: placeholder,
759
+ diagnostic: {
760
+ severity: 'error',
761
+ message: `"output" can only be set if "command" is also set.`,
762
+ location: {
763
+ file: packageJson.jsonFile,
764
+ range: {
765
+ // Highlight the whole `"output": []` part.
766
+ length: (outputNode.parent ?? outputNode).length,
767
+ offset: (outputNode.parent ?? outputNode).offset,
768
+ },
769
+ },
770
+ },
771
+ });
772
+ }
773
+ const values = [];
774
+ const result = failUnlessArray(outputNode, packageJson.jsonFile);
775
+ if (!result.ok) {
776
+ placeholder.failures.push(result.error);
777
+ return;
778
+ }
779
+ const children = outputNode.children ?? [];
780
+ for (const anOutput of children) {
781
+ const result = failUnlessNonBlankString(anOutput, packageJson.jsonFile);
782
+ if (!result.ok) {
783
+ placeholder.failures.push(result.error);
784
+ continue;
785
+ }
786
+ values.push(result.value.value);
787
+ }
788
+ if (!allowUsuallyExcludedPaths && values.length > 0) {
789
+ values.push(...DEFAULT_EXCLUDE_PATHS);
790
+ }
791
+ return { node: outputNode, values };
792
+ }
793
+ #processClean(placeholder, packageJson, syntaxInfo) {
794
+ const defaultValue = true;
795
+ if (syntaxInfo.wireitConfigNode == null) {
796
+ return defaultValue;
797
+ }
798
+ const clean = findNodeAtLocation(syntaxInfo.wireitConfigNode, ['clean']);
799
+ if (clean !== undefined &&
800
+ clean.value !== true &&
801
+ clean.value !== false &&
802
+ clean.value !== 'if-file-deleted') {
803
+ placeholder.failures.push({
804
+ type: 'failure',
805
+ reason: 'invalid-config-syntax',
806
+ script: placeholder,
807
+ diagnostic: {
808
+ severity: 'error',
809
+ message: `The "clean" property must be either true, false, or "if-file-deleted".`,
810
+ location: {
811
+ file: packageJson.jsonFile,
812
+ range: { length: clean.length, offset: clean.offset },
813
+ },
814
+ },
815
+ });
816
+ return defaultValue;
817
+ }
818
+ return clean?.value ?? defaultValue;
819
+ }
820
+ #processService(placeholder, packageJson, syntaxInfo, command, output) {
821
+ if (syntaxInfo.wireitConfigNode === undefined) {
822
+ return undefined;
823
+ }
824
+ const serviceNode = findNodeAtLocation(syntaxInfo.wireitConfigNode, [
825
+ 'service',
826
+ ]);
827
+ if (serviceNode === undefined) {
828
+ return undefined;
829
+ }
830
+ if (serviceNode.value === false) {
831
+ return undefined;
832
+ }
833
+ if (serviceNode.value !== true && serviceNode.type !== 'object') {
834
+ placeholder.failures.push({
835
+ type: 'failure',
836
+ reason: 'invalid-config-syntax',
837
+ script: placeholder,
838
+ diagnostic: {
839
+ severity: 'error',
840
+ message: `The "service" property must be either true, false, or an object.`,
841
+ location: {
842
+ file: packageJson.jsonFile,
843
+ range: { length: serviceNode.length, offset: serviceNode.offset },
844
+ },
845
+ },
846
+ });
847
+ return undefined;
848
+ }
849
+ let lineMatches = undefined;
850
+ if (serviceNode.type === 'object') {
851
+ const waitForNode = findNodeAtLocation(serviceNode, ['readyWhen']);
852
+ if (waitForNode !== undefined) {
853
+ if (waitForNode.type !== 'object') {
854
+ placeholder.failures.push({
855
+ type: 'failure',
856
+ reason: 'invalid-config-syntax',
857
+ script: placeholder,
858
+ diagnostic: {
859
+ severity: 'error',
860
+ message: `Expected an object.`,
861
+ location: {
862
+ file: packageJson.jsonFile,
863
+ range: { length: serviceNode.length, offset: serviceNode.offset },
864
+ },
865
+ },
866
+ });
867
+ }
868
+ else {
869
+ const lineMatchesNode = findNodeAtLocation(waitForNode, [
870
+ 'lineMatches',
871
+ ]);
872
+ if (lineMatchesNode !== undefined) {
873
+ if (lineMatchesNode.type !== 'string') {
874
+ placeholder.failures.push({
875
+ type: 'failure',
876
+ reason: 'invalid-config-syntax',
877
+ script: placeholder,
878
+ diagnostic: {
879
+ severity: 'error',
880
+ message: `Expected a string.`,
881
+ location: {
882
+ file: packageJson.jsonFile,
883
+ range: {
884
+ length: lineMatchesNode.length,
885
+ offset: lineMatchesNode.offset,
886
+ },
887
+ },
888
+ },
889
+ });
890
+ }
891
+ else {
892
+ try {
893
+ lineMatches = new RegExp(lineMatchesNode.value);
894
+ }
895
+ catch (error) {
896
+ placeholder.failures.push({
897
+ type: 'failure',
898
+ reason: 'invalid-config-syntax',
899
+ script: placeholder,
900
+ diagnostic: {
901
+ severity: 'error',
902
+ message: String(error),
903
+ location: {
904
+ file: packageJson.jsonFile,
905
+ range: {
906
+ length: lineMatchesNode.length,
907
+ offset: lineMatchesNode.offset,
908
+ },
909
+ },
910
+ },
911
+ });
912
+ }
913
+ }
914
+ }
915
+ }
916
+ }
917
+ }
918
+ if (command === undefined) {
919
+ placeholder.failures.push({
920
+ type: 'failure',
921
+ reason: 'invalid-config-syntax',
922
+ script: placeholder,
923
+ diagnostic: {
924
+ severity: 'error',
925
+ message: `A "service" script must have a "command".`,
926
+ location: {
927
+ file: packageJson.jsonFile,
928
+ range: {
929
+ length: serviceNode.length,
930
+ offset: serviceNode.offset,
931
+ },
932
+ },
933
+ },
934
+ });
935
+ }
936
+ if (output !== undefined) {
937
+ placeholder.failures.push({
938
+ type: 'failure',
939
+ reason: 'invalid-config-syntax',
940
+ script: placeholder,
941
+ diagnostic: {
942
+ severity: 'error',
943
+ message: `A "service" script cannot have an "output".`,
944
+ location: {
945
+ file: packageJson.jsonFile,
946
+ range: {
947
+ length: output.node.length,
948
+ offset: output.node.offset,
949
+ },
950
+ },
951
+ },
952
+ });
953
+ }
954
+ return { readyWhen: { lineMatches } };
955
+ }
956
+ async #processPackageLocks(placeholder, packageJson, syntaxInfo, files) {
957
+ if (syntaxInfo.wireitConfigNode === undefined) {
958
+ return;
959
+ }
960
+ const packageLocksNode = findNodeAtLocation(syntaxInfo.wireitConfigNode, [
961
+ 'packageLocks',
962
+ ]);
963
+ let packageLocks;
964
+ if (packageLocksNode !== undefined) {
965
+ const result = failUnlessArray(packageLocksNode, packageJson.jsonFile);
966
+ if (!result.ok) {
967
+ placeholder.failures.push(result.error);
968
+ }
969
+ else {
970
+ packageLocks = { node: packageLocksNode, values: [] };
971
+ const children = packageLocksNode.children ?? [];
972
+ for (const maybeFilename of children) {
973
+ const result = failUnlessNonBlankString(maybeFilename, packageJson.jsonFile);
974
+ if (!result.ok) {
975
+ placeholder.failures.push(result.error);
976
+ continue;
977
+ }
978
+ const filename = result.value;
979
+ if (filename.value !== pathlib.basename(filename.value)) {
980
+ placeholder.failures.push({
981
+ type: 'failure',
982
+ reason: 'invalid-config-syntax',
983
+ script: placeholder,
984
+ diagnostic: {
985
+ severity: 'error',
986
+ message: `A package lock must be a filename, not a path`,
987
+ location: {
988
+ file: packageJson.jsonFile,
989
+ range: { length: filename.length, offset: filename.offset },
990
+ },
991
+ },
992
+ });
993
+ continue;
994
+ }
995
+ packageLocks.values.push(filename.value);
996
+ }
997
+ }
998
+ }
999
+ if (
1000
+ // There's no reason to check package locks when "files" is undefined,
1001
+ // because scripts will always run in that case anyway.
1002
+ files !== undefined &&
1003
+ // An explicitly empty "packageLocks" array disables package lock checking
1004
+ // entirely.
1005
+ packageLocks?.values.length !== 0) {
1006
+ const lockfileNames = packageLocks?.values ?? DEFAULT_LOCKFILES[this.#agent];
1007
+ // Generate "package-lock.json", "../package-lock.json",
1008
+ // "../../package-lock.json" etc. all the way up to the root of the
1009
+ // filesystem, because that's how Node package resolution works.
1010
+ const depth = placeholder.packageDir.split(pathlib.sep).length;
1011
+ const paths = [];
1012
+ for (let i = 0; i < depth; i++) {
1013
+ // Glob patterns are specified with forward-slash delimiters, even on
1014
+ // Windows.
1015
+ const prefix = Array(i + 1).join('../');
1016
+ for (const lockfileName of lockfileNames) {
1017
+ paths.push(prefix + lockfileName);
1018
+ }
1019
+ }
1020
+ // Only add the package locks that currently exist to the list of files
1021
+ // for this script. This way, in watch mode we won't create watchers for
1022
+ // all parent directories, just in case a package lock file is created at
1023
+ // some later time during watch, which is a rare and not especially
1024
+ // important event. Creating watchers for all parent directories is
1025
+ // potentially expensive, and on Windows will also result in occasional
1026
+ // errors.
1027
+ const existing = await Promise.all(paths.map(async (path) => {
1028
+ try {
1029
+ await fs.access(pathlib.join(placeholder.packageDir, path));
1030
+ return path;
1031
+ }
1032
+ catch {
1033
+ return undefined;
1034
+ }
1035
+ }));
1036
+ for (const path of existing) {
1037
+ if (path !== undefined) {
1038
+ files.values.push(path);
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ #processEnv(placeholder, packageJson, syntaxInfo, command) {
1044
+ if (syntaxInfo.wireitConfigNode === undefined) {
1045
+ return {};
1046
+ }
1047
+ const envNode = findNodeAtLocation(syntaxInfo.wireitConfigNode, ['env']);
1048
+ if (envNode === undefined) {
1049
+ return {};
1050
+ }
1051
+ if (command === undefined) {
1052
+ placeholder.failures.push({
1053
+ type: 'failure',
1054
+ reason: 'invalid-config-syntax',
1055
+ script: placeholder,
1056
+ diagnostic: {
1057
+ severity: 'error',
1058
+ message: 'Can\'t set "env" unless "command" is set',
1059
+ location: {
1060
+ file: packageJson.jsonFile,
1061
+ range: { length: envNode.length, offset: envNode.offset },
1062
+ },
1063
+ },
1064
+ });
1065
+ }
1066
+ if (envNode.type !== 'object') {
1067
+ placeholder.failures.push({
1068
+ type: 'failure',
1069
+ reason: 'invalid-config-syntax',
1070
+ script: placeholder,
1071
+ diagnostic: {
1072
+ severity: 'error',
1073
+ message: 'Expected an object',
1074
+ location: {
1075
+ file: packageJson.jsonFile,
1076
+ range: { length: envNode.length, offset: envNode.offset },
1077
+ },
1078
+ },
1079
+ });
1080
+ }
1081
+ if (envNode.children === undefined) {
1082
+ return {};
1083
+ }
1084
+ const entries = [];
1085
+ for (const propNode of envNode.children) {
1086
+ if (propNode.children === undefined || propNode.children.length !== 2) {
1087
+ throw new Error('Internal error: expected object JSON node children to be key/val pairs');
1088
+ }
1089
+ const keyValueResult = failUnlessKeyValue(propNode, propNode.children, packageJson.jsonFile);
1090
+ if (!keyValueResult.ok) {
1091
+ placeholder.failures.push(keyValueResult.error);
1092
+ continue;
1093
+ }
1094
+ const [key, val] = keyValueResult.value;
1095
+ if (key.type !== 'string') {
1096
+ throw new Error('Internal error: expected object JSON node child key to be string');
1097
+ }
1098
+ const keyStr = key.value;
1099
+ if (val.type === 'string') {
1100
+ entries.push([keyStr, val.value]);
1101
+ }
1102
+ else if (val.type !== 'object') {
1103
+ placeholder.failures.push({
1104
+ type: 'failure',
1105
+ reason: 'invalid-config-syntax',
1106
+ script: placeholder,
1107
+ diagnostic: {
1108
+ severity: 'error',
1109
+ message: 'Expected a string or object',
1110
+ location: {
1111
+ file: packageJson.jsonFile,
1112
+ range: { length: val.length, offset: val.offset },
1113
+ },
1114
+ },
1115
+ });
1116
+ continue;
1117
+ }
1118
+ else {
1119
+ const externalNode = findNodeAtLocation(val, ['external']);
1120
+ if (externalNode?.value !== true) {
1121
+ placeholder.failures.push({
1122
+ type: 'failure',
1123
+ reason: 'invalid-config-syntax',
1124
+ script: placeholder,
1125
+ diagnostic: {
1126
+ severity: 'error',
1127
+ message: 'Expected "external" to be true',
1128
+ location: {
1129
+ file: packageJson.jsonFile,
1130
+ range: {
1131
+ length: (externalNode ?? val).length,
1132
+ offset: (externalNode ?? val).offset,
1133
+ },
1134
+ },
1135
+ },
1136
+ });
1137
+ continue;
1138
+ }
1139
+ const defaultNode = findNodeAtLocation(val, ['default']);
1140
+ if (defaultNode && defaultNode.type !== 'string') {
1141
+ placeholder.failures.push({
1142
+ type: 'failure',
1143
+ reason: 'invalid-config-syntax',
1144
+ script: placeholder,
1145
+ diagnostic: {
1146
+ severity: 'error',
1147
+ message: 'Expected "default" to be a string',
1148
+ location: {
1149
+ file: packageJson.jsonFile,
1150
+ range: {
1151
+ length: (defaultNode ?? val).length,
1152
+ offset: (defaultNode ?? val).offset,
1153
+ },
1154
+ },
1155
+ },
1156
+ });
1157
+ continue;
1158
+ }
1159
+ const envValue = process.env[keyStr];
1160
+ if (envValue !== undefined) {
1161
+ entries.push([keyStr, envValue]);
1162
+ }
1163
+ else if (defaultNode) {
1164
+ entries.push([keyStr, defaultNode.value]);
1165
+ }
1166
+ }
1167
+ }
1168
+ // Sort for better fingerprint match rate.
1169
+ entries.sort();
1170
+ return Object.fromEntries(entries);
1171
+ }
1172
+ /**
1173
+ * This is where we check for cycles in dependencies, but it's also the
1174
+ * place where we transform LocallyValidScriptConfigs to ScriptConfigs.
1175
+ */
1176
+ #checkForCyclesAndSortDependencies(config, trail, isPersistent) {
1177
+ if (config.state === 'valid') {
1178
+ // Already validated.
1179
+ return { ok: true, value: config };
1180
+ }
1181
+ else if (config.state === 'invalid') {
1182
+ return { ok: false, error: config };
1183
+ }
1184
+ let dependencyStillUnvalidated = undefined;
1185
+ const trailKey = scriptReferenceToString(config);
1186
+ const supplementalLocations = [];
1187
+ if (trail.has(trailKey)) {
1188
+ // Found a cycle.
1189
+ let cycleStart = 0;
1190
+ // Trail is in graph traversal order because JavaScript Map iteration
1191
+ // order matches insertion order.
1192
+ let i = 0;
1193
+ for (const visitedKey of trail) {
1194
+ if (visitedKey === trailKey) {
1195
+ cycleStart = i;
1196
+ }
1197
+ i++;
1198
+ }
1199
+ const trailArray = [...trail].map((key) => {
1200
+ const placeholderInfo = this.#placeholders.get(key);
1201
+ if (placeholderInfo === undefined) {
1202
+ throw new Error(`Internal error: placeholder not found for ${key} during cycle detection`);
1203
+ }
1204
+ return placeholderInfo.placeholder;
1205
+ });
1206
+ trailArray.push(config);
1207
+ const cycleEnd = trailArray.length - 1;
1208
+ for (let i = cycleStart; i < cycleEnd; i++) {
1209
+ const current = trailArray[i];
1210
+ const next = trailArray[i + 1];
1211
+ if (current.state === 'unvalidated') {
1212
+ continue;
1213
+ }
1214
+ const nextNode = current.dependencies.find((dep) => dep.config === next);
1215
+ // Use the actual value in the array, because this could refer to
1216
+ // a script in another package.
1217
+ const nextName = nextNode?.specifier?.value ??
1218
+ next?.name ??
1219
+ trailArray[cycleStart]?.name;
1220
+ const message = next === trailArray[cycleStart]
1221
+ ? `${JSON.stringify(current.name)} points back to ${JSON.stringify(nextName)}`
1222
+ : `${JSON.stringify(current.name)} points to ${JSON.stringify(nextName)}`;
1223
+ const culpritNode =
1224
+ // This should always be present
1225
+ nextNode?.specifier ??
1226
+ // But failing that, fall back to the best node we have.
1227
+ current.configAstNode?.name ??
1228
+ current.scriptAstNode.name;
1229
+ supplementalLocations.push({
1230
+ message,
1231
+ location: {
1232
+ file: current.declaringFile,
1233
+ range: {
1234
+ offset: culpritNode.offset,
1235
+ length: culpritNode.length,
1236
+ },
1237
+ },
1238
+ });
1239
+ }
1240
+ const diagnostic = {
1241
+ severity: 'error',
1242
+ message: `Cycle detected in dependencies of ${JSON.stringify(config.name)}.`,
1243
+ location: {
1244
+ file: config.declaringFile,
1245
+ range: {
1246
+ length: config.configAstNode?.name.length ??
1247
+ config.scriptAstNode.name.length,
1248
+ offset: config.configAstNode?.name.offset ??
1249
+ config.scriptAstNode.name.length,
1250
+ },
1251
+ },
1252
+ supplementalLocations,
1253
+ };
1254
+ const failure = {
1255
+ type: 'failure',
1256
+ reason: 'cycle',
1257
+ script: config,
1258
+ diagnostic,
1259
+ };
1260
+ return { ok: false, error: this.#markAsInvalid(config, failure) };
1261
+ }
1262
+ if (config.dependencies.length > 0) {
1263
+ // Sorting means that if the user re-orders the same set of dependencies,
1264
+ // the trail we take in this walk remains the same, so any cycle error
1265
+ // message we might throw will have the same trail, too. This also helps
1266
+ // make the caching keys that we'll be generating in the later execution
1267
+ // step insensitive to dependency order as well.
1268
+ config.dependencies.sort((a, b) => {
1269
+ if (a.config.packageDir !== b.config.packageDir) {
1270
+ return a.config.packageDir.localeCompare(b.config.packageDir);
1271
+ }
1272
+ return a.config.name.localeCompare(b.config.name);
1273
+ });
1274
+ trail.add(trailKey);
1275
+ for (const dependency of config.dependencies) {
1276
+ if (dependency.config.state === 'unvalidated') {
1277
+ dependencyStillUnvalidated = dependency.config;
1278
+ continue;
1279
+ }
1280
+ const validDependencyConfigResult = this.#checkForCyclesAndSortDependencies(dependency.config, trail,
1281
+ // Walk through no-command scripts and services when determining if
1282
+ // something is persistent.
1283
+ isPersistent &&
1284
+ (config.command === undefined || config.service !== undefined));
1285
+ if (!validDependencyConfigResult.ok) {
1286
+ return {
1287
+ ok: false,
1288
+ error: this.#markAsInvalid(config, validDependencyConfigResult.error.dependencyFailure),
1289
+ };
1290
+ }
1291
+ const validDependencyConfig = validDependencyConfigResult.value;
1292
+ if (validDependencyConfig.service !== undefined) {
1293
+ // We directly depend on a service.
1294
+ config.services.push(validDependencyConfig);
1295
+ }
1296
+ else if (validDependencyConfig.command === undefined) {
1297
+ // We depend on a no-command script, so in effect we depend on all of
1298
+ // the services it depends on.
1299
+ for (const service of validDependencyConfig.services) {
1300
+ config.services.push(service);
1301
+ }
1302
+ }
1303
+ }
1304
+ trail.delete(trailKey);
1305
+ }
1306
+ if (dependencyStillUnvalidated !== undefined) {
1307
+ // At least one of our dependencies was unvalidated, likely because it
1308
+ // had a syntax error or was missing necessary information. Therefore
1309
+ // we can't transition to valid either.
1310
+ const failure = {
1311
+ type: 'failure',
1312
+ reason: 'dependency-invalid',
1313
+ script: config,
1314
+ dependency: dependencyStillUnvalidated,
1315
+ };
1316
+ return { ok: false, error: this.#markAsInvalid(config, failure) };
1317
+ }
1318
+ let validConfig;
1319
+ if (config.service !== undefined) {
1320
+ // We should already have created an invalid script at this point, so we
1321
+ // should never get here. We throw here to convince TypeScript that this
1322
+ // is guaranteed.
1323
+ if (config.command === undefined) {
1324
+ throw new Error('Internal error: Supposedly valid service did not have command');
1325
+ }
1326
+ validConfig = {
1327
+ ...config,
1328
+ state: 'valid',
1329
+ extraArgs: undefined,
1330
+ dependencies: config.dependencies,
1331
+ // Unfortunately TypeScript doesn't narrow the ...config spread, so we
1332
+ // have to assign explicitly.
1333
+ command: config.command,
1334
+ isPersistent,
1335
+ serviceConsumers: [],
1336
+ };
1337
+ }
1338
+ else {
1339
+ validConfig = {
1340
+ ...config,
1341
+ state: 'valid',
1342
+ extraArgs: undefined,
1343
+ dependencies: config.dependencies,
1344
+ // Unfortunately TypeScript doesn't narrow the ...config spread, so we
1345
+ // have to assign explicitly.
1346
+ service: config.service,
1347
+ };
1348
+ }
1349
+ // Propagate reverse service dependencies.
1350
+ if (validConfig.command) {
1351
+ for (const dependency of validConfig.dependencies) {
1352
+ if (dependency.config.service !== undefined) {
1353
+ dependency.config.serviceConsumers.push(validConfig);
1354
+ }
1355
+ else if (dependency.config.command === undefined) {
1356
+ for (const service of dependency.config.services) {
1357
+ service.serviceConsumers.push(validConfig);
1358
+ }
1359
+ }
1360
+ }
1361
+ }
1362
+ // We want to keep the original reference, but get type checking that
1363
+ // the only difference between a ScriptConfig and a
1364
+ // LocallyValidScriptConfig is that the state is 'valid' and the
1365
+ // dependencies are also valid, which we confirmed above.
1366
+ Object.assign(config, validConfig);
1367
+ return { ok: true, value: config };
1368
+ }
1369
+ #markAsInvalid(config, failure) {
1370
+ const invalidConfig = {
1371
+ ...config,
1372
+ state: 'invalid',
1373
+ dependencyFailure: failure,
1374
+ };
1375
+ Object.assign(config, invalidConfig);
1376
+ config.failures.push(failure);
1377
+ return config;
1378
+ }
1379
+ /**
1380
+ * Resolve a dependency string specified in a "wireit.<script>.dependencies"
1381
+ * array, which may contain special syntax like relative paths or
1382
+ * "$WORKSPACES", into concrete packages and script names.
1383
+ *
1384
+ * Note this can return 0, 1, or >1 script references.
1385
+ */
1386
+ #resolveDependency(dependency, context, referencingFile) {
1387
+ // TODO(aomarks) Implement $WORKSPACES syntax.
1388
+ if (dependency.value.startsWith('.')) {
1389
+ // TODO(aomarks) It is technically valid for an npm script to start with a
1390
+ // ".". We should support that edge case with backslash escaping.
1391
+ const result = this.#resolveCrossPackageDependency(dependency, context, referencingFile);
1392
+ if (!result.ok) {
1393
+ return result;
1394
+ }
1395
+ return { ok: true, value: [result.value] };
1396
+ }
1397
+ return {
1398
+ ok: true,
1399
+ value: [{ packageDir: context.packageDir, name: dependency.value }],
1400
+ };
1401
+ }
1402
+ /**
1403
+ * Resolve a cross-package dependency (e.g. "../other-package:build").
1404
+ * Cross-package dependencies always start with a ".".
1405
+ */
1406
+ #resolveCrossPackageDependency(dependency, context, referencingFile) {
1407
+ // TODO(aomarks) On some file systems, it is valid to have a ":" in a file
1408
+ // path. We should support that edge case with backslash escaping.
1409
+ const firstColonIdx = dependency.value.indexOf(':');
1410
+ if (firstColonIdx === -1) {
1411
+ return {
1412
+ ok: false,
1413
+ error: {
1414
+ type: 'failure',
1415
+ reason: 'invalid-config-syntax',
1416
+ script: context,
1417
+ diagnostic: {
1418
+ severity: 'error',
1419
+ message: `Cross-package dependency must use syntax ` +
1420
+ `"<relative-path>:<script-name>", ` +
1421
+ `but there's no ":" character in "${dependency.value}".`,
1422
+ location: {
1423
+ file: referencingFile,
1424
+ range: { offset: dependency.offset, length: dependency.length },
1425
+ },
1426
+ },
1427
+ },
1428
+ };
1429
+ }
1430
+ const scriptName = dependency.value.slice(firstColonIdx + 1);
1431
+ if (!scriptName) {
1432
+ return {
1433
+ ok: false,
1434
+ error: {
1435
+ type: 'failure',
1436
+ reason: 'invalid-config-syntax',
1437
+ script: context,
1438
+ diagnostic: {
1439
+ severity: 'error',
1440
+ message: `Cross-package dependency must use syntax ` +
1441
+ `"<relative-path>:<script-name>", ` +
1442
+ `but there's no script name in "${dependency.value}".`,
1443
+ location: {
1444
+ file: referencingFile,
1445
+ range: { offset: dependency.offset, length: dependency.length },
1446
+ },
1447
+ },
1448
+ },
1449
+ };
1450
+ }
1451
+ const relativePackageDir = dependency.value.slice(0, firstColonIdx);
1452
+ const absolutePackageDir = pathlib.resolve(context.packageDir, relativePackageDir);
1453
+ if (absolutePackageDir === context.packageDir) {
1454
+ return {
1455
+ ok: false,
1456
+ error: {
1457
+ type: 'failure',
1458
+ reason: 'invalid-config-syntax',
1459
+ script: context,
1460
+ diagnostic: {
1461
+ severity: 'error',
1462
+ message: `Cross-package dependency "${dependency.value}" ` +
1463
+ `resolved to the same package.`,
1464
+ location: {
1465
+ file: referencingFile,
1466
+ range: { offset: dependency.offset, length: dependency.length },
1467
+ },
1468
+ },
1469
+ },
1470
+ };
1471
+ }
1472
+ return {
1473
+ ok: true,
1474
+ value: { packageDir: absolutePackageDir, name: scriptName },
1475
+ };
1476
+ }
1477
+ }
1478
+ export function failUnlessNonBlankString(astNode, file) {
1479
+ if (astNode.type !== 'string') {
1480
+ return {
1481
+ ok: false,
1482
+ error: {
1483
+ type: 'failure',
1484
+ reason: 'invalid-config-syntax',
1485
+ script: { packageDir: pathlib.dirname(file.path) },
1486
+ diagnostic: {
1487
+ severity: 'error',
1488
+ message: `Expected a string, but was ${astNode.type}.`,
1489
+ location: {
1490
+ file,
1491
+ range: {
1492
+ offset: astNode.offset,
1493
+ length: astNode.length,
1494
+ },
1495
+ },
1496
+ },
1497
+ },
1498
+ };
1499
+ }
1500
+ if (astNode.value.match(/^\s*$/)) {
1501
+ return {
1502
+ ok: false,
1503
+ error: {
1504
+ type: 'failure',
1505
+ reason: 'invalid-config-syntax',
1506
+ script: { packageDir: pathlib.dirname(file.path) },
1507
+ diagnostic: {
1508
+ severity: 'error',
1509
+ message: `Expected this field to be nonempty`,
1510
+ location: {
1511
+ file,
1512
+ range: {
1513
+ offset: astNode.offset,
1514
+ length: astNode.length,
1515
+ },
1516
+ },
1517
+ },
1518
+ },
1519
+ };
1520
+ }
1521
+ return { ok: true, value: astNode };
1522
+ }
1523
+ /**
1524
+ * Return a failing result if the given value is not an Array.
1525
+ */
1526
+ const failUnlessArray = (astNode, file) => {
1527
+ if (astNode.type !== 'array') {
1528
+ return {
1529
+ ok: false,
1530
+ error: {
1531
+ type: 'failure',
1532
+ reason: 'invalid-config-syntax',
1533
+ script: { packageDir: pathlib.dirname(file.path) },
1534
+ diagnostic: {
1535
+ severity: 'error',
1536
+ message: `Expected an array, but was ${astNode.type}.`,
1537
+ location: {
1538
+ file: file,
1539
+ range: {
1540
+ offset: astNode.offset,
1541
+ length: astNode.length,
1542
+ },
1543
+ },
1544
+ },
1545
+ },
1546
+ };
1547
+ }
1548
+ return { ok: true, value: undefined };
1549
+ };
1550
+ /**
1551
+ * Return a failed result if the given value is not an object literal ({...}).
1552
+ */
1553
+ export const failUnlessJsonObject = (astNode, file) => {
1554
+ if (astNode.type !== 'object') {
1555
+ return {
1556
+ type: 'failure',
1557
+ reason: 'invalid-config-syntax',
1558
+ script: { packageDir: pathlib.dirname(file.path) },
1559
+ diagnostic: {
1560
+ severity: 'error',
1561
+ message: `Expected an object, but was ${astNode.type}.`,
1562
+ location: {
1563
+ file: file,
1564
+ range: {
1565
+ offset: astNode.offset,
1566
+ length: astNode.length,
1567
+ },
1568
+ },
1569
+ },
1570
+ };
1571
+ }
1572
+ };
1573
+ export const failUnlessKeyValue = (node, children, file) => {
1574
+ const [rawName, rawValue] = children;
1575
+ if (children.length !== 2 ||
1576
+ rawName === undefined ||
1577
+ rawValue === undefined) {
1578
+ return {
1579
+ ok: false,
1580
+ error: {
1581
+ type: 'failure',
1582
+ reason: 'invalid-config-syntax',
1583
+ script: { packageDir: pathlib.dirname(file.path) },
1584
+ diagnostic: {
1585
+ severity: 'error',
1586
+ message: `Expected "key": "value"`,
1587
+ location: {
1588
+ file,
1589
+ range: {
1590
+ offset: node.offset,
1591
+ length: node.length,
1592
+ },
1593
+ },
1594
+ },
1595
+ },
1596
+ };
1597
+ }
1598
+ return { ok: true, value: [rawName, rawValue] };
1599
+ };
1600
+ //# sourceMappingURL=analyzer.js.map