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,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
|