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,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation Pattern Detector (DX-61, P0).
|
|
3
|
+
|
|
4
|
+
Detects fail-fast validation patterns that could benefit from
|
|
5
|
+
error accumulation for better user experience.
|
|
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 ValidationDetector(BaseDetector):
|
|
23
|
+
"""
|
|
24
|
+
Detect fail-fast validation that returns early on first error.
|
|
25
|
+
|
|
26
|
+
These are candidates for error accumulation pattern.
|
|
27
|
+
|
|
28
|
+
Detection logic:
|
|
29
|
+
- Find functions with multiple early returns of error-like values
|
|
30
|
+
- Look for patterns like: if condition: return Failure/raise
|
|
31
|
+
- Suggest accumulating all errors before returning
|
|
32
|
+
|
|
33
|
+
>>> import ast
|
|
34
|
+
>>> detector = ValidationDetector()
|
|
35
|
+
>>> code = '''
|
|
36
|
+
... def validate(data):
|
|
37
|
+
... if "name" not in data:
|
|
38
|
+
... return Failure("Missing name")
|
|
39
|
+
... if "email" not in data:
|
|
40
|
+
... return Failure("Missing email")
|
|
41
|
+
... if "age" not in data:
|
|
42
|
+
... return Failure("Missing age")
|
|
43
|
+
... return Success(data)
|
|
44
|
+
... '''
|
|
45
|
+
>>> tree = ast.parse(code)
|
|
46
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
47
|
+
>>> len(suggestions) > 0
|
|
48
|
+
True
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
MIN_EARLY_RETURNS: ClassVar[int] = 3
|
|
52
|
+
ERROR_PATTERNS: ClassVar[set[str]] = {"Failure", "Err", "Error", "Left"}
|
|
53
|
+
VALIDATION_KEYWORDS: ClassVar[set[str]] = {"validate", "check", "verify", "parse"}
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
@post(lambda result: result == PatternID.VALIDATION)
|
|
57
|
+
def pattern_id(self) -> PatternID:
|
|
58
|
+
"""Unique identifier for this pattern."""
|
|
59
|
+
return PatternID.VALIDATION
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
@post(lambda result: result == Priority.P0)
|
|
63
|
+
def priority(self) -> Priority:
|
|
64
|
+
"""Priority tier."""
|
|
65
|
+
return Priority.P0
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
@post(lambda result: len(result) > 0)
|
|
69
|
+
def description(self) -> str:
|
|
70
|
+
"""Human-readable description."""
|
|
71
|
+
return "Use error accumulation instead of fail-fast validation"
|
|
72
|
+
|
|
73
|
+
@post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
|
|
74
|
+
def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
|
|
75
|
+
"""
|
|
76
|
+
Find functions with fail-fast validation patterns.
|
|
77
|
+
|
|
78
|
+
>>> import ast
|
|
79
|
+
>>> detector = ValidationDetector()
|
|
80
|
+
>>> code = '''
|
|
81
|
+
... def check_config(cfg):
|
|
82
|
+
... if not cfg.get("host"):
|
|
83
|
+
... return Failure("No host")
|
|
84
|
+
... if not cfg.get("port"):
|
|
85
|
+
... return Failure("No port")
|
|
86
|
+
... if not cfg.get("user"):
|
|
87
|
+
... return Failure("No user")
|
|
88
|
+
... return Success(cfg)
|
|
89
|
+
... '''
|
|
90
|
+
>>> tree = ast.parse(code)
|
|
91
|
+
>>> suggestions = detector.detect(tree, "test.py")
|
|
92
|
+
>>> len(suggestions) > 0
|
|
93
|
+
True
|
|
94
|
+
"""
|
|
95
|
+
suggestions = []
|
|
96
|
+
|
|
97
|
+
for node in ast.walk(tree):
|
|
98
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
99
|
+
suggestion = self._check_function(node, file_path)
|
|
100
|
+
if suggestion:
|
|
101
|
+
suggestions.append(suggestion)
|
|
102
|
+
|
|
103
|
+
return suggestions
|
|
104
|
+
|
|
105
|
+
@pre(lambda self, node, file_path: len(file_path) > 0)
|
|
106
|
+
def _check_function(
|
|
107
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
|
|
108
|
+
) -> PatternSuggestion | None:
|
|
109
|
+
"""
|
|
110
|
+
Check if function has fail-fast validation pattern.
|
|
111
|
+
|
|
112
|
+
>>> import ast
|
|
113
|
+
>>> detector = ValidationDetector()
|
|
114
|
+
>>> code = '''
|
|
115
|
+
... def validate(x):
|
|
116
|
+
... if not x.a: return Failure("a")
|
|
117
|
+
... if not x.b: return Failure("b")
|
|
118
|
+
... if not x.c: return Failure("c")
|
|
119
|
+
... return Success(x)
|
|
120
|
+
... '''
|
|
121
|
+
>>> tree = ast.parse(code)
|
|
122
|
+
>>> func = tree.body[0]
|
|
123
|
+
>>> suggestion = detector._check_function(func, "test.py")
|
|
124
|
+
>>> suggestion is not None
|
|
125
|
+
True
|
|
126
|
+
"""
|
|
127
|
+
early_returns = self._count_early_error_returns(node)
|
|
128
|
+
|
|
129
|
+
if early_returns >= self.MIN_EARLY_RETURNS:
|
|
130
|
+
confidence = self._calculate_confidence(node, early_returns)
|
|
131
|
+
|
|
132
|
+
return self.make_suggestion(
|
|
133
|
+
pattern_id=self.pattern_id,
|
|
134
|
+
priority=self.priority,
|
|
135
|
+
file_path=file_path,
|
|
136
|
+
line=node.lineno,
|
|
137
|
+
message=f"{early_returns} early error returns - consider error accumulation",
|
|
138
|
+
current_code=self._format_function_preview(node),
|
|
139
|
+
suggested_pattern="Collect all errors, return list[Error]",
|
|
140
|
+
confidence=confidence,
|
|
141
|
+
reference_pattern="Pattern 2: Validation for Error Accumulation",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
@post(lambda result: result >= 0)
|
|
147
|
+
def _count_early_error_returns(
|
|
148
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
149
|
+
) -> int:
|
|
150
|
+
"""
|
|
151
|
+
Count early returns with error-like values inside if statements.
|
|
152
|
+
|
|
153
|
+
Only counts if statements at the top level of the function body,
|
|
154
|
+
not nested functions or lambdas.
|
|
155
|
+
|
|
156
|
+
>>> import ast
|
|
157
|
+
>>> detector = ValidationDetector()
|
|
158
|
+
>>> code = '''
|
|
159
|
+
... def f(x):
|
|
160
|
+
... if not x.a: return Failure("a")
|
|
161
|
+
... if not x.b: return Failure("b")
|
|
162
|
+
... return Success(x)
|
|
163
|
+
... '''
|
|
164
|
+
>>> tree = ast.parse(code)
|
|
165
|
+
>>> func = tree.body[0]
|
|
166
|
+
>>> detector._count_early_error_returns(func)
|
|
167
|
+
2
|
|
168
|
+
"""
|
|
169
|
+
count = 0
|
|
170
|
+
|
|
171
|
+
# Only iterate direct children, not nested functions
|
|
172
|
+
for stmt in node.body:
|
|
173
|
+
count += self._count_if_error_returns(stmt)
|
|
174
|
+
|
|
175
|
+
return count
|
|
176
|
+
|
|
177
|
+
@post(lambda result: result >= 0)
|
|
178
|
+
def _count_if_error_returns(self, stmt: ast.stmt) -> int:
|
|
179
|
+
"""
|
|
180
|
+
Recursively count if statements with error returns, avoiding nested functions.
|
|
181
|
+
|
|
182
|
+
>>> import ast
|
|
183
|
+
>>> detector = ValidationDetector()
|
|
184
|
+
>>> stmt = ast.parse("if x: return Failure('e')").body[0]
|
|
185
|
+
>>> detector._count_if_error_returns(stmt)
|
|
186
|
+
1
|
|
187
|
+
"""
|
|
188
|
+
count = 0
|
|
189
|
+
|
|
190
|
+
if isinstance(stmt, ast.If):
|
|
191
|
+
# Check if body has early error return
|
|
192
|
+
for body_stmt in stmt.body:
|
|
193
|
+
if isinstance(body_stmt, ast.Return) and self._is_error_return(body_stmt):
|
|
194
|
+
count += 1
|
|
195
|
+
break
|
|
196
|
+
# Recurse into else/elif but NOT into nested functions
|
|
197
|
+
for body_stmt in stmt.body:
|
|
198
|
+
if not isinstance(body_stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
199
|
+
count += self._count_if_error_returns(body_stmt)
|
|
200
|
+
for else_stmt in stmt.orelse:
|
|
201
|
+
if not isinstance(else_stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
202
|
+
count += self._count_if_error_returns(else_stmt)
|
|
203
|
+
|
|
204
|
+
return count
|
|
205
|
+
|
|
206
|
+
@post(lambda result: isinstance(result, bool))
|
|
207
|
+
def _is_error_return(self, node: ast.Return) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Check if return value looks like an error.
|
|
210
|
+
|
|
211
|
+
>>> import ast
|
|
212
|
+
>>> detector = ValidationDetector()
|
|
213
|
+
>>> ret = ast.parse("return Failure('error')").body[0].value
|
|
214
|
+
>>> # This tests the return statement's value
|
|
215
|
+
>>> detector._is_error_return(ast.Return(value=ret))
|
|
216
|
+
True
|
|
217
|
+
"""
|
|
218
|
+
if node.value is None:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
# Check for Failure(...), Err(...), etc.
|
|
222
|
+
if isinstance(node.value, ast.Call):
|
|
223
|
+
if isinstance(node.value.func, ast.Name):
|
|
224
|
+
return node.value.func.id in self.ERROR_PATTERNS
|
|
225
|
+
if isinstance(node.value.func, ast.Attribute):
|
|
226
|
+
return node.value.func.attr in self.ERROR_PATTERNS
|
|
227
|
+
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
@pre(lambda self, node, early_returns: early_returns >= 0)
|
|
231
|
+
@post(lambda result: result in Confidence)
|
|
232
|
+
def _calculate_confidence(
|
|
233
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, early_returns: int
|
|
234
|
+
) -> Confidence:
|
|
235
|
+
"""
|
|
236
|
+
Calculate confidence based on function characteristics.
|
|
237
|
+
|
|
238
|
+
>>> import ast
|
|
239
|
+
>>> detector = ValidationDetector()
|
|
240
|
+
>>> func = ast.parse("def validate_config(x): pass").body[0]
|
|
241
|
+
>>> detector._calculate_confidence(func, 3)
|
|
242
|
+
<Confidence.HIGH: 'high'>
|
|
243
|
+
"""
|
|
244
|
+
# High confidence if function name suggests validation
|
|
245
|
+
func_name = node.name.lower()
|
|
246
|
+
if any(kw in func_name for kw in self.VALIDATION_KEYWORDS):
|
|
247
|
+
return Confidence.HIGH
|
|
248
|
+
|
|
249
|
+
# High confidence if many early returns
|
|
250
|
+
if early_returns >= 5:
|
|
251
|
+
return Confidence.HIGH
|
|
252
|
+
|
|
253
|
+
# Medium confidence for moderate early returns
|
|
254
|
+
if early_returns >= 3:
|
|
255
|
+
return Confidence.MEDIUM
|
|
256
|
+
|
|
257
|
+
return Confidence.LOW
|
|
258
|
+
|
|
259
|
+
@post(lambda result: len(result) > 0 and "def " in result)
|
|
260
|
+
def _format_function_preview(
|
|
261
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
262
|
+
) -> str:
|
|
263
|
+
"""
|
|
264
|
+
Format function preview for display.
|
|
265
|
+
|
|
266
|
+
>>> import ast
|
|
267
|
+
>>> detector = ValidationDetector()
|
|
268
|
+
>>> func = ast.parse("def validate(data): pass").body[0]
|
|
269
|
+
>>> preview = detector._format_function_preview(func)
|
|
270
|
+
>>> "validate" in preview
|
|
271
|
+
True
|
|
272
|
+
"""
|
|
273
|
+
prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
|
|
274
|
+
params = self.get_function_params(node)
|
|
275
|
+
param_str = ", ".join(name for name, _ in params[:3])
|
|
276
|
+
if len(params) > 3:
|
|
277
|
+
param_str += ", ..."
|
|
278
|
+
return f"{prefix} {node.name}({param_str})"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern Detector Registry (DX-61).
|
|
3
|
+
|
|
4
|
+
Central registry for all pattern detectors. Provides unified API
|
|
5
|
+
for running detection across multiple patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
|
|
13
|
+
from invar.core.patterns.detector import PatternDetector
|
|
14
|
+
from invar.core.patterns.p0_exhaustive import ExhaustiveMatchDetector
|
|
15
|
+
from invar.core.patterns.p0_literal import LiteralDetector
|
|
16
|
+
from invar.core.patterns.p0_newtype import NewTypeDetector
|
|
17
|
+
from invar.core.patterns.p0_nonempty import NonEmptyDetector
|
|
18
|
+
from invar.core.patterns.p0_validation import ValidationDetector
|
|
19
|
+
from invar.core.patterns.types import (
|
|
20
|
+
Confidence,
|
|
21
|
+
DetectionResult,
|
|
22
|
+
PatternID,
|
|
23
|
+
PatternSuggestion,
|
|
24
|
+
Priority,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PatternRegistry:
|
|
29
|
+
"""
|
|
30
|
+
Registry for pattern detectors.
|
|
31
|
+
|
|
32
|
+
Manages registration and execution of all pattern detectors.
|
|
33
|
+
Provides filtering by priority and confidence levels.
|
|
34
|
+
|
|
35
|
+
>>> registry = PatternRegistry()
|
|
36
|
+
>>> len(registry.detectors) > 0
|
|
37
|
+
True
|
|
38
|
+
>>> PatternID.NEWTYPE in [d.pattern_id for d in registry.detectors]
|
|
39
|
+
True
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# @invar:allow missing_contract: __init__ takes only self, no inputs to validate
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
"""Initialize with all P0 detectors."""
|
|
45
|
+
self._detectors: list[PatternDetector] = [
|
|
46
|
+
NewTypeDetector(),
|
|
47
|
+
ValidationDetector(),
|
|
48
|
+
NonEmptyDetector(),
|
|
49
|
+
LiteralDetector(),
|
|
50
|
+
ExhaustiveMatchDetector(),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
@post(lambda result: len(result) > 0)
|
|
55
|
+
def detectors(self) -> list[PatternDetector]:
|
|
56
|
+
"""Get all registered detectors."""
|
|
57
|
+
return self._detectors
|
|
58
|
+
|
|
59
|
+
@post(lambda result: result is not None)
|
|
60
|
+
def get_detectors_by_priority(self, priority: Priority) -> list[PatternDetector]:
|
|
61
|
+
"""
|
|
62
|
+
Get detectors filtered by priority.
|
|
63
|
+
|
|
64
|
+
>>> registry = PatternRegistry()
|
|
65
|
+
>>> p0_detectors = registry.get_detectors_by_priority(Priority.P0)
|
|
66
|
+
>>> len(p0_detectors) >= 5
|
|
67
|
+
True
|
|
68
|
+
"""
|
|
69
|
+
return [d for d in self._detectors if d.priority == priority]
|
|
70
|
+
|
|
71
|
+
@pre(lambda self, file_path, source, min_confidence=None, priority_filter=None: len(file_path) > 0)
|
|
72
|
+
@post(lambda result: result is not None)
|
|
73
|
+
def detect_file(
|
|
74
|
+
self,
|
|
75
|
+
file_path: str,
|
|
76
|
+
source: str,
|
|
77
|
+
min_confidence: Confidence = Confidence.LOW,
|
|
78
|
+
priority_filter: Priority | None = None,
|
|
79
|
+
) -> DetectionResult:
|
|
80
|
+
"""
|
|
81
|
+
Run all detectors on a file's source code.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
file_path: Path to the Python file (for location reporting)
|
|
85
|
+
source: Source code to analyze
|
|
86
|
+
min_confidence: Minimum confidence level to include
|
|
87
|
+
priority_filter: Optional priority filter (None = all priorities)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
DetectionResult with all suggestions
|
|
91
|
+
|
|
92
|
+
>>> registry = PatternRegistry()
|
|
93
|
+
>>> code = '''
|
|
94
|
+
... def process(user_id: str, order_id: str, product_id: str):
|
|
95
|
+
... pass
|
|
96
|
+
... '''
|
|
97
|
+
>>> result = registry.detect_file("test.py", source=code)
|
|
98
|
+
>>> result.has_suggestions
|
|
99
|
+
True
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
tree = ast.parse(source)
|
|
103
|
+
except SyntaxError:
|
|
104
|
+
# Skip files with syntax errors
|
|
105
|
+
return DetectionResult(
|
|
106
|
+
file=file_path,
|
|
107
|
+
suggestions=[],
|
|
108
|
+
patterns_checked=[],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
detectors = self._detectors
|
|
112
|
+
if priority_filter is not None:
|
|
113
|
+
detectors = self.get_detectors_by_priority(priority_filter)
|
|
114
|
+
|
|
115
|
+
all_suggestions: list[PatternSuggestion] = []
|
|
116
|
+
patterns_checked: list[PatternID] = []
|
|
117
|
+
|
|
118
|
+
for detector in detectors:
|
|
119
|
+
patterns_checked.append(detector.pattern_id)
|
|
120
|
+
suggestions = detector.detect(tree, file_path)
|
|
121
|
+
all_suggestions.extend(suggestions)
|
|
122
|
+
|
|
123
|
+
# Filter by confidence
|
|
124
|
+
result = DetectionResult(
|
|
125
|
+
file=file_path,
|
|
126
|
+
suggestions=all_suggestions,
|
|
127
|
+
patterns_checked=patterns_checked,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
filtered_suggestions = result.filter_by_confidence(min_confidence)
|
|
131
|
+
|
|
132
|
+
return DetectionResult(
|
|
133
|
+
file=file_path,
|
|
134
|
+
suggestions=filtered_suggestions,
|
|
135
|
+
patterns_checked=patterns_checked,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@post(lambda result: result is not None)
|
|
139
|
+
def detect_sources(
|
|
140
|
+
self,
|
|
141
|
+
sources: list[tuple[str, str]],
|
|
142
|
+
min_confidence: Confidence = Confidence.LOW,
|
|
143
|
+
priority_filter: Priority | None = None,
|
|
144
|
+
) -> list[DetectionResult]:
|
|
145
|
+
"""
|
|
146
|
+
Run detection on multiple sources.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
sources: List of (file_path, source_code) tuples
|
|
150
|
+
|
|
151
|
+
>>> registry = PatternRegistry()
|
|
152
|
+
>>> results = registry.detect_sources([])
|
|
153
|
+
>>> len(results)
|
|
154
|
+
0
|
|
155
|
+
"""
|
|
156
|
+
results = []
|
|
157
|
+
for file_path, source in sources:
|
|
158
|
+
result = self.detect_file(
|
|
159
|
+
file_path,
|
|
160
|
+
source,
|
|
161
|
+
min_confidence=min_confidence,
|
|
162
|
+
priority_filter=priority_filter,
|
|
163
|
+
)
|
|
164
|
+
results.append(result)
|
|
165
|
+
return results
|
|
166
|
+
|
|
167
|
+
@post(lambda result: result is not None)
|
|
168
|
+
def format_suggestions(
|
|
169
|
+
self, suggestions: list[PatternSuggestion], verbose: bool = False
|
|
170
|
+
) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Format suggestions for display.
|
|
173
|
+
|
|
174
|
+
>>> from invar.core.patterns.types import PatternSuggestion, PatternID, Confidence, Priority, Location
|
|
175
|
+
>>> registry = PatternRegistry()
|
|
176
|
+
>>> suggestions = [
|
|
177
|
+
... PatternSuggestion(
|
|
178
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
179
|
+
... location=Location(file="test.py", line=10),
|
|
180
|
+
... message="Test",
|
|
181
|
+
... confidence=Confidence.HIGH,
|
|
182
|
+
... priority=Priority.P0,
|
|
183
|
+
... current_code="def f(a, b, c): pass",
|
|
184
|
+
... suggested_pattern="NewType",
|
|
185
|
+
... reference_file=".invar/examples/functional.py",
|
|
186
|
+
... reference_pattern="Pattern 1",
|
|
187
|
+
... )
|
|
188
|
+
... ]
|
|
189
|
+
>>> output = registry.format_suggestions(suggestions)
|
|
190
|
+
>>> "SUGGEST" in output
|
|
191
|
+
True
|
|
192
|
+
"""
|
|
193
|
+
if not suggestions:
|
|
194
|
+
return ""
|
|
195
|
+
|
|
196
|
+
if verbose:
|
|
197
|
+
return "\n\n".join(s.format_for_guard() for s in suggestions)
|
|
198
|
+
else:
|
|
199
|
+
# Compact format
|
|
200
|
+
lines = []
|
|
201
|
+
for s in suggestions:
|
|
202
|
+
lines.append(f"[{s.severity}] {s.location}: {s.message}")
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@lru_cache(maxsize=1)
|
|
207
|
+
@post(lambda result: result is not None)
|
|
208
|
+
def get_registry() -> PatternRegistry:
|
|
209
|
+
"""
|
|
210
|
+
Get the global pattern registry (thread-safe via lru_cache).
|
|
211
|
+
|
|
212
|
+
>>> registry = get_registry()
|
|
213
|
+
>>> isinstance(registry, PatternRegistry)
|
|
214
|
+
True
|
|
215
|
+
"""
|
|
216
|
+
return PatternRegistry()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pre(lambda file_path, source, min_confidence=None: len(file_path) > 0)
|
|
220
|
+
@post(lambda result: result is not None)
|
|
221
|
+
def detect_patterns(
|
|
222
|
+
file_path: str,
|
|
223
|
+
source: str,
|
|
224
|
+
min_confidence: Confidence = Confidence.LOW,
|
|
225
|
+
) -> DetectionResult:
|
|
226
|
+
"""
|
|
227
|
+
Convenience function for pattern detection.
|
|
228
|
+
|
|
229
|
+
>>> code = "def f(a: str, b: str, c: str): pass"
|
|
230
|
+
>>> result = detect_patterns("test.py", source=code)
|
|
231
|
+
>>> isinstance(result, DetectionResult)
|
|
232
|
+
True
|
|
233
|
+
"""
|
|
234
|
+
return get_registry().detect_file(file_path, source, min_confidence)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern Detection Types (DX-61).
|
|
3
|
+
|
|
4
|
+
Core types for the functional pattern guidance system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PatternID(str, Enum):
|
|
15
|
+
"""Unique identifier for each pattern."""
|
|
16
|
+
|
|
17
|
+
# P0 - Core patterns
|
|
18
|
+
NEWTYPE = "newtype"
|
|
19
|
+
VALIDATION = "validation"
|
|
20
|
+
NONEMPTY = "nonempty"
|
|
21
|
+
LITERAL = "literal"
|
|
22
|
+
EXHAUSTIVE = "exhaustive"
|
|
23
|
+
|
|
24
|
+
# P1 - Extended patterns (future)
|
|
25
|
+
SMART_CONSTRUCTOR = "smart_constructor"
|
|
26
|
+
STRUCTURED_ERROR = "structured_error"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Confidence(str, Enum):
|
|
30
|
+
"""Confidence level for pattern suggestions."""
|
|
31
|
+
|
|
32
|
+
HIGH = "high" # Strong signal, very likely applicable
|
|
33
|
+
MEDIUM = "medium" # Moderate signal, likely applicable
|
|
34
|
+
LOW = "low" # Weak signal, possibly applicable
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Priority(str, Enum):
|
|
38
|
+
"""Pattern priority tier."""
|
|
39
|
+
|
|
40
|
+
P0 = "P0" # Core patterns, always suggested
|
|
41
|
+
P1 = "P1" # Extended patterns, suggested when relevant
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Severity for Guard output
|
|
45
|
+
Severity = Literal["SUGGEST"] # Pattern suggestions are always SUGGEST level
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Location:
|
|
50
|
+
"""Source code location for a pattern opportunity."""
|
|
51
|
+
|
|
52
|
+
file: str
|
|
53
|
+
line: int
|
|
54
|
+
column: int = 0
|
|
55
|
+
end_line: int | None = None
|
|
56
|
+
end_column: int | None = None
|
|
57
|
+
|
|
58
|
+
@post(lambda result: ":" in result) # Contains file:line separator
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
"""Format as file:line for display."""
|
|
61
|
+
return f"{self.file}:{self.line}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class PatternSuggestion:
|
|
66
|
+
"""
|
|
67
|
+
A suggestion to apply a functional pattern.
|
|
68
|
+
|
|
69
|
+
>>> from invar.core.patterns.types import PatternSuggestion, PatternID, Confidence, Priority, Location
|
|
70
|
+
>>> suggestion = PatternSuggestion(
|
|
71
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
72
|
+
... location=Location(file="src/api.py", line=42),
|
|
73
|
+
... message="Consider using NewType for semantic clarity",
|
|
74
|
+
... confidence=Confidence.HIGH,
|
|
75
|
+
... priority=Priority.P0,
|
|
76
|
+
... current_code="def process(user_id: str, order_id: str)",
|
|
77
|
+
... suggested_pattern="NewType('UserId', str), NewType('OrderId', str)",
|
|
78
|
+
... reference_file=".invar/examples/functional.py",
|
|
79
|
+
... reference_pattern="Pattern 1: NewType for Semantic Clarity",
|
|
80
|
+
... )
|
|
81
|
+
>>> suggestion.severity
|
|
82
|
+
'SUGGEST'
|
|
83
|
+
>>> "NewType" in suggestion.message
|
|
84
|
+
True
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
pattern_id: PatternID
|
|
88
|
+
location: Location
|
|
89
|
+
message: str
|
|
90
|
+
confidence: Confidence
|
|
91
|
+
priority: Priority
|
|
92
|
+
current_code: str
|
|
93
|
+
suggested_pattern: str
|
|
94
|
+
reference_file: str
|
|
95
|
+
reference_pattern: str
|
|
96
|
+
severity: Severity = "SUGGEST"
|
|
97
|
+
|
|
98
|
+
@post(lambda result: "[SUGGEST]" in result and "Pattern:" in result)
|
|
99
|
+
def format_for_guard(self) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Format suggestion for Guard output.
|
|
102
|
+
|
|
103
|
+
>>> suggestion = PatternSuggestion(
|
|
104
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
105
|
+
... location=Location(file="src/api.py", line=42),
|
|
106
|
+
... message="3+ str params - consider NewType",
|
|
107
|
+
... confidence=Confidence.HIGH,
|
|
108
|
+
... priority=Priority.P0,
|
|
109
|
+
... current_code="def f(a: str, b: str, c: str)",
|
|
110
|
+
... suggested_pattern="NewType",
|
|
111
|
+
... reference_file=".invar/examples/functional.py",
|
|
112
|
+
... reference_pattern="Pattern 1",
|
|
113
|
+
... )
|
|
114
|
+
>>> "SUGGEST" in suggestion.format_for_guard()
|
|
115
|
+
True
|
|
116
|
+
>>> "src/api.py:42" in suggestion.format_for_guard()
|
|
117
|
+
True
|
|
118
|
+
"""
|
|
119
|
+
return (
|
|
120
|
+
f"[{self.severity}] {self.location}: {self.message}\n"
|
|
121
|
+
f" Pattern: {self.pattern_id.value} ({self.priority.value})\n"
|
|
122
|
+
f" Current: {self.current_code[:60]}{'...' if len(self.current_code) > 60 else ''}\n"
|
|
123
|
+
f" Suggest: {self.suggested_pattern}\n"
|
|
124
|
+
f" See: {self.reference_file} - {self.reference_pattern}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class DetectionResult:
|
|
130
|
+
"""
|
|
131
|
+
Result of running pattern detection on a file.
|
|
132
|
+
|
|
133
|
+
>>> from invar.core.patterns.types import DetectionResult, PatternSuggestion, PatternID, Confidence, Priority, Location
|
|
134
|
+
>>> result = DetectionResult(
|
|
135
|
+
... file="src/api.py",
|
|
136
|
+
... suggestions=[],
|
|
137
|
+
... patterns_checked=[PatternID.NEWTYPE, PatternID.LITERAL],
|
|
138
|
+
... )
|
|
139
|
+
>>> len(result.suggestions)
|
|
140
|
+
0
|
|
141
|
+
>>> PatternID.NEWTYPE in result.patterns_checked
|
|
142
|
+
True
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
file: str
|
|
146
|
+
suggestions: list[PatternSuggestion]
|
|
147
|
+
patterns_checked: list[PatternID]
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
@post(lambda result: isinstance(result, bool))
|
|
151
|
+
def has_suggestions(self) -> bool:
|
|
152
|
+
"""Check if any suggestions were found."""
|
|
153
|
+
return len(self.suggestions) > 0
|
|
154
|
+
|
|
155
|
+
@pre(lambda self, min_confidence: min_confidence in Confidence)
|
|
156
|
+
@post(lambda result: isinstance(result, list))
|
|
157
|
+
def filter_by_confidence(self, min_confidence: Confidence) -> list[PatternSuggestion]:
|
|
158
|
+
"""
|
|
159
|
+
Filter suggestions by minimum confidence level.
|
|
160
|
+
|
|
161
|
+
>>> result = DetectionResult(file="test.py", suggestions=[], patterns_checked=[])
|
|
162
|
+
>>> result.filter_by_confidence(Confidence.HIGH)
|
|
163
|
+
[]
|
|
164
|
+
"""
|
|
165
|
+
confidence_order = {Confidence.HIGH: 2, Confidence.MEDIUM: 1, Confidence.LOW: 0}
|
|
166
|
+
min_level = confidence_order[min_confidence]
|
|
167
|
+
return [s for s in self.suggestions if confidence_order[s.confidence] >= min_level]
|