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
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
|