certora-prover-cli 0.1.0__tar.gz → 0.2.1__tar.gz

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 (28) hide show
  1. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/PKG-INFO +3 -4
  2. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/pyproject.toml +4 -1
  3. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/_version.py +2 -2
  4. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/aws_data_fetcher.py +19 -11
  5. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/base_data_fetcher.py +19 -0
  6. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/data_fetcher.py +20 -12
  7. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/local_data_fetcher.py +28 -29
  8. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/main.py +42 -3
  9. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/tree_parser.py +1 -0
  10. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/models.py +3 -0
  11. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/prover_cli.py +38 -0
  12. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/.gitignore +0 -0
  13. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/LICENSE +0 -0
  14. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/README.md +0 -0
  15. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/__init__.py +0 -0
  16. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/__init__.py +0 -0
  17. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/cache.py +0 -0
  18. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/api/url_utils.py +0 -0
  19. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/auth.py +0 -0
  20. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/aws_auth.py +0 -0
  21. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/breadcrumb.py +0 -0
  22. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/exceptions.py +0 -0
  23. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/group_summary.py +0 -0
  24. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/job_report.py +0 -0
  25. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/parsers.py +0 -0
  26. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/prover_api.py +0 -0
  27. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/py.typed +0 -0
  28. {certora_prover_cli-0.1.0 → certora_prover_cli-0.2.1}/src/prover_output_utility/sighash.py +0 -0
@@ -1,14 +1,13 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: certora-prover-cli
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: A Python API and CLI for parsing outputs from the Certora Prover
5
5
  Project-URL: Homepage, https://github.com/Certora/ProverCLI
6
6
  Project-URL: Repository, https://github.com/Certora/ProverCLI
7
7
  Project-URL: Bug Tracker, https://github.com/Certora/ProverCLI/issues
8
8
  Author-email: Certora <support@certora.com>
9
9
  Maintainer-email: Certora <support@certora.com>
10
- License-Expression: GPL-3.0-only
11
- License-File: LICENSE
10
+ License: GPL-3.0-only
12
11
  Classifier: Development Status :: 3 - Alpha
13
12
  Classifier: Intended Audience :: Developers
14
13
  Classifier: Programming Language :: Python :: 3
@@ -1,5 +1,8 @@
1
1
  [build-system]
2
- requires = ["hatchling", "hatch-vcs"]
2
+ # hatchling >=1.27 emits wheel Metadata-Version 2.5 (PEP 639 license fields),
3
+ # which the pinned pypa/gh-action-pypi-publish twine cannot validate. Cap below
4
+ # 1.27 so wheels carry Metadata-Version 2.3, accepted by that action and PyPI.
5
+ requires = ["hatchling<1.27", "hatch-vcs"]
3
6
  build-backend = "hatchling.build"
4
7
 
5
8
  [project]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.1'
22
+ __version_tuple__ = version_tuple = (0, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,6 +7,7 @@ AWS Lambda-based data fetcher for CI environments.
7
7
  Uses AWS SigV4 authentication to access Certora API through Lambda.
8
8
  """
9
9
 
10
+ import json
10
11
  import os
11
12
  from typing import Any, Dict, List, Optional, cast
12
13
 
@@ -199,37 +200,44 @@ class AWSDataFetcher(BaseDataFetcher):
199
200
  raise ProverAPIError(f"Failed to fetch tree-view data for {job_identifier}: {e}")
200
201
 
201
202
  def fetch_statsdata(self, job_identifier: str) -> Dict[str, Any]:
202
- """
203
- Fetch statsdata.json for a job via Lambda.
203
+ """Fetch statsdata.json for a job via Lambda."""
204
+ try:
205
+ return cast(Dict[str, Any], json.loads(self.fetch_output_file(job_identifier, "statsdata.json")))
206
+ except json.JSONDecodeError as e:
207
+ raise ProverAPIError(f"Failed to parse statsdata.json for {job_identifier}: {e}")
208
+
209
+ def fetch_output_file(self, job_identifier: str, rel_path: str) -> str:
210
+ """Fetch the raw text of a Reports/-relative output file (e.g. unsat_core_map.json).
204
211
 
205
212
  Args:
206
- job_identifier: Job ID to fetch
213
+ job_identifier: Job ID
214
+ rel_path: Path of the file relative to the job's Reports/ directory
207
215
 
208
216
  Returns:
209
- Stats data from statsdata.json
217
+ The file contents as text
210
218
 
211
219
  Raises:
212
220
  AuthenticationError: If authentication fails
213
- JobNotFoundError: If job is not found
214
- ProverAPIError: If API call fails
221
+ JobNotFoundError: If the job or file is not found
222
+ ProverAPIError: If the API call fails
215
223
  """
216
- endpoint = f"{self.base_url}/v1/domain/jobs/{job_identifier}/f/statsdata.json"
224
+ endpoint = f"{self.base_url}/v1/domain/jobs/{job_identifier}/f/{rel_path}"
217
225
 
218
226
  try:
219
227
  response = self._make_signed_request("GET", endpoint)
220
228
 
221
229
  if response.status_code == 200:
222
- return cast(Dict[str, Any], response.json())
230
+ return response.text
223
231
  elif response.status_code == 401:
224
232
  raise AuthenticationError("Authentication failed - check AWS credentials")
225
233
  elif response.status_code == 404:
226
- raise JobNotFoundError(f"Stats data not found for job {job_identifier}")
234
+ raise JobNotFoundError(f"Output file not found for job {job_identifier}: {rel_path}")
227
235
  else:
228
236
  response.raise_for_status()
229
- return cast(Dict[str, Any], response.json())
237
+ return response.text
230
238
 
231
239
  except requests.exceptions.RequestException as e:
232
- raise ProverAPIError(f"Failed to fetch stats data for {job_identifier}: {e}")
240
+ raise ProverAPIError(f"Failed to fetch output file {rel_path} for {job_identifier}: {e}")
233
241
 
234
242
  def fetch_outputs(self, job_identifier: str) -> bytes:
235
243
  """
@@ -159,6 +159,25 @@ class BaseDataFetcher(ABC):
159
159
  """
160
160
  pass
161
161
 
162
+ @abstractmethod
163
+ def fetch_output_file(self, job_identifier: str, rel_path: str) -> str:
164
+ """
165
+ Fetch the raw text of a file under the job's Reports/ output dir.
166
+
167
+ Args:
168
+ job_identifier: Job identifier (job ID for remote, emv path for local)
169
+ rel_path: Reports/-relative filename (e.g. "unsat_core_map.json", "UnsatCoreTAC-....txt")
170
+
171
+ Returns:
172
+ Raw file content as a string
173
+
174
+ Raises:
175
+ AuthenticationError: If authentication fails (remote fetchers)
176
+ JobNotFoundError: If the file is not found
177
+ ProverAPIError: If fetch fails
178
+ """
179
+ pass
180
+
162
181
  @abstractmethod
163
182
  def fetch_alert_report(self, job_identifier: str) -> List[Dict[str, Any]]:
164
183
  """
@@ -6,6 +6,7 @@
6
6
  Low-level data fetching utilities for the Prover API.
7
7
  """
8
8
  import functools
9
+ import json
9
10
  from typing import Any, Callable, Dict, List, Optional, TypeVar, cast
10
11
 
11
12
  import requests
@@ -255,39 +256,46 @@ class DataFetcher(BaseDataFetcher):
255
256
  except requests.exceptions.RequestException as e:
256
257
  raise ProverAPIError(f"Failed to fetch tree-view data for {job_identifier}: {e}")
257
258
 
258
- @handle_token_expiration
259
259
  def fetch_statsdata(self, job_identifier: str) -> Dict[str, Any]:
260
- """
261
- Fetch statsdata.json for a job.
260
+ """Fetch statsdata.json for a job."""
261
+ try:
262
+ return cast(Dict[str, Any], json.loads(self.fetch_output_file(job_identifier, "statsdata.json")))
263
+ except json.JSONDecodeError as e:
264
+ raise ProverAPIError(f"Failed to parse statsdata.json for {job_identifier}: {e}")
265
+
266
+ @handle_token_expiration
267
+ def fetch_output_file(self, job_identifier: str, rel_path: str) -> str:
268
+ """Fetch the raw text of a Reports/-relative output file (e.g. unsat_core_map.json).
262
269
 
263
270
  Args:
264
- job_identifier: Job ID to fetch
271
+ job_identifier: Job ID
272
+ rel_path: Path of the file relative to the job's Reports/ directory
265
273
 
266
274
  Returns:
267
- Stats data from statsdata.json
275
+ The file contents as text
268
276
 
269
277
  Raises:
270
278
  AuthenticationError: If authentication fails
271
- JobNotFoundError: If job is not found
272
- ProverAPIError: If API call fails
279
+ JobNotFoundError: If the job or file is not found
280
+ ProverAPIError: If the API call fails
273
281
  """
274
- endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/f/statsdata.json"
282
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/f/{rel_path}"
275
283
 
276
284
  try:
277
285
  response = self.session.get(endpoint)
278
286
 
279
287
  if response.status_code == 200:
280
- return cast(Dict[str, Any], response.json())
288
+ return response.text
281
289
  elif response.status_code == 401:
282
290
  raise AuthenticationError("Authentication failed - check CERTORAKEY")
283
291
  elif response.status_code == 404:
284
- raise JobNotFoundError(f"Stats data not found for job {job_identifier}")
292
+ raise JobNotFoundError(f"Output file not found for job {job_identifier}: {rel_path}")
285
293
  else:
286
294
  response.raise_for_status()
287
- return cast(Dict[str, Any], response.json())
295
+ return response.text
288
296
 
289
297
  except requests.exceptions.RequestException as e:
290
- raise ProverAPIError(f"Failed to fetch stats data for {job_identifier}: {e}")
298
+ raise ProverAPIError(f"Failed to fetch output file {rel_path} for {job_identifier}: {e}")
291
299
 
292
300
  def fetch_outputs(self, job_identifier: str) -> bytes:
293
301
  """
@@ -200,38 +200,11 @@ class LocalDataFetcher(BaseDataFetcher):
200
200
  raise ProverAPIError("cancel_jobs is not supported for local prover outputs")
201
201
 
202
202
  def fetch_statsdata(self, job_identifier: str) -> Dict[str, Any]:
203
- """
204
- Fetch statsdata.json from local emv-* folder.
205
-
206
- Args:
207
- job_identifier: Path to the emv-* folder
208
-
209
- Returns:
210
- Stats data from statsdata.json
211
-
212
- Raises:
213
- JobNotFoundError: If statsdata.json is not found
214
- ProverAPIError: If local files cannot be read
215
- """
216
- emv_path = os.path.abspath(job_identifier)
217
-
218
- if not os.path.exists(emv_path):
219
- raise JobNotFoundError(f"Local prover output path not found: {job_identifier}")
220
-
203
+ """Fetch statsdata.json from local emv-* folder."""
221
204
  try:
222
- # Look for statsdata.json in Reports directory
223
- statsdata_path = os.path.join(emv_path, "Reports", "statsdata.json")
224
-
225
- if not os.path.exists(statsdata_path):
226
- raise JobNotFoundError(f"Stats data not found for local job {job_identifier}")
227
-
228
- with open(statsdata_path, "r") as f:
229
- return json.load(f)
230
-
205
+ return json.loads(self.fetch_output_file(job_identifier, "statsdata.json"))
231
206
  except json.JSONDecodeError as e:
232
207
  raise ProverAPIError(f"Failed to parse statsdata.json: {e}")
233
- except Exception as e:
234
- raise ProverAPIError(f"Failed to read stats data from {job_identifier}: {e}")
235
208
 
236
209
  def fetch_outputs(self, job_identifier: str) -> bytes:
237
210
  """
@@ -266,6 +239,32 @@ class LocalDataFetcher(BaseDataFetcher):
266
239
  """
267
240
  raise ProverAPIError("fetch_source_file_content is not supported for local prover outputs")
268
241
 
242
+ def fetch_output_file(self, job_identifier: str, rel_path: str) -> str:
243
+ """Read a Reports/-relative output file from a local emv-* folder.
244
+
245
+ Args:
246
+ job_identifier: Path to the emv-* folder
247
+ rel_path: Path of the file relative to the folder's Reports/ directory
248
+
249
+ Returns:
250
+ The file contents as text
251
+
252
+ Raises:
253
+ JobNotFoundError: If the folder or file is not found
254
+ ProverAPIError: If the file cannot be read
255
+ """
256
+ emv_path = os.path.abspath(job_identifier)
257
+ if not os.path.exists(emv_path):
258
+ raise JobNotFoundError(f"Local prover output path not found: {job_identifier}")
259
+ file_path = os.path.join(emv_path, "Reports", rel_path)
260
+ if not os.path.exists(file_path):
261
+ raise JobNotFoundError(f"Output file not found: {rel_path}")
262
+ try:
263
+ with open(file_path, "r") as f:
264
+ return f.read()
265
+ except OSError as e:
266
+ raise ProverAPIError(f"Failed to read output file {rel_path}: {e}")
267
+
269
268
  def fetch_alert_report(self, job_identifier: str) -> List[Dict[str, Any]]:
270
269
  """
271
270
  Fetch alert report (alertReport.json) from local emv-* folder.
@@ -22,7 +22,7 @@ import requests
22
22
  from ..auth import ProverAuth
23
23
  from ..aws_auth import AWSAuth
24
24
  from ..breadcrumb import BreadcrumbParser
25
- from ..exceptions import ProverAPIError
25
+ from ..exceptions import JobNotFoundError, ProverAPIError
26
26
  from ..job_report import JobAnalyzer, JobReport
27
27
  from ..models import (
28
28
  BreadcrumbInfo,
@@ -1135,12 +1135,27 @@ class ProverOutputAPI:
1135
1135
  return tar_content
1136
1136
 
1137
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.
1138
+ """Extract UnsatCoreTAC*.txt files into dest_dir.
1139
1139
 
1140
- No filtering is applied — the caller is responsible for any filtering.
1140
+ Uses unsat_core_map.json to fetch only the referenced files; falls back to the
1141
+ full output tar when the map is absent. No filtering is applied — the caller is
1142
+ responsible for any filtering.
1141
1143
 
1142
1144
  Returns list of extracted file paths.
1143
1145
  """
1146
+ dest_dir.mkdir(parents=True, exist_ok=True)
1147
+ core_map = self.unsat_core_map(job_input)
1148
+ if core_map:
1149
+ filenames = sorted({f for files in core_map.values() for f in files})
1150
+ extracted: List[Path] = []
1151
+ for name in filenames:
1152
+ file_dest = dest_dir / Path(name).name
1153
+ file_dest.write_text(self.fetch_output_file(job_input, name), encoding="utf-8")
1154
+ extracted.append(file_dest)
1155
+ return extracted
1156
+ return self._extract_unsat_core_files_from_tar(job_input, dest_dir)
1157
+
1158
+ def _extract_unsat_core_files_from_tar(self, job_input: str, dest_dir: Path) -> List[Path]:
1144
1159
  job_identifier = self._extract_job_identifier(job_input)
1145
1160
  dest_dir.mkdir(parents=True, exist_ok=True)
1146
1161
  tar_content = self._fetch_outputs_cached(job_identifier)
@@ -1159,6 +1174,30 @@ class ProverOutputAPI:
1159
1174
  extracted.append(file_dest)
1160
1175
  return extracted
1161
1176
 
1177
+ def fetch_output_file(self, job_input: str, rel_path: str) -> str:
1178
+ """Fetch the raw text of a Reports/-relative output file (e.g. 'unsat_core_map.json')."""
1179
+ job_identifier = self._extract_job_identifier(job_input)
1180
+ return self.data_fetcher.fetch_output_file(job_identifier, rel_path)
1181
+
1182
+ def unsat_core_map(self, job_input: str) -> Dict[str, List[str]]:
1183
+ """The job's `{ ruleId -> [UnsatCoreTAC .txt filenames] }` map, or {} if the job has none."""
1184
+ try:
1185
+ content = self.fetch_output_file(job_input, "unsat_core_map.json")
1186
+ except JobNotFoundError:
1187
+ return {}
1188
+ try:
1189
+ return json.loads(content)
1190
+ except json.JSONDecodeError as e:
1191
+ raise ProverAPIError(f"Failed to parse unsat_core_map.json for {job_input}: {e}")
1192
+
1193
+ def unsat_core_filenames(self, job_input: str, rule_id: str) -> List[str]:
1194
+ """UnsatCoreTAC .txt filenames for a rule (by its treeView ruleId); [] if none."""
1195
+ return self.unsat_core_map(job_input).get(rule_id, [])
1196
+
1197
+ def read_unsat_cores(self, job_input: str, rule_id: str) -> List[str]:
1198
+ """Contents of a rule's UnsatCoreTAC .txt dumps (by its treeView ruleId)."""
1199
+ return [self.fetch_output_file(job_input, name) for name in self.unsat_core_filenames(job_input, rule_id)]
1200
+
1162
1201
  def extract_certora_sources(self, job_input: str, dest_dir: Path) -> None:
1163
1202
  """Extract the .certora_sources tree from the job output tar into dest_dir.
1164
1203
 
@@ -285,6 +285,7 @@ class TreeParser:
285
285
  return CheckResult(
286
286
  rule_name=self._get_rule_name_from_context(context),
287
287
  method_name=method_name, # Use the explicitly tracked method name
288
+ rule_id=node.get("ruleId"),
288
289
  contract_name=contract_name,
289
290
  method_only=method_only,
290
291
  assert_message=assert_message,
@@ -31,6 +31,7 @@ class NodeStatus(str, Enum):
31
31
 
32
32
  VIOLATED = "VIOLATED"
33
33
  VERIFIED = "VERIFIED"
34
+ SANITY_FAILED = "SANITY_FAILED"
34
35
  TIMEOUT = "TIMEOUT"
35
36
  ERROR = "ERROR"
36
37
  RUNNING = "RUNNING"
@@ -221,6 +222,7 @@ class CheckResult:
221
222
  node_type: NodeType
222
223
  contract_name: Optional[str] = None
223
224
  method_only: Optional[str] = None
225
+ rule_id: Optional[str] = None
224
226
  ui_id: Optional[str] = None
225
227
  output_files: List[str] = field(default_factory=list)
226
228
  debug_trace_file: Optional[str] = None
@@ -255,6 +257,7 @@ class CheckResult:
255
257
  return {
256
258
  "rule_name": self.rule_name,
257
259
  "method_name": self.method_name,
260
+ "rule_id": self.rule_id,
258
261
  "contract_name": self.contract_name,
259
262
  "method_only": self.method_only,
260
263
  "assert_message": self.assert_message,
@@ -13,6 +13,7 @@ import argparse
13
13
  import json
14
14
  import os
15
15
  import sys
16
+ from pathlib import Path
16
17
 
17
18
  from .api import ProverOutputAPI
18
19
  from .breadcrumb import format_breadcrumbs_text
@@ -124,6 +125,7 @@ Examples:
124
125
  %(prog)s --job-id 12345 --statsdata
125
126
  %(prog)s --job-id 12345 --full-report
126
127
  %(prog)s --job-id 12345 --full-report --format json
128
+ %(prog)s --job-id 12345 --download-sources ./job_sources
127
129
  %(prog)s --cancel 12345 67890
128
130
 
129
131
  # Group summary
@@ -215,6 +217,17 @@ Environment Variables:
215
217
  "ProverOutputAPI.get_job_report() and intended as the one-shot triage call."
216
218
  ),
217
219
  )
220
+ parser.add_argument(
221
+ "--download-sources",
222
+ nargs="?",
223
+ const=".",
224
+ metavar="DEST_DIR",
225
+ help=(
226
+ "Download the job's source files into DEST_DIR/inputs/.certora_sources/ "
227
+ "(DEST_DIR defaults to the current directory). Files are fetched "
228
+ "individually in parallel; a completed fetch is skipped on rerun."
229
+ ),
230
+ )
218
231
  parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
219
232
  parser.add_argument("--format", choices=["json", "text"], default="text", help="Output format")
220
233
  parser.add_argument(
@@ -477,6 +490,22 @@ Environment Variables:
477
490
  report = api.get_job_report(job_input)
478
491
  result = {"job_id": job_input, "report": report.to_dict()}
479
492
 
493
+ elif args.download_sources:
494
+ # Download the job's source files (remote jobs only)
495
+ dest_dir = Path(args.download_sources)
496
+ api.fetch_job_sources(job_input, dest_dir)
497
+ sources_dir = dest_dir / "inputs" / ".certora_sources"
498
+ files_count = sum(
499
+ 1
500
+ for p in sources_dir.rglob("*")
501
+ if p.is_file() and p.name != ".source_fetch_complete"
502
+ )
503
+ result = {
504
+ "job_id": job_input,
505
+ "sources_dir": str(sources_dir),
506
+ "files_count": files_count,
507
+ }
508
+
480
509
  else:
481
510
  # Get violated rules (default behavior)
482
511
  violations = api.get_violated_rules(job_input)
@@ -516,6 +545,11 @@ Environment Variables:
516
545
  print(f"Found {count} alerts. Results written to {args.output}")
517
546
  elif args.full_report:
518
547
  print(f"✅ Full report retrieved. Results written to {args.output}")
548
+ elif args.download_sources:
549
+ print(
550
+ f"Downloaded {result['files_count']} source files to {result['sources_dir']}. "
551
+ f"Results written to {args.output}"
552
+ )
519
553
  else:
520
554
  count = result.get("assert_nodes_count", 0)
521
555
  print(f"Found {count} violations. Results written to {args.output}")
@@ -616,6 +650,10 @@ Environment Variables:
616
650
  else:
617
651
  print("\n✅ No alerts found!")
618
652
 
653
+ elif args.download_sources:
654
+ print(f"Job ID: {job_input}")
655
+ print(f"✅ Downloaded {result['files_count']} source files to {result['sources_dir']}")
656
+
619
657
  elif args.full_report:
620
658
  # Summary view of the full report. Full content via --format json.
621
659
  report = result["report"]