invar-tools 1.0.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 +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- 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 +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- 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.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
invar/shell/mutation.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mutation testing integration for Invar.
|
|
3
|
+
|
|
4
|
+
DX-28: Wraps mutmut to detect undertested code by automatically
|
|
5
|
+
mutating code (e.g., `in` → `not in`) and checking if tests catch it.
|
|
6
|
+
|
|
7
|
+
Shell module: handles subprocess execution and result parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from returns.result import Failure, Result, Success
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MutationResult:
|
|
26
|
+
"""Results from mutation testing.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> result = MutationResult(total=10, killed=8, survived=2)
|
|
30
|
+
>>> result.score
|
|
31
|
+
80.0
|
|
32
|
+
>>> result.passed
|
|
33
|
+
True
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
total: int = 0
|
|
37
|
+
killed: int = 0
|
|
38
|
+
survived: int = 0
|
|
39
|
+
timeout: int = 0
|
|
40
|
+
suspicious: int = 0
|
|
41
|
+
errors: list[str] = field(default_factory=list)
|
|
42
|
+
survivors: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def score(self) -> float:
|
|
46
|
+
"""Mutation score as percentage.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
>>> MutationResult(total=10, killed=10).score
|
|
50
|
+
100.0
|
|
51
|
+
>>> MutationResult(total=0).score
|
|
52
|
+
100.0
|
|
53
|
+
"""
|
|
54
|
+
if self.total == 0:
|
|
55
|
+
return 100.0
|
|
56
|
+
return (self.killed / self.total) * 100
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def passed(self) -> bool:
|
|
60
|
+
"""Check if mutation score meets threshold (80%).
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
>>> MutationResult(total=10, killed=8).passed
|
|
64
|
+
True
|
|
65
|
+
>>> MutationResult(total=10, killed=7).passed
|
|
66
|
+
False
|
|
67
|
+
"""
|
|
68
|
+
return self.score >= 80.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_mutmut_installed() -> Result[str, str]:
|
|
72
|
+
"""
|
|
73
|
+
Check if mutmut is installed.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Success with version or Failure with install instructions.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> result = check_mutmut_installed()
|
|
80
|
+
>>> isinstance(result, (Success, Failure))
|
|
81
|
+
True
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
[sys.executable, "-m", "mutmut", "--version"],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=10,
|
|
89
|
+
)
|
|
90
|
+
if result.returncode == 0:
|
|
91
|
+
version = result.stdout.strip() or "installed"
|
|
92
|
+
return Success(version)
|
|
93
|
+
return Failure("mutmut not installed. Run: pip install mutmut")
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
return Failure("mutmut check timed out")
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return Failure(f"mutmut check failed: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# @shell_complexity: Subprocess execution with result parsing
|
|
101
|
+
def run_mutation_test(
|
|
102
|
+
target: Path,
|
|
103
|
+
tests: Path | None = None,
|
|
104
|
+
timeout: int = 300,
|
|
105
|
+
) -> Result[MutationResult, str]:
|
|
106
|
+
"""
|
|
107
|
+
Run mutation testing on a target file or directory.
|
|
108
|
+
|
|
109
|
+
Uses mutmut to generate mutations and run tests against them.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
target: File or directory to mutate
|
|
113
|
+
tests: Test file or directory (auto-detected if None)
|
|
114
|
+
timeout: Maximum time in seconds
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Success with MutationResult or Failure with error message
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> # This is a shell function - actual behavior depends on mutmut
|
|
121
|
+
>>> from pathlib import Path
|
|
122
|
+
>>> result = run_mutation_test(Path("nonexistent.py"))
|
|
123
|
+
>>> isinstance(result, Failure)
|
|
124
|
+
True
|
|
125
|
+
"""
|
|
126
|
+
# Check mutmut is installed
|
|
127
|
+
install_check = check_mutmut_installed()
|
|
128
|
+
if isinstance(install_check, Failure):
|
|
129
|
+
return install_check # type: ignore[return-value]
|
|
130
|
+
|
|
131
|
+
# Validate target
|
|
132
|
+
if not target.exists():
|
|
133
|
+
return Failure(f"Target not found: {target}")
|
|
134
|
+
|
|
135
|
+
# Build command
|
|
136
|
+
cmd = [
|
|
137
|
+
sys.executable,
|
|
138
|
+
"-m",
|
|
139
|
+
"mutmut",
|
|
140
|
+
"run",
|
|
141
|
+
"--paths-to-mutate",
|
|
142
|
+
str(target),
|
|
143
|
+
"--no-progress",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
if tests:
|
|
147
|
+
cmd.extend(["--tests-dir", str(tests)])
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
cmd,
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
cwd=target.parent if target.is_file() else target,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Parse results
|
|
159
|
+
return parse_mutmut_output(result.stdout, result.stderr, result.returncode)
|
|
160
|
+
|
|
161
|
+
except subprocess.TimeoutExpired:
|
|
162
|
+
return Failure(f"Mutation testing timed out after {timeout}s")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
return Failure(f"Mutation testing failed: {e}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# @shell_complexity: Output parsing with multiple formats
|
|
168
|
+
def parse_mutmut_output(
|
|
169
|
+
stdout: str,
|
|
170
|
+
stderr: str,
|
|
171
|
+
returncode: int,
|
|
172
|
+
) -> Result[MutationResult, str]:
|
|
173
|
+
"""
|
|
174
|
+
Parse mutmut output into MutationResult.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
stdout: Standard output from mutmut
|
|
178
|
+
stderr: Standard error from mutmut
|
|
179
|
+
returncode: Exit code from mutmut
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Success with parsed results or Failure with error
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
>>> result = parse_mutmut_output("", "", 0)
|
|
186
|
+
>>> isinstance(result, Success)
|
|
187
|
+
True
|
|
188
|
+
"""
|
|
189
|
+
result = MutationResult()
|
|
190
|
+
|
|
191
|
+
# Parse summary line: "X killed, Y survived, Z timeout"
|
|
192
|
+
for line in stdout.split("\n"):
|
|
193
|
+
line = line.strip().lower()
|
|
194
|
+
|
|
195
|
+
if "killed" in line:
|
|
196
|
+
# Try to extract numbers
|
|
197
|
+
parts = line.split()
|
|
198
|
+
for i, part in enumerate(parts):
|
|
199
|
+
if part.isdigit():
|
|
200
|
+
next_word = parts[i + 1] if i + 1 < len(parts) else ""
|
|
201
|
+
if "killed" in next_word:
|
|
202
|
+
result.killed = int(part)
|
|
203
|
+
elif "survived" in next_word:
|
|
204
|
+
result.survived = int(part)
|
|
205
|
+
elif "timeout" in next_word:
|
|
206
|
+
result.timeout = int(part)
|
|
207
|
+
|
|
208
|
+
if "mutants" in line and "total" in line:
|
|
209
|
+
parts = line.split()
|
|
210
|
+
for _, part in enumerate(parts):
|
|
211
|
+
if part.isdigit():
|
|
212
|
+
result.total = int(part)
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# If we couldn't parse, try alternative format
|
|
216
|
+
if result.total == 0:
|
|
217
|
+
# mutmut results show format: "Killed: X, Survived: Y"
|
|
218
|
+
for line in stdout.split("\n"):
|
|
219
|
+
if "Killed:" in line:
|
|
220
|
+
with contextlib.suppress(ValueError, IndexError):
|
|
221
|
+
result.killed = int(line.split("Killed:")[1].split(",")[0].strip())
|
|
222
|
+
if "Survived:" in line:
|
|
223
|
+
with contextlib.suppress(ValueError, IndexError):
|
|
224
|
+
result.survived = int(
|
|
225
|
+
line.split("Survived:")[1].split(",")[0].strip()
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
result.total = result.killed + result.survived + result.timeout
|
|
229
|
+
|
|
230
|
+
# Check for errors
|
|
231
|
+
if stderr and "error" in stderr.lower():
|
|
232
|
+
result.errors.append(stderr.strip())
|
|
233
|
+
|
|
234
|
+
return Success(result)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# @shell_complexity: Multi-step command execution
|
|
238
|
+
def get_surviving_mutants(target: Path) -> Result[list[str], str]:
|
|
239
|
+
"""
|
|
240
|
+
Get list of surviving mutants from last run.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
target: Target that was mutated
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Success with list of survivor descriptions or Failure
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
>>> from pathlib import Path
|
|
250
|
+
>>> result = get_surviving_mutants(Path("."))
|
|
251
|
+
>>> isinstance(result, (Success, Failure))
|
|
252
|
+
True
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
[sys.executable, "-m", "mutmut", "results"],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=30,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
survivors = []
|
|
263
|
+
in_survivors = False
|
|
264
|
+
|
|
265
|
+
for line in result.stdout.split("\n"):
|
|
266
|
+
if "Survived" in line:
|
|
267
|
+
in_survivors = True
|
|
268
|
+
continue
|
|
269
|
+
if in_survivors and line.strip():
|
|
270
|
+
if line.startswith(" "):
|
|
271
|
+
survivors.append(line.strip())
|
|
272
|
+
elif not line.startswith(" "):
|
|
273
|
+
in_survivors = False
|
|
274
|
+
|
|
275
|
+
return Success(survivors)
|
|
276
|
+
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
return Failure("Results query timed out")
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return Failure(f"Failed to get results: {e}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# @shell_orchestration: Show mutant diff for investigation
|
|
284
|
+
def show_mutant(mutant_id: int) -> Result[str, str]:
|
|
285
|
+
"""
|
|
286
|
+
Show the diff for a specific mutant.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
mutant_id: The mutant ID to show
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Success with diff output or Failure
|
|
293
|
+
|
|
294
|
+
Examples:
|
|
295
|
+
>>> result = show_mutant(1)
|
|
296
|
+
>>> isinstance(result, (Success, Failure))
|
|
297
|
+
True
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
[sys.executable, "-m", "mutmut", "show", str(mutant_id)],
|
|
302
|
+
capture_output=True,
|
|
303
|
+
text=True,
|
|
304
|
+
timeout=10,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if result.returncode == 0:
|
|
308
|
+
return Success(result.stdout)
|
|
309
|
+
return Failure(f"Mutant {mutant_id} not found")
|
|
310
|
+
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
return Failure("Show mutant timed out")
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return Failure(f"Failed to show mutant: {e}")
|
invar/shell/property_tests.py
CHANGED
|
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
|
|
26
26
|
console = Console()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
# @shell_complexity: Property test orchestration with module import
|
|
29
30
|
def run_property_tests_on_file(
|
|
30
31
|
file_path: Path,
|
|
31
32
|
max_examples: int = 100,
|
|
@@ -72,6 +73,7 @@ def run_property_tests_on_file(
|
|
|
72
73
|
|
|
73
74
|
# Run tests on each contracted function
|
|
74
75
|
report = PropertyTestReport()
|
|
76
|
+
file_path_str = str(file_path) # DX-26: For actionable output
|
|
75
77
|
|
|
76
78
|
for func_info in contracted:
|
|
77
79
|
func_name = func_info["name"]
|
|
@@ -83,6 +85,8 @@ def run_property_tests_on_file(
|
|
|
83
85
|
|
|
84
86
|
# Run property test
|
|
85
87
|
result = run_property_test(func, max_examples)
|
|
88
|
+
# DX-26: Set file_path for actionable failure output
|
|
89
|
+
result.file_path = file_path_str
|
|
86
90
|
report.results.append(result)
|
|
87
91
|
report.functions_tested += 1
|
|
88
92
|
report.total_examples += result.examples_run
|
|
@@ -95,11 +99,13 @@ def run_property_tests_on_file(
|
|
|
95
99
|
return Success(report)
|
|
96
100
|
|
|
97
101
|
|
|
102
|
+
# @shell_complexity: Property test orchestration with optional coverage collection
|
|
98
103
|
def run_property_tests_on_files(
|
|
99
104
|
files: list[Path],
|
|
100
105
|
max_examples: int = 100,
|
|
101
106
|
verbose: bool = False,
|
|
102
|
-
|
|
107
|
+
collect_coverage: bool = False,
|
|
108
|
+
) -> Result[tuple[PropertyTestReport, dict | None], str]:
|
|
103
109
|
"""
|
|
104
110
|
Run property tests on multiple files.
|
|
105
111
|
|
|
@@ -107,37 +113,71 @@ def run_property_tests_on_files(
|
|
|
107
113
|
files: List of Python file paths
|
|
108
114
|
max_examples: Maximum Hypothesis examples per function
|
|
109
115
|
verbose: Show detailed output
|
|
116
|
+
collect_coverage: DX-37: If True, collect branch coverage data
|
|
110
117
|
|
|
111
118
|
Returns:
|
|
112
|
-
|
|
119
|
+
Tuple of (PropertyTestReport, coverage_data) where coverage_data is dict or None
|
|
113
120
|
"""
|
|
114
121
|
# Check hypothesis availability first
|
|
115
122
|
try:
|
|
116
123
|
import hypothesis # noqa: F401
|
|
117
124
|
except ImportError:
|
|
118
|
-
return Success(PropertyTestReport(
|
|
125
|
+
return Success((PropertyTestReport(
|
|
119
126
|
errors=["Hypothesis not installed (pip install hypothesis)"]
|
|
120
|
-
))
|
|
127
|
+
), None))
|
|
121
128
|
|
|
122
129
|
combined_report = PropertyTestReport()
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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)
|
|
141
181
|
|
|
142
182
|
|
|
143
183
|
def _import_module_from_path(file_path: Path) -> object | None:
|
|
@@ -163,6 +203,8 @@ def _import_module_from_path(file_path: Path) -> object | None:
|
|
|
163
203
|
return None
|
|
164
204
|
|
|
165
205
|
|
|
206
|
+
# @shell_orchestration: Formatting helper tightly coupled to CLI output
|
|
207
|
+
# @shell_complexity: Report formatting with JSON/rich output modes
|
|
166
208
|
def format_property_test_report(
|
|
167
209
|
report: PropertyTestReport,
|
|
168
210
|
json_output: bool = False,
|
|
@@ -193,6 +235,8 @@ def format_property_test_report(
|
|
|
193
235
|
"passed": r.passed,
|
|
194
236
|
"examples": r.examples_run,
|
|
195
237
|
"error": r.error,
|
|
238
|
+
"file_path": r.file_path, # DX-26
|
|
239
|
+
"seed": r.seed, # DX-26
|
|
196
240
|
}
|
|
197
241
|
for r in report.results
|
|
198
242
|
],
|
|
@@ -215,10 +259,17 @@ def format_property_test_report(
|
|
|
215
259
|
f"{report.total_examples} examples"
|
|
216
260
|
)
|
|
217
261
|
|
|
218
|
-
# Show failures
|
|
262
|
+
# Show failures (DX-26: actionable format)
|
|
219
263
|
for result in report.results:
|
|
220
264
|
if not result.passed:
|
|
221
|
-
|
|
265
|
+
# DX-26: file::function format
|
|
266
|
+
location = f"{result.file_path}::{result.function_name}" if result.file_path else result.function_name
|
|
267
|
+
lines.append(f" [red]✗[/red] {location}")
|
|
268
|
+
if result.error:
|
|
269
|
+
short_error = result.error[:100] + "..." if len(result.error) > 100 else result.error
|
|
270
|
+
lines.append(f" {short_error}")
|
|
271
|
+
if result.seed:
|
|
272
|
+
lines.append(f" [dim]Seed: {result.seed}[/dim]")
|
|
222
273
|
|
|
223
274
|
# Show errors
|
|
224
275
|
for error in report.errors:
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrossHair contract detection utilities.
|
|
3
|
+
|
|
4
|
+
Shell module: Extracted for file size compliance.
|
|
5
|
+
- DX-13: has_verifiable_contracts for contract detection
|
|
6
|
+
- DX-19: @crosshair_accept acknowledgment handling
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# @shell_orchestration: Contract analysis helper for CrossHair prove module
|
|
13
|
+
# @shell_complexity: AST traversal for contract detection
|
|
14
|
+
def has_verifiable_contracts(source: str) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
Check if source has verifiable contracts.
|
|
17
|
+
|
|
18
|
+
DX-13: Hybrid detection - fast string check + AST validation.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
source: Python source code
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
True if file has @pre/@post contracts worth verifying
|
|
25
|
+
"""
|
|
26
|
+
# Fast path: no contract keywords at all
|
|
27
|
+
if "@pre" not in source and "@post" not in source:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# AST validation to avoid false positives from comments/strings
|
|
31
|
+
try:
|
|
32
|
+
import ast
|
|
33
|
+
|
|
34
|
+
tree = ast.parse(source)
|
|
35
|
+
except SyntaxError:
|
|
36
|
+
return True # Conservative: assume has contracts
|
|
37
|
+
|
|
38
|
+
contract_decorators = {"pre", "post"}
|
|
39
|
+
|
|
40
|
+
for node in ast.walk(tree):
|
|
41
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
42
|
+
for dec in node.decorator_list:
|
|
43
|
+
if isinstance(dec, ast.Call):
|
|
44
|
+
func = dec.func
|
|
45
|
+
# @pre(...) or @post(...)
|
|
46
|
+
if isinstance(func, ast.Name) and func.id in contract_decorators:
|
|
47
|
+
return True
|
|
48
|
+
# @deal.pre(...) or @deal.post(...)
|
|
49
|
+
if (
|
|
50
|
+
isinstance(func, ast.Attribute)
|
|
51
|
+
and func.attr in contract_decorators
|
|
52
|
+
):
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# @shell_orchestration: Acceptance criteria analysis for CrossHair prove
|
|
59
|
+
# @shell_complexity: AST traversal for decorator extraction
|
|
60
|
+
def get_crosshair_accept_reasons(source: str) -> dict[str, str]:
|
|
61
|
+
"""
|
|
62
|
+
Extract @crosshair_accept reasons from source.
|
|
63
|
+
|
|
64
|
+
DX-19: Returns a mapping of function_name -> reason for functions
|
|
65
|
+
that have @crosshair_accept decorator.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
source: Python source code
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dict mapping function names to their accept reasons
|
|
72
|
+
"""
|
|
73
|
+
# Fast path: no decorator keyword
|
|
74
|
+
if "@crosshair_accept" not in source:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
import ast
|
|
79
|
+
|
|
80
|
+
tree = ast.parse(source)
|
|
81
|
+
except SyntaxError:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
reasons: dict[str, str] = {}
|
|
85
|
+
|
|
86
|
+
for node in ast.walk(tree):
|
|
87
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
88
|
+
for dec in node.decorator_list:
|
|
89
|
+
if isinstance(dec, ast.Call):
|
|
90
|
+
func = dec.func
|
|
91
|
+
# @crosshair_accept("reason")
|
|
92
|
+
if isinstance(func, ast.Name) and func.id == "crosshair_accept":
|
|
93
|
+
if dec.args and isinstance(dec.args[0], ast.Constant):
|
|
94
|
+
reasons[node.name] = str(dec.args[0].value)
|
|
95
|
+
# @invar.crosshair_accept("reason")
|
|
96
|
+
if isinstance(func, ast.Attribute) and func.attr == "crosshair_accept":
|
|
97
|
+
if dec.args and isinstance(dec.args[0], ast.Constant):
|
|
98
|
+
reasons[node.name] = str(dec.args[0].value)
|
|
99
|
+
|
|
100
|
+
return reasons
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# @shell_orchestration: Counterexample parsing helper for CrossHair output
|
|
104
|
+
def extract_function_from_counterexample(ce: str) -> str | None:
|
|
105
|
+
"""
|
|
106
|
+
Extract function name from CrossHair counterexample.
|
|
107
|
+
|
|
108
|
+
DX-19: Counterexamples look like "func_name(args) (error at ...)"
|
|
109
|
+
"""
|
|
110
|
+
# Function name is everything before the first '('
|
|
111
|
+
if "(" in ce:
|
|
112
|
+
return ce.split("(")[0].strip()
|
|
113
|
+
return None
|