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,807 @@
1
+ """Verification pipeline orchestrator for Serenecode.
2
+
3
+ This module orchestrates the sequential execution of verification
4
+ levels (1→2→3→4→5→6), handling early termination, result merging,
5
+ and level selection.
6
+
7
+ This is a core module — no I/O operations are permitted. All
8
+ verification backends are injected through protocol interfaces.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+
17
+ import icontract
18
+
19
+ from serenecode.config import SerenecodeConfig
20
+ from serenecode.contracts.predicates import is_non_empty_string, is_positive_int, is_valid_verification_level
21
+ from serenecode.core.exceptions import ToolNotInstalledError
22
+ from serenecode.ports.coverage_analyzer import CoverageAnalyzer
23
+ from serenecode.ports.property_tester import PropertyTester
24
+ from serenecode.ports.symbolic_checker import SymbolicChecker, SymbolicFinding
25
+ from serenecode.ports.type_checker import TypeChecker
26
+ from serenecode.models import (
27
+ CheckResult,
28
+ CheckStatus,
29
+ Detail,
30
+ FunctionResult,
31
+ VerificationLevel,
32
+ make_check_result,
33
+ )
34
+
35
+ StructuralChecker = Callable[[str, SerenecodeConfig, str, str], CheckResult]
36
+
37
+
38
+ @icontract.invariant(
39
+ lambda self: len(self.file_path) > 0,
40
+ "Source file must have a non-empty file path",
41
+ )
42
+ @dataclass(frozen=True)
43
+ class SourceFile:
44
+ """A source file to be verified.
45
+
46
+ Contains the file path, derived module path, and source content.
47
+ """
48
+
49
+ file_path: str
50
+ module_path: str
51
+ source: str
52
+ importable_module: str | None = None # e.g. "tests.fixtures.valid.simple_function"
53
+ import_search_paths: tuple[str, ...] = ()
54
+
55
+
56
+ @icontract.require(
57
+ lambda level: is_valid_verification_level(level),
58
+ "level must be between 1 and 6",
59
+ )
60
+ @icontract.require(
61
+ lambda start_level: is_valid_verification_level(start_level),
62
+ "start_level must be between 1 and 6",
63
+ )
64
+ @icontract.require(
65
+ lambda level, start_level: start_level <= level,
66
+ "start_level must not exceed level",
67
+ )
68
+ @icontract.require(
69
+ lambda max_workers: is_positive_int(max_workers),
70
+ "max_workers must be at least 1",
71
+ )
72
+ @icontract.ensure(
73
+ lambda result: isinstance(result, CheckResult),
74
+ "result must be a CheckResult",
75
+ )
76
+ def run_pipeline(
77
+ source_files: tuple[SourceFile, ...],
78
+ level: int,
79
+ start_level: int,
80
+ config: SerenecodeConfig,
81
+ structural_checker: StructuralChecker | None = None,
82
+ type_checker: TypeChecker | None = None,
83
+ coverage_analyzer: CoverageAnalyzer | None = None,
84
+ property_tester: PropertyTester | None = None,
85
+ symbolic_checker: SymbolicChecker | None = None,
86
+ early_termination: bool = True,
87
+ progress: Callable[[str], None] | None = None,
88
+ max_workers: int = 4,
89
+ ) -> CheckResult:
90
+ """Run the verification pipeline up to the specified level.
91
+
92
+ Executes levels sequentially (1→2→3→4→5→6). If early_termination
93
+ is True (default), stops at the first level with failures.
94
+
95
+ Args:
96
+ source_files: Tuple of source files to verify.
97
+ level: Maximum verification level (1-6).
98
+ start_level: First verification level to execute.
99
+ config: Active Serenecode configuration.
100
+ structural_checker: Callable for Level 1 (or None to use default).
101
+ type_checker: TypeChecker protocol implementation for Level 2.
102
+ coverage_analyzer: CoverageAnalyzer protocol implementation for Level 3.
103
+ property_tester: PropertyTester protocol implementation for Level 4.
104
+ symbolic_checker: SymbolicChecker protocol implementation for Level 5.
105
+ early_termination: Stop at first failing level if True.
106
+ progress: Optional callback for progress messages.
107
+ max_workers: Max concurrent modules for Level 5 symbolic verification.
108
+
109
+ Returns:
110
+ An aggregated CheckResult across all executed levels.
111
+ """
112
+ start_time = time.monotonic()
113
+ all_results: list[FunctionResult] = []
114
+ achieved_level = start_level - 1
115
+ has_source_files = len(source_files) > 0
116
+
117
+ # Cap max_workers to a reasonable limit to avoid resource exhaustion
118
+ max_workers = min(max_workers, 32)
119
+
120
+ def _emit(msg: str) -> None:
121
+ if progress is not None:
122
+ try:
123
+ progress(msg)
124
+ except Exception:
125
+ pass # Never let progress callback failures abort the pipeline
126
+
127
+ # Level 1: Structural check
128
+ if start_level <= 1 <= level:
129
+ _emit(f"Level 1: Structural check ({len(source_files)} files)...")
130
+ level_1_results = _run_level_1(source_files, config, structural_checker)
131
+ all_results.extend(level_1_results)
132
+
133
+ if early_termination and _has_failures(level_1_results):
134
+ elapsed = time.monotonic() - start_time
135
+ return make_check_result(
136
+ tuple(all_results),
137
+ level_requested=level,
138
+ duration_seconds=elapsed,
139
+ level_achieved=achieved_level,
140
+ )
141
+ if _level_achieved(level_1_results, has_source_files):
142
+ achieved_level = 1
143
+
144
+ # Level 2: Type checking
145
+ if start_level <= 2 <= level:
146
+ if type_checker is not None:
147
+ _emit("Level 2: Type checking...")
148
+ level_2_results = _run_level_2(source_files, type_checker)
149
+ else:
150
+ _emit("Level 2: Type checking unavailable.")
151
+ level_2_results = _make_unavailable_results(
152
+ source_files,
153
+ requested_level=2,
154
+ level_achieved=1,
155
+ tool="mypy",
156
+ message="Type checking unavailable: mypy is not installed",
157
+ )
158
+ all_results.extend(level_2_results)
159
+
160
+ if early_termination and _has_failures(level_2_results):
161
+ elapsed = time.monotonic() - start_time
162
+ return make_check_result(
163
+ tuple(all_results),
164
+ level_requested=level,
165
+ duration_seconds=elapsed,
166
+ level_achieved=achieved_level,
167
+ )
168
+ if _level_achieved(level_2_results, has_source_files):
169
+ achieved_level = 2
170
+
171
+ # Level 3: Coverage analysis
172
+ if start_level <= 3 <= level:
173
+ if coverage_analyzer is not None:
174
+ _emit("Level 3: Coverage analysis...")
175
+ level_3_results = _run_level_3_coverage(source_files, coverage_analyzer)
176
+ else:
177
+ _emit("Level 3: Coverage analysis unavailable.")
178
+ level_3_results = _make_unavailable_results(
179
+ source_files,
180
+ requested_level=3,
181
+ level_achieved=2,
182
+ tool="coverage",
183
+ message="Coverage analysis unavailable: coverage is not installed",
184
+ )
185
+ all_results.extend(level_3_results)
186
+
187
+ if early_termination and _has_failures(level_3_results):
188
+ elapsed = time.monotonic() - start_time
189
+ return make_check_result(
190
+ tuple(all_results),
191
+ level_requested=level,
192
+ duration_seconds=elapsed,
193
+ level_achieved=achieved_level,
194
+ )
195
+ if _level_achieved(level_3_results, has_source_files, require_evidence=True):
196
+ achieved_level = 3
197
+
198
+ # Level 4: Property-based testing
199
+ if start_level <= 4 <= level:
200
+ if property_tester is not None:
201
+ _emit("Level 4: Property-based testing...")
202
+ level_4_results = _run_level_4(source_files, property_tester)
203
+ else:
204
+ _emit("Level 4: Property-based testing unavailable.")
205
+ level_4_results = _make_unavailable_results(
206
+ source_files,
207
+ requested_level=4,
208
+ level_achieved=3,
209
+ tool="hypothesis",
210
+ message="Property testing unavailable: Hypothesis is not installed",
211
+ )
212
+ all_results.extend(level_4_results)
213
+
214
+ if early_termination and _has_failures(level_4_results):
215
+ elapsed = time.monotonic() - start_time
216
+ return make_check_result(
217
+ tuple(all_results),
218
+ level_requested=level,
219
+ duration_seconds=elapsed,
220
+ level_achieved=achieved_level,
221
+ )
222
+ if _level_achieved(level_4_results, has_source_files, require_evidence=True):
223
+ achieved_level = 4
224
+
225
+ # Level 5: Symbolic verification
226
+ if start_level <= 5 <= level:
227
+ if symbolic_checker is not None:
228
+ _emit("Level 5: Symbolic verification (this may take several minutes)...")
229
+ level_5_results = _run_level_5(source_files, symbolic_checker, _emit, max_workers)
230
+ else:
231
+ _emit("Level 5: Symbolic verification unavailable.")
232
+ level_5_results = _make_unavailable_results(
233
+ source_files,
234
+ requested_level=5,
235
+ level_achieved=4,
236
+ tool="crosshair",
237
+ message="Symbolic verification unavailable: CrossHair is not installed",
238
+ )
239
+ all_results.extend(level_5_results)
240
+
241
+ if early_termination and _has_failures(level_5_results):
242
+ elapsed = time.monotonic() - start_time
243
+ return make_check_result(
244
+ tuple(all_results),
245
+ level_requested=level,
246
+ duration_seconds=elapsed,
247
+ level_achieved=achieved_level,
248
+ )
249
+ if _level_achieved(level_5_results, has_source_files, require_evidence=True):
250
+ achieved_level = 5
251
+
252
+ # Level 6: Compositional verification
253
+ if start_level <= 6 <= level:
254
+ _emit("Level 6: Compositional verification...")
255
+ level_6_results = _run_level_6(source_files, config)
256
+ all_results.extend(level_6_results)
257
+ if _level_achieved(level_6_results, has_source_files):
258
+ achieved_level = 6
259
+
260
+ elapsed = time.monotonic() - start_time
261
+ return make_check_result(
262
+ tuple(all_results),
263
+ level_requested=level,
264
+ duration_seconds=elapsed,
265
+ level_achieved=achieved_level,
266
+ )
267
+
268
+
269
+ @icontract.require(
270
+ lambda results: isinstance(results, list),
271
+ "results must be a list",
272
+ )
273
+ @icontract.ensure(
274
+ lambda result: isinstance(result, bool),
275
+ "result must be a bool",
276
+ )
277
+ def _has_failures(results: list[FunctionResult]) -> bool:
278
+ """Check if any results indicate failure.
279
+
280
+ Args:
281
+ results: List of function results to check.
282
+
283
+ Returns:
284
+ True if any result has FAILED status.
285
+ """
286
+ # Loop invariant: result is True if any of results[0..i] has FAILED status
287
+ for r in results:
288
+ if r.status == CheckStatus.FAILED:
289
+ return True
290
+ return False
291
+
292
+
293
+ @icontract.require(
294
+ lambda results: isinstance(results, list),
295
+ "results must be a list",
296
+ )
297
+ @icontract.ensure(
298
+ lambda result: isinstance(result, bool),
299
+ "result must be a bool",
300
+ )
301
+ def _has_skips(results: list[FunctionResult]) -> bool:
302
+ """Check if any results indicate an incomplete verification step."""
303
+ # Loop invariant: result is True if any of results[0..i] has SKIPPED status
304
+ for r in results:
305
+ if r.status == CheckStatus.SKIPPED:
306
+ return True
307
+ return False
308
+
309
+
310
+ @icontract.require(
311
+ lambda results: isinstance(results, list),
312
+ "results must be a list",
313
+ )
314
+ @icontract.ensure(
315
+ lambda result: isinstance(result, bool),
316
+ "result must be a bool",
317
+ )
318
+ def _level_achieved(
319
+ results: list[FunctionResult],
320
+ has_source_files: bool,
321
+ require_evidence: bool = False,
322
+ ) -> bool:
323
+ """Check if a verification level was achieved.
324
+
325
+ Args:
326
+ results: Level results to evaluate.
327
+ has_source_files: Whether the pipeline had any source files.
328
+ require_evidence: If True, empty results with source files
329
+ means the level is NOT achieved (used for L3/L4/L5 where
330
+ empty results means no functions were exercised). If False,
331
+ empty results means "no issues found" which counts as a pass
332
+ (used for L1/L2/L6 where the checker examines all files).
333
+
334
+ Returns:
335
+ True if the level should be considered achieved.
336
+ """
337
+ if _has_failures(results) or _has_skips(results):
338
+ return False
339
+ if not has_source_files:
340
+ return True
341
+ if require_evidence and not results:
342
+ return False
343
+ return True
344
+
345
+
346
+ @icontract.require(
347
+ lambda source_files: isinstance(source_files, tuple),
348
+ "source_files must be a tuple",
349
+ )
350
+ @icontract.require(
351
+ lambda requested_level: requested_level in (2, 3, 4, 5),
352
+ "requested_level must be a backend verification level",
353
+ )
354
+ @icontract.require(
355
+ lambda level_achieved: 0 <= level_achieved <= 5,
356
+ "level_achieved must be within the completed pipeline range",
357
+ )
358
+ @icontract.require(
359
+ lambda tool: is_non_empty_string(tool),
360
+ "tool must be a non-empty string",
361
+ )
362
+ @icontract.require(
363
+ lambda message: is_non_empty_string(message),
364
+ "message must be a non-empty string",
365
+ )
366
+ @icontract.ensure(
367
+ lambda result: isinstance(result, list),
368
+ "result must be a list",
369
+ )
370
+ def _make_unavailable_results(
371
+ source_files: tuple[SourceFile, ...],
372
+ requested_level: int,
373
+ level_achieved: int,
374
+ tool: str,
375
+ message: str,
376
+ ) -> list[FunctionResult]:
377
+ """Create per-file skipped results when a verification backend is unavailable."""
378
+ level_map = {
379
+ 2: VerificationLevel.TYPES,
380
+ 3: VerificationLevel.COVERAGE,
381
+ 4: VerificationLevel.PROPERTIES,
382
+ 5: VerificationLevel.SYMBOLIC,
383
+ }
384
+ verification_level = level_map[requested_level]
385
+ results: list[FunctionResult] = []
386
+
387
+ # Loop invariant: results contains unavailable-backend findings for source_files[0..i]
388
+ for sf in source_files:
389
+ results.append(FunctionResult(
390
+ function="<module>",
391
+ file=sf.file_path,
392
+ line=1,
393
+ level_requested=requested_level,
394
+ level_achieved=level_achieved,
395
+ status=CheckStatus.SKIPPED,
396
+ details=(Detail(
397
+ level=verification_level,
398
+ tool=tool,
399
+ finding_type="unavailable",
400
+ message=message,
401
+ ),),
402
+ ))
403
+
404
+ return results
405
+
406
+
407
+ @icontract.require(
408
+ lambda source_files: isinstance(source_files, tuple),
409
+ "source_files must be a tuple",
410
+ )
411
+ @icontract.require(
412
+ lambda config: isinstance(config, SerenecodeConfig),
413
+ "config must be a SerenecodeConfig",
414
+ )
415
+ @icontract.ensure(
416
+ lambda result: isinstance(result, list),
417
+ "result must be a list",
418
+ )
419
+ def _run_level_1(
420
+ source_files: tuple[SourceFile, ...],
421
+ config: SerenecodeConfig,
422
+ structural_checker: StructuralChecker | None = None,
423
+ ) -> list[FunctionResult]:
424
+ """Run Level 1 structural checks on all source files.
425
+
426
+ Args:
427
+ source_files: Files to check.
428
+ config: Active configuration.
429
+
430
+ Returns:
431
+ List of function results from structural checking.
432
+ """
433
+ checker = structural_checker
434
+ if checker is None:
435
+ from serenecode.checker.structural import check_structural
436
+
437
+ checker = check_structural
438
+
439
+ results: list[FunctionResult] = []
440
+ # Loop invariant: results contains structural check results for source_files[0..i]
441
+ for sf in source_files:
442
+ check_result = checker(
443
+ sf.source, config, sf.module_path, sf.file_path,
444
+ )
445
+ results.extend(check_result.results)
446
+ return results
447
+
448
+
449
+ @icontract.require(
450
+ lambda source_files: isinstance(source_files, tuple),
451
+ "source_files must be a tuple",
452
+ )
453
+ @icontract.require(
454
+ lambda type_checker: type_checker is not None,
455
+ "type_checker must be provided",
456
+ )
457
+ @icontract.ensure(
458
+ lambda result: isinstance(result, list),
459
+ "result must be a list",
460
+ )
461
+ def _run_level_2(
462
+ source_files: tuple[SourceFile, ...],
463
+ type_checker: TypeChecker,
464
+ ) -> list[FunctionResult]:
465
+ """Run Level 2 type checking on source files.
466
+
467
+ Args:
468
+ source_files: Files to check.
469
+ type_checker: TypeChecker protocol implementation.
470
+
471
+ Returns:
472
+ List of function results from type checking.
473
+ """
474
+ from serenecode.checker.types import transform_type_results
475
+ from serenecode.ports.type_checker import TypeIssue
476
+
477
+ file_paths = [sf.file_path for sf in source_files]
478
+ search_paths = _collect_type_check_search_paths(source_files)
479
+ issues: list[TypeIssue] = type_checker.check(
480
+ file_paths,
481
+ search_paths=search_paths,
482
+ )
483
+ return list(transform_type_results(issues, 0.0).results)
484
+
485
+
486
+ @icontract.require(
487
+ lambda source_files: isinstance(source_files, tuple),
488
+ "source_files must be a tuple",
489
+ )
490
+ @icontract.ensure(
491
+ lambda result: isinstance(result, tuple),
492
+ "result must be a tuple",
493
+ )
494
+ def _collect_type_check_search_paths(
495
+ source_files: tuple[SourceFile, ...],
496
+ ) -> tuple[str, ...]:
497
+ """Collect unique import roots needed for static type checking."""
498
+ search_paths: list[str] = []
499
+
500
+ # Loop invariant: search_paths contains unique import roots from source_files[0..i]
501
+ for sf in source_files:
502
+ # Loop invariant: search_paths contains unique roots from sf.import_search_paths[0..j]
503
+ for path in sf.import_search_paths:
504
+ if path not in search_paths:
505
+ search_paths.append(path)
506
+
507
+ return tuple(search_paths)
508
+
509
+
510
+ @icontract.require(
511
+ lambda source_files: isinstance(source_files, tuple),
512
+ "source_files must be a tuple",
513
+ )
514
+ @icontract.require(
515
+ lambda coverage_analyzer: coverage_analyzer is not None,
516
+ "coverage_analyzer must be provided",
517
+ )
518
+ @icontract.ensure(
519
+ lambda result: isinstance(result, list),
520
+ "result must be a list",
521
+ )
522
+ def _run_level_3_coverage(
523
+ source_files: tuple[SourceFile, ...],
524
+ coverage_analyzer: CoverageAnalyzer,
525
+ ) -> list[FunctionResult]:
526
+ """Run Level 3 coverage analysis on source files.
527
+
528
+ Args:
529
+ source_files: Files to check.
530
+ coverage_analyzer: CoverageAnalyzer protocol implementation.
531
+
532
+ Returns:
533
+ List of function results from coverage analysis.
534
+ """
535
+ from serenecode.checker.coverage import transform_coverage_results
536
+
537
+ results: list[FunctionResult] = []
538
+ # Loop invariant: results contains coverage findings for source_files[0..i]
539
+ for sf in source_files:
540
+ if sf.importable_module is None:
541
+ results.append(FunctionResult(
542
+ function="<module>",
543
+ file=sf.file_path,
544
+ line=1,
545
+ level_requested=3,
546
+ level_achieved=2,
547
+ status=CheckStatus.SKIPPED,
548
+ details=(Detail(
549
+ level=VerificationLevel.COVERAGE,
550
+ tool="coverage",
551
+ finding_type="not_importable",
552
+ message=f"Module '{sf.file_path}' is not importable as a Python package",
553
+ ),),
554
+ ))
555
+ continue
556
+ try:
557
+ findings = coverage_analyzer.analyze_module(
558
+ sf.importable_module,
559
+ search_paths=sf.import_search_paths,
560
+ )
561
+ check_result = transform_coverage_results(findings, sf.file_path, 0.0)
562
+ results.extend(check_result.results)
563
+ except Exception as exc:
564
+ results.append(FunctionResult(
565
+ function="<module>",
566
+ file=sf.file_path,
567
+ line=1,
568
+ level_requested=3,
569
+ level_achieved=2,
570
+ status=CheckStatus.SKIPPED,
571
+ details=(Detail(
572
+ level=VerificationLevel.COVERAGE,
573
+ tool="coverage",
574
+ finding_type="unavailable" if isinstance(exc, ToolNotInstalledError) else "error",
575
+ message=f"Coverage analysis skipped for '{sf.importable_module}': {exc}",
576
+ ),),
577
+ ))
578
+ return results
579
+
580
+
581
+ @icontract.require(
582
+ lambda source_files: isinstance(source_files, tuple),
583
+ "source_files must be a tuple",
584
+ )
585
+ @icontract.require(
586
+ lambda property_tester: property_tester is not None,
587
+ "property_tester must be provided",
588
+ )
589
+ @icontract.ensure(
590
+ lambda result: isinstance(result, list),
591
+ "result must be a list",
592
+ )
593
+ def _run_level_4(
594
+ source_files: tuple[SourceFile, ...],
595
+ property_tester: PropertyTester,
596
+ ) -> list[FunctionResult]:
597
+ """Run Level 4 property-based testing on source files.
598
+
599
+ Args:
600
+ source_files: Files to check.
601
+ property_tester: PropertyTester protocol implementation.
602
+
603
+ Returns:
604
+ List of function results from property testing.
605
+ """
606
+ from serenecode.checker.properties import transform_property_results
607
+
608
+ results: list[FunctionResult] = []
609
+ # Loop invariant: results contains property test results for source_files[0..i]
610
+ for sf in source_files:
611
+ if sf.importable_module is None:
612
+ results.append(FunctionResult(
613
+ function="<module>",
614
+ file=sf.file_path,
615
+ line=1,
616
+ level_requested=4,
617
+ level_achieved=3,
618
+ status=CheckStatus.SKIPPED,
619
+ details=(Detail(
620
+ level=VerificationLevel.PROPERTIES,
621
+ tool="hypothesis",
622
+ finding_type="not_importable",
623
+ message=f"Module '{sf.file_path}' is not importable as a Python package",
624
+ ),),
625
+ ))
626
+ continue
627
+ try:
628
+ findings = property_tester.test_module(
629
+ sf.importable_module,
630
+ search_paths=sf.import_search_paths,
631
+ )
632
+ check_result = transform_property_results(findings, sf.file_path, 0.0)
633
+ results.extend(check_result.results)
634
+ except Exception as exc:
635
+ # Record the error as a skipped result rather than silently dropping it
636
+ results.append(FunctionResult(
637
+ function="<module>",
638
+ file=sf.file_path,
639
+ line=1,
640
+ level_requested=4,
641
+ level_achieved=3,
642
+ status=CheckStatus.SKIPPED,
643
+ details=(Detail(
644
+ level=VerificationLevel.PROPERTIES,
645
+ tool="hypothesis",
646
+ finding_type="unavailable" if isinstance(exc, ToolNotInstalledError) else "error",
647
+ message=f"Property testing skipped for '{sf.importable_module}': {exc}",
648
+ ),),
649
+ ))
650
+ return results
651
+
652
+
653
+ @icontract.require(
654
+ lambda source_files: isinstance(source_files, tuple),
655
+ "source_files must be a tuple",
656
+ )
657
+ @icontract.require(
658
+ lambda symbolic_checker: symbolic_checker is not None,
659
+ "symbolic_checker must be provided",
660
+ )
661
+ @icontract.require(
662
+ lambda emit: emit is not None,
663
+ "emit callback must be provided",
664
+ )
665
+ @icontract.require(
666
+ lambda max_workers: is_positive_int(max_workers),
667
+ "max_workers must be at least 1",
668
+ )
669
+ @icontract.ensure(
670
+ lambda result: isinstance(result, list),
671
+ "result must be a list",
672
+ )
673
+ def _run_level_5(
674
+ source_files: tuple[SourceFile, ...],
675
+ symbolic_checker: SymbolicChecker,
676
+ emit: Callable[[str], None] = lambda _msg: None,
677
+ max_workers: int = 4,
678
+ ) -> list[FunctionResult]:
679
+ """Run Level 5 symbolic verification on source files in parallel.
680
+
681
+ Each module is verified in its own subprocess (via the symbolic
682
+ checker adapter), and we dispatch multiple modules concurrently
683
+ using a thread pool.
684
+
685
+ Args:
686
+ source_files: Files to check.
687
+ symbolic_checker: SymbolicChecker protocol implementation.
688
+ emit: Callback for progress messages.
689
+ max_workers: Maximum number of modules to verify concurrently.
690
+
691
+ Returns:
692
+ List of function results from symbolic verification.
693
+ """
694
+ import concurrent.futures
695
+
696
+ from serenecode.checker.symbolic import transform_symbolic_results
697
+
698
+ verifiable: list[SourceFile] = []
699
+ results: list[FunctionResult] = []
700
+
701
+ # Record non-importable modules as skipped so they appear in the output
702
+ # Loop invariant: verifiable contains importable files from source_files[0..i]
703
+ for sf in source_files:
704
+ if sf.importable_module is not None:
705
+ verifiable.append(sf)
706
+ else:
707
+ results.append(FunctionResult(
708
+ function="<module>",
709
+ file=sf.file_path,
710
+ line=1,
711
+ level_requested=5,
712
+ level_achieved=4,
713
+ status=CheckStatus.SKIPPED,
714
+ details=(Detail(
715
+ level=VerificationLevel.SYMBOLIC,
716
+ tool="crosshair",
717
+ finding_type="not_importable",
718
+ message=f"Module '{sf.file_path}' is not importable as a Python package",
719
+ ),),
720
+ ))
721
+
722
+ total = len(verifiable)
723
+ completed = 0
724
+
725
+ def _verify_one(
726
+ sf: SourceFile,
727
+ ) -> tuple[SourceFile, list[SymbolicFinding] | None, Exception | None]:
728
+ if sf.importable_module is None:
729
+ return (sf, None, ToolNotInstalledError("No importable module"))
730
+ try:
731
+ findings = symbolic_checker.verify_module(
732
+ sf.importable_module,
733
+ search_paths=sf.import_search_paths,
734
+ )
735
+ return (sf, findings, None)
736
+ except Exception as exc:
737
+ return (sf, None, exc)
738
+
739
+ emit(f" Verifying {total} modules ({max_workers} workers)...")
740
+
741
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
742
+ futures = {executor.submit(_verify_one, sf): sf for sf in verifiable}
743
+ # Loop invariant: results contains findings for all completed futures
744
+ for future in concurrent.futures.as_completed(futures):
745
+ completed += 1
746
+ sf, findings, error = future.result()
747
+ module_name = sf.importable_module
748
+ if error is not None:
749
+ emit(f" [{completed}/{total}] Skipped {module_name}: {error}")
750
+ results.append(FunctionResult(
751
+ function="<module>",
752
+ file=sf.file_path,
753
+ line=1,
754
+ level_requested=5,
755
+ level_achieved=4,
756
+ status=CheckStatus.SKIPPED if isinstance(error, ToolNotInstalledError) else CheckStatus.FAILED,
757
+ details=(Detail(
758
+ level=VerificationLevel.SYMBOLIC,
759
+ tool="crosshair",
760
+ finding_type="unavailable" if isinstance(error, ToolNotInstalledError) else "error",
761
+ message=f"Symbolic verification skipped for '{module_name}': {error}"
762
+ if isinstance(error, ToolNotInstalledError)
763
+ else f"Symbolic verification failed for '{module_name}': {error}",
764
+ ),),
765
+ ))
766
+ elif findings is not None:
767
+ emit(f" [{completed}/{total}] Done {module_name}")
768
+ check_result = transform_symbolic_results(findings, sf.file_path, 0.0)
769
+ results.extend(check_result.results)
770
+
771
+ return results
772
+
773
+
774
+ @icontract.require(
775
+ lambda source_files: isinstance(source_files, tuple),
776
+ "source_files must be a tuple",
777
+ )
778
+ @icontract.require(
779
+ lambda config: isinstance(config, SerenecodeConfig),
780
+ "config must be a SerenecodeConfig",
781
+ )
782
+ @icontract.ensure(
783
+ lambda result: isinstance(result, list),
784
+ "result must be a list",
785
+ )
786
+ def _run_level_6(
787
+ source_files: tuple[SourceFile, ...],
788
+ config: SerenecodeConfig,
789
+ ) -> list[FunctionResult]:
790
+ """Run Level 6 compositional verification across all source files.
791
+
792
+ Args:
793
+ source_files: Files to check.
794
+ config: Active configuration.
795
+
796
+ Returns:
797
+ List of function results from compositional checking.
798
+ """
799
+ from serenecode.checker.compositional import check_compositional
800
+
801
+ sources: list[tuple[str, str, str]] = []
802
+ # Loop invariant: sources contains (source, file_path, module_path) for source_files[0..i]
803
+ for sf in source_files:
804
+ sources.append((sf.source, sf.file_path, sf.module_path))
805
+
806
+ result = check_compositional(sources, config)
807
+ return list(result.results)