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.
- invar/core/contracts.py +75 -5
- invar/core/entry_points.py +294 -0
- invar/core/format_specs.py +196 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +27 -4
- invar/core/hypothesis_strategies.py +47 -5
- invar/core/lambda_helpers.py +1 -0
- invar/core/models.py +23 -17
- invar/core/parser.py +6 -2
- invar/core/property_gen.py +81 -40
- invar/core/purity.py +10 -4
- invar/core/review_trigger.py +298 -0
- invar/core/rule_meta.py +61 -2
- invar/core/rules.py +83 -19
- invar/core/shell_analysis.py +252 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/suggestions.py +6 -0
- invar/core/tautology.py +1 -0
- invar/core/utils.py +51 -4
- invar/core/verification_routing.py +158 -0
- invar/invariant.py +1 -0
- invar/mcp/server.py +20 -3
- invar/shell/cli.py +59 -31
- invar/shell/config.py +259 -10
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +78 -3
- invar/shell/guard_output.py +100 -24
- invar/shell/init_cmd.py +27 -7
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutate_cmd.py +184 -0
- invar/shell/mutation.py +314 -0
- invar/shell/perception.py +2 -0
- invar/shell/property_tests.py +17 -2
- invar/shell/prove.py +35 -3
- invar/shell/prove_accept.py +113 -0
- invar/shell/prove_fallback.py +148 -46
- invar/shell/templates.py +34 -0
- invar/shell/test_cmd.py +3 -1
- invar/shell/testing.py +6 -17
- invar/shell/update_cmd.py +2 -0
- invar/templates/CLAUDE.md.template +65 -9
- invar/templates/INVAR.md +96 -23
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/review.md +200 -0
- invar/templates/cursorrules.template +22 -13
- invar/templates/examples/contracts.py +3 -1
- invar/templates/examples/core_shell.py +3 -1
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
- invar_tools-1.2.0.dist-info/RECORD +77 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +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/perception.py
CHANGED
|
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|
|
30
30
|
console = Console()
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# @shell_complexity: Symbol map generation with sorting and output modes
|
|
33
34
|
def run_map(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
|
|
34
35
|
"""
|
|
35
36
|
Run the map command.
|
|
@@ -75,6 +76,7 @@ def run_map(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
|
|
|
75
76
|
return Success(None)
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
# @shell_complexity: Signature extraction with symbol filtering
|
|
78
80
|
def run_sig(target: str, json_output: bool) -> Result[None, str]:
|
|
79
81
|
"""
|
|
80
82
|
Run the sig command.
|
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
|
|
@@ -163,6 +167,8 @@ def _import_module_from_path(file_path: Path) -> object | None:
|
|
|
163
167
|
return None
|
|
164
168
|
|
|
165
169
|
|
|
170
|
+
# @shell_orchestration: Formatting helper tightly coupled to CLI output
|
|
171
|
+
# @shell_complexity: Report formatting with JSON/rich output modes
|
|
166
172
|
def format_property_test_report(
|
|
167
173
|
report: PropertyTestReport,
|
|
168
174
|
json_output: bool = False,
|
|
@@ -193,6 +199,8 @@ def format_property_test_report(
|
|
|
193
199
|
"passed": r.passed,
|
|
194
200
|
"examples": r.examples_run,
|
|
195
201
|
"error": r.error,
|
|
202
|
+
"file_path": r.file_path, # DX-26
|
|
203
|
+
"seed": r.seed, # DX-26
|
|
196
204
|
}
|
|
197
205
|
for r in report.results
|
|
198
206
|
],
|
|
@@ -215,10 +223,17 @@ def format_property_test_report(
|
|
|
215
223
|
f"{report.total_examples} examples"
|
|
216
224
|
)
|
|
217
225
|
|
|
218
|
-
# Show failures
|
|
226
|
+
# Show failures (DX-26: actionable format)
|
|
219
227
|
for result in report.results:
|
|
220
228
|
if not result.passed:
|
|
221
|
-
|
|
229
|
+
# DX-26: file::function format
|
|
230
|
+
location = f"{result.file_path}::{result.function_name}" if result.file_path else result.function_name
|
|
231
|
+
lines.append(f" [red]✗[/red] {location}")
|
|
232
|
+
if result.error:
|
|
233
|
+
short_error = result.error[:100] + "..." if len(result.error) > 100 else result.error
|
|
234
|
+
lines.append(f" {short_error}")
|
|
235
|
+
if result.seed:
|
|
236
|
+
lines.append(f" [dim]Seed: {result.seed}[/dim]")
|
|
222
237
|
|
|
223
238
|
# Show errors
|
|
224
239
|
for error in report.errors:
|
invar/shell/prove.py
CHANGED
|
@@ -56,6 +56,8 @@ class CrossHairStatus:
|
|
|
56
56
|
# ============================================================
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
# @shell_orchestration: Contract detection for CrossHair prove module
|
|
60
|
+
# @shell_complexity: AST traversal for contract detection
|
|
59
61
|
def has_verifiable_contracts(source: str) -> bool:
|
|
60
62
|
"""
|
|
61
63
|
Check if source has verifiable contracts.
|
|
@@ -105,6 +107,7 @@ def has_verifiable_contracts(source: str) -> bool:
|
|
|
105
107
|
# ============================================================
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
# @shell_complexity: CrossHair subprocess with error classification
|
|
108
111
|
def _verify_single_file(
|
|
109
112
|
file_path: str,
|
|
110
113
|
max_iterations: int = 5,
|
|
@@ -153,17 +156,41 @@ def _verify_single_file(
|
|
|
153
156
|
"stdout": result.stdout,
|
|
154
157
|
}
|
|
155
158
|
else:
|
|
159
|
+
# Check if this is an execution error vs actual counterexample
|
|
160
|
+
# CrossHair reports TypeError/AttributeError when it can't
|
|
161
|
+
# symbolically execute C extensions like ast.parse()
|
|
162
|
+
stdout = result.stdout
|
|
163
|
+
execution_errors = [
|
|
164
|
+
"TypeError:",
|
|
165
|
+
"AttributeError:",
|
|
166
|
+
"NotImplementedError:",
|
|
167
|
+
"compile() arg 1 must be", # ast.parse limitation
|
|
168
|
+
]
|
|
169
|
+
is_execution_error = any(err in stdout for err in execution_errors)
|
|
170
|
+
|
|
171
|
+
if is_execution_error:
|
|
172
|
+
# Treat as skipped - function uses unsupported operations
|
|
173
|
+
return {
|
|
174
|
+
"file": file_path,
|
|
175
|
+
"status": CrossHairStatus.SKIPPED,
|
|
176
|
+
"time_ms": elapsed_ms,
|
|
177
|
+
"reason": "uses unsupported operations (ast/compile)",
|
|
178
|
+
"stdout": stdout,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Extract counterexample lines - CrossHair format: "file:line: error: Err when calling func(...)"
|
|
182
|
+
# Include lines with "error:" as they contain the actual counterexamples
|
|
156
183
|
counterexamples = [
|
|
157
184
|
line.strip()
|
|
158
|
-
for line in
|
|
159
|
-
if line.strip() and "error"
|
|
185
|
+
for line in stdout.split("\n")
|
|
186
|
+
if line.strip() and ": error:" in line.lower()
|
|
160
187
|
]
|
|
161
188
|
return {
|
|
162
189
|
"file": file_path,
|
|
163
190
|
"status": CrossHairStatus.COUNTEREXAMPLE,
|
|
164
191
|
"time_ms": elapsed_ms,
|
|
165
192
|
"counterexamples": counterexamples,
|
|
166
|
-
"stdout":
|
|
193
|
+
"stdout": stdout,
|
|
167
194
|
}
|
|
168
195
|
|
|
169
196
|
except subprocess.TimeoutExpired:
|
|
@@ -185,6 +212,7 @@ def _verify_single_file(
|
|
|
185
212
|
# ============================================================
|
|
186
213
|
|
|
187
214
|
|
|
215
|
+
# @shell_complexity: Parallel verification with caching and filtering
|
|
188
216
|
def run_crosshair_parallel(
|
|
189
217
|
files: list[Path],
|
|
190
218
|
max_iterations: int = 5,
|
|
@@ -354,6 +382,8 @@ def run_crosshair_parallel(
|
|
|
354
382
|
)
|
|
355
383
|
|
|
356
384
|
|
|
385
|
+
# @shell_orchestration: Result aggregation helper for parallel verification
|
|
386
|
+
# @shell_complexity: Result classification with cache update
|
|
357
387
|
def _process_verification_result(
|
|
358
388
|
result: dict,
|
|
359
389
|
file_path: Path,
|
|
@@ -417,6 +447,8 @@ def run_crosshair_on_files(
|
|
|
417
447
|
# ============================================================
|
|
418
448
|
|
|
419
449
|
|
|
450
|
+
# @shell_orchestration: File selection for incremental verification
|
|
451
|
+
# @shell_complexity: Git integration for incremental verification
|
|
420
452
|
def get_files_to_prove(
|
|
421
453
|
path: Path,
|
|
422
454
|
all_core_files: list[Path],
|
|
@@ -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
|