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.
- invar/__init__.py +1 -0
- invar/core/contracts.py +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
invar/shell/coverage.py
ADDED
|
@@ -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")
|
invar/shell/guard_helpers.py
CHANGED
|
@@ -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],
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
invar/shell/guard_output.py
CHANGED
|
@@ -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))
|
invar/shell/property_tests.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|