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.
- 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/templates.py +2 -2
- 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.3.3.dist-info → invar_tools-1.5.0.dist-info}/METADATA +24 -15
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/RECORD +29 -17
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/entry_points.txt +1 -0
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.3.3.dist-info → invar_tools-1.5.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Contract coverage analysis for DX-63 - Shell layer.
|
|
2
|
+
|
|
3
|
+
I/O operations for checking contract coverage, detecting batch creation,
|
|
4
|
+
and formatting reports. Different from coverage.py which handles branch coverage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path # noqa: TC003 - used at runtime
|
|
12
|
+
|
|
13
|
+
from returns.result import Failure, Result, Success
|
|
14
|
+
|
|
15
|
+
from invar.core.trivial_detection import (
|
|
16
|
+
TrivialContract,
|
|
17
|
+
analyze_contracts_in_source,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BatchWarning:
|
|
23
|
+
"""Warning for batch file creation without contracts.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> bw = BatchWarning(
|
|
27
|
+
... file_count=5,
|
|
28
|
+
... files=[("src/a.py", 3, 0), ("src/b.py", 4, 1)],
|
|
29
|
+
... message="Multiple new files with low coverage"
|
|
30
|
+
... )
|
|
31
|
+
>>> bw.file_count
|
|
32
|
+
5
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
file_count: int
|
|
36
|
+
files: list[tuple[str, int, int]] # (file, total_funcs, with_contracts)
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ContractCoverageReport:
|
|
42
|
+
"""Contract coverage check result.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> report = ContractCoverageReport(
|
|
46
|
+
... files_checked=3,
|
|
47
|
+
... total_functions=10,
|
|
48
|
+
... functions_with_contracts=8,
|
|
49
|
+
... trivial_contracts=[],
|
|
50
|
+
... batch_warning=None
|
|
51
|
+
... )
|
|
52
|
+
>>> report.coverage_pct
|
|
53
|
+
80
|
|
54
|
+
>>> report.ready_for_build
|
|
55
|
+
True
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
files_checked: int = 0
|
|
59
|
+
total_functions: int = 0
|
|
60
|
+
functions_with_contracts: int = 0
|
|
61
|
+
trivial_contracts: list[TrivialContract] = field(default_factory=list)
|
|
62
|
+
batch_warning: BatchWarning | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def coverage_pct(self) -> int:
|
|
66
|
+
"""Get coverage percentage.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
>>> ContractCoverageReport(total_functions=10, functions_with_contracts=7).coverage_pct
|
|
70
|
+
70
|
|
71
|
+
>>> ContractCoverageReport(total_functions=0, functions_with_contracts=0).coverage_pct
|
|
72
|
+
100
|
|
73
|
+
"""
|
|
74
|
+
if self.total_functions == 0:
|
|
75
|
+
return 100
|
|
76
|
+
return int(100 * self.functions_with_contracts / self.total_functions)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def trivial_count(self) -> int:
|
|
80
|
+
"""Count of trivial contracts."""
|
|
81
|
+
return len(self.trivial_contracts)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def ready_for_build(self) -> bool:
|
|
85
|
+
"""Check if ready for BUILD phase.
|
|
86
|
+
|
|
87
|
+
Ready when:
|
|
88
|
+
- Coverage >= 80%
|
|
89
|
+
- No trivial contracts
|
|
90
|
+
- No batch warning
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
>>> ContractCoverageReport(total_functions=10, functions_with_contracts=10).ready_for_build
|
|
94
|
+
True
|
|
95
|
+
>>> ContractCoverageReport(total_functions=10, functions_with_contracts=5).ready_for_build
|
|
96
|
+
False
|
|
97
|
+
"""
|
|
98
|
+
if self.trivial_contracts:
|
|
99
|
+
return False
|
|
100
|
+
if self.batch_warning:
|
|
101
|
+
return False
|
|
102
|
+
return self.coverage_pct >= 80
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def count_contracts_in_file(
|
|
106
|
+
file_path: Path,
|
|
107
|
+
) -> Result[tuple[int, int, list[TrivialContract]], str]:
|
|
108
|
+
"""Count functions and contracts in a file.
|
|
109
|
+
|
|
110
|
+
Returns: Result containing (total_functions, functions_with_contracts, trivial_contracts)
|
|
111
|
+
"""
|
|
112
|
+
if not file_path.exists():
|
|
113
|
+
return Failure(f"File not found: {file_path}")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
source = file_path.read_text(encoding="utf-8")
|
|
117
|
+
except UnicodeDecodeError as e:
|
|
118
|
+
return Failure(f"Encoding error: {e}")
|
|
119
|
+
|
|
120
|
+
result = analyze_contracts_in_source(source, str(file_path))
|
|
121
|
+
return Success(result)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_changed_python_files(path: Path) -> Result[list[Path], str]:
|
|
125
|
+
"""Get Python files changed in git."""
|
|
126
|
+
try:
|
|
127
|
+
result = subprocess.run(
|
|
128
|
+
["git", "status", "--porcelain"],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
cwd=path,
|
|
132
|
+
check=False,
|
|
133
|
+
)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
return Failure(f"Git error: {result.stderr}")
|
|
136
|
+
|
|
137
|
+
files = []
|
|
138
|
+
for line in result.stdout.strip().split("\n"):
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
# Status is first 2 chars, then space, then filename
|
|
142
|
+
status = line[:2]
|
|
143
|
+
filename = line[3:].strip()
|
|
144
|
+
|
|
145
|
+
# Include new, modified, or untracked Python files
|
|
146
|
+
if filename.endswith(".py") and status.strip():
|
|
147
|
+
file_path = path / filename
|
|
148
|
+
if file_path.exists():
|
|
149
|
+
files.append(file_path)
|
|
150
|
+
|
|
151
|
+
return Success(files)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
return Failure("Git not found")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def calculate_contract_coverage(
|
|
157
|
+
path: Path, changed_only: bool = False
|
|
158
|
+
) -> Result[ContractCoverageReport, str]:
|
|
159
|
+
"""Calculate contract coverage for a path (file or directory)."""
|
|
160
|
+
report = ContractCoverageReport()
|
|
161
|
+
|
|
162
|
+
if path.is_file():
|
|
163
|
+
if path.suffix == ".py":
|
|
164
|
+
result = count_contracts_in_file(path)
|
|
165
|
+
if isinstance(result, Failure):
|
|
166
|
+
return result
|
|
167
|
+
total, with_contracts, trivials = result.unwrap()
|
|
168
|
+
report.files_checked = 1
|
|
169
|
+
report.total_functions = total
|
|
170
|
+
report.functions_with_contracts = with_contracts
|
|
171
|
+
report.trivial_contracts = trivials
|
|
172
|
+
else:
|
|
173
|
+
# Get files to check
|
|
174
|
+
if changed_only:
|
|
175
|
+
files_result = get_changed_python_files(path)
|
|
176
|
+
if isinstance(files_result, Failure):
|
|
177
|
+
return files_result
|
|
178
|
+
files = files_result.unwrap()
|
|
179
|
+
else:
|
|
180
|
+
files = list(path.rglob("*.py"))
|
|
181
|
+
|
|
182
|
+
# Filter out test files, __pycache__, etc.
|
|
183
|
+
files = [
|
|
184
|
+
f
|
|
185
|
+
for f in files
|
|
186
|
+
if "__pycache__" not in str(f)
|
|
187
|
+
and "test_" not in f.name
|
|
188
|
+
and "_test.py" not in f.name
|
|
189
|
+
and ".venv" not in str(f)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
for file_path in files:
|
|
193
|
+
result = count_contracts_in_file(file_path)
|
|
194
|
+
if isinstance(result, Failure):
|
|
195
|
+
continue # Skip files with errors
|
|
196
|
+
total, with_contracts, trivials = result.unwrap()
|
|
197
|
+
report.files_checked += 1
|
|
198
|
+
report.total_functions += total
|
|
199
|
+
report.functions_with_contracts += with_contracts
|
|
200
|
+
report.trivial_contracts.extend(trivials)
|
|
201
|
+
|
|
202
|
+
# Check for batch creation
|
|
203
|
+
batch_result = detect_batch_creation(path)
|
|
204
|
+
if isinstance(batch_result, Success):
|
|
205
|
+
report.batch_warning = batch_result.unwrap()
|
|
206
|
+
|
|
207
|
+
return Success(report)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def detect_batch_creation(
|
|
211
|
+
path: Path, threshold: int = 3
|
|
212
|
+
) -> Result[BatchWarning | None, str]:
|
|
213
|
+
"""Detect batch file creation without contracts.
|
|
214
|
+
|
|
215
|
+
Returns BatchWarning if >= threshold new/untracked files have < 50% coverage.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
["git", "status", "--porcelain"],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
cwd=path,
|
|
223
|
+
check=False,
|
|
224
|
+
)
|
|
225
|
+
if result.returncode != 0:
|
|
226
|
+
return Success(None)
|
|
227
|
+
|
|
228
|
+
uncovered_files: list[tuple[str, int, int]] = []
|
|
229
|
+
|
|
230
|
+
for line in result.stdout.strip().split("\n"):
|
|
231
|
+
if not line:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
status = line[:2]
|
|
235
|
+
filename = line[3:].strip()
|
|
236
|
+
|
|
237
|
+
# Check new/untracked Python files (status starts with ? or A)
|
|
238
|
+
if filename.endswith(".py") and status[0] in ("?", "A"):
|
|
239
|
+
file_path = path / filename
|
|
240
|
+
if file_path.exists():
|
|
241
|
+
count_result = count_contracts_in_file(file_path)
|
|
242
|
+
if isinstance(count_result, Success):
|
|
243
|
+
total, with_contracts, _ = count_result.unwrap()
|
|
244
|
+
if total > 0:
|
|
245
|
+
coverage_pct = int(100 * with_contracts / total)
|
|
246
|
+
if coverage_pct < 50:
|
|
247
|
+
uncovered_files.append(
|
|
248
|
+
(filename, total, with_contracts)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if len(uncovered_files) >= threshold:
|
|
252
|
+
return Success(
|
|
253
|
+
BatchWarning(
|
|
254
|
+
file_count=len(uncovered_files),
|
|
255
|
+
files=uncovered_files,
|
|
256
|
+
message="Multiple new files with low contract coverage",
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return Success(None)
|
|
261
|
+
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
return Success(None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# @shell_orchestration: Report formatting tightly coupled with CLI output
|
|
267
|
+
def format_contract_coverage_report(report: ContractCoverageReport) -> str:
|
|
268
|
+
"""Format coverage report for human-readable output."""
|
|
269
|
+
lines = [
|
|
270
|
+
"Contract Coverage Check",
|
|
271
|
+
"=" * 40,
|
|
272
|
+
f"Files: {report.files_checked} | Functions: {report.total_functions}",
|
|
273
|
+
"",
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
# Coverage line
|
|
277
|
+
coverage_pct = report.coverage_pct
|
|
278
|
+
if coverage_pct >= 80:
|
|
279
|
+
lines.append(
|
|
280
|
+
f"Coverage: {report.functions_with_contracts}/{report.total_functions} "
|
|
281
|
+
f"({coverage_pct}%) \u2713"
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
lines.append(
|
|
285
|
+
f"Coverage: {report.functions_with_contracts}/{report.total_functions} "
|
|
286
|
+
f"({coverage_pct}%) \u2717"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Trivial contracts
|
|
290
|
+
total = max(1, report.total_functions)
|
|
291
|
+
if report.trivial_contracts:
|
|
292
|
+
trivial_pct = int(100 * report.trivial_count / total)
|
|
293
|
+
lines.append(f"Trivial: {report.trivial_count}/{report.total_functions} ({trivial_pct}%) \u2717")
|
|
294
|
+
lines.append("")
|
|
295
|
+
lines.append("\u2717 Trivial contracts detected:")
|
|
296
|
+
for tc in report.trivial_contracts:
|
|
297
|
+
lines.append(
|
|
298
|
+
f" - {tc.file}:{tc.line} {tc.function_name} "
|
|
299
|
+
f"@{tc.contract_type}({tc.expression})"
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
lines.append(f"Trivial: 0/{report.total_functions} (0%) \u2713")
|
|
303
|
+
|
|
304
|
+
# Batch warning
|
|
305
|
+
if report.batch_warning:
|
|
306
|
+
lines.append("")
|
|
307
|
+
lines.append(
|
|
308
|
+
f"\u26a0 BATCH WARNING: {report.batch_warning.file_count} "
|
|
309
|
+
"new files with low coverage"
|
|
310
|
+
)
|
|
311
|
+
for filename, total_f, with_c in report.batch_warning.files:
|
|
312
|
+
lines.append(f" - {filename} ({with_c}/{total_f})")
|
|
313
|
+
lines.append("")
|
|
314
|
+
lines.append("Recommendation: Add contracts incrementally, one file at a time.")
|
|
315
|
+
|
|
316
|
+
# Final status
|
|
317
|
+
lines.append("")
|
|
318
|
+
if report.ready_for_build:
|
|
319
|
+
lines.append("Ready for BUILD phase.")
|
|
320
|
+
else:
|
|
321
|
+
lines.append("Not ready for BUILD phase.")
|
|
322
|
+
|
|
323
|
+
return "\n".join(lines)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# @shell_orchestration: Output formatting for CLI integration
|
|
327
|
+
def format_contract_coverage_agent(report: ContractCoverageReport) -> dict:
|
|
328
|
+
"""Format coverage report for agent JSON output."""
|
|
329
|
+
return {
|
|
330
|
+
"status": "passed" if report.ready_for_build else "failed",
|
|
331
|
+
"contract_coverage": {
|
|
332
|
+
"files_checked": report.files_checked,
|
|
333
|
+
"total_functions": report.total_functions,
|
|
334
|
+
"functions_with_contracts": report.functions_with_contracts,
|
|
335
|
+
"coverage_pct": report.coverage_pct,
|
|
336
|
+
"trivial_count": report.trivial_count,
|
|
337
|
+
"trivial_contracts": [
|
|
338
|
+
{
|
|
339
|
+
"file": tc.file,
|
|
340
|
+
"line": tc.line,
|
|
341
|
+
"function": tc.function_name,
|
|
342
|
+
"type": tc.contract_type,
|
|
343
|
+
"expression": tc.expression,
|
|
344
|
+
}
|
|
345
|
+
for tc in report.trivial_contracts
|
|
346
|
+
],
|
|
347
|
+
"batch_warning": (
|
|
348
|
+
{
|
|
349
|
+
"file_count": report.batch_warning.file_count,
|
|
350
|
+
"files": report.batch_warning.files,
|
|
351
|
+
"message": report.batch_warning.message,
|
|
352
|
+
}
|
|
353
|
+
if report.batch_warning
|
|
354
|
+
else None
|
|
355
|
+
),
|
|
356
|
+
"ready_for_build": report.ready_for_build,
|
|
357
|
+
},
|
|
358
|
+
}
|
invar/shell/guard_output.py
CHANGED
|
@@ -151,6 +151,8 @@ def output_rich(
|
|
|
151
151
|
icon = "[red]ERROR[/red]"
|
|
152
152
|
elif v.severity == Severity.WARNING:
|
|
153
153
|
icon = "[yellow]WARN[/yellow]"
|
|
154
|
+
elif v.severity == Severity.SUGGEST:
|
|
155
|
+
icon = "[magenta]SUGGEST[/magenta]" # DX-61
|
|
154
156
|
else:
|
|
155
157
|
icon = "[blue]INFO[/blue]"
|
|
156
158
|
ln = f":{v.line}" if v.line else ""
|
|
@@ -182,6 +184,9 @@ def output_rich(
|
|
|
182
184
|
)
|
|
183
185
|
if report.infos > 0:
|
|
184
186
|
summary += f"\nInfos: {report.infos}"
|
|
187
|
+
# DX-61: Show suggestions count if any
|
|
188
|
+
if report.suggests > 0:
|
|
189
|
+
summary += f"\n[magenta]Suggestions: {report.suggests}[/magenta]"
|
|
185
190
|
console.print(summary)
|
|
186
191
|
|
|
187
192
|
# P24: Contract coverage statistics (only show if core files exist)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern Detection Integration for Guard (DX-61).
|
|
3
|
+
|
|
4
|
+
Shell module: handles file I/O for pattern detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path # noqa: TC003 - used at runtime (path.exists, rglob, etc)
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
from returns.result import Failure, Result, Success
|
|
13
|
+
|
|
14
|
+
from invar.core.models import RuleConfig, Severity, Violation
|
|
15
|
+
from invar.core.patterns import PatternSuggestion, detect_patterns
|
|
16
|
+
from invar.core.patterns.types import Confidence, Priority
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# @shell_complexity: File collection and filtering requires multiple conditions
|
|
20
|
+
@pre(lambda path, files: path.exists())
|
|
21
|
+
@post(lambda result: isinstance(result, (Success, Failure)))
|
|
22
|
+
def run_pattern_detection(
|
|
23
|
+
path: Path,
|
|
24
|
+
files: list[Path] | None = None,
|
|
25
|
+
) -> Result[list[PatternSuggestion], str]:
|
|
26
|
+
"""
|
|
27
|
+
Run pattern detection on source files.
|
|
28
|
+
|
|
29
|
+
DX-61: Detects opportunities for functional patterns like
|
|
30
|
+
NewType, Validation, NonEmpty, Literal, and ExhaustiveMatch.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Project root directory
|
|
34
|
+
files: Optional list of specific files to check (None = all Python files)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Success with list of suggestions, or Failure with error message
|
|
38
|
+
|
|
39
|
+
@pre: path exists
|
|
40
|
+
@post: returns Result type
|
|
41
|
+
"""
|
|
42
|
+
suggestions: list[PatternSuggestion] = []
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# Collect Python files
|
|
46
|
+
if files:
|
|
47
|
+
python_files = [f for f in files if f.suffix == ".py"]
|
|
48
|
+
else:
|
|
49
|
+
python_files = list(path.rglob("*.py"))
|
|
50
|
+
|
|
51
|
+
# Filter out test files and hidden directories
|
|
52
|
+
python_files = [
|
|
53
|
+
f for f in python_files
|
|
54
|
+
if not any(part.startswith(".") for part in f.parts)
|
|
55
|
+
and "test" not in f.name.lower()
|
|
56
|
+
and "__pycache__" not in str(f)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
for file_path in python_files:
|
|
60
|
+
try:
|
|
61
|
+
source = file_path.read_text(encoding="utf-8")
|
|
62
|
+
result = detect_patterns(str(file_path), source)
|
|
63
|
+
suggestions.extend(result.suggestions)
|
|
64
|
+
except (OSError, UnicodeDecodeError):
|
|
65
|
+
# Skip files that can't be read
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
return Success(suggestions)
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
return Failure(f"Pattern detection failed: {e}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# @shell_orchestration: Converts pattern suggestions to violations for Guard output integration
|
|
75
|
+
@pre(lambda suggestion: suggestion is not None)
|
|
76
|
+
@post(lambda result: result.severity == Severity.SUGGEST)
|
|
77
|
+
def suggestion_to_violation(suggestion: PatternSuggestion) -> Violation:
|
|
78
|
+
"""
|
|
79
|
+
Convert pattern suggestion to Guard violation format.
|
|
80
|
+
|
|
81
|
+
DX-61: Enables pattern suggestions to appear in Guard output
|
|
82
|
+
alongside other violations.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
suggestion: Pattern suggestion from detector
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Violation with SUGGEST severity
|
|
89
|
+
|
|
90
|
+
@pre: suggestion is not None
|
|
91
|
+
@post: result has SUGGEST severity
|
|
92
|
+
|
|
93
|
+
>>> from invar.core.patterns.types import (
|
|
94
|
+
... PatternSuggestion, PatternID, Confidence, Priority, Location
|
|
95
|
+
... )
|
|
96
|
+
>>> suggestion = PatternSuggestion(
|
|
97
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
98
|
+
... location=Location(file="test.py", line=10),
|
|
99
|
+
... message="3 str params",
|
|
100
|
+
... confidence=Confidence.HIGH,
|
|
101
|
+
... priority=Priority.P0,
|
|
102
|
+
... current_code="def f(a, b, c): pass",
|
|
103
|
+
... suggested_pattern="NewType",
|
|
104
|
+
... reference_file=".invar/examples/functional.py",
|
|
105
|
+
... reference_pattern="Pattern 1",
|
|
106
|
+
... )
|
|
107
|
+
>>> violation = suggestion_to_violation(suggestion)
|
|
108
|
+
>>> violation.severity == Severity.SUGGEST
|
|
109
|
+
True
|
|
110
|
+
>>> "Pattern 1" in violation.suggestion
|
|
111
|
+
True
|
|
112
|
+
"""
|
|
113
|
+
# Build suggestion text with reference
|
|
114
|
+
suggestion_text = (
|
|
115
|
+
f"{suggestion.suggested_pattern}\n"
|
|
116
|
+
f"See: {suggestion.reference_file} - {suggestion.reference_pattern}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return Violation(
|
|
120
|
+
file=suggestion.location.file,
|
|
121
|
+
line=suggestion.location.line,
|
|
122
|
+
rule=f"pattern_{suggestion.pattern_id.value}",
|
|
123
|
+
severity=Severity.SUGGEST,
|
|
124
|
+
message=f"[{suggestion.confidence.value.upper()}] {suggestion.message}",
|
|
125
|
+
suggestion=suggestion_text,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# @shell_orchestration: Batch converts pattern suggestions to violations for Guard report
|
|
130
|
+
@pre(lambda suggestions: suggestions is not None)
|
|
131
|
+
@post(lambda result: all(v.severity == Severity.SUGGEST for v in result))
|
|
132
|
+
def suggestions_to_violations(
|
|
133
|
+
suggestions: list[PatternSuggestion],
|
|
134
|
+
) -> list[Violation]:
|
|
135
|
+
"""
|
|
136
|
+
Convert all pattern suggestions to violations.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
suggestions: List of pattern suggestions
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of violations with SUGGEST severity
|
|
143
|
+
|
|
144
|
+
@pre: suggestions is not None
|
|
145
|
+
@post: all results have SUGGEST severity
|
|
146
|
+
"""
|
|
147
|
+
return [suggestion_to_violation(s) for s in suggestions]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# DX-61: Confidence level ordering for filtering
|
|
151
|
+
_CONFIDENCE_ORDER = {
|
|
152
|
+
Confidence.LOW: 0,
|
|
153
|
+
Confidence.MEDIUM: 1,
|
|
154
|
+
Confidence.HIGH: 2,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# @shell_orchestration: Filters suggestions based on RuleConfig settings
|
|
159
|
+
# @shell_complexity: Multiple config filters (confidence, priority, exclusion) require branching
|
|
160
|
+
@pre(lambda suggestions, config: suggestions is not None and config is not None)
|
|
161
|
+
@post(lambda result: isinstance(result, list))
|
|
162
|
+
def filter_suggestions(
|
|
163
|
+
suggestions: list[PatternSuggestion],
|
|
164
|
+
config: RuleConfig,
|
|
165
|
+
) -> list[PatternSuggestion]:
|
|
166
|
+
"""
|
|
167
|
+
Filter suggestions based on configuration.
|
|
168
|
+
|
|
169
|
+
DX-61: Applies confidence, priority, and exclusion filters.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
suggestions: List of pattern suggestions
|
|
173
|
+
config: Rule configuration with pattern settings
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Filtered list of suggestions
|
|
177
|
+
|
|
178
|
+
@pre: suggestions and config are not None
|
|
179
|
+
@post: returns a list
|
|
180
|
+
|
|
181
|
+
>>> from invar.core.patterns.types import (
|
|
182
|
+
... PatternSuggestion, PatternID, Confidence, Priority, Location
|
|
183
|
+
... )
|
|
184
|
+
>>> from invar.core.models import RuleConfig
|
|
185
|
+
>>> config = RuleConfig(pattern_min_confidence="high")
|
|
186
|
+
>>> low = PatternSuggestion(
|
|
187
|
+
... pattern_id=PatternID.NEWTYPE,
|
|
188
|
+
... location=Location(file="t.py", line=1),
|
|
189
|
+
... message="msg", confidence=Confidence.LOW, priority=Priority.P0,
|
|
190
|
+
... current_code="x", suggested_pattern="X", reference_file="f.py",
|
|
191
|
+
... reference_pattern="P1"
|
|
192
|
+
... )
|
|
193
|
+
>>> high = PatternSuggestion(
|
|
194
|
+
... pattern_id=PatternID.VALIDATION,
|
|
195
|
+
... location=Location(file="t.py", line=2),
|
|
196
|
+
... message="msg", confidence=Confidence.HIGH, priority=Priority.P0,
|
|
197
|
+
... current_code="x", suggested_pattern="X", reference_file="f.py",
|
|
198
|
+
... reference_pattern="P1"
|
|
199
|
+
... )
|
|
200
|
+
>>> result = filter_suggestions([low, high], config)
|
|
201
|
+
>>> len(result)
|
|
202
|
+
1
|
|
203
|
+
>>> result[0].confidence == Confidence.HIGH
|
|
204
|
+
True
|
|
205
|
+
"""
|
|
206
|
+
# Parse minimum confidence level
|
|
207
|
+
min_conf_str = config.pattern_min_confidence.lower()
|
|
208
|
+
min_conf_map = {"low": Confidence.LOW, "medium": Confidence.MEDIUM, "high": Confidence.HIGH}
|
|
209
|
+
min_confidence = min_conf_map.get(min_conf_str, Confidence.MEDIUM)
|
|
210
|
+
min_conf_order = _CONFIDENCE_ORDER[min_confidence]
|
|
211
|
+
|
|
212
|
+
# Parse allowed priorities (only P0 and P1 exist, skip invalid values)
|
|
213
|
+
priority_map = {"P0": Priority.P0, "P1": Priority.P1}
|
|
214
|
+
allowed_priorities = {
|
|
215
|
+
priority_map[p] for p in config.pattern_priorities if p in priority_map
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Parse excluded patterns
|
|
219
|
+
excluded_patterns = set(config.pattern_exclude)
|
|
220
|
+
|
|
221
|
+
filtered = []
|
|
222
|
+
for s in suggestions:
|
|
223
|
+
# Check confidence threshold
|
|
224
|
+
if _CONFIDENCE_ORDER[s.confidence] < min_conf_order:
|
|
225
|
+
continue
|
|
226
|
+
# Check priority filter
|
|
227
|
+
if s.priority not in allowed_priorities:
|
|
228
|
+
continue
|
|
229
|
+
# Check exclusion list
|
|
230
|
+
if s.pattern_id.value in excluded_patterns:
|
|
231
|
+
continue
|
|
232
|
+
filtered.append(s)
|
|
233
|
+
|
|
234
|
+
return filtered
|
invar/shell/templates.py
CHANGED
|
@@ -434,7 +434,7 @@ Find your Python path: `python -c "import sys; print(sys.executable)"`
|
|
|
434
434
|
|
|
435
435
|
```bash
|
|
436
436
|
# Recommended: use uvx (no installation needed)
|
|
437
|
-
uvx
|
|
437
|
+
uvx invar-tools guard
|
|
438
438
|
|
|
439
439
|
# Or install globally
|
|
440
440
|
pip install invar-tools
|
|
@@ -449,7 +449,7 @@ Run the MCP server directly:
|
|
|
449
449
|
|
|
450
450
|
```bash
|
|
451
451
|
# Using uvx
|
|
452
|
-
uvx
|
|
452
|
+
uvx invar-tools mcp
|
|
453
453
|
|
|
454
454
|
# Or if installed
|
|
455
455
|
invar mcp
|
invar/shell/testing.py
CHANGED
|
@@ -136,13 +136,24 @@ def run_doctests_on_files(
|
|
|
136
136
|
return Success({"status": "skipped", "reason": "no files", "files": []})
|
|
137
137
|
|
|
138
138
|
# Filter to Python files only
|
|
139
|
-
# Exclude: conftest.py (pytest config), templates/examples/ (source templates,
|
|
139
|
+
# Exclude: conftest.py (pytest config), templates/examples/ (source templates),
|
|
140
|
+
# .invar/examples/ (documentation examples with intentionally "bad" patterns)
|
|
141
|
+
def is_excluded(f: Path) -> bool:
|
|
142
|
+
"""Check if path matches excluded patterns using path parts (not substring)."""
|
|
143
|
+
parts = f.parts
|
|
144
|
+
# Check for consecutive "templates/examples" or ".invar/examples"
|
|
145
|
+
for i in range(len(parts) - 1):
|
|
146
|
+
if (parts[i] == "templates" and parts[i + 1] == "examples") or \
|
|
147
|
+
(parts[i] == ".invar" and parts[i + 1] == "examples"):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
140
151
|
py_files = [
|
|
141
152
|
f for f in files
|
|
142
153
|
if f.suffix == ".py"
|
|
143
154
|
and f.exists()
|
|
144
155
|
and f.name != "conftest.py"
|
|
145
|
-
and
|
|
156
|
+
and not is_excluded(f)
|
|
146
157
|
]
|
|
147
158
|
if not py_files:
|
|
148
159
|
return Success({"status": "skipped", "reason": "no Python files", "files": []})
|
|
@@ -86,6 +86,7 @@ src/{project}/
|
|
|
86
86
|
| INVAR.md | Invar | No | Protocol (`invar update` to sync) |
|
|
87
87
|
| CLAUDE.md | User | Yes | Project customization (this file) |
|
|
88
88
|
| .invar/context.md | User | Yes | Project state, lessons learned |
|
|
89
|
+
| .invar/project-additions.md | User | Yes | Project rules → injected into CLAUDE.md |
|
|
89
90
|
| .invar/examples/ | Invar | No | **Must read:** Core/Shell patterns, workflow |
|
|
90
91
|
|
|
91
92
|
## Visible Workflow (DX-30)
|