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,1504 @@
|
|
|
1
|
+
"""Structural checker for Serenecode conventions (Level 1).
|
|
2
|
+
|
|
3
|
+
This module implements Level 1 verification: AST-based analysis that validates
|
|
4
|
+
Python source code follows the conventions defined in SERENECODE.md. It checks
|
|
5
|
+
for the presence of contracts, type annotations, and architectural compliance.
|
|
6
|
+
|
|
7
|
+
This is a core module — no I/O operations are permitted. Source code is received
|
|
8
|
+
as strings, not read from files.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import io
|
|
15
|
+
import re
|
|
16
|
+
import tokenize
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import icontract
|
|
21
|
+
|
|
22
|
+
from serenecode.config import SerenecodeConfig, is_core_module, is_exempt_module
|
|
23
|
+
from serenecode.contracts.predicates import is_non_empty_string, is_pascal_case, is_snake_case
|
|
24
|
+
from serenecode.models import (
|
|
25
|
+
CheckResult,
|
|
26
|
+
CheckStatus,
|
|
27
|
+
Detail,
|
|
28
|
+
FunctionResult,
|
|
29
|
+
VerificationLevel,
|
|
30
|
+
make_check_result,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Import alias resolution
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@icontract.invariant(
|
|
40
|
+
lambda self: isinstance(self.require_names, frozenset) and isinstance(self.ensure_names, frozenset),
|
|
41
|
+
"Decorator name sets must be frozensets",
|
|
42
|
+
)
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IcontractNames:
|
|
45
|
+
"""Resolved icontract decorator names for a module.
|
|
46
|
+
|
|
47
|
+
Tracks how icontract is imported so the checker can recognize
|
|
48
|
+
decorators regardless of import style.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
module_alias: str | None # e.g. "icontract" or "ic"
|
|
52
|
+
require_names: frozenset[str] # e.g. {"require"} or {"ic.require"}
|
|
53
|
+
ensure_names: frozenset[str]
|
|
54
|
+
invariant_names: frozenset[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@icontract.require(
|
|
58
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
59
|
+
"tree must be an ast.Module",
|
|
60
|
+
)
|
|
61
|
+
@icontract.ensure(
|
|
62
|
+
lambda result: isinstance(result, IcontractNames),
|
|
63
|
+
"result must be an IcontractNames",
|
|
64
|
+
)
|
|
65
|
+
def resolve_icontract_aliases(tree: ast.Module) -> IcontractNames:
|
|
66
|
+
"""Scan imports to determine how icontract decorators are referenced.
|
|
67
|
+
|
|
68
|
+
Handles:
|
|
69
|
+
- import icontract
|
|
70
|
+
- import icontract as ic
|
|
71
|
+
- from icontract import require, ensure, invariant
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
tree: The parsed AST module.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
An IcontractNames with all recognized decorator names.
|
|
78
|
+
"""
|
|
79
|
+
module_alias: str | None = None
|
|
80
|
+
require_names: set[str] = set()
|
|
81
|
+
ensure_names: set[str] = set()
|
|
82
|
+
invariant_names: set[str] = set()
|
|
83
|
+
|
|
84
|
+
# Loop invariant: sets contain all icontract names found in nodes[0..i]
|
|
85
|
+
for node in ast.iter_child_nodes(tree):
|
|
86
|
+
if isinstance(node, ast.Import):
|
|
87
|
+
# Loop invariant: aliases processed for all names in node.names[0..j]
|
|
88
|
+
for alias in node.names:
|
|
89
|
+
if alias.name == "icontract":
|
|
90
|
+
actual_alias = alias.asname if alias.asname else "icontract"
|
|
91
|
+
module_alias = actual_alias
|
|
92
|
+
require_names.add(f"{actual_alias}.require")
|
|
93
|
+
ensure_names.add(f"{actual_alias}.ensure")
|
|
94
|
+
invariant_names.add(f"{actual_alias}.invariant")
|
|
95
|
+
elif isinstance(node, ast.ImportFrom):
|
|
96
|
+
if node.module == "icontract":
|
|
97
|
+
# Loop invariant: icontract names resolved for node.names[0..j]
|
|
98
|
+
for alias in node.names:
|
|
99
|
+
actual_name = alias.asname if alias.asname else alias.name
|
|
100
|
+
if alias.name == "require":
|
|
101
|
+
require_names.add(actual_name)
|
|
102
|
+
elif alias.name == "ensure":
|
|
103
|
+
ensure_names.add(actual_name)
|
|
104
|
+
elif alias.name == "invariant":
|
|
105
|
+
invariant_names.add(actual_name)
|
|
106
|
+
|
|
107
|
+
return IcontractNames(
|
|
108
|
+
module_alias=module_alias,
|
|
109
|
+
require_names=frozenset(require_names),
|
|
110
|
+
ensure_names=frozenset(ensure_names),
|
|
111
|
+
invariant_names=frozenset(invariant_names),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Decorator matching helpers
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@icontract.require(
|
|
121
|
+
lambda decorator: isinstance(decorator, ast.AST),
|
|
122
|
+
"decorator must be an AST node",
|
|
123
|
+
)
|
|
124
|
+
@icontract.ensure(
|
|
125
|
+
lambda result: isinstance(result, str),
|
|
126
|
+
"result must be a string",
|
|
127
|
+
)
|
|
128
|
+
def get_decorator_name(decorator: ast.expr) -> str:
|
|
129
|
+
"""Extract the full dotted name of a decorator.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
decorator: An AST decorator expression.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The decorator name string (e.g. "icontract.require" or "require").
|
|
136
|
+
"""
|
|
137
|
+
# Variant: depth decreases as decorator nesting decreases
|
|
138
|
+
if isinstance(decorator, ast.Call) and hasattr(decorator, "func"):
|
|
139
|
+
return get_decorator_name(decorator.func)
|
|
140
|
+
elif isinstance(decorator, ast.Attribute) and hasattr(decorator, "value") and hasattr(decorator, "attr"):
|
|
141
|
+
value_name = get_decorator_name(decorator.value)
|
|
142
|
+
attr = decorator.attr
|
|
143
|
+
if not isinstance(attr, str):
|
|
144
|
+
return ""
|
|
145
|
+
return f"{value_name}.{attr}" if value_name else attr
|
|
146
|
+
elif isinstance(decorator, ast.Name) and hasattr(decorator, "id"):
|
|
147
|
+
node_id = decorator.id
|
|
148
|
+
return node_id if isinstance(node_id, str) else ""
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@icontract.require(
|
|
153
|
+
lambda names: isinstance(names, frozenset),
|
|
154
|
+
"names must be a frozenset",
|
|
155
|
+
)
|
|
156
|
+
@icontract.ensure(
|
|
157
|
+
lambda result: isinstance(result, bool),
|
|
158
|
+
"result must be a bool",
|
|
159
|
+
)
|
|
160
|
+
def has_decorator(
|
|
161
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
162
|
+
names: frozenset[str],
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""Check if a node has any decorator matching the given names.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
node: An AST node with a decorator_list.
|
|
168
|
+
names: Set of decorator name strings to match.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if any decorator matches.
|
|
172
|
+
"""
|
|
173
|
+
# Loop invariant: result is True if any decorator in decorators[0..i] matches names
|
|
174
|
+
for dec in node.decorator_list:
|
|
175
|
+
if get_decorator_name(dec) in names:
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@icontract.require(
|
|
181
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)),
|
|
182
|
+
"node must be a function or class definition",
|
|
183
|
+
)
|
|
184
|
+
@icontract.require(
|
|
185
|
+
lambda names: isinstance(names, frozenset),
|
|
186
|
+
"names must be a frozenset",
|
|
187
|
+
)
|
|
188
|
+
@icontract.ensure(
|
|
189
|
+
lambda result: isinstance(result, bool),
|
|
190
|
+
"result must be a bool",
|
|
191
|
+
)
|
|
192
|
+
def _decorator_has_description(
|
|
193
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
194
|
+
names: frozenset[str],
|
|
195
|
+
) -> bool:
|
|
196
|
+
"""Check if decorators matching names include a description string.
|
|
197
|
+
|
|
198
|
+
icontract decorators should have at least 2 positional args:
|
|
199
|
+
the lambda condition and a description string.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
node: An AST node with a decorator_list.
|
|
203
|
+
names: Set of decorator name strings to match.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if all matching decorators have description strings.
|
|
207
|
+
"""
|
|
208
|
+
# Loop invariant: all_have_desc is True if all matched decorators in [0..i] have descriptions
|
|
209
|
+
for dec in node.decorator_list:
|
|
210
|
+
if isinstance(dec, ast.Call) and get_decorator_name(dec) in names:
|
|
211
|
+
if len(dec.args) < 2:
|
|
212
|
+
# Check for description= keyword argument
|
|
213
|
+
has_desc_kwarg = False
|
|
214
|
+
# Loop invariant: has_desc_kwarg is True if any keyword in [0..j] is "description"
|
|
215
|
+
for kw in dec.keywords:
|
|
216
|
+
if kw.arg == "description":
|
|
217
|
+
has_desc_kwarg = True
|
|
218
|
+
break
|
|
219
|
+
if not has_desc_kwarg:
|
|
220
|
+
return False
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@icontract.require(
|
|
225
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
|
|
226
|
+
"node must be a function definition",
|
|
227
|
+
)
|
|
228
|
+
@icontract.ensure(
|
|
229
|
+
lambda result: isinstance(result, list),
|
|
230
|
+
"result must be a list",
|
|
231
|
+
)
|
|
232
|
+
def _non_receiver_parameters(
|
|
233
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
234
|
+
) -> list[ast.arg]:
|
|
235
|
+
"""Return all non-self/cls parameters from a function signature."""
|
|
236
|
+
args = node.args
|
|
237
|
+
params = list(args.posonlyargs) + list(args.args)
|
|
238
|
+
if params and params[0].arg in ("self", "cls"):
|
|
239
|
+
params = params[1:]
|
|
240
|
+
params.extend(args.kwonlyargs)
|
|
241
|
+
if args.vararg is not None:
|
|
242
|
+
params.append(args.vararg)
|
|
243
|
+
if args.kwarg is not None:
|
|
244
|
+
params.append(args.kwarg)
|
|
245
|
+
return params
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@icontract.require(
|
|
249
|
+
lambda node: isinstance(node, ast.ClassDef),
|
|
250
|
+
"node must be a class definition",
|
|
251
|
+
)
|
|
252
|
+
@icontract.ensure(
|
|
253
|
+
lambda result: isinstance(result, list),
|
|
254
|
+
"result must be a list",
|
|
255
|
+
)
|
|
256
|
+
def _extract_init_fields(node: ast.ClassDef) -> list[str]:
|
|
257
|
+
"""Extract field names assigned in __init__ (self.x = ...) or class-level annotations."""
|
|
258
|
+
fields: list[str] = []
|
|
259
|
+
# Check class-level annotated fields (dataclass-style)
|
|
260
|
+
# Loop invariant: fields contains annotated names from body[0..i]
|
|
261
|
+
for item in node.body:
|
|
262
|
+
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
263
|
+
fields.append(item.target.id)
|
|
264
|
+
if fields:
|
|
265
|
+
return fields
|
|
266
|
+
# Check __init__ for self.x = ... assignments
|
|
267
|
+
# Loop invariant: checked body items [0..i] for __init__
|
|
268
|
+
for item in node.body:
|
|
269
|
+
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
|
|
270
|
+
# Loop invariant: fields contains self.attr names from init body[0..j]
|
|
271
|
+
for stmt in ast.walk(item):
|
|
272
|
+
if (
|
|
273
|
+
isinstance(stmt, ast.Assign)
|
|
274
|
+
and len(stmt.targets) == 1
|
|
275
|
+
and isinstance(stmt.targets[0], ast.Attribute)
|
|
276
|
+
and isinstance(stmt.targets[0].value, ast.Name)
|
|
277
|
+
and stmt.targets[0].value.id == "self"
|
|
278
|
+
):
|
|
279
|
+
fields.append(stmt.targets[0].attr)
|
|
280
|
+
break
|
|
281
|
+
return fields
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@icontract.require(
|
|
285
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
|
|
286
|
+
"node must be a function definition",
|
|
287
|
+
)
|
|
288
|
+
@icontract.ensure(
|
|
289
|
+
lambda result: result is None or isinstance(result, str),
|
|
290
|
+
"result must be None or a string",
|
|
291
|
+
)
|
|
292
|
+
def _get_return_annotation_str(
|
|
293
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
294
|
+
) -> str | None:
|
|
295
|
+
"""Extract the return type annotation as a string, or None if absent."""
|
|
296
|
+
if node.returns is None:
|
|
297
|
+
return None
|
|
298
|
+
return ast.unparse(node.returns)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@icontract.require(
|
|
302
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
|
|
303
|
+
"node must be a function definition",
|
|
304
|
+
)
|
|
305
|
+
@icontract.ensure(
|
|
306
|
+
lambda result: isinstance(result, bool),
|
|
307
|
+
"result must be a bool",
|
|
308
|
+
)
|
|
309
|
+
def _has_meaningful_params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
310
|
+
"""Check if a function has parameters beyond self/cls.
|
|
311
|
+
|
|
312
|
+
Functions with no input parameters have no preconditions to check.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
node: A function definition AST node.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
True if the function has at least one non-self/cls parameter.
|
|
319
|
+
"""
|
|
320
|
+
return bool(_non_receiver_parameters(node))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@icontract.require(
|
|
324
|
+
lambda name: is_non_empty_string(name),
|
|
325
|
+
"name must be a non-empty string",
|
|
326
|
+
)
|
|
327
|
+
@icontract.ensure(
|
|
328
|
+
lambda result: isinstance(result, bool),
|
|
329
|
+
"result must be a bool",
|
|
330
|
+
)
|
|
331
|
+
def _is_public_function(name: str) -> bool:
|
|
332
|
+
"""Check if a function name indicates a public function.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
name: Function name.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
True if the function is public (not private, not dunder except __init__).
|
|
339
|
+
"""
|
|
340
|
+
if name.startswith("_") and not name.startswith("__"):
|
|
341
|
+
return False
|
|
342
|
+
if name.startswith("__") and name.endswith("__") and name != "__init__":
|
|
343
|
+
return False
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@icontract.require(
|
|
348
|
+
lambda node: isinstance(node, ast.ClassDef),
|
|
349
|
+
"node must be a class definition",
|
|
350
|
+
)
|
|
351
|
+
@icontract.require(
|
|
352
|
+
lambda source: isinstance(source, str),
|
|
353
|
+
"source must be a string",
|
|
354
|
+
)
|
|
355
|
+
@icontract.ensure(
|
|
356
|
+
lambda result: isinstance(result, bool),
|
|
357
|
+
"result must be a bool",
|
|
358
|
+
)
|
|
359
|
+
def _has_no_invariant_comment(node: ast.ClassDef, source: str) -> bool:
|
|
360
|
+
"""Check if the class is preceded by a '# no-invariant:' comment.
|
|
361
|
+
|
|
362
|
+
This allows explicitly documented stateless classes to opt out of
|
|
363
|
+
the invariant requirement.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
node: A class definition AST node.
|
|
367
|
+
source: The full module source code.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
True if a '# no-invariant:' comment is found on the line before the class.
|
|
371
|
+
"""
|
|
372
|
+
if not source:
|
|
373
|
+
return False
|
|
374
|
+
lines = source.splitlines()
|
|
375
|
+
class_line_index = node.lineno - 1
|
|
376
|
+
# Check the line immediately before the class definition
|
|
377
|
+
# Loop invariant: checking lines above the class for no-invariant comment
|
|
378
|
+
for offset in range(1, min(6, class_line_index + 1)):
|
|
379
|
+
prev_line = lines[class_line_index - offset].strip()
|
|
380
|
+
if prev_line.startswith("# no-invariant:"):
|
|
381
|
+
return True
|
|
382
|
+
if prev_line.startswith("#"):
|
|
383
|
+
continue
|
|
384
|
+
# Stop at non-comment, non-decorator lines
|
|
385
|
+
if not prev_line.startswith("@"):
|
|
386
|
+
break
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@icontract.require(
|
|
391
|
+
lambda name: is_non_empty_string(name),
|
|
392
|
+
"name must be a non-empty string",
|
|
393
|
+
)
|
|
394
|
+
@icontract.require(
|
|
395
|
+
lambda config: isinstance(config, SerenecodeConfig),
|
|
396
|
+
"config must be a SerenecodeConfig",
|
|
397
|
+
)
|
|
398
|
+
@icontract.ensure(
|
|
399
|
+
lambda result: isinstance(result, bool),
|
|
400
|
+
"result must be a bool",
|
|
401
|
+
)
|
|
402
|
+
def _should_check_function_contracts(
|
|
403
|
+
name: str,
|
|
404
|
+
config: SerenecodeConfig,
|
|
405
|
+
) -> bool:
|
|
406
|
+
"""Check whether contract requirements apply to a function name."""
|
|
407
|
+
if config.contract_requirements.require_on_private:
|
|
408
|
+
return not (name.startswith("__") and name.endswith("__") and name != "__init__")
|
|
409
|
+
return _is_public_function(name)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@icontract.require(
|
|
413
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
|
|
414
|
+
"node must be a function definition",
|
|
415
|
+
)
|
|
416
|
+
@icontract.ensure(
|
|
417
|
+
lambda result: isinstance(result, bool),
|
|
418
|
+
"result must be a bool",
|
|
419
|
+
)
|
|
420
|
+
def _has_property_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
421
|
+
"""Check if a function is decorated with @property.
|
|
422
|
+
|
|
423
|
+
@property methods are incompatible with icontract decorators
|
|
424
|
+
due to decorator ordering constraints.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
node: A function definition AST node.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
True if the function has a @property decorator.
|
|
431
|
+
"""
|
|
432
|
+
# Loop invariant: checked decorators[0..i] for property name
|
|
433
|
+
for dec in node.decorator_list:
|
|
434
|
+
if isinstance(dec, ast.Name) and dec.id == "property":
|
|
435
|
+
return True
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@icontract.require(
|
|
440
|
+
lambda node: isinstance(node, ast.ClassDef),
|
|
441
|
+
"node must be a class definition",
|
|
442
|
+
)
|
|
443
|
+
@icontract.ensure(
|
|
444
|
+
lambda result: isinstance(result, bool),
|
|
445
|
+
"result must be a bool",
|
|
446
|
+
)
|
|
447
|
+
def _is_enum_class(node: ast.ClassDef) -> bool:
|
|
448
|
+
"""Check if a class inherits from Enum or IntEnum.
|
|
449
|
+
|
|
450
|
+
Enum classes use metaclasses incompatible with icontract invariants.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
node: A class definition AST node.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
True if the class inherits from Enum, IntEnum, or similar.
|
|
457
|
+
"""
|
|
458
|
+
_ENUM_BASES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
|
|
459
|
+
# Loop invariant: checked bases[0..i] for enum names
|
|
460
|
+
for base in node.bases:
|
|
461
|
+
if isinstance(base, ast.Name) and base.id in _ENUM_BASES:
|
|
462
|
+
return True
|
|
463
|
+
if isinstance(base, ast.Attribute) and base.attr in _ENUM_BASES:
|
|
464
|
+
return True
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@icontract.require(
|
|
469
|
+
lambda node: isinstance(node, ast.ClassDef),
|
|
470
|
+
"node must be a class definition",
|
|
471
|
+
)
|
|
472
|
+
@icontract.ensure(
|
|
473
|
+
lambda result: isinstance(result, bool),
|
|
474
|
+
"result must be a bool",
|
|
475
|
+
)
|
|
476
|
+
def _is_exception_class(node: ast.ClassDef) -> bool:
|
|
477
|
+
"""Check if a class participates in an exception hierarchy."""
|
|
478
|
+
# Loop invariant: checked bases[0..i] for exception-like base classes
|
|
479
|
+
for base in node.bases:
|
|
480
|
+
base_name = ""
|
|
481
|
+
if isinstance(base, ast.Name):
|
|
482
|
+
base_name = base.id
|
|
483
|
+
elif isinstance(base, ast.Attribute):
|
|
484
|
+
base_name = base.attr
|
|
485
|
+
|
|
486
|
+
if base_name in {"Exception", "BaseException"}:
|
|
487
|
+
return True
|
|
488
|
+
if base_name.endswith(("Error", "Exception")):
|
|
489
|
+
return True
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@icontract.require(
|
|
494
|
+
lambda node: isinstance(node, ast.ClassDef),
|
|
495
|
+
"node must be a class definition",
|
|
496
|
+
)
|
|
497
|
+
@icontract.ensure(
|
|
498
|
+
lambda result: isinstance(result, bool),
|
|
499
|
+
"result must be a bool",
|
|
500
|
+
)
|
|
501
|
+
def _is_protocol_class(node: ast.ClassDef) -> bool:
|
|
502
|
+
"""Check if a class inherits from Protocol.
|
|
503
|
+
|
|
504
|
+
Protocol classes are abstract interfaces — icontract invariants on
|
|
505
|
+
Protocols are not inherited by implementors and would only verify
|
|
506
|
+
the Protocol itself (which is never instantiated).
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
node: A class definition AST node.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
True if the class inherits from Protocol.
|
|
513
|
+
"""
|
|
514
|
+
# Loop invariant: checked bases[0..i] for Protocol name
|
|
515
|
+
for base in node.bases:
|
|
516
|
+
if isinstance(base, ast.Name) and base.id == "Protocol":
|
|
517
|
+
return True
|
|
518
|
+
if isinstance(base, ast.Attribute) and base.attr == "Protocol":
|
|
519
|
+
return True
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@icontract.require(
|
|
524
|
+
lambda name: is_non_empty_string(name),
|
|
525
|
+
"name must be a non-empty string",
|
|
526
|
+
)
|
|
527
|
+
@icontract.ensure(
|
|
528
|
+
lambda result: isinstance(result, bool),
|
|
529
|
+
"result must be a bool",
|
|
530
|
+
)
|
|
531
|
+
def _is_public_class(name: str) -> bool:
|
|
532
|
+
"""Check if a class name indicates a public class.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
name: Class name.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
True if the class name doesn't start with underscore.
|
|
539
|
+
"""
|
|
540
|
+
return not name.startswith("_")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@icontract.require(
|
|
544
|
+
lambda name: is_non_empty_string(name),
|
|
545
|
+
"name must be a non-empty string",
|
|
546
|
+
)
|
|
547
|
+
@icontract.require(
|
|
548
|
+
lambda config: isinstance(config, SerenecodeConfig),
|
|
549
|
+
"config must be a SerenecodeConfig",
|
|
550
|
+
)
|
|
551
|
+
@icontract.ensure(
|
|
552
|
+
lambda result: isinstance(result, bool),
|
|
553
|
+
"result must be a bool",
|
|
554
|
+
)
|
|
555
|
+
def _should_check_class_invariant(
|
|
556
|
+
name: str,
|
|
557
|
+
config: SerenecodeConfig,
|
|
558
|
+
) -> bool:
|
|
559
|
+
"""Check whether invariant requirements apply to a class name."""
|
|
560
|
+
if config.contract_requirements.require_on_private:
|
|
561
|
+
return True
|
|
562
|
+
return _is_public_class(name)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@icontract.require(
|
|
566
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
567
|
+
"tree must be an ast.Module",
|
|
568
|
+
)
|
|
569
|
+
@icontract.ensure(
|
|
570
|
+
lambda result: isinstance(result, list),
|
|
571
|
+
"result must be a list",
|
|
572
|
+
)
|
|
573
|
+
def _iter_checked_functions(
|
|
574
|
+
tree: ast.Module,
|
|
575
|
+
) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
576
|
+
"""Return module-level functions and class methods to check.
|
|
577
|
+
|
|
578
|
+
Local closures defined inside function bodies are implementation details,
|
|
579
|
+
so structural contract/docstring rules apply to top-level functions and
|
|
580
|
+
methods rather than nested helper closures.
|
|
581
|
+
"""
|
|
582
|
+
functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
583
|
+
|
|
584
|
+
# Loop invariant: functions contains checkable defs from top-level nodes[0..i]
|
|
585
|
+
for node in ast.iter_child_nodes(tree):
|
|
586
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
587
|
+
functions.append(node)
|
|
588
|
+
elif isinstance(node, ast.ClassDef):
|
|
589
|
+
# Loop invariant: functions contains methods from class body[0..j]
|
|
590
|
+
for child in node.body:
|
|
591
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
592
|
+
functions.append(child)
|
|
593
|
+
|
|
594
|
+
return functions
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@icontract.require(
|
|
598
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
599
|
+
"tree must be an ast.Module",
|
|
600
|
+
)
|
|
601
|
+
@icontract.ensure(
|
|
602
|
+
lambda result: isinstance(result, list),
|
|
603
|
+
"result must be a list",
|
|
604
|
+
)
|
|
605
|
+
def _iter_checked_classes(tree: ast.Module) -> list[ast.ClassDef]:
|
|
606
|
+
"""Return the top-level classes that participate in structural checks."""
|
|
607
|
+
classes: list[ast.ClassDef] = []
|
|
608
|
+
|
|
609
|
+
# Loop invariant: classes contains top-level class defs from nodes[0..i]
|
|
610
|
+
for node in ast.iter_child_nodes(tree):
|
|
611
|
+
if isinstance(node, ast.ClassDef):
|
|
612
|
+
classes.append(node)
|
|
613
|
+
|
|
614
|
+
return classes
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# Individual check functions
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@icontract.require(
|
|
623
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
624
|
+
"tree must be an ast.Module",
|
|
625
|
+
)
|
|
626
|
+
@icontract.ensure(
|
|
627
|
+
lambda result: isinstance(result, list),
|
|
628
|
+
"result must be a list",
|
|
629
|
+
)
|
|
630
|
+
def check_contracts(
|
|
631
|
+
tree: ast.Module,
|
|
632
|
+
config: SerenecodeConfig,
|
|
633
|
+
aliases: IcontractNames,
|
|
634
|
+
file_path: str,
|
|
635
|
+
) -> list[FunctionResult]:
|
|
636
|
+
"""Check that public functions have icontract require/ensure decorators.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
tree: Parsed AST module.
|
|
640
|
+
config: Active configuration.
|
|
641
|
+
aliases: Resolved icontract import names.
|
|
642
|
+
file_path: Path to the source file (for reporting).
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
List of FunctionResult for each function checked.
|
|
646
|
+
"""
|
|
647
|
+
results: list[FunctionResult] = []
|
|
648
|
+
|
|
649
|
+
checkable_functions = _iter_checked_functions(tree)
|
|
650
|
+
# Loop invariant: results contains check outcomes for checkable_functions[0..i]
|
|
651
|
+
for node in checkable_functions:
|
|
652
|
+
if not _should_check_function_contracts(node.name, config):
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
# Skip @property-decorated methods (incompatible with icontract decorators)
|
|
656
|
+
if _has_property_decorator(node):
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
details: list[Detail] = []
|
|
660
|
+
|
|
661
|
+
# Skip require check for functions with no meaningful parameters
|
|
662
|
+
# (zero params after excluding self/cls)
|
|
663
|
+
params = _non_receiver_parameters(node)
|
|
664
|
+
param_names = [p.arg for p in params]
|
|
665
|
+
has_params = bool(params)
|
|
666
|
+
if has_params and not has_decorator(node, aliases.require_names):
|
|
667
|
+
param_list = ", ".join(param_names)
|
|
668
|
+
example_param = param_names[0]
|
|
669
|
+
details.append(Detail(
|
|
670
|
+
level=VerificationLevel.STRUCTURAL,
|
|
671
|
+
tool="structural",
|
|
672
|
+
finding_type="violation",
|
|
673
|
+
message=f"Function '{node.name}' missing @icontract.require (precondition)",
|
|
674
|
+
suggestion=(
|
|
675
|
+
f"Add precondition for parameters ({param_list}). "
|
|
676
|
+
f"Example: @icontract.require(lambda {example_param}: "
|
|
677
|
+
f"{example_param} is not None, \"{example_param} must not be None\")"
|
|
678
|
+
),
|
|
679
|
+
))
|
|
680
|
+
|
|
681
|
+
if not has_decorator(node, aliases.ensure_names):
|
|
682
|
+
return_hint = _get_return_annotation_str(node)
|
|
683
|
+
details.append(Detail(
|
|
684
|
+
level=VerificationLevel.STRUCTURAL,
|
|
685
|
+
tool="structural",
|
|
686
|
+
finding_type="violation",
|
|
687
|
+
message=f"Function '{node.name}' missing @icontract.ensure (postcondition)",
|
|
688
|
+
suggestion=(
|
|
689
|
+
f"Add postcondition. "
|
|
690
|
+
f"Example: @icontract.ensure(lambda result: "
|
|
691
|
+
f"result is not None, \"result must not be None\")"
|
|
692
|
+
if return_hint is None
|
|
693
|
+
else f"Add postcondition for return type '{return_hint}'. "
|
|
694
|
+
f"Example: @icontract.ensure(lambda result: "
|
|
695
|
+
f"isinstance(result, {return_hint}), "
|
|
696
|
+
f"\"result must be {return_hint}\")"
|
|
697
|
+
),
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
if (
|
|
701
|
+
config.contract_requirements.require_description_strings
|
|
702
|
+
and not details # only check descriptions if decorators present
|
|
703
|
+
):
|
|
704
|
+
all_names = aliases.require_names | aliases.ensure_names
|
|
705
|
+
if not _decorator_has_description(node, all_names):
|
|
706
|
+
details.append(Detail(
|
|
707
|
+
level=VerificationLevel.STRUCTURAL,
|
|
708
|
+
tool="structural",
|
|
709
|
+
finding_type="violation",
|
|
710
|
+
message=f"Function '{node.name}' has contract without description string",
|
|
711
|
+
suggestion="Add a description string as second argument to contract decorator",
|
|
712
|
+
))
|
|
713
|
+
|
|
714
|
+
status = CheckStatus.PASSED if not details else CheckStatus.FAILED
|
|
715
|
+
results.append(FunctionResult(
|
|
716
|
+
function=node.name,
|
|
717
|
+
file=file_path,
|
|
718
|
+
line=node.lineno,
|
|
719
|
+
level_requested=1,
|
|
720
|
+
level_achieved=1 if not details else 0,
|
|
721
|
+
status=status,
|
|
722
|
+
details=tuple(details),
|
|
723
|
+
))
|
|
724
|
+
|
|
725
|
+
return results
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@icontract.require(
|
|
729
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
730
|
+
"tree must be an ast.Module",
|
|
731
|
+
)
|
|
732
|
+
@icontract.ensure(
|
|
733
|
+
lambda result: isinstance(result, list),
|
|
734
|
+
"result must be a list",
|
|
735
|
+
)
|
|
736
|
+
def check_class_invariants(
|
|
737
|
+
tree: ast.Module,
|
|
738
|
+
config: SerenecodeConfig,
|
|
739
|
+
aliases: IcontractNames,
|
|
740
|
+
file_path: str,
|
|
741
|
+
source: str = "",
|
|
742
|
+
) -> list[FunctionResult]:
|
|
743
|
+
"""Check that classes have @icontract.invariant decorators.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
tree: Parsed AST module.
|
|
747
|
+
config: Active configuration.
|
|
748
|
+
aliases: Resolved icontract import names.
|
|
749
|
+
file_path: Path to the source file.
|
|
750
|
+
source: Original source code for comment checking.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
List of FunctionResult for each class checked.
|
|
754
|
+
"""
|
|
755
|
+
if not config.contract_requirements.require_on_classes:
|
|
756
|
+
return []
|
|
757
|
+
|
|
758
|
+
results: list[FunctionResult] = []
|
|
759
|
+
|
|
760
|
+
checkable_classes = _iter_checked_classes(tree)
|
|
761
|
+
# Loop invariant: results contains check outcomes for checkable_classes[0..i]
|
|
762
|
+
for node in checkable_classes:
|
|
763
|
+
if not _should_check_class_invariant(node.name, config):
|
|
764
|
+
continue
|
|
765
|
+
|
|
766
|
+
# Skip Enum/exception/Protocol classes because icontract invariants do
|
|
767
|
+
# not compose safely with their runtime mechanics. Protocol invariants
|
|
768
|
+
# are never inherited by implementors and would only verify nothing.
|
|
769
|
+
# Skip classes with a "# no-invariant:" comment documenting why they
|
|
770
|
+
# have no meaningful state to constrain.
|
|
771
|
+
if _is_enum_class(node) or _is_exception_class(node) or _is_protocol_class(node):
|
|
772
|
+
continue
|
|
773
|
+
if _has_no_invariant_comment(node, source):
|
|
774
|
+
continue
|
|
775
|
+
|
|
776
|
+
details: list[Detail] = []
|
|
777
|
+
|
|
778
|
+
if not has_decorator(node, aliases.invariant_names):
|
|
779
|
+
fields = _extract_init_fields(node)
|
|
780
|
+
if fields:
|
|
781
|
+
field_list = ", ".join(f"self.{f}" for f in fields)
|
|
782
|
+
example_field = fields[0]
|
|
783
|
+
suggestion = (
|
|
784
|
+
f"Add invariant constraining instance state ({field_list}). "
|
|
785
|
+
f"Example: @icontract.invariant(lambda self: "
|
|
786
|
+
f"self.{example_field} is not None, "
|
|
787
|
+
f"\"{example_field} must not be None\")"
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
suggestion = (
|
|
791
|
+
"Add @icontract.invariant(lambda self: ..., 'description') "
|
|
792
|
+
"or add '# no-invariant: <reason>' if the class is stateless"
|
|
793
|
+
)
|
|
794
|
+
details.append(Detail(
|
|
795
|
+
level=VerificationLevel.STRUCTURAL,
|
|
796
|
+
tool="structural",
|
|
797
|
+
finding_type="violation",
|
|
798
|
+
message=f"Class '{node.name}' missing @icontract.invariant",
|
|
799
|
+
suggestion=suggestion,
|
|
800
|
+
))
|
|
801
|
+
|
|
802
|
+
status = CheckStatus.PASSED if not details else CheckStatus.FAILED
|
|
803
|
+
results.append(FunctionResult(
|
|
804
|
+
function=node.name,
|
|
805
|
+
file=file_path,
|
|
806
|
+
line=node.lineno,
|
|
807
|
+
level_requested=1,
|
|
808
|
+
level_achieved=1 if not details else 0,
|
|
809
|
+
status=status,
|
|
810
|
+
details=tuple(details),
|
|
811
|
+
))
|
|
812
|
+
|
|
813
|
+
return results
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@icontract.require(
|
|
817
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
818
|
+
"tree must be an ast.Module",
|
|
819
|
+
)
|
|
820
|
+
@icontract.ensure(
|
|
821
|
+
lambda result: isinstance(result, list),
|
|
822
|
+
"result must be a list",
|
|
823
|
+
)
|
|
824
|
+
def check_type_annotations(
|
|
825
|
+
tree: ast.Module,
|
|
826
|
+
config: SerenecodeConfig,
|
|
827
|
+
file_path: str,
|
|
828
|
+
) -> list[FunctionResult]:
|
|
829
|
+
"""Check that all function signatures have complete type annotations.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
tree: Parsed AST module.
|
|
833
|
+
config: Active configuration.
|
|
834
|
+
file_path: Path to the source file.
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
List of FunctionResult for functions with missing annotations.
|
|
838
|
+
"""
|
|
839
|
+
if not config.type_requirements.require_annotations:
|
|
840
|
+
return []
|
|
841
|
+
|
|
842
|
+
results: list[FunctionResult] = []
|
|
843
|
+
|
|
844
|
+
checkable_functions = _iter_checked_functions(tree)
|
|
845
|
+
# Loop invariant: results contains annotation findings for checkable_functions[0..i]
|
|
846
|
+
for node in checkable_functions:
|
|
847
|
+
details: list[Detail] = []
|
|
848
|
+
args = node.args
|
|
849
|
+
params_to_check = _non_receiver_parameters(node)
|
|
850
|
+
|
|
851
|
+
# Loop invariant: details contains missing annotations for params[0..j]
|
|
852
|
+
for arg in params_to_check:
|
|
853
|
+
if arg.annotation is None:
|
|
854
|
+
details.append(Detail(
|
|
855
|
+
level=VerificationLevel.STRUCTURAL,
|
|
856
|
+
tool="structural",
|
|
857
|
+
finding_type="violation",
|
|
858
|
+
message=f"Parameter '{arg.arg}' in '{node.name}' missing type annotation",
|
|
859
|
+
suggestion=f"Add type annotation: {arg.arg}: <type>",
|
|
860
|
+
))
|
|
861
|
+
|
|
862
|
+
# Check return type
|
|
863
|
+
if node.returns is None:
|
|
864
|
+
details.append(Detail(
|
|
865
|
+
level=VerificationLevel.STRUCTURAL,
|
|
866
|
+
tool="structural",
|
|
867
|
+
finding_type="violation",
|
|
868
|
+
message=f"Function '{node.name}' missing return type annotation",
|
|
869
|
+
suggestion="Add return type: def func(...) -> <type>:",
|
|
870
|
+
))
|
|
871
|
+
|
|
872
|
+
if details:
|
|
873
|
+
results.append(FunctionResult(
|
|
874
|
+
function=node.name,
|
|
875
|
+
file=file_path,
|
|
876
|
+
line=node.lineno,
|
|
877
|
+
level_requested=1,
|
|
878
|
+
level_achieved=0,
|
|
879
|
+
status=CheckStatus.FAILED,
|
|
880
|
+
details=tuple(details),
|
|
881
|
+
))
|
|
882
|
+
|
|
883
|
+
return results
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@icontract.require(
|
|
887
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
888
|
+
"tree must be an ast.Module",
|
|
889
|
+
)
|
|
890
|
+
@icontract.ensure(
|
|
891
|
+
lambda result: isinstance(result, list),
|
|
892
|
+
"result must be a list",
|
|
893
|
+
)
|
|
894
|
+
def check_no_any_in_core(
|
|
895
|
+
tree: ast.Module,
|
|
896
|
+
config: SerenecodeConfig,
|
|
897
|
+
module_path: str,
|
|
898
|
+
file_path: str,
|
|
899
|
+
) -> list[FunctionResult]:
|
|
900
|
+
"""Check that core modules don't use Any type.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
tree: Parsed AST module.
|
|
904
|
+
config: Active configuration.
|
|
905
|
+
module_path: Module path for core detection.
|
|
906
|
+
file_path: Path to the source file.
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
List of FunctionResult for Any usage violations.
|
|
910
|
+
"""
|
|
911
|
+
if not config.type_requirements.forbid_any_in_core:
|
|
912
|
+
return []
|
|
913
|
+
|
|
914
|
+
if not is_core_module(module_path, config):
|
|
915
|
+
return []
|
|
916
|
+
|
|
917
|
+
results: list[FunctionResult] = []
|
|
918
|
+
|
|
919
|
+
# Loop invariant: results contains Any-usage findings for nodes[0..i]
|
|
920
|
+
for node in ast.walk(tree):
|
|
921
|
+
if isinstance(node, ast.Name) and node.id == "Any":
|
|
922
|
+
results.append(FunctionResult(
|
|
923
|
+
function="<module>",
|
|
924
|
+
file=file_path,
|
|
925
|
+
line=node.lineno,
|
|
926
|
+
level_requested=1,
|
|
927
|
+
level_achieved=0,
|
|
928
|
+
status=CheckStatus.FAILED,
|
|
929
|
+
details=(Detail(
|
|
930
|
+
level=VerificationLevel.STRUCTURAL,
|
|
931
|
+
tool="structural",
|
|
932
|
+
finding_type="violation",
|
|
933
|
+
message=f"Use of 'Any' type at line {node.lineno} in core module",
|
|
934
|
+
suggestion="Replace 'Any' with a specific type, Union, or Protocol",
|
|
935
|
+
),),
|
|
936
|
+
))
|
|
937
|
+
|
|
938
|
+
return results
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@icontract.require(
|
|
942
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
943
|
+
"tree must be an ast.Module",
|
|
944
|
+
)
|
|
945
|
+
@icontract.ensure(
|
|
946
|
+
lambda result: isinstance(result, list),
|
|
947
|
+
"result must be a list",
|
|
948
|
+
)
|
|
949
|
+
def check_imports(
|
|
950
|
+
tree: ast.Module,
|
|
951
|
+
config: SerenecodeConfig,
|
|
952
|
+
module_path: str,
|
|
953
|
+
file_path: str,
|
|
954
|
+
) -> list[FunctionResult]:
|
|
955
|
+
"""Check that core modules don't import forbidden I/O libraries.
|
|
956
|
+
|
|
957
|
+
Args:
|
|
958
|
+
tree: Parsed AST module.
|
|
959
|
+
config: Active configuration.
|
|
960
|
+
module_path: Module path for core detection.
|
|
961
|
+
file_path: Path to the source file.
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
List of FunctionResult for import violations.
|
|
965
|
+
"""
|
|
966
|
+
if not is_core_module(module_path, config):
|
|
967
|
+
return []
|
|
968
|
+
|
|
969
|
+
forbidden = set(config.architecture_rules.forbidden_imports_in_core)
|
|
970
|
+
results: list[FunctionResult] = []
|
|
971
|
+
|
|
972
|
+
# Loop invariant: results contains import violations found in nodes[0..i]
|
|
973
|
+
for node in ast.walk(tree):
|
|
974
|
+
if isinstance(node, ast.Import):
|
|
975
|
+
# Loop invariant: violations checked for all names in node.names[0..j]
|
|
976
|
+
for alias in node.names:
|
|
977
|
+
top_module = alias.name.split(".")[0]
|
|
978
|
+
if top_module in forbidden:
|
|
979
|
+
results.append(FunctionResult(
|
|
980
|
+
function="<module>",
|
|
981
|
+
file=file_path,
|
|
982
|
+
line=node.lineno,
|
|
983
|
+
level_requested=1,
|
|
984
|
+
level_achieved=0,
|
|
985
|
+
status=CheckStatus.FAILED,
|
|
986
|
+
details=(Detail(
|
|
987
|
+
level=VerificationLevel.STRUCTURAL,
|
|
988
|
+
tool="structural",
|
|
989
|
+
finding_type="violation",
|
|
990
|
+
message=f"Forbidden import '{alias.name}' in core module",
|
|
991
|
+
suggestion="Move I/O operations to an adapter module",
|
|
992
|
+
),),
|
|
993
|
+
))
|
|
994
|
+
elif isinstance(node, ast.ImportFrom):
|
|
995
|
+
if node.module:
|
|
996
|
+
top_module = node.module.split(".")[0]
|
|
997
|
+
if top_module in forbidden:
|
|
998
|
+
results.append(FunctionResult(
|
|
999
|
+
function="<module>",
|
|
1000
|
+
file=file_path,
|
|
1001
|
+
line=node.lineno,
|
|
1002
|
+
level_requested=1,
|
|
1003
|
+
level_achieved=0,
|
|
1004
|
+
status=CheckStatus.FAILED,
|
|
1005
|
+
details=(Detail(
|
|
1006
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1007
|
+
tool="structural",
|
|
1008
|
+
finding_type="violation",
|
|
1009
|
+
message=f"Forbidden import from '{node.module}' in core module",
|
|
1010
|
+
suggestion="Move I/O operations to an adapter module",
|
|
1011
|
+
),),
|
|
1012
|
+
))
|
|
1013
|
+
|
|
1014
|
+
return results
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
@icontract.require(
|
|
1018
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
1019
|
+
"tree must be an ast.Module",
|
|
1020
|
+
)
|
|
1021
|
+
@icontract.ensure(
|
|
1022
|
+
lambda result: isinstance(result, list),
|
|
1023
|
+
"result must be a list",
|
|
1024
|
+
)
|
|
1025
|
+
def check_docstrings(
|
|
1026
|
+
tree: ast.Module,
|
|
1027
|
+
config: SerenecodeConfig,
|
|
1028
|
+
file_path: str,
|
|
1029
|
+
) -> list[FunctionResult]:
|
|
1030
|
+
"""Check that public functions, classes, and the module have docstrings.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
tree: Parsed AST module.
|
|
1034
|
+
config: Active configuration.
|
|
1035
|
+
file_path: Path to the source file.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
List of FunctionResult for missing docstrings.
|
|
1039
|
+
"""
|
|
1040
|
+
results: list[FunctionResult] = []
|
|
1041
|
+
|
|
1042
|
+
# Check module docstring
|
|
1043
|
+
if not ast.get_docstring(tree):
|
|
1044
|
+
results.append(FunctionResult(
|
|
1045
|
+
function="<module>",
|
|
1046
|
+
file=file_path,
|
|
1047
|
+
line=1,
|
|
1048
|
+
level_requested=1,
|
|
1049
|
+
level_achieved=0,
|
|
1050
|
+
status=CheckStatus.FAILED,
|
|
1051
|
+
details=(Detail(
|
|
1052
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1053
|
+
tool="structural",
|
|
1054
|
+
finding_type="violation",
|
|
1055
|
+
message="Module missing docstring",
|
|
1056
|
+
suggestion="Add a module-level docstring describing its role",
|
|
1057
|
+
),),
|
|
1058
|
+
))
|
|
1059
|
+
|
|
1060
|
+
checkable_classes = _iter_checked_classes(tree)
|
|
1061
|
+
# Loop invariant: results contains class docstring findings for checkable_classes[0..i]
|
|
1062
|
+
for node in checkable_classes:
|
|
1063
|
+
if _is_public_class(node.name):
|
|
1064
|
+
if not ast.get_docstring(node):
|
|
1065
|
+
results.append(FunctionResult(
|
|
1066
|
+
function=node.name,
|
|
1067
|
+
file=file_path,
|
|
1068
|
+
line=node.lineno,
|
|
1069
|
+
level_requested=1,
|
|
1070
|
+
level_achieved=0,
|
|
1071
|
+
status=CheckStatus.FAILED,
|
|
1072
|
+
details=(Detail(
|
|
1073
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1074
|
+
tool="structural",
|
|
1075
|
+
finding_type="violation",
|
|
1076
|
+
message=f"Class '{node.name}' missing docstring",
|
|
1077
|
+
suggestion="Add a docstring describing the class",
|
|
1078
|
+
),),
|
|
1079
|
+
))
|
|
1080
|
+
checkable_functions = _iter_checked_functions(tree)
|
|
1081
|
+
# Loop invariant: results contains function docstring findings for checkable_functions[0..i]
|
|
1082
|
+
for func_node in checkable_functions:
|
|
1083
|
+
if _is_public_function(func_node.name) and not ast.get_docstring(func_node):
|
|
1084
|
+
results.append(FunctionResult(
|
|
1085
|
+
function=func_node.name,
|
|
1086
|
+
file=file_path,
|
|
1087
|
+
line=func_node.lineno,
|
|
1088
|
+
level_requested=1,
|
|
1089
|
+
level_achieved=0,
|
|
1090
|
+
status=CheckStatus.FAILED,
|
|
1091
|
+
details=(Detail(
|
|
1092
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1093
|
+
tool="structural",
|
|
1094
|
+
finding_type="violation",
|
|
1095
|
+
message=f"Function '{func_node.name}' missing docstring",
|
|
1096
|
+
suggestion="Add a docstring describing what the function does",
|
|
1097
|
+
),),
|
|
1098
|
+
))
|
|
1099
|
+
|
|
1100
|
+
return results
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@icontract.require(
|
|
1104
|
+
lambda source: isinstance(source, str),
|
|
1105
|
+
"source must be a string",
|
|
1106
|
+
)
|
|
1107
|
+
@icontract.require(
|
|
1108
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
1109
|
+
"tree must be an ast.Module",
|
|
1110
|
+
)
|
|
1111
|
+
@icontract.ensure(
|
|
1112
|
+
lambda result: isinstance(result, list),
|
|
1113
|
+
"result must be a list",
|
|
1114
|
+
)
|
|
1115
|
+
def check_loop_invariants(
|
|
1116
|
+
source: str,
|
|
1117
|
+
tree: ast.Module,
|
|
1118
|
+
config: SerenecodeConfig,
|
|
1119
|
+
file_path: str,
|
|
1120
|
+
) -> list[FunctionResult]:
|
|
1121
|
+
"""Check that loops have invariant comments and recursive functions have variant docs.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
source: Raw source code string.
|
|
1125
|
+
tree: Parsed AST module.
|
|
1126
|
+
config: Active configuration.
|
|
1127
|
+
file_path: Path to the source file.
|
|
1128
|
+
|
|
1129
|
+
Returns:
|
|
1130
|
+
List of FunctionResult for missing loop invariant documentation.
|
|
1131
|
+
"""
|
|
1132
|
+
if not config.loop_recursion_rules.require_loop_invariant_comments:
|
|
1133
|
+
return []
|
|
1134
|
+
|
|
1135
|
+
# Extract comment line numbers and their content
|
|
1136
|
+
comments: dict[int, str] = {}
|
|
1137
|
+
try:
|
|
1138
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
1139
|
+
# Loop invariant: comments dict contains all COMMENT tokens seen so far
|
|
1140
|
+
for tok_type, tok_string, tok_start, _, _ in tokens:
|
|
1141
|
+
if tok_type == tokenize.COMMENT:
|
|
1142
|
+
comments[tok_start[0]] = tok_string.lower()
|
|
1143
|
+
except (tokenize.TokenError, UnicodeDecodeError, UnicodeEncodeError):
|
|
1144
|
+
return []
|
|
1145
|
+
|
|
1146
|
+
results: list[FunctionResult] = []
|
|
1147
|
+
invariant_keywords = ("invariant", "loop invariant")
|
|
1148
|
+
variant_keywords = ("variant", "decreasing", "termination")
|
|
1149
|
+
|
|
1150
|
+
# Loop invariant: results contains loop/recursion findings for nodes[0..i]
|
|
1151
|
+
for node in ast.walk(tree):
|
|
1152
|
+
if isinstance(node, (ast.While, ast.For)):
|
|
1153
|
+
loop_line = node.lineno
|
|
1154
|
+
has_invariant_comment = False
|
|
1155
|
+
|
|
1156
|
+
# Check lines around the loop for invariant comments
|
|
1157
|
+
# Loop invariant: has_invariant_comment is True if any checked line has invariant keyword
|
|
1158
|
+
for check_line in range(max(1, loop_line - 3), loop_line + 3):
|
|
1159
|
+
if check_line in comments:
|
|
1160
|
+
comment = comments[check_line]
|
|
1161
|
+
if any(kw in comment for kw in invariant_keywords):
|
|
1162
|
+
has_invariant_comment = True
|
|
1163
|
+
break
|
|
1164
|
+
|
|
1165
|
+
# Also check first few lines inside the loop body
|
|
1166
|
+
if not has_invariant_comment and node.body:
|
|
1167
|
+
first_body_line = node.body[0].lineno
|
|
1168
|
+
# Loop invariant: has_invariant_comment is True if any body line checked has invariant keyword
|
|
1169
|
+
for check_line in range(first_body_line, first_body_line + 2):
|
|
1170
|
+
if check_line in comments:
|
|
1171
|
+
comment = comments[check_line]
|
|
1172
|
+
if any(kw in comment for kw in invariant_keywords):
|
|
1173
|
+
has_invariant_comment = True
|
|
1174
|
+
break
|
|
1175
|
+
|
|
1176
|
+
if not has_invariant_comment:
|
|
1177
|
+
results.append(FunctionResult(
|
|
1178
|
+
function="<loop>",
|
|
1179
|
+
file=file_path,
|
|
1180
|
+
line=loop_line,
|
|
1181
|
+
level_requested=1,
|
|
1182
|
+
level_achieved=0,
|
|
1183
|
+
status=CheckStatus.FAILED,
|
|
1184
|
+
details=(Detail(
|
|
1185
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1186
|
+
tool="structural",
|
|
1187
|
+
finding_type="violation",
|
|
1188
|
+
message=f"Loop at line {loop_line} missing invariant comment",
|
|
1189
|
+
suggestion="Add a comment: # Loop invariant: <property>",
|
|
1190
|
+
),),
|
|
1191
|
+
))
|
|
1192
|
+
|
|
1193
|
+
# Check recursive functions for variant documentation
|
|
1194
|
+
if config.loop_recursion_rules.require_recursion_variant_comments:
|
|
1195
|
+
# Loop invariant: results contains variant findings for recursive functions in nodes[0..i]
|
|
1196
|
+
for node in ast.walk(tree):
|
|
1197
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1198
|
+
continue
|
|
1199
|
+
|
|
1200
|
+
is_recursive = _is_recursive_function(node)
|
|
1201
|
+
if not is_recursive:
|
|
1202
|
+
continue
|
|
1203
|
+
|
|
1204
|
+
func_start = node.lineno
|
|
1205
|
+
func_end = node.end_lineno or func_start + 1
|
|
1206
|
+
has_variant_comment = False
|
|
1207
|
+
|
|
1208
|
+
# Loop invariant: has_variant_comment is True if any line in range has variant keyword
|
|
1209
|
+
for check_line in range(func_start, func_end + 1):
|
|
1210
|
+
if check_line in comments:
|
|
1211
|
+
comment = comments[check_line]
|
|
1212
|
+
if any(kw in comment for kw in variant_keywords):
|
|
1213
|
+
has_variant_comment = True
|
|
1214
|
+
break
|
|
1215
|
+
|
|
1216
|
+
if not has_variant_comment:
|
|
1217
|
+
results.append(FunctionResult(
|
|
1218
|
+
function=node.name,
|
|
1219
|
+
file=file_path,
|
|
1220
|
+
line=func_start,
|
|
1221
|
+
level_requested=1,
|
|
1222
|
+
level_achieved=0,
|
|
1223
|
+
status=CheckStatus.FAILED,
|
|
1224
|
+
details=(Detail(
|
|
1225
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1226
|
+
tool="structural",
|
|
1227
|
+
finding_type="violation",
|
|
1228
|
+
message=f"Recursive function '{node.name}' missing variant documentation",
|
|
1229
|
+
suggestion="Add a comment: # Variant: <decreasing measure>",
|
|
1230
|
+
),),
|
|
1231
|
+
))
|
|
1232
|
+
|
|
1233
|
+
return results
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
@icontract.require(
|
|
1237
|
+
lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)),
|
|
1238
|
+
"node must be a function definition",
|
|
1239
|
+
)
|
|
1240
|
+
@icontract.ensure(
|
|
1241
|
+
lambda result: isinstance(result, bool),
|
|
1242
|
+
"result must be a bool",
|
|
1243
|
+
)
|
|
1244
|
+
def _is_recursive_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
1245
|
+
"""Check if a function calls itself (direct recursion).
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
node: A function definition AST node.
|
|
1249
|
+
|
|
1250
|
+
Returns:
|
|
1251
|
+
True if the function contains a call to itself.
|
|
1252
|
+
"""
|
|
1253
|
+
# Loop invariant: result is True if any child in children[0..i] is a self-call
|
|
1254
|
+
for child in ast.walk(node):
|
|
1255
|
+
if isinstance(child, ast.Call):
|
|
1256
|
+
if isinstance(child.func, ast.Name) and child.func.id == node.name:
|
|
1257
|
+
return True
|
|
1258
|
+
return False
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
@icontract.require(
|
|
1262
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
1263
|
+
"tree must be an ast.Module",
|
|
1264
|
+
)
|
|
1265
|
+
@icontract.ensure(
|
|
1266
|
+
lambda result: isinstance(result, list),
|
|
1267
|
+
"result must be a list",
|
|
1268
|
+
)
|
|
1269
|
+
def check_exception_types(
|
|
1270
|
+
tree: ast.Module,
|
|
1271
|
+
config: SerenecodeConfig,
|
|
1272
|
+
module_path: str,
|
|
1273
|
+
file_path: str,
|
|
1274
|
+
) -> list[FunctionResult]:
|
|
1275
|
+
"""Check that core modules don't raise forbidden exception types.
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
tree: Parsed AST module.
|
|
1279
|
+
config: Active configuration.
|
|
1280
|
+
module_path: Module path for core detection.
|
|
1281
|
+
file_path: Path to the source file.
|
|
1282
|
+
|
|
1283
|
+
Returns:
|
|
1284
|
+
List of FunctionResult for exception type violations.
|
|
1285
|
+
"""
|
|
1286
|
+
if not config.error_handling_rules.require_domain_exceptions:
|
|
1287
|
+
return []
|
|
1288
|
+
|
|
1289
|
+
if not is_core_module(module_path, config):
|
|
1290
|
+
return []
|
|
1291
|
+
|
|
1292
|
+
forbidden = set(config.error_handling_rules.forbidden_exception_types)
|
|
1293
|
+
results: list[FunctionResult] = []
|
|
1294
|
+
|
|
1295
|
+
# Loop invariant: results contains exception findings for nodes[0..i]
|
|
1296
|
+
for node in ast.walk(tree):
|
|
1297
|
+
if not isinstance(node, ast.Raise):
|
|
1298
|
+
continue
|
|
1299
|
+
|
|
1300
|
+
if node.exc is None:
|
|
1301
|
+
continue
|
|
1302
|
+
|
|
1303
|
+
exc_name: str | None = None
|
|
1304
|
+
if isinstance(node.exc, ast.Call):
|
|
1305
|
+
if isinstance(node.exc.func, ast.Name):
|
|
1306
|
+
exc_name = node.exc.func.id
|
|
1307
|
+
elif isinstance(node.exc.func, ast.Attribute):
|
|
1308
|
+
exc_name = node.exc.func.attr
|
|
1309
|
+
elif isinstance(node.exc, ast.Name):
|
|
1310
|
+
exc_name = node.exc.id
|
|
1311
|
+
|
|
1312
|
+
if exc_name and exc_name in forbidden:
|
|
1313
|
+
results.append(FunctionResult(
|
|
1314
|
+
function="<module>",
|
|
1315
|
+
file=file_path,
|
|
1316
|
+
line=node.lineno,
|
|
1317
|
+
level_requested=1,
|
|
1318
|
+
level_achieved=0,
|
|
1319
|
+
status=CheckStatus.FAILED,
|
|
1320
|
+
details=(Detail(
|
|
1321
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1322
|
+
tool="structural",
|
|
1323
|
+
finding_type="violation",
|
|
1324
|
+
message=f"Raising '{exc_name}' in core module — use domain-specific exception",
|
|
1325
|
+
suggestion=f"Define a custom exception inheriting from SerenecodeError",
|
|
1326
|
+
),),
|
|
1327
|
+
))
|
|
1328
|
+
|
|
1329
|
+
return results
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
@icontract.require(
|
|
1333
|
+
lambda tree: isinstance(tree, ast.Module),
|
|
1334
|
+
"tree must be an ast.Module",
|
|
1335
|
+
)
|
|
1336
|
+
@icontract.ensure(
|
|
1337
|
+
lambda result: isinstance(result, list),
|
|
1338
|
+
"result must be a list",
|
|
1339
|
+
)
|
|
1340
|
+
def check_naming_conventions(
|
|
1341
|
+
tree: ast.Module,
|
|
1342
|
+
config: SerenecodeConfig,
|
|
1343
|
+
file_path: str,
|
|
1344
|
+
) -> list[FunctionResult]:
|
|
1345
|
+
"""Check naming conventions for classes, functions, and constants.
|
|
1346
|
+
|
|
1347
|
+
Args:
|
|
1348
|
+
tree: Parsed AST module.
|
|
1349
|
+
config: Active configuration.
|
|
1350
|
+
file_path: Path to the source file.
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
List of FunctionResult for naming convention violations.
|
|
1354
|
+
"""
|
|
1355
|
+
results: list[FunctionResult] = []
|
|
1356
|
+
|
|
1357
|
+
# Loop invariant: results contains naming findings for top-level nodes[0..i]
|
|
1358
|
+
for node in ast.iter_child_nodes(tree):
|
|
1359
|
+
if isinstance(node, ast.ClassDef):
|
|
1360
|
+
if not is_pascal_case(node.name) and _is_public_class(node.name):
|
|
1361
|
+
results.append(FunctionResult(
|
|
1362
|
+
function=node.name,
|
|
1363
|
+
file=file_path,
|
|
1364
|
+
line=node.lineno,
|
|
1365
|
+
level_requested=1,
|
|
1366
|
+
level_achieved=0,
|
|
1367
|
+
status=CheckStatus.FAILED,
|
|
1368
|
+
details=(Detail(
|
|
1369
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1370
|
+
tool="structural",
|
|
1371
|
+
finding_type="violation",
|
|
1372
|
+
message=f"Class '{node.name}' does not follow PascalCase convention",
|
|
1373
|
+
),),
|
|
1374
|
+
))
|
|
1375
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1376
|
+
if (
|
|
1377
|
+
_is_public_function(node.name)
|
|
1378
|
+
and not is_snake_case(node.name)
|
|
1379
|
+
and not node.name.startswith("__")
|
|
1380
|
+
):
|
|
1381
|
+
results.append(FunctionResult(
|
|
1382
|
+
function=node.name,
|
|
1383
|
+
file=file_path,
|
|
1384
|
+
line=node.lineno,
|
|
1385
|
+
level_requested=1,
|
|
1386
|
+
level_achieved=0,
|
|
1387
|
+
status=CheckStatus.FAILED,
|
|
1388
|
+
details=(Detail(
|
|
1389
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1390
|
+
tool="structural",
|
|
1391
|
+
finding_type="violation",
|
|
1392
|
+
message=f"Function '{node.name}' does not follow snake_case convention",
|
|
1393
|
+
),),
|
|
1394
|
+
))
|
|
1395
|
+
|
|
1396
|
+
return results
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
# ---------------------------------------------------------------------------
|
|
1400
|
+
# Orchestrator
|
|
1401
|
+
# ---------------------------------------------------------------------------
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
@icontract.require(
|
|
1405
|
+
lambda source: isinstance(source, str),
|
|
1406
|
+
"source must be a string",
|
|
1407
|
+
)
|
|
1408
|
+
@icontract.ensure(
|
|
1409
|
+
lambda result: isinstance(result, CheckResult),
|
|
1410
|
+
"result must be a CheckResult",
|
|
1411
|
+
)
|
|
1412
|
+
def check_structural(
|
|
1413
|
+
source: str,
|
|
1414
|
+
config: SerenecodeConfig,
|
|
1415
|
+
module_path: str = "",
|
|
1416
|
+
file_path: str = "<unknown>",
|
|
1417
|
+
) -> CheckResult:
|
|
1418
|
+
"""Run the full Level 1 structural check on a source string.
|
|
1419
|
+
|
|
1420
|
+
This is the main entry point for the structural checker. It parses
|
|
1421
|
+
the source code, resolves icontract import aliases, runs all individual
|
|
1422
|
+
check functions, and returns an aggregated CheckResult.
|
|
1423
|
+
|
|
1424
|
+
Args:
|
|
1425
|
+
source: Python source code as a string.
|
|
1426
|
+
config: Active Serenecode configuration.
|
|
1427
|
+
module_path: Module path for architecture checks (e.g. "core/engine.py").
|
|
1428
|
+
file_path: File path for reporting (e.g. "src/serenecode/core/engine.py").
|
|
1429
|
+
|
|
1430
|
+
Returns:
|
|
1431
|
+
A CheckResult containing all structural findings.
|
|
1432
|
+
"""
|
|
1433
|
+
start_time = time.monotonic()
|
|
1434
|
+
|
|
1435
|
+
# Parse the source
|
|
1436
|
+
try:
|
|
1437
|
+
tree = ast.parse(source)
|
|
1438
|
+
except SyntaxError as exc:
|
|
1439
|
+
elapsed = time.monotonic() - start_time
|
|
1440
|
+
error_result = FunctionResult(
|
|
1441
|
+
function="<module>",
|
|
1442
|
+
file=file_path,
|
|
1443
|
+
line=max(1, exc.lineno or 1),
|
|
1444
|
+
level_requested=1,
|
|
1445
|
+
level_achieved=0,
|
|
1446
|
+
status=CheckStatus.FAILED,
|
|
1447
|
+
details=(Detail(
|
|
1448
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1449
|
+
tool="structural",
|
|
1450
|
+
finding_type="error",
|
|
1451
|
+
message=f"Syntax error: {exc.msg}",
|
|
1452
|
+
),),
|
|
1453
|
+
)
|
|
1454
|
+
return make_check_result(
|
|
1455
|
+
(error_result,),
|
|
1456
|
+
level_requested=1,
|
|
1457
|
+
duration_seconds=elapsed,
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
# Exempt modules still need to parse successfully, but skip structural policy checks.
|
|
1461
|
+
# Report them as EXEMPT so the verification scope is transparent.
|
|
1462
|
+
if is_exempt_module(module_path, config):
|
|
1463
|
+
elapsed = time.monotonic() - start_time
|
|
1464
|
+
exempt_result = FunctionResult(
|
|
1465
|
+
function="<module>",
|
|
1466
|
+
file=file_path,
|
|
1467
|
+
line=1,
|
|
1468
|
+
level_requested=1,
|
|
1469
|
+
level_achieved=0,
|
|
1470
|
+
status=CheckStatus.EXEMPT,
|
|
1471
|
+
details=(Detail(
|
|
1472
|
+
level=VerificationLevel.STRUCTURAL,
|
|
1473
|
+
tool="structural",
|
|
1474
|
+
finding_type="exempt",
|
|
1475
|
+
message=f"Module '{module_path}' is exempt from structural checks",
|
|
1476
|
+
),),
|
|
1477
|
+
)
|
|
1478
|
+
return make_check_result(
|
|
1479
|
+
(exempt_result,),
|
|
1480
|
+
level_requested=1,
|
|
1481
|
+
duration_seconds=elapsed,
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
# Resolve icontract aliases
|
|
1485
|
+
aliases = resolve_icontract_aliases(tree)
|
|
1486
|
+
|
|
1487
|
+
# Run all check functions
|
|
1488
|
+
all_results: list[FunctionResult] = []
|
|
1489
|
+
all_results.extend(check_contracts(tree, config, aliases, file_path))
|
|
1490
|
+
all_results.extend(check_class_invariants(tree, config, aliases, file_path, source))
|
|
1491
|
+
all_results.extend(check_type_annotations(tree, config, file_path))
|
|
1492
|
+
all_results.extend(check_no_any_in_core(tree, config, module_path, file_path))
|
|
1493
|
+
all_results.extend(check_imports(tree, config, module_path, file_path))
|
|
1494
|
+
all_results.extend(check_docstrings(tree, config, file_path))
|
|
1495
|
+
all_results.extend(check_loop_invariants(source, tree, config, file_path))
|
|
1496
|
+
all_results.extend(check_exception_types(tree, config, module_path, file_path))
|
|
1497
|
+
all_results.extend(check_naming_conventions(tree, config, file_path))
|
|
1498
|
+
|
|
1499
|
+
elapsed = time.monotonic() - start_time
|
|
1500
|
+
return make_check_result(
|
|
1501
|
+
tuple(all_results),
|
|
1502
|
+
level_requested=1,
|
|
1503
|
+
duration_seconds=elapsed,
|
|
1504
|
+
)
|