@0xsequence/catapult 1.3.16 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +250 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +1 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/index.d.ts +1 -0
  6. package/dist/commands/index.d.ts.map +1 -1
  7. package/dist/commands/index.js +1 -0
  8. package/dist/commands/index.js.map +1 -1
  9. package/dist/commands/list.d.ts.map +1 -1
  10. package/dist/commands/list.js +12 -0
  11. package/dist/commands/list.js.map +1 -1
  12. package/dist/commands/provenance.d.ts +3 -0
  13. package/dist/commands/provenance.d.ts.map +1 -0
  14. package/dist/commands/provenance.js +138 -0
  15. package/dist/commands/provenance.js.map +1 -0
  16. package/dist/commands/run.d.ts.map +1 -1
  17. package/dist/commands/run.js +7 -4
  18. package/dist/commands/run.js.map +1 -1
  19. package/dist/lib/__tests__/deployer.spec.js +118 -1
  20. package/dist/lib/__tests__/deployer.spec.js.map +1 -1
  21. package/dist/lib/__tests__/network-utils.spec.js +53 -8
  22. package/dist/lib/__tests__/network-utils.spec.js.map +1 -1
  23. package/dist/lib/__tests__/provenance.spec.d.ts +2 -0
  24. package/dist/lib/__tests__/provenance.spec.d.ts.map +1 -0
  25. package/dist/lib/__tests__/provenance.spec.js +205 -0
  26. package/dist/lib/__tests__/provenance.spec.js.map +1 -0
  27. package/dist/lib/contracts/__tests__/repository.spec.js +243 -0
  28. package/dist/lib/contracts/__tests__/repository.spec.js.map +1 -1
  29. package/dist/lib/contracts/repository.d.ts +9 -1
  30. package/dist/lib/contracts/repository.d.ts.map +1 -1
  31. package/dist/lib/contracts/repository.js +93 -7
  32. package/dist/lib/contracts/repository.js.map +1 -1
  33. package/dist/lib/core/__tests__/assert-action.spec.d.ts +2 -0
  34. package/dist/lib/core/__tests__/assert-action.spec.d.ts.map +1 -0
  35. package/dist/lib/core/__tests__/assert-action.spec.js +377 -0
  36. package/dist/lib/core/__tests__/assert-action.spec.js.map +1 -0
  37. package/dist/lib/core/__tests__/engine.spec.js +80 -0
  38. package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
  39. package/dist/lib/core/__tests__/loader.spec.js +29 -0
  40. package/dist/lib/core/__tests__/loader.spec.js.map +1 -1
  41. package/dist/lib/core/__tests__/resolver.spec.js +383 -0
  42. package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
  43. package/dist/lib/core/engine.d.ts.map +1 -1
  44. package/dist/lib/core/engine.js +33 -0
  45. package/dist/lib/core/engine.js.map +1 -1
  46. package/dist/lib/core/loader.d.ts +1 -0
  47. package/dist/lib/core/loader.d.ts.map +1 -1
  48. package/dist/lib/core/loader.js +6 -1
  49. package/dist/lib/core/loader.js.map +1 -1
  50. package/dist/lib/core/resolver.d.ts +2 -0
  51. package/dist/lib/core/resolver.d.ts.map +1 -1
  52. package/dist/lib/core/resolver.js +89 -0
  53. package/dist/lib/core/resolver.js.map +1 -1
  54. package/dist/lib/deployer.d.ts.map +1 -1
  55. package/dist/lib/deployer.js +21 -4
  56. package/dist/lib/deployer.js.map +1 -1
  57. package/dist/lib/index.d.ts +1 -0
  58. package/dist/lib/index.d.ts.map +1 -1
  59. package/dist/lib/index.js +1 -0
  60. package/dist/lib/index.js.map +1 -1
  61. package/dist/lib/parsers/__tests__/job.spec.js +77 -0
  62. package/dist/lib/parsers/__tests__/job.spec.js.map +1 -1
  63. package/dist/lib/parsers/__tests__/source.spec.d.ts +2 -0
  64. package/dist/lib/parsers/__tests__/source.spec.d.ts.map +1 -0
  65. package/dist/lib/parsers/__tests__/source.spec.js +121 -0
  66. package/dist/lib/parsers/__tests__/source.spec.js.map +1 -0
  67. package/dist/lib/parsers/index.d.ts +1 -0
  68. package/dist/lib/parsers/index.d.ts.map +1 -1
  69. package/dist/lib/parsers/index.js +1 -0
  70. package/dist/lib/parsers/index.js.map +1 -1
  71. package/dist/lib/parsers/job.d.ts.map +1 -1
  72. package/dist/lib/parsers/job.js +11 -0
  73. package/dist/lib/parsers/job.js.map +1 -1
  74. package/dist/lib/parsers/source.d.ts +4 -0
  75. package/dist/lib/parsers/source.d.ts.map +1 -0
  76. package/dist/lib/parsers/source.js +107 -0
  77. package/dist/lib/parsers/source.js.map +1 -0
  78. package/dist/lib/provenance.d.ts +34 -0
  79. package/dist/lib/provenance.d.ts.map +1 -0
  80. package/dist/lib/provenance.js +645 -0
  81. package/dist/lib/provenance.js.map +1 -0
  82. package/dist/lib/types/actions.d.ts +18 -2
  83. package/dist/lib/types/actions.d.ts.map +1 -1
  84. package/dist/lib/types/actions.js +1 -0
  85. package/dist/lib/types/actions.js.map +1 -1
  86. package/dist/lib/types/contracts.d.ts +3 -0
  87. package/dist/lib/types/contracts.d.ts.map +1 -1
  88. package/dist/lib/types/definitions.d.ts +1 -0
  89. package/dist/lib/types/definitions.d.ts.map +1 -1
  90. package/dist/lib/types/index.d.ts +1 -0
  91. package/dist/lib/types/index.d.ts.map +1 -1
  92. package/dist/lib/types/index.js +1 -0
  93. package/dist/lib/types/index.js.map +1 -1
  94. package/dist/lib/types/source.d.ts +24 -0
  95. package/dist/lib/types/source.d.ts.map +1 -0
  96. package/dist/lib/types/source.js +3 -0
  97. package/dist/lib/types/source.js.map +1 -0
  98. package/dist/lib/types/values.d.ts +33 -1
  99. package/dist/lib/types/values.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/cli.ts +3 -2
  102. package/src/commands/index.ts +2 -1
  103. package/src/commands/list.ts +14 -1
  104. package/src/commands/provenance.ts +120 -0
  105. package/src/commands/run.ts +11 -6
  106. package/src/lib/__tests__/deployer.spec.ts +177 -1
  107. package/src/lib/__tests__/network-utils.spec.ts +63 -14
  108. package/src/lib/__tests__/provenance.spec.ts +208 -0
  109. package/src/lib/contracts/__tests__/repository.spec.ts +270 -2
  110. package/src/lib/contracts/repository.ts +112 -14
  111. package/src/lib/core/__tests__/assert-action.spec.ts +474 -0
  112. package/src/lib/core/__tests__/engine.spec.ts +116 -0
  113. package/src/lib/core/__tests__/loader.spec.ts +34 -1
  114. package/src/lib/core/__tests__/resolver.spec.ts +444 -1
  115. package/src/lib/core/engine.ts +52 -0
  116. package/src/lib/core/loader.ts +8 -2
  117. package/src/lib/core/resolver.ts +116 -0
  118. package/src/lib/deployer.ts +28 -4
  119. package/src/lib/index.ts +4 -1
  120. package/src/lib/parsers/__tests__/job.spec.ts +81 -0
  121. package/src/lib/parsers/__tests__/source.spec.ts +134 -0
  122. package/src/lib/parsers/index.ts +1 -0
  123. package/src/lib/parsers/job.ts +14 -2
  124. package/src/lib/parsers/source.ts +129 -0
  125. package/src/lib/provenance.ts +785 -0
  126. package/src/lib/types/actions.ts +22 -1
  127. package/src/lib/types/contracts.ts +4 -1
  128. package/src/lib/types/definitions.ts +7 -0
  129. package/src/lib/types/index.ts +1 -0
  130. package/src/lib/types/source.ts +26 -0
  131. package/src/lib/types/values.ts +71 -0
@@ -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
  })
@@ -108,16 +108,15 @@ describe('Network Utils', () => {
108
108
  describe('Integration with Run Command', () => {
109
109
  it('should create a complete Network object from detected information', async () => {
110
110
  const { ethers } = require('ethers')
111
-
112
- // Mock successful network detection
111
+
113
112
  const mockNetwork = {
114
113
  name: 'sepolia',
115
- chainId: 11155111
114
+ chainId: 11155111,
116
115
  }
117
-
116
+
118
117
  const getNetworkMock = jest.fn().mockResolvedValue(mockNetwork)
119
118
  ethers.JsonRpcProvider.mockImplementation(() => ({
120
- getNetwork: getNetworkMock
119
+ getNetwork: getNetworkMock,
121
120
  }))
122
121
 
123
122
  const detectedInfo = await detectNetworkFromRpc('https://sepolia.infura.io/v3/abc123')
@@ -129,7 +128,9 @@ describe('Network Utils', () => {
129
128
  rpcUrl: 'https://sepolia.infura.io/v3/abc123',
130
129
  supports: detectedInfo.supports || [],
131
130
  gasLimit: detectedInfo.gasLimit,
132
- testnet: detectedInfo.testnet
131
+ testnet: detectedInfo.testnet,
132
+ evmVersion: detectedInfo.evmVersion,
133
+ params: detectedInfo.params,
133
134
  }
134
135
 
135
136
  expect(customNetwork).toEqual({
@@ -138,22 +139,66 @@ describe('Network Utils', () => {
138
139
  rpcUrl: 'https://sepolia.infura.io/v3/abc123',
139
140
  supports: [],
140
141
  gasLimit: undefined,
141
- testnet: undefined
142
+ testnet: undefined,
143
+ evmVersion: undefined,
144
+ params: undefined,
145
+ })
146
+ })
147
+
148
+ it('should merge params and other yaml fields when chainId matches networks.yaml', async () => {
149
+ const { ethers } = require('ethers')
150
+
151
+ const getNetworkMock = jest.fn().mockResolvedValue({ name: 'unknown', chainId: 137 })
152
+ ethers.JsonRpcProvider.mockImplementation(() => ({
153
+ getNetwork: getNetworkMock,
154
+ }))
155
+
156
+ const detectedInfo = await detectNetworkFromRpc('http://127.0.0.1:8545')
157
+ const knownNetwork: Network = {
158
+ name: 'Polygon',
159
+ chainId: 137,
160
+ rpcUrl: 'https://nodes.sequence.app/polygon/token',
161
+ supports: ['etherscan_v2', 'sourcify'],
162
+ testnet: false,
163
+ evmVersion: 'paris',
164
+ params: { trailsOnly: false },
165
+ }
166
+
167
+ // Simulate what the run command does
168
+ const customNetwork: Network = {
169
+ name: detectedInfo.name || knownNetwork.name || `custom-${detectedInfo.chainId}`,
170
+ chainId: detectedInfo.chainId!,
171
+ rpcUrl: 'http://127.0.0.1:8545',
172
+ supports: detectedInfo.supports || knownNetwork.supports || [],
173
+ gasLimit: detectedInfo.gasLimit || knownNetwork.gasLimit,
174
+ testnet: detectedInfo.testnet !== undefined ? detectedInfo.testnet : knownNetwork.testnet,
175
+ evmVersion: detectedInfo.evmVersion || knownNetwork.evmVersion,
176
+ params: detectedInfo.params || knownNetwork.params,
177
+ }
178
+
179
+ expect(customNetwork).toEqual({
180
+ name: 'unknown',
181
+ chainId: 137,
182
+ rpcUrl: 'http://127.0.0.1:8545',
183
+ supports: ['etherscan_v2', 'sourcify'],
184
+ gasLimit: undefined,
185
+ testnet: false,
186
+ evmVersion: 'paris',
187
+ params: { trailsOnly: false },
142
188
  })
143
189
  })
144
190
 
145
191
  it('should handle partial network information gracefully', async () => {
146
192
  const { ethers } = require('ethers')
147
-
148
- // Mock network with minimal information
193
+
149
194
  const mockNetwork = {
150
195
  name: 'unknown',
151
- chainId: 42
196
+ chainId: 42,
152
197
  }
153
-
198
+
154
199
  const getNetworkMock = jest.fn().mockResolvedValue(mockNetwork)
155
200
  ethers.JsonRpcProvider.mockImplementation(() => ({
156
- getNetwork: getNetworkMock
201
+ getNetwork: getNetworkMock,
157
202
  }))
158
203
 
159
204
  const detectedInfo = await detectNetworkFromRpc('http://custom-network:8545')
@@ -165,7 +210,9 @@ describe('Network Utils', () => {
165
210
  rpcUrl: 'http://custom-network:8545',
166
211
  supports: detectedInfo.supports || [],
167
212
  gasLimit: detectedInfo.gasLimit,
168
- testnet: detectedInfo.testnet
213
+ testnet: detectedInfo.testnet,
214
+ evmVersion: detectedInfo.evmVersion,
215
+ params: detectedInfo.params,
169
216
  }
170
217
 
171
218
  expect(customNetwork).toEqual({
@@ -174,7 +221,9 @@ describe('Network Utils', () => {
174
221
  rpcUrl: 'http://custom-network:8545',
175
222
  supports: [],
176
223
  gasLimit: undefined,
177
- testnet: undefined
224
+ testnet: undefined,
225
+ evmVersion: undefined,
226
+ params: undefined,
178
227
  })
179
228
  })
180
229
  })
@@ -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
+ })