@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.
- package/README.md +249 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +12 -0
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/provenance.d.ts +3 -0
- package/dist/commands/provenance.d.ts.map +1 -0
- package/dist/commands/provenance.js +138 -0
- package/dist/commands/provenance.js.map +1 -0
- package/dist/lib/__tests__/deployer.spec.js +118 -1
- package/dist/lib/__tests__/deployer.spec.js.map +1 -1
- package/dist/lib/__tests__/provenance.spec.d.ts +2 -0
- package/dist/lib/__tests__/provenance.spec.d.ts.map +1 -0
- package/dist/lib/__tests__/provenance.spec.js +205 -0
- package/dist/lib/__tests__/provenance.spec.js.map +1 -0
- package/dist/lib/contracts/__tests__/repository.spec.js +243 -0
- package/dist/lib/contracts/__tests__/repository.spec.js.map +1 -1
- package/dist/lib/contracts/repository.d.ts +9 -1
- package/dist/lib/contracts/repository.d.ts.map +1 -1
- package/dist/lib/contracts/repository.js +93 -7
- package/dist/lib/contracts/repository.js.map +1 -1
- package/dist/lib/core/__tests__/assert-action.spec.d.ts +2 -0
- package/dist/lib/core/__tests__/assert-action.spec.d.ts.map +1 -0
- package/dist/lib/core/__tests__/assert-action.spec.js +377 -0
- package/dist/lib/core/__tests__/assert-action.spec.js.map +1 -0
- package/dist/lib/core/__tests__/engine.spec.js +80 -0
- package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
- package/dist/lib/core/__tests__/loader.spec.js +29 -0
- package/dist/lib/core/__tests__/loader.spec.js.map +1 -1
- package/dist/lib/core/__tests__/resolver.spec.js +383 -0
- package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
- package/dist/lib/core/engine.d.ts.map +1 -1
- package/dist/lib/core/engine.js +33 -0
- package/dist/lib/core/engine.js.map +1 -1
- package/dist/lib/core/loader.d.ts +1 -0
- package/dist/lib/core/loader.d.ts.map +1 -1
- package/dist/lib/core/loader.js +6 -1
- package/dist/lib/core/loader.js.map +1 -1
- package/dist/lib/core/resolver.d.ts +2 -0
- package/dist/lib/core/resolver.d.ts.map +1 -1
- package/dist/lib/core/resolver.js +89 -0
- package/dist/lib/core/resolver.js.map +1 -1
- package/dist/lib/deployer.d.ts.map +1 -1
- package/dist/lib/deployer.js +21 -4
- package/dist/lib/deployer.js.map +1 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/parsers/__tests__/job.spec.js +77 -0
- package/dist/lib/parsers/__tests__/job.spec.js.map +1 -1
- package/dist/lib/parsers/__tests__/source.spec.d.ts +2 -0
- package/dist/lib/parsers/__tests__/source.spec.d.ts.map +1 -0
- package/dist/lib/parsers/__tests__/source.spec.js +121 -0
- package/dist/lib/parsers/__tests__/source.spec.js.map +1 -0
- package/dist/lib/parsers/index.d.ts +1 -0
- package/dist/lib/parsers/index.d.ts.map +1 -1
- package/dist/lib/parsers/index.js +1 -0
- package/dist/lib/parsers/index.js.map +1 -1
- package/dist/lib/parsers/job.d.ts.map +1 -1
- package/dist/lib/parsers/job.js +11 -0
- package/dist/lib/parsers/job.js.map +1 -1
- package/dist/lib/parsers/source.d.ts +4 -0
- package/dist/lib/parsers/source.d.ts.map +1 -0
- package/dist/lib/parsers/source.js +107 -0
- package/dist/lib/parsers/source.js.map +1 -0
- package/dist/lib/provenance.d.ts +34 -0
- package/dist/lib/provenance.d.ts.map +1 -0
- package/dist/lib/provenance.js +645 -0
- package/dist/lib/provenance.js.map +1 -0
- package/dist/lib/types/actions.d.ts +18 -2
- package/dist/lib/types/actions.d.ts.map +1 -1
- package/dist/lib/types/actions.js +1 -0
- package/dist/lib/types/actions.js.map +1 -1
- package/dist/lib/types/contracts.d.ts +3 -0
- package/dist/lib/types/contracts.d.ts.map +1 -1
- package/dist/lib/types/definitions.d.ts +1 -0
- package/dist/lib/types/definitions.d.ts.map +1 -1
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.d.ts.map +1 -1
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/index.js.map +1 -1
- package/dist/lib/types/source.d.ts +24 -0
- package/dist/lib/types/source.d.ts.map +1 -0
- package/dist/lib/types/source.js +3 -0
- package/dist/lib/types/source.js.map +1 -0
- package/dist/lib/types/values.d.ts +33 -1
- package/dist/lib/types/values.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -2
- package/src/commands/index.ts +2 -1
- package/src/commands/list.ts +14 -1
- package/src/commands/provenance.ts +120 -0
- package/src/lib/__tests__/deployer.spec.ts +177 -1
- package/src/lib/__tests__/provenance.spec.ts +208 -0
- package/src/lib/contracts/__tests__/repository.spec.ts +270 -2
- package/src/lib/contracts/repository.ts +112 -14
- package/src/lib/core/__tests__/assert-action.spec.ts +474 -0
- package/src/lib/core/__tests__/engine.spec.ts +116 -0
- package/src/lib/core/__tests__/loader.spec.ts +34 -1
- package/src/lib/core/__tests__/resolver.spec.ts +444 -1
- package/src/lib/core/engine.ts +52 -0
- package/src/lib/core/loader.ts +8 -2
- package/src/lib/core/resolver.ts +116 -0
- package/src/lib/deployer.ts +28 -4
- package/src/lib/index.ts +4 -1
- package/src/lib/parsers/__tests__/job.spec.ts +81 -0
- package/src/lib/parsers/__tests__/source.spec.ts +134 -0
- package/src/lib/parsers/index.ts +1 -0
- package/src/lib/parsers/job.ts +14 -2
- package/src/lib/parsers/source.ts +129 -0
- package/src/lib/provenance.ts +785 -0
- package/src/lib/types/actions.ts +22 -1
- package/src/lib/types/contracts.ts +4 -1
- package/src/lib/types/definitions.ts +7 -0
- package/src/lib/types/index.ts +1 -0
- package/src/lib/types/source.ts +26 -0
- package/src/lib/types/values.ts +71 -0
package/src/lib/core/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/core/resolver.ts
CHANGED
|
@@ -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}`)
|
package/src/lib/deployer.ts
CHANGED
|
@@ -261,13 +261,37 @@ export class Deployer {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
// Check job-level skip conditions before execution
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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:
|
|
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
|
+
})
|
package/src/lib/parsers/index.ts
CHANGED
package/src/lib/parsers/job.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|