serenecode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- serenecode/__init__.py +281 -0
- serenecode/adapters/__init__.py +6 -0
- serenecode/adapters/coverage_adapter.py +1173 -0
- serenecode/adapters/crosshair_adapter.py +1069 -0
- serenecode/adapters/hypothesis_adapter.py +1824 -0
- serenecode/adapters/local_fs.py +169 -0
- serenecode/adapters/module_loader.py +492 -0
- serenecode/adapters/mypy_adapter.py +161 -0
- serenecode/checker/__init__.py +6 -0
- serenecode/checker/compositional.py +2216 -0
- serenecode/checker/coverage.py +186 -0
- serenecode/checker/properties.py +154 -0
- serenecode/checker/structural.py +1504 -0
- serenecode/checker/symbolic.py +178 -0
- serenecode/checker/types.py +148 -0
- serenecode/cli.py +478 -0
- serenecode/config.py +711 -0
- serenecode/contracts/__init__.py +6 -0
- serenecode/contracts/predicates.py +176 -0
- serenecode/core/__init__.py +6 -0
- serenecode/core/exceptions.py +38 -0
- serenecode/core/pipeline.py +807 -0
- serenecode/init.py +307 -0
- serenecode/models.py +308 -0
- serenecode/ports/__init__.py +6 -0
- serenecode/ports/coverage_analyzer.py +124 -0
- serenecode/ports/file_system.py +95 -0
- serenecode/ports/property_tester.py +69 -0
- serenecode/ports/symbolic_checker.py +70 -0
- serenecode/ports/type_checker.py +66 -0
- serenecode/reporter.py +346 -0
- serenecode/source_discovery.py +319 -0
- serenecode/templates/__init__.py +5 -0
- serenecode/templates/content.py +337 -0
- serenecode-0.1.0.dist-info/METADATA +298 -0
- serenecode-0.1.0.dist-info/RECORD +39 -0
- serenecode-0.1.0.dist-info/WHEEL +4 -0
- serenecode-0.1.0.dist-info/entry_points.txt +2 -0
- serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|