invar-tools 1.4.0__py3-none-any.whl → 1.5.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/core/formatter.py +6 -1
- invar/core/models.py +13 -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 +65 -0
- invar/shell/contract_coverage.py +358 -0
- invar/shell/guard_output.py +5 -0
- invar/shell/pattern_integration.py +234 -0
- invar/shell/testing.py +13 -2
- invar/templates/config/CLAUDE.md.jinja +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +49 -0
- invar/templates/skills/review/SKILL.md.jinja +196 -31
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/METADATA +12 -8
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/RECORD +28 -16
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.5.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Trivial contract detection for DX-63.
|
|
2
|
+
|
|
3
|
+
Pure logic module - no I/O. Detects contracts that provide no real constraint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from deal import post, pre
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TrivialContract:
|
|
17
|
+
"""A trivial contract that provides no real constraint.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
>>> tc = TrivialContract(
|
|
21
|
+
... file="src/core/calc.py",
|
|
22
|
+
... line=10,
|
|
23
|
+
... function_name="process",
|
|
24
|
+
... contract_type="post",
|
|
25
|
+
... expression="lambda: True"
|
|
26
|
+
... )
|
|
27
|
+
>>> tc.contract_type
|
|
28
|
+
'post'
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
file: str
|
|
32
|
+
line: int
|
|
33
|
+
function_name: str
|
|
34
|
+
contract_type: str # "pre" or "post"
|
|
35
|
+
expression: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Patterns that match trivial contracts
|
|
39
|
+
TRIVIAL_PATTERNS: list[re.Pattern[str]] = [
|
|
40
|
+
re.compile(r"^\s*lambda\s*:\s*True\s*$"), # lambda: True
|
|
41
|
+
re.compile(r"^\s*lambda\s+\w+\s*:\s*True\s*$"), # lambda x: True
|
|
42
|
+
re.compile(r"^\s*lambda\s+[\w,\s]+:\s*True\s*$"), # lambda x, y: True
|
|
43
|
+
re.compile(r"^\s*lambda\s+\*\w+\s*:\s*True\s*$"), # lambda *args: True
|
|
44
|
+
re.compile(r"^\s*lambda\s+\*\*\w+\s*:\s*True\s*$"), # lambda **kwargs: True
|
|
45
|
+
re.compile(r"^\s*lambda\s+result\s*:\s*True\s*$"), # lambda result: True
|
|
46
|
+
re.compile(r"^\s*lambda\s+_\s*:\s*True\s*$"), # lambda _: True
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pre(lambda expression: len(expression.strip()) > 0)
|
|
51
|
+
@post(lambda result: isinstance(result, bool))
|
|
52
|
+
def is_trivial_contract(expression: str) -> bool:
|
|
53
|
+
"""Check if a contract expression is trivial (provides no constraint).
|
|
54
|
+
|
|
55
|
+
Trivial contracts always return True regardless of input, providing
|
|
56
|
+
no actual constraint on the function's behavior.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> is_trivial_contract("lambda: True")
|
|
60
|
+
True
|
|
61
|
+
>>> is_trivial_contract("lambda x: True")
|
|
62
|
+
True
|
|
63
|
+
>>> is_trivial_contract("lambda x, y: True")
|
|
64
|
+
True
|
|
65
|
+
>>> is_trivial_contract("lambda result: True")
|
|
66
|
+
True
|
|
67
|
+
>>> is_trivial_contract("lambda x: x > 0")
|
|
68
|
+
False
|
|
69
|
+
>>> is_trivial_contract("lambda items: len(items) > 0")
|
|
70
|
+
False
|
|
71
|
+
>>> is_trivial_contract("lambda result: result >= 0")
|
|
72
|
+
False
|
|
73
|
+
"""
|
|
74
|
+
expr = expression.strip()
|
|
75
|
+
return any(pattern.match(expr) for pattern in TRIVIAL_PATTERNS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
79
|
+
@post(lambda result: all(t[0] in ("pre", "post") for t in result))
|
|
80
|
+
def extract_contracts_from_decorators(
|
|
81
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
82
|
+
) -> list[tuple[str, str]]:
|
|
83
|
+
"""Extract contract expressions from function decorators.
|
|
84
|
+
|
|
85
|
+
Returns list of (contract_type, expression) tuples.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> import ast
|
|
89
|
+
>>> code = '''
|
|
90
|
+
... @pre(lambda x: x > 0)
|
|
91
|
+
... @post(lambda result: result >= 0)
|
|
92
|
+
... def calc(x): return x * 2
|
|
93
|
+
... '''
|
|
94
|
+
>>> tree = ast.parse(code)
|
|
95
|
+
>>> func = tree.body[0]
|
|
96
|
+
>>> contracts = extract_contracts_from_decorators(func)
|
|
97
|
+
>>> len(contracts)
|
|
98
|
+
2
|
|
99
|
+
>>> contracts[0][0]
|
|
100
|
+
'pre'
|
|
101
|
+
"""
|
|
102
|
+
contracts = []
|
|
103
|
+
|
|
104
|
+
for decorator in node.decorator_list:
|
|
105
|
+
if isinstance(decorator, ast.Call):
|
|
106
|
+
# Get decorator name
|
|
107
|
+
if isinstance(decorator.func, ast.Name):
|
|
108
|
+
name = decorator.func.id
|
|
109
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
110
|
+
name = decorator.func.attr
|
|
111
|
+
else:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Check if it's a contract decorator
|
|
115
|
+
if name in ("pre", "post"):
|
|
116
|
+
# Get the first argument (the lambda or condition)
|
|
117
|
+
if decorator.args:
|
|
118
|
+
arg = decorator.args[0]
|
|
119
|
+
if isinstance(arg, ast.Lambda):
|
|
120
|
+
# Convert lambda back to source
|
|
121
|
+
expr = ast.unparse(arg)
|
|
122
|
+
contracts.append((name, expr))
|
|
123
|
+
|
|
124
|
+
return contracts
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pre(lambda source, file_path: len(source) >= 0 and len(file_path) > 0)
|
|
128
|
+
@post(lambda result: result[0] >= 0 and result[1] >= 0 and result[1] <= result[0])
|
|
129
|
+
def analyze_contracts_in_source(
|
|
130
|
+
source: str, file_path: str
|
|
131
|
+
) -> tuple[int, int, list[TrivialContract]]:
|
|
132
|
+
"""Analyze contracts in Python source code.
|
|
133
|
+
|
|
134
|
+
Pure function - receives source as string, no file I/O.
|
|
135
|
+
|
|
136
|
+
Returns: (total_functions, functions_with_contracts, trivial_contracts)
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> source = '''
|
|
140
|
+
... from deal import pre, post
|
|
141
|
+
... @pre(lambda x: x > 0)
|
|
142
|
+
... def good(x): return x
|
|
143
|
+
... def no_contract(x): return x
|
|
144
|
+
... @post(lambda: True)
|
|
145
|
+
... def trivial(x): return x
|
|
146
|
+
... '''
|
|
147
|
+
>>> total, with_c, trivials = analyze_contracts_in_source(source, "test.py")
|
|
148
|
+
>>> total
|
|
149
|
+
3
|
|
150
|
+
>>> with_c
|
|
151
|
+
2
|
|
152
|
+
>>> len(trivials)
|
|
153
|
+
1
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
tree = ast.parse(source)
|
|
157
|
+
except SyntaxError:
|
|
158
|
+
return (0, 0, [])
|
|
159
|
+
|
|
160
|
+
total_functions = 0
|
|
161
|
+
functions_with_contracts = 0
|
|
162
|
+
trivial_contracts: list[TrivialContract] = []
|
|
163
|
+
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
166
|
+
# Skip private/dunder methods
|
|
167
|
+
if node.name.startswith("_"):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
total_functions += 1
|
|
171
|
+
contracts = extract_contracts_from_decorators(node)
|
|
172
|
+
|
|
173
|
+
if contracts:
|
|
174
|
+
functions_with_contracts += 1
|
|
175
|
+
|
|
176
|
+
# Check for trivial contracts
|
|
177
|
+
for contract_type, expr in contracts:
|
|
178
|
+
if is_trivial_contract(expr):
|
|
179
|
+
trivial_contracts.append(
|
|
180
|
+
TrivialContract(
|
|
181
|
+
file=file_path,
|
|
182
|
+
line=node.lineno,
|
|
183
|
+
function_name=node.name,
|
|
184
|
+
contract_type=contract_type,
|
|
185
|
+
expression=expr,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return (total_functions, functions_with_contracts, trivial_contracts)
|
invar/mcp/server.py
CHANGED
|
@@ -137,6 +137,7 @@ def _get_guard_tool() -> Tool:
|
|
|
137
137
|
"changed": {"type": "boolean", "description": "Only verify git-changed files", "default": True},
|
|
138
138
|
"strict": {"type": "boolean", "description": "Treat warnings as errors", "default": False},
|
|
139
139
|
"coverage": {"type": "boolean", "description": "DX-37: Collect branch coverage from doctest + hypothesis", "default": False},
|
|
140
|
+
"contracts_only": {"type": "boolean", "description": "DX-63: Contract coverage check only (skip tests)", "default": False},
|
|
140
141
|
},
|
|
141
142
|
},
|
|
142
143
|
)
|
|
@@ -223,6 +224,9 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
|
|
|
223
224
|
# DX-37: Optional coverage collection
|
|
224
225
|
if args.get("coverage", False):
|
|
225
226
|
cmd.append("--coverage")
|
|
227
|
+
# DX-63: Contract coverage check only
|
|
228
|
+
if args.get("contracts_only", False):
|
|
229
|
+
cmd.append("--contracts-only")
|
|
226
230
|
|
|
227
231
|
# DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
|
|
228
232
|
# No explicit flag needed
|
invar/shell/commands/guard.py
CHANGED
|
@@ -133,11 +133,19 @@ def guard(
|
|
|
133
133
|
coverage: bool = typer.Option(
|
|
134
134
|
False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
|
|
135
135
|
),
|
|
136
|
+
suggest: bool = typer.Option(
|
|
137
|
+
False, "--suggest", help="DX-61: Show functional pattern suggestions"
|
|
138
|
+
),
|
|
139
|
+
contracts_only: bool = typer.Option(
|
|
140
|
+
False, "--contracts-only", "-c", help="DX-63: Contract coverage check only"
|
|
141
|
+
),
|
|
136
142
|
) -> None:
|
|
137
143
|
"""Check project against Invar architecture rules.
|
|
138
144
|
|
|
139
145
|
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
140
146
|
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
147
|
+
Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
|
|
148
|
+
Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
|
|
141
149
|
"""
|
|
142
150
|
from invar.shell.guard_helpers import (
|
|
143
151
|
collect_files_to_check,
|
|
@@ -161,6 +169,31 @@ def guard(
|
|
|
161
169
|
if pedantic:
|
|
162
170
|
config.severity_overrides = {}
|
|
163
171
|
|
|
172
|
+
# DX-63: Contract coverage check only mode
|
|
173
|
+
if contracts_only:
|
|
174
|
+
import json
|
|
175
|
+
|
|
176
|
+
from invar.shell.contract_coverage import (
|
|
177
|
+
calculate_contract_coverage,
|
|
178
|
+
format_contract_coverage_agent,
|
|
179
|
+
format_contract_coverage_report,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
coverage_result = calculate_contract_coverage(path, changed_only=changed)
|
|
183
|
+
if isinstance(coverage_result, Failure):
|
|
184
|
+
console.print(f"[red]Error:[/red] {coverage_result.failure()}")
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
|
|
187
|
+
report_data = coverage_result.unwrap()
|
|
188
|
+
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
189
|
+
|
|
190
|
+
if use_agent_output:
|
|
191
|
+
console.print(json.dumps(format_contract_coverage_agent(report_data)))
|
|
192
|
+
else:
|
|
193
|
+
console.print(format_contract_coverage_report(report_data))
|
|
194
|
+
|
|
195
|
+
raise typer.Exit(0 if report_data.ready_for_build else 1)
|
|
196
|
+
|
|
164
197
|
# Handle --changed mode
|
|
165
198
|
only_files: set[Path] | None = None
|
|
166
199
|
checked_files: list[Path] = []
|
|
@@ -181,6 +214,25 @@ def guard(
|
|
|
181
214
|
raise typer.Exit(1)
|
|
182
215
|
report = scan_result.unwrap()
|
|
183
216
|
|
|
217
|
+
# DX-61: Run pattern detection if --suggest flag is set
|
|
218
|
+
pattern_suggestions: list = []
|
|
219
|
+
if suggest:
|
|
220
|
+
from invar.shell.pattern_integration import (
|
|
221
|
+
filter_suggestions,
|
|
222
|
+
run_pattern_detection,
|
|
223
|
+
suggestions_to_violations,
|
|
224
|
+
)
|
|
225
|
+
# Run pattern detection on checked files
|
|
226
|
+
files_to_check = list(only_files) if only_files else None
|
|
227
|
+
pattern_result = run_pattern_detection(path, files_to_check)
|
|
228
|
+
if isinstance(pattern_result, Success):
|
|
229
|
+
raw_suggestions = pattern_result.unwrap()
|
|
230
|
+
# DX-61: Apply config-based filtering
|
|
231
|
+
pattern_suggestions = filter_suggestions(raw_suggestions, config)
|
|
232
|
+
# Add suggestions to report as SUGGEST-level violations
|
|
233
|
+
for violation in suggestions_to_violations(pattern_suggestions):
|
|
234
|
+
report.add_violation(violation)
|
|
235
|
+
|
|
184
236
|
# DX-26: Simplified output mode (TTY auto-detect + --human override)
|
|
185
237
|
use_agent_output = _determine_output_mode(human, agent, json_output)
|
|
186
238
|
|
|
@@ -464,5 +516,18 @@ app.add_typer(dev_app)
|
|
|
464
516
|
app.command("sync-self", hidden=True)(sync_self)
|
|
465
517
|
|
|
466
518
|
|
|
519
|
+
# MCP server command for Claude Code integration
|
|
520
|
+
@app.command()
|
|
521
|
+
def mcp() -> None:
|
|
522
|
+
"""Start Invar MCP server for AI agent integration.
|
|
523
|
+
|
|
524
|
+
This runs the MCP server using stdio transport.
|
|
525
|
+
Used by Claude Code and other MCP-compatible AI agents.
|
|
526
|
+
"""
|
|
527
|
+
from invar.mcp.server import run_server
|
|
528
|
+
|
|
529
|
+
run_server()
|
|
530
|
+
|
|
531
|
+
|
|
467
532
|
if __name__ == "__main__":
|
|
468
533
|
app()
|