invar-tools 1.2.0__py3-none-any.whl → 1.3.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 (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,351 @@
1
+ """
2
+ Coverage integration for Guard verification phases.
3
+
4
+ DX-37: Collect branch coverage from doctest + hypothesis phases.
5
+ Coverage.py is used for accurate tracking via sys.settrace().
6
+
7
+ Note: CrossHair uses symbolic execution (Z3 solver) in subprocess,
8
+ so coverage.py cannot track it. This is a fundamental limitation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from deal import post, pre
18
+ from returns.result import Failure, Result, Success
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterator
22
+ from pathlib import Path
23
+
24
+ from coverage import Coverage
25
+
26
+
27
+ @dataclass
28
+ class UncoveredBranch:
29
+ """A branch that was never taken during testing.
30
+
31
+ Examples:
32
+ >>> branch = UncoveredBranch(line=127, branch_type="else", context="if x > 0:")
33
+ >>> branch.line
34
+ 127
35
+ >>> branch.branch_type
36
+ 'else'
37
+ """
38
+
39
+ line: int
40
+ branch_type: str # "if", "else", "elif", "except", "for", "while"
41
+ context: str # Source line for context
42
+
43
+
44
+ @dataclass
45
+ class FileCoverage:
46
+ """Coverage data for a single file.
47
+
48
+ Examples:
49
+ >>> fc = FileCoverage(path="src/foo.py", branch_coverage=94.5)
50
+ >>> fc.branch_coverage
51
+ 94.5
52
+ >>> len(fc.uncovered_branches)
53
+ 0
54
+ """
55
+
56
+ path: str
57
+ branch_coverage: float # 0.0 to 100.0
58
+ uncovered_branches: list[UncoveredBranch] = field(default_factory=list)
59
+
60
+
61
+ @dataclass
62
+ class CoverageReport:
63
+ """Coverage data from doctest + hypothesis phases.
64
+
65
+ Examples:
66
+ >>> report = CoverageReport(overall_branch_coverage=91.2)
67
+ >>> report.phases_tracked
68
+ []
69
+ >>> report.phases_excluded
70
+ ['crosshair']
71
+ """
72
+
73
+ overall_branch_coverage: float # 0.0 to 100.0
74
+ files: dict[str, FileCoverage] = field(default_factory=dict)
75
+ phases_tracked: list[str] = field(default_factory=list)
76
+ phases_excluded: list[str] = field(default_factory=lambda: ["crosshair"])
77
+
78
+
79
+ # @shell_orchestration: Import check utility for coverage.py dependency
80
+ def _is_coverage_available() -> bool:
81
+ """Check if coverage.py is installed.
82
+
83
+ Examples:
84
+ >>> result = _is_coverage_available()
85
+ >>> isinstance(result, bool)
86
+ True
87
+ """
88
+ try:
89
+ import coverage # noqa: F401
90
+
91
+ return True
92
+ except ImportError:
93
+ return False
94
+
95
+
96
+ @pre(lambda source_dirs: len(source_dirs) >= 0)
97
+ @contextmanager
98
+ def collect_coverage(source_dirs: list[Path]) -> Iterator[Coverage]:
99
+ """Context manager for coverage collection.
100
+
101
+ Args:
102
+ source_dirs: Directories to track coverage for
103
+
104
+ Yields:
105
+ Coverage object for data extraction
106
+
107
+ Examples:
108
+ >>> from pathlib import Path
109
+ >>> # When coverage is available, yields a Coverage object
110
+ >>> # with collect_coverage([Path("src")]) as cov:
111
+ >>> # pass # Execute code to track
112
+ """
113
+ import coverage
114
+
115
+ cov = coverage.Coverage(
116
+ branch=True,
117
+ source=[str(d) for d in source_dirs] if source_dirs else None,
118
+ omit=["**/test_*", "**/*_test.py", "**/conftest.py"],
119
+ )
120
+
121
+ cov.start()
122
+ try:
123
+ yield cov
124
+ finally:
125
+ cov.stop()
126
+ cov.save()
127
+
128
+
129
+ # @shell_complexity: Coverage API interaction with multiple analysis branches
130
+ @pre(lambda cov, files: files is not None)
131
+ @post(lambda result: isinstance(result, CoverageReport))
132
+ def extract_coverage_report(cov: Coverage, files: list[Path], phase: str) -> CoverageReport:
133
+ """Extract coverage report from Coverage object.
134
+
135
+ Args:
136
+ cov: Coverage object after data collection
137
+ files: Files to extract coverage for
138
+ phase: Name of the phase ("doctest" or "hypothesis")
139
+
140
+ Returns:
141
+ CoverageReport with branch coverage data
142
+
143
+ Examples:
144
+ >>> # After running with collect_coverage:
145
+ >>> # report = extract_coverage_report(cov, [Path("src/foo.py")], "doctest")
146
+ >>> # report.phases_tracked == ["doctest"]
147
+ """
148
+ file_coverages: dict[str, FileCoverage] = {}
149
+ total_branches = 0
150
+ covered_branches = 0
151
+
152
+ # Get analysis data
153
+ for file_path in files:
154
+ str_path = str(file_path)
155
+ try:
156
+ # Trigger coverage analysis for this file
157
+ _ = cov.analysis2(str_path)
158
+
159
+ # Get branch data
160
+ branch_stats = cov._analyze(str_path)
161
+ if hasattr(branch_stats, "numbers"):
162
+ nums = branch_stats.numbers
163
+ file_total = nums.n_branches
164
+ file_covered = nums.n_branches - nums.n_missing_branches
165
+
166
+ if file_total > 0:
167
+ total_branches += file_total
168
+ covered_branches += file_covered
169
+ branch_pct = (file_covered / file_total) * 100
170
+
171
+ # Extract uncovered branches
172
+ uncovered = []
173
+ if hasattr(branch_stats, "missing_branch_arcs"):
174
+ for arc in branch_stats.missing_branch_arcs():
175
+ from_line, to_line = arc
176
+ uncovered.append(
177
+ UncoveredBranch(
178
+ line=from_line,
179
+ branch_type="branch",
180
+ context=f"line {from_line} -> {to_line}",
181
+ )
182
+ )
183
+
184
+ file_coverages[str_path] = FileCoverage(
185
+ path=str_path,
186
+ branch_coverage=round(branch_pct, 1),
187
+ uncovered_branches=uncovered[:5], # Limit to 5 per file
188
+ )
189
+ except Exception:
190
+ # File not covered or analysis failed
191
+ continue
192
+
193
+ overall = (covered_branches / total_branches * 100) if total_branches > 0 else 0.0
194
+
195
+ return CoverageReport(
196
+ overall_branch_coverage=round(overall, 1),
197
+ files=file_coverages,
198
+ phases_tracked=[phase],
199
+ )
200
+
201
+
202
+ # @shell_orchestration: Report merging coordinates data from multiple phases
203
+ # @shell_complexity: Report merging with multiple iteration paths
204
+ @pre(lambda reports: all(isinstance(r, CoverageReport) for r in reports if r is not None))
205
+ @post(lambda result: isinstance(result, CoverageReport))
206
+ def merge_coverage_reports(reports: list[CoverageReport | None]) -> CoverageReport:
207
+ """Merge coverage from multiple phases.
208
+
209
+ Union of covered lines/branches across all phases.
210
+ Only branches uncovered in ALL phases are reported as uncovered.
211
+
212
+ Args:
213
+ reports: List of CoverageReport objects (None entries are skipped)
214
+
215
+ Returns:
216
+ Merged CoverageReport
217
+
218
+ Examples:
219
+ >>> r1 = CoverageReport(overall_branch_coverage=80.0, phases_tracked=["doctest"])
220
+ >>> r2 = CoverageReport(overall_branch_coverage=70.0, phases_tracked=["hypothesis"])
221
+ >>> merged = merge_coverage_reports([r1, r2])
222
+ >>> "doctest" in merged.phases_tracked
223
+ True
224
+ >>> "hypothesis" in merged.phases_tracked
225
+ True
226
+ """
227
+ valid_reports = [r for r in reports if r is not None]
228
+
229
+ if not valid_reports:
230
+ return CoverageReport(overall_branch_coverage=0.0)
231
+
232
+ if len(valid_reports) == 1:
233
+ return valid_reports[0]
234
+
235
+ # Merge phases tracked
236
+ all_phases: list[str] = []
237
+ for r in valid_reports:
238
+ all_phases.extend(r.phases_tracked)
239
+
240
+ # Merge file coverages - take the max coverage for each file
241
+ merged_files: dict[str, FileCoverage] = {}
242
+ for r in valid_reports:
243
+ for path, fc in r.files.items():
244
+ if path not in merged_files or fc.branch_coverage > merged_files[path].branch_coverage:
245
+ merged_files[path] = fc
246
+
247
+ # Calculate overall as average of file coverages (weighted would be better but needs LOC)
248
+ if merged_files:
249
+ overall = sum(fc.branch_coverage for fc in merged_files.values()) / len(merged_files)
250
+ else:
251
+ overall = 0.0
252
+
253
+ return CoverageReport(
254
+ overall_branch_coverage=round(overall, 1),
255
+ files=merged_files,
256
+ phases_tracked=all_phases,
257
+ )
258
+
259
+
260
+ # @shell_orchestration: Format report for Rich console output
261
+ @pre(lambda report: isinstance(report, CoverageReport))
262
+ @post(lambda result: isinstance(result, str))
263
+ def format_coverage_output(report: CoverageReport) -> str:
264
+ """Format coverage report for CLI output.
265
+
266
+ Args:
267
+ report: CoverageReport to format
268
+
269
+ Returns:
270
+ Formatted string for terminal output
271
+
272
+ Examples:
273
+ >>> report = CoverageReport(overall_branch_coverage=91.2, phases_tracked=["doctest"])
274
+ >>> output = format_coverage_output(report)
275
+ >>> "91.2%" in output
276
+ True
277
+ >>> "doctest" in output
278
+ True
279
+ """
280
+ lines = [
281
+ f"Coverage Analysis ({' + '.join(report.phases_tracked)}):",
282
+ ]
283
+
284
+ # Sort files by coverage (lowest first to highlight issues)
285
+ sorted_files = sorted(report.files.items(), key=lambda x: x[1].branch_coverage)
286
+
287
+ for path, fc in sorted_files[:10]: # Limit to 10 files
288
+ uncovered_count = len(fc.uncovered_branches)
289
+ lines.append(f" {path}: {fc.branch_coverage}% branch ({uncovered_count} uncovered)")
290
+ for branch in fc.uncovered_branches[:3]: # Limit to 3 branches per file
291
+ lines.append(f" Line {branch.line}: {branch.context}")
292
+
293
+ lines.append("")
294
+ lines.append(f"Overall: {report.overall_branch_coverage}% branch coverage ({' + '.join(report.phases_tracked)})")
295
+ lines.append("")
296
+ lines.append("Note: CrossHair uses symbolic execution; coverage not applicable.")
297
+
298
+ return "\n".join(lines)
299
+
300
+
301
+ # @shell_orchestration: Format report for JSON agent output
302
+ @post(lambda result: isinstance(result, dict))
303
+ def format_coverage_json(report: CoverageReport) -> dict:
304
+ """Format coverage report for JSON output.
305
+
306
+ Args:
307
+ report: CoverageReport to format
308
+
309
+ Returns:
310
+ Dictionary for JSON serialization
311
+
312
+ Examples:
313
+ >>> report = CoverageReport(overall_branch_coverage=91.2, phases_tracked=["doctest"])
314
+ >>> data = format_coverage_json(report)
315
+ >>> data["enabled"]
316
+ True
317
+ >>> data["overall_branch_coverage"]
318
+ 91.2
319
+ """
320
+ return {
321
+ "enabled": True,
322
+ "phases_tracked": report.phases_tracked,
323
+ "phases_excluded": report.phases_excluded,
324
+ "overall_branch_coverage": report.overall_branch_coverage,
325
+ "files": [
326
+ {
327
+ "path": fc.path,
328
+ "branch_coverage": fc.branch_coverage,
329
+ "uncovered_branches": [
330
+ {"line": b.line, "type": b.branch_type, "context": b.context}
331
+ for b in fc.uncovered_branches
332
+ ],
333
+ }
334
+ for fc in report.files.values()
335
+ ],
336
+ }
337
+
338
+
339
+ def check_coverage_available() -> Result[bool, str]:
340
+ """Check if coverage.py is installed and return helpful error if not.
341
+
342
+ Returns:
343
+ Success(True) if available, Failure with install instructions if not
344
+
345
+ Examples:
346
+ >>> result = check_coverage_available()
347
+ >>> # Either Success(True) or Failure("Install coverage...")
348
+ """
349
+ if _is_coverage_available():
350
+ return Success(True)
351
+ return Failure("Install coverage for --coverage support: pip install coverage[toml]>=7.0")
@@ -82,25 +82,38 @@ def collect_files_to_check(
82
82
 
83
83
  # @shell_orchestration: Coordinates doctest execution via testing module
84
84
  def run_doctests_phase(
85
- checked_files: list[Path], explain: bool
86
- ) -> tuple[bool, str]:
85
+ checked_files: list[Path],
86
+ explain: bool,
87
+ timeout: int = 60,
88
+ collect_coverage: bool = False,
89
+ ) -> tuple[bool, str, dict | None]:
87
90
  """Run doctests on collected files.
88
91
 
89
- Returns (passed, output).
92
+ Args:
93
+ checked_files: Files to run doctests on
94
+ explain: Show verbose output
95
+ timeout: Maximum time in seconds (default: 60, from RuleConfig.timeout_doctest)
96
+ collect_coverage: DX-37: If True, collect branch coverage data
97
+
98
+ Returns (passed, output, coverage_data).
90
99
  """
91
100
  from invar.shell.testing import run_doctests_on_files
92
101
 
93
102
  if not checked_files:
94
- return True, ""
103
+ return True, "", None
95
104
 
96
- doctest_result = run_doctests_on_files(checked_files, verbose=explain)
105
+ doctest_result = run_doctests_on_files(
106
+ checked_files, verbose=explain, timeout=timeout, collect_coverage=collect_coverage
107
+ )
97
108
  if isinstance(doctest_result, Success):
98
109
  result_data = doctest_result.unwrap()
99
110
  passed = result_data.get("status") in ("passed", "skipped")
100
111
  output = result_data.get("stdout", "")
101
- return passed, output
112
+ # DX-37: Return coverage data if collected
113
+ coverage_data = {"collected": result_data.get("coverage_collected", False)}
114
+ return passed, output, coverage_data if collect_coverage else None
102
115
 
103
- return False, doctest_result.failure()
116
+ return False, doctest_result.failure(), None
104
117
 
105
118
 
106
119
  # @shell_orchestration: Coordinates CrossHair verification via prove module
@@ -111,6 +124,8 @@ def run_crosshair_phase(
111
124
  doctest_passed: bool,
112
125
  static_exit_code: int,
113
126
  changed_mode: bool = False,
127
+ timeout: int = 300,
128
+ per_condition_timeout: int = 30,
114
129
  ) -> tuple[bool, dict]:
115
130
  """Run CrossHair verification phase.
116
131
 
@@ -120,10 +135,12 @@ def run_crosshair_phase(
120
135
  doctest_passed: Whether doctests passed
121
136
  static_exit_code: Exit code from static analysis
122
137
  changed_mode: If True, only verify git-changed files (--changed flag)
138
+ timeout: Max time per file in seconds (default: 300)
139
+ per_condition_timeout: Max time per contract in seconds (default: 30)
123
140
 
124
141
  Returns (passed, output_dict).
125
142
  """
126
- from invar.shell.prove_cache import ProveCache
143
+ from invar.shell.prove.cache import ProveCache
127
144
  from invar.shell.testing import get_files_to_prove, run_crosshair_parallel
128
145
 
129
146
  # Skip if prior failures
@@ -157,6 +174,8 @@ def run_crosshair_phase(
157
174
  max_iterations=5,
158
175
  max_workers=None,
159
176
  cache=cache,
177
+ timeout=timeout,
178
+ per_condition_timeout=per_condition_timeout,
160
179
  )
161
180
 
162
181
  if isinstance(crosshair_result, Success):
@@ -244,7 +263,8 @@ def run_property_tests_phase(
244
263
  doctest_passed: bool,
245
264
  static_exit_code: int,
246
265
  max_examples: int = 100,
247
- ) -> tuple[bool, dict]:
266
+ collect_coverage: bool = False,
267
+ ) -> tuple[bool, dict, dict | None]:
248
268
  """Run property tests phase (DX-08).
249
269
 
250
270
  Args:
@@ -252,27 +272,28 @@ def run_property_tests_phase(
252
272
  doctest_passed: Whether doctests passed
253
273
  static_exit_code: Exit code from static analysis
254
274
  max_examples: Maximum Hypothesis examples per function
275
+ collect_coverage: DX-37: If True, collect branch coverage data
255
276
 
256
- Returns (passed, output_dict).
277
+ Returns (passed, output_dict, coverage_data).
257
278
  """
258
279
  from invar.shell.property_tests import run_property_tests_on_files
259
280
 
260
281
  # Skip if prior failures
261
282
  if not doctest_passed or static_exit_code != 0:
262
- return True, {"status": "skipped", "reason": "prior failures"}
283
+ return True, {"status": "skipped", "reason": "prior failures"}, None
263
284
 
264
285
  if not checked_files:
265
- return True, {"status": "skipped", "reason": "no files"}
286
+ return True, {"status": "skipped", "reason": "no files"}, None
266
287
 
267
288
  # Only test Core files (with contracts)
268
289
  core_files = [f for f in checked_files if "core" in str(f)]
269
290
  if not core_files:
270
- return True, {"status": "skipped", "reason": "no core files"}
291
+ return True, {"status": "skipped", "reason": "no core files"}, None
271
292
 
272
- result = run_property_tests_on_files(core_files, max_examples)
293
+ result = run_property_tests_on_files(core_files, max_examples, collect_coverage=collect_coverage)
273
294
 
274
295
  if isinstance(result, Success):
275
- report = result.unwrap()
296
+ report, coverage_data = result.unwrap()
276
297
  # DX-26: Build structured failures array for actionable output
277
298
  failures = [
278
299
  {
@@ -292,9 +313,9 @@ def run_property_tests_phase(
292
313
  "total_examples": report.total_examples,
293
314
  "failures": failures, # DX-26: Structured failure info
294
315
  "errors": report.errors,
295
- }
316
+ }, coverage_data
296
317
 
297
- return False, {"status": "error", "error": result.failure()}
318
+ return False, {"status": "error", "error": result.failure()}, None
298
319
 
299
320
 
300
321
  # @shell_complexity: Property test status formatting
@@ -257,8 +257,9 @@ def output_agent(
257
257
  verification_level: str = "standard",
258
258
  property_output: dict | None = None, # DX-08
259
259
  routing_stats: dict | None = None, # DX-22
260
+ coverage_data: dict | None = None, # DX-37
260
261
  ) -> None:
261
- """Output report in Agent-optimized JSON format (Phase 8.2 + DX-06 + DX-08 + DX-09 + DX-22 + DX-26).
262
+ """Output report in Agent-optimized JSON format (Phase 8.2 + DX-06 + DX-08 + DX-09 + DX-22 + DX-26 + DX-37).
262
263
 
263
264
  Args:
264
265
  report: Guard analysis report
@@ -269,9 +270,11 @@ def output_agent(
269
270
  verification_level: Current level (static/standard)
270
271
  property_output: Property test results dict (DX-08)
271
272
  routing_stats: Smart routing statistics (DX-22)
273
+ coverage_data: DX-37: Branch coverage data from doctest + hypothesis
272
274
 
273
275
  DX-22: Adds routing stats showing CrossHair vs Hypothesis distribution.
274
276
  DX-26: status now reflects ALL test phases, not just static analysis.
277
+ DX-37: Adds optional coverage data from doctest + hypothesis phases.
275
278
  """
276
279
  import json
277
280
 
@@ -308,4 +311,7 @@ def output_agent(
308
311
  # DX-22: Add smart routing statistics if available
309
312
  if routing_stats:
310
313
  output["routing"] = routing_stats
314
+ # DX-37: Add coverage data if collected
315
+ if coverage_data:
316
+ output["coverage"] = coverage_data
311
317
  console.print(json.dumps(output, indent=2))
@@ -99,11 +99,13 @@ def run_property_tests_on_file(
99
99
  return Success(report)
100
100
 
101
101
 
102
+ # @shell_complexity: Property test orchestration with optional coverage collection
102
103
  def run_property_tests_on_files(
103
104
  files: list[Path],
104
105
  max_examples: int = 100,
105
106
  verbose: bool = False,
106
- ) -> Result[PropertyTestReport, str]:
107
+ collect_coverage: bool = False,
108
+ ) -> Result[tuple[PropertyTestReport, dict | None], str]:
107
109
  """
108
110
  Run property tests on multiple files.
109
111
 
@@ -111,37 +113,71 @@ def run_property_tests_on_files(
111
113
  files: List of Python file paths
112
114
  max_examples: Maximum Hypothesis examples per function
113
115
  verbose: Show detailed output
116
+ collect_coverage: DX-37: If True, collect branch coverage data
114
117
 
115
118
  Returns:
116
- Combined PropertyTestReport
119
+ Tuple of (PropertyTestReport, coverage_data) where coverage_data is dict or None
117
120
  """
118
121
  # Check hypothesis availability first
119
122
  try:
120
123
  import hypothesis # noqa: F401
121
124
  except ImportError:
122
- return Success(PropertyTestReport(
125
+ return Success((PropertyTestReport(
123
126
  errors=["Hypothesis not installed (pip install hypothesis)"]
124
- ))
127
+ ), None))
125
128
 
126
129
  combined_report = PropertyTestReport()
127
-
128
- for file_path in files:
129
- result = run_property_tests_on_file(file_path, max_examples, verbose)
130
-
131
- if isinstance(result, Failure):
132
- combined_report.errors.append(result.failure())
133
- continue
134
-
135
- file_report = result.unwrap()
136
- combined_report.functions_tested += file_report.functions_tested
137
- combined_report.functions_passed += file_report.functions_passed
138
- combined_report.functions_failed += file_report.functions_failed
139
- combined_report.functions_skipped += file_report.functions_skipped
140
- combined_report.total_examples += file_report.total_examples
141
- combined_report.results.extend(file_report.results)
142
- combined_report.errors.extend(file_report.errors)
143
-
144
- return Success(combined_report)
130
+ coverage_data = None
131
+
132
+ # DX-37: Optional coverage collection for hypothesis tests
133
+ if collect_coverage:
134
+ try:
135
+ from invar.shell.coverage import collect_coverage as cov_ctx
136
+ from invar.shell.coverage import extract_coverage_report
137
+
138
+ source_dirs = list({f.parent for f in files})
139
+ with cov_ctx(source_dirs) as cov:
140
+ for file_path in files:
141
+ result = run_property_tests_on_file(file_path, max_examples, verbose)
142
+ _accumulate_report(combined_report, result)
143
+
144
+ # Extract coverage after all tests
145
+ coverage_report = extract_coverage_report(cov, files, "hypothesis")
146
+ coverage_data = {
147
+ "collected": True,
148
+ "overall_branch_coverage": coverage_report.overall_branch_coverage,
149
+ "files": len(coverage_report.files),
150
+ }
151
+ except ImportError:
152
+ # coverage not installed, run without it
153
+ for file_path in files:
154
+ result = run_property_tests_on_file(file_path, max_examples, verbose)
155
+ _accumulate_report(combined_report, result)
156
+ else:
157
+ for file_path in files:
158
+ result = run_property_tests_on_file(file_path, max_examples, verbose)
159
+ _accumulate_report(combined_report, result)
160
+
161
+ return Success((combined_report, coverage_data))
162
+
163
+
164
+ def _accumulate_report(
165
+ combined_report: PropertyTestReport,
166
+ result: Result[PropertyTestReport, str],
167
+ ) -> None:
168
+ """Accumulate a file result into the combined report."""
169
+ if isinstance(result, Failure):
170
+ combined_report.errors.append(result.failure())
171
+ return
172
+
173
+ file_report = result.unwrap()
174
+ combined_report.functions_tested += file_report.functions_tested
175
+ combined_report.functions_passed += file_report.functions_passed
176
+ combined_report.functions_failed += file_report.functions_failed
177
+ combined_report.functions_skipped += file_report.functions_skipped
178
+ combined_report.total_examples += file_report.total_examples
179
+ combined_report.results.extend(file_report.results)
180
+ combined_report.errors.extend(file_report.errors)
145
181
 
146
182
 
147
183
  def _import_module_from_path(file_path: Path) -> object | None:
@@ -0,0 +1,9 @@
1
+ """
2
+ Verification execution module.
3
+
4
+ This module contains:
5
+ - crosshair: CrossHair symbolic execution
6
+ - hypothesis: Hypothesis property testing fallback
7
+ - cache: Verification result caching
8
+ - accept: Accept mechanism for known violations
9
+ """