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/purity.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purity detection for Guard Enhancement (Phase 3).
|
|
3
|
+
|
|
4
|
+
This module provides functions to detect:
|
|
5
|
+
- Internal imports (imports inside function bodies)
|
|
6
|
+
- Impure function calls (datetime.now, random.*, open, print, etc.)
|
|
7
|
+
- Code line counting (excluding docstrings)
|
|
8
|
+
|
|
9
|
+
No I/O operations - receives AST nodes only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
|
|
16
|
+
from deal import pre
|
|
17
|
+
|
|
18
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
19
|
+
|
|
20
|
+
# Known impure functions and method patterns
|
|
21
|
+
IMPURE_FUNCTIONS: set[str] = {
|
|
22
|
+
"now",
|
|
23
|
+
"today",
|
|
24
|
+
"utcnow",
|
|
25
|
+
"time",
|
|
26
|
+
"random",
|
|
27
|
+
"randint",
|
|
28
|
+
"randrange",
|
|
29
|
+
"choice",
|
|
30
|
+
"shuffle",
|
|
31
|
+
"sample",
|
|
32
|
+
"open",
|
|
33
|
+
"print",
|
|
34
|
+
"input",
|
|
35
|
+
"getenv",
|
|
36
|
+
"environ",
|
|
37
|
+
}
|
|
38
|
+
IMPURE_PATTERNS: set[tuple[str, str]] = {
|
|
39
|
+
("datetime", "now"),
|
|
40
|
+
("datetime", "today"),
|
|
41
|
+
("datetime", "utcnow"),
|
|
42
|
+
("date", "today"),
|
|
43
|
+
("time", "time"),
|
|
44
|
+
("random", "random"),
|
|
45
|
+
("random", "randint"),
|
|
46
|
+
("random", "randrange"),
|
|
47
|
+
("random", "choice"),
|
|
48
|
+
("random", "shuffle"),
|
|
49
|
+
("random", "sample"),
|
|
50
|
+
("os", "getenv"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and hasattr(node, "body"))
|
|
55
|
+
def extract_internal_imports(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
56
|
+
"""
|
|
57
|
+
Extract imports inside a function body.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
>>> import ast
|
|
61
|
+
>>> code = '''
|
|
62
|
+
... def foo():
|
|
63
|
+
... import os
|
|
64
|
+
... from pathlib import Path
|
|
65
|
+
... return Path(".")
|
|
66
|
+
... '''
|
|
67
|
+
>>> tree = ast.parse(code)
|
|
68
|
+
>>> func = tree.body[0]
|
|
69
|
+
>>> sorted(extract_internal_imports(func))
|
|
70
|
+
['os', 'pathlib']
|
|
71
|
+
"""
|
|
72
|
+
imports: list[str] = []
|
|
73
|
+
|
|
74
|
+
for child in ast.walk(node):
|
|
75
|
+
if isinstance(child, ast.Import):
|
|
76
|
+
for alias in child.names:
|
|
77
|
+
imports.append(alias.name.split(".")[0])
|
|
78
|
+
elif isinstance(child, ast.ImportFrom) and child.module:
|
|
79
|
+
imports.append(child.module.split(".")[0])
|
|
80
|
+
|
|
81
|
+
return list(set(imports))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and hasattr(node, "body"))
|
|
85
|
+
def extract_impure_calls(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
86
|
+
"""
|
|
87
|
+
Extract calls to known impure functions.
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
>>> import ast
|
|
91
|
+
>>> code = '''
|
|
92
|
+
... def foo():
|
|
93
|
+
... x = datetime.now()
|
|
94
|
+
... print("hello")
|
|
95
|
+
... return x
|
|
96
|
+
... '''
|
|
97
|
+
>>> tree = ast.parse(code)
|
|
98
|
+
>>> func = tree.body[0]
|
|
99
|
+
>>> sorted(extract_impure_calls(func))
|
|
100
|
+
['datetime.now', 'print']
|
|
101
|
+
"""
|
|
102
|
+
impure: list[str] = []
|
|
103
|
+
|
|
104
|
+
for child in ast.walk(node):
|
|
105
|
+
if isinstance(child, ast.Call):
|
|
106
|
+
call_name = _get_call_name(child)
|
|
107
|
+
if call_name and _is_impure_call(call_name):
|
|
108
|
+
impure.append(call_name)
|
|
109
|
+
|
|
110
|
+
return list(set(impure))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and hasattr(node, "body"))
|
|
114
|
+
def extract_function_calls(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
115
|
+
"""
|
|
116
|
+
Extract all function calls from a function body (P25: for extraction analysis).
|
|
117
|
+
|
|
118
|
+
Only extracts simple function calls (not method calls on objects).
|
|
119
|
+
Used to build call graph for grouping related functions.
|
|
120
|
+
|
|
121
|
+
Examples:
|
|
122
|
+
>>> import ast
|
|
123
|
+
>>> code = '''
|
|
124
|
+
... def foo():
|
|
125
|
+
... helper()
|
|
126
|
+
... result = calculate(x)
|
|
127
|
+
... return result
|
|
128
|
+
... '''
|
|
129
|
+
>>> tree = ast.parse(code)
|
|
130
|
+
>>> func = tree.body[0]
|
|
131
|
+
>>> sorted(extract_function_calls(func))
|
|
132
|
+
['calculate', 'helper']
|
|
133
|
+
>>> # Method calls are excluded
|
|
134
|
+
>>> code2 = '''
|
|
135
|
+
... def bar():
|
|
136
|
+
... self.method()
|
|
137
|
+
... obj.call()
|
|
138
|
+
... helper()
|
|
139
|
+
... '''
|
|
140
|
+
>>> tree2 = ast.parse(code2)
|
|
141
|
+
>>> func2 = tree2.body[0]
|
|
142
|
+
>>> extract_function_calls(func2)
|
|
143
|
+
['helper']
|
|
144
|
+
"""
|
|
145
|
+
calls: list[str] = []
|
|
146
|
+
|
|
147
|
+
for child in ast.walk(node):
|
|
148
|
+
if isinstance(child, ast.Call):
|
|
149
|
+
# Only simple function calls (not x.method())
|
|
150
|
+
if isinstance(child.func, ast.Name):
|
|
151
|
+
calls.append(child.func.id)
|
|
152
|
+
|
|
153
|
+
return list(set(calls))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pre(lambda call: isinstance(call, ast.Call) and hasattr(call, "func"))
|
|
157
|
+
def _get_call_name(call: ast.Call) -> str | None:
|
|
158
|
+
"""Get the name of a function call as a string."""
|
|
159
|
+
func = call.func
|
|
160
|
+
|
|
161
|
+
# Simple name: print(), open()
|
|
162
|
+
if isinstance(func, ast.Name):
|
|
163
|
+
return func.id
|
|
164
|
+
|
|
165
|
+
# Attribute: datetime.now(), random.randint()
|
|
166
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
167
|
+
return f"{func.value.id}.{func.attr}"
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pre(lambda call_name: isinstance(call_name, str) and len(call_name) > 0)
|
|
173
|
+
def _is_impure_call(call_name: str) -> bool:
|
|
174
|
+
"""Check if a call name represents an impure function."""
|
|
175
|
+
# Check simple names
|
|
176
|
+
if call_name in IMPURE_FUNCTIONS:
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
# Check patterns like datetime.now
|
|
180
|
+
if "." in call_name:
|
|
181
|
+
parts = call_name.split(".")
|
|
182
|
+
if len(parts) == 2 and (parts[0], parts[1]) in IMPURE_PATTERNS:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pre(
|
|
189
|
+
lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
|
|
190
|
+
and hasattr(node, "lineno")
|
|
191
|
+
and hasattr(node, "body")
|
|
192
|
+
)
|
|
193
|
+
def count_code_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
194
|
+
"""
|
|
195
|
+
Count lines of code excluding docstring.
|
|
196
|
+
|
|
197
|
+
The total function lines minus docstring lines gives the actual code lines.
|
|
198
|
+
|
|
199
|
+
Examples:
|
|
200
|
+
>>> import ast
|
|
201
|
+
>>> code = '''
|
|
202
|
+
... def foo():
|
|
203
|
+
... \"\"\"This is a docstring.\"\"\"
|
|
204
|
+
... x = 1
|
|
205
|
+
... return x
|
|
206
|
+
... '''
|
|
207
|
+
>>> tree = ast.parse(code)
|
|
208
|
+
>>> func = tree.body[0]
|
|
209
|
+
>>> count_code_lines(func)
|
|
210
|
+
3
|
|
211
|
+
"""
|
|
212
|
+
total_lines = (node.end_lineno or node.lineno) - node.lineno + 1
|
|
213
|
+
|
|
214
|
+
# Check for docstring
|
|
215
|
+
docstring_lines = 0
|
|
216
|
+
if (
|
|
217
|
+
node.body
|
|
218
|
+
and isinstance(node.body[0], ast.Expr)
|
|
219
|
+
and isinstance(node.body[0].value, ast.Constant)
|
|
220
|
+
and isinstance(node.body[0].value.value, str)
|
|
221
|
+
):
|
|
222
|
+
docstring_node = node.body[0]
|
|
223
|
+
docstring_lines = (
|
|
224
|
+
(docstring_node.end_lineno or docstring_node.lineno) - docstring_node.lineno + 1
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return total_lines - docstring_lines
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and hasattr(node, "body"))
|
|
231
|
+
def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
232
|
+
"""
|
|
233
|
+
Count lines that are doctest examples in the docstring.
|
|
234
|
+
|
|
235
|
+
Counts both `>>> ` input lines and their expected output lines.
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
>>> import ast
|
|
239
|
+
>>> code = '''
|
|
240
|
+
... def foo():
|
|
241
|
+
... \"\"\"Example.
|
|
242
|
+
...
|
|
243
|
+
... Examples:
|
|
244
|
+
... >>> foo()
|
|
245
|
+
... 42
|
|
246
|
+
... >>> foo() + 1
|
|
247
|
+
... 43
|
|
248
|
+
... \"\"\"
|
|
249
|
+
... return 42
|
|
250
|
+
... '''
|
|
251
|
+
>>> tree = ast.parse(code)
|
|
252
|
+
>>> func = tree.body[0]
|
|
253
|
+
>>> count_doctest_lines(func)
|
|
254
|
+
4
|
|
255
|
+
"""
|
|
256
|
+
docstring = ast.get_docstring(node)
|
|
257
|
+
if not docstring:
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
count = 0
|
|
261
|
+
in_doctest = False
|
|
262
|
+
for line in docstring.split("\n"):
|
|
263
|
+
stripped = line.strip()
|
|
264
|
+
if stripped.startswith(">>> "):
|
|
265
|
+
count += 1
|
|
266
|
+
in_doctest = True
|
|
267
|
+
elif stripped.startswith("... "):
|
|
268
|
+
count += 1 # Continuation line
|
|
269
|
+
elif in_doctest and stripped and not stripped.startswith(">>>"):
|
|
270
|
+
count += 1 # Expected output line
|
|
271
|
+
if not stripped: # Empty line ends output
|
|
272
|
+
in_doctest = False
|
|
273
|
+
else:
|
|
274
|
+
in_doctest = False
|
|
275
|
+
return count
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Rule checking functions
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
282
|
+
def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
283
|
+
"""
|
|
284
|
+
Check for imports inside function bodies.
|
|
285
|
+
|
|
286
|
+
Only applies to Core files when strict_pure is enabled.
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
290
|
+
>>> sym = Symbol(
|
|
291
|
+
... name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
292
|
+
... internal_imports=["os"]
|
|
293
|
+
... )
|
|
294
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
|
|
295
|
+
>>> violations = check_internal_imports(info, RuleConfig(strict_pure=True))
|
|
296
|
+
>>> len(violations)
|
|
297
|
+
1
|
|
298
|
+
"""
|
|
299
|
+
violations: list[Violation] = []
|
|
300
|
+
|
|
301
|
+
if not file_info.is_core or not config.strict_pure:
|
|
302
|
+
return violations
|
|
303
|
+
|
|
304
|
+
for symbol in file_info.symbols:
|
|
305
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and symbol.internal_imports:
|
|
306
|
+
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
307
|
+
violations.append(
|
|
308
|
+
Violation(
|
|
309
|
+
rule="internal_import",
|
|
310
|
+
severity=Severity.WARNING,
|
|
311
|
+
file=file_info.path,
|
|
312
|
+
line=symbol.line,
|
|
313
|
+
message=f"{kind_name} '{symbol.name}' has internal imports: {', '.join(symbol.internal_imports)}",
|
|
314
|
+
suggestion="Move imports to top of file or move function to Shell",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return violations
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
322
|
+
def check_impure_calls(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
323
|
+
"""
|
|
324
|
+
Check for calls to known impure functions.
|
|
325
|
+
|
|
326
|
+
Only applies to Core files when strict_pure is enabled.
|
|
327
|
+
Respects config.purity_pure for user-declared pure functions.
|
|
328
|
+
|
|
329
|
+
Examples:
|
|
330
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
331
|
+
>>> sym = Symbol(
|
|
332
|
+
... name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
333
|
+
... impure_calls=["datetime.now", "print"]
|
|
334
|
+
... )
|
|
335
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
|
|
336
|
+
>>> violations = check_impure_calls(info, RuleConfig(strict_pure=True))
|
|
337
|
+
>>> len(violations)
|
|
338
|
+
1
|
|
339
|
+
>>> # User declares print as pure → no violation
|
|
340
|
+
>>> violations = check_impure_calls(info, RuleConfig(purity_pure=["print"]))
|
|
341
|
+
>>> any("print" in v.message for v in violations)
|
|
342
|
+
False
|
|
343
|
+
"""
|
|
344
|
+
violations: list[Violation] = []
|
|
345
|
+
|
|
346
|
+
if not file_info.is_core or not config.strict_pure:
|
|
347
|
+
return violations
|
|
348
|
+
|
|
349
|
+
# B4: User-declared pure functions override blacklist
|
|
350
|
+
pure_set = set(config.purity_pure)
|
|
351
|
+
|
|
352
|
+
for symbol in file_info.symbols:
|
|
353
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and symbol.impure_calls:
|
|
354
|
+
# Filter out user-declared pure functions
|
|
355
|
+
actual_impure = [c for c in symbol.impure_calls if c not in pure_set]
|
|
356
|
+
if actual_impure:
|
|
357
|
+
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
358
|
+
violations.append(
|
|
359
|
+
Violation(
|
|
360
|
+
rule="impure_call",
|
|
361
|
+
severity=Severity.ERROR,
|
|
362
|
+
file=file_info.path,
|
|
363
|
+
line=symbol.line,
|
|
364
|
+
message=f"{kind_name} '{symbol.name}' calls impure functions: {', '.join(actual_impure)}",
|
|
365
|
+
suggestion="Inject dependencies or move function to Shell",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return violations
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purity heuristics for unknown functions.
|
|
3
|
+
|
|
4
|
+
Core module: analyzes function metadata to guess purity.
|
|
5
|
+
Layer 2 of Multi-Layer Purity Detection (B4).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from deal import post, pre
|
|
14
|
+
|
|
15
|
+
# Name patterns suggesting impurity
|
|
16
|
+
IMPURE_NAME_PATTERNS = [
|
|
17
|
+
r"^read_",
|
|
18
|
+
r"^write_",
|
|
19
|
+
r"^save_",
|
|
20
|
+
r"^load_",
|
|
21
|
+
r"^fetch_",
|
|
22
|
+
r"^send_",
|
|
23
|
+
r"^delete_",
|
|
24
|
+
r"^update_",
|
|
25
|
+
r"^connect_",
|
|
26
|
+
r"^open_",
|
|
27
|
+
r"^close_",
|
|
28
|
+
r"^print_",
|
|
29
|
+
r"^log_",
|
|
30
|
+
r"_to_file$",
|
|
31
|
+
r"_to_disk$",
|
|
32
|
+
r"_to_db$",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Name patterns suggesting purity
|
|
36
|
+
PURE_NAME_PATTERNS = [
|
|
37
|
+
r"^calculate_",
|
|
38
|
+
r"^compute_",
|
|
39
|
+
r"^parse_",
|
|
40
|
+
r"^validate_",
|
|
41
|
+
r"^transform_",
|
|
42
|
+
r"^convert_",
|
|
43
|
+
r"^is_",
|
|
44
|
+
r"^has_",
|
|
45
|
+
r"^get_",
|
|
46
|
+
r"^from_",
|
|
47
|
+
r"^to_",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Docstring keywords suggesting impurity
|
|
51
|
+
IMPURE_DOC_KEYWORDS = [
|
|
52
|
+
"writes to",
|
|
53
|
+
"reads from",
|
|
54
|
+
"saves",
|
|
55
|
+
"loads",
|
|
56
|
+
"modifies",
|
|
57
|
+
"mutates",
|
|
58
|
+
"side effect",
|
|
59
|
+
"file",
|
|
60
|
+
"disk",
|
|
61
|
+
"database",
|
|
62
|
+
"network",
|
|
63
|
+
"sends",
|
|
64
|
+
"receives",
|
|
65
|
+
"connects",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class HeuristicResult:
|
|
71
|
+
"""Result of heuristic purity analysis."""
|
|
72
|
+
|
|
73
|
+
likely_pure: bool
|
|
74
|
+
confidence: float # 0.0 - 1.0
|
|
75
|
+
hints: list[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pre(lambda func_name, hints: isinstance(func_name, str) and isinstance(hints, list))
|
|
79
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
80
|
+
def _analyze_name_patterns(func_name: str, hints: list[str]) -> tuple[int, int]:
|
|
81
|
+
"""Analyze function name for purity hints. Returns (impure_score, pure_score).
|
|
82
|
+
|
|
83
|
+
>>> h = []
|
|
84
|
+
>>> _analyze_name_patterns("read_file", h)
|
|
85
|
+
(2, 0)
|
|
86
|
+
>>> len(h) > 0
|
|
87
|
+
True
|
|
88
|
+
"""
|
|
89
|
+
impure, pure = 0, 0
|
|
90
|
+
for pattern in IMPURE_NAME_PATTERNS:
|
|
91
|
+
if re.search(pattern, func_name, re.IGNORECASE):
|
|
92
|
+
hints.append(f"Name: {pattern}")
|
|
93
|
+
impure += 2
|
|
94
|
+
for pattern in PURE_NAME_PATTERNS:
|
|
95
|
+
if re.search(pattern, func_name, re.IGNORECASE):
|
|
96
|
+
hints.append(f"Name suggests pure: {pattern}")
|
|
97
|
+
pure += 1
|
|
98
|
+
return impure, pure
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pre(lambda signature, hints: isinstance(hints, list))
|
|
102
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
103
|
+
def _analyze_signature(signature: str | None, hints: list[str]) -> tuple[int, int]:
|
|
104
|
+
"""Analyze signature for purity hints. Returns (impure_score, pure_score).
|
|
105
|
+
|
|
106
|
+
>>> h = []
|
|
107
|
+
>>> _analyze_signature("(path: str) -> None", h)
|
|
108
|
+
(3, 0)
|
|
109
|
+
"""
|
|
110
|
+
if not signature:
|
|
111
|
+
return 0, 0
|
|
112
|
+
impure, pure = 0, 0
|
|
113
|
+
if "-> None" in signature:
|
|
114
|
+
hints.append("Returns None (side effect?)")
|
|
115
|
+
impure += 1
|
|
116
|
+
if re.search(r"path|file", signature, re.IGNORECASE):
|
|
117
|
+
hints.append("Has path/file parameter")
|
|
118
|
+
impure += 2
|
|
119
|
+
if "->" in signature and "None" not in signature:
|
|
120
|
+
hints.append("Returns value")
|
|
121
|
+
pure += 1
|
|
122
|
+
return impure, pure
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pre(lambda docstring, hints: isinstance(hints, list))
|
|
126
|
+
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
127
|
+
def _analyze_docstring(docstring: str | None, hints: list[str]) -> int:
|
|
128
|
+
"""Analyze docstring for purity hints. Returns impure_score.
|
|
129
|
+
|
|
130
|
+
>>> h = []
|
|
131
|
+
>>> _analyze_docstring("Reads from file system.", h)
|
|
132
|
+
1
|
|
133
|
+
"""
|
|
134
|
+
if not docstring:
|
|
135
|
+
return 0
|
|
136
|
+
doc_lower = docstring.lower()
|
|
137
|
+
for keyword in IMPURE_DOC_KEYWORDS:
|
|
138
|
+
if keyword in doc_lower:
|
|
139
|
+
hints.append(f"Docstring: '{keyword}'")
|
|
140
|
+
return 1
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pre(lambda func_name, signature=None, docstring=None: isinstance(func_name, str))
|
|
145
|
+
@post(lambda result: isinstance(result, HeuristicResult))
|
|
146
|
+
def analyze_purity_heuristic(
|
|
147
|
+
func_name: str,
|
|
148
|
+
signature: str | None = None,
|
|
149
|
+
docstring: str | None = None,
|
|
150
|
+
) -> HeuristicResult:
|
|
151
|
+
"""
|
|
152
|
+
Guess purity based on heuristics.
|
|
153
|
+
|
|
154
|
+
>>> r = analyze_purity_heuristic("read_csv")
|
|
155
|
+
>>> r.likely_pure
|
|
156
|
+
False
|
|
157
|
+
>>> r.confidence > 0.5
|
|
158
|
+
True
|
|
159
|
+
|
|
160
|
+
>>> r = analyze_purity_heuristic("calculate_sum")
|
|
161
|
+
>>> r.likely_pure
|
|
162
|
+
True
|
|
163
|
+
|
|
164
|
+
>>> r = analyze_purity_heuristic("process_data")
|
|
165
|
+
>>> r.confidence < 0.6
|
|
166
|
+
True
|
|
167
|
+
"""
|
|
168
|
+
hints: list[str] = []
|
|
169
|
+
|
|
170
|
+
name_impure, name_pure = _analyze_name_patterns(func_name, hints)
|
|
171
|
+
sig_impure, sig_pure = _analyze_signature(signature, hints)
|
|
172
|
+
doc_impure = _analyze_docstring(docstring, hints)
|
|
173
|
+
|
|
174
|
+
impure_score = name_impure + sig_impure + doc_impure
|
|
175
|
+
pure_score = name_pure + sig_pure
|
|
176
|
+
|
|
177
|
+
total = impure_score + pure_score
|
|
178
|
+
if total == 0:
|
|
179
|
+
return HeuristicResult(likely_pure=True, confidence=0.5, hints=["No indicators"])
|
|
180
|
+
|
|
181
|
+
likely_pure = pure_score >= impure_score
|
|
182
|
+
confidence = min(0.9, 0.5 + abs(pure_score - impure_score) / (total + 1) * 0.4)
|
|
183
|
+
|
|
184
|
+
return HeuristicResult(likely_pure, confidence, hints)
|