certora-prover-cli 0.1.0__py3-none-any.whl
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.
- certora_prover_cli-0.1.0.dist-info/METADATA +293 -0
- certora_prover_cli-0.1.0.dist-info/RECORD +28 -0
- certora_prover_cli-0.1.0.dist-info/WHEEL +4 -0
- certora_prover_cli-0.1.0.dist-info/entry_points.txt +3 -0
- certora_prover_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- prover_output_utility/__init__.py +42 -0
- prover_output_utility/_version.py +24 -0
- prover_output_utility/api/__init__.py +14 -0
- prover_output_utility/api/aws_data_fetcher.py +485 -0
- prover_output_utility/api/base_data_fetcher.py +176 -0
- prover_output_utility/api/cache.py +149 -0
- prover_output_utility/api/data_fetcher.py +628 -0
- prover_output_utility/api/local_data_fetcher.py +302 -0
- prover_output_utility/api/main.py +1333 -0
- prover_output_utility/api/tree_parser.py +420 -0
- prover_output_utility/api/url_utils.py +125 -0
- prover_output_utility/auth.py +172 -0
- prover_output_utility/aws_auth.py +124 -0
- prover_output_utility/breadcrumb.py +358 -0
- prover_output_utility/exceptions.py +43 -0
- prover_output_utility/group_summary.py +335 -0
- prover_output_utility/job_report.py +115 -0
- prover_output_utility/models.py +689 -0
- prover_output_utility/parsers.py +212 -0
- prover_output_utility/prover_api.py +37 -0
- prover_output_utility/prover_cli.py +682 -0
- prover_output_utility/py.typed +0 -0
- prover_output_utility/sighash.py +345 -0
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Certora Ltd.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Main ProverOutputAPI class that provides the high-level interface.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import tarfile
|
|
14
|
+
import threading
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from ..auth import ProverAuth
|
|
23
|
+
from ..aws_auth import AWSAuth
|
|
24
|
+
from ..breadcrumb import BreadcrumbParser
|
|
25
|
+
from ..exceptions import ProverAPIError
|
|
26
|
+
from ..job_report import JobAnalyzer, JobReport
|
|
27
|
+
from ..models import (
|
|
28
|
+
BreadcrumbInfo,
|
|
29
|
+
CallResolutionInfo,
|
|
30
|
+
CalltraceInfo,
|
|
31
|
+
CheckResult,
|
|
32
|
+
JobInfo,
|
|
33
|
+
JobStatus,
|
|
34
|
+
ParsedAlert,
|
|
35
|
+
SourceFileNode,
|
|
36
|
+
TreeViewData,
|
|
37
|
+
convert_job_status,
|
|
38
|
+
)
|
|
39
|
+
from ..parsers import OutputParser
|
|
40
|
+
from .aws_data_fetcher import AWSDataFetcher
|
|
41
|
+
from .cache import APICache
|
|
42
|
+
from .data_fetcher import DataFetcher, default_api_base_url
|
|
43
|
+
from .local_data_fetcher import LocalDataFetcher
|
|
44
|
+
from .tree_parser import TreeParser
|
|
45
|
+
from .url_utils import extract_job_id, extract_job_identifier
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Per-job locks for thread-safe fetch operations
|
|
49
|
+
_fetch_locks: dict[str, threading.Lock] = {}
|
|
50
|
+
_fetch_locks_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_fetch_lock(job_identifier: str) -> threading.Lock:
|
|
54
|
+
"""Get or create a per-job lock for thread-safe fetching."""
|
|
55
|
+
with _fetch_locks_lock:
|
|
56
|
+
if job_identifier not in _fetch_locks:
|
|
57
|
+
_fetch_locks[job_identifier] = threading.Lock()
|
|
58
|
+
return _fetch_locks[job_identifier]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _flatten_source_file_tree(nodes: List[SourceFileNode], prefix: str = "") -> List[tuple]:
|
|
62
|
+
"""Flatten a SourceFileNode tree into (relative_path, api_path) tuples."""
|
|
63
|
+
result: List[tuple] = []
|
|
64
|
+
for node in nodes:
|
|
65
|
+
path = f"{prefix}{node.name}" if prefix else node.name
|
|
66
|
+
if node.selectable and not node.name.startswith("."):
|
|
67
|
+
result.append((path, node.output))
|
|
68
|
+
if node.children:
|
|
69
|
+
result.extend(_flatten_source_file_tree(node.children, f"{path}/"))
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _collect_treeview_output_files(tree_status: Dict[str, Any]) -> List[str]:
|
|
74
|
+
"""Recursively collect all output filenames from 'output' keys in a treeViewStatus dict."""
|
|
75
|
+
output_files: List[str] = []
|
|
76
|
+
|
|
77
|
+
def traverse(obj: Any) -> None:
|
|
78
|
+
if isinstance(obj, dict):
|
|
79
|
+
for key, value in obj.items():
|
|
80
|
+
if key == "output" and isinstance(value, list):
|
|
81
|
+
output_files.extend(value)
|
|
82
|
+
else:
|
|
83
|
+
traverse(value)
|
|
84
|
+
elif isinstance(obj, list):
|
|
85
|
+
for item in obj:
|
|
86
|
+
traverse(item)
|
|
87
|
+
|
|
88
|
+
traverse(tree_status)
|
|
89
|
+
return list(set(output_files))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProverOutputAPI:
|
|
93
|
+
"""
|
|
94
|
+
Main API class for fetching and parsing Certora Prover outputs.
|
|
95
|
+
|
|
96
|
+
Authentication is handled automatically via certora_login when accessing remote APIs.
|
|
97
|
+
No CERTORAKEY required.
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
# Remote API (default) - uses certora_login for authentication
|
|
101
|
+
api = ProverOutputAPI()
|
|
102
|
+
violations = api.get_violated_rules("https://prover.certora.com/output/12345/...")
|
|
103
|
+
violations = api.get_violated_rules("12345")
|
|
104
|
+
|
|
105
|
+
# Local emv-* folders (no authentication needed)
|
|
106
|
+
api = ProverOutputAPI(use_local=True)
|
|
107
|
+
violations = api.get_violated_rules("/path/to/emv-1-certora-19-Aug--13-09")
|
|
108
|
+
violations = api.get_violated_rules("./emv-1-certora-19-Aug--13-09")
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
certora_key: Optional[str] = None,
|
|
114
|
+
enable_cache: bool = True,
|
|
115
|
+
use_local: bool = False,
|
|
116
|
+
api_base_url: Optional[str] = None,
|
|
117
|
+
):
|
|
118
|
+
"""
|
|
119
|
+
Initialize the ProverOutputAPI.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
certora_key: Deprecated - authentication is handled automatically via certora_login
|
|
123
|
+
enable_cache: Whether to enable persistent caching of API responses (default: True)
|
|
124
|
+
use_local: Whether to use local emv-* folders instead of remote API (default: False)
|
|
125
|
+
api_base_url: API base URL. When None (the default), resolve from the
|
|
126
|
+
Certora login env via :func:`resolve_login_env` —
|
|
127
|
+
dev → data-api-dev.certora.com, stg → data-api-stg.certora.com,
|
|
128
|
+
prod → data-api.certora.com. Pass an explicit URL (e.g.
|
|
129
|
+
``DataFetcher.STAGING_API_BASE_URL``) to override.
|
|
130
|
+
"""
|
|
131
|
+
self.logger = logging.getLogger(__name__)
|
|
132
|
+
self.parser = OutputParser()
|
|
133
|
+
self.breadcrumb_parser = BreadcrumbParser()
|
|
134
|
+
self.tree_parser = TreeParser()
|
|
135
|
+
self.use_local = use_local
|
|
136
|
+
# Resolve at instance-construction time so the same process can serve
|
|
137
|
+
# multiple envs by re-instantiating; bound-default would freeze on import.
|
|
138
|
+
self.api_base_url = api_base_url if api_base_url is not None else default_api_base_url()
|
|
139
|
+
|
|
140
|
+
# Initialize cache
|
|
141
|
+
self.cache = APICache() if enable_cache else None
|
|
142
|
+
|
|
143
|
+
# Initialize appropriate data fetcher
|
|
144
|
+
if use_local:
|
|
145
|
+
self.data_fetcher = LocalDataFetcher()
|
|
146
|
+
elif os.getenv("CI") and self._is_aws_available():
|
|
147
|
+
# In CI with AWS credentials, use AWS Lambda authentication
|
|
148
|
+
self.logger.info("Using AWS Lambda authentication in CI")
|
|
149
|
+
aws_auth = AWSAuth()
|
|
150
|
+
aws_session = aws_auth.get_session()
|
|
151
|
+
self.data_fetcher = AWSDataFetcher(aws_session)
|
|
152
|
+
# No session needed for AWS auth
|
|
153
|
+
self.session = None
|
|
154
|
+
else:
|
|
155
|
+
# Regular cookie-based authentication
|
|
156
|
+
self.auth = ProverAuth()
|
|
157
|
+
self._setup_session()
|
|
158
|
+
|
|
159
|
+
def _setup_session(self, force_relogin: bool = False):
|
|
160
|
+
"""Setup session with authentication cookies."""
|
|
161
|
+
self.session = requests.Session()
|
|
162
|
+
# Set up authentication
|
|
163
|
+
self.session.cookies = self.auth.get_auth_cookies(force_relogin=force_relogin)
|
|
164
|
+
self.data_fetcher = DataFetcher(self.session, api_instance=self, api_base_url=self.api_base_url)
|
|
165
|
+
|
|
166
|
+
def _is_aws_available(self) -> bool:
|
|
167
|
+
"""Check if AWS authentication is available."""
|
|
168
|
+
try:
|
|
169
|
+
aws_auth = AWSAuth()
|
|
170
|
+
return aws_auth.is_available()
|
|
171
|
+
except Exception:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def _extract_job_identifier(self, job_input: str) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Extract job identifier based on the fetcher type.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Job identifier (job_id for remote, emv_path for local)
|
|
183
|
+
"""
|
|
184
|
+
if self.use_local:
|
|
185
|
+
# For local, validate it's an emv-* path and return the path
|
|
186
|
+
job_identifier, input_type = extract_job_identifier(job_input)
|
|
187
|
+
if input_type != "local":
|
|
188
|
+
raise ProverAPIError(
|
|
189
|
+
f"ProverOutputAPI configured for local use but got remote input: {job_input}"
|
|
190
|
+
)
|
|
191
|
+
return job_identifier
|
|
192
|
+
else:
|
|
193
|
+
# For remote, extract job ID from URL or use direct job ID
|
|
194
|
+
return extract_job_id(job_input)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _set_default_contract(self, job_identifier: str) -> None:
|
|
198
|
+
"""Set the tree parser's default contract from the job's API response."""
|
|
199
|
+
try:
|
|
200
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
201
|
+
self.tree_parser.default_contract = raw_output.get("contract")
|
|
202
|
+
except Exception:
|
|
203
|
+
pass # best-effort
|
|
204
|
+
|
|
205
|
+
def get_treeview_status(self, job_input: str) -> Dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Get tree view status data with caching for completed jobs.
|
|
208
|
+
|
|
209
|
+
This is a public API method that retrieves the treeViewStatus.json file
|
|
210
|
+
for a job. The data is cached for completed jobs to improve performance.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Tree view status data as a dictionary
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
```python
|
|
220
|
+
api = ProverOutputAPI()
|
|
221
|
+
tree_status = api.get_treeview_status("12345678")
|
|
222
|
+
print(f"Tree status: {tree_status}")
|
|
223
|
+
```
|
|
224
|
+
"""
|
|
225
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
226
|
+
return self._get_tree_view_status(job_identifier)
|
|
227
|
+
|
|
228
|
+
def _get_tree_view_status(self, job_identifier: str) -> Dict[str, Any]:
|
|
229
|
+
"""
|
|
230
|
+
Get tree view status data with caching for completed jobs.
|
|
231
|
+
|
|
232
|
+
Internal method - use get_treeview_status() for the public API.
|
|
233
|
+
Only caches treeViewStatus.json if the job is not running.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
job_identifier: Job ID or local emv-* path
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Tree view status data
|
|
240
|
+
"""
|
|
241
|
+
# Check cache first (only for remote jobs)
|
|
242
|
+
cache_key = "treeViewStatus.json"
|
|
243
|
+
if self.cache and not self.use_local:
|
|
244
|
+
cached_data = self.cache.get("tree_view_status", job_identifier, cache_key)
|
|
245
|
+
if cached_data:
|
|
246
|
+
self.logger.debug(f"Using cached treeViewStatus for job {job_identifier}")
|
|
247
|
+
return cached_data
|
|
248
|
+
|
|
249
|
+
# Fetch the data
|
|
250
|
+
tree_data = self.data_fetcher.fetch_tree_view_data(
|
|
251
|
+
job_identifier, path="treeViewStatus.json"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Cache if job is not running (only for remote jobs)
|
|
255
|
+
if self.cache and not self.use_local:
|
|
256
|
+
# Check if job is running by looking for finish_time in the tree data
|
|
257
|
+
# or by calling is_job_running
|
|
258
|
+
try:
|
|
259
|
+
# Get job info to check if it's running
|
|
260
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
261
|
+
is_running = raw_output.get("finish_time") is None
|
|
262
|
+
|
|
263
|
+
if not is_running:
|
|
264
|
+
self.logger.debug(f"Caching treeViewStatus for completed job {job_identifier}")
|
|
265
|
+
self.cache.set("tree_view_status", tree_data, job_identifier, cache_key)
|
|
266
|
+
else:
|
|
267
|
+
self.logger.debug(
|
|
268
|
+
f"Not caching treeViewStatus for running job {job_identifier}"
|
|
269
|
+
)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
# If we can't determine job status, don't cache
|
|
272
|
+
self.logger.debug(f"Could not determine job status for caching: {e}")
|
|
273
|
+
|
|
274
|
+
return tree_data
|
|
275
|
+
|
|
276
|
+
def fetch_output(self, job_input: str) -> Dict[str, Any]:
|
|
277
|
+
"""
|
|
278
|
+
Fetch and parse prover output from either a job URL, job ID, or local emv-* path.
|
|
279
|
+
|
|
280
|
+
This method is kept for backward compatibility.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Parsed output data as a dictionary
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ProverAPIError: If API call fails
|
|
290
|
+
"""
|
|
291
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
292
|
+
self.logger.info(f"Fetching output for job: {job_identifier}")
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
296
|
+
parsed_output = self.parser.parse(raw_output)
|
|
297
|
+
return parsed_output
|
|
298
|
+
|
|
299
|
+
except requests.exceptions.RequestException as e:
|
|
300
|
+
raise ProverAPIError(f"Failed to fetch output for job {job_identifier}: {e}")
|
|
301
|
+
|
|
302
|
+
def get_violated_rules(self, job_input: str) -> List[CheckResult]:
|
|
303
|
+
"""
|
|
304
|
+
Get all violated rules for a job with assert messages.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
List of ViolationInfo objects for violated rules
|
|
311
|
+
"""
|
|
312
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
self._set_default_contract(job_identifier)
|
|
316
|
+
tree_data = self._get_tree_view_status(job_identifier)
|
|
317
|
+
violations = self.tree_parser.parse_violations(tree_data)
|
|
318
|
+
return violations
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
raise ProverAPIError(f"Failed to get violated rules for job {job_identifier}: {e}")
|
|
322
|
+
|
|
323
|
+
def get_all_checks(self, job_input: str, include_rule_not_vacuous: bool = False) -> List[CheckResult]:
|
|
324
|
+
"""
|
|
325
|
+
Get all checks (both successful and failed) for a job.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
329
|
+
include_rule_not_vacuous: If True, include rule_not_vacuous nodes and their descendants.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of ViolationInfo objects for all checks
|
|
333
|
+
"""
|
|
334
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
self._set_default_contract(job_identifier)
|
|
338
|
+
tree_data = self._get_tree_view_status(job_identifier)
|
|
339
|
+
all_checks = self.tree_parser.parse_all_checks(tree_data, include_rule_not_vacuous=include_rule_not_vacuous)
|
|
340
|
+
return all_checks
|
|
341
|
+
|
|
342
|
+
except Exception as e:
|
|
343
|
+
raise ProverAPIError(f"Failed to get all checks for job {job_identifier}: {e}")
|
|
344
|
+
|
|
345
|
+
def get_leaf_checks(self, job_input: str) -> List[CheckResult]:
|
|
346
|
+
"""
|
|
347
|
+
Get only leaf checks (actual assertions without intermediate tree nodes) for a job.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
List of CheckResult objects for leaf checks only
|
|
354
|
+
"""
|
|
355
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
self._set_default_contract(job_identifier)
|
|
359
|
+
tree_data = self._get_tree_view_status(job_identifier)
|
|
360
|
+
leaf_checks = self.tree_parser.parse_leaf_checks(tree_data)
|
|
361
|
+
return leaf_checks
|
|
362
|
+
|
|
363
|
+
except Exception as e:
|
|
364
|
+
raise ProverAPIError(f"Failed to get leaf checks for job {job_identifier}: {e}")
|
|
365
|
+
|
|
366
|
+
def fetch_treeview_output_by_filename(self, job_input: str, filename: str) -> Dict[str, Any]:
|
|
367
|
+
"""
|
|
368
|
+
Fetch a specific tree view output file by filename.
|
|
369
|
+
|
|
370
|
+
This is a public API method that retrieves individual output files from
|
|
371
|
+
the tree-view data, such as rule outputs, calltraces, or other JSON files.
|
|
372
|
+
The data is cached for completed jobs to improve performance.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
376
|
+
filename: Filename within tree-view (e.g., 'rule_output_58.json', 'dap_calltrace_56-certora-dap.json')
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Tree view output data as a dictionary
|
|
380
|
+
|
|
381
|
+
Example:
|
|
382
|
+
```python
|
|
383
|
+
api = ProverOutputAPI()
|
|
384
|
+
|
|
385
|
+
# Fetch a rule output file
|
|
386
|
+
rule_output = api.fetch_treeview_output_by_filename("12345678", "rule_output_58.json")
|
|
387
|
+
print(f"Rule data: {rule_output}")
|
|
388
|
+
|
|
389
|
+
# Fetch a calltrace/DAP file
|
|
390
|
+
dap_data = api.fetch_treeview_output_by_filename("12345678", "dap_calltrace_56-certora-dap.json")
|
|
391
|
+
print(f"DAP data: {dap_data}")
|
|
392
|
+
```
|
|
393
|
+
"""
|
|
394
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
395
|
+
return self._fetch_tree_view_data_with_cache(job_identifier, filename)
|
|
396
|
+
|
|
397
|
+
def _fetch_tree_view_data_with_cache(self, job_identifier: str, path: str) -> Dict[str, Any]:
|
|
398
|
+
"""
|
|
399
|
+
Fetch tree view data with caching support.
|
|
400
|
+
|
|
401
|
+
Internal method - use fetch_treeview_output_by_filename() for the public API.
|
|
402
|
+
Handles caching logic for tree view data fetches.
|
|
403
|
+
Checks bulk cache first, then individual cache, then API.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
job_identifier: Job ID or local emv-* path
|
|
407
|
+
path: Path within tree-view (e.g., 'rule_output_58.json')
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Tree view data
|
|
411
|
+
"""
|
|
412
|
+
# Check bulk cache first (disk-based)
|
|
413
|
+
if not self.use_local:
|
|
414
|
+
bulk_cache_dir = self._get_bulk_cache_dir(job_identifier)
|
|
415
|
+
bulk_file_path = bulk_cache_dir / path
|
|
416
|
+
if bulk_file_path.exists():
|
|
417
|
+
try:
|
|
418
|
+
with open(bulk_file_path, 'r') as f:
|
|
419
|
+
data = json.load(f)
|
|
420
|
+
self.logger.debug(f"Using bulk cache for {path} in job {job_identifier}")
|
|
421
|
+
return data
|
|
422
|
+
except Exception as e:
|
|
423
|
+
self.logger.warning(f"Failed to read bulk cache file {bulk_file_path}: {e}")
|
|
424
|
+
|
|
425
|
+
# Check individual hash-based cache
|
|
426
|
+
cache_key = f"tree_view:{path}"
|
|
427
|
+
|
|
428
|
+
if self.cache and not self.use_local:
|
|
429
|
+
cached_data = self.cache.get("tree_view_data", job_identifier, cache_key)
|
|
430
|
+
if cached_data:
|
|
431
|
+
self.logger.debug(f"Using cached tree view data for {path} in job {job_identifier}")
|
|
432
|
+
return cached_data
|
|
433
|
+
|
|
434
|
+
# Fetch from API if not cached
|
|
435
|
+
data = self.data_fetcher.fetch_tree_view_data(job_identifier, path=path)
|
|
436
|
+
|
|
437
|
+
# Cache for completed jobs (rule outputs and calltraces are immutable once job completes)
|
|
438
|
+
if self.cache and not self.use_local and data:
|
|
439
|
+
try:
|
|
440
|
+
# Check if job is completed before caching
|
|
441
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
442
|
+
is_running = raw_output.get("finish_time") is None
|
|
443
|
+
|
|
444
|
+
if not is_running:
|
|
445
|
+
self.logger.debug(
|
|
446
|
+
f"Caching tree view data for {path} in completed job {job_identifier}"
|
|
447
|
+
)
|
|
448
|
+
self.cache.set("tree_view_data", data, job_identifier, cache_key)
|
|
449
|
+
except Exception:
|
|
450
|
+
# If we can't determine job status, cache anyway for rule outputs
|
|
451
|
+
# (they're immutable and won't change)
|
|
452
|
+
if path.startswith("rule_output") or path.endswith(".json"):
|
|
453
|
+
self.logger.debug(f"Caching tree view data for {path}")
|
|
454
|
+
self.cache.set("tree_view_data", data, job_identifier, cache_key)
|
|
455
|
+
|
|
456
|
+
return data
|
|
457
|
+
|
|
458
|
+
def get_calltrace(self, job_input: str, output_file: str) -> CalltraceInfo:
|
|
459
|
+
"""
|
|
460
|
+
Get calltrace data for a specific rule using its output file path.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
464
|
+
output_file: Output file path from the violation's output field
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
CalltraceInfo object with wrapped calltrace data
|
|
468
|
+
"""
|
|
469
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
calltrace_data = self._fetch_tree_view_data_with_cache(job_identifier, output_file)
|
|
473
|
+
return CalltraceInfo(
|
|
474
|
+
job_id=job_identifier,
|
|
475
|
+
output_file=output_file,
|
|
476
|
+
trace_data=calltrace_data,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
except Exception as e:
|
|
480
|
+
raise ProverAPIError(
|
|
481
|
+
f"Failed to get calltrace for output file {output_file} in job {job_identifier}: {e}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def get_calltrace_for_violation(self, job_input: str, violation: CheckResult) -> CalltraceInfo:
|
|
485
|
+
"""
|
|
486
|
+
Get calltrace data for a specific violation using its output files.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
490
|
+
violation: ViolationInfo object
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
CalltraceInfo object, or raises error if no output files
|
|
494
|
+
"""
|
|
495
|
+
if not violation.output_files:
|
|
496
|
+
raise ProverAPIError("No output files available for this violation")
|
|
497
|
+
|
|
498
|
+
# Use the first output file (there might be multiple)
|
|
499
|
+
output_file = violation.output_files[0]
|
|
500
|
+
calltrace = self.get_calltrace(job_input, output_file)
|
|
501
|
+
calltrace.rule_name = violation.rule_name
|
|
502
|
+
return calltrace
|
|
503
|
+
|
|
504
|
+
def get_breadcrumbs(self, job_input: str, dap_file: str) -> BreadcrumbInfo:
|
|
505
|
+
"""
|
|
506
|
+
Get breadcrumb trace summary from a DAP file with caching.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
510
|
+
dap_file: DAP file path (e.g., 'dap_calltrace_56-certora-dap.json')
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
BreadcrumbInfo object with execution trace
|
|
514
|
+
"""
|
|
515
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
516
|
+
|
|
517
|
+
# Check cache first
|
|
518
|
+
if self.cache:
|
|
519
|
+
cached_data = self.cache.get("get_breadcrumbs", job_identifier, dap_file)
|
|
520
|
+
if cached_data:
|
|
521
|
+
self.logger.debug(
|
|
522
|
+
f"Using cached breadcrumbs for {dap_file} in job {job_identifier}"
|
|
523
|
+
)
|
|
524
|
+
return BreadcrumbInfo.from_dict(cached_data, dap_file=dap_file)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
dap_data = self.data_fetcher.fetch_tree_view_data(job_identifier, path=dap_file)
|
|
528
|
+
breadcrumbs_data = self.breadcrumb_parser.parse_dap_file(dap_data)
|
|
529
|
+
|
|
530
|
+
# Cache the result
|
|
531
|
+
if self.cache:
|
|
532
|
+
self.cache.set("get_breadcrumbs", breadcrumbs_data, job_identifier, dap_file)
|
|
533
|
+
|
|
534
|
+
return BreadcrumbInfo.from_dict(breadcrumbs_data, dap_file=dap_file)
|
|
535
|
+
|
|
536
|
+
except Exception as e:
|
|
537
|
+
raise ProverAPIError(
|
|
538
|
+
f"Failed to get breadcrumbs from DAP file {dap_file} in job {job_identifier}: {e}"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def get_breadcrumbs_for_violation(
|
|
542
|
+
self, job_input: str, violation: CheckResult
|
|
543
|
+
) -> BreadcrumbInfo:
|
|
544
|
+
"""
|
|
545
|
+
Get breadcrumb trace summary for a specific violation using its debug trace file.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
549
|
+
violation: ViolationInfo object
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
BreadcrumbInfo object with execution trace
|
|
553
|
+
"""
|
|
554
|
+
if not violation.debug_trace_file:
|
|
555
|
+
raise ProverAPIError("No debug trace file available for this violation")
|
|
556
|
+
|
|
557
|
+
return self.get_breadcrumbs(job_input, violation.debug_trace_file)
|
|
558
|
+
|
|
559
|
+
def get_call_resolutions(self, job_input: str) -> List[CallResolutionInfo]:
|
|
560
|
+
"""
|
|
561
|
+
Get call resolution information from the globalCallResolution field.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
List of CallResolutionInfo objects
|
|
568
|
+
"""
|
|
569
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
tree_data = self._get_tree_view_status(job_identifier)
|
|
573
|
+
call_resolutions = self.tree_parser.parse_call_resolutions(tree_data)
|
|
574
|
+
return call_resolutions
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
raise ProverAPIError(f"Failed to get unresolved calls for job {job_identifier}: {e}")
|
|
578
|
+
|
|
579
|
+
def get_tree_view_data(self, job_input: str) -> TreeViewData:
|
|
580
|
+
"""
|
|
581
|
+
Get wrapped tree-view data for a job.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
TreeViewData object
|
|
588
|
+
"""
|
|
589
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
590
|
+
raw_data = self._get_tree_view_status(job_identifier)
|
|
591
|
+
return TreeViewData.from_dict(raw_data, job_identifier)
|
|
592
|
+
|
|
593
|
+
def get_job_status(self, job_input: str) -> JobStatus:
|
|
594
|
+
"""
|
|
595
|
+
Get the status of a job without fetching full output.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
JobStatus enum value
|
|
602
|
+
"""
|
|
603
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
607
|
+
status_str = raw_output.get("job_status", "unknown")
|
|
608
|
+
return convert_job_status(status_str)
|
|
609
|
+
except Exception as e:
|
|
610
|
+
raise ProverAPIError(f"Failed to get status for job {job_identifier}: {e}")
|
|
611
|
+
|
|
612
|
+
def get_job_info(self, job_input: str) -> JobInfo:
|
|
613
|
+
"""
|
|
614
|
+
Get comprehensive job information.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
JobInfo object with job details
|
|
621
|
+
"""
|
|
622
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
raw_output = self.data_fetcher.fetch_raw_output(job_identifier)
|
|
626
|
+
status_str = raw_output.get("job_status", "unknown")
|
|
627
|
+
return JobInfo(
|
|
628
|
+
job_id=job_identifier,
|
|
629
|
+
status=convert_job_status(status_str),
|
|
630
|
+
start_time=raw_output.get("start_time"),
|
|
631
|
+
finish_time=raw_output.get("finish_time"),
|
|
632
|
+
user_id=raw_output.get("user_id"),
|
|
633
|
+
project=raw_output.get("project"),
|
|
634
|
+
contract=raw_output.get("contract"),
|
|
635
|
+
raw_data=raw_output,
|
|
636
|
+
)
|
|
637
|
+
except Exception as e:
|
|
638
|
+
raise ProverAPIError(f"Failed to get job info for {job_identifier}: {e}")
|
|
639
|
+
|
|
640
|
+
def is_job_running(self, job_input: str) -> bool:
|
|
641
|
+
"""
|
|
642
|
+
Check if a job is still running by checking if finish_time is null.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
True if job is still running, False if completed
|
|
649
|
+
"""
|
|
650
|
+
job_info = self.get_job_info(job_input)
|
|
651
|
+
return job_info.is_running
|
|
652
|
+
|
|
653
|
+
def list_recent_jobs(
|
|
654
|
+
self,
|
|
655
|
+
days_back: int = 7,
|
|
656
|
+
limit: int = 100,
|
|
657
|
+
all_users: bool = True,
|
|
658
|
+
) -> List[JobInfo]:
|
|
659
|
+
"""
|
|
660
|
+
List recent jobs from the data API.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
days_back: How many days back to look (default: 7).
|
|
664
|
+
limit: Max jobs to return (default: 100, the API caps page size at 100).
|
|
665
|
+
all_users: Include all users' jobs (default: True). False = my jobs only.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
List of JobInfo objects.
|
|
669
|
+
|
|
670
|
+
Raises:
|
|
671
|
+
ProverAPIError: If configured for local use or the API call fails.
|
|
672
|
+
"""
|
|
673
|
+
if self.use_local:
|
|
674
|
+
raise ProverAPIError(
|
|
675
|
+
"list_recent_jobs is not supported when using local prover outputs"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
created_after = (
|
|
679
|
+
datetime.now(timezone.utc) - timedelta(days=days_back)
|
|
680
|
+
).isoformat()
|
|
681
|
+
raw_jobs = self.data_fetcher.list_recent_jobs(
|
|
682
|
+
created_after=created_after,
|
|
683
|
+
limit=limit,
|
|
684
|
+
all_users=all_users,
|
|
685
|
+
)
|
|
686
|
+
return [JobInfo.from_dict(job_data) for job_data in raw_jobs]
|
|
687
|
+
|
|
688
|
+
def cancel_jobs(self, job_inputs: List[str]) -> Dict[str, Any]:
|
|
689
|
+
"""
|
|
690
|
+
Cancel multiple jobs by their URLs or IDs.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
job_inputs: List of job URLs or job IDs to cancel
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
API response data with cancellation results
|
|
697
|
+
|
|
698
|
+
Raises:
|
|
699
|
+
ProverAPIError: If cancellation fails or configured for local use
|
|
700
|
+
"""
|
|
701
|
+
if self.use_local:
|
|
702
|
+
raise ProverAPIError("cancel_jobs is not supported when using local prover outputs")
|
|
703
|
+
|
|
704
|
+
# Extract job IDs from URLs or use IDs directly
|
|
705
|
+
job_ids = []
|
|
706
|
+
for job_input in job_inputs:
|
|
707
|
+
try:
|
|
708
|
+
job_id = extract_job_id(job_input)
|
|
709
|
+
job_ids.append(job_id)
|
|
710
|
+
except Exception as e:
|
|
711
|
+
self.logger.warning(f"Failed to extract job ID from {job_input}: {e}")
|
|
712
|
+
# Skip invalid inputs rather than failing completely
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
if not job_ids:
|
|
716
|
+
raise ProverAPIError("No valid job IDs found to cancel")
|
|
717
|
+
|
|
718
|
+
self.logger.info(f"Cancelling {len(job_ids)} jobs: {job_ids}")
|
|
719
|
+
return self.data_fetcher.cancel_jobs(job_ids)
|
|
720
|
+
|
|
721
|
+
def cancel_job(self, job_input: str) -> Dict[str, Any]:
|
|
722
|
+
"""
|
|
723
|
+
Cancel a single job by its URL or ID.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
job_input: Job URL or job ID to cancel
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
API response data with cancellation results
|
|
730
|
+
|
|
731
|
+
Raises:
|
|
732
|
+
ProverAPIError: If cancellation fails or configured for local use
|
|
733
|
+
"""
|
|
734
|
+
return self.cancel_jobs([job_input])
|
|
735
|
+
|
|
736
|
+
def get_statsdata(self, job_input: str) -> Dict[str, Any]:
|
|
737
|
+
"""
|
|
738
|
+
Get statsdata.json for a job.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Stats data from statsdata.json
|
|
745
|
+
|
|
746
|
+
Raises:
|
|
747
|
+
ProverAPIError: If fetching fails
|
|
748
|
+
"""
|
|
749
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
return self.data_fetcher.fetch_statsdata(job_identifier)
|
|
753
|
+
except Exception as e:
|
|
754
|
+
raise ProverAPIError(f"Failed to get statsdata for job {job_identifier}: {e}")
|
|
755
|
+
|
|
756
|
+
def get_console_logs(self, job_input: str) -> str:
|
|
757
|
+
"""
|
|
758
|
+
Get console logs for a job. This contains the full console output
|
|
759
|
+
including error messages like filtering errors.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
Console logs as a string
|
|
766
|
+
|
|
767
|
+
Raises:
|
|
768
|
+
AuthenticationError: If authentication fails
|
|
769
|
+
JobNotFoundError: If job is not found or logs are not available
|
|
770
|
+
ProverAPIError: If API call fails or configured for local use
|
|
771
|
+
"""
|
|
772
|
+
if self.use_local:
|
|
773
|
+
raise ProverAPIError("get_console_logs is not supported when using local prover outputs")
|
|
774
|
+
|
|
775
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
return self.data_fetcher.fetch_console_logs(job_identifier)
|
|
779
|
+
except Exception as e:
|
|
780
|
+
raise ProverAPIError(f"Failed to get console logs for job {job_identifier}: {e}")
|
|
781
|
+
|
|
782
|
+
def get_alert_report(self, job_input: str) -> List[Dict[str, Any]]:
|
|
783
|
+
"""
|
|
784
|
+
Get raw alert report for a job. This contains prover alerts and messages.
|
|
785
|
+
|
|
786
|
+
For typed alert objects, use get_alerts() instead.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
Alert report data as a list of raw alert dictionaries
|
|
793
|
+
|
|
794
|
+
Raises:
|
|
795
|
+
JobNotFoundError: If job is not found or alert report is not available
|
|
796
|
+
ProverAPIError: If API call fails
|
|
797
|
+
"""
|
|
798
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
return self.data_fetcher.fetch_alert_report(job_identifier)
|
|
802
|
+
except Exception as e:
|
|
803
|
+
raise ProverAPIError(f"Failed to get alert report for job {job_identifier}: {e}")
|
|
804
|
+
|
|
805
|
+
def get_alerts(self, job_input: str) -> List[ParsedAlert]:
|
|
806
|
+
"""
|
|
807
|
+
Get parsed alerts for a job with typed alert information.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
List of ParsedAlert objects with typed alert information
|
|
814
|
+
|
|
815
|
+
Raises:
|
|
816
|
+
JobNotFoundError: If job is not found or alert report is not available
|
|
817
|
+
ProverAPIError: If API call fails
|
|
818
|
+
"""
|
|
819
|
+
raw_alerts = self.get_alert_report(job_input)
|
|
820
|
+
return [ParsedAlert.from_dict(alert) for alert in raw_alerts]
|
|
821
|
+
|
|
822
|
+
def get_job_report(self, job_input: str) -> JobReport:
|
|
823
|
+
"""Analyze a job and return a structured report of its results.
|
|
824
|
+
|
|
825
|
+
Covers call resolution, rule results, and alerts.
|
|
826
|
+
"""
|
|
827
|
+
return JobAnalyzer(self).analyze(job_input)
|
|
828
|
+
|
|
829
|
+
def who_am_i(self) -> Dict[str, Any]:
|
|
830
|
+
"""
|
|
831
|
+
Get information about the currently authenticated user.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
User information including email, user ID, and other details
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
AuthenticationError: If authentication fails
|
|
838
|
+
ProverAPIError: If API call fails or configured for local use
|
|
839
|
+
"""
|
|
840
|
+
if self.use_local:
|
|
841
|
+
raise ProverAPIError("who_am_i is not supported when using local prover outputs")
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
return self.data_fetcher.who_am_i()
|
|
845
|
+
except Exception as e:
|
|
846
|
+
raise ProverAPIError(f"Failed to get user information: {e}")
|
|
847
|
+
|
|
848
|
+
def parse_tree_view_path(self, tree_view_path: str) -> List[str]:
|
|
849
|
+
"""
|
|
850
|
+
Parse a treeViewPath field to extract the rule hierarchy.
|
|
851
|
+
|
|
852
|
+
The treeViewPath is split by '-' to create a list representing
|
|
853
|
+
the hierarchical structure of the rule.
|
|
854
|
+
|
|
855
|
+
Args:
|
|
856
|
+
tree_view_path: The treeViewPath string from rule_output files
|
|
857
|
+
|
|
858
|
+
Returns:
|
|
859
|
+
List of hierarchy levels extracted from the path
|
|
860
|
+
|
|
861
|
+
Example:
|
|
862
|
+
Input: "sanity-previewUnwindExercise(bytes32,uint256)-Satisfy_sanity_check_failed_(sanity_spec_11_5)-sanity check failed"
|
|
863
|
+
Output: ["sanity", "previewUnwindExercise(bytes32,uint256)", "Satisfy_sanity_check_failed_(sanity_spec_11_5)", "sanity check failed"]
|
|
864
|
+
"""
|
|
865
|
+
if not tree_view_path:
|
|
866
|
+
return []
|
|
867
|
+
|
|
868
|
+
# Split by '-' to get hierarchy levels
|
|
869
|
+
hierarchy = tree_view_path.split("-")
|
|
870
|
+
|
|
871
|
+
# Clean up any extra whitespace
|
|
872
|
+
hierarchy = [level.strip() for level in hierarchy if level.strip()]
|
|
873
|
+
|
|
874
|
+
return hierarchy
|
|
875
|
+
|
|
876
|
+
def get_rule_hierarchy(self, job_input: str, output_file: str) -> List[str]:
|
|
877
|
+
"""
|
|
878
|
+
Get the rule hierarchy from a rule output file's treeViewPath.
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
882
|
+
output_file: Output file path (e.g., 'rule_output_58.json')
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
List of hierarchy levels extracted from the treeViewPath
|
|
886
|
+
|
|
887
|
+
Example:
|
|
888
|
+
Returns ["sanity", "previewUnwindExercise(bytes32,uint256)",
|
|
889
|
+
"Satisfy_sanity_check_failed_(sanity_spec_11_5)", "sanity check failed"]
|
|
890
|
+
"""
|
|
891
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
892
|
+
|
|
893
|
+
try:
|
|
894
|
+
# Use the cached fetch method
|
|
895
|
+
rule_data = self._fetch_tree_view_data_with_cache(job_identifier, output_file)
|
|
896
|
+
|
|
897
|
+
# Extract and parse the treeViewPath
|
|
898
|
+
if isinstance(rule_data, dict) and "treeViewPath" in rule_data:
|
|
899
|
+
return self.parse_tree_view_path(rule_data["treeViewPath"])
|
|
900
|
+
|
|
901
|
+
return []
|
|
902
|
+
|
|
903
|
+
except Exception as e:
|
|
904
|
+
raise ProverAPIError(
|
|
905
|
+
f"Failed to get rule hierarchy for {output_file} in job {job_identifier}: {e}"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# Backward compatibility methods that return raw dicts
|
|
909
|
+
def _get_violated_rules_dict(self, job_input: str) -> List[Dict[str, Any]]:
|
|
910
|
+
"""Get violated rules as raw dictionaries (backward compatibility)."""
|
|
911
|
+
violations = self.get_violated_rules(job_input)
|
|
912
|
+
return [v.to_dict() for v in violations]
|
|
913
|
+
|
|
914
|
+
def _get_all_checks_dict(self, job_input: str) -> List[Dict[str, Any]]:
|
|
915
|
+
"""Get all checks as raw dictionaries (backward compatibility)."""
|
|
916
|
+
checks = self.get_all_checks(job_input)
|
|
917
|
+
return [c.to_dict() for c in checks]
|
|
918
|
+
|
|
919
|
+
def _get_leaf_checks_dict(self, job_input: str) -> List[Dict[str, Any]]:
|
|
920
|
+
"""Get leaf checks as raw dictionaries (backward compatibility)."""
|
|
921
|
+
leaf_checks = self.get_leaf_checks(job_input)
|
|
922
|
+
return [c.to_dict() for c in leaf_checks]
|
|
923
|
+
|
|
924
|
+
# Compatibility methods for tests that use internal parsing methods
|
|
925
|
+
def _parse_tree_view_for_all_checks(self, tree_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
926
|
+
"""Parse tree-view data for all checks (backward compatibility for tests)."""
|
|
927
|
+
# Create parser if not exists (for tests that bypass __init__)
|
|
928
|
+
if not hasattr(self, "tree_parser"):
|
|
929
|
+
from .tree_parser import TreeParser
|
|
930
|
+
|
|
931
|
+
self.tree_parser = TreeParser()
|
|
932
|
+
checks = self.tree_parser.parse_all_checks(tree_data)
|
|
933
|
+
return [c.to_dict() for c in checks]
|
|
934
|
+
|
|
935
|
+
def _parse_tree_view_for_violations(self, tree_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
936
|
+
"""
|
|
937
|
+
Parse tree-view data for VIOLATED_ASSERT nodes (backward compatibility for tests).
|
|
938
|
+
|
|
939
|
+
Note: This returns only VIOLATED_ASSERT nodes, not all nodes with VIOLATED status.
|
|
940
|
+
For all violated nodes, use parse_violations() directly.
|
|
941
|
+
"""
|
|
942
|
+
# Create parser if not exists (for tests that bypass __init__)
|
|
943
|
+
if not hasattr(self, "tree_parser"):
|
|
944
|
+
from .tree_parser import TreeParser
|
|
945
|
+
|
|
946
|
+
self.tree_parser = TreeParser()
|
|
947
|
+
violations = self.tree_parser.parse_violated_asserts(tree_data)
|
|
948
|
+
return [v.to_dict() for v in violations]
|
|
949
|
+
|
|
950
|
+
def get_source_files(self, job_input: str) -> List[SourceFileNode]:
|
|
951
|
+
"""
|
|
952
|
+
Get the source file tree for a job.
|
|
953
|
+
|
|
954
|
+
Args:
|
|
955
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
List of SourceFileNode objects representing the file tree
|
|
959
|
+
|
|
960
|
+
Raises:
|
|
961
|
+
ProverAPIError: If API call fails
|
|
962
|
+
"""
|
|
963
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
964
|
+
self.logger.info(f"Fetching source files for job: {job_identifier}")
|
|
965
|
+
raw_data = self.data_fetcher.fetch_source_files_list(job_identifier)
|
|
966
|
+
return SourceFileNode.from_list(raw_data)
|
|
967
|
+
|
|
968
|
+
def get_source_file_content(self, job_input: str, path: str) -> str:
|
|
969
|
+
"""
|
|
970
|
+
Get the content of a specific source file for a job.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
974
|
+
path: Path to the source file (e.g. ".certora_sources/src/Contract.sol")
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
Raw file content as a string
|
|
978
|
+
|
|
979
|
+
Raises:
|
|
980
|
+
ProverAPIError: If API call fails
|
|
981
|
+
"""
|
|
982
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
983
|
+
self.logger.info(f"Fetching source file {path} for job: {job_identifier}")
|
|
984
|
+
return self.data_fetcher.fetch_source_file_content(job_identifier, path)
|
|
985
|
+
|
|
986
|
+
def fetch_custom_endpoint(
|
|
987
|
+
self, endpoint: str, params: Optional[Dict[str, Any]] = None, method: str = "GET"
|
|
988
|
+
) -> Dict[str, Any]:
|
|
989
|
+
"""
|
|
990
|
+
Fetch data from an arbitrary user-provided endpoint.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
endpoint: The full URL endpoint to fetch from
|
|
994
|
+
params: Optional query parameters (for GET/DELETE) or request body (for POST/PUT)
|
|
995
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
API response data
|
|
999
|
+
|
|
1000
|
+
Raises:
|
|
1001
|
+
ProverAPIError: If configured for local use or if API call fails
|
|
1002
|
+
AuthenticationError: If authentication fails
|
|
1003
|
+
"""
|
|
1004
|
+
if self.use_local:
|
|
1005
|
+
raise ProverAPIError(
|
|
1006
|
+
"fetch_custom_endpoint is not supported when using local prover outputs"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
try:
|
|
1010
|
+
return self.data_fetcher.fetch_custom_endpoint(endpoint, params, method)
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
raise ProverAPIError(f"Failed to fetch from custom endpoint {endpoint}: {e}")
|
|
1013
|
+
|
|
1014
|
+
def download_job_outputs(self, job_input: str) -> Dict[str, Any]:
|
|
1015
|
+
"""
|
|
1016
|
+
Download and extract all output files for a job using bulk tar.gz download.
|
|
1017
|
+
|
|
1018
|
+
This method downloads the entire job output archive and extracts individual
|
|
1019
|
+
JSON files to disk cache. Subsequent calls to get_rule_hierarchy, get_calltrace,
|
|
1020
|
+
etc. will read from local disk instead of making individual API calls.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
Dictionary with download statistics:
|
|
1027
|
+
{
|
|
1028
|
+
'files_extracted': 123,
|
|
1029
|
+
'download_size_mb': 30.4,
|
|
1030
|
+
'download_time_s': 4.22,
|
|
1031
|
+
'extraction_time_s': 1.15,
|
|
1032
|
+
'cache_dir': '/path/to/cache'
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
Raises:
|
|
1036
|
+
ProverAPIError: If configured for local use or if download fails
|
|
1037
|
+
"""
|
|
1038
|
+
if self.use_local:
|
|
1039
|
+
raise ProverAPIError(
|
|
1040
|
+
"download_job_outputs is not supported when using local prover outputs"
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
1044
|
+
|
|
1045
|
+
# Check if already downloaded
|
|
1046
|
+
cache_dir = self._get_bulk_cache_dir(job_identifier)
|
|
1047
|
+
if cache_dir.exists():
|
|
1048
|
+
existing_files = list(cache_dir.glob("*.json"))
|
|
1049
|
+
if existing_files:
|
|
1050
|
+
self.logger.info(f"Job outputs already cached for {job_identifier} ({len(existing_files)} files)")
|
|
1051
|
+
return {
|
|
1052
|
+
'files_extracted': len(existing_files),
|
|
1053
|
+
'download_size_mb': 0,
|
|
1054
|
+
'download_time_s': 0,
|
|
1055
|
+
'extraction_time_s': 0,
|
|
1056
|
+
'cache_hit': True,
|
|
1057
|
+
'cache_dir': str(cache_dir)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
self.logger.info(f"Downloading bulk outputs for job {job_identifier}")
|
|
1061
|
+
|
|
1062
|
+
try:
|
|
1063
|
+
import time
|
|
1064
|
+
|
|
1065
|
+
# Download tar.gz using data_fetcher
|
|
1066
|
+
start_download = time.time()
|
|
1067
|
+
tar_content = self.data_fetcher.fetch_outputs(job_identifier)
|
|
1068
|
+
download_time = time.time() - start_download
|
|
1069
|
+
download_size_mb = len(tar_content) / (1024 * 1024)
|
|
1070
|
+
|
|
1071
|
+
# Create cache directory
|
|
1072
|
+
cache_dir = self._get_bulk_cache_dir(job_identifier)
|
|
1073
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
1074
|
+
|
|
1075
|
+
# Extract tar.gz directly to disk
|
|
1076
|
+
start_extraction = time.time()
|
|
1077
|
+
files_extracted = 0
|
|
1078
|
+
|
|
1079
|
+
with tarfile.open(fileobj=io.BytesIO(tar_content), mode='r:gz') as tar:
|
|
1080
|
+
for member in tar.getmembers():
|
|
1081
|
+
if member.isfile() and member.name.endswith('.json'):
|
|
1082
|
+
try:
|
|
1083
|
+
# Extract file content
|
|
1084
|
+
file_obj = tar.extractfile(member)
|
|
1085
|
+
if file_obj:
|
|
1086
|
+
content = file_obj.read().decode('utf-8')
|
|
1087
|
+
# Validate JSON
|
|
1088
|
+
json_data = json.loads(content)
|
|
1089
|
+
|
|
1090
|
+
# Save to disk with just filename
|
|
1091
|
+
filename = os.path.basename(member.name)
|
|
1092
|
+
file_path = cache_dir / filename
|
|
1093
|
+
|
|
1094
|
+
with open(file_path, 'w') as f:
|
|
1095
|
+
json.dump(json_data, f, indent=2, default=str)
|
|
1096
|
+
|
|
1097
|
+
files_extracted += 1
|
|
1098
|
+
|
|
1099
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
1100
|
+
self.logger.warning(f"Skipping invalid file {member.name}: {e}")
|
|
1101
|
+
continue
|
|
1102
|
+
|
|
1103
|
+
extraction_time = time.time() - start_extraction
|
|
1104
|
+
|
|
1105
|
+
self.logger.info(
|
|
1106
|
+
f"Downloaded {files_extracted} files for job {job_identifier} "
|
|
1107
|
+
f"({download_size_mb:.1f}MB in {download_time:.2f}s + {extraction_time:.2f}s extraction)"
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
return {
|
|
1111
|
+
'files_extracted': files_extracted,
|
|
1112
|
+
'download_size_mb': download_size_mb,
|
|
1113
|
+
'download_time_s': download_time,
|
|
1114
|
+
'extraction_time_s': extraction_time,
|
|
1115
|
+
'cache_hit': False,
|
|
1116
|
+
'cache_dir': str(cache_dir)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
raise ProverAPIError(f"Failed to download job outputs for {job_identifier}: {e}")
|
|
1121
|
+
|
|
1122
|
+
def _fetch_outputs_cached(self, job_identifier: str) -> bytes:
|
|
1123
|
+
"""Fetch the job outputs tar with disk-level caching.
|
|
1124
|
+
|
|
1125
|
+
Caches the raw tar bytes in bulk_cache/job_{id}/outputs.tar.gz so subsequent
|
|
1126
|
+
calls for the same job skip the network fetch.
|
|
1127
|
+
"""
|
|
1128
|
+
cache_dir = self._get_bulk_cache_dir(job_identifier)
|
|
1129
|
+
tar_path = cache_dir / "outputs.tar.gz"
|
|
1130
|
+
if tar_path.exists():
|
|
1131
|
+
return tar_path.read_bytes()
|
|
1132
|
+
tar_content = self.data_fetcher.fetch_outputs(job_identifier)
|
|
1133
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
1134
|
+
tar_path.write_bytes(tar_content)
|
|
1135
|
+
return tar_content
|
|
1136
|
+
|
|
1137
|
+
def extract_unsat_core_files(self, job_input: str, dest_dir: Path) -> List[Path]:
|
|
1138
|
+
"""Extract UnsatCoreTAC*.txt files from the job output tar into dest_dir.
|
|
1139
|
+
|
|
1140
|
+
No filtering is applied — the caller is responsible for any filtering.
|
|
1141
|
+
|
|
1142
|
+
Returns list of extracted file paths.
|
|
1143
|
+
"""
|
|
1144
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
1145
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
1146
|
+
tar_content = self._fetch_outputs_cached(job_identifier)
|
|
1147
|
+
extracted: List[Path] = []
|
|
1148
|
+
with tarfile.open(fileobj=io.BytesIO(tar_content), mode="r:gz") as tar:
|
|
1149
|
+
for member in tar.getmembers():
|
|
1150
|
+
if not member.isfile():
|
|
1151
|
+
continue
|
|
1152
|
+
parts = Path(member.name).parts
|
|
1153
|
+
name = parts[-1]
|
|
1154
|
+
if "Reports" in parts and name.startswith("UnsatCoreTAC") and name.endswith(".txt"):
|
|
1155
|
+
file_dest = dest_dir / name
|
|
1156
|
+
file_obj = tar.extractfile(member)
|
|
1157
|
+
if file_obj:
|
|
1158
|
+
file_dest.write_bytes(file_obj.read())
|
|
1159
|
+
extracted.append(file_dest)
|
|
1160
|
+
return extracted
|
|
1161
|
+
|
|
1162
|
+
def extract_certora_sources(self, job_input: str, dest_dir: Path) -> None:
|
|
1163
|
+
"""Extract the .certora_sources tree from the job output tar into dest_dir.
|
|
1164
|
+
|
|
1165
|
+
The tree structure rooted at .certora_sources is preserved under dest_dir.
|
|
1166
|
+
"""
|
|
1167
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
1168
|
+
tar_content = self._fetch_outputs_cached(job_identifier)
|
|
1169
|
+
with tarfile.open(fileobj=io.BytesIO(tar_content), mode="r:gz") as tar:
|
|
1170
|
+
for member in tar.getmembers():
|
|
1171
|
+
if not member.isfile():
|
|
1172
|
+
continue
|
|
1173
|
+
parts = Path(member.name).parts
|
|
1174
|
+
if ".certora_sources" in parts:
|
|
1175
|
+
src_idx = parts.index(".certora_sources")
|
|
1176
|
+
rel = Path(*parts[src_idx:])
|
|
1177
|
+
file_dest = dest_dir / rel
|
|
1178
|
+
file_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1179
|
+
file_obj = tar.extractfile(member)
|
|
1180
|
+
if file_obj:
|
|
1181
|
+
file_dest.write_bytes(file_obj.read())
|
|
1182
|
+
|
|
1183
|
+
# ---------------------------------------------------------------------------
|
|
1184
|
+
# High-level fetch methods (no tarball download)
|
|
1185
|
+
# ---------------------------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
def fetch_job_sources(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
|
|
1188
|
+
"""Download all source files for a job into dest_dir/inputs/.certora_sources/.
|
|
1189
|
+
|
|
1190
|
+
Uses the source-files API to fetch individual files in parallel, avoiding
|
|
1191
|
+
the need to download the full job output tarball.
|
|
1192
|
+
|
|
1193
|
+
Files are only downloaded once — if the marker file .source_fetch_complete
|
|
1194
|
+
exists, the download is skipped.
|
|
1195
|
+
|
|
1196
|
+
Args:
|
|
1197
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
1198
|
+
dest_dir: Base directory for the report layout
|
|
1199
|
+
max_workers: Number of parallel download threads (default: 8)
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
dest_dir (unchanged, for chaining)
|
|
1203
|
+
"""
|
|
1204
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
1205
|
+
sources_dir = dest_dir / "inputs" / ".certora_sources"
|
|
1206
|
+
marker = sources_dir / ".source_fetch_complete"
|
|
1207
|
+
|
|
1208
|
+
# Per-job lock prevents concurrent threads from writing the same files
|
|
1209
|
+
with _get_fetch_lock(f"sources_{job_identifier}"):
|
|
1210
|
+
if marker.exists():
|
|
1211
|
+
self.logger.info(f"Source files already fetched for {dest_dir}")
|
|
1212
|
+
return dest_dir
|
|
1213
|
+
|
|
1214
|
+
sources_dir.mkdir(parents=True, exist_ok=True)
|
|
1215
|
+
|
|
1216
|
+
file_tree = self.get_source_files(job_input)
|
|
1217
|
+
all_files = _flatten_source_file_tree(file_tree)
|
|
1218
|
+
|
|
1219
|
+
certora_prefix = ".certora_sources/"
|
|
1220
|
+
source_files = [
|
|
1221
|
+
(api_path[len(certora_prefix):], api_path)
|
|
1222
|
+
for _, api_path in all_files
|
|
1223
|
+
if api_path.startswith(certora_prefix)
|
|
1224
|
+
]
|
|
1225
|
+
|
|
1226
|
+
if not source_files:
|
|
1227
|
+
self.logger.warning(f"No source files found for job {job_identifier}")
|
|
1228
|
+
marker.touch()
|
|
1229
|
+
return dest_dir
|
|
1230
|
+
|
|
1231
|
+
def _fetch_one(item: tuple) -> None:
|
|
1232
|
+
rel_path, api_path = item
|
|
1233
|
+
content = self.get_source_file_content(job_input, api_path)
|
|
1234
|
+
file_path = sources_dir / rel_path
|
|
1235
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1236
|
+
file_path.write_text(content, encoding="utf-8")
|
|
1237
|
+
|
|
1238
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
1239
|
+
list(pool.map(_fetch_one, source_files))
|
|
1240
|
+
|
|
1241
|
+
marker.touch()
|
|
1242
|
+
self.logger.info(f"Fetched {len(source_files)} source files to {sources_dir}")
|
|
1243
|
+
return dest_dir
|
|
1244
|
+
|
|
1245
|
+
def fetch_job_treeview(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
|
|
1246
|
+
"""Download treeview status and all output files into dest_dir/Reports/treeView/.
|
|
1247
|
+
|
|
1248
|
+
Uses the tree-view API to fetch the treeViewStatus and individual output files
|
|
1249
|
+
in parallel, avoiding the need to download the full job output tarball.
|
|
1250
|
+
|
|
1251
|
+
Files are only downloaded once — if treeViewStatus_0.json exists, the download
|
|
1252
|
+
is skipped.
|
|
1253
|
+
|
|
1254
|
+
Args:
|
|
1255
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
1256
|
+
dest_dir: Base directory for the report layout
|
|
1257
|
+
max_workers: Number of parallel download threads (default: 8)
|
|
1258
|
+
|
|
1259
|
+
Returns:
|
|
1260
|
+
dest_dir (unchanged, for chaining)
|
|
1261
|
+
"""
|
|
1262
|
+
job_identifier = self._extract_job_identifier(job_input)
|
|
1263
|
+
treeview_dir = dest_dir / "Reports" / "treeView"
|
|
1264
|
+
status_file = treeview_dir / "treeViewStatus_0.json"
|
|
1265
|
+
|
|
1266
|
+
# Per-job lock prevents concurrent threads from writing the same files
|
|
1267
|
+
with _get_fetch_lock(f"treeview_{job_identifier}"):
|
|
1268
|
+
if status_file.exists():
|
|
1269
|
+
self.logger.info(f"Treeview data already fetched for {dest_dir}")
|
|
1270
|
+
return dest_dir
|
|
1271
|
+
|
|
1272
|
+
treeview_dir.mkdir(parents=True, exist_ok=True)
|
|
1273
|
+
|
|
1274
|
+
tree_status = self.get_treeview_status(job_input)
|
|
1275
|
+
|
|
1276
|
+
output_files = _collect_treeview_output_files(tree_status)
|
|
1277
|
+
|
|
1278
|
+
def _fetch_one(filename: str) -> Optional[str]:
|
|
1279
|
+
try:
|
|
1280
|
+
file_data = self.fetch_treeview_output_by_filename(job_input, filename)
|
|
1281
|
+
output_path = treeview_dir / filename
|
|
1282
|
+
with open(output_path, "w") as f:
|
|
1283
|
+
json.dump(file_data, f, indent=2)
|
|
1284
|
+
return None
|
|
1285
|
+
except Exception as e:
|
|
1286
|
+
return f"Warning: Failed to fetch {filename}: {e}"
|
|
1287
|
+
|
|
1288
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
1289
|
+
for error in pool.map(_fetch_one, output_files):
|
|
1290
|
+
if error:
|
|
1291
|
+
self.logger.warning(error)
|
|
1292
|
+
|
|
1293
|
+
# Write status file last — acts as the completion marker
|
|
1294
|
+
with open(status_file, "w") as f:
|
|
1295
|
+
json.dump(tree_status, f, indent=2)
|
|
1296
|
+
|
|
1297
|
+
self.logger.info(f"Fetched treeview + {len(output_files)} output files to {treeview_dir}")
|
|
1298
|
+
return dest_dir
|
|
1299
|
+
|
|
1300
|
+
def fetch_sources_and_treeview_files(self, job_input: str, dest_dir: Path, max_workers: int = 8) -> Path:
|
|
1301
|
+
"""Download source files and treeview data into a report directory layout.
|
|
1302
|
+
|
|
1303
|
+
Combines fetch_job_sources() and fetch_job_treeview() into a single call.
|
|
1304
|
+
The resulting directory layout matches what tarball extraction produces::
|
|
1305
|
+
|
|
1306
|
+
dest_dir/
|
|
1307
|
+
inputs/.certora_sources/ <- contract source files
|
|
1308
|
+
Reports/treeView/ <- treeview status + output JSONs
|
|
1309
|
+
|
|
1310
|
+
This is a drop-in replacement for downloading and extracting the full job
|
|
1311
|
+
output tarball when only source files and treeview data are needed.
|
|
1312
|
+
|
|
1313
|
+
Args:
|
|
1314
|
+
job_input: Job URL, job ID, or local emv-* path
|
|
1315
|
+
dest_dir: Base directory for the report layout
|
|
1316
|
+
max_workers: Number of parallel download threads (default: 8)
|
|
1317
|
+
|
|
1318
|
+
Returns:
|
|
1319
|
+
dest_dir (for use as the ``folder`` argument to AIComposer analysis)
|
|
1320
|
+
"""
|
|
1321
|
+
self.fetch_job_sources(job_input, dest_dir, max_workers=max_workers)
|
|
1322
|
+
self.fetch_job_treeview(job_input, dest_dir, max_workers=max_workers)
|
|
1323
|
+
return dest_dir
|
|
1324
|
+
|
|
1325
|
+
def _get_bulk_cache_dir(self, job_identifier: str) -> Path:
|
|
1326
|
+
"""Get the bulk cache directory for a specific job."""
|
|
1327
|
+
if self.cache:
|
|
1328
|
+
base_dir = self.cache.cache_dir.parent / "bulk_cache"
|
|
1329
|
+
else:
|
|
1330
|
+
# Fallback if no regular cache enabled
|
|
1331
|
+
base_dir = Path.cwd() / ".certora_internal" / "bulk_cache"
|
|
1332
|
+
|
|
1333
|
+
return base_dir / f"job_{job_identifier}"
|