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
@@ -0,0 +1,190 @@
1
+ """Lambda expression parsing helpers for contract analysis. No I/O operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+
8
+ from deal import post, pre
9
+
10
+
11
+ @post(lambda result: result is None or isinstance(result, ast.Lambda))
12
+ def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
13
+ """Find the lambda node in an expression tree.
14
+
15
+ Examples:
16
+ >>> tree = ast.parse("lambda x: x > 0", mode="eval")
17
+ >>> find_lambda(tree) is not None
18
+ True
19
+ >>> tree = ast.parse("x + y", mode="eval")
20
+ >>> find_lambda(tree) is None
21
+ True
22
+ """
23
+ return next((n for n in ast.walk(tree) if isinstance(n, ast.Lambda)), None)
24
+
25
+
26
+ @post(lambda result: isinstance(result, dict))
27
+ def extract_annotations(signature: str) -> dict[str, str]:
28
+ """Extract parameter type annotations from signature.
29
+
30
+ Examples:
31
+ >>> extract_annotations("(x: int, y: str) -> bool")
32
+ {'x': 'int', 'y': 'str'}
33
+ >>> extract_annotations("(items: list[int]) -> None")
34
+ {'items': 'list[int]'}
35
+ >>> extract_annotations("(x, y)")
36
+ {}
37
+ """
38
+ if not signature or not signature.startswith("("):
39
+ return {}
40
+ annotations = {}
41
+ match = re.match(r"\(([^)]*)\)", signature)
42
+ if not match:
43
+ return annotations
44
+ for param in match.group(1).split(","):
45
+ param = param.strip()
46
+ if ": " in param:
47
+ name, type_hint = param.split(": ", 1)
48
+ if "=" in type_hint:
49
+ type_hint = type_hint.split("=")[0].strip()
50
+ annotations[name.strip()] = type_hint.strip()
51
+ return annotations
52
+
53
+
54
+ @pre(lambda expression: isinstance(expression, str))
55
+ @post(lambda result: result is None or isinstance(result, list))
56
+ def extract_lambda_params(expression: str) -> list[str] | None:
57
+ """Extract parameter names from a lambda expression.
58
+
59
+ Examples:
60
+ >>> extract_lambda_params("lambda x, y: x > 0")
61
+ ['x', 'y']
62
+ >>> extract_lambda_params("lambda: True")
63
+ []
64
+ >>> extract_lambda_params("not a lambda") is None
65
+ True
66
+ """
67
+ if not expression.strip() or "lambda" not in expression:
68
+ return None
69
+ try:
70
+ tree = ast.parse(expression, mode="eval")
71
+ lambda_node = find_lambda(tree)
72
+ return [arg.arg for arg in lambda_node.args.args] if lambda_node else None
73
+ except (SyntaxError, TypeError, ValueError):
74
+ return None
75
+
76
+
77
+ @post(lambda result: result is None or isinstance(result, list))
78
+ def extract_func_param_names(signature: str) -> list[str] | None:
79
+ """Extract parameter names from a function signature (handles nested brackets).
80
+
81
+ Examples:
82
+ >>> extract_func_param_names("(x: int, y: int) -> int")
83
+ ['x', 'y']
84
+ >>> extract_func_param_names("(items: dict[str, int]) -> None")
85
+ ['items']
86
+ >>> extract_func_param_names("() -> bool")
87
+ []
88
+ """
89
+ if not signature or not signature.startswith("("):
90
+ return None
91
+ match = re.match(r"\(([^)]*)\)", signature)
92
+ if not match:
93
+ return None
94
+ content = match.group(1).strip()
95
+ if not content:
96
+ return []
97
+ # Split by comma, but respect brackets (for dict[K, V], tuple[A, B], etc.)
98
+ params = []
99
+ current = ""
100
+ depth = 0
101
+ for char in content:
102
+ if char in "([{":
103
+ depth += 1
104
+ elif char in ")]}":
105
+ depth -= 1
106
+ elif char == "," and depth == 0:
107
+ if current.strip():
108
+ params.append(current.strip().split(":")[0].split("=")[0].strip())
109
+ current = ""
110
+ continue
111
+ current += char
112
+ if current.strip():
113
+ params.append(current.strip().split(":")[0].split("=")[0].strip())
114
+ return params
115
+
116
+
117
+ @post(lambda result: isinstance(result, set))
118
+ def extract_used_names(node: ast.expr) -> set[str]:
119
+ """Extract all variable names used in an expression (Load context).
120
+
121
+ Examples:
122
+ >>> tree = ast.parse("x + y", mode="eval")
123
+ >>> sorted(extract_used_names(tree.body))
124
+ ['x', 'y']
125
+ >>> tree = ast.parse("len(items) > 0", mode="eval")
126
+ >>> sorted(extract_used_names(tree.body))
127
+ ['items', 'len']
128
+ """
129
+ names: set[str] = set()
130
+ for child in ast.walk(node):
131
+ if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
132
+ names.add(child.id)
133
+ return names
134
+
135
+
136
+ # DX-01: Helper to generate lambda fix templates
137
+
138
+
139
+ @post(lambda result: isinstance(result, str))
140
+ def generate_lambda_fix(signature: str) -> str:
141
+ """
142
+ Generate a lambda fix template from function signature.
143
+
144
+ DX-01: Provides copy-pastable fix for param_mismatch errors.
145
+
146
+ Examples:
147
+ >>> generate_lambda_fix("(x: int, y: str) -> bool")
148
+ '@pre(lambda x, y: <condition>)'
149
+ >>> generate_lambda_fix("(x: int, y: int = 10) -> int")
150
+ '@pre(lambda x, y=10: <condition>)'
151
+ >>> generate_lambda_fix("(items: list[int], n: int = 5, reverse: bool = False) -> list")
152
+ '@pre(lambda items, n=5, reverse=False: <condition>)'
153
+ >>> generate_lambda_fix("() -> bool")
154
+ '@pre(lambda: <condition>)'
155
+ >>> generate_lambda_fix("")
156
+ '@pre(lambda: <condition>)'
157
+ """
158
+ if not signature or signature == "()" or not signature.startswith("("):
159
+ return "@pre(lambda: <condition>)"
160
+
161
+ match = re.match(r"\(([^)]*)\)", signature)
162
+ if not match:
163
+ return "@pre(lambda: <condition>)"
164
+
165
+ param_parts: list[str] = []
166
+ for param in match.group(1).split(","):
167
+ param = param.strip()
168
+ if not param:
169
+ continue
170
+
171
+ # Extract name and default value
172
+ if ": " in param:
173
+ name_part, type_part = param.split(": ", 1)
174
+ name = name_part.strip()
175
+ # Check for default value
176
+ if "=" in type_part:
177
+ default = type_part.split("=", 1)[1].strip()
178
+ param_parts.append(f"{name}={default}")
179
+ else:
180
+ param_parts.append(name)
181
+ elif "=" in param:
182
+ name, default = param.split("=", 1)
183
+ param_parts.append(f"{name.strip()}={default.strip()}")
184
+ else:
185
+ param_parts.append(param)
186
+
187
+ if not param_parts:
188
+ return "@pre(lambda: <condition>)"
189
+ params_str = ", ".join(param_parts)
190
+ return f"@pre(lambda {params_str}: <condition>)"
invar/core/models.py ADDED
@@ -0,0 +1,289 @@
1
+ """
2
+ Pydantic models for Invar.
3
+
4
+ Core models are pure data structures with validation.
5
+ No I/O operations allowed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import Literal
12
+
13
+ from deal import pre
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class SymbolKind(str, Enum):
18
+ """Kind of symbol extracted from Python code."""
19
+
20
+ FUNCTION = "function"
21
+ CLASS = "class"
22
+ METHOD = "method"
23
+
24
+
25
+ class Severity(str, Enum):
26
+ """Severity level for violations."""
27
+
28
+ ERROR = "error"
29
+ WARNING = "warning"
30
+ INFO = "info" # Phase 7: For informational issues like redundant type contracts
31
+
32
+
33
+ class Contract(BaseModel):
34
+ """A contract (precondition or postcondition) on a function."""
35
+
36
+ kind: Literal["pre", "post"]
37
+ expression: str
38
+ line: int
39
+
40
+
41
+ class Symbol(BaseModel):
42
+ """A symbol extracted from Python source code."""
43
+
44
+ name: str
45
+ kind: SymbolKind
46
+ line: int
47
+ end_line: int
48
+ signature: str = ""
49
+ docstring: str | None = None
50
+ contracts: list[Contract] = Field(default_factory=list)
51
+ has_doctest: bool = False
52
+ # Phase 3: Guard Enhancement
53
+ internal_imports: list[str] = Field(default_factory=list)
54
+ impure_calls: list[str] = Field(default_factory=list)
55
+ code_lines: int | None = None # Lines excluding docstring/comments
56
+ # Phase 6: Verification Completeness
57
+ doctest_lines: int = 0 # Number of lines that are doctest examples
58
+ # Phase 11 P25: For extraction analysis
59
+ function_calls: list[str] = Field(default_factory=list) # Functions called within this function
60
+
61
+
62
+ class FileInfo(BaseModel):
63
+ """Information about a Python file."""
64
+
65
+ path: str
66
+ lines: int
67
+ symbols: list[Symbol] = Field(default_factory=list)
68
+ imports: list[str] = Field(default_factory=list)
69
+ is_core: bool = False
70
+ is_shell: bool = False
71
+ source: str = "" # Original source code for advanced analysis
72
+
73
+
74
+ class Violation(BaseModel):
75
+ """A rule violation found by Guard."""
76
+
77
+ rule: str
78
+ severity: Severity
79
+ file: str
80
+ line: int | None = None
81
+ message: str
82
+ suggestion: str | None = None
83
+
84
+
85
+ class GuardReport(BaseModel):
86
+ """Complete Guard report for a project."""
87
+
88
+ files_checked: int
89
+ violations: list[Violation] = Field(default_factory=list)
90
+ errors: int = 0
91
+ warnings: int = 0
92
+ infos: int = 0 # Phase 7: Track INFO-level issues
93
+ # P24: Contract coverage statistics (Core files only)
94
+ core_functions_total: int = 0
95
+ core_functions_with_contracts: int = 0
96
+
97
+ @pre(lambda self, violation: isinstance(violation, Violation))
98
+ def add_violation(self, violation: Violation) -> None:
99
+ """
100
+ Add a violation and update counts.
101
+
102
+ Examples:
103
+ >>> from invar.core.models import Violation, Severity, GuardReport
104
+ >>> report = GuardReport(files_checked=1)
105
+ >>> v = Violation(rule="test", severity=Severity.ERROR, file="x.py", message="err")
106
+ >>> report.add_violation(v)
107
+ >>> report.errors
108
+ 1
109
+ """
110
+ self.violations.append(violation)
111
+ if violation.severity == Severity.ERROR:
112
+ self.errors += 1
113
+ elif violation.severity == Severity.WARNING:
114
+ self.warnings += 1
115
+ else:
116
+ self.infos += 1
117
+
118
+ @pre(lambda self, total, with_contracts: total >= 0 and with_contracts >= 0)
119
+ def update_coverage(self, total: int, with_contracts: int) -> None:
120
+ """
121
+ Update contract coverage statistics (P24).
122
+
123
+ Examples:
124
+ >>> from invar.core.models import GuardReport
125
+ >>> report = GuardReport(files_checked=1)
126
+ >>> report.update_coverage(10, 8)
127
+ >>> report.core_functions_total
128
+ 10
129
+ >>> report.core_functions_with_contracts
130
+ 8
131
+ """
132
+ self.core_functions_total += total
133
+ self.core_functions_with_contracts += with_contracts
134
+
135
+ @property
136
+ @pre(lambda self: isinstance(self, GuardReport))
137
+ def contract_coverage_pct(self) -> int:
138
+ """
139
+ Get contract coverage percentage (P24).
140
+
141
+ Examples:
142
+ >>> from invar.core.models import GuardReport
143
+ >>> report = GuardReport(files_checked=1)
144
+ >>> report.update_coverage(10, 8)
145
+ >>> report.contract_coverage_pct
146
+ 80
147
+ """
148
+ if self.core_functions_total == 0:
149
+ return 100
150
+ return int(self.core_functions_with_contracts / self.core_functions_total * 100)
151
+
152
+ @property
153
+ @pre(lambda self: isinstance(self, GuardReport))
154
+ def contract_issue_counts(self) -> dict[str, int]:
155
+ """
156
+ Count contract quality issues by type (P24).
157
+
158
+ Examples:
159
+ >>> from invar.core.models import GuardReport, Violation, Severity
160
+ >>> report = GuardReport(files_checked=1)
161
+ >>> v1 = Violation(rule="empty_contract", severity=Severity.WARNING, file="x.py", message="m")
162
+ >>> v2 = Violation(rule="semantic_tautology", severity=Severity.WARNING, file="x.py", message="m")
163
+ >>> report.add_violation(v1)
164
+ >>> report.add_violation(v2)
165
+ >>> report.contract_issue_counts
166
+ {'tautology': 1, 'empty': 1, 'partial': 0, 'type_only': 0}
167
+ """
168
+ counts = {"tautology": 0, "empty": 0, "partial": 0, "type_only": 0}
169
+ for v in self.violations:
170
+ if v.rule == "semantic_tautology":
171
+ counts["tautology"] += 1
172
+ elif v.rule == "empty_contract":
173
+ counts["empty"] += 1
174
+ elif v.rule == "partial_contract":
175
+ counts["partial"] += 1
176
+ elif v.rule == "redundant_type_contract":
177
+ counts["type_only"] += 1
178
+ return counts
179
+
180
+ @property
181
+ @pre(lambda self: isinstance(self, GuardReport))
182
+ def passed(self) -> bool:
183
+ """
184
+ Check if guard passed (no errors).
185
+
186
+ Examples:
187
+ >>> from invar.core.models import GuardReport
188
+ >>> report = GuardReport(files_checked=1)
189
+ >>> report.passed
190
+ True
191
+ """
192
+ return self.errors == 0
193
+
194
+
195
+ class RuleExclusion(BaseModel):
196
+ """
197
+ A rule exclusion pattern for specific files.
198
+
199
+ Examples:
200
+ >>> excl = RuleExclusion(pattern="**/generated/**", rules=["*"])
201
+ >>> excl.pattern
202
+ '**/generated/**'
203
+ >>> excl.rules
204
+ ['*']
205
+ """
206
+
207
+ pattern: str # Glob pattern (fnmatch style with ** support)
208
+ rules: list[str] # Rule names to exclude, or ["*"] for all
209
+
210
+
211
+ class RuleConfig(BaseModel):
212
+ """
213
+ Configuration for rule checking.
214
+
215
+ Examples:
216
+ >>> config = RuleConfig()
217
+ >>> config.max_file_lines # Phase 9 P1: Raised from 300
218
+ 500
219
+ >>> config.strict_pure # Phase 9 P12: Default ON for agents
220
+ True
221
+ """
222
+
223
+ max_file_lines: int = 500 # Phase 9 P1: Raised from 300 for less friction
224
+ max_function_lines: int = 50
225
+ forbidden_imports: tuple[str, ...] = (
226
+ "os",
227
+ "sys",
228
+ "socket",
229
+ "requests",
230
+ "urllib",
231
+ "subprocess",
232
+ "shutil",
233
+ "io",
234
+ "pathlib",
235
+ )
236
+ require_contracts: bool = True
237
+ require_doctests: bool = True
238
+ strict_pure: bool = True # Phase 9 P12: Default ON for agent-native
239
+ use_code_lines: bool = False
240
+ exclude_doctest_lines: bool = False
241
+ # Phase 9 P1: Rule exclusions for specific file patterns
242
+ rule_exclusions: list[RuleExclusion] = Field(default_factory=list)
243
+ # Phase 9 P2: Per-rule severity overrides (off, info, warning, error)
244
+ severity_overrides: dict[str, str] = Field(
245
+ default_factory=lambda: {
246
+ "redundant_type_contract": "off", # Expected behavior when forcing contracts
247
+ }
248
+ )
249
+ # Phase 9 P8: File size warning threshold (0 to disable, 0.8 = warn at 80%)
250
+ size_warning_threshold: float = 0.8
251
+ # B4: User-declared purity (override heuristics)
252
+ purity_pure: list[str] = Field(default_factory=list) # Known pure functions
253
+ purity_impure: list[str] = Field(default_factory=list) # Known impure functions
254
+
255
+
256
+ # Phase 4: Perception models
257
+
258
+
259
+ class SymbolRefs(BaseModel):
260
+ """
261
+ A symbol with its cross-file reference count.
262
+
263
+ Examples:
264
+ >>> from invar.core.models import Symbol, SymbolKind, SymbolRefs
265
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
266
+ >>> sr = SymbolRefs(symbol=sym, file_path="core/calc.py", ref_count=10)
267
+ >>> sr.ref_count
268
+ 10
269
+ """
270
+
271
+ symbol: Symbol
272
+ file_path: str
273
+ ref_count: int = 0
274
+
275
+
276
+ class PerceptionMap(BaseModel):
277
+ """
278
+ Complete perception map for a project.
279
+
280
+ Examples:
281
+ >>> pm = PerceptionMap(project_root="/test", total_files=5, total_symbols=20)
282
+ >>> pm.total_files
283
+ 5
284
+ """
285
+
286
+ project_root: str
287
+ total_files: int
288
+ total_symbols: int
289
+ symbols: list[SymbolRefs] = Field(default_factory=list)
invar/core/must_use.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ Must-use return value detection.
3
+
4
+ Core module: detects ignored return values of @must_use functions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+
11
+ from deal import post, pre
12
+
13
+ from invar.core.models import FileInfo, RuleConfig, Severity, Violation
14
+
15
+
16
+ @pre(lambda source: isinstance(source, str) and len(source) > 0)
17
+ def find_must_use_functions(source: str) -> dict[str, str]:
18
+ """
19
+ Find all functions decorated with @must_use in source code.
20
+
21
+ Returns a dict mapping function name to the must_use reason.
22
+
23
+ >>> code = '''
24
+ ... from invar import must_use
25
+ ... @must_use("Handle the error")
26
+ ... def validate(): pass
27
+ ... '''
28
+ >>> find_must_use_functions(code)
29
+ {'validate': 'Handle the error'}
30
+
31
+ >>> code = '''
32
+ ... @must_use()
33
+ ... def check(): pass
34
+ ... '''
35
+ >>> find_must_use_functions(code)
36
+ {'check': 'Return value must be used'}
37
+
38
+ >>> find_must_use_functions("def foo(): pass")
39
+ {}
40
+ """
41
+ try:
42
+ tree = ast.parse(source)
43
+ except (SyntaxError, TypeError, ValueError):
44
+ return {}
45
+
46
+ must_use_funcs: dict[str, str] = {}
47
+
48
+ for node in ast.walk(tree):
49
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
50
+ continue
51
+
52
+ for decorator in node.decorator_list:
53
+ reason = _extract_must_use_reason(decorator)
54
+ if reason is not None:
55
+ must_use_funcs[node.name] = reason
56
+ break
57
+
58
+ return must_use_funcs
59
+
60
+
61
+ @post(lambda result: result is None or isinstance(result, str))
62
+ def _extract_must_use_reason(decorator: ast.expr) -> str | None:
63
+ """Extract reason from @must_use decorator, or None if not a must_use."""
64
+ # Case 1: @must_use("reason")
65
+ if isinstance(decorator, ast.Call):
66
+ func = decorator.func
67
+ if isinstance(func, ast.Name) and func.id == "must_use":
68
+ if decorator.args and isinstance(decorator.args[0], ast.Constant):
69
+ return str(decorator.args[0].value)
70
+ return "Return value must be used"
71
+ if isinstance(func, ast.Attribute) and func.attr == "must_use":
72
+ if decorator.args and isinstance(decorator.args[0], ast.Constant):
73
+ return str(decorator.args[0].value)
74
+ return "Return value must be used"
75
+
76
+ # Case 2: @must_use (no parens - though our API requires parens)
77
+ if isinstance(decorator, ast.Name) and decorator.id == "must_use":
78
+ return "Return value must be used"
79
+
80
+ return None
81
+
82
+
83
+ @pre(lambda source, must_use_funcs: isinstance(source, str) and len(source) > 0 and isinstance(must_use_funcs, set))
84
+ def find_ignored_calls(source: str, must_use_funcs: set[str]) -> list[tuple[str, int]]:
85
+ """
86
+ Find calls to must_use functions whose return values are ignored.
87
+
88
+ Returns list of (function_name, line_number) tuples.
89
+
90
+ >>> code = '''
91
+ ... validate(data)
92
+ ... result = check(x)
93
+ ... '''
94
+ >>> find_ignored_calls(code, {"validate", "check"})
95
+ [('validate', 2)]
96
+
97
+ >>> code = '''
98
+ ... if validate(x):
99
+ ... pass
100
+ ... '''
101
+ >>> find_ignored_calls(code, {"validate"})
102
+ []
103
+ """
104
+ try:
105
+ tree = ast.parse(source)
106
+ except (SyntaxError, TypeError, ValueError):
107
+ return []
108
+
109
+ ignored: list[tuple[str, int]] = []
110
+
111
+ for node in ast.walk(tree):
112
+ # Look for expression statements (standalone calls)
113
+ if not isinstance(node, ast.Expr):
114
+ continue
115
+
116
+ call = node.value
117
+ if not isinstance(call, ast.Call):
118
+ continue
119
+
120
+ func_name = _get_call_name(call)
121
+ if func_name and func_name in must_use_funcs:
122
+ ignored.append((func_name, node.lineno))
123
+
124
+ return ignored
125
+
126
+
127
+ @pre(lambda call: isinstance(call, ast.Call) and hasattr(call, 'func'))
128
+ @post(lambda result: result is None or isinstance(result, str))
129
+ def _get_call_name(call: ast.Call) -> str | None:
130
+ """Extract function name from a Call node."""
131
+ func = call.func
132
+ if isinstance(func, ast.Name):
133
+ return func.id
134
+ if isinstance(func, ast.Attribute):
135
+ return func.attr
136
+ return None
137
+
138
+
139
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
140
+ def check_must_use(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
141
+ """
142
+ Check for ignored return values of @must_use functions.
143
+
144
+ Examples:
145
+ >>> from invar.core.models import FileInfo, RuleConfig
146
+ >>> code = '''
147
+ ... from invar import must_use
148
+ ... @must_use("Error must be handled")
149
+ ... def validate(x): return x
150
+ ... validate(1)
151
+ ... '''
152
+ >>> info = FileInfo(path="test.py", lines=5, symbols=[], is_core=True, source=code)
153
+ >>> len(check_must_use(info, RuleConfig()))
154
+ 1
155
+ """
156
+ violations: list[Violation] = []
157
+ source = file_info.source
158
+ if not source:
159
+ return violations
160
+
161
+ must_use_funcs = find_must_use_functions(source)
162
+ if not must_use_funcs:
163
+ return violations
164
+
165
+ for func_name, line in find_ignored_calls(source, set(must_use_funcs.keys())):
166
+ reason = must_use_funcs.get(func_name, "Return value must be used")
167
+ violations.append(Violation(
168
+ rule="must_use_ignored", severity=Severity.WARNING, file=file_info.path,
169
+ line=line, message=f"Return value of '{func_name}()' ignored",
170
+ suggestion=f"Hint: {reason}",
171
+ ))
172
+ return violations