invar-tools 1.3.3__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.
@@ -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
@@ -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()