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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fix suggestion generation for Guard (Phase 7.3, 11 P27).
|
|
3
|
+
|
|
4
|
+
Generates concrete, usable fix code for violations.
|
|
5
|
+
Agents need exact code, not vague descriptions.
|
|
6
|
+
|
|
7
|
+
P27: Enhanced Context for Agent Decision
|
|
8
|
+
- Show multiple pattern options for constraints
|
|
9
|
+
- Guard provides options, Agent decides
|
|
10
|
+
|
|
11
|
+
No I/O operations - receives parsed data only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from deal import post, pre
|
|
19
|
+
|
|
20
|
+
from invar.core.models import Symbol, SymbolKind
|
|
21
|
+
|
|
22
|
+
# P27: Common constraint patterns by type (ordered by commonality)
|
|
23
|
+
CONSTRAINT_PATTERNS: dict[str, list[str]] = {
|
|
24
|
+
"int": ["{name} >= 0", "{name} > 0", "{name} != 0"],
|
|
25
|
+
"float": ["{name} >= 0", "{name} > 0", "{name} != 0"],
|
|
26
|
+
"str": ["len({name}) > 0", "{name}", "{name}.strip()"],
|
|
27
|
+
"list": ["len({name}) > 0", "{name}"],
|
|
28
|
+
"dict": ["len({name}) > 0", "{name}"],
|
|
29
|
+
"set": ["len({name}) > 0", "{name}"],
|
|
30
|
+
"tuple": ["len({name}) > 0", "{name}"],
|
|
31
|
+
"bytes": ["len({name}) > 0", "{name}"],
|
|
32
|
+
"Optional": ["{name} is not None", "{name}"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pre(lambda signature: signature.startswith("(") or signature == "")
|
|
37
|
+
def generate_contract_suggestion(signature: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Generate a suggested @pre contract based on function signature.
|
|
40
|
+
|
|
41
|
+
Uses common patterns:
|
|
42
|
+
- int/float: param >= 0
|
|
43
|
+
- str/list/dict/set/tuple: len(param) > 0
|
|
44
|
+
- Optional/None union: param is not None
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
>>> generate_contract_suggestion("(x: int, y: int) -> int")
|
|
48
|
+
'@pre(lambda x, y: x >= 0 and y >= 0)'
|
|
49
|
+
>>> generate_contract_suggestion("(name: str) -> bool")
|
|
50
|
+
'@pre(lambda name: len(name) > 0)'
|
|
51
|
+
>>> generate_contract_suggestion("(items: list[int]) -> int")
|
|
52
|
+
'@pre(lambda items: len(items) > 0)'
|
|
53
|
+
>>> generate_contract_suggestion("(x, y)")
|
|
54
|
+
''
|
|
55
|
+
>>> generate_contract_suggestion("(value: Optional[str]) -> str")
|
|
56
|
+
'@pre(lambda value: value is not None)'
|
|
57
|
+
"""
|
|
58
|
+
params = _extract_params(signature)
|
|
59
|
+
if not params:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
constraints = []
|
|
63
|
+
param_names = []
|
|
64
|
+
|
|
65
|
+
for name, type_hint in params:
|
|
66
|
+
if not name: # Skip empty names from malformed signatures
|
|
67
|
+
continue
|
|
68
|
+
param_names.append(name)
|
|
69
|
+
if not type_hint:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
constraint = _suggest_constraint(name, type_hint)
|
|
73
|
+
if constraint:
|
|
74
|
+
constraints.append(constraint)
|
|
75
|
+
|
|
76
|
+
if not constraints:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
params_str = ", ".join(param_names)
|
|
80
|
+
constraints_str = " and ".join(constraints)
|
|
81
|
+
return f"@pre(lambda {params_str}: {constraints_str})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pre(lambda signature: signature.startswith("(") or signature == "")
|
|
85
|
+
def _extract_params(signature: str) -> list[tuple[str, str | None]]:
|
|
86
|
+
"""
|
|
87
|
+
Extract parameters and their types from a signature.
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
>>> _extract_params("(x: int, y: str) -> bool")
|
|
91
|
+
[('x', 'int'), ('y', 'str')]
|
|
92
|
+
>>> _extract_params("(x, y)")
|
|
93
|
+
[('x', None), ('y', None)]
|
|
94
|
+
>>> _extract_params("(items: list[int], n: int = 10) -> list")
|
|
95
|
+
[('items', 'list[int]'), ('n', 'int')]
|
|
96
|
+
"""
|
|
97
|
+
if not signature:
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
match = re.match(r"\(([^)]*)\)", signature)
|
|
101
|
+
if not match:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
params = []
|
|
105
|
+
for param in match.group(1).split(","):
|
|
106
|
+
param = param.strip()
|
|
107
|
+
if not param:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if ": " in param:
|
|
111
|
+
name, type_hint = param.split(": ", 1)
|
|
112
|
+
# Handle default values
|
|
113
|
+
if "=" in type_hint:
|
|
114
|
+
type_hint = type_hint.split("=")[0].strip()
|
|
115
|
+
params.append((name.strip(), type_hint.strip()))
|
|
116
|
+
else:
|
|
117
|
+
# No type annotation
|
|
118
|
+
if "=" in param:
|
|
119
|
+
param = param.split("=")[0].strip()
|
|
120
|
+
params.append((param, None))
|
|
121
|
+
|
|
122
|
+
return params
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pre(lambda name, type_hint: len(name) > 0 and len(type_hint) > 0)
|
|
126
|
+
def _suggest_constraint(name: str, type_hint: str) -> str | None:
|
|
127
|
+
"""
|
|
128
|
+
Suggest a constraint for a parameter based on its type.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
>>> _suggest_constraint("x", "int")
|
|
132
|
+
'x >= 0'
|
|
133
|
+
>>> _suggest_constraint("name", "str")
|
|
134
|
+
'len(name) > 0'
|
|
135
|
+
>>> _suggest_constraint("items", "list[str]")
|
|
136
|
+
'len(items) > 0'
|
|
137
|
+
>>> _suggest_constraint("value", "Optional[int]")
|
|
138
|
+
'value is not None'
|
|
139
|
+
>>> _suggest_constraint("x", "SomeCustomType")
|
|
140
|
+
"""
|
|
141
|
+
# Numeric types: suggest non-negative
|
|
142
|
+
if type_hint in ("int", "float"):
|
|
143
|
+
return f"{name} >= 0"
|
|
144
|
+
|
|
145
|
+
# Collection types: suggest non-empty
|
|
146
|
+
if type_hint in ("str", "list", "dict", "set", "tuple", "bytes"):
|
|
147
|
+
return f"len({name}) > 0"
|
|
148
|
+
|
|
149
|
+
# Generic collections: list[X], dict[K, V], etc.
|
|
150
|
+
base_match = re.match(r"^(list|dict|set|tuple)\[", type_hint)
|
|
151
|
+
if base_match:
|
|
152
|
+
return f"len({name}) > 0"
|
|
153
|
+
|
|
154
|
+
# Optional types: suggest not None
|
|
155
|
+
if type_hint.startswith("Optional[") or " | None" in type_hint or "None |" in type_hint:
|
|
156
|
+
return f"{name} is not None"
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pre(lambda name, type_hint: len(name) > 0 and len(type_hint) > 0)
|
|
162
|
+
def _get_pattern_alternatives(name: str, type_hint: str) -> list[str]:
|
|
163
|
+
"""
|
|
164
|
+
Get multiple constraint pattern alternatives for a parameter (P27).
|
|
165
|
+
|
|
166
|
+
Returns up to 3 common patterns for the type.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
>>> _get_pattern_alternatives("x", "int")
|
|
170
|
+
['x >= 0', 'x > 0', 'x != 0']
|
|
171
|
+
>>> _get_pattern_alternatives("name", "str")
|
|
172
|
+
['len(name) > 0', 'name', 'name.strip()']
|
|
173
|
+
>>> _get_pattern_alternatives("value", "Optional[int]")
|
|
174
|
+
['value is not None', 'value']
|
|
175
|
+
>>> _get_pattern_alternatives("x", "CustomType")
|
|
176
|
+
[]
|
|
177
|
+
"""
|
|
178
|
+
# Check exact type matches
|
|
179
|
+
if type_hint in CONSTRAINT_PATTERNS:
|
|
180
|
+
return [p.format(name=name) for p in CONSTRAINT_PATTERNS[type_hint]]
|
|
181
|
+
|
|
182
|
+
# Generic collections: list[X], dict[K, V], etc.
|
|
183
|
+
base_match = re.match(r"^(list|dict|set|tuple)\[", type_hint)
|
|
184
|
+
if base_match:
|
|
185
|
+
base_type = base_match.group(1)
|
|
186
|
+
if base_type in CONSTRAINT_PATTERNS:
|
|
187
|
+
return [p.format(name=name) for p in CONSTRAINT_PATTERNS[base_type]]
|
|
188
|
+
|
|
189
|
+
# Optional types
|
|
190
|
+
if type_hint.startswith("Optional[") or " | None" in type_hint or "None |" in type_hint:
|
|
191
|
+
return [p.format(name=name) for p in CONSTRAINT_PATTERNS["Optional"]]
|
|
192
|
+
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pre(lambda signature: signature.startswith("(") or signature == "")
|
|
197
|
+
def generate_pattern_options(signature: str) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Generate multiple constraint pattern options for each parameter (P27).
|
|
200
|
+
|
|
201
|
+
Returns a formatted string showing alternatives for each typed parameter.
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
>>> generate_pattern_options("(x: int, y: str) -> int")
|
|
205
|
+
'Patterns: x >= 0 | x > 0 | x != 0, len(y) > 0 | y | y.strip()'
|
|
206
|
+
>>> generate_pattern_options("(data, config)")
|
|
207
|
+
''
|
|
208
|
+
"""
|
|
209
|
+
params = _extract_params(signature)
|
|
210
|
+
if not params:
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
all_patterns: list[str] = []
|
|
214
|
+
for name, type_hint in params:
|
|
215
|
+
if not name or not type_hint: # Skip empty names from malformed signatures
|
|
216
|
+
continue
|
|
217
|
+
patterns = _get_pattern_alternatives(name, type_hint)
|
|
218
|
+
if patterns:
|
|
219
|
+
all_patterns.append(" | ".join(patterns))
|
|
220
|
+
|
|
221
|
+
if not all_patterns:
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
return f"Patterns: {', '.join(all_patterns)}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pre(lambda signature: signature.startswith("(") or signature == "")
|
|
228
|
+
@post(lambda result: isinstance(result, str))
|
|
229
|
+
def _generate_lambda_skeleton(signature: str) -> str:
|
|
230
|
+
"""
|
|
231
|
+
Generate a lambda skeleton from function signature (P4).
|
|
232
|
+
|
|
233
|
+
Returns skeleton with parameters extracted, condition placeholder.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
>>> _generate_lambda_skeleton("(x: int, y: int) -> int")
|
|
237
|
+
'@pre(lambda x, y: <condition>) or @post(lambda result: <condition>)'
|
|
238
|
+
>>> _generate_lambda_skeleton("(items: list) -> None")
|
|
239
|
+
'@pre(lambda items: <condition>) or @post(lambda result: <condition>)'
|
|
240
|
+
>>> _generate_lambda_skeleton("() -> int")
|
|
241
|
+
'@post(lambda result: <condition>)'
|
|
242
|
+
"""
|
|
243
|
+
params = _extract_params(signature)
|
|
244
|
+
param_names = [name for name, _ in params]
|
|
245
|
+
|
|
246
|
+
if not param_names:
|
|
247
|
+
return "@post(lambda result: <condition>)"
|
|
248
|
+
|
|
249
|
+
params_str = ", ".join(param_names)
|
|
250
|
+
return f"@pre(lambda {params_str}: <condition>) or @post(lambda result: <condition>)"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Prefixes for violation type suggestions
|
|
254
|
+
_VIOLATION_PREFIXES = {
|
|
255
|
+
"missing_contract": ("Add: ", "Add: "),
|
|
256
|
+
"empty_contract": ("Replace with: ", "Replace with: "),
|
|
257
|
+
"redundant_type_contract": ("Replace with business logic: ", "Replace with: "),
|
|
258
|
+
"semantic_tautology": ("Replace tautology with meaningful constraint: ", "Replace tautology with: "),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pre(lambda prefix, suggestion, patterns: bool(prefix) and bool(suggestion))
|
|
263
|
+
@post(lambda result: isinstance(result, str) and len(result) > 0)
|
|
264
|
+
def _format_with_patterns(prefix: str, suggestion: str, patterns: str) -> str:
|
|
265
|
+
"""Format suggestion with optional patterns.
|
|
266
|
+
|
|
267
|
+
>>> _format_with_patterns("Add: ", "check(x)", "Patterns: x > 0")
|
|
268
|
+
'Add: check(x)\\nPatterns: x > 0'
|
|
269
|
+
"""
|
|
270
|
+
result = f"{prefix}{suggestion}"
|
|
271
|
+
if patterns:
|
|
272
|
+
result += f"\n{patterns}"
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pre(
|
|
277
|
+
lambda symbol, violation_type: violation_type
|
|
278
|
+
in ("missing_contract", "empty_contract", "redundant_type_contract", "semantic_tautology", "")
|
|
279
|
+
)
|
|
280
|
+
def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
|
|
281
|
+
"""
|
|
282
|
+
Format a complete suggestion message for a violation.
|
|
283
|
+
|
|
284
|
+
Phase 9.2 P4: Generate lambda skeletons when no type-based suggestion available.
|
|
285
|
+
P7: Added semantic_tautology support.
|
|
286
|
+
P27: Show pattern alternatives (Guard provides options, Agent decides).
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
290
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
291
|
+
... signature="(x: int, y: int) -> int")
|
|
292
|
+
>>> msg = format_suggestion_for_violation(sym, "missing_contract")
|
|
293
|
+
>>> "@pre(lambda x, y: x >= 0 and y >= 0)" in msg
|
|
294
|
+
True
|
|
295
|
+
>>> "Patterns:" in msg # P27: shows alternatives
|
|
296
|
+
True
|
|
297
|
+
>>> # P4: skeleton when no type-based suggestion
|
|
298
|
+
>>> sym2 = Symbol(name="process", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
299
|
+
... signature="(data, config)")
|
|
300
|
+
>>> msg2 = format_suggestion_for_violation(sym2, "missing_contract")
|
|
301
|
+
>>> "@pre(lambda data, config: <condition>)" in msg2
|
|
302
|
+
True
|
|
303
|
+
"""
|
|
304
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
305
|
+
return ""
|
|
306
|
+
|
|
307
|
+
if violation_type not in _VIOLATION_PREFIXES:
|
|
308
|
+
return ""
|
|
309
|
+
|
|
310
|
+
# Guard against malformed signatures
|
|
311
|
+
sig = symbol.signature
|
|
312
|
+
if not (sig.startswith("(") or sig == ""):
|
|
313
|
+
return ""
|
|
314
|
+
|
|
315
|
+
suggestion_prefix, skeleton_prefix = _VIOLATION_PREFIXES[violation_type]
|
|
316
|
+
patterns = generate_pattern_options(sig)
|
|
317
|
+
suggestion = generate_contract_suggestion(sig)
|
|
318
|
+
|
|
319
|
+
if suggestion:
|
|
320
|
+
return _format_with_patterns(suggestion_prefix, suggestion, patterns)
|
|
321
|
+
|
|
322
|
+
# P4: Generate lambda skeleton when no type-based suggestion
|
|
323
|
+
skeleton = _generate_lambda_skeleton(sig)
|
|
324
|
+
return f"{skeleton_prefix}{skeleton}"
|
invar/core/tautology.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Semantic tautology detection for contract quality (P7). No I/O operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from deal import post, pre
|
|
8
|
+
|
|
9
|
+
from invar.core.lambda_helpers import find_lambda
|
|
10
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
11
|
+
from invar.core.suggestions import format_suggestion_for_violation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pre(lambda expression: ("lambda" in expression and ":" in expression) or not expression.strip())
|
|
15
|
+
def is_semantic_tautology(expression: str) -> tuple[bool, str]:
|
|
16
|
+
"""Check if a contract expression is a semantic tautology.
|
|
17
|
+
|
|
18
|
+
Returns (is_tautology, pattern_description).
|
|
19
|
+
|
|
20
|
+
P7: Detects patterns that are always true:
|
|
21
|
+
- x == x (identity comparison)
|
|
22
|
+
- len(x) >= 0 (length always non-negative)
|
|
23
|
+
- isinstance(x, object) (everything is object)
|
|
24
|
+
- x or True (always true due to True)
|
|
25
|
+
- True and x (simplifies but starts with True)
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
>>> is_semantic_tautology("lambda x: x == x")
|
|
29
|
+
(True, 'x == x is always True')
|
|
30
|
+
>>> is_semantic_tautology("lambda x: len(x) >= 0")
|
|
31
|
+
(True, 'len(x) >= 0 is always True for any sequence')
|
|
32
|
+
>>> is_semantic_tautology("lambda x: isinstance(x, object)")
|
|
33
|
+
(True, 'isinstance(x, object) is always True')
|
|
34
|
+
>>> is_semantic_tautology("lambda x: x > 0")
|
|
35
|
+
(False, '')
|
|
36
|
+
>>> is_semantic_tautology("lambda x: x or True")
|
|
37
|
+
(True, 'expression contains unconditional True')
|
|
38
|
+
"""
|
|
39
|
+
if not expression.strip():
|
|
40
|
+
return (False, "")
|
|
41
|
+
try:
|
|
42
|
+
tree = ast.parse(expression, mode="eval")
|
|
43
|
+
lambda_node = find_lambda(tree)
|
|
44
|
+
if lambda_node is None:
|
|
45
|
+
return (False, "")
|
|
46
|
+
return _check_tautology_patterns(lambda_node.body)
|
|
47
|
+
except (SyntaxError, TypeError, ValueError):
|
|
48
|
+
return (False, "")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
52
|
+
def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
|
|
53
|
+
"""Check for common tautology patterns in AST node."""
|
|
54
|
+
# Identity comparison pattern (e.g., x == x)
|
|
55
|
+
if (
|
|
56
|
+
isinstance(node, ast.Compare)
|
|
57
|
+
and len(node.ops) == 1
|
|
58
|
+
and isinstance(node.ops[0], (ast.Eq, ast.Is))
|
|
59
|
+
):
|
|
60
|
+
left = ast.unparse(node.left)
|
|
61
|
+
right = ast.unparse(node.comparators[0])
|
|
62
|
+
if left == right:
|
|
63
|
+
return (True, f"{left} == {right} is always True")
|
|
64
|
+
|
|
65
|
+
# Length non-negative pattern (e.g., len(x) >= 0)
|
|
66
|
+
if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
|
|
67
|
+
left = node.left
|
|
68
|
+
op = node.ops[0]
|
|
69
|
+
right = node.comparators[0]
|
|
70
|
+
if (
|
|
71
|
+
isinstance(left, ast.Call)
|
|
72
|
+
and isinstance(left.func, ast.Name)
|
|
73
|
+
and left.func.id == "len"
|
|
74
|
+
and isinstance(op, ast.GtE)
|
|
75
|
+
and isinstance(right, ast.Constant)
|
|
76
|
+
and right.value == 0
|
|
77
|
+
):
|
|
78
|
+
arg = ast.unparse(left.args[0]) if left.args else "x"
|
|
79
|
+
return (True, f"len({arg}) >= 0 is always True for any sequence")
|
|
80
|
+
|
|
81
|
+
# isinstance with object pattern (always True)
|
|
82
|
+
if (
|
|
83
|
+
isinstance(node, ast.Call)
|
|
84
|
+
and isinstance(node.func, ast.Name)
|
|
85
|
+
and node.func.id == "isinstance"
|
|
86
|
+
and len(node.args) == 2
|
|
87
|
+
):
|
|
88
|
+
type_arg = node.args[1]
|
|
89
|
+
if isinstance(type_arg, ast.Name) and type_arg.id == "object":
|
|
90
|
+
arg = ast.unparse(node.args[0])
|
|
91
|
+
return (True, f"isinstance({arg}, object) is always True")
|
|
92
|
+
|
|
93
|
+
# Pattern: x or True, True or x (always true)
|
|
94
|
+
if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
|
|
95
|
+
for val in node.values:
|
|
96
|
+
if isinstance(val, ast.Constant) and val.value is True:
|
|
97
|
+
return (True, "expression contains unconditional True")
|
|
98
|
+
|
|
99
|
+
return (False, "")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
103
|
+
def check_semantic_tautology(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
104
|
+
"""Check for semantic tautology contracts. Core files only.
|
|
105
|
+
|
|
106
|
+
P7: Detects contracts that are always true due to semantic patterns:
|
|
107
|
+
- x == x, len(x) >= 0, isinstance(x, object), x or True
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
111
|
+
>>> c = Contract(kind="pre", expression="lambda x: x == x", line=1)
|
|
112
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
113
|
+
>>> vs = check_semantic_tautology(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())
|
|
114
|
+
>>> vs[0].rule
|
|
115
|
+
'semantic_tautology'
|
|
116
|
+
"""
|
|
117
|
+
violations: list[Violation] = []
|
|
118
|
+
if not file_info.is_core:
|
|
119
|
+
return violations
|
|
120
|
+
for symbol in file_info.symbols:
|
|
121
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
122
|
+
continue
|
|
123
|
+
for contract in symbol.contracts:
|
|
124
|
+
is_tautology, pattern_desc = is_semantic_tautology(contract.expression)
|
|
125
|
+
if is_tautology:
|
|
126
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
127
|
+
violations.append(
|
|
128
|
+
Violation(
|
|
129
|
+
rule="semantic_tautology",
|
|
130
|
+
severity=Severity.WARNING,
|
|
131
|
+
file=file_info.path,
|
|
132
|
+
line=contract.line,
|
|
133
|
+
message=f"{kind} '{symbol.name}' has tautological contract: {pattern_desc}",
|
|
134
|
+
suggestion=format_suggestion_for_violation(symbol, "semantic_tautology"),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return violations
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timeout inference for CrossHair based on code characteristics.
|
|
3
|
+
|
|
4
|
+
Part of DX-12: Hypothesis as CrossHair fallback.
|
|
5
|
+
Extracted from hypothesis_strategies.py to reduce file size.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from deal import post, pre
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TimeoutTier:
|
|
23
|
+
"""Timeout tier for CrossHair based on code characteristics."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
timeout: int
|
|
27
|
+
description: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TIMEOUT_TIERS = {
|
|
31
|
+
"pure_python": TimeoutTier("pure_python", 10, "Pure Python, no external libs"),
|
|
32
|
+
"stdlib_only": TimeoutTier("stdlib_only", 15, "Uses collections, itertools"),
|
|
33
|
+
"numpy_pandas": TimeoutTier("numpy_pandas", 5, "Quick check, likely to skip"),
|
|
34
|
+
"complex_nested": TimeoutTier("complex_nested", 30, "Deep recursion, many branches"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Libraries that CrossHair cannot handle well
|
|
38
|
+
LIBRARY_BLACKLIST = frozenset([
|
|
39
|
+
"numpy", "pandas", "torch", "tensorflow", "scipy",
|
|
40
|
+
"sklearn", "cv2", "PIL", "requests", "aiohttp",
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pre(lambda func: callable(func))
|
|
45
|
+
@post(lambda result: isinstance(result, int) and result > 0)
|
|
46
|
+
def infer_timeout(func: Callable) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Infer appropriate CrossHair timeout from function source.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
func: The function to analyze
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Timeout in seconds
|
|
55
|
+
|
|
56
|
+
>>> def pure_func(x: int) -> int: return x * 2
|
|
57
|
+
>>> infer_timeout(pure_func)
|
|
58
|
+
10
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
source = inspect.getsource(func)
|
|
62
|
+
except (OSError, TypeError):
|
|
63
|
+
return TIMEOUT_TIERS["pure_python"].timeout
|
|
64
|
+
|
|
65
|
+
# Check for blacklisted libraries
|
|
66
|
+
for lib in LIBRARY_BLACKLIST:
|
|
67
|
+
if re.search(rf"\b{lib}\b", source):
|
|
68
|
+
return TIMEOUT_TIERS["numpy_pandas"].timeout
|
|
69
|
+
|
|
70
|
+
# Count complexity indicators
|
|
71
|
+
nesting_depth = _estimate_nesting_depth(source)
|
|
72
|
+
branch_count = _count_branches(source)
|
|
73
|
+
|
|
74
|
+
if nesting_depth > 4 or branch_count > 10:
|
|
75
|
+
return TIMEOUT_TIERS["complex_nested"].timeout
|
|
76
|
+
|
|
77
|
+
if _uses_only_stdlib(source):
|
|
78
|
+
return TIMEOUT_TIERS["stdlib_only"].timeout
|
|
79
|
+
|
|
80
|
+
return TIMEOUT_TIERS["pure_python"].timeout
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pre(lambda source: isinstance(source, str))
|
|
84
|
+
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
85
|
+
def _estimate_nesting_depth(source: str) -> int:
|
|
86
|
+
"""Estimate maximum nesting depth from indentation."""
|
|
87
|
+
max_indent = 0
|
|
88
|
+
for line in source.split("\n"):
|
|
89
|
+
stripped = line.lstrip()
|
|
90
|
+
if stripped and not stripped.startswith("#"):
|
|
91
|
+
indent = len(line) - len(stripped)
|
|
92
|
+
spaces = indent // 4 # Assuming 4-space indent
|
|
93
|
+
max_indent = max(max_indent, spaces)
|
|
94
|
+
return max_indent
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pre(lambda source: isinstance(source, str))
|
|
98
|
+
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
99
|
+
def _count_branches(source: str) -> int:
|
|
100
|
+
"""Count branching statements (if, for, while, try)."""
|
|
101
|
+
return len(re.findall(r"\b(if|for|while|try|elif|except)\b", source))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pre(lambda source: isinstance(source, str))
|
|
105
|
+
@post(lambda result: isinstance(result, bool))
|
|
106
|
+
def _uses_only_stdlib(source: str) -> bool:
|
|
107
|
+
"""Check if source only uses standard library."""
|
|
108
|
+
stdlib_patterns = ["collections", "itertools", "functools", "typing", "dataclasses"]
|
|
109
|
+
third_party_patterns = ["pandas", "numpy", "requests", "flask", "django"]
|
|
110
|
+
|
|
111
|
+
has_stdlib = any(pat in source for pat in stdlib_patterns)
|
|
112
|
+
has_third_party = any(pat in source for pat in third_party_patterns)
|
|
113
|
+
|
|
114
|
+
return has_stdlib and not has_third_party
|