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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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)