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
invar/__init__.py
CHANGED
|
@@ -8,7 +8,13 @@ This package provides development tools (guard, map, sig).
|
|
|
8
8
|
For runtime contracts only, use invar-runtime instead.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = importlib.metadata.version("invar-tools")
|
|
15
|
+
except importlib.metadata.PackageNotFoundError:
|
|
16
|
+
__version__ = "0.0.0.dev" # Development mode fallback
|
|
17
|
+
|
|
12
18
|
__protocol_version__ = "5.0" # Protocol/spec version (separate from package version)
|
|
13
19
|
|
|
14
20
|
# Re-export from invar-runtime for backwards compatibility
|
invar/core/entry_points.py
CHANGED
|
@@ -100,30 +100,30 @@ def count_escape_hatches(source: str) -> int:
|
|
|
100
100
|
return len(extract_escape_hatches(source))
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
@post(lambda result: all(len(t) ==
|
|
104
|
-
def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
|
|
103
|
+
@post(lambda result: all(len(t) == 3 for t in result)) # Returns (rule, reason, line) tuples
|
|
104
|
+
def extract_escape_hatches(source: str) -> list[tuple[str, str, int]]:
|
|
105
105
|
"""
|
|
106
|
-
Extract @invar:allow markers with their reasons (DX-33
|
|
106
|
+
Extract @invar:allow markers with their reasons and line numbers (DX-33, DX-66).
|
|
107
107
|
|
|
108
108
|
Uses tokenize to only match real comments, not strings/docstrings.
|
|
109
|
-
Returns list of (rule, reason) tuples for cross-file analysis.
|
|
109
|
+
Returns list of (rule, reason, line) tuples for cross-file analysis.
|
|
110
110
|
|
|
111
111
|
Examples:
|
|
112
112
|
>>> extract_escape_hatches("")
|
|
113
113
|
[]
|
|
114
114
|
>>> extract_escape_hatches("# @invar:allow shell_result: API boundary")
|
|
115
|
-
[('shell_result', 'API boundary')]
|
|
115
|
+
[('shell_result', 'API boundary', 1)]
|
|
116
116
|
>>> source = '''
|
|
117
117
|
... # @invar:allow rule1: same reason
|
|
118
118
|
... # @invar:allow rule2: different reason
|
|
119
119
|
... '''
|
|
120
120
|
>>> extract_escape_hatches(source)
|
|
121
|
-
[('rule1', 'same reason'), ('rule2', 'different reason')]
|
|
121
|
+
[('rule1', 'same reason', 2), ('rule2', 'different reason', 3)]
|
|
122
122
|
>>> # DX-33 Option C: Strings containing the pattern should NOT match
|
|
123
123
|
>>> extract_escape_hatches('suggestion = "# @invar:allow rule: reason"')
|
|
124
124
|
[]
|
|
125
125
|
"""
|
|
126
|
-
results: list[tuple[str, str]] = []
|
|
126
|
+
results: list[tuple[str, str, int]] = []
|
|
127
127
|
try:
|
|
128
128
|
# Use iterator-based readline to avoid io.StringIO (forbidden in Core)
|
|
129
129
|
lines = iter(source.splitlines(keepends=True))
|
|
@@ -132,10 +132,12 @@ def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
|
|
|
132
132
|
if tok.type == tokenize.COMMENT:
|
|
133
133
|
match = INVAR_ALLOW_PATTERN.search(tok.string)
|
|
134
134
|
if match:
|
|
135
|
-
|
|
135
|
+
# DX-66: tok.start[0] is the 1-based line number
|
|
136
|
+
results.append((match.group(1), match.group(2), tok.start[0]))
|
|
136
137
|
except Exception:
|
|
137
|
-
# Fall back to regex if tokenization fails
|
|
138
|
-
|
|
138
|
+
# Fall back to regex if tokenization fails - can't get line numbers
|
|
139
|
+
# Return line 0 to indicate unknown position
|
|
140
|
+
return [(r, reason, 0) for r, reason in INVAR_ALLOW_PATTERN.findall(source)]
|
|
139
141
|
return results
|
|
140
142
|
|
|
141
143
|
|
invar/core/formatter.py
CHANGED
|
@@ -235,7 +235,7 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
|
|
|
235
235
|
status = combined_status if combined_status else ("passed" if report.passed else "failed")
|
|
236
236
|
static_passed = report.errors == 0
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
result = {
|
|
239
239
|
"status": status,
|
|
240
240
|
# DX-26: Separate static results from combined status
|
|
241
241
|
"static": {
|
|
@@ -252,6 +252,26 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
|
|
|
252
252
|
},
|
|
253
253
|
"fixes": [_violation_to_fix(v) for v in report.violations],
|
|
254
254
|
}
|
|
255
|
+
# DX-61: Add suggests count if any pattern suggestions exist
|
|
256
|
+
if report.suggests > 0:
|
|
257
|
+
result["static"]["suggests"] = report.suggests
|
|
258
|
+
result["summary"]["suggests"] = report.suggests
|
|
259
|
+
# DX-66: Add escape hatch summary if any exist
|
|
260
|
+
if report.escape_hatches.count > 0:
|
|
261
|
+
result["escape_hatches"] = {
|
|
262
|
+
"count": report.escape_hatches.count,
|
|
263
|
+
"by_rule": report.escape_hatches.by_rule,
|
|
264
|
+
"details": [
|
|
265
|
+
{
|
|
266
|
+
"file": d.file,
|
|
267
|
+
"line": d.line,
|
|
268
|
+
"rule": d.rule,
|
|
269
|
+
"reason": d.reason,
|
|
270
|
+
}
|
|
271
|
+
for d in report.escape_hatches.details
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
return result
|
|
255
275
|
|
|
256
276
|
|
|
257
277
|
@post(lambda result: "file" in result and "rule" in result and "severity" in result)
|
invar/core/models.py
CHANGED
|
@@ -28,6 +28,7 @@ class Severity(str, Enum):
|
|
|
28
28
|
ERROR = "error"
|
|
29
29
|
WARNING = "warning"
|
|
30
30
|
INFO = "info" # Phase 7: For informational issues like redundant type contracts
|
|
31
|
+
SUGGEST = "suggest" # DX-61: Functional pattern suggestions
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class Contract(BaseModel):
|
|
@@ -82,6 +83,89 @@ class Violation(BaseModel):
|
|
|
82
83
|
suggestion: str | None = None
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
class EscapeHatchDetail(BaseModel):
|
|
87
|
+
"""
|
|
88
|
+
Detail of a single escape hatch (@invar:allow) marker (DX-66).
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
>>> d = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API")
|
|
92
|
+
>>> d.line
|
|
93
|
+
10
|
|
94
|
+
>>> # line=0 is valid (fallback when line number unknown)
|
|
95
|
+
>>> d0 = EscapeHatchDetail(file="test.py", line=0, rule="test", reason="fallback")
|
|
96
|
+
>>> d0.line
|
|
97
|
+
0
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
file: str
|
|
101
|
+
line: int = Field(ge=0) # 0 = fallback when line number unknown
|
|
102
|
+
rule: str
|
|
103
|
+
reason: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class EscapeHatchSummary(BaseModel):
|
|
107
|
+
"""
|
|
108
|
+
Summary of escape hatches in the codebase (DX-66).
|
|
109
|
+
|
|
110
|
+
Provides visibility into @invar:allow usage for tracking technical debt.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
>>> summary = EscapeHatchSummary()
|
|
114
|
+
>>> summary.count
|
|
115
|
+
0
|
|
116
|
+
>>> summary.by_rule
|
|
117
|
+
{}
|
|
118
|
+
>>> detail = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API boundary")
|
|
119
|
+
>>> summary.add(detail)
|
|
120
|
+
>>> summary.count
|
|
121
|
+
1
|
|
122
|
+
>>> summary.by_rule
|
|
123
|
+
{'shell_result': 1}
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
details: list[EscapeHatchDetail] = Field(default_factory=list)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
@post(lambda result: result >= 0)
|
|
130
|
+
def count(self) -> int:
|
|
131
|
+
"""
|
|
132
|
+
Total number of escape hatches.
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
>>> EscapeHatchSummary().count
|
|
136
|
+
0
|
|
137
|
+
"""
|
|
138
|
+
return len(self.details)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
@post(lambda result: all(v >= 0 for v in result.values()))
|
|
142
|
+
def by_rule(self) -> dict[str, int]:
|
|
143
|
+
"""
|
|
144
|
+
Count of escape hatches grouped by rule.
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
>>> EscapeHatchSummary().by_rule
|
|
148
|
+
{}
|
|
149
|
+
"""
|
|
150
|
+
counts: dict[str, int] = {}
|
|
151
|
+
for detail in self.details:
|
|
152
|
+
counts[detail.rule] = counts.get(detail.rule, 0) + 1
|
|
153
|
+
return counts
|
|
154
|
+
|
|
155
|
+
@pre(lambda self, detail: bool(detail.rule) and bool(detail.file))
|
|
156
|
+
def add(self, detail: EscapeHatchDetail) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Add an escape hatch detail to the summary.
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
>>> s = EscapeHatchSummary()
|
|
162
|
+
>>> s.add(EscapeHatchDetail(file="t.py", line=1, rule="r", reason="x"))
|
|
163
|
+
>>> s.count
|
|
164
|
+
1
|
|
165
|
+
"""
|
|
166
|
+
self.details.append(detail)
|
|
167
|
+
|
|
168
|
+
|
|
85
169
|
class GuardReport(BaseModel):
|
|
86
170
|
"""Complete Guard report for a project."""
|
|
87
171
|
|
|
@@ -90,9 +174,12 @@ class GuardReport(BaseModel):
|
|
|
90
174
|
errors: int = 0
|
|
91
175
|
warnings: int = 0
|
|
92
176
|
infos: int = 0 # Phase 7: Track INFO-level issues
|
|
177
|
+
suggests: int = 0 # DX-61: Track SUGGEST-level pattern suggestions
|
|
93
178
|
# P24: Contract coverage statistics (Core files only)
|
|
94
179
|
core_functions_total: int = 0
|
|
95
180
|
core_functions_with_contracts: int = 0
|
|
181
|
+
# DX-66: Escape hatch visibility
|
|
182
|
+
escape_hatches: EscapeHatchSummary = Field(default_factory=EscapeHatchSummary)
|
|
96
183
|
|
|
97
184
|
@pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
|
|
98
185
|
def add_violation(self, violation: Violation) -> None:
|
|
@@ -106,12 +193,18 @@ class GuardReport(BaseModel):
|
|
|
106
193
|
>>> report.add_violation(v)
|
|
107
194
|
>>> report.errors
|
|
108
195
|
1
|
|
196
|
+
>>> v2 = Violation(rule="pattern", severity=Severity.SUGGEST, file="x.py", message="sug")
|
|
197
|
+
>>> report.add_violation(v2)
|
|
198
|
+
>>> report.suggests
|
|
199
|
+
1
|
|
109
200
|
"""
|
|
110
201
|
self.violations.append(violation)
|
|
111
202
|
if violation.severity == Severity.ERROR:
|
|
112
203
|
self.errors += 1
|
|
113
204
|
elif violation.severity == Severity.WARNING:
|
|
114
205
|
self.warnings += 1
|
|
206
|
+
elif violation.severity == Severity.SUGGEST:
|
|
207
|
+
self.suggests += 1
|
|
115
208
|
else:
|
|
116
209
|
self.infos += 1
|
|
117
210
|
|
|
@@ -263,6 +356,11 @@ class RuleConfig(BaseModel):
|
|
|
263
356
|
timeout_crosshair: int = Field(default=300, ge=1, le=1800) # Symbolic verification total
|
|
264
357
|
timeout_crosshair_per_condition: int = Field(default=30, ge=1, le=300) # Per-contract limit
|
|
265
358
|
|
|
359
|
+
# DX-61: Pattern detection configuration
|
|
360
|
+
pattern_min_confidence: str = Field(default="medium") # low, medium, high
|
|
361
|
+
pattern_priorities: list[str] = Field(default_factory=lambda: ["P0"]) # P0, P1
|
|
362
|
+
pattern_exclude: list[str] = Field(default_factory=list) # Pattern IDs to exclude
|
|
363
|
+
|
|
266
364
|
|
|
267
365
|
# Phase 4: Perception models
|
|
268
366
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Functional Pattern Detection (DX-61).
|
|
3
|
+
|
|
4
|
+
This module provides pattern detection for suggesting functional programming
|
|
5
|
+
improvements in Python code. Guard integrates with this module to provide
|
|
6
|
+
SUGGEST-level feedback when it detects opportunities for patterns like:
|
|
7
|
+
|
|
8
|
+
P0 (Core Patterns):
|
|
9
|
+
- NewType: Semantic clarity for multiple same-type parameters
|
|
10
|
+
- Validation: Error accumulation instead of fail-fast
|
|
11
|
+
- NonEmpty: Compile-time non-empty guarantees
|
|
12
|
+
- Literal: Type-safe finite value sets
|
|
13
|
+
- ExhaustiveMatch: assert_never for enum matching
|
|
14
|
+
|
|
15
|
+
P1 (Extended Patterns - future):
|
|
16
|
+
- SmartConstructor: Validation at construction time
|
|
17
|
+
- StructuredError: Typed errors for programmatic handling
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
>>> from invar.core.patterns import detect_patterns
|
|
21
|
+
>>> source = "def f(a: str, b: str, c: str): pass"
|
|
22
|
+
>>> result = detect_patterns("test.py", source)
|
|
23
|
+
>>> result.has_suggestions
|
|
24
|
+
True
|
|
25
|
+
|
|
26
|
+
See .invar/examples/functional.py for pattern examples.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from invar.core.patterns.registry import (
|
|
30
|
+
PatternRegistry,
|
|
31
|
+
detect_patterns,
|
|
32
|
+
get_registry,
|
|
33
|
+
)
|
|
34
|
+
from invar.core.patterns.types import (
|
|
35
|
+
Confidence,
|
|
36
|
+
DetectionResult,
|
|
37
|
+
Location,
|
|
38
|
+
PatternID,
|
|
39
|
+
PatternSuggestion,
|
|
40
|
+
Priority,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Confidence",
|
|
45
|
+
"DetectionResult",
|
|
46
|
+
"Location",
|
|
47
|
+
"PatternID",
|
|
48
|
+
"PatternRegistry",
|
|
49
|
+
"PatternSuggestion",
|
|
50
|
+
"Priority",
|
|
51
|
+
"detect_patterns",
|
|
52
|
+
"get_registry",
|
|
53
|
+
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern Detector Protocol (DX-61).
|
|
3
|
+
|
|
4
|
+
Base protocol for all pattern detectors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from abc import abstractmethod
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
|
|
13
|
+
from invar.core.patterns.types import (
|
|
14
|
+
Confidence,
|
|
15
|
+
Location,
|
|
16
|
+
PatternID,
|
|
17
|
+
PatternSuggestion,
|
|
18
|
+
Priority,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PatternDetector(Protocol):
|
|
23
|
+
"""
|
|
24
|
+
Protocol for pattern detectors.
|
|
25
|
+
|
|
26
|
+
Each detector identifies opportunities for a specific functional pattern.
|
|
27
|
+
Detectors analyze AST nodes and return suggestions with confidence levels.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@abstractmethod
|
|
32
|
+
@post(lambda result: result in PatternID)
|
|
33
|
+
def pattern_id(self) -> PatternID:
|
|
34
|
+
"""Unique identifier for this pattern."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
@post(lambda result: result in Priority)
|
|
40
|
+
def priority(self) -> Priority:
|
|
41
|
+
"""Priority tier (P0 or P1)."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
@post(lambda result: len(result) > 0)
|
|
47
|
+
def description(self) -> str:
|
|
48
|
+
"""Human-readable description of the pattern."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
@post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
|
|
53
|
+
def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
|
|
54
|
+
"""
|
|
55
|
+
Analyze AST and return pattern suggestions.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tree: Parsed AST of the source file
|
|
59
|
+
file_path: Path to the source file (for location reporting)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of pattern suggestions found in the file
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseDetector:
|
|
68
|
+
"""
|
|
69
|
+
Base class with common detection utilities.
|
|
70
|
+
|
|
71
|
+
Provides helper methods for AST analysis that can be reused
|
|
72
|
+
across different pattern detectors.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@post(lambda result: all(isinstance(name, str) and name for name, _ in result))
|
|
76
|
+
def get_function_params(self, node: ast.FunctionDef) -> list[tuple[str, str | None]]:
|
|
77
|
+
"""
|
|
78
|
+
Extract parameter names and type annotations from a function.
|
|
79
|
+
|
|
80
|
+
>>> import ast
|
|
81
|
+
>>> code = "def f(a: str, b: int, c): pass"
|
|
82
|
+
>>> tree = ast.parse(code)
|
|
83
|
+
>>> func = tree.body[0]
|
|
84
|
+
>>> detector = BaseDetector()
|
|
85
|
+
>>> params = detector.get_function_params(func)
|
|
86
|
+
>>> params[0]
|
|
87
|
+
('a', 'str')
|
|
88
|
+
>>> params[1]
|
|
89
|
+
('b', 'int')
|
|
90
|
+
>>> params[2]
|
|
91
|
+
('c', None)
|
|
92
|
+
"""
|
|
93
|
+
params = []
|
|
94
|
+
for arg in node.args.args:
|
|
95
|
+
name = arg.arg
|
|
96
|
+
type_hint = None
|
|
97
|
+
if arg.annotation:
|
|
98
|
+
type_hint = self._annotation_to_str(arg.annotation)
|
|
99
|
+
params.append((name, type_hint))
|
|
100
|
+
return params
|
|
101
|
+
|
|
102
|
+
@post(lambda result: isinstance(result, str) and len(result) > 0)
|
|
103
|
+
def _annotation_to_str(self, annotation: ast.expr) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Convert an annotation AST node to string.
|
|
106
|
+
|
|
107
|
+
>>> import ast
|
|
108
|
+
>>> detector = BaseDetector()
|
|
109
|
+
>>> detector._annotation_to_str(ast.Name(id="str"))
|
|
110
|
+
'str'
|
|
111
|
+
>>> detector._annotation_to_str(ast.Constant(value="str"))
|
|
112
|
+
'str'
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(annotation, ast.Name):
|
|
115
|
+
return annotation.id
|
|
116
|
+
elif isinstance(annotation, ast.Constant):
|
|
117
|
+
return str(annotation.value)
|
|
118
|
+
elif isinstance(annotation, ast.Subscript):
|
|
119
|
+
# Handle generics like list[str]
|
|
120
|
+
base = self._annotation_to_str(annotation.value)
|
|
121
|
+
if isinstance(annotation.slice, ast.Tuple):
|
|
122
|
+
args = ", ".join(self._annotation_to_str(e) for e in annotation.slice.elts)
|
|
123
|
+
else:
|
|
124
|
+
args = self._annotation_to_str(annotation.slice)
|
|
125
|
+
return f"{base}[{args}]"
|
|
126
|
+
elif isinstance(annotation, ast.Attribute):
|
|
127
|
+
# Handle qualified names like typing.List
|
|
128
|
+
parts = []
|
|
129
|
+
node = annotation
|
|
130
|
+
while isinstance(node, ast.Attribute):
|
|
131
|
+
parts.append(node.attr)
|
|
132
|
+
node = node.value
|
|
133
|
+
if isinstance(node, ast.Name):
|
|
134
|
+
parts.append(node.id)
|
|
135
|
+
return ".".join(reversed(parts))
|
|
136
|
+
elif isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
|
|
137
|
+
# Handle X | Y union syntax
|
|
138
|
+
left = self._annotation_to_str(annotation.left)
|
|
139
|
+
right = self._annotation_to_str(annotation.right)
|
|
140
|
+
return f"{left} | {right}"
|
|
141
|
+
else:
|
|
142
|
+
# Python 3.9+ always has ast.unparse (project requires 3.11+)
|
|
143
|
+
return ast.unparse(annotation)
|
|
144
|
+
|
|
145
|
+
@pre(lambda self, params, type_name: len(type_name) > 0)
|
|
146
|
+
@post(lambda result: result >= 0)
|
|
147
|
+
def count_type_occurrences(
|
|
148
|
+
self, params: list[tuple[str, str | None]], type_name: str
|
|
149
|
+
) -> int:
|
|
150
|
+
"""
|
|
151
|
+
Count how many parameters have a specific type.
|
|
152
|
+
|
|
153
|
+
>>> detector = BaseDetector()
|
|
154
|
+
>>> params = [("a", "str"), ("b", "str"), ("c", "int")]
|
|
155
|
+
>>> detector.count_type_occurrences(params, "str")
|
|
156
|
+
2
|
|
157
|
+
"""
|
|
158
|
+
return sum(1 for _, t in params if t == type_name)
|
|
159
|
+
|
|
160
|
+
@post(lambda result: isinstance(result, bool))
|
|
161
|
+
def has_match_statement(self, node: ast.FunctionDef) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Check if function contains a match statement.
|
|
164
|
+
|
|
165
|
+
>>> import ast
|
|
166
|
+
>>> code = '''
|
|
167
|
+
... def f(x):
|
|
168
|
+
... match x:
|
|
169
|
+
... case 1: pass
|
|
170
|
+
... '''
|
|
171
|
+
>>> tree = ast.parse(code)
|
|
172
|
+
>>> func = tree.body[0]
|
|
173
|
+
>>> detector = BaseDetector()
|
|
174
|
+
>>> detector.has_match_statement(func)
|
|
175
|
+
True
|
|
176
|
+
"""
|
|
177
|
+
return any(isinstance(child, ast.Match) for child in ast.walk(node))
|
|
178
|
+
|
|
179
|
+
@post(lambda result: all(isinstance(c, str) for c in result))
|
|
180
|
+
def get_enum_cases(self, match_node: ast.Match) -> list[str]:
|
|
181
|
+
"""
|
|
182
|
+
Extract case patterns from a match statement.
|
|
183
|
+
|
|
184
|
+
>>> import ast
|
|
185
|
+
>>> code = '''
|
|
186
|
+
... match status:
|
|
187
|
+
... case Status.A: pass
|
|
188
|
+
... case Status.B: pass
|
|
189
|
+
... '''
|
|
190
|
+
>>> tree = ast.parse(code)
|
|
191
|
+
>>> match = tree.body[0]
|
|
192
|
+
>>> detector = BaseDetector()
|
|
193
|
+
>>> cases = detector.get_enum_cases(match)
|
|
194
|
+
>>> "Status.A" in cases
|
|
195
|
+
True
|
|
196
|
+
"""
|
|
197
|
+
cases = []
|
|
198
|
+
for case in match_node.cases:
|
|
199
|
+
pattern = case.pattern
|
|
200
|
+
if isinstance(pattern, ast.MatchValue):
|
|
201
|
+
cases.append(ast.unparse(pattern.value) if hasattr(ast, "unparse") else str(pattern.value))
|
|
202
|
+
elif isinstance(pattern, ast.MatchAs) and pattern.pattern is None:
|
|
203
|
+
cases.append("_") # Wildcard
|
|
204
|
+
return cases
|
|
205
|
+
|
|
206
|
+
@pre(lambda self, pattern_id, priority, file_path, line, message, current_code, suggested_pattern, confidence, reference_pattern: line > 0)
|
|
207
|
+
@post(lambda result: result.reference_file == ".invar/examples/functional.py")
|
|
208
|
+
def make_suggestion(
|
|
209
|
+
self,
|
|
210
|
+
pattern_id: PatternID,
|
|
211
|
+
priority: Priority,
|
|
212
|
+
file_path: str,
|
|
213
|
+
line: int,
|
|
214
|
+
message: str,
|
|
215
|
+
current_code: str,
|
|
216
|
+
suggested_pattern: str,
|
|
217
|
+
confidence: Confidence,
|
|
218
|
+
reference_pattern: str,
|
|
219
|
+
) -> PatternSuggestion:
|
|
220
|
+
"""
|
|
221
|
+
Create a pattern suggestion with standard reference file.
|
|
222
|
+
|
|
223
|
+
>>> from invar.core.patterns.types import PatternID, Priority, Confidence
|
|
224
|
+
>>> detector = BaseDetector()
|
|
225
|
+
>>> suggestion = detector.make_suggestion(
|
|
226
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
227
|
+
... priority=Priority.P0,
|
|
228
|
+
... file_path="test.py",
|
|
229
|
+
... line=10,
|
|
230
|
+
... message="Test message",
|
|
231
|
+
... current_code="def f(): pass",
|
|
232
|
+
... suggested_pattern="NewType",
|
|
233
|
+
... confidence=Confidence.HIGH,
|
|
234
|
+
... reference_pattern="Pattern 1: NewType",
|
|
235
|
+
... )
|
|
236
|
+
>>> suggestion.reference_file
|
|
237
|
+
'.invar/examples/functional.py'
|
|
238
|
+
"""
|
|
239
|
+
return PatternSuggestion(
|
|
240
|
+
pattern_id=pattern_id,
|
|
241
|
+
location=Location(file=file_path, line=line),
|
|
242
|
+
message=message,
|
|
243
|
+
confidence=confidence,
|
|
244
|
+
priority=priority,
|
|
245
|
+
current_code=current_code,
|
|
246
|
+
suggested_pattern=suggested_pattern,
|
|
247
|
+
reference_file=".invar/examples/functional.py",
|
|
248
|
+
reference_pattern=reference_pattern,
|
|
249
|
+
)
|