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,1824 @@
1
+ """Hypothesis adapter for property-based testing (Level 3).
2
+
3
+ This adapter implements the PropertyTester protocol by running
4
+ Hypothesis tests against functions that have icontract decorators.
5
+ It uses icontract's runtime checking to detect postcondition violations.
6
+
7
+ This is an adapter module — it handles I/O (module importing, test
8
+ execution) and is exempt from full contract requirements.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import collections.abc
15
+ import enum
16
+ import inspect
17
+ import re
18
+ import traceback
19
+ import types
20
+ import typing
21
+ from typing import Callable, cast
22
+
23
+ import icontract
24
+
25
+ from serenecode.adapters.module_loader import load_python_module
26
+ from serenecode.contracts.predicates import is_non_empty_string, is_positive_int
27
+ from serenecode.core.exceptions import ToolNotInstalledError, UnsafeCodeExecutionError
28
+ from serenecode.ports.property_tester import PropertyFinding
29
+
30
+ try:
31
+ from hypothesis import given, settings, HealthCheck, Verbosity
32
+ from hypothesis import strategies as st
33
+ from hypothesis.strategies import SearchStrategy
34
+ _HYPOTHESIS_AVAILABLE = True
35
+ except ImportError:
36
+ _HYPOTHESIS_AVAILABLE = False
37
+
38
+
39
+ _TRUST_REQUIRED_MESSAGE = (
40
+ "Level 3 property testing imports and executes project modules. "
41
+ "Re-run with allow_code_execution=True only for trusted code."
42
+ )
43
+
44
+
45
+ @icontract.require(
46
+ lambda annotation: annotation is None or isinstance(annotation, object),
47
+ "annotation must be a Python object or None",
48
+ )
49
+ @icontract.ensure(
50
+ lambda result: result is None or hasattr(result, "map"),
51
+ "result must be a Hypothesis strategy or None",
52
+ )
53
+ def _get_strategy_for_annotation(annotation: type | None) -> SearchStrategy | None:
54
+ return _get_strategy_for_annotation_with_seen(annotation, frozenset())
55
+
56
+
57
+ @icontract.require(
58
+ lambda seen_classes: isinstance(seen_classes, frozenset),
59
+ "seen_classes must be a frozenset",
60
+ )
61
+ @icontract.ensure(
62
+ lambda result: result is None or hasattr(result, "map"),
63
+ "result must be a Hypothesis strategy or None",
64
+ )
65
+ def _get_strategy_for_annotation_with_seen(
66
+ annotation: type | None,
67
+ seen_classes: frozenset[type],
68
+ ) -> SearchStrategy | None:
69
+ """Derive a Hypothesis strategy from a type annotation.
70
+
71
+ Args:
72
+ annotation: A Python type annotation.
73
+ seen_classes: Classes already visited while deriving nested strategies.
74
+
75
+ Returns:
76
+ A Hypothesis strategy, or None if the type is unsupported.
77
+ """
78
+ if not _HYPOTHESIS_AVAILABLE:
79
+ return None
80
+
81
+ if annotation is None:
82
+ return None
83
+
84
+ if annotation is type(None):
85
+ return st.none()
86
+
87
+ known_strategy = _strategy_for_known_annotation(annotation, seen_classes)
88
+ if known_strategy is not None:
89
+ return known_strategy
90
+
91
+ # Handle basic types
92
+ strategy_map: dict[type, SearchStrategy] = {
93
+ int: st.integers(min_value=-1000, max_value=1000),
94
+ float: st.floats(
95
+ min_value=-1e6, max_value=1e6,
96
+ allow_nan=False, allow_infinity=False,
97
+ ),
98
+ str: st.text(min_size=0, max_size=100),
99
+ bool: st.booleans(),
100
+ bytes: st.binary(min_size=0, max_size=100),
101
+ }
102
+
103
+ if annotation in strategy_map:
104
+ return strategy_map[annotation]
105
+
106
+ if annotation is object:
107
+ return st.one_of(
108
+ st.none(),
109
+ st.booleans(),
110
+ st.integers(min_value=-10, max_value=10),
111
+ st.text(min_size=0, max_size=20),
112
+ )
113
+
114
+ # Handle generic types (list[int], etc.)
115
+ origin = typing.get_origin(annotation)
116
+ args = typing.get_args(annotation)
117
+
118
+ if origin in (typing.Union, types.UnionType):
119
+ strategies = [
120
+ strategy
121
+ for arg in args
122
+ if (strategy := _get_strategy_for_annotation_with_seen(arg, seen_classes)) is not None
123
+ ]
124
+ if strategies:
125
+ return st.one_of(*strategies)
126
+ return None
127
+
128
+ if origin is typing.Literal:
129
+ return st.sampled_from(args)
130
+
131
+ if origin is list and args:
132
+ inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
133
+ if inner is not None:
134
+ return st.lists(inner, min_size=0, max_size=20)
135
+
136
+ if origin is set and args:
137
+ inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
138
+ if inner is not None:
139
+ return st.sets(inner, max_size=20)
140
+
141
+ if origin is frozenset and args:
142
+ inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
143
+ if inner is not None:
144
+ return st.frozensets(inner, max_size=20)
145
+
146
+ if origin is tuple and args:
147
+ if len(args) == 2 and args[1] is Ellipsis:
148
+ inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
149
+ if inner is not None:
150
+ return st.lists(inner, min_size=0, max_size=8).map(tuple)
151
+
152
+ inner_strats = []
153
+ # Loop invariant: inner_strats contains strategies for args[0..i]
154
+ for arg in args:
155
+ s = _get_strategy_for_annotation_with_seen(arg, seen_classes)
156
+ if s is None:
157
+ return None
158
+ inner_strats.append(s)
159
+ return st.tuples(*inner_strats)
160
+
161
+ if origin is dict and args and len(args) == 2:
162
+ key_strat = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
163
+ val_strat = _get_strategy_for_annotation_with_seen(args[1], seen_classes)
164
+ if key_strat is not None and val_strat is not None:
165
+ return st.dictionaries(key_strat, val_strat, max_size=10)
166
+
167
+ if origin in (Callable, collections.abc.Callable):
168
+ return st.just(_make_callable_stub(annotation))
169
+
170
+ if inspect.isclass(annotation):
171
+ if _is_protocol_class(annotation):
172
+ return _strategy_for_protocol(annotation)
173
+ if issubclass(annotation, enum.Enum):
174
+ members = list(annotation)
175
+ if members:
176
+ return st.sampled_from(members)
177
+ return None
178
+ if issubclass(annotation, ast.AST):
179
+ return _strategy_for_ast(annotation)
180
+ if annotation in seen_classes:
181
+ return None
182
+ return _strategy_for_class(annotation, seen_classes | frozenset({annotation}))
183
+
184
+ return None
185
+
186
+
187
+ @icontract.require(lambda func: callable(func), "func must be callable")
188
+ @icontract.require(
189
+ lambda seen_classes: isinstance(seen_classes, frozenset),
190
+ "seen_classes must be a frozenset",
191
+ )
192
+ @icontract.ensure(
193
+ lambda result: result is None or isinstance(result, dict),
194
+ "result must be a strategy dictionary or None",
195
+ )
196
+ def _build_strategies_from_signature(
197
+ func: Callable[..., object],
198
+ seen_classes: frozenset[type] = frozenset(),
199
+ ) -> dict[str, SearchStrategy] | None:
200
+ """Build Hypothesis strategies from a function's type annotations.
201
+
202
+ Args:
203
+ func: The function to build strategies for.
204
+
205
+ Returns:
206
+ A dict mapping parameter names to strategies, or None if
207
+ strategies cannot be derived for all parameters.
208
+ """
209
+ if not _HYPOTHESIS_AVAILABLE:
210
+ return None
211
+
212
+ # Use get_type_hints to resolve string annotations (from __future__ import annotations)
213
+ try:
214
+ hints = typing.get_type_hints(func)
215
+ except Exception:
216
+ hints = {}
217
+
218
+ sig = inspect.signature(func)
219
+ strategies: dict[str, SearchStrategy] = {}
220
+ annotations: dict[str, object] = {}
221
+ # Loop invariant: strategies contains entries for all processable params[0..i]
222
+ for name, param in sig.parameters.items():
223
+ if name in ("self", "cls"):
224
+ continue
225
+
226
+ # Prefer resolved type hints over raw annotations
227
+ annotation = hints.get(name, param.annotation)
228
+ if annotation is inspect.Parameter.empty:
229
+ return None # can't derive strategy without annotation
230
+
231
+ strategy = _get_strategy_for_annotation_with_seen(annotation, seen_classes)
232
+ if strategy is None:
233
+ return None # unsupported type
234
+
235
+ strategies[name] = strategy
236
+ annotations[name] = annotation
237
+
238
+ if not strategies:
239
+ return {}
240
+
241
+ # Refine strategies using icontract preconditions
242
+ strategies = _refine_strategies_with_preconditions(
243
+ func,
244
+ strategies,
245
+ annotations,
246
+ )
247
+
248
+ return strategies
249
+
250
+
251
+ @icontract.require(lambda annotation: inspect.isclass(annotation), "annotation must be a class")
252
+ @icontract.require(
253
+ lambda seen_classes: isinstance(seen_classes, frozenset),
254
+ "seen_classes must be a frozenset",
255
+ )
256
+ @icontract.ensure(
257
+ lambda result: result is None or hasattr(result, "map"),
258
+ "result must be a Hypothesis strategy or None",
259
+ )
260
+ def _strategy_for_class(
261
+ annotation: type,
262
+ seen_classes: frozenset[type],
263
+ ) -> SearchStrategy | None:
264
+ """Derive a strategy for a user-defined class from its constructor."""
265
+ init = getattr(annotation, "__init__", None)
266
+ if init is None or init is object.__init__:
267
+ return None
268
+
269
+ constructor_strategies = _build_strategies_from_signature(
270
+ init,
271
+ seen_classes=seen_classes,
272
+ )
273
+ if constructor_strategies is None:
274
+ return None
275
+
276
+ kwargs_strategy = st.fixed_dictionaries(constructor_strategies)
277
+ kwargs_strategy = kwargs_strategy.filter(
278
+ lambda kwargs: _check_preconditions(init, dict(kwargs))
279
+ )
280
+ kwargs_strategy = kwargs_strategy.filter(
281
+ lambda kwargs: _can_construct_class(annotation, kwargs)
282
+ )
283
+
284
+ return kwargs_strategy.map(lambda kwargs: annotation(**kwargs))
285
+
286
+
287
+ @icontract.require(
288
+ lambda seen_classes: isinstance(seen_classes, frozenset),
289
+ "seen_classes must be a frozenset",
290
+ )
291
+ @icontract.ensure(
292
+ lambda result: result is None or hasattr(result, "map"),
293
+ "result must be a Hypothesis strategy or None",
294
+ )
295
+ def _strategy_for_known_annotation(
296
+ annotation: type | object,
297
+ seen_classes: frozenset[type],
298
+ ) -> SearchStrategy | None:
299
+ """Return tailored strategies for Serenecode's own domain types."""
300
+ module_name = getattr(annotation, "__module__", "")
301
+ type_name = getattr(annotation, "__name__", "")
302
+
303
+ if module_name == "serenecode.config" and type_name == "SerenecodeConfig":
304
+ from serenecode.config import default_config, minimal_config, strict_config
305
+
306
+ return st.sampled_from([
307
+ default_config(),
308
+ strict_config(),
309
+ minimal_config(),
310
+ ])
311
+
312
+ if module_name == "serenecode.core.pipeline" and type_name == "SourceFile":
313
+ return _strategy_for_source_file()
314
+
315
+ if module_name == "serenecode.models":
316
+ return _strategy_for_model_type(annotation, type_name)
317
+
318
+ if module_name == "core.models":
319
+ return _strategy_for_example_model_type(annotation, type_name)
320
+
321
+ if module_name == "serenecode.checker.structural" and type_name == "IcontractNames":
322
+ return _strategy_for_icontract_names()
323
+
324
+ if module_name == "serenecode.checker.compositional":
325
+ return _strategy_for_compositional_type(type_name, seen_classes)
326
+
327
+ return None
328
+
329
+
330
+ @icontract.require(
331
+ lambda annotation: annotation is not None,
332
+ "annotation must be provided",
333
+ )
334
+ @icontract.require(
335
+ lambda type_name: is_non_empty_string(type_name),
336
+ "type_name must be a non-empty string",
337
+ )
338
+ @icontract.ensure(
339
+ lambda result: result is None or hasattr(result, "map"),
340
+ "result must be a Hypothesis strategy or None",
341
+ )
342
+ def _strategy_for_example_model_type(
343
+ annotation: type | object,
344
+ type_name: str,
345
+ ) -> SearchStrategy | None:
346
+ """Return efficient strategies for the dosage example model types."""
347
+ if type_name == "Patient" and inspect.isclass(annotation):
348
+ return st.builds(
349
+ annotation,
350
+ weight_kg=st.floats(min_value=0.1, max_value=300.0, allow_nan=False, allow_infinity=False),
351
+ age_years=st.floats(min_value=0.0, max_value=150.0, allow_nan=False, allow_infinity=False),
352
+ creatinine_clearance=st.floats(min_value=1.0, max_value=200.0, allow_nan=False, allow_infinity=False),
353
+ current_medications=st.lists(st.text(min_size=1, max_size=20), max_size=5),
354
+ )
355
+
356
+ if type_name == "Drug" and inspect.isclass(annotation):
357
+ def _build_drug(
358
+ drug_cls: type,
359
+ drug_id: str,
360
+ dose_per_kg: float,
361
+ concentration_mg_per_ml: float,
362
+ max_single_dose_mg: float,
363
+ extra_daily_mg: float,
364
+ doses_per_day: int,
365
+ contraindicated_with: set[str],
366
+ ) -> object:
367
+ return drug_cls(
368
+ drug_id=drug_id,
369
+ dose_per_kg=dose_per_kg,
370
+ concentration_mg_per_ml=concentration_mg_per_ml,
371
+ max_single_dose_mg=max_single_dose_mg,
372
+ max_daily_dose_mg=max_single_dose_mg + extra_daily_mg,
373
+ doses_per_day=doses_per_day,
374
+ contraindicated_with=contraindicated_with,
375
+ )
376
+
377
+ return st.builds(
378
+ _build_drug,
379
+ drug_cls=st.just(annotation),
380
+ drug_id=st.text(min_size=1, max_size=20),
381
+ dose_per_kg=st.floats(min_value=0.001, max_value=100.0, allow_nan=False, allow_infinity=False),
382
+ concentration_mg_per_ml=st.floats(min_value=0.001, max_value=1000.0, allow_nan=False, allow_infinity=False),
383
+ max_single_dose_mg=st.floats(min_value=0.001, max_value=10000.0, allow_nan=False, allow_infinity=False),
384
+ extra_daily_mg=st.floats(min_value=0.0, max_value=40000.0, allow_nan=False, allow_infinity=False),
385
+ doses_per_day=st.integers(min_value=1, max_value=24),
386
+ contraindicated_with=st.sets(st.text(min_size=1, max_size=20), max_size=5),
387
+ )
388
+
389
+ return None
390
+
391
+
392
+ @icontract.require(
393
+ lambda annotation: annotation is not None,
394
+ "annotation must be provided",
395
+ )
396
+ @icontract.require(
397
+ lambda type_name: is_non_empty_string(type_name),
398
+ "type_name must be a non-empty string",
399
+ )
400
+ @icontract.ensure(
401
+ lambda result: result is None or hasattr(result, "map"),
402
+ "result must be a Hypothesis strategy or None",
403
+ )
404
+ def _strategy_for_model_type(
405
+ annotation: type | object,
406
+ type_name: str,
407
+ ) -> SearchStrategy | None:
408
+ """Return strategies for result models that have cross-field invariants."""
409
+ from serenecode.models import (
410
+ CheckStatus as CanonicalCheckStatus,
411
+ CheckSummary as CanonicalCheckSummary,
412
+ Detail as CanonicalDetail,
413
+ FunctionResult as CanonicalFunctionResult,
414
+ VerificationLevel as CanonicalVerificationLevel,
415
+ )
416
+
417
+ module_globals: dict[str, object] = {}
418
+ annotation_globals = getattr(getattr(annotation, "to_dict", None), "__globals__", None)
419
+ if isinstance(annotation_globals, dict):
420
+ module_globals = annotation_globals
421
+
422
+ check_status_values = list(cast(type[enum.Enum], module_globals.get("CheckStatus", CanonicalCheckStatus)))
423
+ detail_factory = cast(
424
+ Callable[..., object],
425
+ annotation if type_name == "Detail" else module_globals.get("Detail", CanonicalDetail),
426
+ )
427
+ function_result_factory = cast(
428
+ Callable[..., object],
429
+ annotation if type_name == "FunctionResult" else module_globals.get("FunctionResult", CanonicalFunctionResult),
430
+ )
431
+ verification_level_values = list(cast(
432
+ type[enum.Enum],
433
+ module_globals.get("VerificationLevel", CanonicalVerificationLevel),
434
+ ))
435
+ non_empty_text = st.text(
436
+ alphabet=st.characters(blacklist_categories=("C", "Z")),
437
+ min_size=1,
438
+ max_size=120,
439
+ )
440
+ path_text = st.text(
441
+ alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-",
442
+ min_size=1,
443
+ max_size=80,
444
+ )
445
+ name_text = st.text(
446
+ alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_",
447
+ min_size=1,
448
+ max_size=40,
449
+ )
450
+
451
+ detail_strategy = st.builds(
452
+ detail_factory,
453
+ level=st.sampled_from(verification_level_values),
454
+ tool=st.sampled_from(["structural", "mypy", "hypothesis", "crosshair", "compositional"]),
455
+ finding_type=st.sampled_from(["verified", "violation", "timeout", "error", "unavailable"]),
456
+ message=non_empty_text,
457
+ counterexample=st.one_of(
458
+ st.none(),
459
+ st.dictionaries(
460
+ name_text,
461
+ st.one_of(
462
+ st.integers(min_value=-10, max_value=10),
463
+ st.text(min_size=0, max_size=20),
464
+ st.booleans(),
465
+ ),
466
+ max_size=3,
467
+ ),
468
+ ),
469
+ suggestion=st.one_of(st.none(), non_empty_text.map(lambda value: value[:80])),
470
+ )
471
+
472
+ @st.composite
473
+ def _function_result_strategy(draw: st.DrawFn) -> object:
474
+ """Build valid FunctionResult objects whose achieved level does not exceed the request."""
475
+ level_requested = draw(st.integers(min_value=1, max_value=5))
476
+ level_achieved = draw(st.integers(min_value=0, max_value=level_requested))
477
+ return function_result_factory(
478
+ function=draw(name_text),
479
+ file=draw(path_text),
480
+ line=draw(st.integers(min_value=1, max_value=1000)),
481
+ level_requested=level_requested,
482
+ level_achieved=level_achieved,
483
+ status=draw(st.sampled_from(check_status_values)),
484
+ details=draw(st.lists(detail_strategy, max_size=3).map(tuple)),
485
+ )
486
+ function_result_strategy = _function_result_strategy()
487
+
488
+ if type_name == "Detail":
489
+ return detail_strategy
490
+
491
+ if type_name == "FunctionResult":
492
+ return function_result_strategy
493
+
494
+ if type_name == "CheckResult":
495
+ check_result_factory = cast(Callable[..., object], annotation)
496
+ check_result_hints = typing.get_type_hints(annotation)
497
+ summary_factory = cast(
498
+ Callable[..., object],
499
+ check_result_hints.get("summary", CanonicalCheckSummary),
500
+ )
501
+
502
+ def _build_check_result(
503
+ results: list[object],
504
+ duration_seconds: float,
505
+ ) -> object:
506
+ level_requested = max(
507
+ (int(getattr(r, "level_requested", 1)) for r in results),
508
+ default=1,
509
+ )
510
+ level_achieved = min(
511
+ (int(getattr(r, "level_achieved", level_requested)) for r in results),
512
+ default=level_requested,
513
+ )
514
+ passed_count = 0
515
+ failed_count = 0
516
+ skipped_count = 0
517
+ # Loop invariant: counts reflect the statuses in results[0..i].
518
+ for result in results:
519
+ status_value = getattr(getattr(result, "status", None), "value", None)
520
+ if status_value == "passed":
521
+ passed_count += 1
522
+ elif status_value == "failed":
523
+ failed_count += 1
524
+ else:
525
+ skipped_count += 1
526
+
527
+ summary = summary_factory(
528
+ total_functions=len(results),
529
+ passed_count=passed_count,
530
+ failed_count=failed_count,
531
+ skipped_count=skipped_count,
532
+ duration_seconds=duration_seconds,
533
+ )
534
+ passed = (
535
+ failed_count == 0
536
+ and skipped_count == 0
537
+ and level_achieved == level_requested
538
+ )
539
+ return check_result_factory(
540
+ passed=passed,
541
+ level_requested=level_requested,
542
+ level_achieved=level_achieved,
543
+ results=tuple(results),
544
+ summary=summary,
545
+ )
546
+
547
+ return st.builds(
548
+ _build_check_result,
549
+ results=st.lists(function_result_strategy, max_size=6),
550
+ duration_seconds=st.floats(
551
+ min_value=0.0,
552
+ max_value=10.0,
553
+ allow_nan=False,
554
+ allow_infinity=False,
555
+ ),
556
+ )
557
+
558
+ return None
559
+
560
+
561
+ @icontract.ensure(
562
+ lambda result: hasattr(result, "map"),
563
+ "result must be a Hypothesis strategy",
564
+ )
565
+ def _strategy_for_source_file() -> SearchStrategy:
566
+ """Build valid SourceFile instances for pipeline property tests."""
567
+ from serenecode.core.pipeline import SourceFile
568
+
569
+ return st.builds(
570
+ SourceFile,
571
+ file_path=st.text(min_size=1, max_size=80),
572
+ module_path=st.text(min_size=1, max_size=80),
573
+ source=st.text(min_size=0, max_size=200),
574
+ importable_module=st.one_of(
575
+ st.none(),
576
+ st.text(
577
+ alphabet="abcdefghijklmnopqrstuvwxyz._",
578
+ min_size=1,
579
+ max_size=40,
580
+ ).filter(lambda value: not value.startswith(".")),
581
+ ),
582
+ import_search_paths=st.lists(
583
+ st.text(min_size=1, max_size=60),
584
+ max_size=4,
585
+ ).map(tuple),
586
+ )
587
+
588
+
589
+ @icontract.ensure(
590
+ lambda result: hasattr(result, "map"),
591
+ "result must be a Hypothesis strategy",
592
+ )
593
+ def _strategy_for_icontract_names() -> SearchStrategy:
594
+ """Build valid icontract alias sets for structural helper tests."""
595
+ from serenecode.checker.structural import IcontractNames
596
+
597
+ names = st.lists(
598
+ st.sampled_from([
599
+ "require",
600
+ "ensure",
601
+ "invariant",
602
+ "icontract.require",
603
+ "icontract.ensure",
604
+ "icontract.invariant",
605
+ ]),
606
+ unique=True,
607
+ max_size=3,
608
+ ).map(frozenset)
609
+
610
+ return st.builds(
611
+ IcontractNames,
612
+ module_alias=st.one_of(st.none(), st.text(min_size=1, max_size=12)),
613
+ require_names=names,
614
+ ensure_names=names,
615
+ invariant_names=names,
616
+ )
617
+
618
+
619
+ @icontract.require(
620
+ lambda type_name: is_non_empty_string(type_name),
621
+ "type_name must be a non-empty string",
622
+ )
623
+ @icontract.require(
624
+ lambda seen_classes: isinstance(seen_classes, frozenset),
625
+ "seen_classes must be a frozenset",
626
+ )
627
+ @icontract.ensure(
628
+ lambda result: result is None or hasattr(result, "map"),
629
+ "result must be a Hypothesis strategy or None",
630
+ )
631
+ def _strategy_for_compositional_type(
632
+ type_name: str,
633
+ seen_classes: frozenset[type],
634
+ ) -> SearchStrategy | None:
635
+ """Return strategies for compositional-analysis dataclasses."""
636
+ from serenecode.checker.compositional import (
637
+ ClassInfo,
638
+ FunctionInfo,
639
+ MethodSignature,
640
+ ModuleInfo,
641
+ ParameterInfo,
642
+ ProtocolInfo,
643
+ )
644
+
645
+ method_signature = st.builds(
646
+ MethodSignature,
647
+ name=st.text(min_size=1, max_size=20),
648
+ parameters=st.lists(st.text(min_size=1, max_size=16), max_size=4).map(tuple),
649
+ has_return_annotation=st.booleans(),
650
+ )
651
+ parameter_info = st.builds(
652
+ ParameterInfo,
653
+ name=st.text(min_size=1, max_size=20),
654
+ annotation=st.one_of(st.none(), st.text(min_size=1, max_size=20)),
655
+ )
656
+ function_info = st.builds(
657
+ FunctionInfo,
658
+ name=st.text(min_size=1, max_size=20),
659
+ line=st.integers(min_value=1, max_value=500),
660
+ is_public=st.booleans(),
661
+ parameters=st.lists(parameter_info, max_size=4).map(tuple),
662
+ return_annotation=st.one_of(st.none(), st.text(min_size=1, max_size=20)),
663
+ has_require=st.booleans(),
664
+ has_ensure=st.booleans(),
665
+ calls=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
666
+ )
667
+ class_info = st.builds(
668
+ ClassInfo,
669
+ name=st.text(min_size=1, max_size=20),
670
+ line=st.integers(min_value=1, max_value=500),
671
+ bases=st.lists(st.text(min_size=1, max_size=20), max_size=3).map(tuple),
672
+ methods=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
673
+ is_protocol=st.booleans(),
674
+ method_signatures=st.lists(method_signature, max_size=4).map(tuple),
675
+ has_invariant=st.booleans(),
676
+ )
677
+ protocol_info = st.builds(
678
+ ProtocolInfo,
679
+ name=st.text(min_size=1, max_size=20),
680
+ line=st.integers(min_value=1, max_value=500),
681
+ methods=st.lists(method_signature, max_size=4).map(tuple),
682
+ )
683
+ module_info = st.builds(
684
+ ModuleInfo,
685
+ file_path=st.text(min_size=1, max_size=60),
686
+ module_path=st.text(min_size=1, max_size=60),
687
+ imports=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
688
+ from_imports=st.lists(
689
+ st.tuples(
690
+ st.text(min_size=1, max_size=20),
691
+ st.text(min_size=1, max_size=20),
692
+ ),
693
+ max_size=4,
694
+ ).map(tuple),
695
+ classes=st.lists(class_info, max_size=3).map(tuple),
696
+ functions=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
697
+ protocols=st.lists(protocol_info, max_size=3).map(tuple),
698
+ function_infos=st.lists(function_info, max_size=4).map(tuple),
699
+ )
700
+
701
+ strategies = {
702
+ "MethodSignature": method_signature,
703
+ "ParameterInfo": parameter_info,
704
+ "FunctionInfo": function_info,
705
+ "ClassInfo": class_info,
706
+ "ProtocolInfo": protocol_info,
707
+ "ModuleInfo": module_info,
708
+ }
709
+ return strategies.get(type_name)
710
+
711
+
712
+ @icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
713
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
714
+ def _is_protocol_class(annotation: type) -> bool:
715
+ """Check whether an annotation is a typing.Protocol-derived class."""
716
+ return bool(getattr(annotation, "_is_protocol", False))
717
+
718
+
719
+ @icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
720
+ @icontract.ensure(
721
+ lambda result: result is None or hasattr(result, "map"),
722
+ "result must be a Hypothesis strategy or None",
723
+ )
724
+ def _strategy_for_protocol(annotation: type) -> SearchStrategy | None:
725
+ """Build stubs for protocol-typed dependencies used in Serenecode."""
726
+ module_name = getattr(annotation, "__module__", "")
727
+ type_name = getattr(annotation, "__name__", "")
728
+
729
+ if module_name == "serenecode.ports.file_system" and type_name == "FileReader":
730
+ class _ReaderStub:
731
+ def read_file(self, path: str) -> str:
732
+ return "def generated() -> int:\n return 1\n"
733
+
734
+ def file_exists(self, path: str) -> bool:
735
+ return False
736
+
737
+ def list_python_files(self, directory: str) -> list[str]:
738
+ return []
739
+
740
+ return st.just(_ReaderStub())
741
+
742
+ if module_name == "serenecode.ports.file_system" and type_name == "FileWriter":
743
+ class _WriterStub:
744
+ def write_file(self, path: str, content: str) -> None:
745
+ return None
746
+
747
+ def ensure_directory(self, path: str) -> None:
748
+ return None
749
+
750
+ return st.just(_WriterStub())
751
+
752
+ if module_name == "serenecode.ports.type_checker" and type_name == "TypeChecker":
753
+ class _TypeCheckerStub:
754
+ def check(
755
+ self,
756
+ file_paths: list[str],
757
+ strict: bool = True,
758
+ search_paths: tuple[str, ...] = (),
759
+ ) -> list[object]:
760
+ return []
761
+
762
+ return st.just(_TypeCheckerStub())
763
+
764
+ if module_name == "serenecode.ports.property_tester" and type_name == "PropertyTester":
765
+ class _PropertyTesterStub:
766
+ def test_module(
767
+ self,
768
+ module_path: str,
769
+ max_examples: int | None = None,
770
+ search_paths: tuple[str, ...] = (),
771
+ ) -> list[object]:
772
+ return []
773
+
774
+ return st.just(_PropertyTesterStub())
775
+
776
+ if module_name == "serenecode.ports.symbolic_checker" and type_name == "SymbolicChecker":
777
+ class _SymbolicCheckerStub:
778
+ def verify_module(
779
+ self,
780
+ module_path: str,
781
+ per_condition_timeout: int | None = None,
782
+ per_path_timeout: int | None = None,
783
+ search_paths: tuple[str, ...] = (),
784
+ ) -> list[object]:
785
+ return []
786
+
787
+ return st.just(_SymbolicCheckerStub())
788
+
789
+ return None
790
+
791
+
792
+ @icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
793
+ @icontract.ensure(
794
+ lambda result: result is None or hasattr(result, "map"),
795
+ "result must be a Hypothesis strategy or None",
796
+ )
797
+ def _strategy_for_ast(annotation: type[ast.AST]) -> SearchStrategy | None:
798
+ """Build simple AST nodes for structural helper property tests."""
799
+ module_samples = [
800
+ ast.parse("import icontract"),
801
+ ast.parse(
802
+ "from icontract import require, ensure\n"
803
+ "@require(lambda x: x > 0, 'positive')\n"
804
+ "@ensure(lambda result: result >= 0, 'non-negative')\n"
805
+ "def square(x: int) -> int:\n"
806
+ " return x * x\n"
807
+ ),
808
+ ast.parse(
809
+ "@decorator\n"
810
+ "class Demo:\n"
811
+ " pass\n"
812
+ ),
813
+ ]
814
+ expr_samples = [
815
+ ast.parse("require", mode="eval").body,
816
+ ast.parse("icontract.require", mode="eval").body,
817
+ ast.parse("require(x)", mode="eval").body,
818
+ ]
819
+ stmt_samples: list[ast.AST] = []
820
+ # Loop invariant: stmt_samples contains all statement nodes from module_samples[0..i]
821
+ for module in module_samples:
822
+ stmt_samples.extend(module.body)
823
+
824
+ all_samples: list[ast.AST] = []
825
+ all_samples.extend(module_samples)
826
+ all_samples.extend(expr_samples)
827
+ all_samples.extend(stmt_samples)
828
+
829
+ matching = [sample for sample in all_samples if isinstance(sample, annotation)]
830
+ if not matching:
831
+ return None
832
+ return st.sampled_from(matching)
833
+
834
+
835
+ @icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
836
+ @icontract.ensure(lambda result: callable(result), "result must be callable")
837
+ def _make_callable_stub(annotation: object) -> Callable[..., object]:
838
+ """Build a simple callable returning a valid value for the annotation."""
839
+ args = typing.get_args(annotation)
840
+ return_annotation = args[1] if len(args) == 2 else None
841
+ return_value = _sample_value_for_annotation(return_annotation)
842
+
843
+ def _stub(*_args: object, **_kwargs: object) -> object:
844
+ return return_value
845
+
846
+ return _stub
847
+
848
+
849
+ @icontract.require(
850
+ lambda value: isinstance(value, object),
851
+ "value must be a Python object",
852
+ )
853
+ @icontract.ensure(
854
+ lambda result: isinstance(result, bool),
855
+ "result must be a bool",
856
+ )
857
+ def _is_placeholder_value(value: object) -> bool:
858
+ """Check whether a sampled placeholder value is deterministic and safe."""
859
+ if value is None:
860
+ return True
861
+ if isinstance(value, (bool, int, float, str, bytes, list, tuple, dict, set, frozenset)):
862
+ return True
863
+ return (
864
+ value.__class__.__module__ == "serenecode.models"
865
+ and value.__class__.__name__ == "CheckResult"
866
+ )
867
+
868
+
869
+ @icontract.require(
870
+ lambda annotation: annotation is None or isinstance(annotation, object),
871
+ "annotation must be a Python object or None",
872
+ )
873
+ @icontract.ensure(
874
+ lambda result: _is_placeholder_value(result),
875
+ "result must be a deterministic placeholder value",
876
+ )
877
+ def _sample_value_for_annotation(annotation: object) -> object:
878
+ """Return a deterministic placeholder value for a type annotation."""
879
+ # Variant: recursive calls peel off a union branch or container wrapper.
880
+ if annotation in (None, type(None)):
881
+ return None
882
+ if annotation is bool:
883
+ return True
884
+ if annotation is int:
885
+ return 1
886
+ if annotation is float:
887
+ return 1.0
888
+ if annotation is str:
889
+ return "x"
890
+ if annotation is bytes:
891
+ return b"x"
892
+
893
+ module_name = getattr(annotation, "__module__", "")
894
+ type_name = getattr(annotation, "__name__", "")
895
+ if module_name == "serenecode.models" and type_name == "CheckResult":
896
+ from serenecode.models import make_check_result
897
+
898
+ return make_check_result((), level_requested=1, duration_seconds=0.0)
899
+
900
+ origin = typing.get_origin(annotation)
901
+ args = typing.get_args(annotation)
902
+
903
+ if origin in (typing.Union, types.UnionType) and args:
904
+ if type(None) in args:
905
+ return None
906
+ return _sample_value_for_annotation(args[0])
907
+ if origin is list:
908
+ return []
909
+ if origin is tuple:
910
+ return ()
911
+ if origin is dict:
912
+ return {}
913
+ if origin is set:
914
+ return set()
915
+ if origin is frozenset:
916
+ return frozenset()
917
+
918
+ return None
919
+
920
+
921
+ @icontract.require(lambda annotation: inspect.isclass(annotation), "annotation must be a class")
922
+ @icontract.require(lambda kwargs: isinstance(kwargs, dict), "kwargs must be a dict")
923
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
924
+ def _can_construct_class(annotation: type, kwargs: dict[str, object]) -> bool:
925
+ """Check whether a class constructor accepts the given keyword arguments."""
926
+ try:
927
+ annotation(**kwargs)
928
+ except Exception:
929
+ return False
930
+ return True
931
+
932
+
933
+ @icontract.require(lambda func: callable(func), "func must be callable")
934
+ @icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
935
+ @icontract.require(lambda annotations: isinstance(annotations, dict), "annotations must be a dict")
936
+ @icontract.ensure(lambda result: isinstance(result, dict), "result must be a dict")
937
+ def _refine_strategies_with_preconditions(
938
+ func: Callable[..., object],
939
+ strategies: dict[str, SearchStrategy],
940
+ annotations: dict[str, object],
941
+ ) -> dict[str, SearchStrategy]:
942
+ """Refine strategies using icontract preconditions.
943
+
944
+ Inspects precondition lambdas to derive tighter strategies:
945
+ - Detects `x in (...)` patterns → st.sampled_from()
946
+ - Detects `x > 0`, `x >= 0` bounds → filtered integers
947
+ - Applies remaining preconditions as .filter() on strategies
948
+
949
+ Args:
950
+ func: The contracted function.
951
+ strategies: Base strategies derived from type annotations.
952
+
953
+ Returns:
954
+ Refined strategies dict.
955
+ """
956
+ preconditions = getattr(func, "__preconditions__", None)
957
+ if not preconditions:
958
+ return strategies
959
+
960
+ refined = dict(strategies)
961
+
962
+ # Loop invariant: refined contains strategies for all params, updated for groups[0..i]
963
+ for group in preconditions:
964
+ for contract in group:
965
+ condition = contract.condition
966
+ _try_refine_from_condition(condition, refined, annotations)
967
+
968
+ return refined
969
+
970
+
971
+ @icontract.require(lambda condition: callable(condition), "condition must be callable")
972
+ @icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
973
+ @icontract.require(lambda annotations: isinstance(annotations, dict), "annotations must be a dict")
974
+ @icontract.ensure(lambda result: result is None, "refinement happens in place")
975
+ def _try_refine_from_condition(
976
+ condition: Callable[..., bool],
977
+ strategies: dict[str, SearchStrategy],
978
+ annotations: dict[str, object],
979
+ ) -> None:
980
+ """Try to refine strategies based on a single precondition.
981
+
982
+ Inspects the condition's source code to detect common patterns
983
+ and replaces broad strategies with targeted ones.
984
+
985
+ Args:
986
+ condition: A precondition lambda/function.
987
+ strategies: Mutable dict of strategies to refine in place.
988
+ """
989
+ # Get the condition's parameter names
990
+ try:
991
+ cond_sig = inspect.signature(condition)
992
+ cond_params = [p for p in cond_sig.parameters if p not in ("self", "cls")]
993
+ except (ValueError, TypeError):
994
+ return
995
+
996
+ # Only refine single-parameter conditions (simple predicates)
997
+ if len(cond_params) != 1:
998
+ # For multi-param conditions, apply as a joint filter later
999
+ return
1000
+
1001
+ param_name = cond_params[0]
1002
+ if param_name not in strategies:
1003
+ return
1004
+
1005
+ # Try to extract source code of the condition lambda only
1006
+ source = _get_lambda_source(condition)
1007
+
1008
+ # Pattern: `x in ("a", "b", "c")` or `x in {...}` → sampled_from
1009
+ import re
1010
+ effective_source = source
1011
+
1012
+ # If the condition calls a predicate function, try to get that function's source too
1013
+ func_call_match = re.search(r'(\w+)\s*\(\s*' + re.escape(param_name) + r'\s*\)', source)
1014
+ if func_call_match:
1015
+ called_name = func_call_match.group(1)
1016
+ # Try to resolve the called function from the condition's globals
1017
+ called_func = getattr(condition, "__globals__", {}).get(called_name)
1018
+ if called_func and callable(called_func):
1019
+ try:
1020
+ called_source = inspect.getsource(called_func)
1021
+ effective_source = source + "\n" + called_source
1022
+ except (OSError, TypeError):
1023
+ pass
1024
+
1025
+ # Recognize common Serenecode contract predicates and synthesize
1026
+ # directly bounded strategies instead of filtering broad primitives.
1027
+ if called_name == "is_valid_verification_level":
1028
+ strategies[param_name] = st.integers(min_value=1, max_value=6)
1029
+ return
1030
+ if called_name == "is_non_negative_int":
1031
+ strategies[param_name] = st.integers(min_value=0, max_value=1000)
1032
+ return
1033
+ if called_name == "is_positive_int":
1034
+ strategies[param_name] = st.integers(min_value=1, max_value=1000)
1035
+ return
1036
+ if called_name == "is_non_empty_string":
1037
+ strategies[param_name] = st.text(min_size=1, max_size=100).filter(
1038
+ lambda value: len(value.strip()) > 0
1039
+ )
1040
+ return
1041
+ if called_name == "is_valid_template_name":
1042
+ strategies[param_name] = st.sampled_from(["default", "strict", "minimal"])
1043
+ return
1044
+
1045
+ in_match = re.search(
1046
+ r'in\s*[\(\[\{]\s*(.+?)\s*[\)\]\}]',
1047
+ effective_source,
1048
+ )
1049
+ if in_match:
1050
+ items_str = in_match.group(1)
1051
+ items = _parse_literal_collection(items_str)
1052
+ if items:
1053
+ strategies[param_name] = st.sampled_from(items)
1054
+ return
1055
+
1056
+ # Pattern: `x > N` or `x >= N` → integers with min_value
1057
+ gt_match = re.search(r'>\s*(\d+)', source)
1058
+ ge_match = re.search(r'>=\s*(\d+)', source)
1059
+ lt_match = re.search(r'<\s*(\d+)', source)
1060
+ le_match = re.search(r'<=\s*(\d+)', source)
1061
+
1062
+ current = strategies[param_name]
1063
+ annotation = annotations.get(param_name)
1064
+ is_numeric = annotation in (int, float)
1065
+ is_literal_like = annotation in (str, int, float)
1066
+
1067
+ # Apply bound constraints for integer/float strategies
1068
+ if is_numeric and (ge_match or gt_match or le_match or lt_match):
1069
+ min_val = None
1070
+ max_val = None
1071
+ if ge_match:
1072
+ min_val = int(ge_match.group(1))
1073
+ if gt_match:
1074
+ min_val = int(gt_match.group(1)) + 1
1075
+ if le_match:
1076
+ max_val = int(le_match.group(1))
1077
+ if lt_match:
1078
+ max_val = int(lt_match.group(1)) - 1
1079
+
1080
+ # Replace with bounded strategy
1081
+ if min_val is not None or max_val is not None:
1082
+ if annotation is float:
1083
+ strategies[param_name] = st.floats(
1084
+ min_value=float(min_val) if min_val is not None else -1e6,
1085
+ max_value=float(max_val) if max_val is not None else 1e6,
1086
+ allow_nan=False,
1087
+ allow_infinity=False,
1088
+ )
1089
+ else:
1090
+ strategies[param_name] = st.integers(
1091
+ min_value=min_val if min_val is not None else -1000,
1092
+ max_value=max_val if max_val is not None else 1000,
1093
+ )
1094
+ return
1095
+
1096
+ if not is_literal_like and not inspect.isclass(annotation):
1097
+ return
1098
+
1099
+ # Fallback: apply the condition as a filter on the existing strategy
1100
+ try:
1101
+ strategies[param_name] = current.filter(condition)
1102
+ except Exception:
1103
+ pass # keep the original strategy
1104
+
1105
+
1106
+ @icontract.require(lambda condition: callable(condition), "condition must be callable")
1107
+ @icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
1108
+ def _get_lambda_source(condition: Callable[..., bool]) -> str:
1109
+ """Extract the source of a lambda condition only.
1110
+
1111
+ inspect.getsource on a lambda inside a decorator returns the entire
1112
+ decorated function. This function extracts just the lambda expression.
1113
+
1114
+ Args:
1115
+ condition: A callable (typically a lambda).
1116
+
1117
+ Returns:
1118
+ The lambda source string, or empty string if not extractable.
1119
+ """
1120
+ try:
1121
+ full_source = inspect.getsource(condition).strip()
1122
+ except (OSError, TypeError):
1123
+ return ""
1124
+
1125
+ # If it's a named function (not a lambda), return the first line
1126
+ if condition.__name__ != "<lambda>":
1127
+ return full_source.split("\n")[0]
1128
+
1129
+ # For single-line lambdas (typical in icontract decorators),
1130
+ # find the line containing "lambda" and extract it
1131
+ import re
1132
+
1133
+ # Get the parameter name(s) of our condition
1134
+ try:
1135
+ cond_params = list(inspect.signature(condition).parameters.keys())
1136
+ except (ValueError, TypeError):
1137
+ cond_params = []
1138
+
1139
+ # Search each line for a lambda matching our parameters
1140
+ # Loop invariant: checked lines[0..i] for matching lambda
1141
+ for line in full_source.split("\n"):
1142
+ line = line.strip()
1143
+ if "lambda" not in line:
1144
+ continue
1145
+
1146
+ # Extract from "lambda" to end of meaningful content
1147
+ lambda_match = re.search(r"lambda\s+.+", line)
1148
+ if lambda_match:
1149
+ lambda_text = lambda_match.group(0)
1150
+ # Strip only trailing decorator syntax (comma after closing paren)
1151
+ lambda_text = re.sub(r",\s*$", "", lambda_text).strip()
1152
+ if cond_params and all(p in lambda_text for p in cond_params):
1153
+ return lambda_text
1154
+
1155
+ return ""
1156
+
1157
+
1158
+ @icontract.require(lambda items_str: isinstance(items_str, str), "items_str must be a string")
1159
+ @icontract.ensure(
1160
+ lambda result: result is None or isinstance(result, list),
1161
+ "result must be a list or None",
1162
+ )
1163
+ def _parse_literal_collection(items_str: str) -> list[object] | None:
1164
+ """Parse a string of literal values from source code.
1165
+
1166
+ Handles strings like '"a", "b", "c"' or '1, 2, 3'.
1167
+
1168
+ Args:
1169
+ items_str: Comma-separated literal values.
1170
+
1171
+ Returns:
1172
+ List of parsed values, or None if parsing fails.
1173
+ """
1174
+ items: list[object] = []
1175
+ # Loop invariant: items contains parsed values for parts[0..i]
1176
+ for part in items_str.split(","):
1177
+ part = part.strip()
1178
+ if not part:
1179
+ continue
1180
+ # Try string literals
1181
+ if (part.startswith('"') and part.endswith('"')) or \
1182
+ (part.startswith("'") and part.endswith("'")):
1183
+ items.append(part[1:-1])
1184
+ # Try integers
1185
+ elif part.lstrip("-").isdigit():
1186
+ items.append(int(part))
1187
+ # Try floats
1188
+ else:
1189
+ try:
1190
+ items.append(float(part))
1191
+ except ValueError:
1192
+ return None # can't parse
1193
+ return items if items else None
1194
+
1195
+
1196
+ @icontract.require(lambda func: callable(func), "func must be callable")
1197
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1198
+ def _has_icontract_decorators(func: Callable[..., object]) -> bool:
1199
+ """Check if a function has icontract decorators.
1200
+
1201
+ Args:
1202
+ func: Function to check.
1203
+
1204
+ Returns:
1205
+ True if the function has icontract require or ensure decorators.
1206
+ """
1207
+ return (
1208
+ hasattr(func, "__preconditions__")
1209
+ or hasattr(func, "__postconditions__")
1210
+ )
1211
+
1212
+
1213
+ @icontract.require(lambda func: callable(func), "func must be callable")
1214
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1215
+ def _is_property_friendly_function(func: Callable[..., object]) -> bool:
1216
+ """Check whether a function is a good fit for property-based fuzzing."""
1217
+ module_name = getattr(func, "__module__", "")
1218
+ if module_name in {"serenecode", "serenecode.cli", "serenecode.init"}:
1219
+ return False
1220
+ if module_name.startswith("serenecode.adapters"):
1221
+ return False
1222
+ return True
1223
+
1224
+
1225
+ @icontract.require(
1226
+ lambda annotation: annotation is inspect.Parameter.empty or isinstance(annotation, object),
1227
+ "annotation must be a Python annotation object",
1228
+ )
1229
+ @icontract.require(
1230
+ lambda globalns: globalns is None or isinstance(globalns, dict),
1231
+ "globalns must be a globals dictionary when provided",
1232
+ )
1233
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1234
+ def _uses_result_model_annotation(
1235
+ annotation: object,
1236
+ globalns: dict[str, object] | None = None,
1237
+ ) -> bool:
1238
+ """Check whether an annotation references Serenecode's result-model graph."""
1239
+ # Variant: the remaining annotation nesting decreases on each recursive call into args.
1240
+ if annotation is inspect.Parameter.empty:
1241
+ return False
1242
+ if isinstance(annotation, typing.ForwardRef):
1243
+ return _uses_result_model_annotation(annotation.__forward_arg__, globalns)
1244
+ if isinstance(annotation, str):
1245
+ return _string_annotation_uses_result_model(annotation, globalns)
1246
+
1247
+ origin = typing.get_origin(annotation)
1248
+ args = typing.get_args(annotation)
1249
+ if origin in (typing.Union, types.UnionType):
1250
+ return any(_uses_result_model_annotation(arg, globalns) for arg in args)
1251
+ if origin is not None:
1252
+ return any(_uses_result_model_annotation(arg, globalns) for arg in args if arg is not Ellipsis)
1253
+
1254
+ module_name = getattr(annotation, "__module__", "")
1255
+ return module_name == "serenecode.models"
1256
+
1257
+
1258
+ @icontract.require(lambda annotation: isinstance(annotation, str), "annotation must be a string")
1259
+ @icontract.require(
1260
+ lambda globalns: globalns is None or isinstance(globalns, dict),
1261
+ "globalns must be a globals dictionary when provided",
1262
+ )
1263
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1264
+ def _string_annotation_uses_result_model(
1265
+ annotation: str,
1266
+ globalns: dict[str, object] | None = None,
1267
+ ) -> bool:
1268
+ """Check whether a raw string annotation clearly references serenecode.models."""
1269
+ result_model_names = _result_model_public_names()
1270
+
1271
+ # Loop invariant: no previously-seen dotted name referenced Serenecode's result models.
1272
+ for dotted_name in re.findall(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*", annotation):
1273
+ if dotted_name.startswith("serenecode.models."):
1274
+ if dotted_name.rsplit(".", 1)[-1] in result_model_names:
1275
+ return True
1276
+ continue
1277
+
1278
+ if "." not in dotted_name:
1279
+ if globalns is not None and _is_result_model_object(globalns.get(dotted_name)):
1280
+ return True
1281
+ continue
1282
+
1283
+ root_name, _, remainder = dotted_name.partition(".")
1284
+ if globalns is None or not _is_result_model_module(globalns.get(root_name)):
1285
+ continue
1286
+ remainder_parts = tuple(part for part in remainder.split(".") if part)
1287
+ if remainder_parts and remainder_parts[0] in result_model_names:
1288
+ return True
1289
+
1290
+ return False
1291
+
1292
+
1293
+ @icontract.ensure(lambda result: isinstance(result, frozenset), "result must be a frozenset")
1294
+ def _result_model_public_names() -> frozenset[str]:
1295
+ """Return the public names exported from serenecode.models."""
1296
+ from serenecode import models as result_models
1297
+
1298
+ return frozenset(
1299
+ name
1300
+ for name in dir(result_models)
1301
+ if not name.startswith("_")
1302
+ )
1303
+
1304
+
1305
+ @icontract.require(
1306
+ lambda value: isinstance(value, object),
1307
+ "value must be a Python object",
1308
+ )
1309
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1310
+ def _is_result_model_module(value: object) -> bool:
1311
+ """Check whether a runtime value is the serenecode.models module."""
1312
+ return isinstance(value, types.ModuleType) and value.__name__ == "serenecode.models"
1313
+
1314
+
1315
+ @icontract.require(
1316
+ lambda value: isinstance(value, object),
1317
+ "value must be a Python object",
1318
+ )
1319
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1320
+ def _is_result_model_object(value: object) -> bool:
1321
+ """Check whether a runtime value comes from serenecode.models."""
1322
+ return getattr(value, "__module__", "") == "serenecode.models"
1323
+
1324
+
1325
+ @icontract.require(
1326
+ lambda module_path: is_non_empty_string(module_path),
1327
+ "module_path must be a non-empty string",
1328
+ )
1329
+ @icontract.require(
1330
+ lambda search_paths: isinstance(search_paths, tuple),
1331
+ "search_paths must be a tuple",
1332
+ )
1333
+ @icontract.ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "result must be a 2-tuple")
1334
+ def _get_contracted_functions(
1335
+ module_path: str,
1336
+ search_paths: tuple[str, ...] = (),
1337
+ ) -> tuple[list[tuple[str, Callable[..., object]]], list[str]]:
1338
+ """Import a module and find all functions with icontract decorators.
1339
+
1340
+ Args:
1341
+ module_path: Importable Python module path.
1342
+
1343
+ Returns:
1344
+ A tuple of (testable_functions, excluded_function_names).
1345
+ """
1346
+ try:
1347
+ module = load_python_module(module_path, search_paths)
1348
+ except ImportError as exc:
1349
+ raise ToolNotInstalledError(
1350
+ f"Cannot import module '{module_path}': {exc}"
1351
+ ) from exc
1352
+
1353
+ functions: list[tuple[str, Callable[..., object]]] = []
1354
+ excluded: list[str] = []
1355
+
1356
+ # Loop invariant: functions + excluded accounts for all contracted public functions in dir(module)[0..i]
1357
+ for name in dir(module):
1358
+ if name.startswith("_"):
1359
+ continue
1360
+ obj = getattr(module, name)
1361
+ if callable(obj) and inspect.isfunction(obj):
1362
+ if getattr(obj, "__module__", None) != module.__name__:
1363
+ continue
1364
+ if not _has_icontract_decorators(obj):
1365
+ continue
1366
+ if _is_property_friendly_function(obj):
1367
+ functions.append((name, obj))
1368
+ else:
1369
+ excluded.append(name)
1370
+
1371
+ return (functions, excluded)
1372
+
1373
+
1374
+ @icontract.invariant(
1375
+ lambda self: is_positive_int(self._max_examples),
1376
+ "max_examples must remain positive",
1377
+ )
1378
+ class HypothesisPropertyTester:
1379
+ """Property tester implementation using Hypothesis.
1380
+
1381
+ Runs Hypothesis tests against functions with icontract decorators,
1382
+ using icontract's runtime contract checking to detect violations.
1383
+ """
1384
+
1385
+ @icontract.require(
1386
+ lambda max_examples: is_positive_int(max_examples),
1387
+ "max_examples must be positive",
1388
+ )
1389
+ @icontract.ensure(lambda result: result is None, "initialization returns None")
1390
+ def __init__(
1391
+ self,
1392
+ max_examples: int = 100,
1393
+ allow_code_execution: bool = False,
1394
+ ) -> None:
1395
+ """Initialize the tester.
1396
+
1397
+ Args:
1398
+ max_examples: Default maximum examples per function.
1399
+ """
1400
+ self._max_examples = max_examples
1401
+ self._allow_code_execution = allow_code_execution
1402
+
1403
+ @icontract.require(
1404
+ lambda module_path: is_non_empty_string(module_path),
1405
+ "module_path must be a non-empty string",
1406
+ )
1407
+ @icontract.require(
1408
+ lambda max_examples: max_examples is None or is_positive_int(max_examples),
1409
+ "max_examples must be positive when provided",
1410
+ )
1411
+ @icontract.require(
1412
+ lambda search_paths: isinstance(search_paths, tuple),
1413
+ "search_paths must be a tuple",
1414
+ )
1415
+ @icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
1416
+ def test_module(
1417
+ self,
1418
+ module_path: str,
1419
+ max_examples: int | None = None,
1420
+ search_paths: tuple[str, ...] = (),
1421
+ ) -> list[PropertyFinding]:
1422
+ """Run property-based tests on all contracted functions in a module.
1423
+
1424
+ Args:
1425
+ module_path: Importable Python module path to test.
1426
+ max_examples: Maximum number of test examples per function.
1427
+
1428
+ Returns:
1429
+ List of property findings.
1430
+ """
1431
+ if not _HYPOTHESIS_AVAILABLE:
1432
+ raise ToolNotInstalledError(
1433
+ "Hypothesis is not installed. Install with: pip install hypothesis"
1434
+ )
1435
+ if not self._allow_code_execution:
1436
+ raise UnsafeCodeExecutionError(_TRUST_REQUIRED_MESSAGE)
1437
+
1438
+ effective_max = self._max_examples if max_examples is None else max_examples
1439
+ functions, excluded = _get_contracted_functions(module_path, search_paths)
1440
+ findings: list[PropertyFinding] = []
1441
+
1442
+ # Report excluded functions so they are visible in the output
1443
+ # Loop invariant: findings contains exclusion records for excluded[0..i]
1444
+ for excluded_name in excluded:
1445
+ findings.append(PropertyFinding(
1446
+ function_name=excluded_name,
1447
+ module_path=module_path,
1448
+ passed=True,
1449
+ finding_type="excluded",
1450
+ message=f"Function '{excluded_name}' excluded from property testing (adapter/composition-root code)",
1451
+ ))
1452
+
1453
+ # Loop invariant: findings contains test results for functions[0..i]
1454
+ for func_name, func in functions:
1455
+ finding = self._test_single_function(func_name, func, module_path, effective_max)
1456
+ findings.append(finding)
1457
+
1458
+ return findings
1459
+
1460
+ @icontract.require(
1461
+ lambda func_name: is_non_empty_string(func_name),
1462
+ "func_name must be a non-empty string",
1463
+ )
1464
+ @icontract.require(lambda func: callable(func), "func must be callable")
1465
+ @icontract.require(
1466
+ lambda module_path: is_non_empty_string(module_path),
1467
+ "module_path must be a non-empty string",
1468
+ )
1469
+ @icontract.require(
1470
+ lambda max_examples: is_positive_int(max_examples),
1471
+ "max_examples must be positive",
1472
+ )
1473
+ @icontract.ensure(
1474
+ lambda result: isinstance(result, PropertyFinding),
1475
+ "result must be a PropertyFinding",
1476
+ )
1477
+ def _test_single_function(
1478
+ self,
1479
+ func_name: str,
1480
+ func: Callable[..., object],
1481
+ module_path: str,
1482
+ max_examples: int,
1483
+ ) -> PropertyFinding:
1484
+ """Test a single function with Hypothesis.
1485
+
1486
+ Args:
1487
+ func_name: Name of the function.
1488
+ func: The function to test.
1489
+ module_path: Module path for reporting.
1490
+ max_examples: Max examples to generate.
1491
+
1492
+ Returns:
1493
+ A PropertyFinding for this function.
1494
+ """
1495
+ strategies = _build_strategies_from_signature(func)
1496
+
1497
+ if strategies is None:
1498
+ return PropertyFinding(
1499
+ function_name=func_name,
1500
+ module_path=module_path,
1501
+ passed=True,
1502
+ finding_type="skipped",
1503
+ message=f"Cannot derive strategies for '{func_name}' — unsupported parameter types",
1504
+ )
1505
+
1506
+ try:
1507
+ self._run_hypothesis_test(func, strategies, max_examples)
1508
+ return PropertyFinding(
1509
+ function_name=func_name,
1510
+ module_path=module_path,
1511
+ passed=True,
1512
+ finding_type="verified",
1513
+ message=f"Property tests passed for '{func_name}' ({max_examples} examples)",
1514
+ )
1515
+ except icontract.ViolationError as exc:
1516
+ # Distinguish precondition vs postcondition violations
1517
+ error_str = str(exc)
1518
+ if "Precondition" in error_str:
1519
+ # Precondition violation means the input doesn't satisfy the
1520
+ # contract — this is expected and not a real failure.
1521
+ # But if it happens consistently, it means strategies are bad.
1522
+ return PropertyFinding(
1523
+ function_name=func_name,
1524
+ module_path=module_path,
1525
+ passed=True,
1526
+ finding_type="skipped",
1527
+ message=(
1528
+ f"Cannot generate valid inputs for '{func_name}' — "
1529
+ "preconditions too restrictive for derived strategies"
1530
+ ),
1531
+ )
1532
+ else:
1533
+ # Postcondition violation — real failure
1534
+ counterexample = _extract_counterexample(exc)
1535
+ condition = _extract_violated_condition(error_str)
1536
+ inputs_str = (
1537
+ ", ".join(f"{k}={v}" for k, v in counterexample.items())
1538
+ if counterexample
1539
+ else "unknown"
1540
+ )
1541
+ message = (
1542
+ f"Postcondition violated for '{func_name}': "
1543
+ f"condition '{condition}' failed with inputs: {inputs_str}"
1544
+ if condition
1545
+ else f"Postcondition violated for '{func_name}': {error_str}"
1546
+ )
1547
+ return PropertyFinding(
1548
+ function_name=func_name,
1549
+ module_path=module_path,
1550
+ passed=False,
1551
+ finding_type="postcondition_violated",
1552
+ message=message,
1553
+ counterexample=counterexample,
1554
+ )
1555
+ except Exception as exc:
1556
+ # Check if a ViolationError is nested inside (Hypothesis wraps exceptions)
1557
+ violation = _find_nested_violation(exc)
1558
+ if violation is not None:
1559
+ counterexample = _extract_counterexample(violation)
1560
+ violation_str = str(violation)
1561
+ condition = _extract_violated_condition(violation_str)
1562
+ inputs_str = (
1563
+ ", ".join(f"{k}={v}" for k, v in counterexample.items())
1564
+ if counterexample
1565
+ else "unknown"
1566
+ )
1567
+ message = (
1568
+ f"Postcondition violated for '{func_name}': "
1569
+ f"condition '{condition}' failed with inputs: {inputs_str}"
1570
+ if condition
1571
+ else f"Postcondition violated for '{func_name}': {violation_str}"
1572
+ )
1573
+ return PropertyFinding(
1574
+ function_name=func_name,
1575
+ module_path=module_path,
1576
+ passed=False,
1577
+ finding_type="postcondition_violated",
1578
+ message=message,
1579
+ counterexample=counterexample,
1580
+ )
1581
+ return PropertyFinding(
1582
+ function_name=func_name,
1583
+ module_path=module_path,
1584
+ passed=False,
1585
+ finding_type="crash",
1586
+ message=f"Function '{func_name}' crashed during testing: {exc}",
1587
+ exception_type=type(exc).__name__,
1588
+ exception_message=str(exc),
1589
+ )
1590
+
1591
+ @icontract.require(lambda func: callable(func), "func must be callable")
1592
+ @icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
1593
+ @icontract.require(
1594
+ lambda max_examples: is_positive_int(max_examples),
1595
+ "max_examples must be positive",
1596
+ )
1597
+ @icontract.ensure(lambda result: result is None, "test execution returns None")
1598
+ def _run_hypothesis_test(
1599
+ self,
1600
+ func: Callable[..., object],
1601
+ strategies: dict[str, SearchStrategy],
1602
+ max_examples: int,
1603
+ ) -> None:
1604
+ """Run a Hypothesis test on a function.
1605
+
1606
+ Args:
1607
+ func: The function to test.
1608
+ strategies: Mapping of parameter names to strategies.
1609
+ max_examples: Max examples to generate.
1610
+
1611
+ Raises:
1612
+ Any exception raised by the function or its contracts.
1613
+ """
1614
+ if not strategies:
1615
+ func()
1616
+ return
1617
+
1618
+ test_settings = settings(
1619
+ max_examples=max_examples,
1620
+ deadline=None,
1621
+ suppress_health_check=[
1622
+ HealthCheck.too_slow,
1623
+ HealthCheck.filter_too_much,
1624
+ ],
1625
+ verbosity=Verbosity.quiet,
1626
+ database=None,
1627
+ )
1628
+
1629
+ @test_settings
1630
+ @given(**strategies)
1631
+ def test_wrapper(**kwargs: object) -> None:
1632
+ """Wrapper that calls the function with generated inputs."""
1633
+ # First check if inputs satisfy preconditions by testing them
1634
+ if not _check_preconditions(func, kwargs):
1635
+ from hypothesis import assume
1636
+ assume(False)
1637
+ return
1638
+
1639
+ try:
1640
+ func(**kwargs)
1641
+ except icontract.ViolationError:
1642
+ # If preconditions passed but ViolationError is raised,
1643
+ # it must be a postcondition or invariant violation
1644
+ raise
1645
+
1646
+ test_wrapper()
1647
+
1648
+
1649
+ @icontract.require(lambda exc: isinstance(exc, BaseException), "exc must be an exception")
1650
+ @icontract.ensure(
1651
+ lambda result: result is None or isinstance(result, icontract.ViolationError),
1652
+ "result must be a ViolationError or None",
1653
+ )
1654
+ def _find_nested_violation(exc: BaseException) -> icontract.ViolationError | None:
1655
+ """Search exception chain for an icontract ViolationError.
1656
+
1657
+ Hypothesis wraps exceptions in MultipleFailures or other wrapper
1658
+ types. This function walks the exception chain to find the
1659
+ underlying ViolationError.
1660
+
1661
+ Args:
1662
+ exc: The exception to search.
1663
+
1664
+ Returns:
1665
+ The ViolationError if found, None otherwise.
1666
+ """
1667
+ # Check the exception itself
1668
+ if isinstance(exc, icontract.ViolationError):
1669
+ return exc
1670
+
1671
+ # Check __cause__ and __context__
1672
+ # Variant: depth of exception chain decreases
1673
+ seen: set[int] = set()
1674
+ current: BaseException | None = exc
1675
+ # Loop invariant: seen contains every exception object already traversed in the chain
1676
+ while current is not None and id(current) not in seen:
1677
+ seen.add(id(current))
1678
+ if isinstance(current, icontract.ViolationError):
1679
+ return current
1680
+ # Also check sub-exceptions (Hypothesis MultipleFailures)
1681
+ sub_exceptions = getattr(current, "exceptions", None)
1682
+ if sub_exceptions:
1683
+ # Loop invariant: checked sub_exceptions[0..i]
1684
+ for sub in sub_exceptions:
1685
+ if isinstance(sub, icontract.ViolationError):
1686
+ return sub
1687
+ nested = _find_nested_violation(sub)
1688
+ if nested is not None:
1689
+ return nested
1690
+ current = current.__cause__ or current.__context__
1691
+
1692
+ return None
1693
+
1694
+
1695
+ @icontract.require(lambda func: callable(func), "func must be callable")
1696
+ @icontract.require(lambda kwargs: isinstance(kwargs, dict), "kwargs must be a dict")
1697
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1698
+ def _check_preconditions(
1699
+ func: Callable[..., object],
1700
+ kwargs: dict[str, object],
1701
+ ) -> bool:
1702
+ """Check if inputs satisfy a function's icontract preconditions.
1703
+
1704
+ Evaluates each precondition lambda without calling the full function.
1705
+
1706
+ Args:
1707
+ func: The contracted function.
1708
+ kwargs: The keyword arguments to check.
1709
+
1710
+ Returns:
1711
+ True if all preconditions are satisfied.
1712
+ """
1713
+ preconditions = getattr(func, "__preconditions__", None)
1714
+ if not preconditions:
1715
+ return True
1716
+
1717
+ # Loop invariant: all precondition contracts in preconditions[0..i] are satisfied
1718
+ for group in preconditions:
1719
+ # Loop invariant: every contract in group[0..j] is satisfied
1720
+ for contract in group:
1721
+ condition = contract.condition
1722
+ try:
1723
+ # Get the parameters the condition expects
1724
+ import inspect
1725
+ sig = inspect.signature(condition)
1726
+ parameter_names = [
1727
+ name
1728
+ for name in sig.parameters
1729
+ if name not in ("self", "cls", "result")
1730
+ ]
1731
+ if not parameter_names:
1732
+ continue
1733
+ if any(name not in kwargs for name in parameter_names):
1734
+ continue
1735
+ condition_kwargs = {
1736
+ name: kwargs[name]
1737
+ for name in parameter_names
1738
+ }
1739
+ if not condition(**condition_kwargs):
1740
+ return False
1741
+ except Exception:
1742
+ return False
1743
+
1744
+ return True
1745
+
1746
+
1747
+ @icontract.require(
1748
+ lambda exc: isinstance(exc, icontract.ViolationError),
1749
+ "exc must be an icontract violation",
1750
+ )
1751
+ @icontract.ensure(
1752
+ lambda result: result is None or isinstance(result, dict),
1753
+ "result must be a dictionary or None",
1754
+ )
1755
+ @icontract.require(
1756
+ lambda error_str: isinstance(error_str, str),
1757
+ "error_str must be a string",
1758
+ )
1759
+ @icontract.ensure(
1760
+ lambda result: result is None or isinstance(result, str),
1761
+ "result must be a string or None",
1762
+ )
1763
+ def _extract_violated_condition(error_str: str) -> str | None:
1764
+ """Extract the violated condition text from an icontract error message.
1765
+
1766
+ icontract formats the second line as "description: condition_expression:".
1767
+
1768
+ Args:
1769
+ error_str: The full icontract error string.
1770
+
1771
+ Returns:
1772
+ The condition expression, or None.
1773
+ """
1774
+ lines = error_str.split("\n")
1775
+ if len(lines) < 2:
1776
+ return None
1777
+ # Second line is typically "description: condition:"
1778
+ condition_line = lines[1].strip()
1779
+ if ": " in condition_line:
1780
+ # Extract everything after the description
1781
+ _, _, condition_part = condition_line.partition(": ")
1782
+ # Remove trailing colon
1783
+ return condition_part.rstrip(":")
1784
+ return None
1785
+
1786
+
1787
+ @icontract.require(
1788
+ lambda exc: isinstance(exc, icontract.ViolationError),
1789
+ "exc must be an icontract violation",
1790
+ )
1791
+ @icontract.ensure(
1792
+ lambda result: result is None or isinstance(result, dict),
1793
+ "result must be a dictionary or None",
1794
+ )
1795
+ def _extract_counterexample(exc: icontract.ViolationError) -> dict[str, object] | None:
1796
+ """Extract counterexample data from an icontract violation.
1797
+
1798
+ icontract formats variable values as "<name> was <value>" lines in the
1799
+ error message. This function parses those lines into a dict.
1800
+
1801
+ Args:
1802
+ exc: The violation error to extract from.
1803
+
1804
+ Returns:
1805
+ A dict mapping argument names to their values, or None.
1806
+ """
1807
+ error_str = str(exc)
1808
+ try:
1809
+ lines = error_str.split("\n")
1810
+ counterexample: dict[str, object] = {}
1811
+ # Loop invariant: counterexample contains parsed "X was Y" bindings from lines[0..i]
1812
+ for line in lines:
1813
+ stripped = line.strip()
1814
+ # icontract uses "<name> was <value>" format for variable bindings
1815
+ if " was " in stripped:
1816
+ name, _, value = stripped.partition(" was ")
1817
+ name = name.strip()
1818
+ value = value.strip()
1819
+ # Skip the condition description line (contains ":")
1820
+ if name and ":" not in name and not name.startswith("File "):
1821
+ counterexample[name] = value
1822
+ return counterexample if counterexample else None
1823
+ except Exception:
1824
+ return None