@0xsequence/catapult 1.3.17 → 1.4.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 (124) hide show
  1. package/README.md +249 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +1 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/index.d.ts +1 -0
  6. package/dist/commands/index.d.ts.map +1 -1
  7. package/dist/commands/index.js +1 -0
  8. package/dist/commands/index.js.map +1 -1
  9. package/dist/commands/list.d.ts.map +1 -1
  10. package/dist/commands/list.js +12 -0
  11. package/dist/commands/list.js.map +1 -1
  12. package/dist/commands/provenance.d.ts +3 -0
  13. package/dist/commands/provenance.d.ts.map +1 -0
  14. package/dist/commands/provenance.js +138 -0
  15. package/dist/commands/provenance.js.map +1 -0
  16. package/dist/lib/__tests__/deployer.spec.js +118 -1
  17. package/dist/lib/__tests__/deployer.spec.js.map +1 -1
  18. package/dist/lib/__tests__/provenance.spec.d.ts +2 -0
  19. package/dist/lib/__tests__/provenance.spec.d.ts.map +1 -0
  20. package/dist/lib/__tests__/provenance.spec.js +205 -0
  21. package/dist/lib/__tests__/provenance.spec.js.map +1 -0
  22. package/dist/lib/contracts/__tests__/repository.spec.js +243 -0
  23. package/dist/lib/contracts/__tests__/repository.spec.js.map +1 -1
  24. package/dist/lib/contracts/repository.d.ts +9 -1
  25. package/dist/lib/contracts/repository.d.ts.map +1 -1
  26. package/dist/lib/contracts/repository.js +93 -7
  27. package/dist/lib/contracts/repository.js.map +1 -1
  28. package/dist/lib/core/__tests__/assert-action.spec.d.ts +2 -0
  29. package/dist/lib/core/__tests__/assert-action.spec.d.ts.map +1 -0
  30. package/dist/lib/core/__tests__/assert-action.spec.js +377 -0
  31. package/dist/lib/core/__tests__/assert-action.spec.js.map +1 -0
  32. package/dist/lib/core/__tests__/engine.spec.js +80 -0
  33. package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
  34. package/dist/lib/core/__tests__/loader.spec.js +29 -0
  35. package/dist/lib/core/__tests__/loader.spec.js.map +1 -1
  36. package/dist/lib/core/__tests__/resolver.spec.js +383 -0
  37. package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
  38. package/dist/lib/core/engine.d.ts.map +1 -1
  39. package/dist/lib/core/engine.js +33 -0
  40. package/dist/lib/core/engine.js.map +1 -1
  41. package/dist/lib/core/loader.d.ts +1 -0
  42. package/dist/lib/core/loader.d.ts.map +1 -1
  43. package/dist/lib/core/loader.js +6 -1
  44. package/dist/lib/core/loader.js.map +1 -1
  45. package/dist/lib/core/resolver.d.ts +2 -0
  46. package/dist/lib/core/resolver.d.ts.map +1 -1
  47. package/dist/lib/core/resolver.js +89 -0
  48. package/dist/lib/core/resolver.js.map +1 -1
  49. package/dist/lib/deployer.d.ts.map +1 -1
  50. package/dist/lib/deployer.js +21 -4
  51. package/dist/lib/deployer.js.map +1 -1
  52. package/dist/lib/index.d.ts +1 -0
  53. package/dist/lib/index.d.ts.map +1 -1
  54. package/dist/lib/index.js +1 -0
  55. package/dist/lib/index.js.map +1 -1
  56. package/dist/lib/parsers/__tests__/job.spec.js +77 -0
  57. package/dist/lib/parsers/__tests__/job.spec.js.map +1 -1
  58. package/dist/lib/parsers/__tests__/source.spec.d.ts +2 -0
  59. package/dist/lib/parsers/__tests__/source.spec.d.ts.map +1 -0
  60. package/dist/lib/parsers/__tests__/source.spec.js +121 -0
  61. package/dist/lib/parsers/__tests__/source.spec.js.map +1 -0
  62. package/dist/lib/parsers/index.d.ts +1 -0
  63. package/dist/lib/parsers/index.d.ts.map +1 -1
  64. package/dist/lib/parsers/index.js +1 -0
  65. package/dist/lib/parsers/index.js.map +1 -1
  66. package/dist/lib/parsers/job.d.ts.map +1 -1
  67. package/dist/lib/parsers/job.js +11 -0
  68. package/dist/lib/parsers/job.js.map +1 -1
  69. package/dist/lib/parsers/source.d.ts +4 -0
  70. package/dist/lib/parsers/source.d.ts.map +1 -0
  71. package/dist/lib/parsers/source.js +107 -0
  72. package/dist/lib/parsers/source.js.map +1 -0
  73. package/dist/lib/provenance.d.ts +34 -0
  74. package/dist/lib/provenance.d.ts.map +1 -0
  75. package/dist/lib/provenance.js +645 -0
  76. package/dist/lib/provenance.js.map +1 -0
  77. package/dist/lib/types/actions.d.ts +18 -2
  78. package/dist/lib/types/actions.d.ts.map +1 -1
  79. package/dist/lib/types/actions.js +1 -0
  80. package/dist/lib/types/actions.js.map +1 -1
  81. package/dist/lib/types/contracts.d.ts +3 -0
  82. package/dist/lib/types/contracts.d.ts.map +1 -1
  83. package/dist/lib/types/definitions.d.ts +1 -0
  84. package/dist/lib/types/definitions.d.ts.map +1 -1
  85. package/dist/lib/types/index.d.ts +1 -0
  86. package/dist/lib/types/index.d.ts.map +1 -1
  87. package/dist/lib/types/index.js +1 -0
  88. package/dist/lib/types/index.js.map +1 -1
  89. package/dist/lib/types/source.d.ts +24 -0
  90. package/dist/lib/types/source.d.ts.map +1 -0
  91. package/dist/lib/types/source.js +3 -0
  92. package/dist/lib/types/source.js.map +1 -0
  93. package/dist/lib/types/values.d.ts +33 -1
  94. package/dist/lib/types/values.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/src/cli.ts +3 -2
  97. package/src/commands/index.ts +2 -1
  98. package/src/commands/list.ts +14 -1
  99. package/src/commands/provenance.ts +120 -0
  100. package/src/lib/__tests__/deployer.spec.ts +177 -1
  101. package/src/lib/__tests__/provenance.spec.ts +208 -0
  102. package/src/lib/contracts/__tests__/repository.spec.ts +270 -2
  103. package/src/lib/contracts/repository.ts +112 -14
  104. package/src/lib/core/__tests__/assert-action.spec.ts +474 -0
  105. package/src/lib/core/__tests__/engine.spec.ts +116 -0
  106. package/src/lib/core/__tests__/loader.spec.ts +34 -1
  107. package/src/lib/core/__tests__/resolver.spec.ts +444 -1
  108. package/src/lib/core/engine.ts +52 -0
  109. package/src/lib/core/loader.ts +8 -2
  110. package/src/lib/core/resolver.ts +116 -0
  111. package/src/lib/deployer.ts +28 -4
  112. package/src/lib/index.ts +4 -1
  113. package/src/lib/parsers/__tests__/job.spec.ts +81 -0
  114. package/src/lib/parsers/__tests__/source.spec.ts +134 -0
  115. package/src/lib/parsers/index.ts +1 -0
  116. package/src/lib/parsers/job.ts +14 -2
  117. package/src/lib/parsers/source.ts +129 -0
  118. package/src/lib/provenance.ts +785 -0
  119. package/src/lib/types/actions.ts +22 -1
  120. package/src/lib/types/contracts.ts +4 -1
  121. package/src/lib/types/definitions.ts +7 -0
  122. package/src/lib/types/index.ts +1 -0
  123. package/src/lib/types/source.ts +26 -0
  124. package/src/lib/types/values.ts +71 -0
@@ -0,0 +1,785 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import { execFile, spawn } from 'child_process'
5
+ import { promisify } from 'util'
6
+ import { DependencyGraph } from './core/graph'
7
+ import { ProjectLoader } from './core/loader'
8
+ import { parseSourceDocument } from './parsers/source'
9
+ import { isBuildInfoFile } from './parsers/buildinfo'
10
+ import { BuildInfoSourceProvenance } from './types/source'
11
+ import { Job } from './types'
12
+
13
+ const execFileAsync = promisify(execFile)
14
+ const IGNORED_DIRS = new Set(['node_modules', 'dist', '.git', '.idea', '.vscode'])
15
+ const COMMAND_MAX_BUFFER = 20 * 1024 * 1024
16
+ const BUILD_INFO_ID_PLACEHOLDER = '<build-info-id>'
17
+ const BUILD_INFO_BASE_PATH_PLACEHOLDER = '<build-info-base-path>'
18
+ const CATAPULT_CHECKOUT_PATH_PATTERN = /(?:[A-Za-z]:)?[/\\](?:[^/\\]+[/\\])*catapult-provenance-[^/\\]+[/\\]repo(?=[/\\]|$)/g
19
+
20
+ export interface SourceProvenanceEntry {
21
+ sourceDocumentPath: string
22
+ buildInfoRef: string
23
+ buildInfoPath: string
24
+ provenance: BuildInfoSourceProvenance
25
+ }
26
+
27
+ export interface CollectProvenanceOptions {
28
+ jobs?: string[]
29
+ includeDependencies?: boolean
30
+ loadStdTemplates?: boolean
31
+ }
32
+
33
+ export interface ProvenanceOperationOptions extends CollectProvenanceOptions {
34
+ force?: boolean
35
+ }
36
+
37
+ export type ProvenanceOperationStatus = 'verified' | 'generated' | 'skipped' | 'failed'
38
+
39
+ export interface ProvenanceOperationResult {
40
+ entry: SourceProvenanceEntry
41
+ status: ProvenanceOperationStatus
42
+ message: string
43
+ generatedBuildInfoPath?: string
44
+ }
45
+
46
+ export interface ProvenanceRunResult {
47
+ entries: SourceProvenanceEntry[]
48
+ warnings: string[]
49
+ results: ProvenanceOperationResult[]
50
+ }
51
+
52
+ interface BuildInfoCandidate {
53
+ filePath: string
54
+ relativePath: string
55
+ content: string
56
+ json: unknown
57
+ id?: string
58
+ }
59
+
60
+ interface BuildOutput {
61
+ tempDir: string
62
+ checkoutDir: string
63
+ head: string
64
+ candidates: BuildInfoCandidate[]
65
+ }
66
+
67
+ interface JsonComparison {
68
+ matches: boolean
69
+ difference?: string
70
+ }
71
+
72
+ export async function collectSourceProvenanceEntries(
73
+ projectRoot: string,
74
+ options: CollectProvenanceOptions = {}
75
+ ): Promise<{ entries: SourceProvenanceEntry[]; warnings: string[] }> {
76
+ const absoluteProjectRoot = path.resolve(projectRoot)
77
+ const sourceFiles = await findSourceFiles(absoluteProjectRoot)
78
+ const warnings: string[] = []
79
+ const entries: SourceProvenanceEntry[] = []
80
+
81
+ for (const sourceFilePath of sourceFiles) {
82
+ let sourceDocument
83
+ try {
84
+ const content = await fs.readFile(sourceFilePath, 'utf-8')
85
+ sourceDocument = parseSourceDocument(content)
86
+ } catch (error) {
87
+ warnings.push(`Skipping source provenance file ${sourceFilePath}: ${formatError(error)}`)
88
+ continue
89
+ }
90
+
91
+ if (!sourceDocument) {
92
+ continue
93
+ }
94
+
95
+ for (const warning of sourceDocument.warnings || []) {
96
+ warnings.push(`Skipping source provenance entry ${sourceFilePath}: ${warning}`)
97
+ }
98
+
99
+ const sourceDir = path.dirname(sourceFilePath)
100
+ for (const [buildInfoRef, provenance] of Object.entries(sourceDocument.build_info)) {
101
+ const buildInfoPath = path.resolve(sourceDir, buildInfoRef)
102
+ if (!isBuildInfoFile(buildInfoPath)) {
103
+ warnings.push(`Skipping source provenance entry ${sourceFilePath}: "${buildInfoRef}" does not point to a build-info JSON file.`)
104
+ continue
105
+ }
106
+
107
+ entries.push({
108
+ sourceDocumentPath: sourceFilePath,
109
+ buildInfoRef,
110
+ buildInfoPath,
111
+ provenance
112
+ })
113
+ }
114
+ }
115
+
116
+ if (options.jobs && options.jobs.length > 0) {
117
+ return {
118
+ entries: await filterEntriesByJobs(absoluteProjectRoot, entries, options),
119
+ warnings
120
+ }
121
+ }
122
+
123
+ return { entries, warnings }
124
+ }
125
+
126
+ export async function verifySourceProvenance(
127
+ projectRoot: string,
128
+ options: CollectProvenanceOptions = {}
129
+ ): Promise<ProvenanceRunResult> {
130
+ const collected = await collectSourceProvenanceEntries(projectRoot, options)
131
+ const buildCache = new Map<string, Promise<BuildOutput>>()
132
+ const results: ProvenanceOperationResult[] = []
133
+
134
+ try {
135
+ for (const entry of collected.entries) {
136
+ results.push(await verifySourceProvenanceEntry(entry, buildCache))
137
+ }
138
+ } finally {
139
+ await cleanupBuildCache(buildCache)
140
+ }
141
+
142
+ return {
143
+ ...collected,
144
+ results
145
+ }
146
+ }
147
+
148
+ export async function generateBuildInfoFromSourceProvenance(
149
+ projectRoot: string,
150
+ options: ProvenanceOperationOptions = {}
151
+ ): Promise<ProvenanceRunResult> {
152
+ const collected = await collectSourceProvenanceEntries(projectRoot, options)
153
+ const buildCache = new Map<string, Promise<BuildOutput>>()
154
+ const results: ProvenanceOperationResult[] = []
155
+
156
+ try {
157
+ for (const entry of collected.entries) {
158
+ results.push(await generateBuildInfoForEntry(entry, buildCache, options.force === true))
159
+ }
160
+ } finally {
161
+ await cleanupBuildCache(buildCache)
162
+ }
163
+
164
+ return {
165
+ ...collected,
166
+ results
167
+ }
168
+ }
169
+
170
+ async function verifySourceProvenanceEntry(
171
+ entry: SourceProvenanceEntry,
172
+ buildCache: Map<string, Promise<BuildOutput>>
173
+ ): Promise<ProvenanceOperationResult> {
174
+ try {
175
+ await assertPathExists(entry.buildInfoPath)
176
+ const buildOutput = await getBuildOutput(entry, buildCache)
177
+ const generated = await selectGeneratedBuildInfo(entry, buildOutput.candidates)
178
+ const comparison = await compareJsonFiles(entry.buildInfoPath, generated)
179
+
180
+ if (!comparison.matches) {
181
+ return {
182
+ entry,
183
+ status: 'failed',
184
+ message: `Committed build-info does not match generated ${generated.relativePath}.${comparison.difference ? ` First difference: ${comparison.difference}.` : ''}`,
185
+ generatedBuildInfoPath: generated.filePath
186
+ }
187
+ }
188
+
189
+ return {
190
+ entry,
191
+ status: 'verified',
192
+ message: `Matches generated ${generated.relativePath} at ${shortCommit(buildOutput.head)}.`,
193
+ generatedBuildInfoPath: generated.filePath
194
+ }
195
+ } catch (error) {
196
+ return {
197
+ entry,
198
+ status: 'failed',
199
+ message: formatError(error)
200
+ }
201
+ }
202
+ }
203
+
204
+ async function generateBuildInfoForEntry(
205
+ entry: SourceProvenanceEntry,
206
+ buildCache: Map<string, Promise<BuildOutput>>,
207
+ force: boolean
208
+ ): Promise<ProvenanceOperationResult> {
209
+ try {
210
+ if (!force && await pathExists(entry.buildInfoPath)) {
211
+ return {
212
+ entry,
213
+ status: 'skipped',
214
+ message: 'Build-info already exists. Use --force to overwrite it.'
215
+ }
216
+ }
217
+
218
+ const buildOutput = await getBuildOutput(entry, buildCache)
219
+ const generated = await selectGeneratedBuildInfo(entry, buildOutput.candidates)
220
+ await fs.mkdir(path.dirname(entry.buildInfoPath), { recursive: true })
221
+ await fs.writeFile(entry.buildInfoPath, generated.content)
222
+
223
+ return {
224
+ entry,
225
+ status: 'generated',
226
+ message: `Wrote build-info from generated ${generated.relativePath} at ${shortCommit(buildOutput.head)}.`,
227
+ generatedBuildInfoPath: generated.filePath
228
+ }
229
+ } catch (error) {
230
+ return {
231
+ entry,
232
+ status: 'failed',
233
+ message: formatError(error)
234
+ }
235
+ }
236
+ }
237
+
238
+ async function getBuildOutput(
239
+ entry: SourceProvenanceEntry,
240
+ buildCache: Map<string, Promise<BuildOutput>>
241
+ ): Promise<BuildOutput> {
242
+ const key = buildCacheKey(entry.provenance)
243
+ if (!buildCache.has(key)) {
244
+ buildCache.set(key, buildFromSourceProvenance(entry.provenance))
245
+ }
246
+ return buildCache.get(key)!
247
+ }
248
+
249
+ async function buildFromSourceProvenance(provenance: BuildInfoSourceProvenance): Promise<BuildOutput> {
250
+ if (!provenance.build) {
251
+ throw new Error('source provenance is missing a build command.')
252
+ }
253
+ if (!provenance.commit && !provenance.ref) {
254
+ throw new Error('source provenance must include either commit or ref.')
255
+ }
256
+
257
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'catapult-provenance-'))
258
+ const checkoutDir = path.join(tempDir, 'repo')
259
+
260
+ try {
261
+ await runGit(['clone', '--', provenance.repo, checkoutDir])
262
+
263
+ if (provenance.ref) {
264
+ const refHead = await gitCommitForRevision(provenance.ref, checkoutDir)
265
+ if (provenance.commit && !commitMatches(refHead, provenance.commit)) {
266
+ throw new Error(`ref "${provenance.ref}" resolves to ${refHead}, not ${provenance.commit}.`)
267
+ }
268
+ await checkoutGitCommit(refHead, checkoutDir)
269
+ } else if (provenance.commit) {
270
+ const commitHead = await gitCommitForRevision(provenance.commit, checkoutDir)
271
+ await checkoutGitCommit(commitHead, checkoutDir)
272
+ }
273
+
274
+ const head = await gitStdout(['rev-parse', 'HEAD'], checkoutDir)
275
+ await runShell(provenance.build, checkoutDir)
276
+ const candidates = await findBuildInfoCandidates(checkoutDir)
277
+
278
+ if (candidates.length === 0) {
279
+ throw new Error('build command did not produce any build-info JSON files.')
280
+ }
281
+
282
+ return {
283
+ tempDir,
284
+ checkoutDir,
285
+ head,
286
+ candidates
287
+ }
288
+ } catch (error) {
289
+ await fs.rm(tempDir, { recursive: true, force: true })
290
+ throw error
291
+ }
292
+ }
293
+
294
+ async function cleanupBuildCache(buildCache: Map<string, Promise<BuildOutput>>): Promise<void> {
295
+ const seen = new Set<string>()
296
+ for (const buildOutputPromise of buildCache.values()) {
297
+ try {
298
+ const buildOutput = await buildOutputPromise
299
+ if (!seen.has(buildOutput.tempDir)) {
300
+ seen.add(buildOutput.tempDir)
301
+ await fs.rm(buildOutput.tempDir, { recursive: true, force: true })
302
+ }
303
+ } catch {
304
+ // Failed builds clean up their own temp directories.
305
+ }
306
+ }
307
+ }
308
+
309
+ async function findBuildInfoCandidates(root: string): Promise<BuildInfoCandidate[]> {
310
+ const files = await findJsonFiles(root)
311
+ const candidates: BuildInfoCandidate[] = []
312
+
313
+ for (const filePath of files) {
314
+ if (!isBuildInfoFile(filePath)) {
315
+ continue
316
+ }
317
+
318
+ try {
319
+ const content = await fs.readFile(filePath, 'utf-8')
320
+ const json = JSON.parse(content)
321
+ candidates.push({
322
+ filePath,
323
+ relativePath: path.relative(root, filePath),
324
+ content,
325
+ json,
326
+ id: getJsonId(json)
327
+ })
328
+ } catch {
329
+ // Ignore invalid JSON files under build-info directories.
330
+ }
331
+ }
332
+
333
+ return candidates
334
+ }
335
+
336
+ async function selectGeneratedBuildInfo(
337
+ entry: SourceProvenanceEntry,
338
+ candidates: BuildInfoCandidate[]
339
+ ): Promise<BuildInfoCandidate> {
340
+ if (candidates.length === 0) {
341
+ throw new Error('build command did not produce any build-info JSON files.')
342
+ }
343
+
344
+ const existingJson = await readJsonIfExists(entry.buildInfoPath)
345
+ const existingId = getJsonId(existingJson)
346
+ const selectionIssues: string[] = []
347
+ if (existingId) {
348
+ const idMatches = candidates.filter(candidate => candidate.id === existingId)
349
+ if (idMatches.length === 1) {
350
+ return idMatches[0]
351
+ }
352
+ if (idMatches.length > 1) {
353
+ selectionIssues.push(`${idMatches.length} candidates share id "${existingId}"`)
354
+ }
355
+ }
356
+
357
+ const targetBaseName = path.basename(entry.buildInfoPath)
358
+ const nameMatches = candidates.filter(candidate => path.basename(candidate.filePath) === targetBaseName)
359
+ if (nameMatches.length === 1) {
360
+ return nameMatches[0]
361
+ }
362
+ if (nameMatches.length > 1) {
363
+ selectionIssues.push(`${nameMatches.length} candidates are named "${targetBaseName}"`)
364
+ }
365
+
366
+ if (candidates.length === 1) {
367
+ return candidates[0]
368
+ }
369
+
370
+ throw new Error(
371
+ `Generated ${candidates.length} build-info files and could not select one for "${entry.buildInfoRef}". ` +
372
+ `${selectionIssues.length > 0 ? `${selectionIssues.join('; ')}. ` : ''}` +
373
+ `Use a build command that emits one file or names the file "${targetBaseName}".`
374
+ )
375
+ }
376
+
377
+ async function compareJsonFiles(existingPath: string, generated: BuildInfoCandidate): Promise<JsonComparison> {
378
+ const existingRaw = await fs.readFile(existingPath, 'utf-8')
379
+ const existingJson = JSON.parse(existingRaw)
380
+ const normalizedExisting = sortJson(normalizeBuildInfoJson(existingJson))
381
+ const normalizedGenerated = sortJson(normalizeBuildInfoJson(generated.json))
382
+
383
+ if (JSON.stringify(normalizedExisting) === JSON.stringify(normalizedGenerated)) {
384
+ return { matches: true }
385
+ }
386
+
387
+ return {
388
+ matches: false,
389
+ difference: findFirstJsonDifference(normalizedExisting, normalizedGenerated)
390
+ }
391
+ }
392
+
393
+ async function filterEntriesByJobs(
394
+ projectRoot: string,
395
+ entries: SourceProvenanceEntry[],
396
+ options: CollectProvenanceOptions
397
+ ): Promise<SourceProvenanceEntry[]> {
398
+ const loader = new ProjectLoader(projectRoot, {
399
+ loadStdTemplates: options.loadStdTemplates,
400
+ loadContracts: false
401
+ })
402
+ await loader.load()
403
+
404
+ const graph = new DependencyGraph(loader.jobs, loader.templates)
405
+ const selectedJobs = selectJobNames(loader.jobs, graph, options.jobs || [], options.includeDependencies === true)
406
+ const scopeRoots = selectedJobs.flatMap(jobName => {
407
+ const job = loader.jobs.get(jobName)
408
+ return job ? jobScopeRoots(projectRoot, job) : []
409
+ })
410
+
411
+ return entries.filter(entry => scopeRoots.some(root =>
412
+ isPathWithin(root, entry.sourceDocumentPath) || isPathWithin(root, entry.buildInfoPath)
413
+ ))
414
+ }
415
+
416
+ function selectJobNames(
417
+ jobs: Map<string, Job>,
418
+ graph: DependencyGraph,
419
+ patterns: string[],
420
+ includeDependencies: boolean
421
+ ): string[] {
422
+ const allJobNames = Array.from(jobs.keys())
423
+ const requested = new Set<string>()
424
+
425
+ for (const pattern of patterns) {
426
+ const matches = isPattern(pattern)
427
+ ? allJobNames.filter(jobName => patternToRegex(pattern).test(jobName))
428
+ : allJobNames.filter(jobName => jobName === pattern)
429
+
430
+ if (matches.length === 0) {
431
+ throw new Error(`Job "${pattern}" not found in project.`)
432
+ }
433
+ matches.forEach(match => requested.add(match))
434
+ }
435
+
436
+ const selected = new Set(requested)
437
+ if (includeDependencies) {
438
+ for (const jobName of requested) {
439
+ graph.getDependencies(jobName).forEach(dep => selected.add(dep))
440
+ }
441
+ }
442
+
443
+ return graph.getExecutionOrder().filter(jobName => selected.has(jobName))
444
+ }
445
+
446
+ function jobScopeRoots(projectRoot: string, job: Job): string[] {
447
+ if (!job._path) {
448
+ return []
449
+ }
450
+
451
+ const jobsRoot = path.resolve(projectRoot, 'jobs')
452
+ const jobPath = path.resolve(job._path)
453
+ const jobDir = path.dirname(jobPath)
454
+ const jobBaseDir = path.join(jobDir, path.basename(jobPath, path.extname(jobPath)))
455
+
456
+ if (path.normalize(jobDir) === path.normalize(jobsRoot)) {
457
+ return [jobBaseDir]
458
+ }
459
+
460
+ return Array.from(new Set([jobDir, jobBaseDir]))
461
+ }
462
+
463
+ async function findSourceFiles(root: string): Promise<string[]> {
464
+ const results: string[] = []
465
+ await walk(root, async (filePath, direntName) => {
466
+ if (direntName === 'source.yaml' || direntName === 'source.yml') {
467
+ results.push(filePath)
468
+ }
469
+ })
470
+ return results.sort()
471
+ }
472
+
473
+ async function findJsonFiles(root: string): Promise<string[]> {
474
+ const results: string[] = []
475
+ await walk(root, async (filePath, direntName) => {
476
+ if (direntName.endsWith('.json')) {
477
+ results.push(filePath)
478
+ }
479
+ })
480
+ return results.sort()
481
+ }
482
+
483
+ async function walk(root: string, onFile: (filePath: string, direntName: string) => Promise<void>): Promise<void> {
484
+ let dirents
485
+ try {
486
+ dirents = await fs.readdir(root, { withFileTypes: true })
487
+ } catch {
488
+ return
489
+ }
490
+
491
+ for (const dirent of dirents) {
492
+ const fullPath = path.resolve(root, dirent.name)
493
+ if (dirent.isDirectory()) {
494
+ if (!IGNORED_DIRS.has(dirent.name)) {
495
+ await walk(fullPath, onFile)
496
+ }
497
+ } else if (dirent.isFile()) {
498
+ await onFile(fullPath, dirent.name)
499
+ }
500
+ }
501
+ }
502
+
503
+ async function runGit(args: string[], cwd?: string): Promise<void> {
504
+ try {
505
+ await execFileAsync('git', args, { cwd, maxBuffer: COMMAND_MAX_BUFFER })
506
+ } catch (error) {
507
+ throw new Error(`git ${args.join(' ')} failed: ${commandErrorMessage(error)}`)
508
+ }
509
+ }
510
+
511
+ async function gitStdout(args: string[], cwd: string): Promise<string> {
512
+ try {
513
+ const result = await execFileAsync('git', args, { cwd, maxBuffer: COMMAND_MAX_BUFFER })
514
+ return String(result.stdout).trim()
515
+ } catch (error) {
516
+ throw new Error(`git ${args.join(' ')} failed: ${commandErrorMessage(error)}`)
517
+ }
518
+ }
519
+
520
+ async function gitCommitForRevision(revision: string, cwd: string): Promise<string> {
521
+ return gitStdout(['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`], cwd)
522
+ }
523
+
524
+ async function checkoutGitCommit(commit: string, cwd: string): Promise<void> {
525
+ await runGit(['checkout', '--detach', commit], cwd)
526
+ }
527
+
528
+ async function runShell(command: string, cwd: string): Promise<void> {
529
+ await new Promise<void>((resolve, reject) => {
530
+ const child = spawn(command, {
531
+ cwd,
532
+ env: process.env,
533
+ shell: true,
534
+ stdio: 'inherit'
535
+ })
536
+
537
+ child.on('error', error => {
538
+ reject(new Error(`build command failed to start: ${error.message}`))
539
+ })
540
+ child.on('exit', (code, signal) => {
541
+ if (code === 0) {
542
+ resolve()
543
+ } else if (signal) {
544
+ reject(new Error(`build command failed: terminated by ${signal}`))
545
+ } else {
546
+ reject(new Error(`build command failed: exited with code ${code}`))
547
+ }
548
+ })
549
+ })
550
+ }
551
+
552
+ async function assertPathExists(filePath: string): Promise<void> {
553
+ if (!await pathExists(filePath)) {
554
+ throw new Error(`build-info file does not exist: ${filePath}`)
555
+ }
556
+ }
557
+
558
+ async function pathExists(filePath: string): Promise<boolean> {
559
+ try {
560
+ await fs.access(filePath)
561
+ return true
562
+ } catch {
563
+ return false
564
+ }
565
+ }
566
+
567
+ async function readJsonIfExists(filePath: string): Promise<unknown | undefined> {
568
+ try {
569
+ const content = await fs.readFile(filePath, 'utf-8')
570
+ return JSON.parse(content)
571
+ } catch {
572
+ return undefined
573
+ }
574
+ }
575
+
576
+ function getJsonId(value: unknown): string | undefined {
577
+ if (value && typeof value === 'object' && 'id' in value) {
578
+ const id = (value as { id?: unknown }).id
579
+ return typeof id === 'string' ? id : undefined
580
+ }
581
+ return undefined
582
+ }
583
+
584
+ function buildCacheKey(provenance: BuildInfoSourceProvenance): string {
585
+ return JSON.stringify({
586
+ repo: provenance.repo,
587
+ ref: provenance.ref,
588
+ commit: provenance.commit,
589
+ build: provenance.build
590
+ })
591
+ }
592
+
593
+ function normalizeBuildInfoJson(value: unknown): unknown {
594
+ return normalizeBuildInfoValue(value, [], getBuildInfoBasePath(value))
595
+ }
596
+
597
+ function normalizeBuildInfoValue(value: unknown, pathSegments: string[], basePath?: string): unknown {
598
+ if (pathSegments.length === 1 && pathSegments[0] === 'id') {
599
+ return BUILD_INFO_ID_PLACEHOLDER
600
+ }
601
+
602
+ if (typeof value === 'string') {
603
+ return normalizeBuildInfoString(value, basePath)
604
+ }
605
+
606
+ if (Array.isArray(value)) {
607
+ return value.map((item, index) => normalizeBuildInfoValue(item, [...pathSegments, String(index)], basePath))
608
+ }
609
+
610
+ if (value && typeof value === 'object') {
611
+ const objectValue = value as Record<string, unknown>
612
+ return Object.fromEntries(Object.entries(objectValue).map(([key, item]) => [
613
+ normalizeBuildInfoString(key, basePath),
614
+ normalizeBuildInfoValue(item, [...pathSegments, key], basePath)
615
+ ]))
616
+ }
617
+
618
+ return value
619
+ }
620
+
621
+ function normalizeBuildInfoString(value: string, basePath?: string): string {
622
+ let normalized = value
623
+ const basePathPattern = basePath && isAbsolutePath(basePath) ? pathPrefixPattern(basePath) : undefined
624
+
625
+ if (basePathPattern) {
626
+ normalized = normalized.replace(basePathPattern, BUILD_INFO_BASE_PATH_PLACEHOLDER)
627
+ }
628
+
629
+ return normalized.replace(CATAPULT_CHECKOUT_PATH_PATTERN, BUILD_INFO_BASE_PATH_PLACEHOLDER)
630
+ }
631
+
632
+ function getBuildInfoBasePath(value: unknown): string | undefined {
633
+ if (!value || typeof value !== 'object') {
634
+ return undefined
635
+ }
636
+
637
+ const input = (value as { input?: unknown }).input
638
+ if (!input || typeof input !== 'object') {
639
+ return undefined
640
+ }
641
+
642
+ const basePath = (input as { basePath?: unknown }).basePath
643
+ return typeof basePath === 'string' && basePath.length > 0 ? basePath : undefined
644
+ }
645
+
646
+ function pathPrefixPattern(value: string): RegExp | undefined {
647
+ const hasLeadingSeparator = /^[\\/]/.test(value)
648
+ const trimmed = value.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
649
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean)
650
+
651
+ if (segments.length === 0) {
652
+ return undefined
653
+ }
654
+
655
+ const source = `${hasLeadingSeparator ? '[/\\\\]+' : ''}${segments.map(escapeRegex).join('[/\\\\]+')}`
656
+ return new RegExp(`${source}(?=[/\\\\]|$)`, 'g')
657
+ }
658
+
659
+ function escapeRegex(value: string): string {
660
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
661
+ }
662
+
663
+ function isAbsolutePath(value: string): boolean {
664
+ return path.posix.isAbsolute(value) || path.win32.isAbsolute(value)
665
+ }
666
+
667
+ function findFirstJsonDifference(existing: unknown, generated: unknown, location = '$'): string {
668
+ if (Object.is(existing, generated)) {
669
+ return ''
670
+ }
671
+
672
+ if (Array.isArray(existing) || Array.isArray(generated)) {
673
+ if (!Array.isArray(existing) || !Array.isArray(generated)) {
674
+ return `${location} type differs: committed ${jsonType(existing)}, generated ${jsonType(generated)}`
675
+ }
676
+ if (existing.length !== generated.length) {
677
+ return `${location} length differs: committed ${existing.length}, generated ${generated.length}`
678
+ }
679
+ for (let i = 0; i < existing.length; i++) {
680
+ const difference = findFirstJsonDifference(existing[i], generated[i], `${location}[${i}]`)
681
+ if (difference) {
682
+ return difference
683
+ }
684
+ }
685
+ return ''
686
+ }
687
+
688
+ if (isRecord(existing) || isRecord(generated)) {
689
+ if (!isRecord(existing) || !isRecord(generated)) {
690
+ return `${location} type differs: committed ${jsonType(existing)}, generated ${jsonType(generated)}`
691
+ }
692
+
693
+ const keys = Array.from(new Set([...Object.keys(existing), ...Object.keys(generated)])).sort()
694
+ for (const key of keys) {
695
+ const nextLocation = jsonPath(location, key)
696
+ if (!(key in existing)) {
697
+ return `${nextLocation} is missing from committed build-info`
698
+ }
699
+ if (!(key in generated)) {
700
+ return `${nextLocation} is missing from generated build-info`
701
+ }
702
+
703
+ const difference = findFirstJsonDifference(existing[key], generated[key], nextLocation)
704
+ if (difference) {
705
+ return difference
706
+ }
707
+ }
708
+
709
+ return ''
710
+ }
711
+
712
+ return `${location} differs: committed ${formatJsonScalar(existing)}, generated ${formatJsonScalar(generated)}`
713
+ }
714
+
715
+ function isRecord(value: unknown): value is Record<string, unknown> {
716
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
717
+ }
718
+
719
+ function jsonPath(location: string, key: string): string {
720
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${location}.${key}` : `${location}[${JSON.stringify(key)}]`
721
+ }
722
+
723
+ function jsonType(value: unknown): string {
724
+ if (Array.isArray(value)) {
725
+ return 'array'
726
+ }
727
+ if (value === null) {
728
+ return 'null'
729
+ }
730
+ return typeof value
731
+ }
732
+
733
+ function formatJsonScalar(value: unknown): string {
734
+ const formatted = JSON.stringify(value)
735
+ return formatted && formatted.length > 120 ? `${formatted.slice(0, 117)}...` : formatted ?? String(value)
736
+ }
737
+
738
+ function sortJson(value: unknown): unknown {
739
+ if (Array.isArray(value)) {
740
+ return value.map(sortJson)
741
+ }
742
+ if (value && typeof value === 'object') {
743
+ const objectValue = value as Record<string, unknown>
744
+ return Object.fromEntries(Object.keys(objectValue).sort().map(key => [key, sortJson(objectValue[key])]))
745
+ }
746
+ return value
747
+ }
748
+
749
+ function isPathWithin(root: string, candidate: string): boolean {
750
+ const relative = path.relative(path.resolve(root), path.resolve(candidate))
751
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
752
+ }
753
+
754
+ function isPattern(value: string): boolean {
755
+ return /[*?]/.test(value)
756
+ }
757
+
758
+ function patternToRegex(pattern: string): RegExp {
759
+ const escaped = pattern.replace(/[-\\^$+?.()|[\]{}]/g, '\\$&')
760
+ .replace(/\*/g, '.*')
761
+ .replace(/\?/g, '.')
762
+ return new RegExp(`^${escaped}$`)
763
+ }
764
+
765
+ function commitMatches(actual: string, expected: string): boolean {
766
+ return actual === expected || actual.startsWith(expected)
767
+ }
768
+
769
+ function shortCommit(commit: string): string {
770
+ return commit.slice(0, 12)
771
+ }
772
+
773
+ function commandErrorMessage(error: unknown): string {
774
+ if (error && typeof error === 'object') {
775
+ const maybe = error as { message?: string; stderr?: string | Buffer; stdout?: string | Buffer }
776
+ const stderr = maybe.stderr ? String(maybe.stderr).trim() : ''
777
+ const stdout = maybe.stdout ? String(maybe.stdout).trim() : ''
778
+ return stderr || stdout || maybe.message || String(error)
779
+ }
780
+ return String(error)
781
+ }
782
+
783
+ function formatError(error: unknown): string {
784
+ return error instanceof Error ? error.message : String(error)
785
+ }