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,2216 @@
1
+ """Compositional verification checker for Serenecode (Level 5).
2
+
3
+ This module implements Level 5 verification: module-level analysis that
4
+ checks component interactions, dependency direction, interface compliance,
5
+ and system-level properties across the entire codebase.
6
+
7
+ This is a core module — no I/O operations are permitted. Source code
8
+ is received as structured SourceFile objects with pre-read content.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ import icontract
18
+
19
+ from serenecode.checker.structural import (
20
+ IcontractNames,
21
+ get_decorator_name,
22
+ has_decorator,
23
+ resolve_icontract_aliases,
24
+ )
25
+ from serenecode.config import SerenecodeConfig, is_core_module, is_exempt_module
26
+ from serenecode.contracts.predicates import is_non_empty_string, is_valid_file_path_string
27
+ from serenecode.models import (
28
+ CheckResult,
29
+ CheckStatus,
30
+ Detail,
31
+ FunctionResult,
32
+ VerificationLevel,
33
+ make_check_result,
34
+ )
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Data structures for compositional analysis
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @icontract.invariant(
43
+ lambda self: len(self.name) > 0,
44
+ "Method name must be non-empty",
45
+ )
46
+ @icontract.invariant(
47
+ lambda self: 0 <= self.required_parameters <= len(self.parameters),
48
+ "required parameter count must fit within the signature",
49
+ )
50
+ @dataclass(frozen=True)
51
+ class MethodSignature:
52
+ """A method signature from a Protocol or class."""
53
+
54
+ name: str
55
+ parameters: tuple[str, ...] # parameter names (excluding self)
56
+ has_return_annotation: bool
57
+ required_parameters: int = -1
58
+ return_annotation: str | None = None
59
+
60
+ @icontract.ensure(lambda result: result is None, "post-init returns None")
61
+ def __post_init__(self) -> None:
62
+ """Fill in backwards-compatible defaults for optional metadata."""
63
+ if self.required_parameters < 0:
64
+ object.__setattr__(self, "required_parameters", len(self.parameters))
65
+
66
+
67
+ @icontract.invariant(
68
+ lambda self: len(self.name) > 0,
69
+ "Parameter name must be non-empty",
70
+ )
71
+ @dataclass(frozen=True)
72
+ class ParameterInfo:
73
+ """A single function parameter with its type annotation."""
74
+
75
+ name: str
76
+ annotation: str | None # type annotation as string, or None if untyped
77
+
78
+
79
+ @icontract.invariant(
80
+ lambda self: len(self.name) > 0 and self.line >= 1,
81
+ "Function must have a non-empty name and valid line number",
82
+ )
83
+ @dataclass(frozen=True)
84
+ class FunctionInfo:
85
+ """Full information about a function definition."""
86
+
87
+ name: str
88
+ line: int
89
+ is_public: bool
90
+ parameters: tuple[ParameterInfo, ...]
91
+ return_annotation: str | None
92
+ has_require: bool
93
+ has_ensure: bool
94
+ calls: tuple[str, ...] # call target names extracted from body
95
+
96
+
97
+ @icontract.invariant(
98
+ lambda self: len(self.name) > 0 and self.line >= 1,
99
+ "Class must have a non-empty name and valid line number",
100
+ )
101
+ @dataclass(frozen=True)
102
+ class ClassInfo:
103
+ """Information about a class definition."""
104
+
105
+ name: str
106
+ line: int
107
+ bases: tuple[str, ...]
108
+ methods: tuple[str, ...]
109
+ is_protocol: bool
110
+ method_signatures: tuple[MethodSignature, ...] = ()
111
+ has_invariant: bool = False
112
+ has_no_invariant_comment: bool = False
113
+
114
+
115
+ @icontract.invariant(
116
+ lambda self: len(self.name) > 0 and self.line >= 1,
117
+ "Protocol must have a non-empty name and valid line number",
118
+ )
119
+ @dataclass(frozen=True)
120
+ class ProtocolInfo:
121
+ """Information about a Protocol definition."""
122
+
123
+ name: str
124
+ line: int
125
+ methods: tuple[MethodSignature, ...]
126
+
127
+
128
+ @icontract.invariant(
129
+ lambda self: len(self.file_path) > 0 and len(self.module_path) > 0,
130
+ "Module must have non-empty file and module paths",
131
+ )
132
+ @dataclass(frozen=True)
133
+ class ModuleInfo:
134
+ """Parsed information about a single module for compositional analysis."""
135
+
136
+ file_path: str
137
+ module_path: str
138
+ imports: tuple[str, ...]
139
+ from_imports: tuple[tuple[str, str], ...] # (resolved_module, imported_name) pairs
140
+ classes: tuple[ClassInfo, ...]
141
+ functions: tuple[str, ...]
142
+ protocols: tuple[ProtocolInfo, ...]
143
+ function_infos: tuple[FunctionInfo, ...] = ()
144
+ import_bindings: tuple[tuple[str, str, str | None], ...] = ()
145
+ parse_error: str | None = None
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Module parsing
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ @icontract.require(
154
+ lambda source: isinstance(source, str),
155
+ "source must be a string",
156
+ )
157
+ @icontract.require(
158
+ lambda file_path: is_non_empty_string(file_path),
159
+ "file_path must be a non-empty string",
160
+ )
161
+ @icontract.require(
162
+ lambda file_path: is_valid_file_path_string(file_path),
163
+ "file_path must be a valid path string",
164
+ )
165
+ @icontract.require(
166
+ lambda module_path: is_non_empty_string(module_path),
167
+ "module_path must be a non-empty string",
168
+ )
169
+ @icontract.require(
170
+ lambda module_path: is_valid_file_path_string(module_path),
171
+ "module_path must be a valid module path string",
172
+ )
173
+ @icontract.ensure(
174
+ lambda result: isinstance(result, ModuleInfo),
175
+ "result must be a ModuleInfo",
176
+ )
177
+ def parse_module_info(
178
+ source: str,
179
+ file_path: str,
180
+ module_path: str,
181
+ ) -> ModuleInfo:
182
+ """Parse a Python source file into a ModuleInfo for compositional analysis.
183
+
184
+ Args:
185
+ source: Python source code.
186
+ file_path: Path to the file.
187
+ module_path: Derived module path for architecture checks.
188
+
189
+ Returns:
190
+ A ModuleInfo containing structural information about the module.
191
+ """
192
+ try:
193
+ tree = ast.parse(source)
194
+ except (SyntaxError, TypeError, ValueError) as parse_exc:
195
+ return ModuleInfo(
196
+ file_path=file_path,
197
+ module_path=module_path,
198
+ imports=(),
199
+ from_imports=(),
200
+ classes=(),
201
+ functions=(),
202
+ protocols=(),
203
+ parse_error=str(parse_exc),
204
+ )
205
+
206
+ aliases = resolve_icontract_aliases(tree)
207
+
208
+ imports: list[str] = []
209
+ from_imports: list[tuple[str, str]] = []
210
+ import_bindings: list[tuple[str, str, str | None]] = []
211
+ classes: list[ClassInfo] = []
212
+ functions: list[str] = []
213
+ function_infos: list[FunctionInfo] = []
214
+ protocols: list[ProtocolInfo] = []
215
+
216
+ # Loop invariant: collected info for all top-level nodes processed so far
217
+ for node in ast.iter_child_nodes(tree):
218
+ if isinstance(node, ast.Import):
219
+ # Loop invariant: imports list updated for aliases[0..i]
220
+ for alias in node.names:
221
+ imports.append(alias.name)
222
+ bound_name = alias.asname if alias.asname else alias.name.split(".")[0]
223
+ import_bindings.append((bound_name, alias.name, None))
224
+ elif isinstance(node, ast.ImportFrom):
225
+ resolved_module = _resolve_from_import_module(node, module_path)
226
+ if resolved_module:
227
+ # Loop invariant: from_imports updated for names[0..i]
228
+ for alias in node.names:
229
+ from_imports.append((resolved_module, alias.name))
230
+ if alias.name != "*":
231
+ bound_name = alias.asname if alias.asname else alias.name
232
+ import_bindings.append((bound_name, resolved_module, alias.name))
233
+ elif isinstance(node, ast.ClassDef):
234
+ class_info = _parse_class(node, aliases, source)
235
+ classes.append(class_info)
236
+ if class_info.is_protocol:
237
+ protocol_info = _parse_protocol(node)
238
+ protocols.append(protocol_info)
239
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
240
+ is_public = _is_public_function_name(node.name)
241
+ if is_public:
242
+ functions.append(node.name)
243
+ func_info = _parse_function_info(node, aliases)
244
+ function_infos.append(func_info)
245
+
246
+ return ModuleInfo(
247
+ file_path=file_path,
248
+ module_path=module_path,
249
+ imports=tuple(imports),
250
+ from_imports=tuple(from_imports),
251
+ classes=tuple(classes),
252
+ functions=tuple(functions),
253
+ protocols=tuple(protocols),
254
+ function_infos=tuple(function_infos),
255
+ import_bindings=tuple(import_bindings),
256
+ )
257
+
258
+
259
+ @icontract.require(
260
+ lambda node: isinstance(node, ast.ImportFrom),
261
+ "node must be an ImportFrom node",
262
+ )
263
+ @icontract.require(
264
+ lambda module_path: isinstance(module_path, str),
265
+ "module_path must be a string",
266
+ )
267
+ @icontract.ensure(
268
+ lambda result: result is None or isinstance(result, str),
269
+ "result must be a string or None",
270
+ )
271
+ def _resolve_from_import_module(
272
+ node: ast.ImportFrom,
273
+ module_path: str,
274
+ ) -> str | None:
275
+ """Resolve an ImportFrom node to a module name relative to module_path."""
276
+ if node.level == 0:
277
+ return node.module
278
+
279
+ current_package = _module_package_name(module_path)
280
+ package_parts = [part for part in current_package.split(".") if part]
281
+ ascend = node.level - 1
282
+
283
+ if ascend > len(package_parts):
284
+ base_parts: list[str] = []
285
+ else:
286
+ base_parts = package_parts[:len(package_parts) - ascend]
287
+
288
+ resolved_parts = list(base_parts)
289
+ if node.module:
290
+ resolved_parts.extend(part for part in node.module.split(".") if part)
291
+
292
+ if not resolved_parts:
293
+ return None
294
+
295
+ return ".".join(resolved_parts)
296
+
297
+
298
+ @icontract.require(
299
+ lambda module_path: isinstance(module_path, str),
300
+ "module_path must be a string",
301
+ )
302
+ @icontract.ensure(
303
+ lambda result: isinstance(result, str),
304
+ "result must be a string",
305
+ )
306
+ def _module_package_name(module_path: str) -> str:
307
+ """Get the dotted package path for a module path."""
308
+ normalized = _normalize_module_name(module_path)
309
+
310
+ if normalized.endswith(".__init__"):
311
+ return normalized[:-9]
312
+
313
+ if "." in normalized:
314
+ return normalized.rsplit(".", 1)[0]
315
+
316
+ return ""
317
+
318
+
319
+ @icontract.require(
320
+ lambda name: is_non_empty_string(name),
321
+ "name must be a non-empty string",
322
+ )
323
+ @icontract.ensure(
324
+ lambda result: isinstance(result, bool),
325
+ "result must be a bool",
326
+ )
327
+ def _is_public_function_name(name: str) -> bool:
328
+ """Check whether a function should count as public API."""
329
+ if name.startswith("_") and not name.startswith("__"):
330
+ return False
331
+ if name.startswith("__") and name.endswith("__") and name != "__init__":
332
+ return False
333
+ return True
334
+
335
+
336
+ @icontract.require(
337
+ lambda func_info: isinstance(func_info, FunctionInfo),
338
+ "func_info must be a FunctionInfo",
339
+ )
340
+ @icontract.require(
341
+ lambda config: isinstance(config, SerenecodeConfig),
342
+ "config must be a SerenecodeConfig",
343
+ )
344
+ @icontract.ensure(
345
+ lambda result: isinstance(result, bool),
346
+ "result must be a bool",
347
+ )
348
+ def _should_check_function_contracts(
349
+ func_info: FunctionInfo,
350
+ config: SerenecodeConfig,
351
+ ) -> bool:
352
+ """Check whether contract completeness applies to a function."""
353
+ if config.contract_requirements.require_on_private:
354
+ return not (
355
+ func_info.name.startswith("__")
356
+ and func_info.name.endswith("__")
357
+ and func_info.name != "__init__"
358
+ )
359
+ return _is_public_function_name(func_info.name)
360
+
361
+
362
+ @icontract.require(
363
+ lambda cls: isinstance(cls, ClassInfo),
364
+ "cls must be a ClassInfo",
365
+ )
366
+ @icontract.require(
367
+ lambda config: isinstance(config, SerenecodeConfig),
368
+ "config must be a SerenecodeConfig",
369
+ )
370
+ @icontract.ensure(
371
+ lambda result: isinstance(result, bool),
372
+ "result must be a bool",
373
+ )
374
+ def _should_check_class_invariants(
375
+ cls: ClassInfo,
376
+ config: SerenecodeConfig,
377
+ ) -> bool:
378
+ """Check whether invariant completeness applies to a class."""
379
+ if config.contract_requirements.require_on_private:
380
+ return True
381
+ return not cls.name.startswith("_")
382
+
383
+
384
+ @icontract.require(
385
+ lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
386
+ "node must be a function definition",
387
+ )
388
+ @icontract.require(
389
+ lambda aliases: isinstance(aliases, IcontractNames),
390
+ "aliases must be IcontractNames",
391
+ )
392
+ @icontract.ensure(
393
+ lambda result: isinstance(result, FunctionInfo),
394
+ "result must be a FunctionInfo",
395
+ )
396
+ def _parse_function_info(
397
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
398
+ aliases: IcontractNames,
399
+ ) -> FunctionInfo:
400
+ """Parse a function definition into a FunctionInfo.
401
+
402
+ Args:
403
+ node: An AST FunctionDef or AsyncFunctionDef node.
404
+ aliases: Resolved icontract import names for decorator detection.
405
+
406
+ Returns:
407
+ A FunctionInfo with function metadata including contracts and calls.
408
+ """
409
+ parameters = _parse_parameters(node)
410
+ return_ann = ast.unparse(node.returns) if node.returns else None
411
+ has_req = has_decorator(node, aliases.require_names)
412
+ has_ens = has_decorator(node, aliases.ensure_names)
413
+ calls = _extract_calls(node)
414
+
415
+ return FunctionInfo(
416
+ name=node.name,
417
+ line=node.lineno,
418
+ is_public=_is_public_function_name(node.name),
419
+ parameters=parameters,
420
+ return_annotation=return_ann,
421
+ has_require=has_req,
422
+ has_ensure=has_ens,
423
+ calls=calls,
424
+ )
425
+
426
+
427
+ @icontract.require(
428
+ lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
429
+ "node must be a function definition",
430
+ )
431
+ @icontract.ensure(
432
+ lambda result: isinstance(result, tuple),
433
+ "result must be a tuple",
434
+ )
435
+ def _parse_parameters(
436
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
437
+ ) -> tuple[ParameterInfo, ...]:
438
+ """Extract parameter names and type annotations from a function.
439
+
440
+ Args:
441
+ node: An AST FunctionDef or AsyncFunctionDef node.
442
+
443
+ Returns:
444
+ Tuple of ParameterInfo for non-self/cls parameters.
445
+ """
446
+ params: list[ParameterInfo] = []
447
+ signature_params = list(node.args.posonlyargs) + list(node.args.args) + list(node.args.kwonlyargs)
448
+ if node.args.vararg is not None:
449
+ signature_params.append(node.args.vararg)
450
+ if node.args.kwarg is not None:
451
+ signature_params.append(node.args.kwarg)
452
+
453
+ # Loop invariant: params contains ParameterInfo for signature_params[0..i] excluding self/cls
454
+ for arg in signature_params:
455
+ if arg.arg in ("self", "cls"):
456
+ continue
457
+ annotation = ast.unparse(arg.annotation) if arg.annotation else None
458
+ params.append(ParameterInfo(name=arg.arg, annotation=annotation))
459
+ return tuple(params)
460
+
461
+
462
+ @icontract.require(
463
+ lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
464
+ "node must be a function definition",
465
+ )
466
+ @icontract.ensure(
467
+ lambda result: isinstance(result, tuple),
468
+ "result must be a tuple",
469
+ )
470
+ def _extract_calls(
471
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
472
+ ) -> tuple[str, ...]:
473
+ """Extract all function call target names from a function body.
474
+
475
+ Args:
476
+ node: An AST FunctionDef or AsyncFunctionDef node.
477
+
478
+ Returns:
479
+ Tuple of call target name strings.
480
+ """
481
+ calls: list[str] = []
482
+ # Loop invariant: calls contains target names for all ast.Call nodes in body[0..i]
483
+ for child in ast.walk(node):
484
+ if isinstance(child, ast.Call):
485
+ name = _get_call_target_name(child.func)
486
+ if name:
487
+ calls.append(name)
488
+ return tuple(calls)
489
+
490
+
491
+ @icontract.require(
492
+ lambda node: isinstance(node, ast.AST),
493
+ "node must be an AST node",
494
+ )
495
+ @icontract.ensure(
496
+ lambda result: isinstance(result, str),
497
+ "result must be a string",
498
+ )
499
+ def _get_call_target_name(node: ast.expr) -> str:
500
+ """Resolve an AST call target to a dotted name string.
501
+
502
+ Args:
503
+ node: The func attribute of an ast.Call node.
504
+
505
+ Returns:
506
+ The resolved name string, or empty string if unresolvable.
507
+ """
508
+ # Variant: depth of nesting decreases
509
+ if isinstance(node, ast.Name):
510
+ return node.id
511
+ elif isinstance(node, ast.Attribute):
512
+ value_name = _get_call_target_name(node.value)
513
+ if value_name:
514
+ return f"{value_name}.{node.attr}"
515
+ return node.attr
516
+ return ""
517
+
518
+
519
+ @icontract.require(lambda node: isinstance(node, ast.ClassDef), "node must be a class definition")
520
+ @icontract.require(
521
+ lambda aliases: isinstance(aliases, IcontractNames),
522
+ "aliases must be IcontractNames",
523
+ )
524
+ @icontract.ensure(
525
+ lambda result: isinstance(result, ClassInfo),
526
+ "result must be a ClassInfo",
527
+ )
528
+ def _parse_class(node: ast.ClassDef, aliases: IcontractNames, source: str = "") -> ClassInfo:
529
+ """Parse a class definition into a ClassInfo.
530
+
531
+ Args:
532
+ node: An AST ClassDef node.
533
+ aliases: Resolved icontract import names for invariant detection.
534
+ source: Original source code for comment checking.
535
+
536
+ Returns:
537
+ A ClassInfo with class structural information.
538
+ """
539
+ bases: list[str] = []
540
+ # Loop invariant: bases contains names for node.bases[0..i]
541
+ for base in node.bases:
542
+ if isinstance(base, ast.Name):
543
+ bases.append(base.id)
544
+ elif isinstance(base, ast.Attribute):
545
+ bases.append(f"{_get_name(base.value)}.{base.attr}")
546
+
547
+ methods: list[str] = []
548
+ method_sigs: list[MethodSignature] = []
549
+ # Loop invariant: methods and method_sigs contain data for all method nodes processed
550
+ for item in node.body:
551
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
552
+ methods.append(item.name)
553
+ method_sigs.append(_parse_method_signature(item))
554
+
555
+ is_protocol = "Protocol" in bases or any(
556
+ b.endswith(".Protocol") for b in bases
557
+ )
558
+ has_inv = has_decorator(node, aliases.invariant_names)
559
+ has_no_inv_comment = _check_no_invariant_comment(node, source)
560
+
561
+ return ClassInfo(
562
+ name=node.name,
563
+ line=node.lineno,
564
+ bases=tuple(bases),
565
+ methods=tuple(methods),
566
+ is_protocol=is_protocol,
567
+ method_signatures=tuple(method_sigs),
568
+ has_invariant=has_inv,
569
+ has_no_invariant_comment=has_no_inv_comment,
570
+ )
571
+
572
+
573
+ @icontract.require(
574
+ lambda node: isinstance(node, ast.ClassDef),
575
+ "node must be a class definition",
576
+ )
577
+ @icontract.ensure(
578
+ lambda result: isinstance(result, bool),
579
+ "result must be a bool",
580
+ )
581
+ def _check_no_invariant_comment(node: ast.ClassDef, source: str) -> bool:
582
+ """Check if the class is preceded by a '# no-invariant:' comment."""
583
+ if not source:
584
+ return False
585
+ lines = source.splitlines()
586
+ class_line_index = node.lineno - 1
587
+ # Loop invariant: checking lines above the class for no-invariant comment
588
+ for offset in range(1, min(6, class_line_index + 1)):
589
+ prev_line = lines[class_line_index - offset].strip()
590
+ if prev_line.startswith("# no-invariant:"):
591
+ return True
592
+ if prev_line.startswith("#"):
593
+ continue
594
+ if not prev_line.startswith("@"):
595
+ break
596
+ return False
597
+
598
+
599
+ @icontract.require(lambda node: isinstance(node, ast.ClassDef), "node must be a class definition")
600
+ @icontract.ensure(
601
+ lambda result: isinstance(result, ProtocolInfo),
602
+ "result must be a ProtocolInfo",
603
+ )
604
+ def _parse_protocol(node: ast.ClassDef) -> ProtocolInfo:
605
+ """Parse a Protocol class into a ProtocolInfo with method signatures.
606
+
607
+ Args:
608
+ node: An AST ClassDef node representing a Protocol.
609
+
610
+ Returns:
611
+ A ProtocolInfo with method signatures.
612
+ """
613
+ methods: list[MethodSignature] = []
614
+
615
+ # Loop invariant: methods contains signatures for all method nodes processed
616
+ for item in node.body:
617
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
618
+ methods.append(_parse_method_signature(item))
619
+
620
+ return ProtocolInfo(
621
+ name=node.name,
622
+ line=node.lineno,
623
+ methods=tuple(methods),
624
+ )
625
+
626
+
627
+ @icontract.require(
628
+ lambda node: isinstance(node, ast.AST),
629
+ "node must be an AST node",
630
+ )
631
+ @icontract.ensure(
632
+ lambda result: isinstance(result, str),
633
+ "result must be a string",
634
+ )
635
+ def _get_name(node: ast.expr) -> str:
636
+ """Get a simple name from an AST expression.
637
+
638
+ Args:
639
+ node: An AST expression node.
640
+
641
+ Returns:
642
+ The name string.
643
+ """
644
+ # Variant: depth of nesting decreases
645
+ if isinstance(node, ast.Name):
646
+ return node.id
647
+ elif isinstance(node, ast.Attribute):
648
+ return f"{_get_name(node.value)}.{node.attr}"
649
+ return "<unknown>"
650
+
651
+
652
+ @icontract.require(
653
+ lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
654
+ "node must be a function definition",
655
+ )
656
+ @icontract.ensure(
657
+ lambda result: isinstance(result, MethodSignature),
658
+ "result must be a MethodSignature",
659
+ )
660
+ def _parse_method_signature(
661
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
662
+ ) -> MethodSignature:
663
+ """Extract parameter and return-shape metadata for interface checks."""
664
+ params: list[str] = []
665
+ required_parameters = 0
666
+
667
+ positional_params = list(node.args.posonlyargs) + list(node.args.args)
668
+ first_optional_index = len(positional_params) - len(node.args.defaults)
669
+ # Loop invariant: params and required_parameters reflect positional_params[0..i].
670
+ for index, arg in enumerate(positional_params):
671
+ if arg.arg in ("self", "cls"):
672
+ continue
673
+ params.append(arg.arg)
674
+ if index < first_optional_index:
675
+ required_parameters += 1
676
+
677
+ # Loop invariant: params and required_parameters reflect kwonlyargs[0..i].
678
+ for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults):
679
+ if arg.arg in ("self", "cls"):
680
+ continue
681
+ params.append(arg.arg)
682
+ if default is None:
683
+ required_parameters += 1
684
+
685
+ if node.args.vararg is not None and node.args.vararg.arg not in ("self", "cls"):
686
+ params.append(node.args.vararg.arg)
687
+ if node.args.kwarg is not None and node.args.kwarg.arg not in ("self", "cls"):
688
+ params.append(node.args.kwarg.arg)
689
+
690
+ return MethodSignature(
691
+ name=node.name,
692
+ parameters=tuple(params),
693
+ has_return_annotation=node.returns is not None,
694
+ required_parameters=required_parameters,
695
+ return_annotation=ast.unparse(node.returns) if node.returns else None,
696
+ )
697
+
698
+
699
+ @icontract.require(
700
+ lambda module_path: isinstance(module_path, str),
701
+ "module_path must be a string",
702
+ )
703
+ @icontract.require(
704
+ lambda segment: isinstance(segment, str) and len(segment) > 0,
705
+ "segment must be a non-empty string",
706
+ )
707
+ @icontract.ensure(
708
+ lambda result: isinstance(result, bool),
709
+ "result must be a bool",
710
+ )
711
+ def _module_path_has_segment(module_path: str, segment: str) -> bool:
712
+ """Check whether a slash-separated path contains an exact segment."""
713
+ normalized = module_path.replace("\\", "/").strip("/")
714
+ if not normalized:
715
+ return False
716
+ segments = tuple(part for part in normalized.split("/") if part and part != ".")
717
+ return segment in segments
718
+
719
+
720
+ # ---------------------------------------------------------------------------
721
+ # Compositional checks
722
+ # ---------------------------------------------------------------------------
723
+
724
+
725
+ @icontract.require(
726
+ lambda modules: isinstance(modules, list),
727
+ "modules must be a list",
728
+ )
729
+ @icontract.ensure(
730
+ lambda result: isinstance(result, list),
731
+ "result must be a list",
732
+ )
733
+ def check_dependency_direction(
734
+ modules: list[ModuleInfo],
735
+ config: SerenecodeConfig,
736
+ ) -> list[FunctionResult]:
737
+ """Check that import dependencies follow the hexagonal architecture.
738
+
739
+ Rules:
740
+ - core/ must not import from adapters/
741
+ - ports/ must not import from adapters/
742
+ - core/ must not import from cli.py
743
+ - No circular imports between core modules
744
+
745
+ Args:
746
+ modules: List of parsed module information.
747
+ config: Active Serenecode configuration.
748
+
749
+ Returns:
750
+ List of FunctionResult for dependency violations.
751
+ """
752
+ results: list[FunctionResult] = []
753
+
754
+ # Dependency direction checks apply to core and port modules.
755
+ # Unlike contract exemptions, architectural rules are always enforced.
756
+ # Loop invariant: results contains dependency violations for modules[0..i]
757
+ for mod in modules:
758
+ is_core = is_core_module(mod.module_path, config)
759
+ is_port = _module_path_has_segment(mod.module_path, "ports")
760
+
761
+ if not is_core and not is_port:
762
+ continue
763
+
764
+ # Check imports
765
+ # Loop invariant: results contains violations for imports[0..j]
766
+ for imp in mod.imports:
767
+ violation = _check_import_direction(imp, is_core, is_port)
768
+ if violation:
769
+ results.append(FunctionResult(
770
+ function="<module>",
771
+ file=mod.file_path,
772
+ line=1,
773
+ level_requested=6,
774
+ level_achieved=5,
775
+ status=CheckStatus.FAILED,
776
+ details=(Detail(
777
+ level=VerificationLevel.COMPOSITIONAL,
778
+ tool="compositional",
779
+ finding_type="violation",
780
+ message=violation,
781
+ suggestion="Move this import to an adapter module or use dependency injection",
782
+ ),),
783
+ ))
784
+
785
+ # Check from-imports
786
+ # Loop invariant: results contains violations for from_imports[0..j]
787
+ for from_mod, _ in mod.from_imports:
788
+ violation = _check_import_direction(from_mod, is_core, is_port)
789
+ if violation:
790
+ results.append(FunctionResult(
791
+ function="<module>",
792
+ file=mod.file_path,
793
+ line=1,
794
+ level_requested=6,
795
+ level_achieved=5,
796
+ status=CheckStatus.FAILED,
797
+ details=(Detail(
798
+ level=VerificationLevel.COMPOSITIONAL,
799
+ tool="compositional",
800
+ finding_type="violation",
801
+ message=violation,
802
+ suggestion="Move this import to an adapter module or use dependency injection",
803
+ ),),
804
+ ))
805
+
806
+ return results
807
+
808
+
809
+ @icontract.require(
810
+ lambda imported: is_non_empty_string(imported),
811
+ "imported must be a non-empty string",
812
+ )
813
+ @icontract.ensure(
814
+ lambda result: isinstance(result, bool),
815
+ "result must be a bool",
816
+ )
817
+ def _is_adapter_import(imported: str) -> bool:
818
+ """Check if an import refers to an adapter module by segment matching.
819
+
820
+ Matches 'adapters' as a complete module/path segment, not as a substring.
821
+
822
+ Args:
823
+ imported: The imported module or path string.
824
+
825
+ Returns:
826
+ True if the import refers to an adapter module.
827
+ """
828
+ segments = imported.replace("/", ".").split(".")
829
+ return "adapters" in segments
830
+
831
+
832
+ @icontract.require(
833
+ lambda imported: is_non_empty_string(imported),
834
+ "imported must be a non-empty string",
835
+ )
836
+ @icontract.ensure(
837
+ lambda result: isinstance(result, bool),
838
+ "result must be a bool",
839
+ )
840
+ def _is_cli_import(imported: str) -> bool:
841
+ """Check if an import refers to a CLI module by segment matching.
842
+
843
+ Matches 'cli' as a complete module segment, not as a substring.
844
+ For example, 'cli' and 'myproject.cli' match, but 'click' does not.
845
+
846
+ Args:
847
+ imported: The imported module or path string.
848
+
849
+ Returns:
850
+ True if the import refers to a CLI module.
851
+ """
852
+ segments = imported.replace("/", ".").split(".")
853
+ return "cli" in segments
854
+
855
+
856
+ @icontract.require(
857
+ lambda imported: is_non_empty_string(imported),
858
+ "imported must be a non-empty string",
859
+ )
860
+ @icontract.require(lambda is_core: isinstance(is_core, bool), "is_core must be a bool")
861
+ @icontract.require(lambda is_port: isinstance(is_port, bool), "is_port must be a bool")
862
+ @icontract.ensure(
863
+ lambda result: result is None or isinstance(result, str),
864
+ "result must be a string or None",
865
+ )
866
+ def _check_import_direction(
867
+ imported: str,
868
+ is_core: bool,
869
+ is_port: bool,
870
+ ) -> str | None:
871
+ """Check if a single import violates dependency direction.
872
+
873
+ Uses segment-based matching to avoid false positives on library
874
+ names that contain 'cli' or 'adapters' as substrings.
875
+
876
+ Args:
877
+ imported: The imported module name.
878
+ is_core: Whether the importing module is a core module.
879
+ is_port: Whether the importing module is a port module.
880
+
881
+ Returns:
882
+ A violation message string, or None if the import is valid.
883
+ """
884
+ if _is_adapter_import(imported):
885
+ location = "core" if is_core else "ports"
886
+ return f"Module in {location}/ imports from adapters: '{imported}'"
887
+
888
+ if is_core and _is_cli_import(imported):
889
+ return f"Core module imports from CLI: '{imported}'"
890
+
891
+ return None
892
+
893
+
894
+ @icontract.require(
895
+ lambda modules: isinstance(modules, list),
896
+ "modules must be a list",
897
+ )
898
+ @icontract.ensure(
899
+ lambda result: isinstance(result, list),
900
+ "result must be a list",
901
+ )
902
+ def check_interface_compliance(
903
+ modules: list[ModuleInfo],
904
+ config: SerenecodeConfig,
905
+ ) -> list[FunctionResult]:
906
+ """Check that adapter classes implement all Protocol methods.
907
+
908
+ For each Protocol defined in ports/, finds classes in adapters/
909
+ that claim to implement it (by type or convention) and verifies
910
+ all Protocol methods are present.
911
+
912
+ Args:
913
+ modules: List of parsed module information.
914
+ config: Active Serenecode configuration.
915
+
916
+ Returns:
917
+ List of FunctionResult for compliance violations.
918
+ """
919
+ # Collect all protocols from port modules
920
+ protocols: list[tuple[str, ProtocolInfo]] = []
921
+ # Loop invariant: protocols contains all Protocol defs from ports modules[0..i]
922
+ for mod in modules:
923
+ if _module_path_has_segment(mod.module_path, "ports"):
924
+ # Loop invariant: protocols updated for mod.protocols[0..j]
925
+ for proto in mod.protocols:
926
+ protocols.append((mod.file_path, proto))
927
+
928
+ if not protocols:
929
+ return []
930
+
931
+ # Collect all classes from adapter modules
932
+ adapter_classes: list[tuple[str, ClassInfo]] = []
933
+ # Loop invariant: adapter_classes contains classes from adapter modules[0..i]
934
+ for mod in modules:
935
+ if _module_path_has_segment(mod.module_path, "adapters"):
936
+ # Loop invariant: adapter_classes updated for mod.classes[0..j]
937
+ for cls in mod.classes:
938
+ adapter_classes.append((mod.file_path, cls))
939
+
940
+ results: list[FunctionResult] = []
941
+
942
+ # For each protocol, check if adapter classes implement all methods
943
+ # Loop invariant: results contains compliance findings for protocols[0..i]
944
+ for port_file, proto in protocols:
945
+ proto_method_names = {m.name for m in proto.methods}
946
+
947
+ # Loop invariant: checked adapter_classes[0..j] against this protocol
948
+ for adapter_file, adapter_cls in adapter_classes:
949
+ if not _class_likely_implements(adapter_cls, proto):
950
+ continue
951
+
952
+ adapter_method_names = set(adapter_cls.methods)
953
+ missing = proto_method_names - adapter_method_names
954
+
955
+ # Report missing methods
956
+ # Loop invariant: results contains findings for missing[0..k]
957
+ for method_name in sorted(missing):
958
+ results.append(FunctionResult(
959
+ function=adapter_cls.name,
960
+ file=adapter_file,
961
+ line=adapter_cls.line,
962
+ level_requested=6,
963
+ level_achieved=5,
964
+ status=CheckStatus.FAILED,
965
+ details=(Detail(
966
+ level=VerificationLevel.COMPOSITIONAL,
967
+ tool="compositional",
968
+ finding_type="violation",
969
+ message=(
970
+ f"Class '{adapter_cls.name}' appears to implement "
971
+ f"'{proto.name}' but is missing method '{method_name}'"
972
+ ),
973
+ suggestion=f"Add method '{method_name}' to '{adapter_cls.name}'",
974
+ ),),
975
+ ))
976
+
977
+ # Check signature compatibility for methods that exist in both
978
+ adapter_sig_map = {s.name: s for s in adapter_cls.method_signatures}
979
+ # Loop invariant: results contains signature findings for proto.methods[0..k]
980
+ for proto_method in proto.methods:
981
+ adapter_sig = adapter_sig_map.get(proto_method.name)
982
+ if adapter_sig is None:
983
+ continue # already reported as missing above
984
+ # Loop invariant: results updated for all issues from this comparison
985
+ for issue in _check_signature_compatibility(adapter_sig, proto_method):
986
+ results.append(FunctionResult(
987
+ function=adapter_cls.name,
988
+ file=adapter_file,
989
+ line=adapter_cls.line,
990
+ level_requested=6,
991
+ level_achieved=5,
992
+ status=CheckStatus.FAILED,
993
+ details=(Detail(
994
+ level=VerificationLevel.COMPOSITIONAL,
995
+ tool="compositional",
996
+ finding_type="violation",
997
+ message=(
998
+ f"Class '{adapter_cls.name}' vs Protocol "
999
+ f"'{proto.name}': {issue}"
1000
+ ),
1001
+ suggestion=(
1002
+ f"Update method signature to match "
1003
+ f"Protocol '{proto.name}'"
1004
+ ),
1005
+ ),),
1006
+ ))
1007
+
1008
+ return results
1009
+
1010
+
1011
+ @icontract.require(
1012
+ lambda adapter_sig: isinstance(adapter_sig, MethodSignature),
1013
+ "adapter_sig must be a MethodSignature",
1014
+ )
1015
+ @icontract.require(
1016
+ lambda proto_sig: isinstance(proto_sig, MethodSignature),
1017
+ "proto_sig must be a MethodSignature",
1018
+ )
1019
+ @icontract.ensure(
1020
+ lambda result: isinstance(result, list),
1021
+ "result must be a list",
1022
+ )
1023
+ def _check_signature_compatibility(
1024
+ adapter_sig: MethodSignature,
1025
+ proto_sig: MethodSignature,
1026
+ ) -> list[str]:
1027
+ """Check if an adapter method signature is compatible with a Protocol method.
1028
+
1029
+ Args:
1030
+ adapter_sig: The adapter class method signature.
1031
+ proto_sig: The Protocol method signature to check against.
1032
+
1033
+ Returns:
1034
+ List of incompatibility descriptions (empty if compatible).
1035
+ """
1036
+ issues: list[str] = []
1037
+ if len(adapter_sig.parameters) < len(proto_sig.parameters):
1038
+ issues.append(
1039
+ f"Method '{proto_sig.name}' implementation has "
1040
+ f"{len(adapter_sig.parameters)} parameters but Protocol "
1041
+ f"requires {len(proto_sig.parameters)}"
1042
+ )
1043
+ if adapter_sig.required_parameters > proto_sig.required_parameters:
1044
+ issues.append(
1045
+ f"Method '{proto_sig.name}' implementation requires "
1046
+ f"{adapter_sig.required_parameters} parameters but Protocol "
1047
+ f"requires only {proto_sig.required_parameters}"
1048
+ )
1049
+ if proto_sig.has_return_annotation and not adapter_sig.has_return_annotation:
1050
+ issues.append(
1051
+ f"Method '{proto_sig.name}' missing return annotation "
1052
+ f"(Protocol specifies one)"
1053
+ )
1054
+ if (
1055
+ proto_sig.return_annotation is not None
1056
+ and adapter_sig.return_annotation is not None
1057
+ and "".join(proto_sig.return_annotation.split()) != "".join(adapter_sig.return_annotation.split())
1058
+ ):
1059
+ issues.append(
1060
+ f"Method '{proto_sig.name}' return annotation "
1061
+ f"'{adapter_sig.return_annotation}' does not match Protocol "
1062
+ f"annotation '{proto_sig.return_annotation}'"
1063
+ )
1064
+ return issues
1065
+
1066
+
1067
+ @icontract.require(lambda cls: isinstance(cls, ClassInfo), "cls must be a ClassInfo")
1068
+ @icontract.require(
1069
+ lambda proto: isinstance(proto, ProtocolInfo),
1070
+ "proto must be a ProtocolInfo",
1071
+ )
1072
+ @icontract.ensure(
1073
+ lambda result: isinstance(result, bool),
1074
+ "result must be a bool",
1075
+ )
1076
+ def _class_likely_implements(cls: ClassInfo, proto: ProtocolInfo) -> bool:
1077
+ """Heuristic to check if a class likely implements a protocol.
1078
+
1079
+ Checks if the class name contains the protocol name (without
1080
+ 'Protocol' suffix), or if they share most method names.
1081
+
1082
+ Args:
1083
+ cls: The candidate implementing class.
1084
+ proto: The Protocol to check against.
1085
+
1086
+ Returns:
1087
+ True if the class likely implements the protocol.
1088
+ """
1089
+ # Loop invariant: no base seen so far explicitly names the protocol.
1090
+ for base in cls.bases:
1091
+ if base == proto.name or base.endswith(f".{proto.name}"):
1092
+ return True
1093
+
1094
+ # Name-based heuristic
1095
+ proto_base = proto.name.replace("Protocol", "")
1096
+ if proto_base and proto_base.lower() in cls.name.lower():
1097
+ return True
1098
+
1099
+ # Method overlap heuristic — if >50% of protocol methods are present
1100
+ if not proto.methods:
1101
+ return False
1102
+ proto_method_names = {m.name for m in proto.methods}
1103
+ cls_method_names = set(cls.methods)
1104
+ overlap = proto_method_names & cls_method_names
1105
+ return len(overlap) > len(proto_method_names) * 0.5
1106
+
1107
+
1108
+ _ENUM_BASE_NAMES = frozenset({
1109
+ "Enum", "IntEnum", "StrEnum", "Flag", "IntFlag",
1110
+ "enum.Enum", "enum.IntEnum", "enum.StrEnum", "enum.Flag", "enum.IntFlag",
1111
+ })
1112
+
1113
+
1114
+ @icontract.require(lambda cls: isinstance(cls, ClassInfo), "cls must be a ClassInfo")
1115
+ @icontract.ensure(
1116
+ lambda result: isinstance(result, bool),
1117
+ "result must be a bool",
1118
+ )
1119
+ def _is_enum_class(cls: ClassInfo) -> bool:
1120
+ """Check if a class is an Enum subclass based on its bases.
1121
+
1122
+ Args:
1123
+ cls: The class info to check.
1124
+
1125
+ Returns:
1126
+ True if the class inherits from an Enum base.
1127
+ """
1128
+ # Loop invariant: checked bases[0..i] against _ENUM_BASE_NAMES
1129
+ for base in cls.bases:
1130
+ if base in _ENUM_BASE_NAMES:
1131
+ return True
1132
+ return False
1133
+
1134
+
1135
+ @icontract.require(
1136
+ lambda modules: isinstance(modules, list),
1137
+ "modules must be a list",
1138
+ )
1139
+ @icontract.ensure(
1140
+ lambda result: isinstance(result, list),
1141
+ "result must be a list",
1142
+ )
1143
+ def check_contract_completeness(
1144
+ modules: list[ModuleInfo],
1145
+ config: SerenecodeConfig,
1146
+ ) -> list[FunctionResult]:
1147
+ """Check that all public functions across the codebase have contracts.
1148
+
1149
+ Uses FunctionInfo metadata to verify that every public function in
1150
+ non-exempt modules has icontract.require (if it has parameters) and
1151
+ icontract.ensure decorators, and every public class has an invariant.
1152
+
1153
+ Args:
1154
+ modules: List of parsed module information.
1155
+ config: Active Serenecode configuration.
1156
+
1157
+ Returns:
1158
+ List of FunctionResult for contract coverage violations.
1159
+ """
1160
+ results: list[FunctionResult] = []
1161
+
1162
+ # Loop invariant: results contains completeness findings for modules[0..i]
1163
+ for mod in modules:
1164
+ if is_exempt_module(mod.module_path, config):
1165
+ continue
1166
+
1167
+ # Check functions for contract presence
1168
+ # Loop invariant: results contains findings for function_infos[0..j]
1169
+ for func_info in mod.function_infos:
1170
+ if not _should_check_function_contracts(func_info, config):
1171
+ continue
1172
+
1173
+ details: list[Detail] = []
1174
+ has_params = len(func_info.parameters) > 0
1175
+
1176
+ if has_params and not func_info.has_require:
1177
+ details.append(Detail(
1178
+ level=VerificationLevel.COMPOSITIONAL,
1179
+ tool="compositional",
1180
+ finding_type="violation",
1181
+ message=(
1182
+ f"Function '{func_info.name}' in {mod.module_path} "
1183
+ "missing @icontract.require (precondition)"
1184
+ ),
1185
+ suggestion="Add precondition contract",
1186
+ ))
1187
+
1188
+ if not func_info.has_ensure:
1189
+ details.append(Detail(
1190
+ level=VerificationLevel.COMPOSITIONAL,
1191
+ tool="compositional",
1192
+ finding_type="violation",
1193
+ message=(
1194
+ f"Function '{func_info.name}' in {mod.module_path} "
1195
+ "missing @icontract.ensure (postcondition)"
1196
+ ),
1197
+ suggestion="Add postcondition contract",
1198
+ ))
1199
+
1200
+ if details:
1201
+ results.append(FunctionResult(
1202
+ function=func_info.name,
1203
+ file=mod.file_path,
1204
+ line=func_info.line,
1205
+ level_requested=6,
1206
+ level_achieved=5,
1207
+ status=CheckStatus.FAILED,
1208
+ details=tuple(details),
1209
+ ))
1210
+
1211
+ # Check classes for invariants (skip Enum, exception, Protocol classes)
1212
+ # Loop invariant: results contains findings for classes[0..j]
1213
+ for cls in mod.classes:
1214
+ if not _should_check_class_invariants(cls, config):
1215
+ continue
1216
+ if _is_enum_class(cls) or _is_exception_class(cls) or cls.is_protocol or cls.has_no_invariant_comment:
1217
+ continue
1218
+ if not cls.has_invariant:
1219
+ results.append(FunctionResult(
1220
+ function=cls.name,
1221
+ file=mod.file_path,
1222
+ line=cls.line,
1223
+ level_requested=6,
1224
+ level_achieved=5,
1225
+ status=CheckStatus.FAILED,
1226
+ details=(Detail(
1227
+ level=VerificationLevel.COMPOSITIONAL,
1228
+ tool="compositional",
1229
+ finding_type="violation",
1230
+ message=(
1231
+ f"Class '{cls.name}' in {mod.module_path} "
1232
+ "missing @icontract.invariant"
1233
+ ),
1234
+ suggestion="Add class invariant",
1235
+ ),),
1236
+ ))
1237
+
1238
+ # Informational: flag large modules
1239
+ total_public = len([f for f in mod.function_infos if f.is_public])
1240
+ if total_public > 10:
1241
+ results.append(FunctionResult(
1242
+ function="<module>",
1243
+ file=mod.file_path,
1244
+ line=1,
1245
+ level_requested=6,
1246
+ level_achieved=6,
1247
+ status=CheckStatus.PASSED,
1248
+ details=(Detail(
1249
+ level=VerificationLevel.COMPOSITIONAL,
1250
+ tool="compositional",
1251
+ finding_type="info",
1252
+ message=(
1253
+ f"Module has {total_public} public functions — "
1254
+ "consider splitting into smaller modules"
1255
+ ),
1256
+ ),),
1257
+ ))
1258
+
1259
+ return results
1260
+
1261
+
1262
+ # ---------------------------------------------------------------------------
1263
+ # Circular dependency detection
1264
+ # ---------------------------------------------------------------------------
1265
+
1266
+
1267
+ @icontract.require(
1268
+ lambda modules: isinstance(modules, list),
1269
+ "modules must be a list",
1270
+ )
1271
+ @icontract.ensure(
1272
+ lambda result: isinstance(result, list),
1273
+ "result must be a list",
1274
+ )
1275
+ def check_circular_dependencies(
1276
+ modules: list[ModuleInfo],
1277
+ config: SerenecodeConfig,
1278
+ ) -> list[FunctionResult]:
1279
+ """Detect circular import dependencies between internal modules.
1280
+
1281
+ Builds a directed graph from module imports, filtering to only
1282
+ internal project modules, then uses DFS to detect cycles.
1283
+
1284
+ Args:
1285
+ modules: List of parsed module information.
1286
+ config: Active Serenecode configuration.
1287
+
1288
+ Returns:
1289
+ List of FunctionResult for circular dependency violations.
1290
+ """
1291
+ # Build set of known module paths for internal resolution
1292
+ known_modules: dict[str, ModuleInfo] = {}
1293
+ # Loop invariant: known_modules contains entries for modules[0..i]
1294
+ for mod in modules:
1295
+ known_modules[mod.module_path] = mod
1296
+ base = mod.module_path.removesuffix(".py")
1297
+ known_modules[base] = mod
1298
+
1299
+ # Build adjacency list
1300
+ graph: dict[str, set[str]] = {mod.module_path: set() for mod in modules}
1301
+
1302
+ # Loop invariant: graph contains edges for modules[0..i]
1303
+ for mod in modules:
1304
+ # Loop invariant: graph[mod] updated for imports[0..j]
1305
+ for imp in mod.imports:
1306
+ resolved = _resolve_to_known_module(imp, known_modules)
1307
+ if resolved and resolved != mod.module_path:
1308
+ graph[mod.module_path].add(resolved)
1309
+ # Loop invariant: graph[mod] updated for from_imports[0..j]
1310
+ for from_mod, imported_name in mod.from_imports:
1311
+ resolved = _resolve_from_import_target(
1312
+ from_mod,
1313
+ imported_name,
1314
+ known_modules,
1315
+ )
1316
+ if resolved and resolved != mod.module_path:
1317
+ graph[mod.module_path].add(resolved)
1318
+
1319
+ cycles = _find_cycles(graph)
1320
+
1321
+ results: list[FunctionResult] = []
1322
+ reported_cycles: set[frozenset[str]] = set()
1323
+
1324
+ # Loop invariant: results contains findings for deduplicated cycles[0..i]
1325
+ for cycle in cycles:
1326
+ cycle_key = frozenset(cycle)
1327
+ if cycle_key in reported_cycles:
1328
+ continue
1329
+ reported_cycles.add(cycle_key)
1330
+
1331
+ cycle_str = " -> ".join(cycle) + " -> " + cycle[0]
1332
+ first_file = known_modules[cycle[0]].file_path if cycle[0] in known_modules else "<unknown>"
1333
+ results.append(FunctionResult(
1334
+ function="<module>",
1335
+ file=first_file,
1336
+ line=1,
1337
+ level_requested=6,
1338
+ level_achieved=5,
1339
+ status=CheckStatus.FAILED,
1340
+ details=(Detail(
1341
+ level=VerificationLevel.COMPOSITIONAL,
1342
+ tool="compositional",
1343
+ finding_type="violation",
1344
+ message=f"Circular dependency detected: {cycle_str}",
1345
+ suggestion="Break the cycle by introducing a Protocol interface or restructuring imports",
1346
+ ),),
1347
+ ))
1348
+
1349
+ return results
1350
+
1351
+
1352
+ @icontract.require(
1353
+ lambda import_name: is_non_empty_string(import_name),
1354
+ "import_name must be a non-empty string",
1355
+ )
1356
+ @icontract.require(
1357
+ lambda known_modules: isinstance(known_modules, dict),
1358
+ "known_modules must be a dictionary",
1359
+ )
1360
+ @icontract.ensure(
1361
+ lambda result: result is None or isinstance(result, str),
1362
+ "result must be a string or None",
1363
+ )
1364
+ def _resolve_to_known_module(
1365
+ import_name: str,
1366
+ known_modules: dict[str, ModuleInfo],
1367
+ ) -> str | None:
1368
+ """Try to map an import name to a known internal module path.
1369
+
1370
+ Attempts various transformations: dotted to path, with/without
1371
+ package prefix, etc.
1372
+
1373
+ Args:
1374
+ import_name: The import module name to resolve.
1375
+ known_modules: Map of known module paths to ModuleInfo.
1376
+
1377
+ Returns:
1378
+ The resolved module_path string, or None if not internal.
1379
+ """
1380
+ if import_name in known_modules:
1381
+ return known_modules[import_name].module_path
1382
+
1383
+ path_form = import_name.replace(".", "/")
1384
+ if path_form in known_modules:
1385
+ return known_modules[path_form].module_path
1386
+
1387
+ if f"{path_form}.py" in known_modules:
1388
+ return known_modules[f"{path_form}.py"].module_path
1389
+
1390
+ # Try stripping common prefix segments
1391
+ parts = import_name.split(".")
1392
+ # Loop invariant: checked parts[i:] for matches
1393
+ for i in range(len(parts)):
1394
+ suffix = "/".join(parts[i:])
1395
+ if suffix in known_modules:
1396
+ return known_modules[suffix].module_path
1397
+ if f"{suffix}.py" in known_modules:
1398
+ return known_modules[f"{suffix}.py"].module_path
1399
+
1400
+ return None
1401
+
1402
+
1403
+ @icontract.require(
1404
+ lambda from_module: isinstance(from_module, str),
1405
+ "from_module must be a string",
1406
+ )
1407
+ @icontract.require(
1408
+ lambda imported_name: is_non_empty_string(imported_name),
1409
+ "imported_name must be a non-empty string",
1410
+ )
1411
+ @icontract.require(
1412
+ lambda known_modules: isinstance(known_modules, dict),
1413
+ "known_modules must be a dictionary",
1414
+ )
1415
+ @icontract.ensure(
1416
+ lambda result: result is None or isinstance(result, str),
1417
+ "result must be a string or None",
1418
+ )
1419
+ def _resolve_from_import_target(
1420
+ from_module: str,
1421
+ imported_name: str,
1422
+ known_modules: dict[str, ModuleInfo],
1423
+ ) -> str | None:
1424
+ """Resolve a from-import to the most specific known internal module."""
1425
+ if from_module:
1426
+ combined = _resolve_to_known_module(f"{from_module}.{imported_name}", known_modules)
1427
+ if combined is not None:
1428
+ return combined
1429
+
1430
+ resolved = _resolve_to_known_module(from_module, known_modules)
1431
+ if resolved is not None:
1432
+ return resolved
1433
+
1434
+ return _resolve_to_known_module(imported_name, known_modules)
1435
+
1436
+
1437
+ @icontract.require(
1438
+ lambda graph: isinstance(graph, dict),
1439
+ "graph must be a dictionary",
1440
+ )
1441
+ @icontract.ensure(
1442
+ lambda result: isinstance(result, list),
1443
+ "result must be a list",
1444
+ )
1445
+ def _find_cycles(graph: dict[str, set[str]]) -> list[tuple[str, ...]]:
1446
+ """Find all cycles in a directed graph using DFS coloring.
1447
+
1448
+ Args:
1449
+ graph: Adjacency list representation of the directed graph.
1450
+
1451
+ Returns:
1452
+ List of tuples, each representing a cycle path.
1453
+ """
1454
+ white, gray, black = 0, 1, 2
1455
+ color: dict[str, int] = {v: white for v in graph}
1456
+ path: list[str] = []
1457
+ cycles: list[tuple[str, ...]] = []
1458
+
1459
+ def _dfs(node: str) -> None:
1460
+ """DFS visit for cycle detection.
1461
+
1462
+ Args:
1463
+ node: Current node to visit.
1464
+ """
1465
+ # Variant: number of WHITE nodes decreases with each call
1466
+ color[node] = gray
1467
+ path.append(node)
1468
+ # Loop invariant: checked neighbors[0..i] for back-edges
1469
+ for neighbor in graph.get(node, set()):
1470
+ if neighbor not in color:
1471
+ continue
1472
+ if color[neighbor] == gray:
1473
+ idx = path.index(neighbor)
1474
+ cycles.append(tuple(path[idx:]))
1475
+ elif color[neighbor] == white:
1476
+ _dfs(neighbor)
1477
+ path.pop()
1478
+ color[node] = black
1479
+
1480
+ # Loop invariant: DFS completed for all WHITE nodes in graph[0..i]
1481
+ for node in graph:
1482
+ if color[node] == white:
1483
+ _dfs(node)
1484
+
1485
+ return cycles
1486
+
1487
+
1488
+ # ---------------------------------------------------------------------------
1489
+ # Assume-guarantee reasoning
1490
+ # ---------------------------------------------------------------------------
1491
+
1492
+
1493
+ @icontract.require(
1494
+ lambda modules: isinstance(modules, list),
1495
+ "modules must be a list",
1496
+ )
1497
+ @icontract.ensure(
1498
+ lambda result: isinstance(result, list),
1499
+ "result must be a list",
1500
+ )
1501
+ def check_assume_guarantee(
1502
+ modules: list[ModuleInfo],
1503
+ config: SerenecodeConfig,
1504
+ ) -> list[FunctionResult]:
1505
+ """Check assume-guarantee reasoning across module boundaries.
1506
+
1507
+ For each cross-module function call, verifies that:
1508
+ 1. If the callee has preconditions, the caller has postconditions
1509
+ to guarantee them.
1510
+ 2. If the callee has preconditions and the caller has parameters,
1511
+ the caller has preconditions to constrain its own inputs.
1512
+
1513
+ Args:
1514
+ modules: List of parsed module information.
1515
+ config: Active Serenecode configuration.
1516
+
1517
+ Returns:
1518
+ List of FunctionResult for assume-guarantee violations.
1519
+ """
1520
+ results: list[FunctionResult] = []
1521
+
1522
+ module_functions = _build_module_function_map(modules)
1523
+ import_map = _build_import_resolution_map(modules)
1524
+
1525
+ # Loop invariant: results contains assume-guarantee findings for modules[0..i]
1526
+ for mod in modules:
1527
+ if is_exempt_module(mod.module_path, config):
1528
+ continue
1529
+
1530
+ # Track reported pairs to avoid duplicates from multiple calls
1531
+ reported_ensure: set[str] = set()
1532
+ reported_require: set[str] = set()
1533
+
1534
+ # Loop invariant: results contains findings for function_infos[0..j]
1535
+ for func_info in mod.function_infos:
1536
+ if not func_info.is_public:
1537
+ continue
1538
+
1539
+ # Loop invariant: results contains findings for calls[0..k]
1540
+ for call_target in func_info.calls:
1541
+ resolved = _resolve_call_target(
1542
+ call_target, mod, import_map, module_functions,
1543
+ )
1544
+ if resolved is None:
1545
+ continue
1546
+
1547
+ callee_module, callee_func = resolved
1548
+
1549
+ if callee_module == mod.module_path:
1550
+ continue
1551
+
1552
+ ensure_key = f"{func_info.name}->{callee_module}"
1553
+ if (
1554
+ callee_func.has_require
1555
+ and not func_info.has_ensure
1556
+ and ensure_key not in reported_ensure
1557
+ ):
1558
+ reported_ensure.add(ensure_key)
1559
+ results.append(FunctionResult(
1560
+ function=func_info.name,
1561
+ file=mod.file_path,
1562
+ line=func_info.line,
1563
+ level_requested=6,
1564
+ level_achieved=5,
1565
+ status=CheckStatus.FAILED,
1566
+ details=(Detail(
1567
+ level=VerificationLevel.COMPOSITIONAL,
1568
+ tool="compositional",
1569
+ finding_type="violation",
1570
+ message=(
1571
+ f"Function '{func_info.name}' calls "
1572
+ f"'{callee_func.name}' (in {callee_module}) "
1573
+ f"which has preconditions, but "
1574
+ f"'{func_info.name}' lacks postconditions"
1575
+ ),
1576
+ suggestion=(
1577
+ f"Add @icontract.ensure to '{func_info.name}' "
1578
+ f"to document guarantees for "
1579
+ f"'{callee_func.name}'"
1580
+ ),
1581
+ ),),
1582
+ ))
1583
+
1584
+ require_key = f"{func_info.name}->{callee_module}"
1585
+ if (
1586
+ callee_func.has_require
1587
+ and len(func_info.parameters) > 0
1588
+ and not func_info.has_require
1589
+ and require_key not in reported_require
1590
+ ):
1591
+ reported_require.add(require_key)
1592
+ results.append(FunctionResult(
1593
+ function=func_info.name,
1594
+ file=mod.file_path,
1595
+ line=func_info.line,
1596
+ level_requested=6,
1597
+ level_achieved=5,
1598
+ status=CheckStatus.FAILED,
1599
+ details=(Detail(
1600
+ level=VerificationLevel.COMPOSITIONAL,
1601
+ tool="compositional",
1602
+ finding_type="violation",
1603
+ message=(
1604
+ f"Function '{func_info.name}' passes data to "
1605
+ f"'{callee_func.name}' (in {callee_module}) "
1606
+ f"which has preconditions, but "
1607
+ f"'{func_info.name}' has no preconditions "
1608
+ f"to constrain its inputs"
1609
+ ),
1610
+ suggestion=(
1611
+ f"Add @icontract.require to "
1612
+ f"'{func_info.name}' to constrain inputs "
1613
+ f"flowing to '{callee_func.name}'"
1614
+ ),
1615
+ ),),
1616
+ ))
1617
+
1618
+ return results
1619
+
1620
+
1621
+ @icontract.require(
1622
+ lambda modules: isinstance(modules, list),
1623
+ "modules must be a list",
1624
+ )
1625
+ @icontract.ensure(
1626
+ lambda result: isinstance(result, dict),
1627
+ "result must be a dictionary",
1628
+ )
1629
+ def _build_module_function_map(
1630
+ modules: list[ModuleInfo],
1631
+ ) -> dict[str, dict[str, FunctionInfo]]:
1632
+ """Build a lookup map of module_path -> {function_name -> FunctionInfo}.
1633
+
1634
+ Args:
1635
+ modules: List of parsed module information.
1636
+
1637
+ Returns:
1638
+ Nested dict mapping module paths to their function info by name.
1639
+ """
1640
+ result: dict[str, dict[str, FunctionInfo]] = {}
1641
+ # Loop invariant: result contains entries for modules[0..i]
1642
+ for mod in modules:
1643
+ func_map: dict[str, FunctionInfo] = {}
1644
+ # Loop invariant: func_map contains entries for function_infos[0..j]
1645
+ for fi in mod.function_infos:
1646
+ func_map[fi.name] = fi
1647
+ result[mod.module_path] = func_map
1648
+ return result
1649
+
1650
+
1651
+ @icontract.require(
1652
+ lambda modules: isinstance(modules, list),
1653
+ "modules must be a list",
1654
+ )
1655
+ @icontract.ensure(
1656
+ lambda result: isinstance(result, dict),
1657
+ "result must be a dictionary",
1658
+ )
1659
+ def _build_import_resolution_map(
1660
+ modules: list[ModuleInfo],
1661
+ ) -> dict[str, dict[str, tuple[str, str | None]]]:
1662
+ """Build a map of what names are imported into each module.
1663
+
1664
+ Args:
1665
+ modules: List of parsed module information.
1666
+
1667
+ Returns:
1668
+ Dict of module_path -> {bound_name -> (source_module, original_name)}.
1669
+ """
1670
+ result: dict[str, dict[str, tuple[str, str | None]]] = {}
1671
+ # Loop invariant: result contains entries for modules[0..i]
1672
+ for mod in modules:
1673
+ names: dict[str, tuple[str, str | None]] = {}
1674
+ if mod.import_bindings:
1675
+ # Loop invariant: names contains entries for import_bindings[0..j]
1676
+ for bound_name, source_mod, original_name in mod.import_bindings:
1677
+ names[bound_name] = (source_mod, original_name)
1678
+ else:
1679
+ # Backward-compatible fallback for tests that build ModuleInfo manually.
1680
+ # Loop invariant: names contains entries for from_imports[0..j]
1681
+ for from_mod, name in mod.from_imports:
1682
+ names[name] = (from_mod, name)
1683
+ result[mod.module_path] = names
1684
+ return result
1685
+
1686
+
1687
+ @icontract.require(lambda name: isinstance(name, str), "name must be a string")
1688
+ @icontract.ensure(
1689
+ lambda result: isinstance(result, str),
1690
+ "result must be a string",
1691
+ )
1692
+ def _normalize_module_name(name: str) -> str:
1693
+ """Normalize a module name for comparison by converting to dot-separated form.
1694
+
1695
+ Strips '.py' suffix and replaces '/' with '.'.
1696
+
1697
+ Args:
1698
+ name: A module name or path string.
1699
+
1700
+ Returns:
1701
+ Normalized dot-separated module name.
1702
+ """
1703
+ return name.removesuffix(".py").replace("/", ".")
1704
+
1705
+
1706
+ @icontract.require(lambda module_name: isinstance(module_name, str), "module_name must be a string")
1707
+ @icontract.require(lambda reference: isinstance(reference, str), "reference must be a string")
1708
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
1709
+ def _module_name_matches_reference(module_name: str, reference: str) -> bool:
1710
+ """Check whether a module name matches a reference on full segment boundaries."""
1711
+ normalized_module = _normalize_module_name(module_name)
1712
+ normalized_reference = _normalize_module_name(reference)
1713
+ if not normalized_module or not normalized_reference:
1714
+ return False
1715
+
1716
+ module_parts = tuple(part for part in normalized_module.split(".") if part)
1717
+ reference_parts = tuple(part for part in normalized_reference.split(".") if part)
1718
+ if not module_parts or not reference_parts or len(reference_parts) > len(module_parts):
1719
+ return False
1720
+
1721
+ return module_parts[-len(reference_parts):] == reference_parts
1722
+
1723
+
1724
+ @icontract.require(
1725
+ lambda call_target: isinstance(call_target, str),
1726
+ "call_target must be a string",
1727
+ )
1728
+ @icontract.require(
1729
+ lambda caller_module: isinstance(caller_module, ModuleInfo),
1730
+ "caller_module must be a ModuleInfo",
1731
+ )
1732
+ @icontract.require(
1733
+ lambda import_map: isinstance(import_map, dict),
1734
+ "import_map must be a dictionary",
1735
+ )
1736
+ @icontract.require(
1737
+ lambda module_functions: isinstance(module_functions, dict),
1738
+ "module_functions must be a dictionary",
1739
+ )
1740
+ @icontract.ensure(
1741
+ lambda result: result is None or isinstance(result, tuple),
1742
+ "result must be a tuple or None",
1743
+ )
1744
+ def _resolve_call_target(
1745
+ call_target: str,
1746
+ caller_module: ModuleInfo,
1747
+ import_map: dict[str, dict[str, tuple[str, str | None]]],
1748
+ module_functions: dict[str, dict[str, FunctionInfo]],
1749
+ ) -> tuple[str, FunctionInfo] | None:
1750
+ """Try to resolve a call target to a specific module and FunctionInfo.
1751
+
1752
+ Args:
1753
+ call_target: The call target name string.
1754
+ caller_module: The module containing the call.
1755
+ import_map: Map of imported names per module.
1756
+ module_functions: Map of functions per module.
1757
+
1758
+ Returns:
1759
+ Tuple of (module_path, FunctionInfo) or None if unresolvable.
1760
+ """
1761
+ # Case 1: simple name — check if it was imported from another module
1762
+ if "." not in call_target:
1763
+ imports = import_map.get(caller_module.module_path, {})
1764
+ if call_target in imports:
1765
+ source_mod, orig_name = imports[call_target]
1766
+ target_name = orig_name if orig_name is not None else call_target
1767
+ # Loop invariant: checked module_functions entries[0..i]
1768
+ for mod_path, funcs in module_functions.items():
1769
+ if _module_name_matches_reference(mod_path, source_mod):
1770
+ if target_name in funcs:
1771
+ return (mod_path, funcs[target_name])
1772
+ return None
1773
+
1774
+ # Case 2: dotted name — e.g., "module.function"
1775
+ parts = call_target.rsplit(".", 1)
1776
+ if len(parts) == 2:
1777
+ module_part, func_name = parts
1778
+ imports = import_map.get(caller_module.module_path, {})
1779
+ if module_part in imports:
1780
+ source_mod, orig_name = imports[module_part]
1781
+ if orig_name is not None:
1782
+ normalized_part = _normalize_module_name(f"{source_mod}.{orig_name}")
1783
+ else:
1784
+ normalized_part = _normalize_module_name(source_mod)
1785
+ else:
1786
+ normalized_part = _normalize_module_name(module_part)
1787
+ # Loop invariant: checked module_functions entries[0..i]
1788
+ for mod_path, funcs in module_functions.items():
1789
+ if _module_name_matches_reference(mod_path, normalized_part) and func_name in funcs:
1790
+ return (mod_path, funcs[func_name])
1791
+
1792
+ return None
1793
+
1794
+
1795
+ # ---------------------------------------------------------------------------
1796
+ # Data flow verification
1797
+ # ---------------------------------------------------------------------------
1798
+
1799
+
1800
+ @icontract.require(
1801
+ lambda modules: isinstance(modules, list),
1802
+ "modules must be a list",
1803
+ )
1804
+ @icontract.ensure(
1805
+ lambda result: isinstance(result, list),
1806
+ "result must be a list",
1807
+ )
1808
+ def check_data_flow(
1809
+ modules: list[ModuleInfo],
1810
+ config: SerenecodeConfig,
1811
+ ) -> list[FunctionResult]:
1812
+ """Verify that data flowing across module boundaries maintains contracts.
1813
+
1814
+ For each cross-module function call between public functions, checks:
1815
+ 1. The callee's parameters have type annotations.
1816
+ 2. If the callee has preconditions, the caller has a return type annotation.
1817
+
1818
+ Args:
1819
+ modules: List of parsed module information.
1820
+ config: Active Serenecode configuration.
1821
+
1822
+ Returns:
1823
+ List of FunctionResult for data flow violations.
1824
+ """
1825
+ results: list[FunctionResult] = []
1826
+ module_functions = _build_module_function_map(modules)
1827
+ import_map = _build_import_resolution_map(modules)
1828
+
1829
+ # Loop invariant: results contains data flow findings for modules[0..i]
1830
+ for mod in modules:
1831
+ if is_exempt_module(mod.module_path, config):
1832
+ continue
1833
+
1834
+ # Track reported pairs to avoid duplicates
1835
+ reported_untyped: set[str] = set()
1836
+ reported_return: set[str] = set()
1837
+
1838
+ # Loop invariant: results contains findings for function_infos[0..j]
1839
+ for func_info in mod.function_infos:
1840
+ if not func_info.is_public:
1841
+ continue
1842
+
1843
+ # Loop invariant: results contains findings for calls[0..k]
1844
+ for call_target in func_info.calls:
1845
+ resolved = _resolve_call_target(
1846
+ call_target, mod, import_map, module_functions,
1847
+ )
1848
+ if resolved is None:
1849
+ continue
1850
+
1851
+ callee_module, callee_func = resolved
1852
+ if callee_module == mod.module_path:
1853
+ continue
1854
+
1855
+ if not callee_func.is_public:
1856
+ continue
1857
+
1858
+ # Check: callee parameters should be typed at boundaries
1859
+ untyped = [
1860
+ p for p in callee_func.parameters
1861
+ if p.annotation is None
1862
+ ]
1863
+ untyped_key = f"{callee_module}.{callee_func.name}"
1864
+ if untyped and untyped_key not in reported_untyped:
1865
+ reported_untyped.add(untyped_key)
1866
+ param_names = ", ".join(p.name for p in untyped)
1867
+ callee_file = _find_file_for_module(callee_module, modules)
1868
+ results.append(FunctionResult(
1869
+ function=callee_func.name,
1870
+ file=callee_file,
1871
+ line=callee_func.line,
1872
+ level_requested=6,
1873
+ level_achieved=5,
1874
+ status=CheckStatus.FAILED,
1875
+ details=(Detail(
1876
+ level=VerificationLevel.COMPOSITIONAL,
1877
+ tool="compositional",
1878
+ finding_type="violation",
1879
+ message=(
1880
+ f"Function '{callee_func.name}' receives "
1881
+ f"cross-module data but parameters "
1882
+ f"[{param_names}] lack type annotations"
1883
+ ),
1884
+ suggestion=(
1885
+ "Add type annotations to all parameters "
1886
+ "that receive cross-module data"
1887
+ ),
1888
+ ),),
1889
+ ))
1890
+
1891
+ # Check: caller return type should be annotated
1892
+ return_key = f"{func_info.name}->{callee_module}"
1893
+ if (
1894
+ callee_func.has_require
1895
+ and func_info.return_annotation is None
1896
+ and return_key not in reported_return
1897
+ ):
1898
+ reported_return.add(return_key)
1899
+ results.append(FunctionResult(
1900
+ function=func_info.name,
1901
+ file=mod.file_path,
1902
+ line=func_info.line,
1903
+ level_requested=6,
1904
+ level_achieved=5,
1905
+ status=CheckStatus.FAILED,
1906
+ details=(Detail(
1907
+ level=VerificationLevel.COMPOSITIONAL,
1908
+ tool="compositional",
1909
+ finding_type="violation",
1910
+ message=(
1911
+ f"Function '{func_info.name}' provides data "
1912
+ f"to '{callee_func.name}' (which has "
1913
+ f"preconditions) but lacks a return type "
1914
+ f"annotation"
1915
+ ),
1916
+ suggestion=(
1917
+ "Add return type annotation to document "
1918
+ "the data contract"
1919
+ ),
1920
+ ),),
1921
+ ))
1922
+
1923
+ return results
1924
+
1925
+
1926
+ @icontract.require(
1927
+ lambda module_path: is_non_empty_string(module_path),
1928
+ "module_path must be a non-empty string",
1929
+ )
1930
+ @icontract.require(
1931
+ lambda modules: isinstance(modules, list),
1932
+ "modules must be a list",
1933
+ )
1934
+ @icontract.ensure(
1935
+ lambda result: isinstance(result, str),
1936
+ "result must be a string",
1937
+ )
1938
+ def _find_file_for_module(
1939
+ module_path: str,
1940
+ modules: list[ModuleInfo],
1941
+ ) -> str:
1942
+ """Find the file path for a given module path.
1943
+
1944
+ Args:
1945
+ module_path: The module path to look up.
1946
+ modules: List of all parsed modules.
1947
+
1948
+ Returns:
1949
+ The file path string, or '<unknown>' if not found.
1950
+ """
1951
+ # Loop invariant: checked modules[0..i] for matching module_path
1952
+ for mod in modules:
1953
+ if mod.module_path == module_path:
1954
+ return mod.file_path
1955
+ return "<unknown>"
1956
+
1957
+
1958
+ # ---------------------------------------------------------------------------
1959
+ # System invariants
1960
+ # ---------------------------------------------------------------------------
1961
+
1962
+
1963
+ @icontract.require(
1964
+ lambda modules: isinstance(modules, list),
1965
+ "modules must be a list",
1966
+ )
1967
+ @icontract.ensure(
1968
+ lambda result: isinstance(result, list),
1969
+ "result must be a list",
1970
+ )
1971
+ def check_system_invariants(
1972
+ modules: list[ModuleInfo],
1973
+ config: SerenecodeConfig,
1974
+ ) -> list[FunctionResult]:
1975
+ """Verify system-wide architectural invariants.
1976
+
1977
+ Checks:
1978
+ 1. All public classes in ports/ are Protocols.
1979
+ 2. Every Protocol has at least one adapter implementation.
1980
+ 3. Core modules do not import forbidden I/O libraries.
1981
+
1982
+ Args:
1983
+ modules: List of parsed module information.
1984
+ config: Active Serenecode configuration.
1985
+
1986
+ Returns:
1987
+ List of FunctionResult for system invariant violations.
1988
+ """
1989
+ results: list[FunctionResult] = []
1990
+
1991
+ # Collect all protocols from ports
1992
+ all_protocols: dict[str, tuple[str, ProtocolInfo]] = {}
1993
+ # Loop invariant: all_protocols and non-protocol findings updated for modules[0..i]
1994
+ for mod in modules:
1995
+ if not _module_path_has_segment(mod.module_path, "ports"):
1996
+ continue
1997
+ # Loop invariant: all_protocols updated for mod.protocols[0..j]
1998
+ for proto in mod.protocols:
1999
+ all_protocols[proto.name] = (mod.file_path, proto)
2000
+ # Check non-protocol classes in ports (allow DTO dataclasses)
2001
+ # Loop invariant: results updated for mod.classes[0..j]
2002
+ for cls in mod.classes:
2003
+ if cls.is_protocol or cls.name.startswith("_"):
2004
+ continue
2005
+ # Allow dataclass DTOs: classes with no public methods are data carriers
2006
+ public_methods = [m for m in cls.methods if not m.startswith("_")]
2007
+ if public_methods:
2008
+ results.append(FunctionResult(
2009
+ function=cls.name,
2010
+ file=mod.file_path,
2011
+ line=cls.line,
2012
+ level_requested=6,
2013
+ level_achieved=5,
2014
+ status=CheckStatus.FAILED,
2015
+ details=(Detail(
2016
+ level=VerificationLevel.COMPOSITIONAL,
2017
+ tool="compositional",
2018
+ finding_type="violation",
2019
+ message=(
2020
+ f"Class '{cls.name}' in ports/ is not a "
2021
+ f"Protocol. All classes in ports/ must be "
2022
+ f"Protocol definitions or data carriers."
2023
+ ),
2024
+ suggestion=(
2025
+ "Add Protocol as a base class, or move to "
2026
+ "adapters/"
2027
+ ),
2028
+ ),),
2029
+ ))
2030
+
2031
+ # Check every Protocol has at least one likely implementation
2032
+ adapter_classes: list[ClassInfo] = []
2033
+ # Loop invariant: adapter_classes contains classes from adapter modules[0..i]
2034
+ for mod in modules:
2035
+ if _module_path_has_segment(mod.module_path, "adapters"):
2036
+ adapter_classes.extend(mod.classes)
2037
+
2038
+ # Loop invariant: results updated for all_protocols entries[0..i]
2039
+ for proto_name, (proto_file, proto_info) in all_protocols.items():
2040
+ has_impl = False
2041
+ # Loop invariant: has_impl is True if any adapter_classes[0..j] implements proto
2042
+ for cls in adapter_classes:
2043
+ if _class_likely_implements(cls, proto_info):
2044
+ has_impl = True
2045
+ break
2046
+ if not has_impl:
2047
+ results.append(FunctionResult(
2048
+ function=proto_name,
2049
+ file=proto_file,
2050
+ line=proto_info.line,
2051
+ level_requested=6,
2052
+ level_achieved=6,
2053
+ status=CheckStatus.PASSED,
2054
+ details=(Detail(
2055
+ level=VerificationLevel.COMPOSITIONAL,
2056
+ tool="compositional",
2057
+ finding_type="info",
2058
+ message=(
2059
+ f"Protocol '{proto_name}' has no detected adapter "
2060
+ f"implementation. This may indicate a missing "
2061
+ f"adapter or a detection limitation."
2062
+ ),
2063
+ ),),
2064
+ ))
2065
+
2066
+ # Check forbidden imports in core
2067
+ forbidden = set(config.architecture_rules.forbidden_imports_in_core)
2068
+ # Loop invariant: results updated for modules[0..i] regarding forbidden imports
2069
+ for mod in modules:
2070
+ if not is_core_module(mod.module_path, config):
2071
+ continue
2072
+ # Loop invariant: results updated for imports[0..j]
2073
+ for imp in mod.imports:
2074
+ top_module = imp.split(".")[0]
2075
+ if top_module in forbidden:
2076
+ results.append(FunctionResult(
2077
+ function="<module>",
2078
+ file=mod.file_path,
2079
+ line=1,
2080
+ level_requested=6,
2081
+ level_achieved=5,
2082
+ status=CheckStatus.FAILED,
2083
+ details=(Detail(
2084
+ level=VerificationLevel.COMPOSITIONAL,
2085
+ tool="compositional",
2086
+ finding_type="violation",
2087
+ message=(
2088
+ f"Core module '{mod.module_path}' imports "
2089
+ f"forbidden I/O library '{imp}'"
2090
+ ),
2091
+ suggestion=(
2092
+ "Use dependency injection via a Protocol "
2093
+ "in ports/"
2094
+ ),
2095
+ ),),
2096
+ ))
2097
+ # Loop invariant: results updated for from_imports[0..j]
2098
+ for from_mod, _ in mod.from_imports:
2099
+ top_module = from_mod.split(".")[0]
2100
+ if top_module in forbidden:
2101
+ results.append(FunctionResult(
2102
+ function="<module>",
2103
+ file=mod.file_path,
2104
+ line=1,
2105
+ level_requested=6,
2106
+ level_achieved=5,
2107
+ status=CheckStatus.FAILED,
2108
+ details=(Detail(
2109
+ level=VerificationLevel.COMPOSITIONAL,
2110
+ tool="compositional",
2111
+ finding_type="violation",
2112
+ message=(
2113
+ f"Core module '{mod.module_path}' imports from "
2114
+ f"forbidden I/O library '{from_mod}'"
2115
+ ),
2116
+ suggestion=(
2117
+ "Use dependency injection via a Protocol "
2118
+ "in ports/"
2119
+ ),
2120
+ ),),
2121
+ ))
2122
+
2123
+ return results
2124
+
2125
+
2126
+ # ---------------------------------------------------------------------------
2127
+ # Orchestrator
2128
+ # ---------------------------------------------------------------------------
2129
+
2130
+
2131
+ @icontract.require(
2132
+ lambda sources: isinstance(sources, (list, tuple)),
2133
+ "sources must be a list or tuple",
2134
+ )
2135
+ @icontract.ensure(
2136
+ lambda result: isinstance(result, CheckResult),
2137
+ "result must be a CheckResult",
2138
+ )
2139
+ def check_compositional(
2140
+ sources: list[tuple[str, str, str]] | tuple[tuple[str, str, str], ...],
2141
+ config: SerenecodeConfig,
2142
+ ) -> CheckResult:
2143
+ """Run the full Level 5 compositional check on a set of source files.
2144
+
2145
+ This is the main entry point for compositional verification. It
2146
+ parses all modules, then runs all compositional checks: dependency
2147
+ direction, circular dependencies, interface compliance, contract
2148
+ completeness, assume-guarantee reasoning, data flow verification,
2149
+ and system invariants.
2150
+
2151
+ Args:
2152
+ sources: Sequence of (source_code, file_path, module_path) tuples.
2153
+ config: Active Serenecode configuration.
2154
+
2155
+ Returns:
2156
+ A CheckResult containing all compositional findings.
2157
+ """
2158
+ start_time = time.monotonic()
2159
+
2160
+ # Parse all modules
2161
+ modules: list[ModuleInfo] = []
2162
+ # Loop invariant: modules contains parsed info for sources[0..i]
2163
+ for source, file_path, module_path in sources:
2164
+ mod_info = parse_module_info(source, file_path, module_path)
2165
+ modules.append(mod_info)
2166
+
2167
+ # Run all compositional checks
2168
+ all_results: list[FunctionResult] = []
2169
+
2170
+ # Report parse errors so they are visible instead of silently ignored
2171
+ # Loop invariant: all_results contains parse error findings for modules[0..i]
2172
+ for mod in modules:
2173
+ if mod.parse_error is not None:
2174
+ all_results.append(FunctionResult(
2175
+ function="<module>",
2176
+ file=mod.file_path,
2177
+ line=1,
2178
+ level_requested=6,
2179
+ level_achieved=5,
2180
+ status=CheckStatus.SKIPPED,
2181
+ details=(Detail(
2182
+ level=VerificationLevel.COMPOSITIONAL,
2183
+ tool="compositional",
2184
+ finding_type="parse_error",
2185
+ message=f"Could not parse '{mod.file_path}': {mod.parse_error}",
2186
+ suggestion="Fix the syntax error before running compositional verification",
2187
+ ),),
2188
+ ))
2189
+ all_results.extend(check_dependency_direction(modules, config))
2190
+ all_results.extend(check_circular_dependencies(modules, config))
2191
+ all_results.extend(check_interface_compliance(modules, config))
2192
+ all_results.extend(check_contract_completeness(modules, config))
2193
+ all_results.extend(check_assume_guarantee(modules, config))
2194
+ all_results.extend(check_data_flow(modules, config))
2195
+ all_results.extend(check_system_invariants(modules, config))
2196
+
2197
+ elapsed = time.monotonic() - start_time
2198
+ return make_check_result(
2199
+ tuple(all_results),
2200
+ level_requested=6,
2201
+ duration_seconds=elapsed,
2202
+ )
2203
+ @icontract.require(lambda cls: isinstance(cls, ClassInfo), "cls must be a ClassInfo")
2204
+ @icontract.ensure(
2205
+ lambda result: isinstance(result, bool),
2206
+ "result must be a bool",
2207
+ )
2208
+ def _is_exception_class(cls: ClassInfo) -> bool:
2209
+ """Check if a class participates in an exception hierarchy."""
2210
+ # Loop invariant: checked bases[0..i] for exception-like base names
2211
+ for base in cls.bases:
2212
+ if base in {"Exception", "BaseException"}:
2213
+ return True
2214
+ if base.endswith(("Error", "Exception")):
2215
+ return True
2216
+ return False