@0xsequence/catapult 1.3.16 → 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 (131) hide show
  1. package/README.md +250 -1
  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/commands/run.d.ts.map +1 -1
  17. package/dist/commands/run.js +7 -4
  18. package/dist/commands/run.js.map +1 -1
  19. package/dist/lib/__tests__/deployer.spec.js +118 -1
  20. package/dist/lib/__tests__/deployer.spec.js.map +1 -1
  21. package/dist/lib/__tests__/network-utils.spec.js +53 -8
  22. package/dist/lib/__tests__/network-utils.spec.js.map +1 -1
  23. package/dist/lib/__tests__/provenance.spec.d.ts +2 -0
  24. package/dist/lib/__tests__/provenance.spec.d.ts.map +1 -0
  25. package/dist/lib/__tests__/provenance.spec.js +205 -0
  26. package/dist/lib/__tests__/provenance.spec.js.map +1 -0
  27. package/dist/lib/contracts/__tests__/repository.spec.js +243 -0
  28. package/dist/lib/contracts/__tests__/repository.spec.js.map +1 -1
  29. package/dist/lib/contracts/repository.d.ts +9 -1
  30. package/dist/lib/contracts/repository.d.ts.map +1 -1
  31. package/dist/lib/contracts/repository.js +93 -7
  32. package/dist/lib/contracts/repository.js.map +1 -1
  33. package/dist/lib/core/__tests__/assert-action.spec.d.ts +2 -0
  34. package/dist/lib/core/__tests__/assert-action.spec.d.ts.map +1 -0
  35. package/dist/lib/core/__tests__/assert-action.spec.js +377 -0
  36. package/dist/lib/core/__tests__/assert-action.spec.js.map +1 -0
  37. package/dist/lib/core/__tests__/engine.spec.js +80 -0
  38. package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
  39. package/dist/lib/core/__tests__/loader.spec.js +29 -0
  40. package/dist/lib/core/__tests__/loader.spec.js.map +1 -1
  41. package/dist/lib/core/__tests__/resolver.spec.js +383 -0
  42. package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
  43. package/dist/lib/core/engine.d.ts.map +1 -1
  44. package/dist/lib/core/engine.js +33 -0
  45. package/dist/lib/core/engine.js.map +1 -1
  46. package/dist/lib/core/loader.d.ts +1 -0
  47. package/dist/lib/core/loader.d.ts.map +1 -1
  48. package/dist/lib/core/loader.js +6 -1
  49. package/dist/lib/core/loader.js.map +1 -1
  50. package/dist/lib/core/resolver.d.ts +2 -0
  51. package/dist/lib/core/resolver.d.ts.map +1 -1
  52. package/dist/lib/core/resolver.js +89 -0
  53. package/dist/lib/core/resolver.js.map +1 -1
  54. package/dist/lib/deployer.d.ts.map +1 -1
  55. package/dist/lib/deployer.js +21 -4
  56. package/dist/lib/deployer.js.map +1 -1
  57. package/dist/lib/index.d.ts +1 -0
  58. package/dist/lib/index.d.ts.map +1 -1
  59. package/dist/lib/index.js +1 -0
  60. package/dist/lib/index.js.map +1 -1
  61. package/dist/lib/parsers/__tests__/job.spec.js +77 -0
  62. package/dist/lib/parsers/__tests__/job.spec.js.map +1 -1
  63. package/dist/lib/parsers/__tests__/source.spec.d.ts +2 -0
  64. package/dist/lib/parsers/__tests__/source.spec.d.ts.map +1 -0
  65. package/dist/lib/parsers/__tests__/source.spec.js +121 -0
  66. package/dist/lib/parsers/__tests__/source.spec.js.map +1 -0
  67. package/dist/lib/parsers/index.d.ts +1 -0
  68. package/dist/lib/parsers/index.d.ts.map +1 -1
  69. package/dist/lib/parsers/index.js +1 -0
  70. package/dist/lib/parsers/index.js.map +1 -1
  71. package/dist/lib/parsers/job.d.ts.map +1 -1
  72. package/dist/lib/parsers/job.js +11 -0
  73. package/dist/lib/parsers/job.js.map +1 -1
  74. package/dist/lib/parsers/source.d.ts +4 -0
  75. package/dist/lib/parsers/source.d.ts.map +1 -0
  76. package/dist/lib/parsers/source.js +107 -0
  77. package/dist/lib/parsers/source.js.map +1 -0
  78. package/dist/lib/provenance.d.ts +34 -0
  79. package/dist/lib/provenance.d.ts.map +1 -0
  80. package/dist/lib/provenance.js +645 -0
  81. package/dist/lib/provenance.js.map +1 -0
  82. package/dist/lib/types/actions.d.ts +18 -2
  83. package/dist/lib/types/actions.d.ts.map +1 -1
  84. package/dist/lib/types/actions.js +1 -0
  85. package/dist/lib/types/actions.js.map +1 -1
  86. package/dist/lib/types/contracts.d.ts +3 -0
  87. package/dist/lib/types/contracts.d.ts.map +1 -1
  88. package/dist/lib/types/definitions.d.ts +1 -0
  89. package/dist/lib/types/definitions.d.ts.map +1 -1
  90. package/dist/lib/types/index.d.ts +1 -0
  91. package/dist/lib/types/index.d.ts.map +1 -1
  92. package/dist/lib/types/index.js +1 -0
  93. package/dist/lib/types/index.js.map +1 -1
  94. package/dist/lib/types/source.d.ts +24 -0
  95. package/dist/lib/types/source.d.ts.map +1 -0
  96. package/dist/lib/types/source.js +3 -0
  97. package/dist/lib/types/source.js.map +1 -0
  98. package/dist/lib/types/values.d.ts +33 -1
  99. package/dist/lib/types/values.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/cli.ts +3 -2
  102. package/src/commands/index.ts +2 -1
  103. package/src/commands/list.ts +14 -1
  104. package/src/commands/provenance.ts +120 -0
  105. package/src/commands/run.ts +11 -6
  106. package/src/lib/__tests__/deployer.spec.ts +177 -1
  107. package/src/lib/__tests__/network-utils.spec.ts +63 -14
  108. package/src/lib/__tests__/provenance.spec.ts +208 -0
  109. package/src/lib/contracts/__tests__/repository.spec.ts +270 -2
  110. package/src/lib/contracts/repository.ts +112 -14
  111. package/src/lib/core/__tests__/assert-action.spec.ts +474 -0
  112. package/src/lib/core/__tests__/engine.spec.ts +116 -0
  113. package/src/lib/core/__tests__/loader.spec.ts +34 -1
  114. package/src/lib/core/__tests__/resolver.spec.ts +444 -1
  115. package/src/lib/core/engine.ts +52 -0
  116. package/src/lib/core/loader.ts +8 -2
  117. package/src/lib/core/resolver.ts +116 -0
  118. package/src/lib/deployer.ts +28 -4
  119. package/src/lib/index.ts +4 -1
  120. package/src/lib/parsers/__tests__/job.spec.ts +81 -0
  121. package/src/lib/parsers/__tests__/source.spec.ts +134 -0
  122. package/src/lib/parsers/index.ts +1 -0
  123. package/src/lib/parsers/job.ts +14 -2
  124. package/src/lib/parsers/source.ts +129 -0
  125. package/src/lib/provenance.ts +785 -0
  126. package/src/lib/types/actions.ts +22 -1
  127. package/src/lib/types/contracts.ts +4 -1
  128. package/src/lib/types/definitions.ts +7 -0
  129. package/src/lib/types/index.ts +1 -0
  130. package/src/lib/types/source.ts +26 -0
  131. package/src/lib/types/values.ts +71 -0
@@ -261,13 +261,37 @@ export class Deployer {
261
261
  }
262
262
 
263
263
  // Check job-level skip conditions before execution
264
- if (job.skip_condition) {
265
- const shouldSkip = await engine.evaluateSkipConditions(job.skip_condition, context, new Map())
264
+ // skip_if is a pure gate (no post-check), skip_condition has both pre-skip and post-check
265
+ const skipIfConditions = job.skip_if
266
+ const skipConditions = job.skip_condition
267
+
268
+ if (skipIfConditions || skipConditions) {
269
+ // Evaluate skip_if first (pure gate)
270
+ let shouldSkip = false
271
+ let skipReason: string = 'skip_condition'
272
+
273
+ if (skipIfConditions) {
274
+ const skipIfResult = await engine.evaluateSkipConditions(skipIfConditions, context, new Map())
275
+ if (skipIfResult) {
276
+ shouldSkip = true
277
+ skipReason = 'skip_if'
278
+ }
279
+ }
280
+
281
+ // If not skipped by skip_if, check skip_condition
282
+ if (!shouldSkip && skipConditions) {
283
+ const skipConditionResult = await engine.evaluateSkipConditions(skipConditions, context, new Map())
284
+ if (skipConditionResult) {
285
+ shouldSkip = true
286
+ skipReason = 'skip_condition'
287
+ }
288
+ }
289
+
266
290
  if (shouldSkip) {
267
291
  // Store skipped result
268
292
  this.results.get(job.name)!.outputs.set(network.chainId, {
269
293
  status: 'skipped',
270
- data: `Job "${job.name}" skipped due to skip condition`
294
+ data: `Job "${job.name}" skipped due to ${skipReason}`
271
295
  })
272
296
 
273
297
  this.events.emitEvent({
@@ -276,7 +300,7 @@ export class Deployer {
276
300
  data: {
277
301
  jobName: job.name,
278
302
  networkName: network.name,
279
- reason: 'skip_condition'
303
+ reason: skipReason
280
304
  }
281
305
  })
282
306
 
package/src/lib/index.ts CHANGED
@@ -10,5 +10,8 @@ export * as parsers from './parsers'
10
10
  // Export the deployer
11
11
  export * from './deployer'
12
12
 
13
+ // Export provenance helpers
14
+ export * from './provenance'
15
+
13
16
  // This will be the main entry point for the lib
14
- // Additional functionality will be added here as the lib grows
17
+ // Additional functionality will be added here as the lib grows
@@ -355,4 +355,85 @@ actions:
355
355
  arguments: { address: '0xabc' }
356
356
  })
357
357
  })
358
+
359
+ it('should parse job-level skip_if conditions', () => {
360
+ const yamlContent = `
361
+ name: "job-with-skip-if"
362
+ version: "1"
363
+ skip_if:
364
+ - type: "contract-exists"
365
+ arguments:
366
+ address: "0xdef"
367
+ actions:
368
+ - name: "a1"
369
+ template: "t1"
370
+ arguments: {}
371
+ `
372
+ const job = parseJob(yamlContent)
373
+ expect(job.skip_if).toHaveLength(1)
374
+ expect(job.skip_if?.[0]).toEqual({
375
+ type: 'contract-exists',
376
+ arguments: { address: '0xdef' }
377
+ })
378
+ })
379
+
380
+ it('should parse both skip_condition and skip_if on a job', () => {
381
+ const yamlContent = `
382
+ name: "job-with-both"
383
+ version: "1"
384
+ skip_condition:
385
+ - type: "contract-exists"
386
+ arguments:
387
+ address: "0xabc"
388
+ skip_if:
389
+ - type: "job-completed"
390
+ arguments:
391
+ job: "other-job"
392
+ actions:
393
+ - name: "a1"
394
+ template: "t1"
395
+ arguments: {}
396
+ `
397
+ const job = parseJob(yamlContent)
398
+ expect(job.skip_condition).toHaveLength(1)
399
+ expect(job.skip_condition?.[0]).toEqual({
400
+ type: 'contract-exists',
401
+ arguments: { address: '0xabc' }
402
+ })
403
+ expect(job.skip_if).toHaveLength(1)
404
+ expect(job.skip_if?.[0]).toEqual({
405
+ type: 'job-completed',
406
+ arguments: { job: 'other-job' }
407
+ })
408
+ })
409
+
410
+ it('should throw when skip_if is not an array', () => {
411
+ const yamlContent = `
412
+ name: "job-with-invalid-skip-if"
413
+ version: "1"
414
+ skip_if:
415
+ type: "contract-exists"
416
+ arguments:
417
+ address: "0xabc"
418
+ actions:
419
+ - name: "a1"
420
+ template: "t1"
421
+ arguments: {}
422
+ `
423
+ expect(() => parseJob(yamlContent)).toThrow('"skip_if" must be an array if provided')
424
+ })
425
+
426
+ it('should throw when skip_if contains invalid condition', () => {
427
+ const yamlContent = `
428
+ name: "job-with-invalid-skip-if"
429
+ version: "1"
430
+ skip_if:
431
+ - type: "invalid-type"
432
+ actions:
433
+ - name: "a1"
434
+ template: "t1"
435
+ arguments: {}
436
+ `
437
+ expect(() => parseJob(yamlContent)).toThrow('"skip_if" contains an invalid condition entry')
438
+ })
358
439
  })
@@ -0,0 +1,134 @@
1
+ import { mergeSourceProvenance, parseSourceDocument } from '../source'
2
+
3
+ describe('Source Provenance Parsing', () => {
4
+ it('should return null for non-source YAML documents', () => {
5
+ const result = parseSourceDocument(`
6
+ type: constants
7
+ constants:
8
+ value: 1
9
+ `)
10
+
11
+ expect(result).toBeNull()
12
+ })
13
+
14
+ it('should parse source provenance documents', () => {
15
+ const result = parseSourceDocument(`
16
+ type: source
17
+ build_info:
18
+ "./stage1.json":
19
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
20
+ ref: "v3.0.0-rc.5"
21
+ commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
22
+ build: "forge build --build-info"
23
+ contracts:
24
+ "src/Stage1Module.sol:Stage1Module":
25
+ ref: "stage1-special"
26
+ `)
27
+
28
+ expect(result).not.toBeNull()
29
+ expect(result!.build_info['./stage1.json'].repo).toBe('https://github.com/0xsequence/wallet-contracts-v3')
30
+ expect(result!.build_info['./stage1.json'].contracts?.['src/Stage1Module.sol:Stage1Module'].ref).toBe('stage1-special')
31
+ })
32
+
33
+ it('should require repo on each build-info provenance entry', () => {
34
+ const result = parseSourceDocument(`
35
+ type: source
36
+ build_info:
37
+ "./stage1.json":
38
+ ref: "v3.0.0-rc.5"
39
+ `)
40
+
41
+ expect(result).not.toBeNull()
42
+ expect(result!.build_info).toEqual({})
43
+ expect(result!.warnings).toEqual([
44
+ expect.stringContaining('build_info["./stage1.json"].repo')
45
+ ])
46
+ })
47
+
48
+ it('should validate optional string fields', () => {
49
+ const result = parseSourceDocument(`
50
+ type: source
51
+ build_info:
52
+ "./stage1.json":
53
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
54
+ commit: 123
55
+ `)
56
+
57
+ expect(result).not.toBeNull()
58
+ expect(result!.build_info).toEqual({})
59
+ expect(result!.warnings).toEqual([
60
+ expect.stringContaining('commit')
61
+ ])
62
+ })
63
+
64
+ it('should reject unsupported fields', () => {
65
+ const result = parseSourceDocument(`
66
+ type: source
67
+ build_info:
68
+ "./stage1.json":
69
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
70
+ path: "contracts"
71
+ `)
72
+
73
+ expect(result).not.toBeNull()
74
+ expect(result!.build_info).toEqual({})
75
+ expect(result!.warnings).toEqual([
76
+ expect.stringContaining('path is not supported')
77
+ ])
78
+ })
79
+
80
+ it('should quote build-info paths in validation errors', () => {
81
+ const result = parseSourceDocument(`
82
+ type: source
83
+ build_info:
84
+ "./stage1.json":
85
+ ref: "v3.0.0-rc.5"
86
+ `)
87
+
88
+ expect(result).not.toBeNull()
89
+ expect(result!.warnings).toEqual([
90
+ expect.stringContaining('build_info["./stage1.json"].repo')
91
+ ])
92
+ })
93
+
94
+ it('should keep valid entries when sibling entries are invalid', () => {
95
+ const result = parseSourceDocument(`
96
+ type: source
97
+ build_info:
98
+ "./stage1.json":
99
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
100
+ "./bad.json":
101
+ typo_field: true
102
+ `)
103
+
104
+ expect(result).not.toBeNull()
105
+ expect(Object.keys(result!.build_info)).toEqual(['./stage1.json'])
106
+ expect(result!.warnings).toEqual([
107
+ expect.stringContaining('typo_field is not supported')
108
+ ])
109
+ })
110
+
111
+ describe('mergeSourceProvenance', () => {
112
+ it('should merge contract overrides into build-info provenance', () => {
113
+ const result = mergeSourceProvenance({
114
+ repo: 'https://github.com/0xsequence/wallet-contracts-v3',
115
+ ref: 'v3.0.0-rc.5',
116
+ commit: '0d9061f229da73edae890e6fdd1fbf753028df6d',
117
+ contracts: {
118
+ 'src/Stage1Module.sol:Stage1Module': {
119
+ ref: 'stage1-special'
120
+ }
121
+ }
122
+ }, {
123
+ ref: 'stage1-special'
124
+ })
125
+
126
+ expect(result).toEqual({
127
+ repo: 'https://github.com/0xsequence/wallet-contracts-v3',
128
+ ref: 'stage1-special',
129
+ commit: '0d9061f229da73edae890e6fdd1fbf753028df6d'
130
+ })
131
+ expect(result).not.toHaveProperty('contracts')
132
+ })
133
+ })
134
+ })
@@ -3,3 +3,4 @@ export * from './template'
3
3
  export * from './job'
4
4
  export * from './buildinfo'
5
5
  export * from './constants'
6
+ export * from './source'
@@ -110,7 +110,7 @@ export function parseJob(yamlContent: string): Job {
110
110
  throw new Error(`Invalid job "${rawObject.name}": "min_evm_version" must be a string if provided.`)
111
111
  }
112
112
 
113
- // --- Optional: validate job-level skip_condition and constants ---
113
+ // --- Optional: validate job-level skip_condition and skip_if ---
114
114
  if (rawObject.skip_condition !== undefined) {
115
115
  if (!Array.isArray(rawObject.skip_condition)) {
116
116
  throw new Error(`Invalid job "${rawObject.name}": "skip_condition" must be an array if provided.`)
@@ -122,6 +122,17 @@ export function parseJob(yamlContent: string): Job {
122
122
  }
123
123
  }
124
124
 
125
+ if (rawObject.skip_if !== undefined) {
126
+ if (!Array.isArray(rawObject.skip_if)) {
127
+ throw new Error(`Invalid job "${rawObject.name}": "skip_if" must be an array if provided.`)
128
+ }
129
+ for (const condition of rawObject.skip_if) {
130
+ if (!isCondition(condition)) {
131
+ throw new Error(`Invalid job "${rawObject.name}": "skip_if" contains an invalid condition entry.`)
132
+ }
133
+ }
134
+ }
135
+
125
136
  if (rawObject.constants !== undefined) {
126
137
  if (typeof rawObject.constants !== 'object' || rawObject.constants === null || Array.isArray(rawObject.constants)) {
127
138
  throw new Error(`Invalid job "${rawObject.name}": "constants" field must be an object if provided.`)
@@ -141,8 +152,9 @@ export function parseJob(yamlContent: string): Job {
141
152
  min_evm_version: rawObject.min_evm_version,
142
153
  deprecated: rawObject.deprecated === true,
143
154
  skip_condition: rawObject.skip_condition as Condition[] | undefined,
155
+ skip_if: rawObject.skip_if as Condition[] | undefined,
144
156
  constants: rawObject.constants,
145
157
  }
146
158
 
147
159
  return job
148
- }
160
+ }
@@ -0,0 +1,129 @@
1
+ import { parse as parseYaml, YAMLParseError } from 'yaml'
2
+ import { BuildInfoSourceProvenance, SourceDocument, SourceProvenance, SourceProvenanceOverride } from '../types'
3
+
4
+ const STRING_FIELDS = ['repo', 'ref', 'commit', 'build'] as const
5
+ const BUILD_INFO_FIELDS = new Set<string>([...STRING_FIELDS, 'contracts'])
6
+ const CONTRACT_OVERRIDE_FIELDS = new Set<string>(STRING_FIELDS)
7
+
8
+ function isPlainObject(value: any): value is Record<string, any> {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ function buildInfoLabel(buildInfoPath: string): string {
13
+ return `build_info[${JSON.stringify(buildInfoPath)}]`
14
+ }
15
+
16
+ function contractOverrideLabel(buildInfoPath: string, contractName: string): string {
17
+ return `${buildInfoLabel(buildInfoPath)}.contracts[${JSON.stringify(contractName)}]`
18
+ }
19
+
20
+ function validateKnownFields(value: Record<string, any>, label: string, allowedFields: Set<string>): void {
21
+ for (const field of Object.keys(value)) {
22
+ if (!allowedFields.has(field)) {
23
+ throw new Error(`Invalid source: ${label}.${field} is not supported.`)
24
+ }
25
+ }
26
+ }
27
+
28
+ function validateStringFields(value: Record<string, any>, label: string, requiredRepo: boolean): void {
29
+ if (requiredRepo && (typeof value.repo !== 'string' || value.repo.length === 0)) {
30
+ throw new Error(`Invalid source: ${label}.repo field is required and must be a non-empty string.`)
31
+ }
32
+
33
+ for (const field of STRING_FIELDS) {
34
+ if (value[field] !== undefined && typeof value[field] !== 'string') {
35
+ throw new Error(`Invalid source: ${label}.${field} must be a string if provided.`)
36
+ }
37
+ }
38
+ }
39
+
40
+ function validateBuildInfoProvenance(buildInfoPath: string, provenance: unknown): BuildInfoSourceProvenance {
41
+ if (!isPlainObject(provenance)) {
42
+ throw new Error(`Invalid source: ${buildInfoLabel(buildInfoPath)} must be an object.`)
43
+ }
44
+
45
+ const label = buildInfoLabel(buildInfoPath)
46
+ validateKnownFields(provenance, label, BUILD_INFO_FIELDS)
47
+ validateStringFields(provenance, label, true)
48
+
49
+ if (provenance.contracts !== undefined) {
50
+ if (!isPlainObject(provenance.contracts)) {
51
+ throw new Error(`Invalid source: ${label}.contracts must be an object if provided.`)
52
+ }
53
+
54
+ for (const [contractName, override] of Object.entries(provenance.contracts)) {
55
+ if (!contractName || typeof contractName !== 'string') {
56
+ throw new Error(`Invalid source: ${label}.contracts keys must be non-empty strings.`)
57
+ }
58
+ if (!isPlainObject(override)) {
59
+ throw new Error(`Invalid source: ${contractOverrideLabel(buildInfoPath, contractName)} must be an object.`)
60
+ }
61
+ const overrideLabel = contractOverrideLabel(buildInfoPath, contractName)
62
+ validateKnownFields(override, overrideLabel, CONTRACT_OVERRIDE_FIELDS)
63
+ validateStringFields(override, overrideLabel, false)
64
+ }
65
+ }
66
+
67
+ return provenance as BuildInfoSourceProvenance
68
+ }
69
+
70
+ /**
71
+ * Parses a YAML source provenance document.
72
+ * Returns null for YAML documents that are not marked with `type: "source"`.
73
+ */
74
+ export function parseSourceDocument(yamlContent: string): SourceDocument | null {
75
+ let rawObject: any
76
+ try {
77
+ rawObject = parseYaml(yamlContent)
78
+ } catch (e) {
79
+ if (e instanceof YAMLParseError) {
80
+ const line = (e as any).linePos?.[0]?.line ? ` at line ${(e as any).linePos[0].line}` : ''
81
+ throw new Error(`Failed to parse source YAML: ${e.message}${line}.`)
82
+ }
83
+ throw e
84
+ }
85
+
86
+ if (!rawObject || typeof rawObject !== 'object') {
87
+ return null
88
+ }
89
+
90
+ if (rawObject.type !== 'source') {
91
+ return null
92
+ }
93
+
94
+ if (!isPlainObject(rawObject.build_info)) {
95
+ throw new Error('Invalid source: "build_info" field is required and must be an object.')
96
+ }
97
+
98
+ const buildInfo: Record<string, BuildInfoSourceProvenance> = {}
99
+ const warnings: string[] = []
100
+
101
+ for (const [buildInfoPath, provenance] of Object.entries(rawObject.build_info)) {
102
+ if (!buildInfoPath || typeof buildInfoPath !== 'string') {
103
+ throw new Error('Invalid source: "build_info" keys must be non-empty strings.')
104
+ }
105
+
106
+ try {
107
+ buildInfo[buildInfoPath] = validateBuildInfoProvenance(buildInfoPath, provenance)
108
+ } catch (error) {
109
+ warnings.push(error instanceof Error ? error.message : String(error))
110
+ }
111
+ }
112
+
113
+ return {
114
+ type: 'source',
115
+ build_info: buildInfo,
116
+ warnings
117
+ }
118
+ }
119
+
120
+ export function mergeSourceProvenance(
121
+ base: BuildInfoSourceProvenance,
122
+ override?: SourceProvenanceOverride
123
+ ): SourceProvenance {
124
+ const { contracts, ...baseFields } = base
125
+ return {
126
+ ...baseFields,
127
+ ...(override || {})
128
+ }
129
+ }