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.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
@@ -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}")
@@ -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
- ) -> Result[PropertyTestReport, str]:
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
- Combined PropertyTestReport
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
- for file_path in files:
125
- result = run_property_tests_on_file(file_path, max_examples, verbose)
126
-
127
- if isinstance(result, Failure):
128
- combined_report.errors.append(result.failure())
129
- continue
130
-
131
- file_report = result.unwrap()
132
- combined_report.functions_tested += file_report.functions_tested
133
- combined_report.functions_passed += file_report.functions_passed
134
- combined_report.functions_failed += file_report.functions_failed
135
- combined_report.functions_skipped += file_report.functions_skipped
136
- combined_report.total_examples += file_report.total_examples
137
- combined_report.results.extend(file_report.results)
138
- combined_report.errors.extend(file_report.errors)
139
-
140
- 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)
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
- lines.append(f" [red]✗[/red] {result.function_name}: {result.error}")
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,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
+ """
@@ -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