invar-tools 1.0.0__py3-none-any.whl → 1.2.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 (57) hide show
  1. invar/core/contracts.py +75 -5
  2. invar/core/entry_points.py +294 -0
  3. invar/core/format_specs.py +196 -0
  4. invar/core/format_strategies.py +197 -0
  5. invar/core/formatter.py +27 -4
  6. invar/core/hypothesis_strategies.py +47 -5
  7. invar/core/lambda_helpers.py +1 -0
  8. invar/core/models.py +23 -17
  9. invar/core/parser.py +6 -2
  10. invar/core/property_gen.py +81 -40
  11. invar/core/purity.py +10 -4
  12. invar/core/review_trigger.py +298 -0
  13. invar/core/rule_meta.py +61 -2
  14. invar/core/rules.py +83 -19
  15. invar/core/shell_analysis.py +252 -0
  16. invar/core/shell_architecture.py +171 -0
  17. invar/core/suggestions.py +6 -0
  18. invar/core/tautology.py +1 -0
  19. invar/core/utils.py +51 -4
  20. invar/core/verification_routing.py +158 -0
  21. invar/invariant.py +1 -0
  22. invar/mcp/server.py +20 -3
  23. invar/shell/cli.py +59 -31
  24. invar/shell/config.py +259 -10
  25. invar/shell/fs.py +5 -2
  26. invar/shell/git.py +2 -0
  27. invar/shell/guard_helpers.py +78 -3
  28. invar/shell/guard_output.py +100 -24
  29. invar/shell/init_cmd.py +27 -7
  30. invar/shell/mcp_config.py +3 -0
  31. invar/shell/mutate_cmd.py +184 -0
  32. invar/shell/mutation.py +314 -0
  33. invar/shell/perception.py +2 -0
  34. invar/shell/property_tests.py +17 -2
  35. invar/shell/prove.py +35 -3
  36. invar/shell/prove_accept.py +113 -0
  37. invar/shell/prove_fallback.py +148 -46
  38. invar/shell/templates.py +34 -0
  39. invar/shell/test_cmd.py +3 -1
  40. invar/shell/testing.py +6 -17
  41. invar/shell/update_cmd.py +2 -0
  42. invar/templates/CLAUDE.md.template +65 -9
  43. invar/templates/INVAR.md +96 -23
  44. invar/templates/aider.conf.yml.template +16 -14
  45. invar/templates/commands/review.md +200 -0
  46. invar/templates/cursorrules.template +22 -13
  47. invar/templates/examples/contracts.py +3 -1
  48. invar/templates/examples/core_shell.py +3 -1
  49. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
  50. invar_tools-1.2.0.dist-info/RECORD +77 -0
  51. invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
  52. invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
  53. invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
  54. invar_tools-1.0.0.dist-info/RECORD +0 -64
  55. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  56. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
  57. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  console = Console()
20
20
 
21
21
 
22
+ # @shell_complexity: Git changed mode with file collection
22
23
  def handle_changed_mode(
23
24
  path: Path,
24
25
  ) -> Result[tuple[set[Path], list[Path]], str]:
@@ -42,6 +43,8 @@ def handle_changed_mode(
42
43
  return Success((only_files, list(only_files)))
43
44
 
44
45
 
46
+ # @shell_orchestration: Coordinates path classification and file collection
47
+ # @shell_complexity: File collection with path normalization
45
48
  def collect_files_to_check(
46
49
  path: Path, checked_files: list[Path]
47
50
  ) -> list[Path]:
@@ -77,6 +80,7 @@ def collect_files_to_check(
77
80
  return result_files
78
81
 
79
82
 
83
+ # @shell_orchestration: Coordinates doctest execution via testing module
80
84
  def run_doctests_phase(
81
85
  checked_files: list[Path], explain: bool
82
86
  ) -> tuple[bool, str]:
@@ -99,6 +103,8 @@ def run_doctests_phase(
99
103
  return False, doctest_result.failure()
100
104
 
101
105
 
106
+ # @shell_orchestration: Coordinates CrossHair verification via prove module
107
+ # @shell_complexity: CrossHair phase with conditional execution
102
108
  def run_crosshair_phase(
103
109
  path: Path,
104
110
  checked_files: list[Path],
@@ -161,6 +167,7 @@ def run_crosshair_phase(
161
167
  return False, {"status": "error", "error": crosshair_result.failure()}
162
168
 
163
169
 
170
+ # @shell_complexity: Status output with multiple phases
164
171
  def output_verification_status(
165
172
  verification_level: VerificationLevel,
166
173
  static_exit_code: int,
@@ -169,17 +176,30 @@ def output_verification_status(
169
176
  crosshair_output: dict,
170
177
  explain: bool,
171
178
  property_output: dict | None = None,
179
+ strict: bool = False,
172
180
  ) -> None:
173
181
  """Output verification status for human-readable mode.
174
182
 
175
183
  DX-19: Simplified - STANDARD runs all phases (doctests + CrossHair + Hypothesis).
184
+ DX-26: Shows combined conclusion after all phase results.
176
185
  """
177
186
  from invar.shell.testing import VerificationLevel
178
187
 
179
- # STATIC mode: no runtime tests to report
188
+ # STATIC mode: no runtime tests to report (conclusion shown by output_rich)
180
189
  if verification_level == VerificationLevel.STATIC:
181
190
  return
182
191
 
192
+ # DX-26: Extract passed status from phase outputs
193
+ crosshair_passed = True
194
+ if crosshair_output:
195
+ crosshair_status = crosshair_output.get("status", "verified")
196
+ crosshair_passed = crosshair_status in ("verified", "skipped")
197
+
198
+ property_passed = True
199
+ if property_output:
200
+ property_status = property_output.get("status", "passed")
201
+ property_passed = property_status in ("passed", "skipped")
202
+
183
203
  # STANDARD mode: report all test results
184
204
  if static_exit_code == 0:
185
205
  # Doctest results
@@ -203,7 +223,22 @@ def output_verification_status(
203
223
  else:
204
224
  console.print("[dim]⊘ Runtime tests skipped (static errors)[/dim]")
205
225
 
226
+ # DX-26: Combined conclusion after all phases
227
+ console.print("-" * 40)
228
+ all_passed = (
229
+ static_exit_code == 0
230
+ and doctest_passed
231
+ and crosshair_passed
232
+ and property_passed
233
+ )
234
+ # In strict mode, warnings also cause failure (but exit code already reflects this)
235
+ status = "passed" if all_passed else "failed"
236
+ color = "green" if all_passed else "red"
237
+ console.print(f"[{color}]Guard {status}.[/{color}]")
238
+
206
239
 
240
+ # @shell_orchestration: Coordinates shell module calls for property testing
241
+ # @shell_complexity: Property tests with result aggregation
207
242
  def run_property_tests_phase(
208
243
  checked_files: list[Path],
209
244
  doctest_passed: bool,
@@ -238,24 +273,40 @@ def run_property_tests_phase(
238
273
 
239
274
  if isinstance(result, Success):
240
275
  report = result.unwrap()
276
+ # DX-26: Build structured failures array for actionable output
277
+ failures = [
278
+ {
279
+ "function": r.function_name,
280
+ "file_path": r.file_path,
281
+ "error": r.error,
282
+ "seed": r.seed,
283
+ }
284
+ for r in report.results
285
+ if not r.passed
286
+ ]
241
287
  return report.all_passed(), {
242
288
  "status": "passed" if report.all_passed() else "failed",
243
289
  "functions_tested": report.functions_tested,
244
290
  "functions_passed": report.functions_passed,
245
291
  "functions_failed": report.functions_failed,
246
292
  "total_examples": report.total_examples,
293
+ "failures": failures, # DX-26: Structured failure info
247
294
  "errors": report.errors,
248
295
  }
249
296
 
250
297
  return False, {"status": "error", "error": result.failure()}
251
298
 
252
299
 
300
+ # @shell_complexity: Property test status formatting
253
301
  def _output_property_tests_status(
254
302
  static_exit_code: int,
255
303
  doctest_passed: bool,
256
304
  property_output: dict,
257
305
  ) -> None:
258
- """Output property tests status (DX-08)."""
306
+ """Output property tests status (DX-08, DX-26).
307
+
308
+ DX-26: Show file::function format and reproduction command for failures.
309
+ """
259
310
  if static_exit_code != 0 or not doctest_passed:
260
311
  console.print("[dim]⊘ Property tests skipped (prior failures)[/dim]")
261
312
  return
@@ -275,12 +326,36 @@ def _output_property_tests_status(
275
326
  elif status == "failed":
276
327
  failed = property_output.get("functions_failed", 0)
277
328
  console.print(f"[red]✗ Property tests failed ({failed} functions)[/red]")
329
+ # DX-26: Show actionable failure info
330
+ for failure in property_output.get("failures", [])[:5]:
331
+ file_path = failure.get("file_path", "")
332
+ func_name = failure.get("function", "unknown")
333
+ seed = failure.get("seed")
334
+ error = failure.get("error", "")
335
+
336
+ # Show file::function format
337
+ location = f"{file_path}::{func_name}" if file_path else func_name
338
+ console.print(f" [red]✗[/red] {location}")
339
+
340
+ # Show truncated error
341
+ if error:
342
+ short_error = error[:100] + "..." if len(error) > 100 else error
343
+ console.print(f" {short_error}")
344
+
345
+ # Show reproduction command with seed
346
+ if seed:
347
+ console.print(
348
+ f" [dim]Reproduce: python -c \"from hypothesis import reproduce_failure; "
349
+ f"import {func_name}\" --seed={seed}[/dim]"
350
+ )
351
+ # Fallback for errors without structured failures
278
352
  for error in property_output.get("errors", [])[:5]:
279
- console.print(f" {error}")
353
+ console.print(f" [yellow]![/yellow] {error}")
280
354
  else:
281
355
  console.print(f"[yellow]! Property tests: {status}[/yellow]")
282
356
 
283
357
 
358
+ # @shell_complexity: CrossHair status formatting
284
359
  def _output_crosshair_status(
285
360
  static_exit_code: int,
286
361
  doctest_passed: bool,
@@ -3,18 +3,77 @@ Guard output formatters.
3
3
 
4
4
  Shell module: handles output formatting for guard command.
5
5
  Extracted from cli.py to reduce file size.
6
+
7
+ DX-22: Added verification routing statistics for de-duplication.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
9
11
 
12
+ from dataclasses import dataclass
13
+
10
14
  from rich.console import Console
11
15
 
12
16
  from invar.core.formatter import format_guard_agent
13
17
  from invar.core.models import GuardReport, Severity
18
+ from invar.core.utils import get_combined_status
14
19
 
15
20
  console = Console()
16
21
 
17
22
 
23
+ @dataclass
24
+ class VerificationStats:
25
+ """
26
+ DX-22: De-duplicated verification statistics.
27
+
28
+ Tracks separate counts for CrossHair (proof) vs Hypothesis (testing)
29
+ to avoid misleading double-counting.
30
+ """
31
+
32
+ crosshair_proven: int = 0
33
+ hypothesis_tested: int = 0
34
+ doctests_passed: int = 0
35
+ routed_to_hypothesis: int = 0 # Files routed due to C extensions
36
+
37
+ @property
38
+ def total_verified(self) -> int:
39
+ """Total unique functions verified (no double-counting)."""
40
+ return self.crosshair_proven + self.hypothesis_tested
41
+
42
+ @property
43
+ def proof_coverage_pct(self) -> float:
44
+ """Percentage of verifiable code proven by CrossHair."""
45
+ total = self.crosshair_proven + self.hypothesis_tested
46
+ if total == 0:
47
+ return 0.0
48
+ return (self.crosshair_proven / total) * 100
49
+
50
+
51
+ # @shell_orchestration: Rich markup formatting tightly coupled to shell output
52
+ # @shell_complexity: Conditional formatting for each stat category
53
+ def format_verification_stats(stats: VerificationStats) -> str:
54
+ """
55
+ Format verification statistics for display.
56
+
57
+ DX-22: Shows de-duplicated counts distinguishing proof from testing.
58
+ """
59
+ lines = []
60
+ lines.append("Verification breakdown:")
61
+ if stats.crosshair_proven > 0:
62
+ lines.append(f" ✓ Proven (CrossHair): {stats.crosshair_proven} functions")
63
+ if stats.hypothesis_tested > 0:
64
+ lines.append(f" ✓ Tested (Hypothesis): {stats.hypothesis_tested} functions")
65
+ if stats.routed_to_hypothesis > 0:
66
+ lines.append(
67
+ f" [dim](C-extension routing: {stats.routed_to_hypothesis} files)[/dim]"
68
+ )
69
+ if stats.doctests_passed > 0:
70
+ lines.append(f" ✓ Doctests: {stats.doctests_passed} passed")
71
+ if stats.total_verified > 0:
72
+ lines.append(f" Proof coverage: {stats.proof_coverage_pct:.0f}%")
73
+ return "\n".join(lines)
74
+
75
+
76
+ # @shell_complexity: Context display with line range extraction
18
77
  def show_file_context(file_path: str) -> None:
19
78
  """
20
79
  Show INSPECT section for a file (Phase 9.2 P14).
@@ -47,12 +106,14 @@ def show_file_context(file_path: str) -> None:
47
106
  pass # Silently ignore errors in context display
48
107
 
49
108
 
109
+ # @shell_complexity: Rich formatting with conditional sections
50
110
  def output_rich(
51
111
  report: GuardReport,
52
112
  strict_pure: bool = False,
53
113
  changed_mode: bool = False,
54
114
  pedantic_mode: bool = False,
55
115
  explain_mode: bool = False,
116
+ static_mode: bool = False,
56
117
  ) -> None:
57
118
  """Output report using Rich formatting."""
58
119
  console.print("\n[bold]Invar Guard Report[/bold]")
@@ -60,6 +121,7 @@ def output_rich(
60
121
  mode_info = [
61
122
  m
62
123
  for m, c in [
124
+ ("static", static_mode),
63
125
  ("strict-pure", strict_pure),
64
126
  ("changed-only", changed_mode),
65
127
  ("pedantic", pedantic_mode),
@@ -174,51 +236,62 @@ def output_rich(
174
236
  "[dim]💡 Fix warnings in files you modified to improve code health.[/dim]"
175
237
  )
176
238
 
177
- console.print(
178
- f"\n[{'green' if report.passed else 'red'}]Guard {'passed' if report.passed else 'failed'}.[/]"
179
- )
180
- console.print(
181
- "\n[dim]Note: Guard performs static analysis only. "
182
- "Dynamic imports and runtime behavior are not checked.[/dim]"
183
- )
184
-
185
-
186
- def output_json(report: GuardReport) -> None:
187
- """Output report as JSON."""
188
- import json
189
-
190
- output = {
191
- "files_checked": report.files_checked,
192
- "errors": report.errors,
193
- "warnings": report.warnings,
194
- "infos": report.infos,
195
- "passed": report.passed,
196
- "violations": [v.model_dump() for v in report.violations],
197
- }
198
- console.print(json.dumps(output, indent=2))
239
+ # DX-26: Show static-only conclusion for --static mode
240
+ # Full mode shows conclusion after all phases in output_verification_status()
241
+ if static_mode:
242
+ console.print(
243
+ f"\n[{'green' if report.passed else 'red'}]Guard {'passed' if report.passed else 'failed'}.[/]"
244
+ )
245
+ console.print(
246
+ "\n[dim]Note: --static mode skips runtime tests (doctests, CrossHair, Hypothesis).[/dim]"
247
+ )
199
248
 
200
249
 
250
+ # @shell_complexity: JSON output assembly with multiple sections
201
251
  def output_agent(
202
252
  report: GuardReport,
253
+ strict: bool = False,
203
254
  doctest_passed: bool = True,
204
255
  doctest_output: str = "",
205
256
  crosshair_output: dict | None = None,
206
257
  verification_level: str = "standard",
207
258
  property_output: dict | None = None, # DX-08
259
+ routing_stats: dict | None = None, # DX-22
208
260
  ) -> None:
209
- """Output report in Agent-optimized JSON format (Phase 8.2 + DX-06 + DX-08 + DX-09).
261
+ """Output report in Agent-optimized JSON format (Phase 8.2 + DX-06 + DX-08 + DX-09 + DX-22 + DX-26).
210
262
 
211
263
  Args:
212
264
  report: Guard analysis report
265
+ strict: Whether warnings are treated as errors
213
266
  doctest_passed: Whether doctests passed
214
267
  doctest_output: Doctest stdout (only if failed)
215
268
  crosshair_output: CrossHair results dict
216
269
  verification_level: Current level (static/standard)
217
270
  property_output: Property test results dict (DX-08)
271
+ routing_stats: Smart routing statistics (DX-22)
272
+
273
+ DX-22: Adds routing stats showing CrossHair vs Hypothesis distribution.
274
+ DX-26: status now reflects ALL test phases, not just static analysis.
218
275
  """
219
276
  import json
220
277
 
221
- output = format_guard_agent(report)
278
+ # DX-26: Extract passed status from phase outputs
279
+ crosshair_passed = True
280
+ if crosshair_output:
281
+ crosshair_status = crosshair_output.get("status", "verified")
282
+ crosshair_passed = crosshair_status in ("verified", "skipped")
283
+
284
+ property_passed = True
285
+ if property_output:
286
+ property_status = property_output.get("status", "passed")
287
+ property_passed = property_status in ("passed", "skipped")
288
+
289
+ # DX-26: Calculate combined status including all test phases
290
+ combined_status = get_combined_status(
291
+ report, strict, doctest_passed, crosshair_passed, property_passed
292
+ )
293
+
294
+ output = format_guard_agent(report, combined_status=combined_status)
222
295
  # DX-09: Add verification level for Agent transparency
223
296
  output["verification_level"] = verification_level
224
297
  # DX-06: Add doctest results to agent output
@@ -232,4 +305,7 @@ def output_agent(
232
305
  # DX-08: Add property test results if available
233
306
  if property_output:
234
307
  output["property_tests"] = property_output
308
+ # DX-22: Add smart routing statistics if available
309
+ if routing_stats:
310
+ output["routing"] = routing_stats
235
311
  console.print(json.dumps(output, indent=2))
invar/shell/init_cmd.py CHANGED
@@ -24,6 +24,7 @@ from invar.shell.mcp_config import (
24
24
  from invar.shell.templates import (
25
25
  add_config,
26
26
  add_invar_reference,
27
+ copy_commands_directory,
27
28
  copy_examples_directory,
28
29
  copy_template,
29
30
  create_agent_config,
@@ -35,6 +36,7 @@ from invar.shell.templates import (
35
36
  console = Console()
36
37
 
37
38
 
39
+ # @shell_complexity: Claude init with config file detection
38
40
  def run_claude_init(path: Path) -> bool:
39
41
  """
40
42
  Run 'claude /init' to generate intelligent CLAUDE.md.
@@ -95,16 +97,27 @@ def append_invar_reference_to_claude_md(path: Path) -> bool:
95
97
 
96
98
  ## Invar Protocol
97
99
 
98
- > **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.
100
+ > **Protocol:** Follow [INVAR.md](./INVAR.md) includes Check-In, USBV workflow, and Task Completion.
99
101
 
100
- Your **first message** for any implementation task MUST include actual output from:
102
+ ### Check-In
101
103
 
102
- ```bash
103
- invar guard --changed # or: invar_guard(changed=true)
104
- invar map --top 10 # or: invar_map(top=10)
104
+ Your first message MUST display:
105
+
106
+ ```
107
+ ✓ Check-In: guard PASS | top: <entry1>, <entry2>
108
+ ```
109
+
110
+ Execute `invar guard --changed` and `invar map --top 10`, then show this one-line summary.
111
+
112
+ ### Final
113
+
114
+ Your last message MUST display:
115
+
116
+ ```
117
+ ✓ Final: guard PASS | 0 errors, 2 warnings
105
118
  ```
106
119
 
107
- **Use MCP tools if available**, otherwise use CLI commands.
120
+ Execute `invar guard` and show this one-line summary.
108
121
  """
109
122
 
110
123
  claude_md.write_text(content + invar_reference)
@@ -112,6 +125,7 @@ invar map --top 10 # or: invar_map(top=10)
112
125
  return True
113
126
 
114
127
 
128
+ # @shell_complexity: MCP config with method selection and validation
115
129
  def configure_mcp_with_method(
116
130
  path: Path, mcp_method: str | None
117
131
  ) -> None:
@@ -163,6 +177,7 @@ def show_available_mcp_methods() -> None:
163
177
  console.print(f" {marker} {method.method.value}: {method.description}")
164
178
 
165
179
 
180
+ # @shell_complexity: Project init with config detection and template setup
166
181
  def init(
167
182
  path: Path = typer.Argument(Path(), help="Project root directory"),
168
183
  claude: bool = typer.Option(
@@ -187,7 +202,9 @@ def init(
187
202
  Initialize Invar configuration in a project.
188
203
 
189
204
  Works with or without pyproject.toml:
190
- - If pyproject.toml exists: adds [tool.invar.guard] section
205
+
206
+ \b
207
+ - If pyproject.toml exists: adds tool.invar section
191
208
  - Otherwise: creates invar.toml
192
209
 
193
210
  Use --claude to run 'claude /init' first (recommended for Claude Code users).
@@ -256,6 +273,9 @@ def init(
256
273
  # Create full template with workflow enforcement (DX-17)
257
274
  create_agent_config(path, agent, console)
258
275
 
276
+ # Copy Claude commands (DX-32: /review skill with Mode Detection)
277
+ copy_commands_directory(path, console)
278
+
259
279
  # Configure MCP server (DX-16, DX-21B)
260
280
  configure_mcp_with_method(path, mcp_method)
261
281
 
invar/shell/mcp_config.py CHANGED
@@ -100,6 +100,7 @@ def detect_available_methods() -> list[McpExecConfig]:
100
100
  return methods
101
101
 
102
102
 
103
+ # @shell_orchestration: MCP method selection helper
103
104
  def get_recommended_method() -> McpExecConfig:
104
105
  """
105
106
  Get the recommended MCP execution method.
@@ -115,6 +116,7 @@ def get_recommended_method() -> McpExecConfig:
115
116
  return methods[0]
116
117
 
117
118
 
119
+ # @shell_orchestration: MCP method lookup helper
118
120
  def get_method_by_name(name: str) -> McpExecConfig | None:
119
121
  """
120
122
  Get a specific MCP method by name.
@@ -139,6 +141,7 @@ def get_method_by_name(name: str) -> McpExecConfig | None:
139
141
  return None
140
142
 
141
143
 
144
+ # @shell_orchestration: MCP configuration generator
142
145
  def generate_mcp_json(config: McpExecConfig | None = None) -> dict[str, Any]:
143
146
  """
144
147
  Generate .mcp.json content for the given configuration.
@@ -0,0 +1,184 @@
1
+ """
2
+ Mutation testing command for Invar CLI.
3
+
4
+ DX-28: `invar mutate` wraps mutmut to detect undertested code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as json_lib
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from returns.result import Failure
14
+ from rich.console import Console
15
+
16
+ from invar.shell.mutation import (
17
+ MutationResult,
18
+ check_mutmut_installed,
19
+ get_surviving_mutants,
20
+ run_mutation_test,
21
+ show_mutant,
22
+ )
23
+
24
+ console = Console()
25
+
26
+
27
+ # @shell:entry - CLI command entry point
28
+ # @invar:allow entry_point_too_thick: CLI orchestration with multiple output modes
29
+ def mutate(
30
+ target: Path = typer.Argument(
31
+ Path(),
32
+ help="File or directory to mutate",
33
+ exists=True,
34
+ ),
35
+ tests: Path = typer.Option(
36
+ None,
37
+ "--tests",
38
+ "-t",
39
+ help="Test directory (auto-detected if not specified)",
40
+ ),
41
+ timeout: int = typer.Option(
42
+ 300,
43
+ "--timeout",
44
+ help="Maximum time in seconds",
45
+ ),
46
+ show_survivors: bool = typer.Option(
47
+ False,
48
+ "--survivors",
49
+ "-s",
50
+ help="Show surviving mutants",
51
+ ),
52
+ json_output: bool = typer.Option(
53
+ False,
54
+ "--json",
55
+ help="Output as JSON",
56
+ ),
57
+ ) -> None:
58
+ """
59
+ Run mutation testing to find undertested code.
60
+
61
+ DX-28: Uses mutmut to automatically mutate code (e.g., `in` → `not in`)
62
+ and check if tests catch the mutations. Surviving mutants indicate
63
+ weak test coverage.
64
+
65
+ Examples:
66
+
67
+ invar mutate src/myapp/core/parser.py
68
+
69
+ invar mutate src/myapp --tests tests/ --timeout 600
70
+
71
+ invar mutate --survivors # Show surviving mutants from last run
72
+ """
73
+ # Check if mutmut is installed
74
+ install_check = check_mutmut_installed()
75
+ if isinstance(install_check, Failure):
76
+ if json_output:
77
+ console.print(json_lib.dumps({"error": install_check.failure()}))
78
+ else:
79
+ console.print(f"[red]Error:[/red] {install_check.failure()}")
80
+ console.print("\n[dim]Install with: pip install mutmut[/dim]")
81
+ raise typer.Exit(1)
82
+
83
+ # If just showing survivors from last run
84
+ if show_survivors:
85
+ result = get_surviving_mutants(target)
86
+ if isinstance(result, Failure):
87
+ if json_output:
88
+ console.print(json_lib.dumps({"error": result.failure()}))
89
+ else:
90
+ console.print(f"[red]Error:[/red] {result.failure()}")
91
+ raise typer.Exit(1)
92
+
93
+ survivors = result.unwrap()
94
+ if json_output:
95
+ console.print(json_lib.dumps({"survivors": survivors}))
96
+ else:
97
+ if survivors:
98
+ console.print(f"[yellow]Surviving mutants ({len(survivors)}):[/yellow]")
99
+ for s in survivors:
100
+ console.print(f" {s}")
101
+ else:
102
+ console.print("[green]No surviving mutants![/green]")
103
+ return
104
+
105
+ # Run mutation testing
106
+ if not json_output:
107
+ console.print(f"[bold]Running mutation testing on {target}...[/bold]")
108
+ console.print("[dim]This may take a while.[/dim]\n")
109
+
110
+ result = run_mutation_test(target, tests, timeout)
111
+
112
+ if isinstance(result, Failure):
113
+ if json_output:
114
+ console.print(json_lib.dumps({"error": result.failure()}))
115
+ else:
116
+ console.print(f"[red]Error:[/red] {result.failure()}")
117
+ raise typer.Exit(1)
118
+
119
+ mutation_result = result.unwrap()
120
+ _display_mutation_result(mutation_result, json_output)
121
+
122
+ # Exit with error if mutation score is too low
123
+ if not mutation_result.passed:
124
+ raise typer.Exit(1)
125
+
126
+
127
+ # @shell_complexity: Result display with dual output modes (JSON/human)
128
+ def _display_mutation_result(result: MutationResult, json_output: bool) -> None:
129
+ """Display mutation testing results."""
130
+ if json_output:
131
+ data = {
132
+ "total": result.total,
133
+ "killed": result.killed,
134
+ "survived": result.survived,
135
+ "timeout": result.timeout,
136
+ "score": round(result.score, 1),
137
+ "passed": result.passed,
138
+ "errors": result.errors,
139
+ "survivors": result.survivors,
140
+ }
141
+ console.print(json_lib.dumps(data, indent=2))
142
+ else:
143
+ # Human-readable output
144
+ score_color = "green" if result.passed else "red"
145
+
146
+ console.print("\n[bold]Mutation Testing Results[/bold]")
147
+ console.print(f" Total mutants: {result.total}")
148
+ console.print(f" [green]Killed:[/green] {result.killed}")
149
+ console.print(f" [red]Survived:[/red] {result.survived}")
150
+ if result.timeout > 0:
151
+ console.print(f" [yellow]Timeout:[/yellow] {result.timeout}")
152
+
153
+ console.print(
154
+ f"\n [{score_color}]Mutation Score: {result.score:.1f}%[/{score_color}]"
155
+ )
156
+
157
+ if result.passed:
158
+ console.print("\n[green]✓ Mutation testing passed (≥80% killed)[/green]")
159
+ else:
160
+ console.print("\n[red]✗ Mutation testing failed (<80% killed)[/red]")
161
+ console.print("[dim]Run with --survivors to see surviving mutants[/dim]")
162
+
163
+ if result.errors:
164
+ console.print("\n[yellow]Errors:[/yellow]")
165
+ for err in result.errors:
166
+ console.print(f" {err}")
167
+
168
+
169
+ # @shell:entry - CLI command for showing mutant details
170
+ def mutant_show(
171
+ mutant_id: int = typer.Argument(..., help="Mutant ID to show"),
172
+ ) -> None:
173
+ """
174
+ Show the diff for a specific mutant.
175
+
176
+ Use after `invar mutate --survivors` to investigate surviving mutants.
177
+ """
178
+ result = show_mutant(mutant_id)
179
+
180
+ if isinstance(result, Failure):
181
+ console.print(f"[red]Error:[/red] {result.failure()}")
182
+ raise typer.Exit(1)
183
+
184
+ console.print(result.unwrap())