serenecode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. serenecode/__init__.py +281 -0
  2. serenecode/adapters/__init__.py +6 -0
  3. serenecode/adapters/coverage_adapter.py +1173 -0
  4. serenecode/adapters/crosshair_adapter.py +1069 -0
  5. serenecode/adapters/hypothesis_adapter.py +1824 -0
  6. serenecode/adapters/local_fs.py +169 -0
  7. serenecode/adapters/module_loader.py +492 -0
  8. serenecode/adapters/mypy_adapter.py +161 -0
  9. serenecode/checker/__init__.py +6 -0
  10. serenecode/checker/compositional.py +2216 -0
  11. serenecode/checker/coverage.py +186 -0
  12. serenecode/checker/properties.py +154 -0
  13. serenecode/checker/structural.py +1504 -0
  14. serenecode/checker/symbolic.py +178 -0
  15. serenecode/checker/types.py +148 -0
  16. serenecode/cli.py +478 -0
  17. serenecode/config.py +711 -0
  18. serenecode/contracts/__init__.py +6 -0
  19. serenecode/contracts/predicates.py +176 -0
  20. serenecode/core/__init__.py +6 -0
  21. serenecode/core/exceptions.py +38 -0
  22. serenecode/core/pipeline.py +807 -0
  23. serenecode/init.py +307 -0
  24. serenecode/models.py +308 -0
  25. serenecode/ports/__init__.py +6 -0
  26. serenecode/ports/coverage_analyzer.py +124 -0
  27. serenecode/ports/file_system.py +95 -0
  28. serenecode/ports/property_tester.py +69 -0
  29. serenecode/ports/symbolic_checker.py +70 -0
  30. serenecode/ports/type_checker.py +66 -0
  31. serenecode/reporter.py +346 -0
  32. serenecode/source_discovery.py +319 -0
  33. serenecode/templates/__init__.py +5 -0
  34. serenecode/templates/content.py +337 -0
  35. serenecode-0.1.0.dist-info/METADATA +298 -0
  36. serenecode-0.1.0.dist-info/RECORD +39 -0
  37. serenecode-0.1.0.dist-info/WHEEL +4 -0
  38. serenecode-0.1.0.dist-info/entry_points.txt +2 -0
  39. serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )