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,345 @@
1
+ """DX-49: Template engine with I/O operations and Jinja2 support.
2
+
3
+ This module provides:
4
+ - File update with region preservation
5
+ - Jinja2 template rendering
6
+ - Manifest loading
7
+
8
+ Pure parsing logic is in core/template_parser.py.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from deal import pre
17
+ from returns.result import Failure, Result, Success
18
+
19
+ # Import pure logic from Core
20
+ from invar.core.template_parser import (
21
+ ParsedFile,
22
+ Region,
23
+ get_syntax_for_command,
24
+ parse_invar_regions,
25
+ reconstruct_file,
26
+ )
27
+
28
+ # Re-export for convenience
29
+ __all__ = [
30
+ "ParsedFile",
31
+ "Region",
32
+ "generate_from_manifest",
33
+ "get_syntax_for_command",
34
+ "get_templates_dir",
35
+ "is_invar_project",
36
+ "load_manifest",
37
+ "parse_invar_regions",
38
+ "reconstruct_file",
39
+ "render_template",
40
+ "render_template_file",
41
+ "update_file_with_regions",
42
+ ]
43
+
44
+
45
+ # =============================================================================
46
+ # File Update Logic
47
+ # =============================================================================
48
+
49
+
50
+ # @shell_complexity: File handling requires branching for exists/has_regions/new_project cases
51
+ @pre(lambda path, new_managed: isinstance(path, Path) and isinstance(new_managed, str))
52
+ def update_file_with_regions(
53
+ path: Path,
54
+ new_managed: str,
55
+ new_project: str | None = None,
56
+ ) -> Result[str, str]:
57
+ """Update file preserving user regions and unmarked content.
58
+
59
+ Args:
60
+ path: File path to update
61
+ new_managed: New content for managed region
62
+ new_project: New content for project region (sync-self only)
63
+
64
+ Returns:
65
+ Success with updated content, or Failure with error message
66
+
67
+ Examples:
68
+ >>> from pathlib import Path
69
+ >>> import tempfile
70
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
71
+ ... _ = f.write('''<!--invar:managed-->
72
+ ... old
73
+ ... <!--/invar:managed-->
74
+ ... <!--invar:user-->
75
+ ... keep this
76
+ ... <!--/invar:user-->''')
77
+ ... path = Path(f.name)
78
+ >>> result = update_file_with_regions(path, "NEW")
79
+ >>> isinstance(result, Success)
80
+ True
81
+ >>> "NEW" in result.unwrap()
82
+ True
83
+ >>> "keep this" in result.unwrap()
84
+ True
85
+ >>> path.unlink() # cleanup
86
+ """
87
+ if not path.exists():
88
+ # New file - just wrap in managed region
89
+ content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n"
90
+ if new_project:
91
+ content += f"\n<!--invar:project-->\n{new_project}\n<!--/invar:project-->\n"
92
+ content += "\n<!--invar:user-->\n\n<!--/invar:user-->\n"
93
+ return Success(content)
94
+
95
+ try:
96
+ current = path.read_text()
97
+ except OSError as e:
98
+ return Failure(f"Failed to read {path}: {e}")
99
+
100
+ parsed = parse_invar_regions(current)
101
+
102
+ if not parsed.has_regions:
103
+ # No markers - insert at top, preserve rest
104
+ content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n\n"
105
+ content += current
106
+ return Success(content)
107
+
108
+ # Build updates dict
109
+ updates: dict[str, str] = {"managed": new_managed}
110
+ if new_project is not None and "project" in parsed.regions:
111
+ updates["project"] = new_project
112
+
113
+ result = reconstruct_file(parsed, updates)
114
+ return Success(result)
115
+
116
+
117
+ # =============================================================================
118
+ # Jinja2 Template Rendering
119
+ # =============================================================================
120
+
121
+
122
+ def render_template(
123
+ template_content: str,
124
+ variables: dict[str, str],
125
+ ) -> Result[str, str]:
126
+ """Render a Jinja2 template with given variables.
127
+
128
+ Args:
129
+ template_content: Jinja2 template string
130
+ variables: Variables to inject (syntax, version, project_name, etc.)
131
+
132
+ Returns:
133
+ Success with rendered content, or Failure with error
134
+
135
+ Examples:
136
+ >>> result = render_template("Hello {{ name }}", {"name": "World"})
137
+ >>> result.unwrap()
138
+ 'Hello World'
139
+
140
+ >>> result = render_template("{% if x %}yes{% endif %}", {"x": True})
141
+ >>> result.unwrap()
142
+ 'yes'
143
+ """
144
+ try:
145
+ from jinja2 import BaseLoader, Environment, StrictUndefined
146
+
147
+ env = Environment(
148
+ loader=BaseLoader(),
149
+ undefined=StrictUndefined,
150
+ keep_trailing_newline=True,
151
+ )
152
+ template = env.from_string(template_content)
153
+ rendered = template.render(**variables)
154
+ return Success(rendered)
155
+ except ImportError:
156
+ return Failure("Jinja2 not installed. Run: pip install jinja2")
157
+ except Exception as e:
158
+ return Failure(f"Template rendering failed: {e}")
159
+
160
+
161
+ def render_template_file(
162
+ template_path: Path,
163
+ variables: dict[str, str],
164
+ ) -> Result[str, str]:
165
+ """Render a Jinja2 template file.
166
+
167
+ Examples:
168
+ >>> from pathlib import Path
169
+ >>> import tempfile
170
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.jinja', delete=False) as f:
171
+ ... _ = f.write("Version: {{ version }}")
172
+ ... path = Path(f.name)
173
+ >>> result = render_template_file(path, {"version": "5.0"})
174
+ >>> result.unwrap()
175
+ 'Version: 5.0'
176
+ >>> path.unlink()
177
+ """
178
+ try:
179
+ content = template_path.read_text()
180
+ except OSError as e:
181
+ return Failure(f"Failed to read template {template_path}: {e}")
182
+
183
+ return render_template(content, variables)
184
+
185
+
186
+ # =============================================================================
187
+ # Manifest Loading
188
+ # =============================================================================
189
+
190
+
191
+ # @shell_complexity: TOML loading with Python version fallback (tomllib vs tomli)
192
+ def load_manifest(templates_dir: Path) -> Result[dict, str]:
193
+ """Load manifest.toml from templates directory.
194
+
195
+ Examples:
196
+ >>> from pathlib import Path
197
+ >>> # Will fail if manifest doesn't exist
198
+ >>> result = load_manifest(Path("/nonexistent"))
199
+ >>> isinstance(result, Failure)
200
+ True
201
+ """
202
+ manifest_path = templates_dir / "manifest.toml"
203
+
204
+ if not manifest_path.exists():
205
+ return Failure(f"Manifest not found: {manifest_path}")
206
+
207
+ try:
208
+ import tomllib
209
+
210
+ content = manifest_path.read_text()
211
+ data = tomllib.loads(content)
212
+ return Success(data)
213
+ except ImportError:
214
+ # Python < 3.11
215
+ try:
216
+ import tomli as tomllib # type: ignore
217
+
218
+ content = manifest_path.read_text()
219
+ data = tomllib.loads(content)
220
+ return Success(data)
221
+ except ImportError:
222
+ return Failure("tomllib/tomli not available")
223
+ except Exception as e:
224
+ return Failure(f"Failed to parse manifest: {e}")
225
+
226
+
227
+ # =============================================================================
228
+ # Template Generation
229
+ # =============================================================================
230
+
231
+
232
+ def get_templates_dir() -> Path:
233
+ """Get the templates directory path."""
234
+ return Path(__file__).parent.parent / "templates"
235
+
236
+
237
+ # @shell_complexity: Template generation has multiple paths for copy/jinja/copy_dir types
238
+ def generate_from_manifest(
239
+ dest_root: Path,
240
+ syntax: str = "cli",
241
+ files_to_generate: list[str] | None = None,
242
+ ) -> Result[list[str], str]:
243
+ """Generate files from manifest.toml templates.
244
+
245
+ Args:
246
+ dest_root: Destination project root
247
+ syntax: "cli" or "mcp" for command syntax
248
+ files_to_generate: Optional list of files to generate (None = all from manifest)
249
+
250
+ Returns:
251
+ Success with list of generated files, or Failure with error
252
+ """
253
+ templates_dir = get_templates_dir()
254
+ manifest_result = load_manifest(templates_dir)
255
+ if isinstance(manifest_result, Failure):
256
+ return manifest_result
257
+
258
+ manifest = manifest_result.unwrap()
259
+ templates = manifest.get("templates", {})
260
+ # Copy to avoid mutating cached manifest
261
+ variables = {**manifest.get("variables", {}), "syntax": syntax}
262
+
263
+ generated: list[str] = []
264
+
265
+ for dest_path, config in templates.items():
266
+ # Skip if not in files_to_generate list
267
+ if files_to_generate is not None and dest_path not in files_to_generate:
268
+ continue
269
+
270
+ src = config.get("src", "")
271
+ template_type = config.get("type", "copy")
272
+ src_path = templates_dir / src
273
+
274
+ # Resolve destination
275
+ full_dest = dest_root / dest_path
276
+
277
+ # Ensure parent directories exist
278
+ full_dest.parent.mkdir(parents=True, exist_ok=True)
279
+
280
+ if template_type == "copy":
281
+ # Direct file copy
282
+ if not src_path.exists():
283
+ continue
284
+ if full_dest.exists():
285
+ continue # Don't overwrite existing
286
+ try:
287
+ full_dest.write_text(src_path.read_text())
288
+ generated.append(dest_path)
289
+ except OSError as e:
290
+ print(f"Warning: Failed to copy {dest_path}: {e}", file=sys.stderr)
291
+ continue
292
+
293
+ elif template_type == "jinja":
294
+ # Jinja2 template rendering
295
+ if not src_path.exists():
296
+ continue
297
+ if full_dest.exists():
298
+ continue # Don't overwrite existing
299
+ result = render_template_file(src_path, variables)
300
+ if isinstance(result, Success):
301
+ try:
302
+ full_dest.write_text(result.unwrap())
303
+ generated.append(dest_path)
304
+ except OSError as e:
305
+ print(f"Warning: Failed to write {dest_path}: {e}", file=sys.stderr)
306
+ continue
307
+
308
+ elif template_type == "copy_dir":
309
+ # Directory copy
310
+ if not src_path.exists() or not src_path.is_dir():
311
+ continue
312
+ if full_dest.exists():
313
+ continue # Don't overwrite existing directory
314
+ try:
315
+ import shutil
316
+ shutil.copytree(src_path, full_dest)
317
+ generated.append(dest_path)
318
+ except OSError as e:
319
+ print(f"Warning: Failed to copy directory {dest_path}: {e}", file=sys.stderr)
320
+ continue
321
+
322
+ return Success(generated)
323
+
324
+
325
+ # =============================================================================
326
+ # Utility Functions
327
+ # =============================================================================
328
+
329
+
330
+ def is_invar_project(project_root: Path) -> bool:
331
+ """Check if this is the Invar project itself.
332
+
333
+ The Invar project has special handling via sync-self.
334
+
335
+ Examples:
336
+ >>> from pathlib import Path
337
+ >>> is_invar_project(Path("/some/random/project"))
338
+ False
339
+ """
340
+ # Check for Invar-specific markers
341
+ invar_markers = [
342
+ project_root / "src" / "invar" / "__init__.py",
343
+ project_root / "runtime" / "src" / "invar_runtime" / "__init__.py",
344
+ ]
345
+ return all(marker.exists() for marker in invar_markers)
invar/shell/templates.py CHANGED
@@ -53,6 +53,7 @@ def get_template_path(name: str) -> Result[Path, str]:
53
53
  return Failure(f"Failed to get template path: {e}")
54
54
 
55
55
 
56
+ # @shell_complexity: Template copy with path resolution
56
57
  def copy_template(
57
58
  template_name: str, dest: Path, dest_name: str | None = None
58
59
  ) -> Result[bool, str]:
@@ -73,6 +74,7 @@ def copy_template(
73
74
  return Failure(f"Failed to copy template: {e}")
74
75
 
75
76
 
77
+ # @shell_complexity: Config addition with existing file detection
76
78
  def add_config(path: Path, console) -> Result[bool, str]:
77
79
  """Add configuration to project. Returns Success(True) if added, Success(False) if skipped."""
78
80
  pyproject = path / "pyproject.toml"
@@ -114,6 +116,7 @@ def create_directories(path: Path, console) -> None:
114
116
  console.print("[green]Created[/green] src/shell/")
115
117
 
116
118
 
119
+ # @shell_complexity: Directory copy with file filtering
117
120
  def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
118
121
  """Copy examples directory to .invar/examples/. Returns Success(True) if copied."""
119
122
  import shutil
@@ -139,6 +142,51 @@ def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
139
142
  return Failure(f"Failed to copy examples: {e}")
140
143
 
141
144
 
145
+ # @shell_complexity: Directory copy for Claude commands (DX-32)
146
+ def copy_commands_directory(dest: Path, console) -> Result[bool, str]:
147
+ """Copy commands directory to .claude/commands/. Returns Success(True) if copied."""
148
+ import shutil
149
+
150
+ commands_dest = dest / ".claude" / "commands"
151
+ if commands_dest.exists():
152
+ return Success(False)
153
+
154
+ try:
155
+ commands_src = Path(str(resources.files("invar.templates").joinpath("commands")))
156
+ if not commands_src.exists():
157
+ return Failure("Commands template directory not found")
158
+
159
+ # Create .claude if needed
160
+ claude_dir = dest / ".claude"
161
+ if not claude_dir.exists():
162
+ claude_dir.mkdir()
163
+
164
+ shutil.copytree(commands_src, commands_dest)
165
+ console.print("[green]Created[/green] .claude/commands/ (Claude Code skills)")
166
+ return Success(True)
167
+ except OSError as e:
168
+ return Failure(f"Failed to copy commands: {e}")
169
+
170
+
171
+ # @shell_complexity: Directory copy for Claude skills (DX-36)
172
+ def copy_skills_directory(dest: Path, console) -> Result[bool, str]:
173
+ """Copy skills directory to .claude/skills/. Returns Success(True) if copied."""
174
+ import shutil
175
+ skills_dest = dest / ".claude" / "skills"
176
+ if skills_dest.exists():
177
+ return Success(False)
178
+ try:
179
+ skills_src = Path(str(resources.files("invar.templates").joinpath("skills")))
180
+ if not skills_src.exists():
181
+ return Failure("Skills template directory not found")
182
+ (dest / ".claude").mkdir(exist_ok=True)
183
+ shutil.copytree(skills_src, skills_dest)
184
+ console.print("[green]Created[/green] .claude/skills/ (workflow skills)")
185
+ return Success(True)
186
+ except OSError as e:
187
+ return Failure(f"Failed to copy skills: {e}")
188
+
189
+
142
190
  # Agent configuration for multi-agent support (DX-11, DX-17)
143
191
  AGENT_CONFIGS = {
144
192
  "claude": {
@@ -162,6 +210,7 @@ AGENT_CONFIGS = {
162
210
  }
163
211
 
164
212
 
213
+ # @shell_complexity: Agent config detection across multiple locations
165
214
  def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
166
215
  """
167
216
  Detect existing agent configuration files.
@@ -195,6 +244,7 @@ def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
195
244
  return Failure(f"Failed to detect agent configs: {e}")
196
245
 
197
246
 
247
+ # @shell_complexity: Reference addition with existing check
198
248
  def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
199
249
  """Add Invar reference to an existing agent config file."""
200
250
  if agent not in AGENT_CONFIGS:
@@ -220,6 +270,7 @@ def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
220
270
  return Failure(f"Failed to update {config['file']}: {e}")
221
271
 
222
272
 
273
+ # @shell_complexity: Config creation with template selection
223
274
  def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
224
275
  """
225
276
  Create agent config from template (DX-17).
@@ -248,6 +299,7 @@ def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
248
299
  return Success(False)
249
300
 
250
301
 
302
+ # @shell_complexity: MCP server config with JSON manipulation
251
303
  def configure_mcp_server(path: Path, console) -> Result[list[str], str]:
252
304
  """
253
305
  Configure MCP server for AI agents (DX-16).
@@ -407,6 +459,7 @@ The server communicates via stdio and should be managed by your AI agent.
407
459
  """
408
460
 
409
461
 
462
+ # @shell_complexity: Git hooks installation with backup
410
463
  def install_hooks(path: Path, console) -> Result[bool, str]:
411
464
  """Install pre-commit hooks configuration and activate them."""
412
465
  import subprocess
invar/shell/testing.py CHANGED
@@ -18,10 +18,12 @@ from pathlib import Path
18
18
  from returns.result import Failure, Result, Success
19
19
  from rich.console import Console
20
20
 
21
+ from invar.shell.prove.cache import ProveCache
22
+
21
23
  # DX-12: Import from prove module
22
24
  # DX-13: Added get_files_to_prove, run_crosshair_parallel
23
- # DX-13: ProveCache extracted to prove_cache.py
24
- from invar.shell.prove import (
25
+ # DX-48b: Relocated to shell/prove/
26
+ from invar.shell.prove.crosshair import (
25
27
  CrossHairStatus,
26
28
  get_files_to_prove,
27
29
  run_crosshair_on_files,
@@ -29,7 +31,7 @@ from invar.shell.prove import (
29
31
  run_hypothesis_fallback,
30
32
  run_prove_with_fallback,
31
33
  )
32
- from invar.shell.prove_cache import ProveCache
34
+ from invar.shell.subprocess_env import build_subprocess_env
33
35
 
34
36
  console = Console()
35
37
 
@@ -40,7 +42,6 @@ __all__ = [
40
42
  "ProveCache",
41
43
  "VerificationLevel",
42
44
  "VerificationResult",
43
- "detect_verification_context",
44
45
  "get_available_verifiers",
45
46
  "get_files_to_prove",
46
47
  "run_crosshair_on_files",
@@ -80,6 +81,7 @@ class VerificationResult:
80
81
  errors: list[str] = field(default_factory=list)
81
82
 
82
83
 
84
+ # @shell_orchestration: Verifier discovery helper
83
85
  def get_available_verifiers() -> list[str]:
84
86
  """
85
87
  Detect installed verification tools.
@@ -111,23 +113,12 @@ def get_available_verifiers() -> list[str]:
111
113
  return available
112
114
 
113
115
 
114
- def detect_verification_context() -> VerificationLevel:
115
- """
116
- Auto-detect appropriate verification depth based on context.
117
-
118
- DX-19: Simplified to 2 levels. Always returns STANDARD (full verification).
119
- STATIC is only used when explicitly requested via --static flag.
120
-
121
- >>> detect_verification_context() == VerificationLevel.STANDARD
122
- True
123
- """
124
- # DX-19: Always use STANDARD (full verification) by default
125
- # STATIC is only for explicit --static flag
126
- return VerificationLevel.STANDARD
127
-
128
-
116
+ # @shell_complexity: Doctest execution with subprocess and result parsing
129
117
  def run_doctests_on_files(
130
- files: list[Path], verbose: bool = False
118
+ files: list[Path],
119
+ verbose: bool = False,
120
+ timeout: int = 60,
121
+ collect_coverage: bool = False,
131
122
  ) -> Result[dict, str]:
132
123
  """
133
124
  Run doctests on a list of Python files.
@@ -135,6 +126,8 @@ def run_doctests_on_files(
135
126
  Args:
136
127
  files: List of Python file paths to test
137
128
  verbose: Show verbose output
129
+ timeout: Maximum time in seconds (default: 60, from RuleConfig.timeout_doctest)
130
+ collect_coverage: DX-37: If True, run with coverage.py and return coverage data
138
131
 
139
132
  Returns:
140
133
  Success with test results or Failure with error message
@@ -143,21 +136,45 @@ def run_doctests_on_files(
143
136
  return Success({"status": "skipped", "reason": "no files", "files": []})
144
137
 
145
138
  # Filter to Python files only
146
- py_files = [f for f in files if f.suffix == ".py" and f.exists()]
139
+ # Exclude: conftest.py (pytest config), templates/examples/ (source templates, not user examples)
140
+ py_files = [
141
+ f for f in files
142
+ if f.suffix == ".py"
143
+ and f.exists()
144
+ and f.name != "conftest.py"
145
+ and "templates/examples" not in str(f)
146
+ ]
147
147
  if not py_files:
148
148
  return Success({"status": "skipped", "reason": "no Python files", "files": []})
149
149
 
150
- # Build pytest command
151
- cmd = [
152
- sys.executable, "-m", "pytest",
153
- "--doctest-modules", "-x", "--tb=short",
154
- ]
150
+ # DX-37: Build command with optional coverage
151
+ if collect_coverage:
152
+ # Use coverage run to wrap pytest
153
+ cmd = [
154
+ sys.executable, "-m", "coverage", "run",
155
+ "--branch", # Enable branch coverage
156
+ "--parallel-mode", # For merging with hypothesis later
157
+ "-m", "pytest",
158
+ "--doctest-modules", "-x", "--tb=short",
159
+ ]
160
+ else:
161
+ cmd = [
162
+ sys.executable, "-m", "pytest",
163
+ "--doctest-modules", "-x", "--tb=short",
164
+ ]
155
165
  cmd.extend(str(f) for f in py_files)
156
166
  if verbose:
157
167
  cmd.append("-v")
158
168
 
159
169
  try:
160
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
170
+ # DX-52: Inject project venv site-packages for uvx compatibility
171
+ result = subprocess.run(
172
+ cmd,
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=timeout,
176
+ env=build_subprocess_env(),
177
+ )
161
178
  # Pytest exit codes: 0=passed, 5=no tests collected (also OK)
162
179
  is_passed = result.returncode in (0, 5)
163
180
  return Success({
@@ -166,15 +183,17 @@ def run_doctests_on_files(
166
183
  "exit_code": result.returncode,
167
184
  "stdout": result.stdout,
168
185
  "stderr": result.stderr,
186
+ "coverage_collected": collect_coverage, # DX-37: Flag for caller
169
187
  })
170
188
  except subprocess.TimeoutExpired:
171
- return Failure("Doctest timeout (120s)")
189
+ return Failure(f"Doctest timeout ({timeout}s)")
172
190
  except Exception as e:
173
191
  return Failure(f"Doctest error: {e}")
174
192
 
175
193
 
194
+ # @shell_complexity: Property test orchestration with subprocess
176
195
  def run_test(
177
- target: str, json_output: bool = False, verbose: bool = False
196
+ target: str, json_output: bool = False, verbose: bool = False, timeout: int = 300
178
197
  ) -> Result[dict, str]:
179
198
  """
180
199
  Run property-based tests using Hypothesis via deal.cases.
@@ -183,6 +202,7 @@ def run_test(
183
202
  target: File path or module to test
184
203
  json_output: Output as JSON
185
204
  verbose: Show verbose output
205
+ timeout: Maximum time in seconds (default: 300, from RuleConfig.timeout_hypothesis)
186
206
 
187
207
  Returns:
188
208
  Success with test results or Failure with error message
@@ -201,7 +221,14 @@ def run_test(
201
221
  cmd.append("-v")
202
222
 
203
223
  try:
204
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
224
+ # DX-52: Inject project venv site-packages for uvx compatibility
225
+ result = subprocess.run(
226
+ cmd,
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=timeout,
230
+ env=build_subprocess_env(),
231
+ )
205
232
  test_result = {
206
233
  "status": "passed" if result.returncode == 0 else "failed",
207
234
  "target": str(target_path),
@@ -225,13 +252,17 @@ def run_test(
225
252
 
226
253
  return Success(test_result)
227
254
  except subprocess.TimeoutExpired:
228
- return Failure(f"Test timeout (300s): {target}")
255
+ return Failure(f"Test timeout ({timeout}s): {target}")
229
256
  except Exception as e:
230
257
  return Failure(f"Test error: {e}")
231
258
 
232
259
 
260
+ # @shell_complexity: CrossHair verification with subprocess
233
261
  def run_verify(
234
- target: str, json_output: bool = False, timeout: int = 30
262
+ target: str,
263
+ json_output: bool = False,
264
+ total_timeout: int = 300,
265
+ per_condition_timeout: int = 30,
235
266
  ) -> Result[dict, str]:
236
267
  """
237
268
  Run symbolic verification using CrossHair.
@@ -239,7 +270,8 @@ def run_verify(
239
270
  Args:
240
271
  target: File path or module to verify
241
272
  json_output: Output as JSON
242
- timeout: Timeout per function in seconds
273
+ total_timeout: Total timeout in seconds (default: 300, from RuleConfig.timeout_crosshair)
274
+ per_condition_timeout: Per-contract timeout (default: 30, from RuleConfig.timeout_crosshair_per_condition)
243
275
 
244
276
  Returns:
245
277
  Success with verification results or Failure with error message
@@ -260,15 +292,23 @@ def run_verify(
260
292
 
261
293
  cmd = [
262
294
  sys.executable, "-m", "crosshair", "check",
263
- str(target_path), f"--per_condition_timeout={timeout}",
295
+ str(target_path), f"--per_condition_timeout={per_condition_timeout}",
264
296
  ]
265
297
 
266
298
  try:
267
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * 10)
299
+ # DX-52: Inject project venv site-packages for uvx compatibility
300
+ result = subprocess.run(
301
+ cmd,
302
+ capture_output=True,
303
+ text=True,
304
+ timeout=total_timeout,
305
+ env=build_subprocess_env(),
306
+ )
268
307
 
308
+ # CrossHair format: "file:line: error: Err when calling func(...)"
269
309
  counterexamples = [
270
310
  line.strip() for line in result.stdout.split("\n")
271
- if "error" in line.lower() or "counterexample" in line.lower()
311
+ if ": error:" in line.lower() or "counterexample" in line.lower()
272
312
  ]
273
313
 
274
314
  verify_result = {
@@ -292,6 +332,6 @@ def run_verify(
292
332
 
293
333
  return Success(verify_result)
294
334
  except subprocess.TimeoutExpired:
295
- return Failure(f"Verification timeout ({timeout * 10}s): {target}")
335
+ return Failure(f"Verification timeout ({total_timeout}s): {target}")
296
336
  except Exception as e:
297
337
  return Failure(f"Verification error: {e}")