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.
Files changed (34) hide show
  1. invar/__init__.py +7 -1
  2. invar/core/entry_points.py +12 -10
  3. invar/core/formatter.py +21 -1
  4. invar/core/models.py +98 -0
  5. invar/core/patterns/__init__.py +53 -0
  6. invar/core/patterns/detector.py +249 -0
  7. invar/core/patterns/p0_exhaustive.py +207 -0
  8. invar/core/patterns/p0_literal.py +307 -0
  9. invar/core/patterns/p0_newtype.py +211 -0
  10. invar/core/patterns/p0_nonempty.py +307 -0
  11. invar/core/patterns/p0_validation.py +278 -0
  12. invar/core/patterns/registry.py +234 -0
  13. invar/core/patterns/types.py +167 -0
  14. invar/core/trivial_detection.py +189 -0
  15. invar/mcp/server.py +4 -0
  16. invar/shell/commands/guard.py +100 -8
  17. invar/shell/config.py +46 -0
  18. invar/shell/contract_coverage.py +358 -0
  19. invar/shell/guard_output.py +15 -0
  20. invar/shell/pattern_integration.py +234 -0
  21. invar/shell/testing.py +13 -2
  22. invar/templates/CLAUDE.md.template +18 -10
  23. invar/templates/config/CLAUDE.md.jinja +52 -30
  24. invar/templates/config/context.md.jinja +14 -0
  25. invar/templates/protocol/INVAR.md +1 -0
  26. invar/templates/skills/develop/SKILL.md.jinja +51 -1
  27. invar/templates/skills/review/SKILL.md.jinja +196 -31
  28. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
  29. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
  30. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
  31. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
  32. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
  33. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
  34. {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
+ }
@@ -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, not user examples)
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 "templates/examples" not in str(f)
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 corresponding skill:
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" | `/review` | Adversarial review with fix loop |
128
- | "implement", "add", "fix", "update" | `/develop` | Unless in review context |
129
- | "why", "explain", "investigate" | `/investigate` | Research mode, no code changes |
130
- | "compare", "should we", "design" | `/propose` | Decision facilitation |
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
- - "Am I in a workflow?"
134
- - "Did I invoke the correct skill?"
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-self.
157
+ This section is preserved across `invar update` and `invar dev sync`.
150
158
  ======================================================================== -->
151
159
  <!--/invar:user-->
152
160