@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
@@ -1679,7 +1679,7 @@ describe('Deployer', () => {
1679
1679
  const jobAResult = results.get('job-a')
1680
1680
  expect(jobAResult).toBeDefined()
1681
1681
  expect(jobAResult.outputs.get(mockNetwork1.chainId).status).toBe('skipped')
1682
- expect(jobAResult.outputs.get(mockNetwork1.chainId).data).toContain('skipped due to skip condition')
1682
+ expect(jobAResult.outputs.get(mockNetwork1.chainId).data).toContain('skip_condition')
1683
1683
 
1684
1684
  // Verify that job B ran successfully
1685
1685
  const jobBResult = results.get('job-b')
@@ -2090,4 +2090,180 @@ describe('Deployer', () => {
2090
2090
  )
2091
2091
  })
2092
2092
  })
2093
+
2094
+ describe('skip_if functionality', () => {
2095
+ it('should skip job when skip_if condition is true', async () => {
2096
+ const jobWithSkipIf: Job = {
2097
+ name: 'job-with-skip-if',
2098
+ version: '1.0.0',
2099
+ description: 'Job with skip_if condition',
2100
+ skip_if: [{ type: 'contract-exists', arguments: { address: '0xabc' } }],
2101
+ actions: [
2102
+ { name: 'action1', template: 'template1', arguments: {} }
2103
+ ]
2104
+ }
2105
+
2106
+ mockLoader.jobs.clear()
2107
+ mockLoader.jobs.set('job-with-skip-if', jobWithSkipIf)
2108
+ mockGraph.getExecutionOrder.mockReturnValue(['job-with-skip-if'])
2109
+
2110
+ // Mock engine.evaluateSkipConditions to return true (condition met)
2111
+ mockEngine.evaluateSkipConditions = jest.fn().mockResolvedValue(true)
2112
+
2113
+ const deployer = new Deployer(deployerOptions)
2114
+ await deployer.run()
2115
+
2116
+ // Job should be skipped
2117
+ const results = (deployer as any).results
2118
+ const jobResult = results.get('job-with-skip-if')
2119
+ expect(jobResult).toBeDefined()
2120
+ expect(jobResult.outputs.get(mockNetwork1.chainId).status).toBe('skipped')
2121
+ expect(jobResult.outputs.get(mockNetwork1.chainId).data).toContain('skip_if')
2122
+
2123
+ // executeJob should NOT be called for skipped job
2124
+ expect(mockEngine.executeJob).not.toHaveBeenCalled()
2125
+ })
2126
+
2127
+ it('should run job when skip_if condition is false', async () => {
2128
+ const jobWithSkipIf: Job = {
2129
+ name: 'job-with-skip-if-false',
2130
+ version: '1.0.0',
2131
+ description: 'Job with skip_if condition that is false',
2132
+ skip_if: [{ type: 'contract-exists', arguments: { address: '0xabc' } }],
2133
+ actions: [
2134
+ { name: 'action1', template: 'template1', arguments: {} }
2135
+ ]
2136
+ }
2137
+
2138
+ mockLoader.jobs.clear()
2139
+ mockLoader.jobs.set('job-with-skip-if-false', jobWithSkipIf)
2140
+ mockGraph.getExecutionOrder.mockReturnValue(['job-with-skip-if-false'])
2141
+
2142
+ // Mock engine.evaluateSkipConditions to return false (condition not met)
2143
+ mockEngine.evaluateSkipConditions = jest.fn().mockResolvedValue(false)
2144
+
2145
+ const deployer = new Deployer(deployerOptions)
2146
+ await deployer.run()
2147
+
2148
+ // Job should run normally
2149
+ const results = (deployer as any).results
2150
+ const jobResult = results.get('job-with-skip-if-false')
2151
+ expect(jobResult).toBeDefined()
2152
+ expect(jobResult.outputs.get(mockNetwork1.chainId).status).toBe('success')
2153
+
2154
+ // executeJob should be called
2155
+ expect(mockEngine.executeJob).toHaveBeenCalledWith(jobWithSkipIf, expect.anything())
2156
+ })
2157
+
2158
+ it('should skip job when skip_if OR skip_condition is true', async () => {
2159
+ const jobWithBoth: Job = {
2160
+ name: 'job-with-both',
2161
+ version: '1.0.0',
2162
+ description: 'Job with both skip_if and skip_condition',
2163
+ skip_condition: [{ type: 'contract-exists', arguments: { address: '0xabc' } }],
2164
+ skip_if: [{ type: 'job-completed', arguments: { job: 'other-job' } }],
2165
+ actions: [
2166
+ { name: 'action1', template: 'template1', arguments: {} }
2167
+ ]
2168
+ }
2169
+
2170
+ mockLoader.jobs.clear()
2171
+ mockLoader.jobs.set('job-with-both', jobWithBoth)
2172
+ mockGraph.getExecutionOrder.mockReturnValue(['job-with-both'])
2173
+
2174
+ // Mock engine.evaluateSkipConditions to return false for skip_condition but true for skip_if
2175
+ // The combined conditions should be evaluated together
2176
+ mockEngine.evaluateSkipConditions = jest.fn().mockResolvedValue(true)
2177
+
2178
+ const deployer = new Deployer(deployerOptions)
2179
+ await deployer.run()
2180
+
2181
+ // Job should be skipped (skip_if is true)
2182
+ const results = (deployer as any).results
2183
+ const jobResult = results.get('job-with-both')
2184
+ expect(jobResult).toBeDefined()
2185
+ expect(jobResult.outputs.get(mockNetwork1.chainId).status).toBe('skipped')
2186
+
2187
+ // executeJob should NOT be called
2188
+ expect(mockEngine.executeJob).not.toHaveBeenCalled()
2189
+ })
2190
+
2191
+ it('should NOT post-check skip_if after execution (only skip_condition is post-checked)', async () => {
2192
+ // This is the key test: skip_if should NOT cause post-execution failures
2193
+ const jobWithSkipIfOnly: Job = {
2194
+ name: 'job-skip-if-only',
2195
+ version: '1.0.0',
2196
+ description: 'Job with skip_if that remains false after execution',
2197
+ only_networks: [1], // Only run on mainnet to have predictable call count
2198
+ skip_if: [{ type: 'contract-exists', arguments: { address: '0xabc' } }],
2199
+ actions: [
2200
+ { name: 'action1', template: 'template1', arguments: {} }
2201
+ ]
2202
+ }
2203
+
2204
+ mockLoader.jobs.clear()
2205
+ mockLoader.jobs.set('job-skip-if-only', jobWithSkipIfOnly)
2206
+ mockGraph.getExecutionOrder.mockReturnValue(['job-skip-if-only'])
2207
+
2208
+ // Pre-skip: skip_if is false, so job runs
2209
+ mockEngine.evaluateSkipConditions = jest.fn().mockResolvedValue(false)
2210
+
2211
+ // Job runs successfully - no post-check for skip_if
2212
+ mockEngine.executeJob = jest.fn().mockResolvedValue(undefined)
2213
+
2214
+ const deployer = new Deployer(deployerOptions)
2215
+ await deployer.run()
2216
+
2217
+ // Job should succeed (no post-check failure for skip_if)
2218
+ const results = (deployer as any).results
2219
+ const jobResult = results.get('job-skip-if-only')
2220
+ expect(jobResult).toBeDefined()
2221
+ expect(jobResult.outputs.get(mockNetwork1.chainId).status).toBe('success')
2222
+
2223
+ // evaluateSkipConditions called once for pre-skip check (skip_if on mainnet only)
2224
+ // skip_if is NOT post-checked, so no additional calls from engine
2225
+ expect(mockEngine.evaluateSkipConditions).toHaveBeenCalledTimes(1)
2226
+ // executeJob should be called once (job ran on mainnet only)
2227
+ expect(mockEngine.executeJob).toHaveBeenCalledTimes(1)
2228
+ })
2229
+
2230
+ it('should still post-check skip_condition (existing behavior unchanged)', async () => {
2231
+ // Verify that skip_condition still gets post-checked
2232
+ // For this test, we need to use the real ExecutionEngine behavior
2233
+ // Since the mock doesn't easily support this, we'll verify the behavior indirectly
2234
+
2235
+ // The key is that skip_condition triggers post-check in the engine,
2236
+ // while skip_if does not. This is verified by the engine.spec.ts tests.
2237
+
2238
+ const jobWithSkipCondition: Job = {
2239
+ name: 'job-skip-condition',
2240
+ version: '1.0.0',
2241
+ description: 'Job with skip_condition',
2242
+ skip_condition: [{ type: 'contract-exists', arguments: { address: '0xabc' } }],
2243
+ actions: [
2244
+ { name: 'action1', template: 'template1', arguments: {} }
2245
+ ]
2246
+ }
2247
+
2248
+ mockLoader.jobs.clear()
2249
+ mockLoader.jobs.set('job-skip-condition', jobWithSkipCondition)
2250
+ mockGraph.getExecutionOrder.mockReturnValue(['job-skip-condition'])
2251
+
2252
+ // Pre-skip: skip_condition is false, so job runs
2253
+ mockEngine.evaluateSkipConditions = jest.fn().mockResolvedValue(false)
2254
+
2255
+ // Job runs successfully
2256
+ mockEngine.executeJob = jest.fn().mockResolvedValue(undefined)
2257
+
2258
+ const deployer = new Deployer(deployerOptions)
2259
+ await deployer.run()
2260
+
2261
+ // Job should succeed in this mock scenario
2262
+ // The actual post-check behavior is tested in engine.spec.ts
2263
+ const results = (deployer as any).results
2264
+ const jobResult = results.get('job-skip-condition')
2265
+ expect(jobResult).toBeDefined()
2266
+ expect(jobResult.outputs.get(mockNetwork1.chainId).status).toBe('success')
2267
+ })
2268
+ })
2093
2269
  })
@@ -0,0 +1,208 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import { execFile } from 'child_process'
5
+ import { promisify } from 'util'
6
+ import {
7
+ collectSourceProvenanceEntries,
8
+ generateBuildInfoFromSourceProvenance,
9
+ verifySourceProvenance
10
+ } from '../provenance'
11
+
12
+ const execFileAsync = promisify(execFile)
13
+
14
+ async function git(cwd: string, args: string[]): Promise<string> {
15
+ const result = await execFileAsync('git', args, { cwd })
16
+ return String(result.stdout).trim()
17
+ }
18
+
19
+ async function writeFile(filePath: string, content: string): Promise<void> {
20
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
21
+ await fs.writeFile(filePath, content)
22
+ }
23
+
24
+ function sourceYaml(repo: string, commit: string, build: string): string {
25
+ return `
26
+ type: source
27
+ build_info:
28
+ "./stage1.json":
29
+ repo: ${JSON.stringify(repo)}
30
+ commit: ${JSON.stringify(commit)}
31
+ build: ${JSON.stringify(build)}
32
+ `
33
+ }
34
+
35
+ function jobYaml(name: string, dependsOn: string[] = []): string {
36
+ const depends = dependsOn.length > 0 ? `depends_on: ${JSON.stringify(dependsOn)}\n` : ''
37
+ return `
38
+ name: ${JSON.stringify(name)}
39
+ version: "1.0.0"
40
+ ${depends}actions:
41
+ - name: "noop"
42
+ type: "static"
43
+ arguments:
44
+ value: true
45
+ `
46
+ }
47
+
48
+ describe('source provenance operations', () => {
49
+ let tempDir: string
50
+
51
+ beforeEach(async () => {
52
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'catapult-provenance-test-'))
53
+ })
54
+
55
+ afterEach(async () => {
56
+ await fs.rm(tempDir, { recursive: true, force: true })
57
+ })
58
+
59
+ it('collects source provenance entries even when build-info is missing', async () => {
60
+ const projectRoot = path.join(tempDir, 'project')
61
+ const sourcePath = path.join(projectRoot, 'jobs', 'demo', 'build-info', 'source.yaml')
62
+ await writeFile(sourcePath, sourceYaml('https://github.com/example/repo', 'abc123', 'forge build --build-info'))
63
+
64
+ const result = await collectSourceProvenanceEntries(projectRoot)
65
+
66
+ expect(result.warnings).toEqual([])
67
+ expect(result.entries).toHaveLength(1)
68
+ expect(result.entries[0]).toMatchObject({
69
+ sourceDocumentPath: sourcePath,
70
+ buildInfoRef: './stage1.json',
71
+ buildInfoPath: path.join(projectRoot, 'jobs', 'demo', 'build-info', 'stage1.json')
72
+ })
73
+ })
74
+
75
+ it('can scope provenance entries to a job and its dependencies', async () => {
76
+ const projectRoot = path.join(tempDir, 'project')
77
+ await writeFile(path.join(projectRoot, 'jobs', 'base.yaml'), jobYaml('base'))
78
+ await writeFile(path.join(projectRoot, 'jobs', 'child.yaml'), jobYaml('child', ['base']))
79
+ await writeFile(path.join(projectRoot, 'jobs', 'base', 'build-info', 'source.yaml'), sourceYaml('https://github.com/example/base', 'abc123', 'build-base'))
80
+ await writeFile(path.join(projectRoot, 'jobs', 'child', 'build-info', 'source.yaml'), sourceYaml('https://github.com/example/child', 'def456', 'build-child'))
81
+
82
+ const childOnly = await collectSourceProvenanceEntries(projectRoot, {
83
+ jobs: ['child'],
84
+ loadStdTemplates: false
85
+ })
86
+ const withDependencies = await collectSourceProvenanceEntries(projectRoot, {
87
+ jobs: ['child'],
88
+ includeDependencies: true,
89
+ loadStdTemplates: false
90
+ })
91
+
92
+ expect(childOnly.entries.map(entry => entry.provenance.repo)).toEqual(['https://github.com/example/child'])
93
+ expect(withDependencies.entries.map(entry => entry.provenance.repo).sort()).toEqual([
94
+ 'https://github.com/example/base',
95
+ 'https://github.com/example/child'
96
+ ])
97
+ })
98
+
99
+ it('generates missing build-info from a local Git provenance repo', async () => {
100
+ const { projectRoot, expectedBuildInfo } = await createProjectWithLocalProvenanceRepo()
101
+
102
+ const result = await generateBuildInfoFromSourceProvenance(projectRoot)
103
+ const generatedPath = path.join(projectRoot, 'jobs', 'demo', 'build-info', 'stage1.json')
104
+ const generatedJson = JSON.parse(await fs.readFile(generatedPath, 'utf-8'))
105
+
106
+ expect(result.results).toHaveLength(1)
107
+ expect(result.results[0].status).toBe('generated')
108
+ expect(generatedJson).toEqual(expectedBuildInfo)
109
+ })
110
+
111
+ it('verifies generated build-info and reports mismatches', async () => {
112
+ const { projectRoot } = await createProjectWithLocalProvenanceRepo()
113
+ const targetPath = path.join(projectRoot, 'jobs', 'demo', 'build-info', 'stage1.json')
114
+
115
+ await generateBuildInfoFromSourceProvenance(projectRoot)
116
+ const verified = await verifySourceProvenance(projectRoot)
117
+ expect(verified.results[0].status).toBe('verified')
118
+
119
+ const changed = JSON.parse(await fs.readFile(targetPath, 'utf-8'))
120
+ changed.solcVersion = '0.8.1'
121
+ await fs.writeFile(targetPath, JSON.stringify(changed, null, 2))
122
+
123
+ const mismatch = await verifySourceProvenance(projectRoot)
124
+ expect(mismatch.results[0].status).toBe('failed')
125
+ expect(mismatch.results[0].message).toContain('does not match')
126
+ expect(mismatch.results[0].message).toContain('$.solcVersion')
127
+ })
128
+
129
+ it('normalizes checkout-local build-info paths and top-level ids while verifying', async () => {
130
+ const { projectRoot } = await createProjectWithLocalProvenanceRepo({ checkoutSensitiveBuildInfo: true })
131
+ const targetPath = path.join(projectRoot, 'jobs', 'demo', 'build-info', 'stage1.json')
132
+
133
+ await generateBuildInfoFromSourceProvenance(projectRoot)
134
+ const generatedJson = JSON.parse(await fs.readFile(targetPath, 'utf-8'))
135
+ expect(generatedJson.id).toContain('catapult-provenance-')
136
+ expect(generatedJson.input.basePath).toContain('catapult-provenance-')
137
+
138
+ const verified = await verifySourceProvenance(projectRoot)
139
+ expect(verified.results[0].status).toBe('verified')
140
+ })
141
+
142
+ async function createProjectWithLocalProvenanceRepo(
143
+ options: { checkoutSensitiveBuildInfo?: boolean } = {}
144
+ ): Promise<{ projectRoot: string; expectedBuildInfo: Record<string, unknown> }> {
145
+ const sourceRepo = path.join(tempDir, 'source-repo')
146
+ const projectRoot = path.join(tempDir, 'project')
147
+ await fs.mkdir(sourceRepo, { recursive: true })
148
+
149
+ const expectedBuildInfo = {
150
+ _format: 'hh-sol-build-info-1',
151
+ id: 'stage1',
152
+ solcVersion: '0.8.0',
153
+ input: {
154
+ language: 'Solidity',
155
+ sources: {}
156
+ },
157
+ output: {
158
+ contracts: {}
159
+ }
160
+ }
161
+
162
+ const buildInfoScript = options.checkoutSensitiveBuildInfo
163
+ ? `
164
+ const fs = require('fs')
165
+ const path = require('path')
166
+ const buildInfo = {
167
+ _format: 'hh-sol-build-info-1',
168
+ id: path.basename(path.dirname(__dirname)),
169
+ solcVersion: '0.8.0',
170
+ input: {
171
+ language: 'Solidity',
172
+ basePath: __dirname,
173
+ allowPaths: [__dirname, path.join(__dirname, 'lib')],
174
+ includePaths: [__dirname],
175
+ sources: {}
176
+ },
177
+ output: {
178
+ contracts: {}
179
+ }
180
+ }
181
+ fs.mkdirSync(path.join(__dirname, 'out', 'build-info'), { recursive: true })
182
+ fs.writeFileSync(path.join(__dirname, 'out', 'build-info', 'stage1.json'), JSON.stringify(buildInfo, null, 2))
183
+ `
184
+ : `
185
+ const fs = require('fs')
186
+ const path = require('path')
187
+ fs.mkdirSync(path.join(__dirname, 'out', 'build-info'), { recursive: true })
188
+ fs.writeFileSync(path.join(__dirname, 'out', 'build-info', 'stage1.json'), JSON.stringify(${JSON.stringify(expectedBuildInfo)}, null, 2))
189
+ `
190
+
191
+ await writeFile(path.join(sourceRepo, 'build-info.js'), `
192
+ ${buildInfoScript.trim()}
193
+ `)
194
+ await git(sourceRepo, ['init'])
195
+ await git(sourceRepo, ['config', 'user.email', 'catapult@example.com'])
196
+ await git(sourceRepo, ['config', 'user.name', 'Catapult Test'])
197
+ await git(sourceRepo, ['add', 'build-info.js'])
198
+ await git(sourceRepo, ['commit', '-m', 'add build script'])
199
+ const commit = await git(sourceRepo, ['rev-parse', 'HEAD'])
200
+
201
+ await writeFile(
202
+ path.join(projectRoot, 'jobs', 'demo', 'build-info', 'source.yaml'),
203
+ sourceYaml(sourceRepo, commit, 'node build-info.js')
204
+ )
205
+
206
+ return { projectRoot, expectedBuildInfo }
207
+ }
208
+ })
@@ -1,7 +1,64 @@
1
1
  import * as fs from 'fs/promises'
2
2
  import * as path from 'path'
3
3
  import { ContractRepository } from '../repository'
4
- import { Contract } from '../../types/contracts'
4
+
5
+ function buildInfoContent(): string {
6
+ return JSON.stringify({
7
+ _format: 'hh-sol-build-info-1',
8
+ id: 'test-build-id',
9
+ solcVersion: '0.8.0',
10
+ solcLongVersion: '0.8.0+commit.c7dfd78e',
11
+ input: {
12
+ language: 'Solidity',
13
+ sources: {
14
+ 'src/Stage1Module.sol': {
15
+ content: 'contract Stage1Module {}'
16
+ },
17
+ 'src/Stage2Module.sol': {
18
+ content: 'contract Stage2Module {}'
19
+ }
20
+ },
21
+ settings: {
22
+ outputSelection: {
23
+ '*': {
24
+ '*': ['abi', 'evm.bytecode', 'evm.deployedBytecode']
25
+ }
26
+ }
27
+ }
28
+ },
29
+ output: {
30
+ contracts: {
31
+ 'src/Stage1Module.sol': {
32
+ Stage1Module: {
33
+ abi: [],
34
+ evm: {
35
+ bytecode: {
36
+ object: '0x608060405234801561001057600080fd5b50111111'
37
+ },
38
+ deployedBytecode: {
39
+ object: '0x608060405234801561001057600080fd5b50111112'
40
+ }
41
+ }
42
+ }
43
+ },
44
+ 'src/Stage2Module.sol': {
45
+ Stage2Module: {
46
+ abi: [],
47
+ evm: {
48
+ bytecode: {
49
+ object: '0x608060405234801561001057600080fd5b50222222'
50
+ },
51
+ deployedBytecode: {
52
+ object: '0x608060405234801561001057600080fd5b50222223'
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ sources: {}
59
+ }
60
+ })
61
+ }
5
62
 
6
63
  describe('ContractRepository', () => {
7
64
  let repository: ContractRepository
@@ -111,6 +168,217 @@ describe('ContractRepository', () => {
111
168
  expect(contract._sources.has(buildInfoPath)).toBe(true)
112
169
  })
113
170
 
171
+ it('should attach source provenance from build-info sidecars', async () => {
172
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
173
+ await fs.mkdir(buildInfoDir, { recursive: true })
174
+ const buildInfoPath = path.join(buildInfoDir, 'stage1.json')
175
+ const sourcePath = path.join(buildInfoDir, 'source.yaml')
176
+
177
+ await fs.writeFile(buildInfoPath, buildInfoContent())
178
+ await fs.writeFile(sourcePath, `
179
+ type: source
180
+ build_info:
181
+ "./stage1.json":
182
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
183
+ ref: "v3.0.0-rc.5"
184
+ commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
185
+ build: "forge build --build-info"
186
+ contracts:
187
+ "src/Stage1Module.sol:Stage1Module":
188
+ ref: "stage1-special"
189
+ `)
190
+
191
+ await repository.loadFrom(tempDir)
192
+
193
+ const stage1 = repository.lookup(`${buildInfoPath}:Stage1Module`)
194
+ const stage2 = repository.lookup(`${buildInfoPath}:Stage2Module`)
195
+
196
+ expect(stage1).not.toBeNull()
197
+ expect(stage2).not.toBeNull()
198
+
199
+ expect(stage1!.sourceProvenance).toMatchObject({
200
+ repo: 'https://github.com/0xsequence/wallet-contracts-v3',
201
+ ref: 'stage1-special',
202
+ commit: '0d9061f229da73edae890e6fdd1fbf753028df6d',
203
+ build: 'forge build --build-info',
204
+ sourceDocumentPath: sourcePath,
205
+ buildInfoPath
206
+ })
207
+ expect(stage2!.sourceProvenance).toMatchObject({
208
+ repo: 'https://github.com/0xsequence/wallet-contracts-v3',
209
+ ref: 'v3.0.0-rc.5',
210
+ commit: '0d9061f229da73edae890e6fdd1fbf753028df6d'
211
+ })
212
+
213
+ expect(stage1!._sourceProvenance?.get(buildInfoPath)?.ref).toBe('stage1-special')
214
+ })
215
+
216
+ it('should skip source sidecars that point to missing build-info files', async () => {
217
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
218
+ await fs.mkdir(buildInfoDir, { recursive: true })
219
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
220
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
221
+ type: source
222
+ build_info:
223
+ "./missing.json":
224
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
225
+ `)
226
+
227
+ try {
228
+ await expect(repository.loadFrom(tempDir)).resolves.toBeUndefined()
229
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('does not exist'))
230
+ } finally {
231
+ warnSpy.mockRestore()
232
+ }
233
+ })
234
+
235
+ it('should skip malformed source sidecars without blocking build-info loading', async () => {
236
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
237
+ await fs.mkdir(buildInfoDir, { recursive: true })
238
+ const buildInfoPath = path.join(buildInfoDir, 'stage1.json')
239
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
240
+
241
+ await fs.writeFile(buildInfoPath, buildInfoContent())
242
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
243
+ type: source
244
+ build_info:
245
+ "./stage1.json":
246
+ ref: "v3.0.0-rc.5"
247
+ `)
248
+
249
+ try {
250
+ await repository.loadFrom(tempDir)
251
+ } finally {
252
+ warnSpy.mockRestore()
253
+ }
254
+
255
+ const stage1 = repository.lookup(`${buildInfoPath}:Stage1Module`)
256
+ expect(stage1).not.toBeNull()
257
+ expect(stage1!.sourceProvenance).toBeUndefined()
258
+ })
259
+
260
+ it('should keep valid source sidecar entries when sibling entries are invalid', async () => {
261
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
262
+ await fs.mkdir(buildInfoDir, { recursive: true })
263
+ const buildInfoPath = path.join(buildInfoDir, 'stage1.json')
264
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
265
+
266
+ await fs.writeFile(buildInfoPath, buildInfoContent())
267
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
268
+ type: source
269
+ build_info:
270
+ "./stage1.json":
271
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
272
+ commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
273
+ "./bad.json":
274
+ typo_field: true
275
+ `)
276
+
277
+ try {
278
+ await repository.loadFrom(tempDir)
279
+
280
+ const stage1 = repository.lookup(`${buildInfoPath}:Stage1Module`)
281
+ expect(stage1).not.toBeNull()
282
+ expect(stage1!.sourceProvenance).toMatchObject({
283
+ repo: 'https://github.com/0xsequence/wallet-contracts-v3',
284
+ commit: '0d9061f229da73edae890e6fdd1fbf753028df6d'
285
+ })
286
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('typo_field is not supported'))
287
+ } finally {
288
+ warnSpy.mockRestore()
289
+ }
290
+ })
291
+
292
+ it('should skip source sidecar entries that do not point to build-info JSON', async () => {
293
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
294
+ await fs.mkdir(buildInfoDir, { recursive: true })
295
+ const buildInfoPath = path.join(buildInfoDir, 'stage1.json')
296
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
297
+
298
+ await fs.writeFile(buildInfoPath, buildInfoContent())
299
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
300
+ type: source
301
+ build_info:
302
+ "./stage1.txt":
303
+ repo: "https://github.com/0xsequence/wallet-contracts-v3"
304
+ `)
305
+
306
+ try {
307
+ await repository.loadFrom(tempDir)
308
+
309
+ const stage1 = repository.lookup(`${buildInfoPath}:Stage1Module`)
310
+ expect(stage1).not.toBeNull()
311
+ expect(stage1!.sourceProvenance).toBeUndefined()
312
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('does not point to a build-info JSON file'))
313
+ } finally {
314
+ warnSpy.mockRestore()
315
+ }
316
+ })
317
+
318
+ it('should skip duplicate source provenance entries for the same build-info file', async () => {
319
+ const buildInfoDir = path.join(tempDir, 'build-info', 'rc-5')
320
+ await fs.mkdir(buildInfoDir, { recursive: true })
321
+ const buildInfoPath = path.join(buildInfoDir, 'stage1.json')
322
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
323
+
324
+ await fs.writeFile(buildInfoPath, buildInfoContent())
325
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
326
+ type: source
327
+ build_info:
328
+ "./stage1.json":
329
+ repo: "https://github.com/0xsequence/wallet-contracts-v3-a"
330
+ `)
331
+ await fs.writeFile(path.join(buildInfoDir, 'source.yml'), `
332
+ type: source
333
+ build_info:
334
+ "./stage1.json":
335
+ repo: "https://github.com/0xsequence/wallet-contracts-v3-b"
336
+ `)
337
+
338
+ try {
339
+ await repository.loadFrom(tempDir)
340
+
341
+ const stage1 = repository.lookup(`${buildInfoPath}:Stage1Module`)
342
+ expect(stage1).not.toBeNull()
343
+ expect(stage1!._sourceProvenance?.size).toBe(1)
344
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('duplicate provenance'))
345
+ } finally {
346
+ warnSpy.mockRestore()
347
+ }
348
+ })
349
+
350
+ it('should select preferred source provenance deterministically for duplicate bytecode', async () => {
351
+ const olderBuildInfoDir = path.join(tempDir, 'build-info', 'b-release')
352
+ const newerBuildInfoDir = path.join(tempDir, 'build-info', 'a-release')
353
+ await fs.mkdir(olderBuildInfoDir, { recursive: true })
354
+ await fs.mkdir(newerBuildInfoDir, { recursive: true })
355
+
356
+ const olderBuildInfoPath = path.join(olderBuildInfoDir, 'stage1.json')
357
+ const newerBuildInfoPath = path.join(newerBuildInfoDir, 'stage1.json')
358
+ await fs.writeFile(olderBuildInfoPath, buildInfoContent())
359
+ await fs.writeFile(newerBuildInfoPath, buildInfoContent())
360
+
361
+ await fs.writeFile(path.join(olderBuildInfoDir, 'source.yaml'), `
362
+ type: source
363
+ build_info:
364
+ "./stage1.json":
365
+ repo: "https://github.com/0xsequence/wallet-contracts-v3-old"
366
+ `)
367
+ await fs.writeFile(path.join(newerBuildInfoDir, 'source.yaml'), `
368
+ type: source
369
+ build_info:
370
+ "./stage1.json":
371
+ repo: "https://github.com/0xsequence/wallet-contracts-v3-new"
372
+ `)
373
+
374
+ await repository.loadFrom(tempDir)
375
+
376
+ const stage1 = repository.lookup(`${newerBuildInfoPath}:Stage1Module`)
377
+ expect(stage1).not.toBeNull()
378
+ expect(stage1!._sourceProvenance?.size).toBe(2)
379
+ expect(stage1!.sourceProvenance?.repo).toBe('https://github.com/0xsequence/wallet-contracts-v3-new')
380
+ })
381
+
114
382
  it('should hydrate contracts from multiple source files', async () => {
115
383
  // Create a basic artifact file (minimal info, will be hydrated by build-info)
116
384
  const artifactContent = JSON.stringify({
@@ -341,4 +609,4 @@ describe('ContractRepository', () => {
341
609
  expect(contracts).toHaveLength(0)
342
610
  })
343
611
  })
344
- })
612
+ })