invar-tools 1.0.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.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/core/rules.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Rule engine for Guard. Rules check FileInfo and produce Violations. No I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from deal import post, pre
|
|
8
|
+
|
|
9
|
+
from invar.core.contracts import (
|
|
10
|
+
check_empty_contracts,
|
|
11
|
+
check_param_mismatch,
|
|
12
|
+
check_partial_contract,
|
|
13
|
+
check_redundant_type_contracts,
|
|
14
|
+
check_semantic_tautology,
|
|
15
|
+
)
|
|
16
|
+
from invar.core.extraction import format_extraction_hint
|
|
17
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
18
|
+
from invar.core.must_use import check_must_use
|
|
19
|
+
from invar.core.purity import check_impure_calls, check_internal_imports
|
|
20
|
+
from invar.core.suggestions import format_suggestion_for_violation
|
|
21
|
+
from invar.core.utils import get_excluded_rules
|
|
22
|
+
|
|
23
|
+
# P17: Pure alternatives for forbidden imports (module → suggestion)
|
|
24
|
+
FORBIDDEN_IMPORT_ALTERNATIVES: dict[str, str] = {
|
|
25
|
+
"os": "Inject paths as strings",
|
|
26
|
+
"sys": "Pass sys.argv as parameter",
|
|
27
|
+
"pathlib": "Use string operations",
|
|
28
|
+
"subprocess": "Move to Shell",
|
|
29
|
+
"shutil": "Move to Shell",
|
|
30
|
+
"io": "Pass content as str/bytes",
|
|
31
|
+
"socket": "Move to Shell",
|
|
32
|
+
"requests": "Move HTTP to Shell",
|
|
33
|
+
"urllib": "Move to Shell",
|
|
34
|
+
"datetime": "Inject now as parameter",
|
|
35
|
+
"random": "Inject random values",
|
|
36
|
+
"open": "Shell reads, Core processes",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Type alias for rule functions
|
|
40
|
+
RuleFunc = Callable[[FileInfo, RuleConfig], list[Violation]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@post(lambda result: isinstance(result, str))
|
|
44
|
+
def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> str:
|
|
45
|
+
"""Build suggestion message with extraction hints."""
|
|
46
|
+
if extraction_hint:
|
|
47
|
+
return f"{base}\nExtractable groups:\n{extraction_hint}"
|
|
48
|
+
return f"{base}{func_hint}" if func_hint else base
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pre(lambda file_info: isinstance(file_info, FileInfo))
|
|
52
|
+
@post(lambda result: isinstance(result, str))
|
|
53
|
+
def _get_func_hint(file_info: FileInfo) -> str:
|
|
54
|
+
"""Get top 5 largest functions as hint string."""
|
|
55
|
+
funcs = sorted(
|
|
56
|
+
[(s.name, s.end_line - s.line + 1) for s in file_info.symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)],
|
|
57
|
+
key=lambda x: -x[1],
|
|
58
|
+
)[:5]
|
|
59
|
+
return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
63
|
+
def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
64
|
+
"""
|
|
65
|
+
Check if file exceeds maximum line count or warning threshold.
|
|
66
|
+
|
|
67
|
+
P18: Shows function groups in size warnings to help agents decide what to extract.
|
|
68
|
+
P25: Shows extractable groups with dependencies for warnings.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
>>> from invar.core.models import FileInfo, RuleConfig
|
|
72
|
+
>>> check_file_size(FileInfo(path="ok.py", lines=100), RuleConfig())
|
|
73
|
+
[]
|
|
74
|
+
>>> len(check_file_size(FileInfo(path="big.py", lines=600), RuleConfig()))
|
|
75
|
+
1
|
|
76
|
+
>>> # P8: Warning at 80% threshold (400 lines when max is 500)
|
|
77
|
+
>>> vs = check_file_size(FileInfo(path="growing.py", lines=420), RuleConfig())
|
|
78
|
+
>>> len(vs) == 1 and vs[0].rule == "file_size_warning"
|
|
79
|
+
True
|
|
80
|
+
"""
|
|
81
|
+
violations: list[Violation] = []
|
|
82
|
+
func_hint = _get_func_hint(file_info)
|
|
83
|
+
extraction_hint = format_extraction_hint(file_info)
|
|
84
|
+
|
|
85
|
+
if file_info.lines > config.max_file_lines:
|
|
86
|
+
violations.append(Violation(
|
|
87
|
+
rule="file_size", severity=Severity.ERROR, file=file_info.path, line=None,
|
|
88
|
+
message=f"File has {file_info.lines} lines (max: {config.max_file_lines})",
|
|
89
|
+
suggestion=_build_size_suggestion("Split into smaller modules.", extraction_hint, func_hint),
|
|
90
|
+
))
|
|
91
|
+
elif config.size_warning_threshold > 0:
|
|
92
|
+
threshold = int(config.max_file_lines * config.size_warning_threshold)
|
|
93
|
+
if file_info.lines >= threshold:
|
|
94
|
+
pct = int(file_info.lines / config.max_file_lines * 100)
|
|
95
|
+
violations.append(Violation(
|
|
96
|
+
rule="file_size_warning", severity=Severity.WARNING, file=file_info.path, line=None,
|
|
97
|
+
message=f"File has {file_info.lines} lines ({pct}% of {config.max_file_lines} limit)",
|
|
98
|
+
suggestion=_build_size_suggestion("Consider splitting before reaching limit.", extraction_hint, func_hint),
|
|
99
|
+
))
|
|
100
|
+
return violations
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
104
|
+
def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
105
|
+
"""
|
|
106
|
+
Check if any function exceeds maximum line count.
|
|
107
|
+
|
|
108
|
+
When use_code_lines is True, uses code_lines (excluding docstring).
|
|
109
|
+
When exclude_doctest_lines is True, subtracts doctest lines from count.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
113
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
|
|
114
|
+
>>> info = FileInfo(path="test.py", lines=20, symbols=[sym])
|
|
115
|
+
>>> cfg = RuleConfig(max_function_lines=50)
|
|
116
|
+
>>> check_function_size(info, cfg)
|
|
117
|
+
[]
|
|
118
|
+
"""
|
|
119
|
+
violations: list[Violation] = []
|
|
120
|
+
|
|
121
|
+
for symbol in file_info.symbols:
|
|
122
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
123
|
+
total_lines = symbol.end_line - symbol.line + 1
|
|
124
|
+
# Calculate effective line count based on config
|
|
125
|
+
if config.use_code_lines and symbol.code_lines is not None:
|
|
126
|
+
func_lines = symbol.code_lines
|
|
127
|
+
line_type = "code lines"
|
|
128
|
+
else:
|
|
129
|
+
func_lines = total_lines
|
|
130
|
+
line_type = "lines"
|
|
131
|
+
# Optionally exclude doctest lines
|
|
132
|
+
if config.exclude_doctest_lines and symbol.doctest_lines > 0:
|
|
133
|
+
func_lines -= symbol.doctest_lines
|
|
134
|
+
line_type = f"{line_type} (excl. doctest)"
|
|
135
|
+
|
|
136
|
+
if func_lines > config.max_function_lines:
|
|
137
|
+
# P19: Show breakdown if doctest lines exist
|
|
138
|
+
if symbol.doctest_lines > 0 and not config.exclude_doctest_lines:
|
|
139
|
+
code_only = total_lines - symbol.doctest_lines
|
|
140
|
+
breakdown = f" ({code_only} code + {symbol.doctest_lines} doctest)"
|
|
141
|
+
suggestion = f"Extract helper or set exclude_doctest_lines=true{breakdown}"
|
|
142
|
+
else:
|
|
143
|
+
suggestion = "Extract helper functions"
|
|
144
|
+
violations.append(
|
|
145
|
+
Violation(
|
|
146
|
+
rule="function_size",
|
|
147
|
+
severity=Severity.WARNING,
|
|
148
|
+
file=file_info.path,
|
|
149
|
+
line=symbol.line,
|
|
150
|
+
message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
|
|
151
|
+
suggestion=suggestion,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return violations
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
159
|
+
def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
160
|
+
"""
|
|
161
|
+
Check for forbidden imports in Core files.
|
|
162
|
+
|
|
163
|
+
Only applies to files marked as Core.
|
|
164
|
+
|
|
165
|
+
Examples:
|
|
166
|
+
>>> from invar.core.models import FileInfo
|
|
167
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, imports=["math"], is_core=True)
|
|
168
|
+
>>> cfg = RuleConfig()
|
|
169
|
+
>>> check_forbidden_imports(info, cfg)
|
|
170
|
+
[]
|
|
171
|
+
>>> info = FileInfo(path="core/bad.py", lines=10, imports=["os"], is_core=True)
|
|
172
|
+
>>> violations = check_forbidden_imports(info, cfg)
|
|
173
|
+
>>> len(violations)
|
|
174
|
+
1
|
|
175
|
+
"""
|
|
176
|
+
violations: list[Violation] = []
|
|
177
|
+
|
|
178
|
+
if not file_info.is_core:
|
|
179
|
+
return violations
|
|
180
|
+
|
|
181
|
+
for imp in file_info.imports:
|
|
182
|
+
if imp in config.forbidden_imports:
|
|
183
|
+
# P17: Include pure alternative in suggestion
|
|
184
|
+
alt = FORBIDDEN_IMPORT_ALTERNATIVES.get(imp, "")
|
|
185
|
+
suggestion = f"Move I/O code using '{imp}' to Shell"
|
|
186
|
+
if alt:
|
|
187
|
+
suggestion += f". Alternative: {alt}"
|
|
188
|
+
violations.append(
|
|
189
|
+
Violation(
|
|
190
|
+
rule="forbidden_import",
|
|
191
|
+
severity=Severity.ERROR,
|
|
192
|
+
file=file_info.path,
|
|
193
|
+
line=None,
|
|
194
|
+
message=f"Imports '{imp}' (forbidden in Core)",
|
|
195
|
+
suggestion=suggestion,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return violations
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
203
|
+
def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
204
|
+
"""
|
|
205
|
+
Check that public Core functions have contracts.
|
|
206
|
+
|
|
207
|
+
Only applies to files marked as Core when require_contracts is True.
|
|
208
|
+
|
|
209
|
+
Examples:
|
|
210
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract
|
|
211
|
+
>>> contract = Contract(kind="pre", expression="x > 0", line=1)
|
|
212
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[contract])
|
|
213
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
|
|
214
|
+
>>> cfg = RuleConfig(require_contracts=True)
|
|
215
|
+
>>> check_contracts(info, cfg)
|
|
216
|
+
[]
|
|
217
|
+
"""
|
|
218
|
+
violations: list[Violation] = []
|
|
219
|
+
|
|
220
|
+
if not file_info.is_core or not config.require_contracts:
|
|
221
|
+
return violations
|
|
222
|
+
|
|
223
|
+
for symbol in file_info.symbols:
|
|
224
|
+
# Check all functions and methods - agent needs contracts everywhere
|
|
225
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not symbol.contracts:
|
|
226
|
+
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
227
|
+
suggestion = format_suggestion_for_violation(symbol, "missing_contract")
|
|
228
|
+
violations.append(
|
|
229
|
+
Violation(
|
|
230
|
+
rule="missing_contract",
|
|
231
|
+
severity=Severity.ERROR,
|
|
232
|
+
file=file_info.path,
|
|
233
|
+
line=symbol.line,
|
|
234
|
+
message=f"{kind_name} '{symbol.name}' has no @pre or @post contract",
|
|
235
|
+
suggestion=suggestion,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return violations
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
243
|
+
def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
244
|
+
"""
|
|
245
|
+
Check that contracted functions have doctest examples.
|
|
246
|
+
|
|
247
|
+
Only applies to files marked as Core when require_doctests is True.
|
|
248
|
+
|
|
249
|
+
Examples:
|
|
250
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract
|
|
251
|
+
>>> contract = Contract(kind="pre", expression="x > 0", line=1)
|
|
252
|
+
>>> sym = Symbol(
|
|
253
|
+
... name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
254
|
+
... contracts=[contract], has_doctest=True
|
|
255
|
+
... )
|
|
256
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
|
|
257
|
+
>>> cfg = RuleConfig(require_doctests=True)
|
|
258
|
+
>>> check_doctests(info, cfg)
|
|
259
|
+
[]
|
|
260
|
+
"""
|
|
261
|
+
violations: list[Violation] = []
|
|
262
|
+
|
|
263
|
+
if not file_info.is_core or not config.require_doctests:
|
|
264
|
+
return violations
|
|
265
|
+
|
|
266
|
+
for symbol in file_info.symbols:
|
|
267
|
+
# Only public functions/methods require doctests (private can skip)
|
|
268
|
+
# For methods, check if method name (after dot) starts with _
|
|
269
|
+
name_part = symbol.name.split(".")[-1] if "." in symbol.name else symbol.name
|
|
270
|
+
is_public = not name_part.startswith("_")
|
|
271
|
+
if (
|
|
272
|
+
symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)
|
|
273
|
+
and is_public
|
|
274
|
+
and symbol.contracts
|
|
275
|
+
and not symbol.has_doctest
|
|
276
|
+
):
|
|
277
|
+
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
278
|
+
violations.append(
|
|
279
|
+
Violation(
|
|
280
|
+
rule="missing_doctest",
|
|
281
|
+
severity=Severity.WARNING,
|
|
282
|
+
file=file_info.path,
|
|
283
|
+
line=symbol.line,
|
|
284
|
+
message=f"{kind_name} '{symbol.name}' has contracts but no doctest examples",
|
|
285
|
+
suggestion="Add >>> examples in docstring",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return violations
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
293
|
+
def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
294
|
+
"""
|
|
295
|
+
Check that Shell functions with return values use Result[T, E].
|
|
296
|
+
|
|
297
|
+
Skips: functions returning None (CLI entry points).
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
301
|
+
>>> sym = Symbol(name="load", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
302
|
+
... signature="(path: str) -> Result[str, str]")
|
|
303
|
+
>>> info = FileInfo(path="shell/fs.py", lines=10, symbols=[sym], is_shell=True)
|
|
304
|
+
>>> check_shell_result(info, RuleConfig())
|
|
305
|
+
[]
|
|
306
|
+
"""
|
|
307
|
+
violations: list[Violation] = []
|
|
308
|
+
if not file_info.is_shell:
|
|
309
|
+
return violations
|
|
310
|
+
|
|
311
|
+
for symbol in file_info.symbols:
|
|
312
|
+
if symbol.kind != SymbolKind.FUNCTION:
|
|
313
|
+
continue
|
|
314
|
+
# Skip functions with no return type or returning None
|
|
315
|
+
if "-> None" in symbol.signature or "->" not in symbol.signature:
|
|
316
|
+
continue
|
|
317
|
+
# Skip generators (Iterator/Generator) - acceptable exception per protocol
|
|
318
|
+
if "Iterator[" in symbol.signature or "Generator[" in symbol.signature:
|
|
319
|
+
continue
|
|
320
|
+
if "Result[" not in symbol.signature:
|
|
321
|
+
violations.append(
|
|
322
|
+
Violation(
|
|
323
|
+
rule="shell_result",
|
|
324
|
+
severity=Severity.WARNING,
|
|
325
|
+
file=file_info.path,
|
|
326
|
+
line=symbol.line,
|
|
327
|
+
message=f"Shell function '{symbol.name}' should return Result[T, E]",
|
|
328
|
+
suggestion="Use Result[T, E] from returns library",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
return violations
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@post(lambda result: len(result) > 0)
|
|
335
|
+
def get_all_rules() -> list[RuleFunc]:
|
|
336
|
+
"""
|
|
337
|
+
Return all available rule functions.
|
|
338
|
+
|
|
339
|
+
Examples:
|
|
340
|
+
>>> len(get_all_rules()) >= 5
|
|
341
|
+
True
|
|
342
|
+
"""
|
|
343
|
+
return [
|
|
344
|
+
check_file_size,
|
|
345
|
+
check_function_size,
|
|
346
|
+
check_forbidden_imports,
|
|
347
|
+
check_contracts,
|
|
348
|
+
check_doctests,
|
|
349
|
+
check_shell_result,
|
|
350
|
+
check_internal_imports,
|
|
351
|
+
check_impure_calls,
|
|
352
|
+
check_empty_contracts,
|
|
353
|
+
check_semantic_tautology,
|
|
354
|
+
check_redundant_type_contracts,
|
|
355
|
+
check_param_mismatch,
|
|
356
|
+
check_partial_contract,
|
|
357
|
+
check_must_use,
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@post(lambda result: result is None or isinstance(result, Violation))
|
|
362
|
+
def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violation | None:
|
|
363
|
+
"""
|
|
364
|
+
Apply severity override to a violation.
|
|
365
|
+
|
|
366
|
+
Returns None if rule is set to "off", otherwise returns violation
|
|
367
|
+
with potentially updated severity.
|
|
368
|
+
|
|
369
|
+
Examples:
|
|
370
|
+
>>> from invar.core.models import Violation, Severity
|
|
371
|
+
>>> v = Violation(rule="test", severity=Severity.INFO, file="x.py", message="msg")
|
|
372
|
+
>>> _apply_severity_override(v, {"test": "off"}) is None
|
|
373
|
+
True
|
|
374
|
+
>>> v2 = _apply_severity_override(v, {"test": "error"})
|
|
375
|
+
>>> v2.severity
|
|
376
|
+
<Severity.ERROR: 'error'>
|
|
377
|
+
>>> _apply_severity_override(v, {}).severity # No override
|
|
378
|
+
<Severity.INFO: 'info'>
|
|
379
|
+
"""
|
|
380
|
+
override = overrides.get(v.rule)
|
|
381
|
+
if override is None:
|
|
382
|
+
return v
|
|
383
|
+
if override == "off":
|
|
384
|
+
return None
|
|
385
|
+
# Map string to Severity enum
|
|
386
|
+
severity_map = {"info": Severity.INFO, "warning": Severity.WARNING, "error": Severity.ERROR}
|
|
387
|
+
new_severity = severity_map.get(override)
|
|
388
|
+
if new_severity is None:
|
|
389
|
+
return v # Invalid override, keep original
|
|
390
|
+
# Create new violation with updated severity
|
|
391
|
+
return Violation(
|
|
392
|
+
rule=v.rule,
|
|
393
|
+
severity=new_severity,
|
|
394
|
+
file=v.file,
|
|
395
|
+
line=v.line,
|
|
396
|
+
message=v.message,
|
|
397
|
+
suggestion=v.suggestion,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
402
|
+
def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
403
|
+
"""
|
|
404
|
+
Run all rules against a file and collect violations.
|
|
405
|
+
|
|
406
|
+
Respects rule_exclusions and severity_overrides config.
|
|
407
|
+
|
|
408
|
+
Examples:
|
|
409
|
+
>>> from invar.core.models import FileInfo, RuleConfig, RuleExclusion
|
|
410
|
+
>>> violations = check_all_rules(FileInfo(path="test.py", lines=50), RuleConfig())
|
|
411
|
+
>>> isinstance(violations, list)
|
|
412
|
+
True
|
|
413
|
+
>>> # Test exclusion: file_size excluded for generated files
|
|
414
|
+
>>> excl = RuleExclusion(pattern="**/generated/**", rules=["file_size"])
|
|
415
|
+
>>> cfg = RuleConfig(rule_exclusions=[excl])
|
|
416
|
+
>>> big_file = FileInfo(path="src/generated/data.py", lines=600)
|
|
417
|
+
>>> vs = check_all_rules(big_file, cfg)
|
|
418
|
+
>>> any(v.rule == "file_size" for v in vs)
|
|
419
|
+
False
|
|
420
|
+
"""
|
|
421
|
+
# Phase 9 P1: Get excluded rules for this file
|
|
422
|
+
excluded = get_excluded_rules(file_info.path, config)
|
|
423
|
+
exclude_all = "*" in excluded
|
|
424
|
+
|
|
425
|
+
violations = []
|
|
426
|
+
for rule in get_all_rules():
|
|
427
|
+
for v in rule(file_info, config):
|
|
428
|
+
# Skip if rule is excluded (either specifically or via "*")
|
|
429
|
+
if exclude_all or v.rule in excluded:
|
|
430
|
+
continue
|
|
431
|
+
# Phase 9 P2: Apply severity overrides
|
|
432
|
+
v = _apply_severity_override(v, config.severity_overrides)
|
|
433
|
+
if v is not None:
|
|
434
|
+
violations.append(v)
|
|
435
|
+
return violations
|
invar/core/strategies.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strategy inference for property-based testing.
|
|
3
|
+
|
|
4
|
+
Core module: parses @pre contract lambdas to infer Hypothesis strategies.
|
|
5
|
+
Inspired by icontract-hypothesis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from deal import post, pre
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class StrategyHint:
|
|
22
|
+
"""Inferred strategy hint for a parameter."""
|
|
23
|
+
|
|
24
|
+
param_name: str
|
|
25
|
+
param_type: type | None
|
|
26
|
+
constraints: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
description: str = ""
|
|
28
|
+
|
|
29
|
+
@post(lambda result: isinstance(result, dict))
|
|
30
|
+
def to_hypothesis_args(self) -> dict[str, Any]:
|
|
31
|
+
"""Convert constraints to Hypothesis strategy arguments.
|
|
32
|
+
|
|
33
|
+
>>> hint = StrategyHint("x", int, {"min_value": 0, "max_value": 100})
|
|
34
|
+
>>> hint.to_hypothesis_args()
|
|
35
|
+
{'min_value': 0, 'max_value': 100}
|
|
36
|
+
"""
|
|
37
|
+
return self.constraints.copy()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Helper to parse numbers (int or float, including scientific notation)
|
|
41
|
+
# Pattern matches valid number literals extracted from regex patterns
|
|
42
|
+
# Uses [0-9] instead of \d to avoid matching Unicode digits
|
|
43
|
+
# Requires at least one digit before optional decimal/exponent parts
|
|
44
|
+
_NUMBER_PATTERN = re.compile(r"^-?[0-9]+\.?[0-9]*(?:e[+-]?[0-9]+)?$", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pre(lambda s: isinstance(s, str) and _NUMBER_PATTERN.match(s.strip()))
|
|
48
|
+
@post(lambda result: isinstance(result, (int, float)))
|
|
49
|
+
def _parse_number(s: str) -> int | float:
|
|
50
|
+
"""Parse a number string to int or float.
|
|
51
|
+
|
|
52
|
+
>>> _parse_number("42")
|
|
53
|
+
42
|
|
54
|
+
>>> _parse_number("-3.14")
|
|
55
|
+
-3.14
|
|
56
|
+
>>> _parse_number("1e-5")
|
|
57
|
+
1e-05
|
|
58
|
+
"""
|
|
59
|
+
s = s.strip()
|
|
60
|
+
if "." in s or "e" in s.lower():
|
|
61
|
+
return float(s)
|
|
62
|
+
return int(s)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Pattern → Constraint extraction
|
|
66
|
+
# Each pattern maps to a function that extracts constraints from regex match
|
|
67
|
+
# DX-12: Added float support with exclude_min/exclude_max for precise bounds
|
|
68
|
+
PATTERNS: list[tuple[str, Callable[[re.Match, str], dict[str, Any] | None]]] = [
|
|
69
|
+
# Numeric comparisons: x > 5, x >= 5 (supports int, float, scientific notation)
|
|
70
|
+
(
|
|
71
|
+
r"(\w+)\s*>\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
72
|
+
lambda m, p: {"min_value": _parse_number(m.group(2)), "exclude_min": True}
|
|
73
|
+
if m.group(1) == p
|
|
74
|
+
else None,
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
r"(\w+)\s*>=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
78
|
+
lambda m, p: {"min_value": _parse_number(m.group(2))} if m.group(1) == p else None,
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
r"(\w+)\s*<\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
82
|
+
lambda m, p: {"max_value": _parse_number(m.group(2)), "exclude_max": True}
|
|
83
|
+
if m.group(1) == p
|
|
84
|
+
else None,
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
r"(\w+)\s*<=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
88
|
+
lambda m, p: {"max_value": _parse_number(m.group(2))} if m.group(1) == p else None,
|
|
89
|
+
),
|
|
90
|
+
# Reversed comparisons: 5 < x, 5 <= x
|
|
91
|
+
(
|
|
92
|
+
r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<\s*(\w+)(?!\s*<)",
|
|
93
|
+
lambda m, p: {"min_value": _parse_number(m.group(1)), "exclude_min": True}
|
|
94
|
+
if m.group(2) == p
|
|
95
|
+
else None,
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<=\s*(\w+)(?!\s*<)",
|
|
99
|
+
lambda m, p: {"min_value": _parse_number(m.group(1))} if m.group(2) == p else None,
|
|
100
|
+
),
|
|
101
|
+
# Pattern: Range comparison (e.g. 0 < x < 10)
|
|
102
|
+
(
|
|
103
|
+
r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<\s*(\w+)\s*<\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
104
|
+
lambda m, p: {
|
|
105
|
+
"min_value": _parse_number(m.group(1)),
|
|
106
|
+
"max_value": _parse_number(m.group(3)),
|
|
107
|
+
"exclude_min": True,
|
|
108
|
+
"exclude_max": True,
|
|
109
|
+
}
|
|
110
|
+
if m.group(2) == p
|
|
111
|
+
else None,
|
|
112
|
+
),
|
|
113
|
+
(
|
|
114
|
+
r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<=\s*(\w+)\s*<=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
|
|
115
|
+
lambda m, p: {"min_value": _parse_number(m.group(1)), "max_value": _parse_number(m.group(3))}
|
|
116
|
+
if m.group(2) == p
|
|
117
|
+
else None,
|
|
118
|
+
),
|
|
119
|
+
# Length constraints: len(x) > 0
|
|
120
|
+
(
|
|
121
|
+
r"len\((\w+)\)\s*>\s*(\d+)",
|
|
122
|
+
lambda m, p: {"min_size": int(m.group(2)) + 1} if m.group(1) == p else None,
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
r"len\((\w+)\)\s*>=\s*(\d+)",
|
|
126
|
+
lambda m, p: {"min_size": int(m.group(2))} if m.group(1) == p else None,
|
|
127
|
+
),
|
|
128
|
+
(
|
|
129
|
+
r"len\((\w+)\)\s*<\s*(\d+)",
|
|
130
|
+
lambda m, p: {"max_size": int(m.group(2)) - 1} if m.group(1) == p else None,
|
|
131
|
+
),
|
|
132
|
+
(
|
|
133
|
+
r"len\((\w+)\)\s*<=\s*(\d+)",
|
|
134
|
+
lambda m, p: {"max_size": int(m.group(2))} if m.group(1) == p else None,
|
|
135
|
+
),
|
|
136
|
+
# Non-empty: len(x) > 0 or just x
|
|
137
|
+
(
|
|
138
|
+
r"len\((\w+)\)\s*>\s*0",
|
|
139
|
+
lambda m, p: {"min_size": 1} if m.group(1) == p else None,
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pre(
|
|
145
|
+
lambda pre_source, param_name, param_type=None: isinstance(pre_source, str)
|
|
146
|
+
and isinstance(param_name, str)
|
|
147
|
+
)
|
|
148
|
+
@post(lambda result: isinstance(result, StrategyHint))
|
|
149
|
+
def infer_from_lambda(
|
|
150
|
+
pre_source: str,
|
|
151
|
+
param_name: str,
|
|
152
|
+
param_type: type | None = None,
|
|
153
|
+
) -> StrategyHint:
|
|
154
|
+
"""
|
|
155
|
+
Parse @pre lambda source to infer strategy constraints.
|
|
156
|
+
|
|
157
|
+
DX-12: Now returns exclude_min/exclude_max for strict inequalities,
|
|
158
|
+
allowing Hypothesis to generate precise bounds for floats.
|
|
159
|
+
|
|
160
|
+
>>> hint = infer_from_lambda("lambda x: x > 0", "x", int)
|
|
161
|
+
>>> hint.constraints['min_value']
|
|
162
|
+
0
|
|
163
|
+
>>> hint.constraints.get('exclude_min')
|
|
164
|
+
True
|
|
165
|
+
|
|
166
|
+
>>> hint = infer_from_lambda("lambda x: 0 < x < 100", "x", float)
|
|
167
|
+
>>> hint.constraints['min_value'], hint.constraints['max_value']
|
|
168
|
+
(0, 100)
|
|
169
|
+
>>> hint.constraints.get('exclude_min'), hint.constraints.get('exclude_max')
|
|
170
|
+
(True, True)
|
|
171
|
+
|
|
172
|
+
>>> hint = infer_from_lambda("lambda x: len(x) > 0", "x", list)
|
|
173
|
+
>>> hint.constraints
|
|
174
|
+
{'min_size': 1}
|
|
175
|
+
|
|
176
|
+
>>> hint = infer_from_lambda("lambda x, y: x > 5 and y < 10", "x", int)
|
|
177
|
+
>>> hint.constraints['min_value']
|
|
178
|
+
5
|
|
179
|
+
"""
|
|
180
|
+
constraints: dict[str, Any] = {}
|
|
181
|
+
hints: list[str] = []
|
|
182
|
+
|
|
183
|
+
for pattern, extractor in PATTERNS:
|
|
184
|
+
for match in re.finditer(pattern, pre_source):
|
|
185
|
+
extracted = extractor(match, param_name)
|
|
186
|
+
if extracted:
|
|
187
|
+
constraints.update(extracted)
|
|
188
|
+
hints.append(f"Matched: {pattern}")
|
|
189
|
+
|
|
190
|
+
description = "; ".join(hints) if hints else "No patterns matched"
|
|
191
|
+
|
|
192
|
+
return StrategyHint(
|
|
193
|
+
param_name=param_name,
|
|
194
|
+
param_type=param_type,
|
|
195
|
+
constraints=constraints,
|
|
196
|
+
description=description,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pre(
|
|
201
|
+
lambda pre_sources, param_name, param_type=None: isinstance(pre_sources, list)
|
|
202
|
+
and isinstance(param_name, str)
|
|
203
|
+
)
|
|
204
|
+
def infer_from_multiple(
|
|
205
|
+
pre_sources: list[str],
|
|
206
|
+
param_name: str,
|
|
207
|
+
param_type: type | None = None,
|
|
208
|
+
) -> StrategyHint:
|
|
209
|
+
"""
|
|
210
|
+
Combine constraints from multiple @pre contracts.
|
|
211
|
+
|
|
212
|
+
>>> hints = infer_from_multiple(["lambda x: x > 0", "lambda x: x < 100"], "x", float)
|
|
213
|
+
>>> hints.constraints['min_value'], hints.constraints['max_value']
|
|
214
|
+
(0, 100)
|
|
215
|
+
>>> hints.constraints.get('exclude_min'), hints.constraints.get('exclude_max')
|
|
216
|
+
(True, True)
|
|
217
|
+
"""
|
|
218
|
+
combined: dict[str, Any] = {}
|
|
219
|
+
descriptions: list[str] = []
|
|
220
|
+
|
|
221
|
+
for source in pre_sources:
|
|
222
|
+
hint = infer_from_lambda(source, param_name, param_type)
|
|
223
|
+
combined.update(hint.constraints)
|
|
224
|
+
if hint.description != "No patterns matched":
|
|
225
|
+
descriptions.append(hint.description)
|
|
226
|
+
|
|
227
|
+
return StrategyHint(
|
|
228
|
+
param_name=param_name,
|
|
229
|
+
param_type=param_type,
|
|
230
|
+
constraints=combined,
|
|
231
|
+
description="; ".join(descriptions) if descriptions else "No patterns matched",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pre(lambda hint: isinstance(hint, StrategyHint))
|
|
236
|
+
@post(lambda result: isinstance(result, str))
|
|
237
|
+
def format_strategy_hint(hint: StrategyHint) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Format a strategy hint as a human-readable string.
|
|
240
|
+
|
|
241
|
+
>>> hint = StrategyHint("x", int, {"min_value": 0, "max_value": 100})
|
|
242
|
+
>>> format_strategy_hint(hint)
|
|
243
|
+
'x: integers(min_value=0, max_value=100)'
|
|
244
|
+
|
|
245
|
+
>>> hint = StrategyHint("y", float, {"min_value": 0, "exclude_min": True})
|
|
246
|
+
>>> 'floats' in format_strategy_hint(hint)
|
|
247
|
+
True
|
|
248
|
+
"""
|
|
249
|
+
if not hint.constraints:
|
|
250
|
+
if hint.param_type:
|
|
251
|
+
return f"{hint.param_name}: from_type({hint.param_type.__name__})"
|
|
252
|
+
return f"{hint.param_name}: <unknown>"
|
|
253
|
+
|
|
254
|
+
type_name = hint.param_type.__name__ if hint.param_type else "unknown"
|
|
255
|
+
|
|
256
|
+
if type_name in ("int", "float"):
|
|
257
|
+
strategy = "integers" if type_name == "int" else "floats"
|
|
258
|
+
args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
|
|
259
|
+
return f"{hint.param_name}: {strategy}({args})"
|
|
260
|
+
|
|
261
|
+
if type_name in ("list", "str", "tuple"):
|
|
262
|
+
strategy = "lists" if type_name == "list" else "text"
|
|
263
|
+
args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
|
|
264
|
+
return f"{hint.param_name}: {strategy}({args})"
|
|
265
|
+
|
|
266
|
+
args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
|
|
267
|
+
return f"{hint.param_name}: {args}"
|