invar-tools 1.4.0__py3-none-any.whl → 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +7 -1
- invar/core/entry_points.py +12 -10
- invar/core/formatter.py +21 -1
- invar/core/models.py +98 -0
- invar/core/patterns/__init__.py +53 -0
- invar/core/patterns/detector.py +249 -0
- invar/core/patterns/p0_exhaustive.py +207 -0
- invar/core/patterns/p0_literal.py +307 -0
- invar/core/patterns/p0_newtype.py +211 -0
- invar/core/patterns/p0_nonempty.py +307 -0
- invar/core/patterns/p0_validation.py +278 -0
- invar/core/patterns/registry.py +234 -0
- invar/core/patterns/types.py +167 -0
- invar/core/trivial_detection.py +189 -0
- invar/mcp/server.py +4 -0
- invar/shell/commands/guard.py +100 -8
- invar/shell/config.py +46 -0
- invar/shell/contract_coverage.py +358 -0
- invar/shell/guard_output.py +15 -0
- invar/shell/pattern_integration.py +234 -0
- invar/shell/testing.py +13 -2
- invar/templates/CLAUDE.md.template +18 -10
- invar/templates/config/CLAUDE.md.jinja +52 -30
- invar/templates/config/context.md.jinja +14 -0
- invar/templates/protocol/INVAR.md +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +51 -1
- invar/templates/skills/review/SKILL.md.jinja +196 -31
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,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)
|
|
@@ -204,6 +209,16 @@ def output_rich(
|
|
|
204
209
|
if issue_parts:
|
|
205
210
|
console.print(f"[dim]Issues: {', '.join(issue_parts)}[/dim]")
|
|
206
211
|
|
|
212
|
+
# DX-66: Escape hatch summary (only show if any exist)
|
|
213
|
+
if report.escape_hatches.count > 0:
|
|
214
|
+
escape_count = report.escape_hatches.count
|
|
215
|
+
by_rule = report.escape_hatches.by_rule
|
|
216
|
+
rule_parts = [f"{count} {rule}" for rule, count in sorted(by_rule.items())]
|
|
217
|
+
console.print(
|
|
218
|
+
f"\n[bold]Escape hatches:[/bold] {escape_count} "
|
|
219
|
+
f"({', '.join(rule_parts)})"
|
|
220
|
+
)
|
|
221
|
+
|
|
207
222
|
# Code Health display (only when guard passes)
|
|
208
223
|
if report.passed and report.files_checked > 0:
|
|
209
224
|
# Calculate health: 100% for 0 warnings, decreases by 5% per warning, min 50%
|
|
@@ -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/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": []})
|
|
@@ -120,18 +120,26 @@ Guard triggers `review_suggested` for: security-sensitive files, escape hatches
|
|
|
120
120
|
|
|
121
121
|
## Workflow Routing (MANDATORY)
|
|
122
122
|
|
|
123
|
-
When user message contains these triggers, you MUST invoke the
|
|
123
|
+
When user message contains these triggers, you MUST use the **Skill tool** to invoke the skill:
|
|
124
124
|
|
|
125
|
-
| Trigger Words | Skill | Notes |
|
|
126
|
-
|
|
127
|
-
| "review", "review and fix" |
|
|
128
|
-
| "implement", "add", "fix", "update" |
|
|
129
|
-
| "why", "explain", "investigate" |
|
|
130
|
-
| "compare", "should we", "design" |
|
|
125
|
+
| Trigger Words | Skill Tool Call | Notes |
|
|
126
|
+
|---------------|-----------------|-------|
|
|
127
|
+
| "review", "review and fix" | `Skill(skill="review")` | Adversarial review with fix loop |
|
|
128
|
+
| "implement", "add", "fix", "update" | `Skill(skill="develop")` | Unless in review context |
|
|
129
|
+
| "why", "explain", "investigate" | `Skill(skill="investigate")` | Research mode, no code changes |
|
|
130
|
+
| "compare", "should we", "design" | `Skill(skill="propose")` | Decision facilitation |
|
|
131
|
+
|
|
132
|
+
**⚠️ CRITICAL: You must call the Skill tool, not just follow the workflow mentally.**
|
|
133
|
+
|
|
134
|
+
The Skill tool reads `.claude/skills/<skill>/SKILL.md` which contains:
|
|
135
|
+
- Detailed phase instructions (USBV breakdown)
|
|
136
|
+
- Error handling rules
|
|
137
|
+
- Timeout policies
|
|
138
|
+
- Incremental development patterns (DX-63)
|
|
131
139
|
|
|
132
140
|
**Violation check (before writing ANY code):**
|
|
133
|
-
- "
|
|
134
|
-
- "
|
|
141
|
+
- "Did I call `Skill(skill="...")`?"
|
|
142
|
+
- "Am I following the SKILL.md instructions?"
|
|
135
143
|
|
|
136
144
|
<!--/invar:managed-->
|
|
137
145
|
|
|
@@ -146,7 +154,7 @@ When user message contains these triggers, you MUST invoke the corresponding ski
|
|
|
146
154
|
<!-- ========================================================================
|
|
147
155
|
USER REGION - EDITABLE
|
|
148
156
|
Add your team conventions and project-specific rules below.
|
|
149
|
-
This section is preserved across invar update and sync
|
|
157
|
+
This section is preserved across `invar update` and `invar dev sync`.
|
|
150
158
|
======================================================================== -->
|
|
151
159
|
<!--/invar:user-->
|
|
152
160
|
|