@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
@@ -7,6 +7,7 @@ import { parseConstants } from '../parsers/constants'
7
7
 
8
8
  export interface ProjectLoaderOptions {
9
9
  loadStdTemplates?: boolean
10
+ loadContracts?: boolean
10
11
  }
11
12
 
12
13
  export class ProjectLoader {
@@ -28,7 +29,9 @@ export class ProjectLoader {
28
29
 
29
30
  async load() {
30
31
  // Load all contracts from the project root first
31
- await this.contractRepository.loadFrom(this.projectRoot)
32
+ if (this.options.loadContracts !== false) {
33
+ await this.contractRepository.loadFrom(this.projectRoot)
34
+ }
32
35
 
33
36
  // Load standard library templates (unless disabled)
34
37
  if (this.options.loadStdTemplates !== false) {
@@ -119,6 +122,9 @@ export class ProjectLoader {
119
122
  this.templates.set(template.name, template)
120
123
  continue
121
124
  }
125
+ if (raw && typeof raw === 'object' && (raw.type === 'source' || raw.type === 'constants')) {
126
+ continue
127
+ }
122
128
 
123
129
  const job = parseJob(content)
124
130
  job._path = filePath
@@ -244,4 +250,4 @@ export class ProjectLoader {
244
250
  return false
245
251
  }
246
252
  }
247
- }
253
+ }
@@ -8,6 +8,8 @@ import {
8
8
  ComputeCreateValue,
9
9
  ComputeCreate2Value,
10
10
  ReadBalanceValue,
11
+ GetStorageAtValue,
12
+ ComputeSlotValue,
11
13
  BasicArithmeticValue,
12
14
  CallValue,
13
15
  ContractExistsValue,
@@ -197,6 +199,10 @@ export class ValueResolver {
197
199
  return this.resolveComputeCreate2(resolvedArgs as ComputeCreate2Value['arguments'])
198
200
  case 'read-balance':
199
201
  return this.resolveReadBalance(resolvedArgs as ReadBalanceValue['arguments'], context)
202
+ case 'get-storage-at':
203
+ return this.resolveGetStorageAt(resolvedArgs as GetStorageAtValue['arguments'], context)
204
+ case 'compute-slot':
205
+ return this.resolveComputeSlot(resolvedArgs as ComputeSlotValue['arguments'])
200
206
  case 'basic-arithmetic':
201
207
  return this.resolveBasicArithmetic(resolvedArgs as BasicArithmeticValue['arguments'])
202
208
  case 'call':
@@ -375,6 +381,116 @@ export class ValueResolver {
375
381
  return balance.toString()
376
382
  }
377
383
 
384
+ private async resolveGetStorageAt(args: GetStorageAtValue['arguments'], context: ExecutionContext): Promise<string> {
385
+ const { address, slot } = args
386
+
387
+ // Check if the address is a valid address
388
+ if (!isAddress(address)) {
389
+ throw new Error(`Invalid address: ${address}`)
390
+ }
391
+
392
+ // Normalize the slot to a BigInt
393
+ // After resolution, slot should be a string or number
394
+ const slotValue = ethers.toBigInt(slot as string | number)
395
+
396
+ const storageValue = await context.provider.getStorage(address, slotValue)
397
+ // getStorage returns a hex string, ensure it's 32 bytes (64 hex chars + 0x)
398
+ return ethers.hexlify(storageValue)
399
+ }
400
+
401
+ /**
402
+ * Computes EVM storage slots for the common Solidity storage layouts.
403
+ * Always returns a 32-byte, 0x-prefixed lowercase hex string so the result can
404
+ * be fed straight into `get-storage-at` or nested as the `slot` of another
405
+ * `compute-slot` (e.g. nested mappings, structs inside mappings, ...).
406
+ */
407
+ private resolveComputeSlot(args: ComputeSlotValue['arguments']): string {
408
+ if (!args || typeof args !== 'object' || typeof (args as any).kind !== 'string') {
409
+ throw new Error('compute-slot: "kind" is required')
410
+ }
411
+
412
+ switch (args.kind) {
413
+ case 'mapping': {
414
+ const { slot, key, keyType } = args
415
+ if (slot === undefined || slot === null) {
416
+ throw new Error('compute-slot (mapping): "slot" is required')
417
+ }
418
+ if (key === undefined || key === null) {
419
+ throw new Error('compute-slot (mapping): "key" is required')
420
+ }
421
+ const baseSlot = ethers.toBigInt(slot as string | number)
422
+ const type = (keyType as string) || 'uint256'
423
+ try {
424
+ // mapping value slot = keccak256(h(key) . p)
425
+ // Dynamic key types (string/bytes) are packed; value types are ABI-encoded (left-padded to 32 bytes).
426
+ if (type === 'string' || type === 'bytes') {
427
+ return ethers.solidityPackedKeccak256([type, 'uint256'], [key, baseSlot])
428
+ }
429
+ const encoded = ethers.AbiCoder.defaultAbiCoder().encode([type, 'uint256'], [key, baseSlot])
430
+ return ethers.keccak256(encoded)
431
+ } catch (error) {
432
+ throw new Error(`compute-slot (mapping): failed to encode key as "${type}": ${error instanceof Error ? error.message : String(error)}`)
433
+ }
434
+ }
435
+
436
+ case 'dynamic-array': {
437
+ const { slot, index, elementSize } = args
438
+ if (slot === undefined || slot === null) {
439
+ throw new Error('compute-slot (dynamic-array): "slot" is required')
440
+ }
441
+ const baseSlot = ethers.toBigInt(slot as string | number)
442
+ const idx = index === undefined || index === null ? 0n : ethers.toBigInt(index as string | number)
443
+ const size = elementSize === undefined || elementSize === null ? 1n : ethers.toBigInt(elementSize as string | number)
444
+ // element slot = keccak256(slot) + index * elementSize
445
+ const dataStart = ethers.toBigInt(ethers.keccak256(ethers.toBeHex(baseSlot, 32)))
446
+ return ethers.toBeHex(dataStart + idx * size, 32)
447
+ }
448
+
449
+ case 'struct-field': {
450
+ const { slot, offset } = args
451
+ if (slot === undefined || slot === null) {
452
+ throw new Error('compute-slot (struct-field): "slot" is required')
453
+ }
454
+ if (offset === undefined || offset === null) {
455
+ throw new Error('compute-slot (struct-field): "offset" is required')
456
+ }
457
+ const result = ethers.toBigInt(slot as string | number) + ethers.toBigInt(offset as string | number)
458
+ return ethers.toBeHex(result, 32)
459
+ }
460
+
461
+ case 'erc7201': {
462
+ const { id } = args
463
+ if (typeof id !== 'string' || id.length === 0) {
464
+ throw new Error('compute-slot (erc7201): "id" must be a non-empty string')
465
+ }
466
+ // keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~bytes32(uint256(0xff))
467
+ const idHash = ethers.toBigInt(ethers.keccak256(ethers.toUtf8Bytes(id)))
468
+ const inner = ethers.keccak256(ethers.toBeHex(idHash - 1n, 32))
469
+ const mask = ((1n << 256n) - 1n) ^ 0xffn
470
+ return ethers.toBeHex(ethers.toBigInt(inner) & mask, 32)
471
+ }
472
+
473
+ case 'eip1967': {
474
+ const labels: Record<string, string> = {
475
+ implementation: 'eip1967.proxy.implementation',
476
+ admin: 'eip1967.proxy.admin',
477
+ beacon: 'eip1967.proxy.beacon',
478
+ }
479
+ const name = args.name as string
480
+ const label = labels[name]
481
+ if (!label) {
482
+ throw new Error(`compute-slot (eip1967): "name" must be one of implementation, admin, beacon (got "${name}")`)
483
+ }
484
+ // slot = keccak256("eip1967.proxy.<name>") - 1
485
+ const slot = ethers.toBigInt(ethers.keccak256(ethers.toUtf8Bytes(label))) - 1n
486
+ return ethers.toBeHex(slot, 32)
487
+ }
488
+
489
+ default:
490
+ throw new Error(`compute-slot: unknown kind "${(args as any).kind}"`)
491
+ }
492
+ }
493
+
378
494
  private resolveBasicArithmetic(args: BasicArithmeticValue['arguments']): string | boolean {
379
495
  if (!args.values || args.values.length < 2) {
380
496
  throw new Error(`basic-arithmetic requires at least 2 values, got ${args.values?.length ?? 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
+ }