serenecode 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.
- serenecode/__init__.py +281 -0
- serenecode/adapters/__init__.py +6 -0
- serenecode/adapters/coverage_adapter.py +1173 -0
- serenecode/adapters/crosshair_adapter.py +1069 -0
- serenecode/adapters/hypothesis_adapter.py +1824 -0
- serenecode/adapters/local_fs.py +169 -0
- serenecode/adapters/module_loader.py +492 -0
- serenecode/adapters/mypy_adapter.py +161 -0
- serenecode/checker/__init__.py +6 -0
- serenecode/checker/compositional.py +2216 -0
- serenecode/checker/coverage.py +186 -0
- serenecode/checker/properties.py +154 -0
- serenecode/checker/structural.py +1504 -0
- serenecode/checker/symbolic.py +178 -0
- serenecode/checker/types.py +148 -0
- serenecode/cli.py +478 -0
- serenecode/config.py +711 -0
- serenecode/contracts/__init__.py +6 -0
- serenecode/contracts/predicates.py +176 -0
- serenecode/core/__init__.py +6 -0
- serenecode/core/exceptions.py +38 -0
- serenecode/core/pipeline.py +807 -0
- serenecode/init.py +307 -0
- serenecode/models.py +308 -0
- serenecode/ports/__init__.py +6 -0
- serenecode/ports/coverage_analyzer.py +124 -0
- serenecode/ports/file_system.py +95 -0
- serenecode/ports/property_tester.py +69 -0
- serenecode/ports/symbolic_checker.py +70 -0
- serenecode/ports/type_checker.py +66 -0
- serenecode/reporter.py +346 -0
- serenecode/source_discovery.py +319 -0
- serenecode/templates/__init__.py +5 -0
- serenecode/templates/content.py +337 -0
- serenecode-0.1.0.dist-info/METADATA +298 -0
- serenecode-0.1.0.dist-info/RECORD +39 -0
- serenecode-0.1.0.dist-info/WHEEL +4 -0
- serenecode-0.1.0.dist-info/entry_points.txt +2 -0
- serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
"""CrossHair adapter for symbolic verification (Level 4).
|
|
2
|
+
|
|
3
|
+
This adapter implements the SymbolicChecker protocol by running
|
|
4
|
+
CrossHair's symbolic execution engine on Python modules.
|
|
5
|
+
It uses the CrossHair Python API when available, with a CLI
|
|
6
|
+
subprocess fallback.
|
|
7
|
+
|
|
8
|
+
This is an adapter module — it handles I/O (module importing,
|
|
9
|
+
subprocess execution) and is exempt from full contract requirements.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import collections.abc
|
|
15
|
+
import inspect
|
|
16
|
+
import multiprocessing
|
|
17
|
+
import multiprocessing.queues
|
|
18
|
+
import queue
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import types
|
|
24
|
+
import typing
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import icontract
|
|
29
|
+
|
|
30
|
+
from serenecode.adapters.module_loader import load_python_module
|
|
31
|
+
from serenecode.contracts.predicates import is_non_empty_string, is_positive_int
|
|
32
|
+
from serenecode.core.exceptions import ToolNotInstalledError, UnsafeCodeExecutionError
|
|
33
|
+
from serenecode.ports.symbolic_checker import SymbolicFinding
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from crosshair.core_and_libs import analyze_module
|
|
37
|
+
from crosshair.options import AnalysisKind, AnalysisOptionSet
|
|
38
|
+
from crosshair.statespace import AnalysisMessage, MessageType
|
|
39
|
+
_CROSSHAIR_API_AVAILABLE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
_CROSSHAIR_API_AVAILABLE = False
|
|
42
|
+
|
|
43
|
+
_CROSSHAIR_CLI_AVAILABLE: bool | None = None
|
|
44
|
+
_TRUST_REQUIRED_MESSAGE = (
|
|
45
|
+
"Level 4 symbolic verification imports and executes project modules. "
|
|
46
|
+
"Re-run with allow_code_execution=True only for trusted code."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
51
|
+
def _check_crosshair_cli() -> bool:
|
|
52
|
+
"""Check if CrossHair CLI is available."""
|
|
53
|
+
global _CROSSHAIR_CLI_AVAILABLE
|
|
54
|
+
if _CROSSHAIR_CLI_AVAILABLE is not None:
|
|
55
|
+
return _CROSSHAIR_CLI_AVAILABLE
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
[sys.executable, "-m", "crosshair", "--help"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
timeout=10,
|
|
61
|
+
)
|
|
62
|
+
_CROSSHAIR_CLI_AVAILABLE = result.returncode == 0
|
|
63
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
64
|
+
_CROSSHAIR_CLI_AVAILABLE = False
|
|
65
|
+
return _CROSSHAIR_CLI_AVAILABLE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@icontract.require(
|
|
69
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
70
|
+
"module_path must be a non-empty string",
|
|
71
|
+
)
|
|
72
|
+
@icontract.require(
|
|
73
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
74
|
+
"search_paths must be a tuple",
|
|
75
|
+
)
|
|
76
|
+
@icontract.ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "result must be a 2-tuple")
|
|
77
|
+
def _discover_cli_targets(
|
|
78
|
+
module_path: str,
|
|
79
|
+
search_paths: tuple[str, ...] = (),
|
|
80
|
+
) -> tuple[list[tuple[str, str]], list[str]]:
|
|
81
|
+
"""Discover contracted top-level functions to verify with CrossHair CLI.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A tuple of (verifiable_targets, excluded_function_names).
|
|
85
|
+
"""
|
|
86
|
+
module = load_python_module(module_path, search_paths)
|
|
87
|
+
targets: list[tuple[str, str]] = []
|
|
88
|
+
excluded: list[str] = []
|
|
89
|
+
|
|
90
|
+
# Loop invariant: targets + excluded accounts for all contracted top-level functions seen so far
|
|
91
|
+
for name in sorted(dir(module)):
|
|
92
|
+
if name.startswith("_"):
|
|
93
|
+
continue
|
|
94
|
+
obj = getattr(module, name)
|
|
95
|
+
if (
|
|
96
|
+
inspect.isfunction(obj)
|
|
97
|
+
and getattr(obj, "__module__", None) == module.__name__
|
|
98
|
+
and _has_icontract_contracts(obj)
|
|
99
|
+
):
|
|
100
|
+
if _is_symbolic_friendly_target(obj):
|
|
101
|
+
targets.append((_cli_target_reference(module_path, name, obj), name))
|
|
102
|
+
else:
|
|
103
|
+
excluded.append(name)
|
|
104
|
+
|
|
105
|
+
return (targets, excluded)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@icontract.require(
|
|
109
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
110
|
+
"module_path must be a non-empty string",
|
|
111
|
+
)
|
|
112
|
+
@icontract.require(
|
|
113
|
+
lambda function_name: is_non_empty_string(function_name),
|
|
114
|
+
"function_name must be a non-empty string",
|
|
115
|
+
)
|
|
116
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
117
|
+
@icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
|
|
118
|
+
def _cli_target_reference(
|
|
119
|
+
module_path: str,
|
|
120
|
+
function_name: str,
|
|
121
|
+
func: Any,
|
|
122
|
+
) -> str:
|
|
123
|
+
"""Build a CrossHair CLI target for a module/function pair."""
|
|
124
|
+
path = Path(module_path)
|
|
125
|
+
if path.is_absolute() and path.suffix == ".py":
|
|
126
|
+
line_number: int | None
|
|
127
|
+
try:
|
|
128
|
+
line_number = inspect.getsourcelines(inspect.unwrap(func))[1]
|
|
129
|
+
except (OSError, TypeError):
|
|
130
|
+
line_number = getattr(getattr(inspect.unwrap(func), "__code__", None), "co_firstlineno", None)
|
|
131
|
+
if isinstance(line_number, int) and line_number >= 1:
|
|
132
|
+
return f"{module_path}:{line_number}"
|
|
133
|
+
return f"{module_path}.{function_name}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
137
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
138
|
+
def _has_icontract_contracts(func: Any) -> bool:
|
|
139
|
+
"""Check whether a function exposes icontract pre/postconditions."""
|
|
140
|
+
return hasattr(func, "__preconditions__") or hasattr(func, "__postconditions__")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
144
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
145
|
+
def _is_symbolic_friendly_target(func: Any) -> bool:
|
|
146
|
+
"""Check if a function signature is tractable for direct CrossHair CLI checks."""
|
|
147
|
+
module_name = getattr(func, "__module__", "")
|
|
148
|
+
if module_name in {"serenecode", "serenecode.init", "serenecode.config"}:
|
|
149
|
+
return False
|
|
150
|
+
if module_name == "serenecode.contracts.predicates":
|
|
151
|
+
return False
|
|
152
|
+
if module_name.startswith("serenecode.adapters"):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
resolved_hints = typing.get_type_hints(func)
|
|
157
|
+
signature = inspect.signature(func)
|
|
158
|
+
except Exception:
|
|
159
|
+
resolved_hints = {}
|
|
160
|
+
signature = inspect.signature(func)
|
|
161
|
+
|
|
162
|
+
# Loop invariant: every parameter seen so far is either primitive-like or has
|
|
163
|
+
# caused the function to be rejected as too object-heavy for CLI verification.
|
|
164
|
+
for name, parameter in signature.parameters.items():
|
|
165
|
+
if name in ("self", "cls"):
|
|
166
|
+
continue
|
|
167
|
+
annotation = resolved_hints.get(name, parameter.annotation)
|
|
168
|
+
if not _is_symbolic_friendly_annotation(annotation):
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@icontract.require(
|
|
175
|
+
lambda annotation: annotation is inspect.Parameter.empty or isinstance(annotation, object),
|
|
176
|
+
"annotation must be a Python annotation object",
|
|
177
|
+
)
|
|
178
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
179
|
+
def _is_symbolic_friendly_annotation(annotation: Any) -> bool:
|
|
180
|
+
"""Check whether an annotation is simple enough for direct CLI verification."""
|
|
181
|
+
# Variant: the remaining annotation nesting decreases on each recursive call into args.
|
|
182
|
+
if annotation is inspect.Parameter.empty or annotation is typing.Any:
|
|
183
|
+
return True
|
|
184
|
+
if annotation in {bool, int, float, str, bytes, object, type(None)}:
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
origin = typing.get_origin(annotation)
|
|
188
|
+
args = typing.get_args(annotation)
|
|
189
|
+
|
|
190
|
+
if origin is typing.Literal:
|
|
191
|
+
return True
|
|
192
|
+
if origin in (typing.Union, types.UnionType):
|
|
193
|
+
return all(_is_symbolic_friendly_annotation(arg) for arg in args)
|
|
194
|
+
if origin in (list, set, frozenset):
|
|
195
|
+
return len(args) == 1 and _is_symbolic_friendly_annotation(args[0])
|
|
196
|
+
if origin is tuple:
|
|
197
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
198
|
+
return _is_symbolic_friendly_annotation(args[0])
|
|
199
|
+
return all(arg is not Ellipsis and _is_symbolic_friendly_annotation(arg) for arg in args)
|
|
200
|
+
if origin is dict:
|
|
201
|
+
return len(args) == 2 and all(_is_symbolic_friendly_annotation(arg) for arg in args)
|
|
202
|
+
if origin in (typing.Callable, collections.abc.Callable):
|
|
203
|
+
return False
|
|
204
|
+
if inspect.isclass(annotation):
|
|
205
|
+
module_name = getattr(annotation, "__module__", "")
|
|
206
|
+
return module_name in {"builtins", "typing", "types", "collections.abc"}
|
|
207
|
+
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@icontract.invariant(
|
|
212
|
+
lambda self: is_positive_int(self._per_condition_timeout)
|
|
213
|
+
and is_positive_int(self._per_path_timeout)
|
|
214
|
+
and is_positive_int(self._module_timeout),
|
|
215
|
+
"timeouts must remain positive",
|
|
216
|
+
)
|
|
217
|
+
class CrossHairSymbolicChecker:
|
|
218
|
+
"""Symbolic checker implementation using CrossHair.
|
|
219
|
+
|
|
220
|
+
Performs symbolic execution using CrossHair/Z3 to verify
|
|
221
|
+
icontract postconditions hold for all valid inputs.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
@icontract.require(
|
|
225
|
+
lambda per_condition_timeout: is_positive_int(per_condition_timeout),
|
|
226
|
+
"per_condition_timeout must be positive",
|
|
227
|
+
)
|
|
228
|
+
@icontract.require(
|
|
229
|
+
lambda per_path_timeout: is_positive_int(per_path_timeout),
|
|
230
|
+
"per_path_timeout must be positive",
|
|
231
|
+
)
|
|
232
|
+
@icontract.require(
|
|
233
|
+
lambda module_timeout: is_positive_int(module_timeout),
|
|
234
|
+
"module_timeout must be positive",
|
|
235
|
+
)
|
|
236
|
+
@icontract.ensure(lambda result: result is None, "initialization returns None")
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
per_condition_timeout: int = 30,
|
|
240
|
+
per_path_timeout: int = 10,
|
|
241
|
+
module_timeout: int = 300,
|
|
242
|
+
allow_code_execution: bool = False,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Initialize the checker.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
per_condition_timeout: Default seconds per condition.
|
|
248
|
+
per_path_timeout: Default seconds per execution path.
|
|
249
|
+
module_timeout: Hard timeout in seconds for verifying an entire module.
|
|
250
|
+
"""
|
|
251
|
+
self._per_condition_timeout = per_condition_timeout
|
|
252
|
+
self._per_path_timeout = per_path_timeout
|
|
253
|
+
self._module_timeout = module_timeout
|
|
254
|
+
self._allow_code_execution = allow_code_execution
|
|
255
|
+
|
|
256
|
+
@icontract.require(
|
|
257
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
258
|
+
"module_path must be a non-empty string",
|
|
259
|
+
)
|
|
260
|
+
@icontract.require(
|
|
261
|
+
lambda per_condition_timeout: per_condition_timeout is None or is_positive_int(per_condition_timeout),
|
|
262
|
+
"per_condition_timeout must be positive when provided",
|
|
263
|
+
)
|
|
264
|
+
@icontract.require(
|
|
265
|
+
lambda per_path_timeout: per_path_timeout is None or is_positive_int(per_path_timeout),
|
|
266
|
+
"per_path_timeout must be positive when provided",
|
|
267
|
+
)
|
|
268
|
+
@icontract.require(
|
|
269
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
270
|
+
"search_paths must be a tuple",
|
|
271
|
+
)
|
|
272
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
273
|
+
def verify_module(
|
|
274
|
+
self,
|
|
275
|
+
module_path: str,
|
|
276
|
+
per_condition_timeout: int | None = None,
|
|
277
|
+
per_path_timeout: int | None = None,
|
|
278
|
+
search_paths: tuple[str, ...] = (),
|
|
279
|
+
) -> list[SymbolicFinding]:
|
|
280
|
+
"""Run symbolic verification on all contracted functions in a module.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
module_path: Importable Python module path to verify.
|
|
284
|
+
per_condition_timeout: Max seconds per postcondition.
|
|
285
|
+
per_path_timeout: Max seconds per execution path.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of symbolic findings.
|
|
289
|
+
"""
|
|
290
|
+
if not self._allow_code_execution:
|
|
291
|
+
raise UnsafeCodeExecutionError(_TRUST_REQUIRED_MESSAGE)
|
|
292
|
+
|
|
293
|
+
effective_condition_timeout = (
|
|
294
|
+
self._per_condition_timeout
|
|
295
|
+
if per_condition_timeout is None
|
|
296
|
+
else per_condition_timeout
|
|
297
|
+
)
|
|
298
|
+
effective_path_timeout = (
|
|
299
|
+
self._per_path_timeout
|
|
300
|
+
if per_path_timeout is None
|
|
301
|
+
else per_path_timeout
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Prefer the CLI backend because it has been more stable on real-world
|
|
305
|
+
# modules than CrossHair's in-process Python API.
|
|
306
|
+
if _check_crosshair_cli():
|
|
307
|
+
return self._verify_via_cli(
|
|
308
|
+
module_path,
|
|
309
|
+
effective_condition_timeout,
|
|
310
|
+
effective_path_timeout,
|
|
311
|
+
search_paths,
|
|
312
|
+
)
|
|
313
|
+
elif _CROSSHAIR_API_AVAILABLE:
|
|
314
|
+
return self._verify_via_api(
|
|
315
|
+
module_path,
|
|
316
|
+
effective_condition_timeout,
|
|
317
|
+
effective_path_timeout,
|
|
318
|
+
search_paths,
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
raise ToolNotInstalledError(
|
|
322
|
+
"CrossHair is not installed. Install with: pip install crosshair-tool"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
@icontract.require(
|
|
326
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
327
|
+
"module_path must be a non-empty string",
|
|
328
|
+
)
|
|
329
|
+
@icontract.require(
|
|
330
|
+
lambda per_condition_timeout: is_positive_int(per_condition_timeout),
|
|
331
|
+
"per_condition_timeout must be positive",
|
|
332
|
+
)
|
|
333
|
+
@icontract.require(
|
|
334
|
+
lambda per_path_timeout: is_positive_int(per_path_timeout),
|
|
335
|
+
"per_path_timeout must be positive",
|
|
336
|
+
)
|
|
337
|
+
@icontract.require(
|
|
338
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
339
|
+
"search_paths must be a tuple",
|
|
340
|
+
)
|
|
341
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
342
|
+
def _verify_via_api(
|
|
343
|
+
self,
|
|
344
|
+
module_path: str,
|
|
345
|
+
per_condition_timeout: int,
|
|
346
|
+
per_path_timeout: int,
|
|
347
|
+
search_paths: tuple[str, ...] = (),
|
|
348
|
+
) -> list[SymbolicFinding]:
|
|
349
|
+
"""Verify using CrossHair's Python API in an isolated process.
|
|
350
|
+
|
|
351
|
+
Runs verification in a child process so it can be hard-killed
|
|
352
|
+
if Z3 gets stuck in native C code (signal.SIGALRM cannot
|
|
353
|
+
interrupt C extensions).
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
module_path: Module to verify.
|
|
357
|
+
per_condition_timeout: Timeout per condition.
|
|
358
|
+
per_path_timeout: Timeout per path.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
List of symbolic findings.
|
|
362
|
+
"""
|
|
363
|
+
# Use "spawn" instead of "fork" — fork can deadlock on macOS
|
|
364
|
+
# when system frameworks hold locks at fork time.
|
|
365
|
+
ctx = multiprocessing.get_context("spawn")
|
|
366
|
+
result_queue: multiprocessing.Queue[list[SymbolicFinding]] = ctx.Queue()
|
|
367
|
+
worker = getattr(sys.modules[__name__], "_api_verification_worker")
|
|
368
|
+
|
|
369
|
+
process = ctx.Process(
|
|
370
|
+
target=worker,
|
|
371
|
+
args=(
|
|
372
|
+
module_path,
|
|
373
|
+
per_condition_timeout,
|
|
374
|
+
per_path_timeout,
|
|
375
|
+
search_paths,
|
|
376
|
+
result_queue,
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
process.start()
|
|
380
|
+
|
|
381
|
+
# Read from the queue BEFORE joining the process to avoid a
|
|
382
|
+
# pipe-buffer deadlock: if the child's put() fills the pipe,
|
|
383
|
+
# it blocks until the parent drains it — but join() waits for
|
|
384
|
+
# the child to exit first, creating a circular wait.
|
|
385
|
+
result: list[SymbolicFinding] | None = None
|
|
386
|
+
try:
|
|
387
|
+
result = result_queue.get(timeout=self._module_timeout)
|
|
388
|
+
except (queue.Empty, OSError):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
# Now clean up the child process.
|
|
392
|
+
if process.is_alive():
|
|
393
|
+
process.terminate()
|
|
394
|
+
process.join(timeout=5)
|
|
395
|
+
if process.is_alive():
|
|
396
|
+
process.kill()
|
|
397
|
+
process.join()
|
|
398
|
+
else:
|
|
399
|
+
process.join(timeout=5)
|
|
400
|
+
|
|
401
|
+
if result is not None:
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
if process.exitcode is None or process.exitcode != 0:
|
|
405
|
+
timed_out = process.exitcode is None or process.exitcode < 0
|
|
406
|
+
message = (
|
|
407
|
+
f"Module verification timed out after {self._module_timeout}s"
|
|
408
|
+
if timed_out
|
|
409
|
+
else f"Verification process exited with code {process.exitcode}"
|
|
410
|
+
)
|
|
411
|
+
return [SymbolicFinding(
|
|
412
|
+
function_name="<module>",
|
|
413
|
+
module_path=module_path,
|
|
414
|
+
outcome="error" if not timed_out else "timeout",
|
|
415
|
+
message=message,
|
|
416
|
+
duration_seconds=float(self._module_timeout),
|
|
417
|
+
)]
|
|
418
|
+
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
@icontract.require(
|
|
422
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
423
|
+
"module_path must be a non-empty string",
|
|
424
|
+
)
|
|
425
|
+
@icontract.require(
|
|
426
|
+
lambda per_condition_timeout: is_positive_int(per_condition_timeout),
|
|
427
|
+
"per_condition_timeout must be positive",
|
|
428
|
+
)
|
|
429
|
+
@icontract.require(
|
|
430
|
+
lambda per_path_timeout: is_positive_int(per_path_timeout),
|
|
431
|
+
"per_path_timeout must be positive",
|
|
432
|
+
)
|
|
433
|
+
@icontract.require(
|
|
434
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
435
|
+
"search_paths must be a tuple",
|
|
436
|
+
)
|
|
437
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
438
|
+
def _verify_via_cli(
|
|
439
|
+
self,
|
|
440
|
+
module_path: str,
|
|
441
|
+
per_condition_timeout: int,
|
|
442
|
+
per_path_timeout: int,
|
|
443
|
+
search_paths: tuple[str, ...] = (),
|
|
444
|
+
) -> list[SymbolicFinding]:
|
|
445
|
+
"""Verify using CrossHair CLI as a subprocess fallback.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
module_path: Module to verify.
|
|
449
|
+
per_condition_timeout: Timeout per condition.
|
|
450
|
+
per_path_timeout: Timeout per execution path.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
List of symbolic findings.
|
|
454
|
+
"""
|
|
455
|
+
targets, excluded = _discover_cli_targets(module_path, search_paths)
|
|
456
|
+
|
|
457
|
+
findings: list[SymbolicFinding] = []
|
|
458
|
+
|
|
459
|
+
# Report excluded functions so they are visible in the output
|
|
460
|
+
# Loop invariant: findings contains exclusion records for excluded[0..i]
|
|
461
|
+
for excluded_name in excluded:
|
|
462
|
+
findings.append(SymbolicFinding(
|
|
463
|
+
function_name=excluded_name,
|
|
464
|
+
module_path=module_path,
|
|
465
|
+
outcome="unsupported",
|
|
466
|
+
message=f"Function '{excluded_name}' excluded from symbolic verification (non-primitive parameters or adapter code)",
|
|
467
|
+
))
|
|
468
|
+
|
|
469
|
+
if not targets:
|
|
470
|
+
return findings
|
|
471
|
+
|
|
472
|
+
deadline = time.monotonic() + self._module_timeout
|
|
473
|
+
base_timeout = max(per_condition_timeout * 4, per_path_timeout * 8)
|
|
474
|
+
|
|
475
|
+
# Loop invariant: findings contains results for targets[0..i]
|
|
476
|
+
for target, function_name in targets:
|
|
477
|
+
remaining = deadline - time.monotonic()
|
|
478
|
+
if remaining <= 0:
|
|
479
|
+
findings.append(_make_module_timeout_finding(
|
|
480
|
+
module_path,
|
|
481
|
+
self._module_timeout,
|
|
482
|
+
))
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
timeout_seconds = min(base_timeout, remaining)
|
|
486
|
+
try:
|
|
487
|
+
env = _subprocess_env(search_paths)
|
|
488
|
+
result = subprocess.run(
|
|
489
|
+
[
|
|
490
|
+
sys.executable, "-m", "crosshair", "check",
|
|
491
|
+
target,
|
|
492
|
+
"--analysis_kind=icontract",
|
|
493
|
+
f"--per_condition_timeout={per_condition_timeout}",
|
|
494
|
+
f"--per_path_timeout={per_path_timeout}",
|
|
495
|
+
],
|
|
496
|
+
capture_output=True,
|
|
497
|
+
text=True,
|
|
498
|
+
timeout=timeout_seconds,
|
|
499
|
+
env=env,
|
|
500
|
+
)
|
|
501
|
+
except subprocess.TimeoutExpired:
|
|
502
|
+
if timeout_seconds < base_timeout:
|
|
503
|
+
findings.append(_make_module_timeout_finding(
|
|
504
|
+
module_path,
|
|
505
|
+
self._module_timeout,
|
|
506
|
+
))
|
|
507
|
+
break
|
|
508
|
+
findings.append(SymbolicFinding(
|
|
509
|
+
function_name=function_name,
|
|
510
|
+
module_path=module_path,
|
|
511
|
+
outcome="timeout",
|
|
512
|
+
message=f"CrossHair verification timed out for function '{function_name}'",
|
|
513
|
+
))
|
|
514
|
+
continue
|
|
515
|
+
except FileNotFoundError:
|
|
516
|
+
raise ToolNotInstalledError(
|
|
517
|
+
"CrossHair CLI not found. Install with: pip install crosshair-tool"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
findings.extend(_parse_cli_output(
|
|
521
|
+
module_path,
|
|
522
|
+
result.stdout,
|
|
523
|
+
result.stderr,
|
|
524
|
+
function_name=function_name,
|
|
525
|
+
))
|
|
526
|
+
|
|
527
|
+
return findings
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@icontract.require(
|
|
531
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
532
|
+
"module_path must be a non-empty string",
|
|
533
|
+
)
|
|
534
|
+
@icontract.require(
|
|
535
|
+
lambda per_condition_timeout: is_positive_int(per_condition_timeout),
|
|
536
|
+
"per_condition_timeout must be positive",
|
|
537
|
+
)
|
|
538
|
+
@icontract.require(
|
|
539
|
+
lambda per_path_timeout: is_positive_int(per_path_timeout),
|
|
540
|
+
"per_path_timeout must be positive",
|
|
541
|
+
)
|
|
542
|
+
@icontract.require(
|
|
543
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
544
|
+
"search_paths must be a tuple",
|
|
545
|
+
)
|
|
546
|
+
@icontract.require(
|
|
547
|
+
lambda result_queue: result_queue is not None,
|
|
548
|
+
"result_queue must be provided",
|
|
549
|
+
)
|
|
550
|
+
@icontract.ensure(lambda result: result is None, "worker returns None")
|
|
551
|
+
def _api_verification_worker(
|
|
552
|
+
module_path: str,
|
|
553
|
+
per_condition_timeout: int,
|
|
554
|
+
per_path_timeout: int,
|
|
555
|
+
search_paths: tuple[str, ...],
|
|
556
|
+
result_queue: multiprocessing.Queue, # type: ignore[type-arg]
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Run CrossHair API verification in a child process.
|
|
559
|
+
|
|
560
|
+
This is the target function for multiprocessing.Process. Running
|
|
561
|
+
in a separate process allows hard-killing via SIGTERM/SIGKILL when
|
|
562
|
+
Z3 gets stuck in native C code that signal.SIGALRM cannot interrupt.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
module_path: Importable module path.
|
|
566
|
+
per_condition_timeout: Seconds per condition.
|
|
567
|
+
per_path_timeout: Seconds per execution path.
|
|
568
|
+
result_queue: Queue to put findings into.
|
|
569
|
+
"""
|
|
570
|
+
try:
|
|
571
|
+
module = load_python_module(module_path, search_paths)
|
|
572
|
+
except ImportError as exc:
|
|
573
|
+
result_queue.put([SymbolicFinding(
|
|
574
|
+
function_name="<module>",
|
|
575
|
+
module_path=module_path,
|
|
576
|
+
outcome="error",
|
|
577
|
+
message=f"Cannot import module '{module_path}': {exc}",
|
|
578
|
+
)])
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
options = AnalysisOptionSet(
|
|
582
|
+
analysis_kind=[AnalysisKind.icontract],
|
|
583
|
+
per_condition_timeout=float(per_condition_timeout),
|
|
584
|
+
per_path_timeout=float(per_path_timeout),
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
findings: list[SymbolicFinding] = []
|
|
588
|
+
start = time.monotonic()
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
all_checkables = list(analyze_module(module, options))
|
|
592
|
+
|
|
593
|
+
# Filter out auto-generated dataclass dunder methods — Python
|
|
594
|
+
# guarantees their correctness, no need for symbolic verification.
|
|
595
|
+
checkables = []
|
|
596
|
+
# Loop invariant: checkables contains non-dunder items from all_checkables[0..i]
|
|
597
|
+
for checkable in all_checkables:
|
|
598
|
+
if _is_autogenerated_dunder(checkable):
|
|
599
|
+
func_name = _extract_func_name_from_checkable(checkable)
|
|
600
|
+
findings.append(SymbolicFinding(
|
|
601
|
+
function_name=func_name,
|
|
602
|
+
module_path=module_path,
|
|
603
|
+
outcome="verified",
|
|
604
|
+
message=f"Auto-generated dataclass method '{func_name}' — correct by construction",
|
|
605
|
+
duration_seconds=0.0,
|
|
606
|
+
))
|
|
607
|
+
else:
|
|
608
|
+
checkables.append(checkable)
|
|
609
|
+
|
|
610
|
+
if not checkables:
|
|
611
|
+
result_queue.put(findings)
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
# Loop invariant: findings contains results for all processed checkables
|
|
615
|
+
for checkable in checkables:
|
|
616
|
+
check_start = time.monotonic()
|
|
617
|
+
try:
|
|
618
|
+
messages = list(checkable.analyze())
|
|
619
|
+
check_elapsed = time.monotonic() - check_start
|
|
620
|
+
func_name = _extract_func_name_from_checkable(checkable)
|
|
621
|
+
|
|
622
|
+
if not messages:
|
|
623
|
+
findings.append(SymbolicFinding(
|
|
624
|
+
function_name=func_name,
|
|
625
|
+
module_path=module_path,
|
|
626
|
+
outcome="verified",
|
|
627
|
+
message=f"Verified: postcondition holds for '{func_name}'",
|
|
628
|
+
duration_seconds=check_elapsed,
|
|
629
|
+
))
|
|
630
|
+
else:
|
|
631
|
+
# Loop invariant: findings contains results for messages[0..j]
|
|
632
|
+
for msg in messages:
|
|
633
|
+
findings.append(_message_to_finding(
|
|
634
|
+
func_name, module_path, msg, check_elapsed,
|
|
635
|
+
))
|
|
636
|
+
except Exception as exc:
|
|
637
|
+
check_elapsed = time.monotonic() - check_start
|
|
638
|
+
func_name = _extract_func_name_from_checkable(checkable)
|
|
639
|
+
outcome = _classify_exception(exc)
|
|
640
|
+
findings.append(SymbolicFinding(
|
|
641
|
+
function_name=func_name,
|
|
642
|
+
module_path=module_path,
|
|
643
|
+
outcome=outcome,
|
|
644
|
+
message=f"CrossHair cannot verify '{func_name}': {exc}",
|
|
645
|
+
duration_seconds=check_elapsed,
|
|
646
|
+
))
|
|
647
|
+
|
|
648
|
+
except Exception as exc:
|
|
649
|
+
elapsed = time.monotonic() - start
|
|
650
|
+
outcome = _classify_exception(exc)
|
|
651
|
+
findings.append(SymbolicFinding(
|
|
652
|
+
function_name="<module>",
|
|
653
|
+
module_path=module_path,
|
|
654
|
+
outcome=outcome,
|
|
655
|
+
message=f"CrossHair cannot verify module: {exc}",
|
|
656
|
+
duration_seconds=elapsed,
|
|
657
|
+
))
|
|
658
|
+
|
|
659
|
+
result_queue.put(findings)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# Exception types and message patterns that indicate solver limitations,
|
|
663
|
+
# not actual bugs in the code under verification.
|
|
664
|
+
_SOLVER_LIMITATION_TYPES = (RecursionError,)
|
|
665
|
+
_SOLVER_LIMITATION_PATTERNS = (
|
|
666
|
+
"unhashable type",
|
|
667
|
+
"symbolicbool",
|
|
668
|
+
"notdeterministic",
|
|
669
|
+
"not deterministic",
|
|
670
|
+
"non-string object",
|
|
671
|
+
"returned a non-",
|
|
672
|
+
"must be a string, bytes or ast",
|
|
673
|
+
"must be a string or",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@icontract.require(lambda exc: isinstance(exc, Exception), "exc must be an Exception")
|
|
678
|
+
@icontract.ensure(
|
|
679
|
+
lambda result: result in {"unsupported", "error"},
|
|
680
|
+
"result must be a recognized symbolic outcome",
|
|
681
|
+
)
|
|
682
|
+
def _classify_exception(exc: Exception) -> str:
|
|
683
|
+
"""Classify an exception as a solver limitation or a real error.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
exc: The exception raised during verification.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
"unsupported" for solver limitations, "error" for real errors.
|
|
690
|
+
"""
|
|
691
|
+
if isinstance(exc, _SOLVER_LIMITATION_TYPES):
|
|
692
|
+
return "unsupported"
|
|
693
|
+
error_str = str(exc).lower()
|
|
694
|
+
# Loop invariant: checked patterns[0..i]
|
|
695
|
+
for pattern in _SOLVER_LIMITATION_PATTERNS:
|
|
696
|
+
if pattern in error_str:
|
|
697
|
+
return "unsupported"
|
|
698
|
+
if "unsupported" in error_str or "not implemented" in error_str:
|
|
699
|
+
return "unsupported"
|
|
700
|
+
return "error"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
_AUTOGEN_DUNDER_NAMES = frozenset({
|
|
704
|
+
"__hash__", "__eq__", "__ne__", "__repr__", "__str__",
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@icontract.require(lambda checkable: checkable is not None, "checkable must be provided")
|
|
709
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
710
|
+
def _is_autogenerated_dunder(checkable: Any) -> bool:
|
|
711
|
+
"""Check if a checkable is an auto-generated dataclass dunder method.
|
|
712
|
+
|
|
713
|
+
Python's dataclass machinery generates __eq__, __hash__, __repr__,
|
|
714
|
+
etc. These are guaranteed correct and don't need symbolic verification.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
checkable: A CrossHair Checkable.
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
True if this is an auto-generated dataclass dunder method.
|
|
721
|
+
"""
|
|
722
|
+
name = _extract_func_name_from_checkable(checkable)
|
|
723
|
+
if name not in _AUTOGEN_DUNDER_NAMES:
|
|
724
|
+
return False
|
|
725
|
+
|
|
726
|
+
# Check if the function belongs to a dataclass by inspecting
|
|
727
|
+
# the checkable's function object for a dataclass owner.
|
|
728
|
+
inner = checkable
|
|
729
|
+
# Loop invariant: inner is the most deeply unwrapped checkable found so far
|
|
730
|
+
for _depth in range(5):
|
|
731
|
+
wrapped = (
|
|
732
|
+
getattr(inner, "_checkable", None)
|
|
733
|
+
or getattr(inner, "inner", None)
|
|
734
|
+
or getattr(inner, "_inner", None)
|
|
735
|
+
)
|
|
736
|
+
if wrapped is None:
|
|
737
|
+
break
|
|
738
|
+
inner = wrapped
|
|
739
|
+
|
|
740
|
+
# Try to find the owning class via the function's __qualname__
|
|
741
|
+
ctxfn = getattr(inner, "ctxfn", None)
|
|
742
|
+
if ctxfn is not None:
|
|
743
|
+
fn = getattr(ctxfn, "fn", None)
|
|
744
|
+
if fn is not None:
|
|
745
|
+
qualname = getattr(fn, "__qualname__", "")
|
|
746
|
+
if "." in qualname:
|
|
747
|
+
# e.g. "SourceFile.__hash__" — get the class
|
|
748
|
+
cls_name = qualname.rsplit(".", 1)[0]
|
|
749
|
+
module = getattr(fn, "__module__", None)
|
|
750
|
+
if module:
|
|
751
|
+
import sys
|
|
752
|
+
mod = sys.modules.get(module)
|
|
753
|
+
if mod:
|
|
754
|
+
cls = getattr(mod, cls_name, None)
|
|
755
|
+
if cls and hasattr(cls, "__dataclass_fields__"):
|
|
756
|
+
return True
|
|
757
|
+
|
|
758
|
+
# If we can't determine the class, don't skip — verify it to be safe.
|
|
759
|
+
return False
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@icontract.require(lambda checkable: checkable is not None, "checkable must be provided")
|
|
763
|
+
@icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
|
|
764
|
+
def _extract_func_name_from_checkable(checkable: Any) -> str:
|
|
765
|
+
"""Extract function name from a CrossHair Checkable object.
|
|
766
|
+
|
|
767
|
+
Handles wrapper types like ClampedCheckable by unwrapping
|
|
768
|
+
to find the inner ConditionCheckable with the actual name.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
checkable: A CrossHair Checkable.
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
The function name string.
|
|
775
|
+
"""
|
|
776
|
+
# Unwrap wrapper checkables (ClampedCheckable wraps ConditionCheckable)
|
|
777
|
+
inner = checkable
|
|
778
|
+
# Loop invariant: inner is the most deeply unwrapped checkable found so far
|
|
779
|
+
for _depth in range(5):
|
|
780
|
+
wrapped = (
|
|
781
|
+
getattr(inner, "_checkable", None)
|
|
782
|
+
or getattr(inner, "inner", None)
|
|
783
|
+
or getattr(inner, "_inner", None)
|
|
784
|
+
)
|
|
785
|
+
if wrapped is None:
|
|
786
|
+
break
|
|
787
|
+
inner = wrapped
|
|
788
|
+
|
|
789
|
+
# CrossHair ConditionCheckable has ctxfn.name
|
|
790
|
+
ctxfn = getattr(inner, "ctxfn", None)
|
|
791
|
+
if ctxfn is not None:
|
|
792
|
+
name = getattr(ctxfn, "name", None)
|
|
793
|
+
if isinstance(name, str):
|
|
794
|
+
return name
|
|
795
|
+
|
|
796
|
+
# Try other common attributes on the unwrapped checkable
|
|
797
|
+
# Loop invariant: checked attributes[0..i]
|
|
798
|
+
for attr in ("fn", "function", "name"):
|
|
799
|
+
obj = getattr(inner, attr, None)
|
|
800
|
+
if obj is not None:
|
|
801
|
+
name = getattr(obj, "__name__", None) or getattr(obj, "name", None)
|
|
802
|
+
if isinstance(name, str):
|
|
803
|
+
return name
|
|
804
|
+
|
|
805
|
+
# Last resort: try to extract a name from the string representation
|
|
806
|
+
checkable_str = str(checkable)
|
|
807
|
+
if "ctxfn=Function" in checkable_str:
|
|
808
|
+
# Pattern: ClampedCheckable(ConditionCheckable(ctxfn=Function(name='foo'...
|
|
809
|
+
import re as _re
|
|
810
|
+
name_match = _re.search(r"name='([^']+)'", checkable_str)
|
|
811
|
+
if name_match:
|
|
812
|
+
return name_match.group(1)
|
|
813
|
+
|
|
814
|
+
return checkable_str[:80]
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@icontract.require(
|
|
818
|
+
lambda func_name: is_non_empty_string(func_name),
|
|
819
|
+
"func_name must be a non-empty string",
|
|
820
|
+
)
|
|
821
|
+
@icontract.require(
|
|
822
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
823
|
+
"module_path must be a non-empty string",
|
|
824
|
+
)
|
|
825
|
+
@icontract.require(
|
|
826
|
+
lambda elapsed: elapsed >= 0.0,
|
|
827
|
+
"elapsed must be non-negative",
|
|
828
|
+
)
|
|
829
|
+
@icontract.ensure(
|
|
830
|
+
lambda result: isinstance(result, SymbolicFinding),
|
|
831
|
+
"result must be a SymbolicFinding",
|
|
832
|
+
)
|
|
833
|
+
def _message_to_finding(
|
|
834
|
+
func_name: str,
|
|
835
|
+
module_path: str,
|
|
836
|
+
msg: Any,
|
|
837
|
+
elapsed: float,
|
|
838
|
+
) -> SymbolicFinding:
|
|
839
|
+
"""Convert a CrossHair AnalysisMessage to a SymbolicFinding.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
func_name: Function name.
|
|
843
|
+
module_path: Module path.
|
|
844
|
+
msg: CrossHair analysis message.
|
|
845
|
+
elapsed: Time taken.
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
A SymbolicFinding.
|
|
849
|
+
"""
|
|
850
|
+
message_str = getattr(msg, "message", str(msg))
|
|
851
|
+
state = getattr(msg, "state", None)
|
|
852
|
+
state_name = str(getattr(state, "name", "")) if state else ""
|
|
853
|
+
|
|
854
|
+
if state_name == "CONFIRMED":
|
|
855
|
+
# CONFIRMED means postconditions hold for all paths — success
|
|
856
|
+
return SymbolicFinding(
|
|
857
|
+
function_name=func_name,
|
|
858
|
+
module_path=module_path,
|
|
859
|
+
outcome="verified",
|
|
860
|
+
message=f"Verified: postconditions hold for '{func_name}'",
|
|
861
|
+
duration_seconds=elapsed,
|
|
862
|
+
)
|
|
863
|
+
elif state_name == "POST_FAIL":
|
|
864
|
+
return SymbolicFinding(
|
|
865
|
+
function_name=func_name,
|
|
866
|
+
module_path=module_path,
|
|
867
|
+
outcome="counterexample",
|
|
868
|
+
message=f"Postcondition violated for '{func_name}': {message_str}",
|
|
869
|
+
counterexample=_parse_counterexample(str(message_str)),
|
|
870
|
+
duration_seconds=elapsed,
|
|
871
|
+
)
|
|
872
|
+
elif state_name == "CANNOT_CONFIRM":
|
|
873
|
+
return SymbolicFinding(
|
|
874
|
+
function_name=func_name,
|
|
875
|
+
module_path=module_path,
|
|
876
|
+
outcome="timeout",
|
|
877
|
+
message=f"Cannot confirm postcondition for '{func_name}': {message_str}",
|
|
878
|
+
duration_seconds=elapsed,
|
|
879
|
+
)
|
|
880
|
+
elif state_name in ("EXEC_ERR", "SYNTAX_ERR", "IMPORT_ERR"):
|
|
881
|
+
# Check if this is a solver limitation rather than a real bug
|
|
882
|
+
msg_lower = str(message_str).lower()
|
|
883
|
+
is_solver_issue = any(p in msg_lower for p in _SOLVER_LIMITATION_PATTERNS)
|
|
884
|
+
is_solver_issue = is_solver_issue or "recursionerror" in msg_lower
|
|
885
|
+
outcome = "unsupported" if is_solver_issue else "error"
|
|
886
|
+
return SymbolicFinding(
|
|
887
|
+
function_name=func_name,
|
|
888
|
+
module_path=module_path,
|
|
889
|
+
outcome=outcome,
|
|
890
|
+
message=f"Error verifying '{func_name}': {message_str}",
|
|
891
|
+
duration_seconds=elapsed,
|
|
892
|
+
)
|
|
893
|
+
elif state_name == "PRE_UNSAT":
|
|
894
|
+
return SymbolicFinding(
|
|
895
|
+
function_name=func_name,
|
|
896
|
+
module_path=module_path,
|
|
897
|
+
outcome="verified",
|
|
898
|
+
message=f"Preconditions unsatisfiable for '{func_name}' — vacuously true",
|
|
899
|
+
duration_seconds=elapsed,
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
# Check if the message indicates a solver limitation
|
|
903
|
+
msg_lower = str(message_str).lower()
|
|
904
|
+
is_solver_issue = any(p in msg_lower for p in _SOLVER_LIMITATION_PATTERNS)
|
|
905
|
+
is_solver_issue = is_solver_issue or "recursionerror" in msg_lower
|
|
906
|
+
if is_solver_issue:
|
|
907
|
+
return SymbolicFinding(
|
|
908
|
+
function_name=func_name,
|
|
909
|
+
module_path=module_path,
|
|
910
|
+
outcome="unsupported",
|
|
911
|
+
message=f"Solver limitation for '{func_name}': {message_str}",
|
|
912
|
+
duration_seconds=elapsed,
|
|
913
|
+
)
|
|
914
|
+
return SymbolicFinding(
|
|
915
|
+
function_name=func_name,
|
|
916
|
+
module_path=module_path,
|
|
917
|
+
outcome="counterexample",
|
|
918
|
+
message=f"Issue found for '{func_name}': {message_str}",
|
|
919
|
+
counterexample=_parse_counterexample(str(message_str)),
|
|
920
|
+
duration_seconds=elapsed,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@icontract.require(lambda message: isinstance(message, str), "message must be a string")
|
|
925
|
+
@icontract.ensure(
|
|
926
|
+
lambda result: result is None or isinstance(result, dict),
|
|
927
|
+
"result must be a dictionary or None",
|
|
928
|
+
)
|
|
929
|
+
def _parse_counterexample(message: str) -> dict[str, object] | None:
|
|
930
|
+
"""Extract counterexample values from a CrossHair message.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
message: The error message to parse.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
Dict of variable→value mappings, or None.
|
|
937
|
+
"""
|
|
938
|
+
call_match = re.search(r"when calling \w+\((.+?)\)", message)
|
|
939
|
+
if call_match:
|
|
940
|
+
args_str = call_match.group(1)
|
|
941
|
+
counterexample: dict[str, object] = {}
|
|
942
|
+
# Loop invariant: counterexample contains parsed args from parts[0..i]
|
|
943
|
+
for part in args_str.split(","):
|
|
944
|
+
part = part.strip()
|
|
945
|
+
if "=" in part:
|
|
946
|
+
key, val = part.split("=", 1)
|
|
947
|
+
counterexample[key.strip()] = val.strip()
|
|
948
|
+
return counterexample if counterexample else None
|
|
949
|
+
return None
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
@icontract.require(
|
|
953
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
954
|
+
"module_path must be a non-empty string",
|
|
955
|
+
)
|
|
956
|
+
@icontract.require(lambda stdout: isinstance(stdout, str), "stdout must be a string")
|
|
957
|
+
@icontract.require(lambda stderr: isinstance(stderr, str), "stderr must be a string")
|
|
958
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
959
|
+
def _parse_cli_output(
|
|
960
|
+
module_path: str,
|
|
961
|
+
stdout: str,
|
|
962
|
+
stderr: str,
|
|
963
|
+
function_name: str = "<unknown>",
|
|
964
|
+
) -> list[SymbolicFinding]:
|
|
965
|
+
"""Parse CrossHair CLI output into findings.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
module_path: Module that was verified.
|
|
969
|
+
stdout: CLI stdout.
|
|
970
|
+
stderr: CLI stderr.
|
|
971
|
+
|
|
972
|
+
Returns:
|
|
973
|
+
List of findings parsed from CLI output.
|
|
974
|
+
"""
|
|
975
|
+
findings: list[SymbolicFinding] = []
|
|
976
|
+
|
|
977
|
+
if not stdout.strip() and not stderr.strip():
|
|
978
|
+
return [SymbolicFinding(
|
|
979
|
+
function_name=function_name,
|
|
980
|
+
module_path=module_path,
|
|
981
|
+
outcome="verified",
|
|
982
|
+
message=(
|
|
983
|
+
f"Function '{function_name}' verified successfully"
|
|
984
|
+
if function_name != "<unknown>"
|
|
985
|
+
else f"All functions in '{module_path}' verified successfully"
|
|
986
|
+
),
|
|
987
|
+
)]
|
|
988
|
+
|
|
989
|
+
line_pattern = re.compile(r"^(.+?):(\d+):\s*error:\s*(.+)$")
|
|
990
|
+
|
|
991
|
+
# Loop invariant: findings contains parsed results for lines[0..i]
|
|
992
|
+
for line in stdout.splitlines():
|
|
993
|
+
line = line.strip()
|
|
994
|
+
if not line:
|
|
995
|
+
continue
|
|
996
|
+
match = line_pattern.match(line)
|
|
997
|
+
if match:
|
|
998
|
+
findings.append(SymbolicFinding(
|
|
999
|
+
function_name=function_name,
|
|
1000
|
+
module_path=module_path,
|
|
1001
|
+
outcome="counterexample",
|
|
1002
|
+
message=match.group(3),
|
|
1003
|
+
counterexample=_parse_counterexample(match.group(3)),
|
|
1004
|
+
))
|
|
1005
|
+
elif "error" in line.lower():
|
|
1006
|
+
findings.append(SymbolicFinding(
|
|
1007
|
+
function_name=function_name,
|
|
1008
|
+
module_path=module_path,
|
|
1009
|
+
outcome="error",
|
|
1010
|
+
message=line,
|
|
1011
|
+
))
|
|
1012
|
+
|
|
1013
|
+
if not findings and stderr.strip():
|
|
1014
|
+
findings.append(SymbolicFinding(
|
|
1015
|
+
function_name=function_name,
|
|
1016
|
+
module_path=module_path,
|
|
1017
|
+
outcome="error",
|
|
1018
|
+
message=f"CrossHair error: {stderr[:200]}",
|
|
1019
|
+
))
|
|
1020
|
+
|
|
1021
|
+
return findings
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
@icontract.require(
|
|
1025
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
1026
|
+
"module_path must be a non-empty string",
|
|
1027
|
+
)
|
|
1028
|
+
@icontract.require(
|
|
1029
|
+
lambda module_timeout: is_positive_int(module_timeout),
|
|
1030
|
+
"module_timeout must be positive",
|
|
1031
|
+
)
|
|
1032
|
+
@icontract.ensure(
|
|
1033
|
+
lambda result: isinstance(result, SymbolicFinding),
|
|
1034
|
+
"result must be a SymbolicFinding",
|
|
1035
|
+
)
|
|
1036
|
+
def _make_module_timeout_finding(
|
|
1037
|
+
module_path: str,
|
|
1038
|
+
module_timeout: int,
|
|
1039
|
+
) -> SymbolicFinding:
|
|
1040
|
+
"""Create a module-level timeout finding for CLI-backed verification."""
|
|
1041
|
+
return SymbolicFinding(
|
|
1042
|
+
function_name="<module>",
|
|
1043
|
+
module_path=module_path,
|
|
1044
|
+
outcome="timeout",
|
|
1045
|
+
message=f"Module verification timed out after {module_timeout}s",
|
|
1046
|
+
duration_seconds=float(module_timeout),
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
@icontract.require(
|
|
1051
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
1052
|
+
"search_paths must be a tuple",
|
|
1053
|
+
)
|
|
1054
|
+
@icontract.ensure(lambda result: isinstance(result, dict), "result must be a dictionary")
|
|
1055
|
+
def _subprocess_env(search_paths: tuple[str, ...]) -> dict[str, str]:
|
|
1056
|
+
"""Build subprocess environment with project import roots on PYTHONPATH."""
|
|
1057
|
+
import os
|
|
1058
|
+
|
|
1059
|
+
env = dict(os.environ)
|
|
1060
|
+
existing = env.get("PYTHONPATH", "")
|
|
1061
|
+
paths: list[str] = list(search_paths)
|
|
1062
|
+
|
|
1063
|
+
if existing:
|
|
1064
|
+
paths.append(existing)
|
|
1065
|
+
|
|
1066
|
+
if paths:
|
|
1067
|
+
env["PYTHONPATH"] = os.pathsep.join(paths)
|
|
1068
|
+
|
|
1069
|
+
return env
|