invar-tools 1.4.0__py3-none-any.whl → 1.6.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 +7 -1
- invar/core/entry_points.py +12 -10
- invar/core/formatter.py +21 -1
- invar/core/models.py +98 -0
- invar/core/patterns/__init__.py +53 -0
- invar/core/patterns/detector.py +249 -0
- invar/core/patterns/p0_exhaustive.py +207 -0
- invar/core/patterns/p0_literal.py +307 -0
- invar/core/patterns/p0_newtype.py +211 -0
- invar/core/patterns/p0_nonempty.py +307 -0
- invar/core/patterns/p0_validation.py +278 -0
- invar/core/patterns/registry.py +234 -0
- invar/core/patterns/types.py +167 -0
- invar/core/trivial_detection.py +189 -0
- invar/mcp/server.py +4 -0
- invar/shell/commands/guard.py +100 -8
- invar/shell/config.py +46 -0
- invar/shell/contract_coverage.py +358 -0
- invar/shell/guard_output.py +15 -0
- invar/shell/pattern_integration.py +234 -0
- invar/shell/testing.py +13 -2
- invar/templates/CLAUDE.md.template +18 -10
- invar/templates/config/CLAUDE.md.jinja +52 -30
- invar/templates/config/context.md.jinja +14 -0
- invar/templates/protocol/INVAR.md +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +51 -1
- invar/templates/skills/review/SKILL.md.jinja +196 -31
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NewType Pattern Detector (DX-61, P0).
|
|
3
|
+
|
|
4
|
+
Detects opportunities to use NewType for semantic clarity when
|
|
5
|
+
multiple parameters share the same primitive type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
|
|
13
|
+
from invar.core.patterns.detector import BaseDetector
|
|
14
|
+
from invar.core.patterns.types import (
|
|
15
|
+
Confidence,
|
|
16
|
+
PatternID,
|
|
17
|
+
PatternSuggestion,
|
|
18
|
+
Priority,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NewTypeDetector(BaseDetector):
|
|
23
|
+
"""
|
|
24
|
+
Detect functions with 3+ parameters of the same primitive type.
|
|
25
|
+
|
|
26
|
+
These are candidates for NewType to prevent parameter confusion.
|
|
27
|
+
|
|
28
|
+
Detection logic:
|
|
29
|
+
- Find functions with 3+ str/int/float params of same type
|
|
30
|
+
- Exclude common patterns (e.g., *args, **kwargs)
|
|
31
|
+
- Suggest NewType for semantic differentiation
|
|
32
|
+
|
|
33
|
+
>>> import ast
|
|
34
|
+
>>> detector = NewTypeDetector()
|
|
35
|
+
>>> code = '''
|
|
36
|
+
... def process(user_id: str, order_id: str, product_id: str):
|
|
37
|
+
... pass
|
|
38
|
+
... '''
|
|
39
|
+
>>> tree = ast.parse(code)
|
|
40
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
41
|
+
>>> len(suggestions) > 0
|
|
42
|
+
True
|
|
43
|
+
>>> suggestions[0].pattern_id == PatternID.NEWTYPE
|
|
44
|
+
True
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
PRIMITIVE_TYPES: ClassVar[set[str]] = {"str", "int", "float", "bool", "bytes"}
|
|
48
|
+
MIN_SAME_TYPE_PARAMS: ClassVar[int] = 3
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@post(lambda result: result == PatternID.NEWTYPE)
|
|
52
|
+
def pattern_id(self) -> PatternID:
|
|
53
|
+
"""Unique identifier for this pattern."""
|
|
54
|
+
return PatternID.NEWTYPE
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
@post(lambda result: result == Priority.P0)
|
|
58
|
+
def priority(self) -> Priority:
|
|
59
|
+
"""Priority tier."""
|
|
60
|
+
return Priority.P0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
@post(lambda result: len(result) > 0)
|
|
64
|
+
def description(self) -> str:
|
|
65
|
+
"""Human-readable description."""
|
|
66
|
+
return "Use NewType for semantic clarity with multiple same-type parameters"
|
|
67
|
+
|
|
68
|
+
@post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
|
|
69
|
+
def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
|
|
70
|
+
"""
|
|
71
|
+
Find functions with multiple parameters of the same primitive type.
|
|
72
|
+
|
|
73
|
+
>>> import ast
|
|
74
|
+
>>> detector = NewTypeDetector()
|
|
75
|
+
>>> code = '''
|
|
76
|
+
... def good(a: str, b: int):
|
|
77
|
+
... pass
|
|
78
|
+
... def bad(user_id: str, order_id: str, product_id: str):
|
|
79
|
+
... pass
|
|
80
|
+
... '''
|
|
81
|
+
>>> tree = ast.parse(code)
|
|
82
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
83
|
+
>>> len(suggestions)
|
|
84
|
+
1
|
|
85
|
+
>>> "bad" in suggestions[0].current_code
|
|
86
|
+
True
|
|
87
|
+
"""
|
|
88
|
+
suggestions = []
|
|
89
|
+
|
|
90
|
+
for node in ast.walk(tree):
|
|
91
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
92
|
+
suggestion = self._check_function(node, file_path)
|
|
93
|
+
if suggestion:
|
|
94
|
+
suggestions.append(suggestion)
|
|
95
|
+
|
|
96
|
+
return suggestions
|
|
97
|
+
|
|
98
|
+
@pre(lambda self, node, file_path: len(file_path) > 0)
|
|
99
|
+
def _check_function(
|
|
100
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
|
|
101
|
+
) -> PatternSuggestion | None:
|
|
102
|
+
"""
|
|
103
|
+
Check if function has multiple params of same primitive type.
|
|
104
|
+
|
|
105
|
+
>>> import ast
|
|
106
|
+
>>> detector = NewTypeDetector()
|
|
107
|
+
>>> code = "def f(user_id: str, order_id: str, product_id: str): pass"
|
|
108
|
+
>>> tree = ast.parse(code)
|
|
109
|
+
>>> func = tree.body[0]
|
|
110
|
+
>>> suggestion = detector._check_function(func, "test.py")
|
|
111
|
+
>>> suggestion is not None
|
|
112
|
+
True
|
|
113
|
+
>>> suggestion.confidence == Confidence.HIGH
|
|
114
|
+
True
|
|
115
|
+
"""
|
|
116
|
+
params = self.get_function_params(node)
|
|
117
|
+
|
|
118
|
+
# Skip if too few parameters
|
|
119
|
+
if len(params) < self.MIN_SAME_TYPE_PARAMS:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Count occurrences of each primitive type
|
|
123
|
+
for prim_type in self.PRIMITIVE_TYPES:
|
|
124
|
+
count = self.count_type_occurrences(params, prim_type)
|
|
125
|
+
if count >= self.MIN_SAME_TYPE_PARAMS:
|
|
126
|
+
# Found opportunity
|
|
127
|
+
matching_params = [name for name, t in params if t == prim_type]
|
|
128
|
+
confidence = self._calculate_confidence(matching_params, node)
|
|
129
|
+
|
|
130
|
+
return self.make_suggestion(
|
|
131
|
+
pattern_id=self.pattern_id,
|
|
132
|
+
priority=self.priority,
|
|
133
|
+
file_path=file_path,
|
|
134
|
+
line=node.lineno,
|
|
135
|
+
message=f"{count} '{prim_type}' params - consider NewType for semantic clarity",
|
|
136
|
+
current_code=self._format_signature(node),
|
|
137
|
+
suggested_pattern=self._suggest_newtypes(matching_params, prim_type),
|
|
138
|
+
confidence=confidence,
|
|
139
|
+
reference_pattern="Pattern 1: NewType for Semantic Clarity",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
@pre(lambda self, param_names, _node: len(param_names) > 0)
|
|
145
|
+
@post(lambda result: result in Confidence)
|
|
146
|
+
def _calculate_confidence(
|
|
147
|
+
self, param_names: list[str], _node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
148
|
+
) -> Confidence:
|
|
149
|
+
"""
|
|
150
|
+
Calculate confidence based on parameter naming patterns.
|
|
151
|
+
|
|
152
|
+
Higher confidence if names suggest distinct entities (e.g., *_id patterns).
|
|
153
|
+
|
|
154
|
+
>>> detector = NewTypeDetector()
|
|
155
|
+
>>> import ast
|
|
156
|
+
>>> func = ast.parse("def f(user_id, order_id, product_id): pass").body[0]
|
|
157
|
+
>>> detector._calculate_confidence(["user_id", "order_id", "product_id"], func)
|
|
158
|
+
<Confidence.HIGH: 'high'>
|
|
159
|
+
"""
|
|
160
|
+
# High confidence if names follow *_id, *_name, or *_code patterns
|
|
161
|
+
id_pattern = sum(1 for n in param_names if n.endswith(("_id", "_name", "_code", "_key")))
|
|
162
|
+
if id_pattern >= 2:
|
|
163
|
+
return Confidence.HIGH
|
|
164
|
+
|
|
165
|
+
# Medium confidence for descriptive names
|
|
166
|
+
if all(len(n) > 3 for n in param_names):
|
|
167
|
+
return Confidence.MEDIUM
|
|
168
|
+
|
|
169
|
+
# Low confidence for short/generic names
|
|
170
|
+
return Confidence.LOW
|
|
171
|
+
|
|
172
|
+
@post(lambda result: len(result) > 0 and "def " in result)
|
|
173
|
+
def _format_signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Format function signature for display.
|
|
176
|
+
|
|
177
|
+
>>> import ast
|
|
178
|
+
>>> detector = NewTypeDetector()
|
|
179
|
+
>>> func = ast.parse("def process(a: str, b: str): pass").body[0]
|
|
180
|
+
>>> sig = detector._format_signature(func)
|
|
181
|
+
>>> "process" in sig
|
|
182
|
+
True
|
|
183
|
+
"""
|
|
184
|
+
params = self.get_function_params(node)
|
|
185
|
+
param_str = ", ".join(
|
|
186
|
+
f"{name}: {t}" if t else name
|
|
187
|
+
for name, t in params[:5] # Limit for readability
|
|
188
|
+
)
|
|
189
|
+
if len(params) > 5:
|
|
190
|
+
param_str += ", ..."
|
|
191
|
+
prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
192
|
+
return f"{prefix} {node.name}({param_str})"
|
|
193
|
+
|
|
194
|
+
@pre(lambda self, param_names, base_type: len(param_names) > 0 and len(base_type) > 0)
|
|
195
|
+
@post(lambda result: "NewType" in result)
|
|
196
|
+
def _suggest_newtypes(self, param_names: list[str], base_type: str) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Generate NewType suggestion for parameters.
|
|
199
|
+
|
|
200
|
+
>>> detector = NewTypeDetector()
|
|
201
|
+
>>> detector._suggest_newtypes(["user_id", "order_id"], "str")
|
|
202
|
+
"NewType('UserId', str), NewType('OrderId', str)"
|
|
203
|
+
"""
|
|
204
|
+
newtypes = []
|
|
205
|
+
for name in param_names[:3]: # Limit suggestions
|
|
206
|
+
# Convert snake_case to PascalCase
|
|
207
|
+
pascal = "".join(word.capitalize() for word in name.split("_"))
|
|
208
|
+
newtypes.append(f"NewType('{pascal}', {base_type})")
|
|
209
|
+
if len(param_names) > 3:
|
|
210
|
+
newtypes.append("...")
|
|
211
|
+
return ", ".join(newtypes)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NonEmpty Pattern Detector (DX-61, P0).
|
|
3
|
+
|
|
4
|
+
Detects runtime empty-collection checks that could benefit from
|
|
5
|
+
compile-time NonEmpty type safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
|
|
10
|
+
from deal import post, pre
|
|
11
|
+
|
|
12
|
+
from invar.core.patterns.detector import BaseDetector
|
|
13
|
+
from invar.core.patterns.types import (
|
|
14
|
+
Confidence,
|
|
15
|
+
PatternID,
|
|
16
|
+
PatternSuggestion,
|
|
17
|
+
Priority,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NonEmptyDetector(BaseDetector):
|
|
22
|
+
"""
|
|
23
|
+
Detect runtime checks for empty collections.
|
|
24
|
+
|
|
25
|
+
These are candidates for NonEmpty type to guarantee non-emptiness
|
|
26
|
+
at compile time instead of runtime.
|
|
27
|
+
|
|
28
|
+
Detection logic:
|
|
29
|
+
- Find 'if not items:' or 'if len(items) == 0:' patterns
|
|
30
|
+
- Look for raises or early returns after such checks
|
|
31
|
+
- Suggest NonEmpty type for the parameter
|
|
32
|
+
|
|
33
|
+
>>> import ast
|
|
34
|
+
>>> detector = NonEmptyDetector()
|
|
35
|
+
>>> code = '''
|
|
36
|
+
... def summarize(items: list[str]) -> str:
|
|
37
|
+
... if not items:
|
|
38
|
+
... raise ValueError("Cannot summarize empty list")
|
|
39
|
+
... return f"First: {items[0]}"
|
|
40
|
+
... '''
|
|
41
|
+
>>> tree = ast.parse(code)
|
|
42
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
43
|
+
>>> len(suggestions) > 0
|
|
44
|
+
True
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
@post(lambda result: result == PatternID.NONEMPTY)
|
|
49
|
+
def pattern_id(self) -> PatternID:
|
|
50
|
+
"""Unique identifier for this pattern."""
|
|
51
|
+
return PatternID.NONEMPTY
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
@post(lambda result: result == Priority.P0)
|
|
55
|
+
def priority(self) -> Priority:
|
|
56
|
+
"""Priority tier."""
|
|
57
|
+
return Priority.P0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@post(lambda result: len(result) > 0)
|
|
61
|
+
def description(self) -> str:
|
|
62
|
+
"""Human-readable description."""
|
|
63
|
+
return "Use NonEmpty type for compile-time non-empty guarantees"
|
|
64
|
+
|
|
65
|
+
@post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
|
|
66
|
+
def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
|
|
67
|
+
"""
|
|
68
|
+
Find functions with runtime empty-collection checks.
|
|
69
|
+
|
|
70
|
+
>>> import ast
|
|
71
|
+
>>> detector = NonEmptyDetector()
|
|
72
|
+
>>> code = '''
|
|
73
|
+
... def process(data: list[int]):
|
|
74
|
+
... if len(data) == 0:
|
|
75
|
+
... raise ValueError("Empty data")
|
|
76
|
+
... return data[0]
|
|
77
|
+
... '''
|
|
78
|
+
>>> tree = ast.parse(code)
|
|
79
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
80
|
+
>>> len(suggestions) > 0
|
|
81
|
+
True
|
|
82
|
+
"""
|
|
83
|
+
suggestions = []
|
|
84
|
+
|
|
85
|
+
for node in ast.walk(tree):
|
|
86
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
87
|
+
suggestion = self._check_function(node, file_path)
|
|
88
|
+
if suggestion:
|
|
89
|
+
suggestions.append(suggestion)
|
|
90
|
+
|
|
91
|
+
return suggestions
|
|
92
|
+
|
|
93
|
+
@pre(lambda self, node, file_path: len(file_path) > 0)
|
|
94
|
+
def _check_function(
|
|
95
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
|
|
96
|
+
) -> PatternSuggestion | None:
|
|
97
|
+
"""
|
|
98
|
+
Check if function has empty-collection guard patterns.
|
|
99
|
+
|
|
100
|
+
>>> import ast
|
|
101
|
+
>>> detector = NonEmptyDetector()
|
|
102
|
+
>>> code = '''
|
|
103
|
+
... def f(items):
|
|
104
|
+
... if not items:
|
|
105
|
+
... raise ValueError("Empty")
|
|
106
|
+
... return items[0]
|
|
107
|
+
... '''
|
|
108
|
+
>>> tree = ast.parse(code)
|
|
109
|
+
>>> func = tree.body[0]
|
|
110
|
+
>>> suggestion = detector._check_function(func, "test.py")
|
|
111
|
+
>>> suggestion is not None
|
|
112
|
+
True
|
|
113
|
+
"""
|
|
114
|
+
empty_checks = self._find_empty_checks(node)
|
|
115
|
+
|
|
116
|
+
if empty_checks:
|
|
117
|
+
var_name, check_line = empty_checks[0]
|
|
118
|
+
param_type = self._get_param_type(node, var_name)
|
|
119
|
+
confidence = self._calculate_confidence(node, var_name, param_type)
|
|
120
|
+
|
|
121
|
+
return self.make_suggestion(
|
|
122
|
+
pattern_id=self.pattern_id,
|
|
123
|
+
priority=self.priority,
|
|
124
|
+
file_path=file_path,
|
|
125
|
+
line=check_line,
|
|
126
|
+
message=f"Runtime empty check on '{var_name}' - consider NonEmpty type",
|
|
127
|
+
current_code=self._format_check(var_name, param_type),
|
|
128
|
+
suggested_pattern=f"NonEmpty[{param_type or 'T'}] guarantees non-empty at compile time",
|
|
129
|
+
confidence=confidence,
|
|
130
|
+
reference_pattern="Pattern 3: NonEmpty for Compile-Time Safety",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
@post(lambda result: all(isinstance(v, str) and line > 0 for v, line in result))
|
|
136
|
+
def _find_empty_checks(
|
|
137
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
138
|
+
) -> list[tuple[str, int]]:
|
|
139
|
+
"""
|
|
140
|
+
Find 'if not x' or 'if len(x) == 0' patterns with raise/return.
|
|
141
|
+
|
|
142
|
+
Only checks if statements at the function level, not nested functions.
|
|
143
|
+
|
|
144
|
+
>>> import ast
|
|
145
|
+
>>> detector = NonEmptyDetector()
|
|
146
|
+
>>> code = '''
|
|
147
|
+
... def f(items):
|
|
148
|
+
... if not items:
|
|
149
|
+
... raise ValueError("Empty")
|
|
150
|
+
... '''
|
|
151
|
+
>>> tree = ast.parse(code)
|
|
152
|
+
>>> func = tree.body[0]
|
|
153
|
+
>>> checks = detector._find_empty_checks(func)
|
|
154
|
+
>>> len(checks) > 0
|
|
155
|
+
True
|
|
156
|
+
>>> checks[0][0]
|
|
157
|
+
'items'
|
|
158
|
+
"""
|
|
159
|
+
checks: list[tuple[str, int]] = []
|
|
160
|
+
self._collect_empty_checks(node.body, checks)
|
|
161
|
+
return checks
|
|
162
|
+
|
|
163
|
+
@pre(lambda self, stmts, checks: stmts is not None and checks is not None)
|
|
164
|
+
def _collect_empty_checks(
|
|
165
|
+
self, stmts: list[ast.stmt], checks: list[tuple[str, int]]
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Recursively collect empty checks, avoiding nested functions.
|
|
169
|
+
|
|
170
|
+
>>> import ast
|
|
171
|
+
>>> detector = NonEmptyDetector()
|
|
172
|
+
>>> stmts = ast.parse("if not x: raise ValueError('e')").body
|
|
173
|
+
>>> checks: list[tuple[str, int]] = []
|
|
174
|
+
>>> detector._collect_empty_checks(stmts, checks)
|
|
175
|
+
>>> len(checks)
|
|
176
|
+
1
|
|
177
|
+
"""
|
|
178
|
+
for stmt in stmts:
|
|
179
|
+
# Skip nested functions
|
|
180
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if isinstance(stmt, ast.If):
|
|
184
|
+
var_name = self._extract_empty_check_var(stmt.test)
|
|
185
|
+
if var_name and self._has_raise_or_return(stmt.body):
|
|
186
|
+
checks.append((var_name, stmt.lineno))
|
|
187
|
+
# Recurse into if body and else
|
|
188
|
+
self._collect_empty_checks(stmt.body, checks)
|
|
189
|
+
self._collect_empty_checks(stmt.orelse, checks)
|
|
190
|
+
|
|
191
|
+
@post(lambda result: result is None or (isinstance(result, str) and len(result) > 0))
|
|
192
|
+
def _extract_empty_check_var(self, test: ast.expr) -> str | None:
|
|
193
|
+
"""
|
|
194
|
+
Extract variable name from empty-check condition.
|
|
195
|
+
|
|
196
|
+
Handles:
|
|
197
|
+
- 'not items' -> 'items'
|
|
198
|
+
- 'len(items) == 0' -> 'items'
|
|
199
|
+
- 'len(items) < 1' -> 'items'
|
|
200
|
+
|
|
201
|
+
>>> import ast
|
|
202
|
+
>>> detector = NonEmptyDetector()
|
|
203
|
+
>>> test = ast.parse("not items", mode="eval").body
|
|
204
|
+
>>> detector._extract_empty_check_var(test)
|
|
205
|
+
'items'
|
|
206
|
+
"""
|
|
207
|
+
# Handle 'not items'
|
|
208
|
+
if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
|
|
209
|
+
if isinstance(test.operand, ast.Name):
|
|
210
|
+
return test.operand.id
|
|
211
|
+
|
|
212
|
+
# Handle 'len(items) == 0' or 'len(items) < 1'
|
|
213
|
+
if isinstance(test, ast.Compare):
|
|
214
|
+
if len(test.ops) == 1 and len(test.comparators) == 1:
|
|
215
|
+
left = test.left
|
|
216
|
+
op = test.ops[0]
|
|
217
|
+
right = test.comparators[0]
|
|
218
|
+
|
|
219
|
+
# Check for len(x) on left
|
|
220
|
+
if (
|
|
221
|
+
isinstance(left, ast.Call)
|
|
222
|
+
and isinstance(left.func, ast.Name)
|
|
223
|
+
and left.func.id == "len"
|
|
224
|
+
and len(left.args) == 1
|
|
225
|
+
and isinstance(left.args[0], ast.Name)
|
|
226
|
+
):
|
|
227
|
+
var_name = left.args[0].id
|
|
228
|
+
|
|
229
|
+
# Check for == 0 or < 1
|
|
230
|
+
if isinstance(right, ast.Constant):
|
|
231
|
+
if isinstance(op, ast.Eq) and right.value == 0:
|
|
232
|
+
return var_name
|
|
233
|
+
if isinstance(op, ast.Lt) and right.value == 1:
|
|
234
|
+
return var_name
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
@post(lambda result: isinstance(result, bool))
|
|
239
|
+
def _has_raise_or_return(self, body: list[ast.stmt]) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Check if body contains raise or return statement.
|
|
242
|
+
|
|
243
|
+
>>> import ast
|
|
244
|
+
>>> detector = NonEmptyDetector()
|
|
245
|
+
>>> body = ast.parse("raise ValueError('x')").body
|
|
246
|
+
>>> detector._has_raise_or_return(body)
|
|
247
|
+
True
|
|
248
|
+
"""
|
|
249
|
+
return any(isinstance(stmt, (ast.Raise, ast.Return)) for stmt in body)
|
|
250
|
+
|
|
251
|
+
@pre(lambda self, node, var_name: len(var_name) > 0)
|
|
252
|
+
def _get_param_type(
|
|
253
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, var_name: str
|
|
254
|
+
) -> str | None:
|
|
255
|
+
"""
|
|
256
|
+
Get type annotation for a parameter.
|
|
257
|
+
|
|
258
|
+
>>> import ast
|
|
259
|
+
>>> detector = NonEmptyDetector()
|
|
260
|
+
>>> func = ast.parse("def f(items: list[str]): pass").body[0]
|
|
261
|
+
>>> detector._get_param_type(func, "items")
|
|
262
|
+
'list[str]'
|
|
263
|
+
"""
|
|
264
|
+
for arg in node.args.args:
|
|
265
|
+
if arg.arg == var_name and arg.annotation:
|
|
266
|
+
return self._annotation_to_str(arg.annotation)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
@pre(lambda self, _node, var_name, param_type: len(var_name) > 0)
|
|
270
|
+
@post(lambda result: result in Confidence)
|
|
271
|
+
def _calculate_confidence(
|
|
272
|
+
self,
|
|
273
|
+
_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
274
|
+
var_name: str,
|
|
275
|
+
param_type: str | None,
|
|
276
|
+
) -> Confidence:
|
|
277
|
+
"""
|
|
278
|
+
Calculate confidence based on context.
|
|
279
|
+
|
|
280
|
+
>>> import ast
|
|
281
|
+
>>> detector = NonEmptyDetector()
|
|
282
|
+
>>> func = ast.parse("def f(items: list[str]): pass").body[0]
|
|
283
|
+
>>> detector._calculate_confidence(func, "items", "list[str]")
|
|
284
|
+
<Confidence.HIGH: 'high'>
|
|
285
|
+
"""
|
|
286
|
+
# High confidence if typed as list[T]
|
|
287
|
+
if param_type and param_type.startswith("list["):
|
|
288
|
+
return Confidence.HIGH
|
|
289
|
+
|
|
290
|
+
# Medium confidence if var name suggests collection
|
|
291
|
+
if any(kw in var_name.lower() for kw in ("items", "list", "elements", "data")):
|
|
292
|
+
return Confidence.MEDIUM
|
|
293
|
+
|
|
294
|
+
return Confidence.LOW
|
|
295
|
+
|
|
296
|
+
@pre(lambda self, var_name, param_type: len(var_name) > 0)
|
|
297
|
+
@post(lambda result: len(result) > 0 and "if not" in result)
|
|
298
|
+
def _format_check(self, var_name: str, param_type: str | None) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Format the empty check for display.
|
|
301
|
+
|
|
302
|
+
>>> detector = NonEmptyDetector()
|
|
303
|
+
>>> detector._format_check("items", "list[str]")
|
|
304
|
+
'if not items: raise ... (items: list[str])'
|
|
305
|
+
"""
|
|
306
|
+
type_info = f" ({var_name}: {param_type})" if param_type else ""
|
|
307
|
+
return f"if not {var_name}: raise ...{type_info}"
|