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.
Files changed (39) hide show
  1. serenecode/__init__.py +281 -0
  2. serenecode/adapters/__init__.py +6 -0
  3. serenecode/adapters/coverage_adapter.py +1173 -0
  4. serenecode/adapters/crosshair_adapter.py +1069 -0
  5. serenecode/adapters/hypothesis_adapter.py +1824 -0
  6. serenecode/adapters/local_fs.py +169 -0
  7. serenecode/adapters/module_loader.py +492 -0
  8. serenecode/adapters/mypy_adapter.py +161 -0
  9. serenecode/checker/__init__.py +6 -0
  10. serenecode/checker/compositional.py +2216 -0
  11. serenecode/checker/coverage.py +186 -0
  12. serenecode/checker/properties.py +154 -0
  13. serenecode/checker/structural.py +1504 -0
  14. serenecode/checker/symbolic.py +178 -0
  15. serenecode/checker/types.py +148 -0
  16. serenecode/cli.py +478 -0
  17. serenecode/config.py +711 -0
  18. serenecode/contracts/__init__.py +6 -0
  19. serenecode/contracts/predicates.py +176 -0
  20. serenecode/core/__init__.py +6 -0
  21. serenecode/core/exceptions.py +38 -0
  22. serenecode/core/pipeline.py +807 -0
  23. serenecode/init.py +307 -0
  24. serenecode/models.py +308 -0
  25. serenecode/ports/__init__.py +6 -0
  26. serenecode/ports/coverage_analyzer.py +124 -0
  27. serenecode/ports/file_system.py +95 -0
  28. serenecode/ports/property_tester.py +69 -0
  29. serenecode/ports/symbolic_checker.py +70 -0
  30. serenecode/ports/type_checker.py +66 -0
  31. serenecode/reporter.py +346 -0
  32. serenecode/source_discovery.py +319 -0
  33. serenecode/templates/__init__.py +5 -0
  34. serenecode/templates/content.py +337 -0
  35. serenecode-0.1.0.dist-info/METADATA +298 -0
  36. serenecode-0.1.0.dist-info/RECORD +39 -0
  37. serenecode-0.1.0.dist-info/WHEEL +4 -0
  38. serenecode-0.1.0.dist-info/entry_points.txt +2 -0
  39. 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