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
serenecode/config.py ADDED
@@ -0,0 +1,711 @@
1
+ """SERENECODE.md parser and configuration models.
2
+
3
+ This module defines the structured configuration that drives the
4
+ verification pipeline. It provides hardcoded configs for the default,
5
+ strict, and minimal templates, plus a basic markdown parser that
6
+ detects the template type and extracts exemption paths.
7
+
8
+ This is a core module — no I/O imports are permitted. Configuration
9
+ is parsed from strings, not files.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ import icontract
18
+
19
+ from serenecode.contracts.predicates import is_non_empty_string
20
+
21
+
22
+ @icontract.invariant(
23
+ lambda self: not self.require_on_private or self.require_on_public_functions,
24
+ "Private contract requirements imply public contract requirements",
25
+ )
26
+ @dataclass(frozen=True)
27
+ class ContractConfig:
28
+ """Configuration for contract requirements."""
29
+
30
+ require_on_public_functions: bool
31
+ require_on_classes: bool
32
+ require_description_strings: bool
33
+ require_on_private: bool
34
+
35
+
36
+ @icontract.invariant(
37
+ lambda self: not self.forbid_any_in_core or self.require_annotations,
38
+ "Forbidding Any in core implies annotations are required",
39
+ )
40
+ @dataclass(frozen=True)
41
+ class TypeConfig:
42
+ """Configuration for type annotation requirements."""
43
+
44
+ require_annotations: bool
45
+ forbid_any_in_core: bool
46
+ require_parameterized_generics: bool
47
+
48
+
49
+ @icontract.invariant(
50
+ lambda self: isinstance(self.forbidden_imports_in_core, tuple)
51
+ and isinstance(self.core_module_patterns, tuple),
52
+ "Import and pattern lists must be tuples",
53
+ )
54
+ @dataclass(frozen=True)
55
+ class ArchitectureConfig:
56
+ """Configuration for architectural rules."""
57
+
58
+ forbidden_imports_in_core: tuple[str, ...]
59
+ core_module_patterns: tuple[str, ...]
60
+
61
+
62
+ @icontract.invariant(
63
+ lambda self: isinstance(self.forbidden_exception_types, tuple),
64
+ "Forbidden exception types must be a tuple",
65
+ )
66
+ @dataclass(frozen=True)
67
+ class ErrorHandlingConfig:
68
+ """Configuration for error handling rules."""
69
+
70
+ require_domain_exceptions: bool
71
+ forbidden_exception_types: tuple[str, ...]
72
+
73
+
74
+ @icontract.invariant(
75
+ lambda self: not self.require_recursion_variant_comments
76
+ or self.require_loop_invariant_comments,
77
+ "Recursion variant requirements imply loop invariant requirements",
78
+ )
79
+ @dataclass(frozen=True)
80
+ class LoopRecursionConfig:
81
+ """Configuration for loop and recursion documentation rules."""
82
+
83
+ require_loop_invariant_comments: bool
84
+ require_recursion_variant_comments: bool
85
+
86
+
87
+ @icontract.invariant(
88
+ lambda self: self.module_style in ("snake_case", "PascalCase", "UPPER_SNAKE_CASE")
89
+ and self.class_style in ("snake_case", "PascalCase", "UPPER_SNAKE_CASE")
90
+ and self.function_style in ("snake_case", "PascalCase", "UPPER_SNAKE_CASE"),
91
+ "Naming styles must be recognized conventions",
92
+ )
93
+ @dataclass(frozen=True)
94
+ class NamingConfig:
95
+ """Configuration for naming conventions."""
96
+
97
+ module_style: str
98
+ class_style: str
99
+ function_style: str
100
+
101
+
102
+ @icontract.invariant(
103
+ lambda self: isinstance(self.exempt_paths, tuple),
104
+ "Exempt paths must be a tuple",
105
+ )
106
+ @dataclass(frozen=True)
107
+ class ExemptionConfig:
108
+ """Configuration for exempt paths."""
109
+
110
+ exempt_paths: tuple[str, ...]
111
+
112
+
113
+ @icontract.invariant(
114
+ lambda self: 1 <= self.recommended_level <= 6,
115
+ "Recommended level must be between 1 and 6",
116
+ )
117
+ @icontract.invariant(
118
+ lambda self: self.template_name in ("default", "strict", "minimal"),
119
+ "Template name must be a recognized template",
120
+ )
121
+ @dataclass(frozen=True)
122
+ class SerenecodeConfig:
123
+ """Complete Serenecode configuration parsed from SERENECODE.md.
124
+
125
+ This is the central configuration object that all checkers use
126
+ to determine what rules to enforce.
127
+ """
128
+
129
+ contract_requirements: ContractConfig
130
+ type_requirements: TypeConfig
131
+ architecture_rules: ArchitectureConfig
132
+ error_handling_rules: ErrorHandlingConfig
133
+ loop_recursion_rules: LoopRecursionConfig
134
+ naming_conventions: NamingConfig
135
+ exemptions: ExemptionConfig
136
+ template_name: str
137
+ recommended_level: int = 3
138
+
139
+
140
+ # Default I/O-related modules forbidden in core
141
+ _DEFAULT_FORBIDDEN_IMPORTS = (
142
+ "os",
143
+ "pathlib",
144
+ "subprocess",
145
+ "requests",
146
+ "socket",
147
+ "shutil",
148
+ "tempfile",
149
+ "glob",
150
+ )
151
+
152
+ # Default core module path patterns
153
+ _DEFAULT_CORE_PATTERNS = (
154
+ "core/",
155
+ "checker/",
156
+ "models.py",
157
+ "contracts/",
158
+ "config.py",
159
+ )
160
+
161
+ # Default exempt paths
162
+ _DEFAULT_EXEMPT_PATHS = (
163
+ "cli.py",
164
+ "__init__.py",
165
+ "adapters/",
166
+ "templates/",
167
+ "tests/fixtures/",
168
+ "ports/",
169
+ "exceptions.py",
170
+ )
171
+
172
+ # Default forbidden exception types in core
173
+ _DEFAULT_FORBIDDEN_EXCEPTIONS = (
174
+ "Exception",
175
+ "ValueError",
176
+ "TypeError",
177
+ "RuntimeError",
178
+ "KeyError",
179
+ "IndexError",
180
+ "AttributeError",
181
+ )
182
+
183
+
184
+ @icontract.ensure(
185
+ lambda result: isinstance(result, SerenecodeConfig),
186
+ "result must be a SerenecodeConfig",
187
+ )
188
+ def default_config() -> SerenecodeConfig:
189
+ """Return the default Serenecode configuration.
190
+
191
+ Matches the conventions defined in the standard SERENECODE.md template.
192
+ """
193
+ return SerenecodeConfig(
194
+ contract_requirements=ContractConfig(
195
+ require_on_public_functions=True,
196
+ require_on_classes=True,
197
+ require_description_strings=True,
198
+ require_on_private=False,
199
+ ),
200
+ type_requirements=TypeConfig(
201
+ require_annotations=True,
202
+ forbid_any_in_core=True,
203
+ require_parameterized_generics=True,
204
+ ),
205
+ architecture_rules=ArchitectureConfig(
206
+ forbidden_imports_in_core=_DEFAULT_FORBIDDEN_IMPORTS,
207
+ core_module_patterns=_DEFAULT_CORE_PATTERNS,
208
+ ),
209
+ error_handling_rules=ErrorHandlingConfig(
210
+ require_domain_exceptions=False,
211
+ forbidden_exception_types=(),
212
+ ),
213
+ loop_recursion_rules=LoopRecursionConfig(
214
+ require_loop_invariant_comments=False,
215
+ require_recursion_variant_comments=False,
216
+ ),
217
+ naming_conventions=NamingConfig(
218
+ module_style="snake_case",
219
+ class_style="PascalCase",
220
+ function_style="snake_case",
221
+ ),
222
+ exemptions=ExemptionConfig(
223
+ exempt_paths=_DEFAULT_EXEMPT_PATHS,
224
+ ),
225
+ template_name="default",
226
+ recommended_level=4,
227
+ )
228
+
229
+
230
+ @icontract.ensure(
231
+ lambda result: isinstance(result, SerenecodeConfig),
232
+ "result must be a SerenecodeConfig",
233
+ )
234
+ def strict_config() -> SerenecodeConfig:
235
+ """Return the strict Serenecode configuration.
236
+
237
+ All SHOULD become MUST, no exemptions.
238
+ """
239
+ return SerenecodeConfig(
240
+ contract_requirements=ContractConfig(
241
+ require_on_public_functions=True,
242
+ require_on_classes=True,
243
+ require_description_strings=True,
244
+ require_on_private=True,
245
+ ),
246
+ type_requirements=TypeConfig(
247
+ require_annotations=True,
248
+ forbid_any_in_core=True,
249
+ require_parameterized_generics=True,
250
+ ),
251
+ architecture_rules=ArchitectureConfig(
252
+ forbidden_imports_in_core=_DEFAULT_FORBIDDEN_IMPORTS,
253
+ core_module_patterns=_DEFAULT_CORE_PATTERNS,
254
+ ),
255
+ error_handling_rules=ErrorHandlingConfig(
256
+ require_domain_exceptions=True,
257
+ forbidden_exception_types=_DEFAULT_FORBIDDEN_EXCEPTIONS,
258
+ ),
259
+ loop_recursion_rules=LoopRecursionConfig(
260
+ require_loop_invariant_comments=True,
261
+ require_recursion_variant_comments=True,
262
+ ),
263
+ naming_conventions=NamingConfig(
264
+ module_style="snake_case",
265
+ class_style="PascalCase",
266
+ function_style="snake_case",
267
+ ),
268
+ exemptions=ExemptionConfig(
269
+ exempt_paths=(),
270
+ ),
271
+ template_name="strict",
272
+ recommended_level=6,
273
+ )
274
+
275
+
276
+ @icontract.ensure(
277
+ lambda result: isinstance(result, SerenecodeConfig),
278
+ "result must be a SerenecodeConfig",
279
+ )
280
+ def minimal_config() -> SerenecodeConfig:
281
+ """Return the minimal Serenecode configuration.
282
+
283
+ Contracts on public functions only, relaxed architecture rules.
284
+ """
285
+ return SerenecodeConfig(
286
+ contract_requirements=ContractConfig(
287
+ require_on_public_functions=True,
288
+ require_on_classes=False,
289
+ require_description_strings=False,
290
+ require_on_private=False,
291
+ ),
292
+ type_requirements=TypeConfig(
293
+ require_annotations=True,
294
+ forbid_any_in_core=False,
295
+ require_parameterized_generics=False,
296
+ ),
297
+ architecture_rules=ArchitectureConfig(
298
+ forbidden_imports_in_core=(),
299
+ core_module_patterns=(),
300
+ ),
301
+ error_handling_rules=ErrorHandlingConfig(
302
+ require_domain_exceptions=False,
303
+ forbidden_exception_types=(),
304
+ ),
305
+ loop_recursion_rules=LoopRecursionConfig(
306
+ require_loop_invariant_comments=False,
307
+ require_recursion_variant_comments=False,
308
+ ),
309
+ naming_conventions=NamingConfig(
310
+ module_style="snake_case",
311
+ class_style="PascalCase",
312
+ function_style="snake_case",
313
+ ),
314
+ exemptions=ExemptionConfig(
315
+ exempt_paths=_DEFAULT_EXEMPT_PATHS,
316
+ ),
317
+ template_name="minimal",
318
+ recommended_level=2,
319
+ )
320
+
321
+
322
+ @icontract.require(
323
+ lambda template_name: template_name in ("default", "strict", "minimal"),
324
+ "template_name must be 'default', 'strict', or 'minimal'",
325
+ )
326
+ @icontract.ensure(
327
+ lambda result: isinstance(result, SerenecodeConfig),
328
+ "result must be a SerenecodeConfig",
329
+ )
330
+ def config_for_template(template_name: str) -> SerenecodeConfig:
331
+ """Return the configuration for a named template.
332
+
333
+ Args:
334
+ template_name: One of 'default', 'strict', or 'minimal'.
335
+
336
+ Returns:
337
+ The corresponding SerenecodeConfig.
338
+ """
339
+ configs = {
340
+ "default": default_config,
341
+ "strict": strict_config,
342
+ "minimal": minimal_config,
343
+ }
344
+ return configs[template_name]()
345
+
346
+
347
+ @icontract.require(
348
+ lambda content: isinstance(content, str),
349
+ "content must be a string",
350
+ )
351
+ @icontract.ensure(
352
+ lambda result: isinstance(result, SerenecodeConfig),
353
+ "result must be a SerenecodeConfig",
354
+ )
355
+ def parse_serenecode_md(content: str) -> SerenecodeConfig:
356
+ """Parse a SERENECODE.md file content into a SerenecodeConfig.
357
+
358
+ Uses template detection as a starting point, then applies supported
359
+ rule overrides extracted from the file content so local edits still
360
+ influence the active verification policy.
361
+
362
+ Args:
363
+ content: The full text content of a SERENECODE.md file.
364
+
365
+ Returns:
366
+ A SerenecodeConfig matching the detected template with
367
+ any extracted exemption overrides.
368
+ """
369
+ template = _detect_template(content)
370
+ config = config_for_template(template)
371
+ exempt_paths = _extract_exemptions(content)
372
+ return _apply_content_overrides(content, config, exempt_paths)
373
+
374
+
375
+ @icontract.require(
376
+ lambda content: isinstance(content, str),
377
+ "content must be a string",
378
+ )
379
+ @icontract.require(
380
+ lambda config: isinstance(config, SerenecodeConfig),
381
+ "config must be a SerenecodeConfig",
382
+ )
383
+ @icontract.ensure(
384
+ lambda result: isinstance(result, SerenecodeConfig),
385
+ "result must be a SerenecodeConfig",
386
+ )
387
+ def _apply_content_overrides(
388
+ content: str,
389
+ config: SerenecodeConfig,
390
+ exempt_paths: tuple[str, ...] | None,
391
+ ) -> SerenecodeConfig:
392
+ """Apply supported rule overrides derived from SERENECODE.md content."""
393
+ require_domain_exceptions = _matches_rule(
394
+ content,
395
+ r"Core domain functions raise domain-specific exceptions",
396
+ config.error_handling_rules.require_domain_exceptions,
397
+ )
398
+ forbidden_exception_types = _extract_forbidden_exception_types(content)
399
+
400
+ contract_requirements = ContractConfig(
401
+ require_on_public_functions=config.contract_requirements.require_on_public_functions,
402
+ require_on_classes=_matches_rule(
403
+ content,
404
+ r"Every class[^\n]*MUST[^\n]*@icontract\.invariant",
405
+ config.contract_requirements.require_on_classes,
406
+ ),
407
+ require_description_strings=_matches_rule(
408
+ content,
409
+ r"Every `@icontract\.require` and `@icontract\.ensure` MUST include a human-readable description string",
410
+ config.contract_requirements.require_description_strings,
411
+ ),
412
+ require_on_private=_matches_rule(
413
+ content,
414
+ r"Private(?:/Helper)?\s+Functions?[^\n]*MUST\s+have\s+contracts|Private(?: functions|/Helper Functions)[^\n]*MUST\s+have\s+contracts",
415
+ config.contract_requirements.require_on_private,
416
+ ),
417
+ )
418
+
419
+ type_requirements = TypeConfig(
420
+ require_annotations=config.type_requirements.require_annotations,
421
+ forbid_any_in_core=_matches_rule(
422
+ content,
423
+ r"No use of `Any` in core modules|No use of `Any` anywhere",
424
+ config.type_requirements.forbid_any_in_core,
425
+ ),
426
+ require_parameterized_generics=_matches_rule(
427
+ content,
428
+ r"Generic types must be fully parameterized",
429
+ config.type_requirements.require_parameterized_generics,
430
+ ),
431
+ )
432
+
433
+ error_handling_rules = ErrorHandlingConfig(
434
+ require_domain_exceptions=require_domain_exceptions,
435
+ forbidden_exception_types=(
436
+ forbidden_exception_types
437
+ if forbidden_exception_types is not None
438
+ else config.error_handling_rules.forbidden_exception_types
439
+ if require_domain_exceptions
440
+ else ()
441
+ ),
442
+ )
443
+
444
+ loop_recursion_rules = LoopRecursionConfig(
445
+ require_loop_invariant_comments=_matches_rule(
446
+ content,
447
+ r"Loops? MUST include a comment describing the loop invariant|Every loop MUST include a comment describing the loop invariant",
448
+ config.loop_recursion_rules.require_loop_invariant_comments,
449
+ ),
450
+ require_recursion_variant_comments=_matches_rule(
451
+ content,
452
+ r"Recursive functions MUST include a comment documenting the variant|Recursive functions MUST document the variant",
453
+ config.loop_recursion_rules.require_recursion_variant_comments,
454
+ ),
455
+ )
456
+
457
+ needs_core_patterns = (
458
+ (
459
+ type_requirements.forbid_any_in_core
460
+ or error_handling_rules.require_domain_exceptions
461
+ )
462
+ and len(config.architecture_rules.core_module_patterns) == 0
463
+ )
464
+ architecture_rules = ArchitectureConfig(
465
+ forbidden_imports_in_core=config.architecture_rules.forbidden_imports_in_core,
466
+ core_module_patterns=(
467
+ _DEFAULT_CORE_PATTERNS
468
+ if needs_core_patterns
469
+ else config.architecture_rules.core_module_patterns
470
+ ),
471
+ )
472
+
473
+ exemptions = ExemptionConfig(
474
+ exempt_paths=(
475
+ config.exemptions.exempt_paths
476
+ if exempt_paths is None
477
+ else exempt_paths
478
+ ),
479
+ )
480
+
481
+ return SerenecodeConfig(
482
+ contract_requirements=contract_requirements,
483
+ type_requirements=type_requirements,
484
+ architecture_rules=architecture_rules,
485
+ error_handling_rules=error_handling_rules,
486
+ loop_recursion_rules=loop_recursion_rules,
487
+ naming_conventions=config.naming_conventions,
488
+ exemptions=exemptions,
489
+ template_name=config.template_name,
490
+ recommended_level=config.recommended_level,
491
+ )
492
+
493
+
494
+ @icontract.require(
495
+ lambda content: isinstance(content, str),
496
+ "content must be a string",
497
+ )
498
+ @icontract.require(
499
+ lambda pattern: isinstance(pattern, str),
500
+ "pattern must be a string",
501
+ )
502
+ @icontract.ensure(
503
+ lambda result: isinstance(result, bool),
504
+ "result must be a bool",
505
+ )
506
+ def _matches_rule(content: str, pattern: str, default: bool) -> bool:
507
+ """Return a parsed rule value when the file mentions the rule, else keep default.
508
+
509
+ Matches the pattern to determine whether a rule is mentioned. When the
510
+ rule text is mentioned, returns True. This function only activates rules;
511
+ it cannot deactivate them. The template system (default/strict/minimal)
512
+ controls baseline activation.
513
+ """
514
+ if re.search(pattern, content, re.IGNORECASE):
515
+ return True
516
+ return default
517
+
518
+
519
+ @icontract.require(lambda content: isinstance(content, str), "content must be a string")
520
+ @icontract.ensure(lambda result: result is None or isinstance(result, tuple), "result must be a tuple or None")
521
+ def _extract_forbidden_exception_types(content: str) -> tuple[str, ...] | None:
522
+ """Extract explicitly forbidden exception type names from SERENECODE.md content."""
523
+ line_match = re.search(r"(?:Never raise bare|never bare)([^\n]+)", content)
524
+ if line_match is None:
525
+ return None
526
+
527
+ exception_types = tuple(re.findall(r"`([^`]+)`", line_match.group(0)))
528
+ return exception_types if exception_types else None
529
+
530
+
531
+ @icontract.require(lambda content: isinstance(content, str), "content must be a string")
532
+ @icontract.ensure(lambda result: result in ("default", "strict", "minimal"), "result must be a known template")
533
+ def _detect_template(content: str) -> str:
534
+ """Detect which template a SERENECODE.md most closely matches.
535
+
536
+ Uses the presence of key section headings and rule keywords.
537
+
538
+ Args:
539
+ content: SERENECODE.md file content.
540
+
541
+ Returns:
542
+ Template name: 'default', 'strict', or 'minimal'.
543
+ """
544
+ has_contract_section = "## Contract Standards" in content
545
+ has_architecture_section = "## Architecture Standards" in content
546
+ has_loop_section = "## Loop and Recursion Standards" in content
547
+
548
+ if not has_contract_section:
549
+ return "minimal"
550
+
551
+ if not has_architecture_section and not has_loop_section:
552
+ return "minimal"
553
+
554
+ # Check for strict indicators
555
+ has_private_must = bool(re.search(r"Private.*MUST\s+have\s+contracts", content))
556
+ if has_private_must:
557
+ return "strict"
558
+
559
+ return "default"
560
+
561
+
562
+ @icontract.require(lambda content: isinstance(content, str), "content must be a string")
563
+ @icontract.ensure(lambda result: result is None or isinstance(result, tuple), "result must be a tuple or None")
564
+ def _extract_exemptions(content: str) -> tuple[str, ...] | None:
565
+ """Extract exempt paths from the Exemptions section.
566
+
567
+ Args:
568
+ content: SERENECODE.md file content.
569
+
570
+ Returns:
571
+ Tuple of exempt path strings, or None if no Exemptions section found.
572
+ """
573
+ exemptions_match = re.search(
574
+ r"## Exemptions\s*\n(.*?)(?:\n## |\Z)",
575
+ content,
576
+ re.DOTALL,
577
+ )
578
+ if not exemptions_match:
579
+ return None
580
+
581
+ section = exemptions_match.group(1)
582
+ paths: list[str] = []
583
+
584
+ # Loop invariant: paths contains all exempt paths found in lines[0..i]
585
+ for line in section.splitlines():
586
+ stripped = line.strip()
587
+ if not stripped.startswith("-"):
588
+ continue
589
+ # Match backtick-quoted path-like values at the start of bullet text.
590
+ # The path must be the first backtick content and look like a file/dir
591
+ # reference (contains a dot or ends with /).
592
+ path_match = re.match(r"-\s+`([^`]+)`", stripped)
593
+ if path_match:
594
+ candidate = path_match.group(1)
595
+ if "." in candidate or candidate.endswith("/"):
596
+ paths.append(candidate)
597
+
598
+ if not paths:
599
+ return None
600
+
601
+ return tuple(paths)
602
+
603
+
604
+ @icontract.require(
605
+ lambda path: isinstance(path, str),
606
+ "path must be a string",
607
+ )
608
+ @icontract.ensure(
609
+ lambda result: isinstance(result, tuple),
610
+ "result must be a tuple",
611
+ )
612
+ def _path_segments(path: str) -> tuple[str, ...]:
613
+ """Normalize a path-like string into slash-separated segments."""
614
+ normalized = path.replace("\\", "/").strip("/")
615
+ if not normalized:
616
+ return ()
617
+ return tuple(segment for segment in normalized.split("/") if segment and segment != ".")
618
+
619
+
620
+ @icontract.require(
621
+ lambda module_path: isinstance(module_path, str),
622
+ "module_path must be a string",
623
+ )
624
+ @icontract.require(
625
+ lambda pattern: isinstance(pattern, str),
626
+ "pattern must be a string",
627
+ )
628
+ @icontract.ensure(
629
+ lambda result: isinstance(result, bool),
630
+ "result must be a bool",
631
+ )
632
+ def _path_pattern_matches(module_path: str, pattern: str) -> bool:
633
+ """Check whether a configured path pattern matches a module path by segments."""
634
+ module_segments = _path_segments(module_path)
635
+ pattern_segments = _path_segments(pattern)
636
+ if not module_segments or not pattern_segments:
637
+ return False
638
+
639
+ if pattern.endswith(("/", "\\")):
640
+ window_size = len(pattern_segments)
641
+ if window_size > len(module_segments):
642
+ return False
643
+
644
+ # Loop invariant: no prior window of module_segments matched pattern_segments.
645
+ for index in range(len(module_segments) - window_size + 1):
646
+ if module_segments[index:index + window_size] == pattern_segments:
647
+ return True
648
+ return False
649
+
650
+ if len(pattern_segments) == 1:
651
+ return module_segments[-1] == pattern_segments[0]
652
+
653
+ window_size = len(pattern_segments)
654
+ if window_size > len(module_segments):
655
+ return False
656
+
657
+ # Loop invariant: no prior window of module_segments matched pattern_segments.
658
+ for index in range(len(module_segments) - window_size + 1):
659
+ if module_segments[index:index + window_size] == pattern_segments:
660
+ return True
661
+ return False
662
+
663
+
664
+ @icontract.require(
665
+ lambda module_path: isinstance(module_path, str),
666
+ "module_path must be a string",
667
+ )
668
+ @icontract.ensure(
669
+ lambda result: isinstance(result, bool),
670
+ "result must be a boolean",
671
+ )
672
+ def is_core_module(module_path: str, config: SerenecodeConfig) -> bool:
673
+ """Check whether a module path matches any core module pattern.
674
+
675
+ Args:
676
+ module_path: The file path or module path to check.
677
+ config: The active configuration.
678
+
679
+ Returns:
680
+ True if the module is considered a core module.
681
+ """
682
+ # Loop invariant: result is True if any pattern in patterns[0..i] matches
683
+ for pattern in config.architecture_rules.core_module_patterns:
684
+ if _path_pattern_matches(module_path, pattern):
685
+ return True
686
+ return False
687
+
688
+
689
+ @icontract.require(
690
+ lambda module_path: isinstance(module_path, str),
691
+ "module_path must be a string",
692
+ )
693
+ @icontract.ensure(
694
+ lambda result: isinstance(result, bool),
695
+ "result must be a boolean",
696
+ )
697
+ def is_exempt_module(module_path: str, config: SerenecodeConfig) -> bool:
698
+ """Check whether a module path is exempt from full verification.
699
+
700
+ Args:
701
+ module_path: The file path or module path to check.
702
+ config: The active configuration.
703
+
704
+ Returns:
705
+ True if the module is exempt.
706
+ """
707
+ # Loop invariant: result is True if any path in exempt_paths[0..i] matches
708
+ for exempt_path in config.exemptions.exempt_paths:
709
+ if _path_pattern_matches(module_path, exempt_path):
710
+ return True
711
+ return False