invar-tools 1.17.19__py3-none-any.whl → 1.17.20__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.
@@ -17,7 +17,7 @@ from invar import __version__
17
17
  from invar.core.models import GuardReport, RuleConfig
18
18
  from invar.core.rules import check_all_rules
19
19
  from invar.core.utils import get_exit_code
20
- from invar.shell.config import find_project_root, load_config
20
+ from invar.shell.config import find_project_root, find_pyproject_root, load_config
21
21
  from invar.shell.fs import scan_project
22
22
  from invar.shell.guard_output import output_agent, output_rich
23
23
 
@@ -225,7 +225,14 @@ def guard(
225
225
  console.print(f"[red]Error:[/red] {path} is not a Python file")
226
226
  raise typer.Exit(1)
227
227
  single_file = path.resolve()
228
- path = find_project_root(path)
228
+
229
+ pyproject_root = find_pyproject_root(single_file if single_file else path)
230
+ if pyproject_root is None:
231
+ console.print(
232
+ "[red]Error:[/red] pyproject.toml not found (searched upward from the target path)"
233
+ )
234
+ raise typer.Exit(1)
235
+ path = pyproject_root
229
236
 
230
237
  # Load and configure
231
238
  config_result = load_config(path)
@@ -373,6 +380,7 @@ def guard(
373
380
 
374
381
  # Phase 1: Doctests (DX-37: with optional coverage)
375
382
  doctest_passed, doctest_output, doctest_coverage = run_doctests_phase(
383
+ path,
376
384
  checked_files,
377
385
  explain,
378
386
  timeout=config.timeout_doctest,
@@ -393,6 +401,7 @@ def guard(
393
401
 
394
402
  # Phase 3: Hypothesis property tests (DX-37: with optional coverage)
395
403
  property_passed, property_output, property_coverage = run_property_tests_phase(
404
+ path,
396
405
  checked_files,
397
406
  doctest_passed,
398
407
  static_exit_code,
invar/shell/config.py CHANGED
@@ -39,11 +39,27 @@ class ModuleType(Enum):
39
39
 
40
40
 
41
41
  # I/O libraries that indicate Shell module (for AST import checking)
42
- _IO_LIBRARIES = frozenset([
43
- "os", "sys", "subprocess", "pathlib", "shutil", "io", "socket",
44
- "requests", "aiohttp", "httpx", "urllib", "sqlite3", "psycopg2",
45
- "pymongo", "sqlalchemy", "typer", "click",
46
- ])
42
+ _IO_LIBRARIES = frozenset(
43
+ [
44
+ "os",
45
+ "sys",
46
+ "subprocess",
47
+ "pathlib",
48
+ "shutil",
49
+ "io",
50
+ "socket",
51
+ "requests",
52
+ "aiohttp",
53
+ "httpx",
54
+ "urllib",
55
+ "sqlite3",
56
+ "psycopg2",
57
+ "pymongo",
58
+ "sqlalchemy",
59
+ "typer",
60
+ "click",
61
+ ]
62
+ )
47
63
 
48
64
  # Contract decorator names
49
65
  _CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
@@ -226,6 +242,7 @@ def auto_detect_module_type(source: str, file_path: str = "") -> ModuleType:
226
242
  # Unknown: neither clear pattern
227
243
  return ModuleType.UNKNOWN
228
244
 
245
+
229
246
  if TYPE_CHECKING:
230
247
  from pathlib import Path
231
248
 
@@ -268,6 +285,20 @@ def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigS
268
285
 
269
286
 
270
287
  # @shell_complexity: Project root discovery requires checking multiple markers
288
+ def find_pyproject_root(start_path: "Path") -> "Path | None": # noqa: UP037
289
+ from pathlib import Path
290
+
291
+ current = Path(start_path).resolve()
292
+ if current.is_file():
293
+ current = current.parent
294
+
295
+ for parent in [current, *current.parents]:
296
+ if (parent / "pyproject.toml").exists():
297
+ return parent
298
+
299
+ return None
300
+
301
+
271
302
  def find_project_root(start_path: "Path") -> "Path": # noqa: UP037
272
303
  """
273
304
  Find project root by walking up from start_path looking for config files.
@@ -492,6 +523,7 @@ def classify_file(
492
523
  else:
493
524
  # Log warning about config error, use defaults
494
525
  import logging
526
+
495
527
  logging.getLogger(__name__).debug(
496
528
  "Pattern classification failed: %s, using defaults", pattern_result.failure()
497
529
  )
@@ -503,6 +535,7 @@ def classify_file(
503
535
  else:
504
536
  # Log warning about config error, use defaults
505
537
  import logging
538
+
506
539
  logging.getLogger(__name__).debug(
507
540
  "Path classification failed: %s, using defaults", path_result.failure()
508
541
  )
invar/shell/git.py CHANGED
@@ -7,13 +7,10 @@ Shell module: handles git I/O for changed file detection.
7
7
  from __future__ import annotations
8
8
 
9
9
  import subprocess
10
- from typing import TYPE_CHECKING
10
+ from pathlib import Path
11
11
 
12
12
  from returns.result import Failure, Result, Success
13
13
 
14
- if TYPE_CHECKING:
15
- from pathlib import Path
16
-
17
14
 
18
15
  def _run_git(args: list[str], cwd: Path) -> Result[str, str]:
19
16
  """Run a git command and return stdout."""
@@ -49,27 +46,29 @@ def get_changed_files(project_root: Path) -> Result[set[Path], str]:
49
46
  >>> isinstance(result, (Success, Failure))
50
47
  True
51
48
  """
52
- # Verify git repo
53
49
  check = _run_git(["rev-parse", "--git-dir"], project_root)
54
50
  if isinstance(check, Failure):
55
51
  return Failure(f"Not a git repository: {project_root}")
56
52
 
53
+ repo_root_result = _run_git(["rev-parse", "--show-toplevel"], project_root)
54
+ if isinstance(repo_root_result, Failure):
55
+ return Failure(repo_root_result.failure())
56
+
57
+ repo_root = Path(repo_root_result.unwrap().strip())
58
+
57
59
  changed: set[Path] = set()
58
60
 
59
- # Staged changes
60
61
  staged = _run_git(["diff", "--cached", "--name-only"], project_root)
61
62
  if isinstance(staged, Success):
62
- changed.update(_parse_py_files(staged.unwrap(), project_root))
63
+ changed.update(_parse_py_files(staged.unwrap(), repo_root))
63
64
 
64
- # Unstaged changes
65
65
  unstaged = _run_git(["diff", "--name-only"], project_root)
66
66
  if isinstance(unstaged, Success):
67
- changed.update(_parse_py_files(unstaged.unwrap(), project_root))
67
+ changed.update(_parse_py_files(unstaged.unwrap(), repo_root))
68
68
 
69
- # Untracked files
70
69
  untracked = _run_git(["ls-files", "--others", "--exclude-standard"], project_root)
71
70
  if isinstance(untracked, Success):
72
- changed.update(_parse_py_files(untracked.unwrap(), project_root))
71
+ changed.update(_parse_py_files(untracked.unwrap(), repo_root))
73
72
 
74
73
  return Success(changed)
75
74
 
@@ -36,24 +36,36 @@ def handle_changed_mode(
36
36
  if isinstance(changed_result, Failure):
37
37
  return Failure(changed_result.failure())
38
38
 
39
- only_files = changed_result.unwrap()
39
+ all_files = changed_result.unwrap()
40
+ only_files = {p for p in all_files if p.is_relative_to(path)}
40
41
  if not only_files:
41
- return Failure("NO_CHANGES") # Special marker for "no changes"
42
+ return Failure("NO_CHANGES")
42
43
 
43
44
  return Success((only_files, list(only_files)))
44
45
 
45
46
 
46
47
  # @shell_orchestration: Coordinates path classification and file collection
47
48
  # @shell_complexity: File collection with path normalization
48
- def collect_files_to_check(
49
- path: Path, checked_files: list[Path]
50
- ) -> list[Path]:
51
- """Collect Python files to check when not in --changed mode."""
52
- from invar.shell.config import get_path_classification
49
+ def collect_files_to_check(path: Path, checked_files: list[Path]) -> list[Path]:
50
+ """Collect Python files for runtime phases, honoring exclude_paths."""
51
+ from invar.shell.config import get_exclude_paths, get_path_classification
52
+ from invar.shell.fs import _is_excluded
53
53
 
54
54
  if checked_files:
55
55
  return checked_files
56
56
 
57
+ exclude_result = get_exclude_paths(path)
58
+ exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
59
+
60
+ def _add_py_files_under(root: Path) -> None:
61
+ for py_file in root.rglob("*.py"):
62
+ try:
63
+ rel = str(py_file.relative_to(path))
64
+ except ValueError:
65
+ rel = str(py_file)
66
+ if not _is_excluded(rel, exclude_patterns):
67
+ result_files.append(py_file)
68
+
57
69
  result_files: list[Path] = []
58
70
 
59
71
  path_result = get_path_classification(path)
@@ -62,26 +74,33 @@ def collect_files_to_check(
62
74
  else:
63
75
  core_paths, shell_paths = ["src/core"], ["src/shell"]
64
76
 
65
- # Scan core/shell paths
66
77
  for core_path in core_paths:
67
78
  full_path = path / core_path
68
79
  if full_path.exists():
69
- result_files.extend(full_path.rglob("*.py"))
80
+ _add_py_files_under(full_path)
70
81
 
71
82
  for shell_path in shell_paths:
72
83
  full_path = path / shell_path
73
84
  if full_path.exists():
74
- result_files.extend(full_path.rglob("*.py"))
85
+ _add_py_files_under(full_path)
75
86
 
76
- # Fallback: scan path directly
77
87
  if not result_files and path.exists():
78
- result_files.extend(path.rglob("*.py"))
88
+ _add_py_files_under(path)
89
+
90
+ seen: set[str] = set()
91
+ unique: list[Path] = []
92
+ for f in result_files:
93
+ key = str(f)
94
+ if key not in seen:
95
+ seen.add(key)
96
+ unique.append(f)
79
97
 
80
- return result_files
98
+ return unique
81
99
 
82
100
 
83
101
  # @shell_orchestration: Coordinates doctest execution via testing module
84
102
  def run_doctests_phase(
103
+ project_root: Path,
85
104
  checked_files: list[Path],
86
105
  explain: bool,
87
106
  timeout: int = 60,
@@ -103,12 +122,20 @@ def run_doctests_phase(
103
122
  return True, "", None
104
123
 
105
124
  doctest_result = run_doctests_on_files(
106
- checked_files, verbose=explain, timeout=timeout, collect_coverage=collect_coverage
125
+ checked_files,
126
+ verbose=explain,
127
+ timeout=timeout,
128
+ collect_coverage=collect_coverage,
129
+ cwd=project_root,
107
130
  )
108
131
  if isinstance(doctest_result, Success):
109
132
  result_data = doctest_result.unwrap()
110
133
  passed = result_data.get("status") in ("passed", "skipped")
111
- output = result_data.get("stdout", "")
134
+ stdout = result_data.get("stdout", "")
135
+ stderr = result_data.get("stderr", "")
136
+ output = stdout
137
+ if not passed and stderr:
138
+ output = f"{stdout}\n{stderr}" if stdout else stderr
112
139
  # DX-37: Return coverage data if collected
113
140
  coverage_data = {"collected": result_data.get("coverage_collected", False)}
114
141
  return passed, output, coverage_data if collect_coverage else None
@@ -176,6 +203,7 @@ def run_crosshair_phase(
176
203
  cache=cache,
177
204
  timeout=timeout,
178
205
  per_condition_timeout=per_condition_timeout,
206
+ project_root=path,
179
207
  )
180
208
 
181
209
  if isinstance(crosshair_result, Success):
@@ -230,26 +258,17 @@ def output_verification_status(
230
258
  console.print(doctest_output)
231
259
 
232
260
  # CrossHair results
233
- _output_crosshair_status(
234
- static_exit_code, doctest_passed, crosshair_output
235
- )
261
+ _output_crosshair_status(static_exit_code, doctest_passed, crosshair_output)
236
262
 
237
263
  # Property tests results
238
264
  if property_output:
239
- _output_property_tests_status(
240
- static_exit_code, doctest_passed, property_output
241
- )
265
+ _output_property_tests_status(static_exit_code, doctest_passed, property_output)
242
266
  else:
243
267
  console.print("[dim]⊘ Runtime tests skipped (static errors)[/dim]")
244
268
 
245
269
  # DX-26: Combined conclusion after all phases
246
270
  console.print("-" * 40)
247
- all_passed = (
248
- static_exit_code == 0
249
- and doctest_passed
250
- and crosshair_passed
251
- and property_passed
252
- )
271
+ all_passed = static_exit_code == 0 and doctest_passed and crosshair_passed and property_passed
253
272
  # In strict mode, warnings also cause failure (but exit code already reflects this)
254
273
  status = "passed" if all_passed else "failed"
255
274
  color = "green" if all_passed else "red"
@@ -259,6 +278,7 @@ def output_verification_status(
259
278
  # @shell_orchestration: Coordinates shell module calls for property testing
260
279
  # @shell_complexity: Property tests with result aggregation
261
280
  def run_property_tests_phase(
281
+ project_root: Path,
262
282
  checked_files: list[Path],
263
283
  doctest_passed: bool,
264
284
  static_exit_code: int,
@@ -290,7 +310,12 @@ def run_property_tests_phase(
290
310
  if not core_files:
291
311
  return True, {"status": "skipped", "reason": "no core files"}, None
292
312
 
293
- result = run_property_tests_on_files(core_files, max_examples, collect_coverage=collect_coverage)
313
+ result = run_property_tests_on_files(
314
+ core_files,
315
+ max_examples,
316
+ collect_coverage=collect_coverage,
317
+ project_root=project_root,
318
+ )
294
319
 
295
320
  if isinstance(result, Success):
296
321
  report, coverage_data = result.unwrap()
@@ -305,15 +330,19 @@ def run_property_tests_phase(
305
330
  for r in report.results
306
331
  if not r.passed
307
332
  ]
308
- return report.all_passed(), {
309
- "status": "passed" if report.all_passed() else "failed",
310
- "functions_tested": report.functions_tested,
311
- "functions_passed": report.functions_passed,
312
- "functions_failed": report.functions_failed,
313
- "total_examples": report.total_examples,
314
- "failures": failures, # DX-26: Structured failure info
315
- "errors": report.errors,
316
- }, coverage_data
333
+ return (
334
+ report.all_passed(),
335
+ {
336
+ "status": "passed" if report.all_passed() else "failed",
337
+ "functions_tested": report.functions_tested,
338
+ "functions_passed": report.functions_passed,
339
+ "functions_failed": report.functions_failed,
340
+ "total_examples": report.total_examples,
341
+ "failures": failures, # DX-26: Structured failure info
342
+ "errors": report.errors,
343
+ },
344
+ coverage_data,
345
+ )
317
346
 
318
347
  return False, {"status": "error", "error": result.failure()}, None
319
348
 
@@ -366,8 +395,8 @@ def _output_property_tests_status(
366
395
  # Show reproduction command with seed
367
396
  if seed:
368
397
  console.print(
369
- f" [dim]Reproduce: python -c \"from hypothesis import reproduce_failure; "
370
- f"import {func_name}\" --seed={seed}[/dim]"
398
+ f' [dim]Reproduce: python -c "from hypothesis import reproduce_failure; '
399
+ f'import {func_name}" --seed={seed}[/dim]'
371
400
  )
372
401
  # Fallback for errors without structured failures
373
402
  for error in property_output.get("errors", [])[:5]:
@@ -406,8 +435,7 @@ def _output_crosshair_status(
406
435
  if workers > 1:
407
436
  stats += f", {workers} workers"
408
437
  console.print(
409
- f"[green]✓ CrossHair verified[/green] "
410
- f"[dim]({stats}, {time_sec:.1f}s)[/dim]"
438
+ f"[green]✓ CrossHair verified[/green] [dim]({stats}, {time_sec:.1f}s)[/dim]"
411
439
  )
412
440
  else:
413
441
  console.print("[green]✓ CrossHair verified[/green]")
@@ -9,28 +9,57 @@ from __future__ import annotations
9
9
 
10
10
  import importlib.util
11
11
  import sys
12
+ from contextlib import contextmanager, suppress
12
13
  from typing import TYPE_CHECKING
13
14
 
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
14
18
  from returns.result import Failure, Result, Success
15
19
  from rich.console import Console
16
20
 
17
- from invar.core.property_gen import (
18
- PropertyTestReport,
19
- find_contracted_functions,
20
- run_property_test,
21
- )
22
-
23
- if TYPE_CHECKING:
24
- from pathlib import Path
21
+ from invar.core.property_gen import PropertyTestReport, find_contracted_functions, run_property_test
22
+ from invar.shell.subprocess_env import detect_project_venv, find_site_packages
25
23
 
26
24
  console = Console()
27
25
 
28
26
 
27
+ # @shell_orchestration: Temporarily inject venv site-packages for module imports
28
+ @contextmanager
29
+ def _inject_project_site_packages(project_root: Path):
30
+ venv = detect_project_venv(project_root)
31
+ site_packages = find_site_packages(venv) if venv is not None else None
32
+
33
+ if site_packages is None:
34
+ yield
35
+ return
36
+
37
+ src_dir = project_root / "src"
38
+
39
+ added: list[str] = []
40
+ if src_dir.exists():
41
+ src_dir_str = str(src_dir)
42
+ sys.path.insert(0, src_dir_str)
43
+ added.append(src_dir_str)
44
+
45
+ site_packages_str = str(site_packages)
46
+ sys.path.insert(0, site_packages_str)
47
+ added.append(site_packages_str)
48
+
49
+ try:
50
+ yield
51
+ finally:
52
+ for p in added:
53
+ with suppress(ValueError):
54
+ sys.path.remove(p)
55
+
56
+
29
57
  # @shell_complexity: Property test orchestration with module import
30
58
  def run_property_tests_on_file(
31
59
  file_path: Path,
32
60
  max_examples: int = 100,
33
61
  verbose: bool = False,
62
+ project_root: Path | None = None,
34
63
  ) -> Result[PropertyTestReport, str]:
35
64
  """
36
65
  Run property tests on all contracted functions in a file.
@@ -66,8 +95,10 @@ def run_property_tests_on_file(
66
95
  if not contracted:
67
96
  return Success(PropertyTestReport()) # No contracted functions, skip
68
97
 
69
- # Import the module to get actual function objects
70
- module = _import_module_from_path(file_path)
98
+ root = project_root or file_path.parent
99
+ with _inject_project_site_packages(root):
100
+ module = _import_module_from_path(file_path)
101
+
71
102
  if module is None:
72
103
  return Failure(f"Could not import module: {file_path}")
73
104
 
@@ -105,6 +136,7 @@ def run_property_tests_on_files(
105
136
  max_examples: int = 100,
106
137
  verbose: bool = False,
107
138
  collect_coverage: bool = False,
139
+ project_root: Path | None = None,
108
140
  ) -> Result[tuple[PropertyTestReport, dict | None], str]:
109
141
  """
110
142
  Run property tests on multiple files.
@@ -122,9 +154,9 @@ def run_property_tests_on_files(
122
154
  try:
123
155
  import hypothesis # noqa: F401
124
156
  except ImportError:
125
- return Success((PropertyTestReport(
126
- errors=["Hypothesis not installed (pip install hypothesis)"]
127
- ), None))
157
+ return Success(
158
+ (PropertyTestReport(errors=["Hypothesis not installed (pip install hypothesis)"]), None)
159
+ )
128
160
 
129
161
  combined_report = PropertyTestReport()
130
162
  coverage_data = None
@@ -138,7 +170,9 @@ def run_property_tests_on_files(
138
170
  source_dirs = list({f.parent for f in files})
139
171
  with cov_ctx(source_dirs) as cov:
140
172
  for file_path in files:
141
- result = run_property_tests_on_file(file_path, max_examples, verbose)
173
+ result = run_property_tests_on_file(
174
+ file_path, max_examples, verbose, project_root=project_root
175
+ )
142
176
  _accumulate_report(combined_report, result)
143
177
 
144
178
  # Extract coverage after all tests
@@ -151,11 +185,15 @@ def run_property_tests_on_files(
151
185
  except ImportError:
152
186
  # coverage not installed, run without it
153
187
  for file_path in files:
154
- result = run_property_tests_on_file(file_path, max_examples, verbose)
188
+ result = run_property_tests_on_file(
189
+ file_path, max_examples, verbose, project_root=project_root
190
+ )
155
191
  _accumulate_report(combined_report, result)
156
192
  else:
157
193
  for file_path in files:
158
- result = run_property_tests_on_file(file_path, max_examples, verbose)
194
+ result = run_property_tests_on_file(
195
+ file_path, max_examples, verbose, project_root=project_root
196
+ )
159
197
  _accumulate_report(combined_report, result)
160
198
 
161
199
  return Success((combined_report, coverage_data))
@@ -222,26 +260,29 @@ def format_property_test_report(
222
260
  import json
223
261
 
224
262
  if json_output:
225
- return json.dumps({
226
- "functions_tested": report.functions_tested,
227
- "functions_passed": report.functions_passed,
228
- "functions_failed": report.functions_failed,
229
- "functions_skipped": report.functions_skipped,
230
- "total_examples": report.total_examples,
231
- "all_passed": report.all_passed(),
232
- "results": [
233
- {
234
- "function": r.function_name,
235
- "passed": r.passed,
236
- "examples": r.examples_run,
237
- "error": r.error,
238
- "file_path": r.file_path, # DX-26
239
- "seed": r.seed, # DX-26
240
- }
241
- for r in report.results
242
- ],
243
- "errors": report.errors,
244
- }, indent=2)
263
+ return json.dumps(
264
+ {
265
+ "functions_tested": report.functions_tested,
266
+ "functions_passed": report.functions_passed,
267
+ "functions_failed": report.functions_failed,
268
+ "functions_skipped": report.functions_skipped,
269
+ "total_examples": report.total_examples,
270
+ "all_passed": report.all_passed(),
271
+ "results": [
272
+ {
273
+ "function": r.function_name,
274
+ "passed": r.passed,
275
+ "examples": r.examples_run,
276
+ "error": r.error,
277
+ "file_path": r.file_path, # DX-26
278
+ "seed": r.seed, # DX-26
279
+ }
280
+ for r in report.results
281
+ ],
282
+ "errors": report.errors,
283
+ },
284
+ indent=2,
285
+ )
245
286
 
246
287
  # Human-readable format
247
288
  lines = []
@@ -263,10 +304,16 @@ def format_property_test_report(
263
304
  for result in report.results:
264
305
  if not result.passed:
265
306
  # DX-26: file::function format
266
- location = f"{result.file_path}::{result.function_name}" if result.file_path else result.function_name
307
+ location = (
308
+ f"{result.file_path}::{result.function_name}"
309
+ if result.file_path
310
+ else result.function_name
311
+ )
267
312
  lines.append(f" [red]✗[/red] {location}")
268
313
  if result.error:
269
- short_error = result.error[:100] + "..." if len(result.error) > 100 else result.error
314
+ short_error = (
315
+ result.error[:100] + "..." if len(result.error) > 100 else result.error
316
+ )
270
317
  lines.append(f" {short_error}")
271
318
  if result.seed:
272
319
  lines.append(f" [dim]Seed: {result.seed}[/dim]")
@@ -12,7 +12,7 @@ import os
12
12
  import subprocess
13
13
  import sys
14
14
  from concurrent.futures import ProcessPoolExecutor, as_completed
15
- from pathlib import Path # noqa: TC003 - used at runtime
15
+ from pathlib import Path
16
16
  from typing import TYPE_CHECKING
17
17
 
18
18
  from returns.result import Failure, Result, Success
@@ -82,10 +82,7 @@ def has_verifiable_contracts(source: str) -> bool:
82
82
  if isinstance(func, ast.Name) and func.id in contract_decorators:
83
83
  return True
84
84
  # @deal.pre(...) or @deal.post(...)
85
- if (
86
- isinstance(func, ast.Attribute)
87
- and func.attr in contract_decorators
88
- ):
85
+ if isinstance(func, ast.Attribute) and func.attr in contract_decorators:
89
86
  return True
90
87
 
91
88
  return False
@@ -102,6 +99,7 @@ def _verify_single_file(
102
99
  max_iterations: int = 5,
103
100
  timeout: int = 300,
104
101
  per_condition_timeout: int = 30,
102
+ project_root: str | None = None,
105
103
  ) -> dict[str, Any]:
106
104
  """
107
105
  Verify a single file with CrossHair.
@@ -133,13 +131,14 @@ def _verify_single_file(
133
131
  ]
134
132
 
135
133
  try:
136
- # DX-52: Inject project venv site-packages for uvx compatibility
134
+ env_root = Path(project_root) if project_root else None
137
135
  result = subprocess.run(
138
136
  cmd,
139
137
  capture_output=True,
140
138
  text=True,
141
139
  timeout=timeout,
142
- env=build_subprocess_env(),
140
+ cwd=project_root,
141
+ env=build_subprocess_env(cwd=env_root),
143
142
  )
144
143
 
145
144
  elapsed_ms = int((time.time() - start_time) * 1000)
@@ -222,6 +221,7 @@ def run_crosshair_parallel(
222
221
  cache: ProveCache | None = None,
223
222
  timeout: int = 300,
224
223
  per_condition_timeout: int = 30,
224
+ project_root: Path | None = None,
225
225
  ) -> Result[dict, str]:
226
226
  """Run CrossHair on multiple files in parallel (DX-13).
227
227
 
@@ -331,7 +331,12 @@ def run_crosshair_parallel(
331
331
  with ProcessPoolExecutor(max_workers=max_workers) as executor:
332
332
  futures = {
333
333
  executor.submit(
334
- _verify_single_file, str(f), max_iterations, timeout, per_condition_timeout
334
+ _verify_single_file,
335
+ str(f.resolve()),
336
+ max_iterations,
337
+ timeout,
338
+ per_condition_timeout,
339
+ str(project_root) if project_root else None,
335
340
  ): f
336
341
  for f in files_to_verify
337
342
  }
@@ -355,7 +360,11 @@ def run_crosshair_parallel(
355
360
  # Sequential execution (single file or max_workers=1)
356
361
  for py_file in files_to_verify:
357
362
  result = _verify_single_file(
358
- str(py_file), max_iterations, timeout, per_condition_timeout
363
+ str(py_file.resolve()),
364
+ max_iterations,
365
+ timeout,
366
+ per_condition_timeout,
367
+ str(project_root) if project_root else None,
359
368
  )
360
369
  _process_verification_result(
361
370
  result,
@@ -368,9 +377,7 @@ def run_crosshair_parallel(
368
377
  total_time_ms += result.get("time_ms", 0)
369
378
 
370
379
  # Determine overall status
371
- status = (
372
- CrossHairStatus.VERIFIED if not failed_files else CrossHairStatus.COUNTEREXAMPLE
373
- )
380
+ status = CrossHairStatus.VERIFIED if not failed_files else CrossHairStatus.COUNTEREXAMPLE
374
381
 
375
382
  return Success(
376
383
  {
@@ -135,13 +135,17 @@ def build_subprocess_env(cwd: Path | None = None) -> dict[str, str]:
135
135
  if site_packages is None:
136
136
  return env
137
137
 
138
- # Prepend to PYTHONPATH (project packages have priority)
139
138
  current = env.get("PYTHONPATH", "")
140
139
  separator = ";" if os.name == "nt" else ":"
141
- if current:
142
- env["PYTHONPATH"] = f"{site_packages}{separator}{current}"
143
- else:
144
- env["PYTHONPATH"] = str(site_packages)
140
+
141
+ src_dir = project_root / "src"
142
+ prefix_parts: list[str] = []
143
+ if src_dir.exists():
144
+ prefix_parts.append(str(src_dir))
145
+ prefix_parts.append(str(site_packages))
146
+
147
+ prefix = separator.join(prefix_parts)
148
+ env["PYTHONPATH"] = f"{prefix}{separator}{current}" if current else prefix
145
149
 
146
150
  return env
147
151
 
invar/shell/testing.py CHANGED
@@ -119,6 +119,7 @@ def run_doctests_on_files(
119
119
  verbose: bool = False,
120
120
  timeout: int = 60,
121
121
  collect_coverage: bool = False,
122
+ cwd: Path | None = None,
122
123
  ) -> Result[dict, str]:
123
124
  """
124
125
  Run doctests on a list of Python files.
@@ -143,17 +144,16 @@ def run_doctests_on_files(
143
144
  parts = f.parts
144
145
  # Check for consecutive "templates/examples" or ".invar/examples"
145
146
  for i in range(len(parts) - 1):
146
- if (parts[i] == "templates" and parts[i + 1] == "examples") or \
147
- (parts[i] == ".invar" and parts[i + 1] == "examples"):
147
+ if (parts[i] == "templates" and parts[i + 1] == "examples") or (
148
+ parts[i] == ".invar" and parts[i + 1] == "examples"
149
+ ):
148
150
  return True
149
151
  return False
150
152
 
151
153
  py_files = [
152
- f for f in files
153
- if f.suffix == ".py"
154
- and f.exists()
155
- and f.name != "conftest.py"
156
- and not is_excluded(f)
154
+ f
155
+ for f in files
156
+ if f.suffix == ".py" and f.exists() and f.name != "conftest.py" and not is_excluded(f)
157
157
  ]
158
158
  if not py_files:
159
159
  return Success({"status": "skipped", "reason": "no Python files", "files": []})
@@ -162,16 +162,26 @@ def run_doctests_on_files(
162
162
  if collect_coverage:
163
163
  # Use coverage run to wrap pytest
164
164
  cmd = [
165
- sys.executable, "-m", "coverage", "run",
165
+ sys.executable,
166
+ "-m",
167
+ "coverage",
168
+ "run",
166
169
  "--branch", # Enable branch coverage
167
170
  "--parallel-mode", # For merging with hypothesis later
168
- "-m", "pytest",
169
- "--doctest-modules", "-x", "--tb=short",
171
+ "-m",
172
+ "pytest",
173
+ "--doctest-modules",
174
+ "-x",
175
+ "--tb=short",
170
176
  ]
171
177
  else:
172
178
  cmd = [
173
- sys.executable, "-m", "pytest",
174
- "--doctest-modules", "-x", "--tb=short",
179
+ sys.executable,
180
+ "-m",
181
+ "pytest",
182
+ "--doctest-modules",
183
+ "-x",
184
+ "--tb=short",
175
185
  ]
176
186
  cmd.extend(str(f) for f in py_files)
177
187
  if verbose:
@@ -188,14 +198,16 @@ def run_doctests_on_files(
188
198
  )
189
199
  # Pytest exit codes: 0=passed, 5=no tests collected (also OK)
190
200
  is_passed = result.returncode in (0, 5)
191
- return Success({
192
- "status": "passed" if is_passed else "failed",
193
- "files": [str(f) for f in py_files],
194
- "exit_code": result.returncode,
195
- "stdout": result.stdout,
196
- "stderr": result.stderr,
197
- "coverage_collected": collect_coverage, # DX-37: Flag for caller
198
- })
201
+ return Success(
202
+ {
203
+ "status": "passed" if is_passed else "failed",
204
+ "files": [str(f) for f in py_files],
205
+ "exit_code": result.returncode,
206
+ "stdout": result.stdout,
207
+ "stderr": result.stderr,
208
+ "coverage_collected": collect_coverage, # DX-37: Flag for caller
209
+ }
210
+ )
199
211
  except subprocess.TimeoutExpired:
200
212
  return Failure(f"Doctest timeout ({timeout}s)")
201
213
  except Exception as e:
@@ -204,7 +216,11 @@ def run_doctests_on_files(
204
216
 
205
217
  # @shell_complexity: Property test orchestration with subprocess
206
218
  def run_test(
207
- target: str, json_output: bool = False, verbose: bool = False, timeout: int = 300
219
+ target: str,
220
+ json_output: bool = False,
221
+ verbose: bool = False,
222
+ timeout: int = 300,
223
+ cwd: Path | None = None,
208
224
  ) -> Result[dict, str]:
209
225
  """
210
226
  Run property-based tests using Hypothesis via deal.cases.
@@ -225,20 +241,25 @@ def run_test(
225
241
  return Failure(f"Target must be a Python file: {target}")
226
242
 
227
243
  cmd = [
228
- sys.executable, "-m", "pytest",
229
- str(target_path), "--doctest-modules", "-x", "--tb=short",
244
+ sys.executable,
245
+ "-m",
246
+ "pytest",
247
+ str(target_path),
248
+ "--doctest-modules",
249
+ "-x",
250
+ "--tb=short",
230
251
  ]
231
252
  if verbose:
232
253
  cmd.append("-v")
233
254
 
234
255
  try:
235
- # DX-52: Inject project venv site-packages for uvx compatibility
236
256
  result = subprocess.run(
237
257
  cmd,
238
258
  capture_output=True,
239
259
  text=True,
240
260
  timeout=timeout,
241
- env=build_subprocess_env(),
261
+ cwd=str(cwd) if cwd is not None else None,
262
+ env=build_subprocess_env(cwd=cwd),
242
263
  )
243
264
  test_result = {
244
265
  "status": "passed" if result.returncode == 0 else "failed",
@@ -274,6 +295,7 @@ def run_verify(
274
295
  json_output: bool = False,
275
296
  total_timeout: int = 300,
276
297
  per_condition_timeout: int = 30,
298
+ cwd: Path | None = None,
277
299
  ) -> Result[dict, str]:
278
300
  """
279
301
  Run symbolic verification using CrossHair.
@@ -302,23 +324,28 @@ def run_verify(
302
324
  return Failure(f"Target must be a Python file: {target}")
303
325
 
304
326
  cmd = [
305
- sys.executable, "-m", "crosshair", "check",
306
- str(target_path), f"--per_condition_timeout={per_condition_timeout}",
327
+ sys.executable,
328
+ "-m",
329
+ "crosshair",
330
+ "check",
331
+ str(target_path),
332
+ f"--per_condition_timeout={per_condition_timeout}",
307
333
  ]
308
334
 
309
335
  try:
310
- # DX-52: Inject project venv site-packages for uvx compatibility
311
336
  result = subprocess.run(
312
337
  cmd,
313
338
  capture_output=True,
314
339
  text=True,
315
340
  timeout=total_timeout,
316
- env=build_subprocess_env(),
341
+ cwd=str(cwd) if cwd is not None else None,
342
+ env=build_subprocess_env(cwd=cwd),
317
343
  )
318
344
 
319
345
  # CrossHair format: "file:line: error: Err when calling func(...)"
320
346
  counterexamples = [
321
- line.strip() for line in result.stdout.split("\n")
347
+ line.strip()
348
+ for line in result.stdout.split("\n")
322
349
  if ": error:" in line.lower() or "counterexample" in line.lower()
323
350
  ]
324
351
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: invar-tools
3
- Version: 1.17.19
3
+ Version: 1.17.20
4
4
  Summary: AI-native software engineering tools with design-by-contract verification
5
5
  Project-URL: Homepage, https://github.com/tefx/invar
6
6
  Project-URL: Documentation, https://github.com/tefx/invar#readme
@@ -23,13 +23,14 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
24
  Requires-Dist: crosshair-tool>=0.0.60
25
25
  Requires-Dist: hypothesis>=6.0
26
- Requires-Dist: invar-runtime>=1.0
26
+ Requires-Dist: invar-runtime>=1.3.0
27
27
  Requires-Dist: jedi>=0.19
28
28
  Requires-Dist: jinja2>=3.0
29
29
  Requires-Dist: markdown-it-py>=3.0
30
30
  Requires-Dist: mcp>=1.0
31
31
  Requires-Dist: pre-commit>=3.0
32
32
  Requires-Dist: pydantic>=2.0
33
+ Requires-Dist: pytest>=7.0
33
34
  Requires-Dist: questionary>=2.0
34
35
  Requires-Dist: returns>=0.20
35
36
  Requires-Dist: rich>=13.0
@@ -38,7 +39,6 @@ Provides-Extra: dev
38
39
  Requires-Dist: coverage[toml]>=7.0; extra == 'dev'
39
40
  Requires-Dist: mypy>=1.0; extra == 'dev'
40
41
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
41
- Requires-Dist: pytest>=7.0; extra == 'dev'
42
42
  Requires-Dist: ruff>=0.1; extra == 'dev'
43
43
  Description-Content-Type: text/markdown
44
44
 
@@ -2657,31 +2657,31 @@ invar/node_tools/quick-check/cli.js,sha256=dwV3hdJleFQga2cKUn3PPfQDvvujhzKdjQcIv
2657
2657
  invar/node_tools/ts-analyzer/cli.js,sha256=SvZ6HyjmobpP8NAZqXFiy8BwH_t5Hb17Ytar_18udaQ,4092887
2658
2658
  invar/shell/__init__.py,sha256=FFw1mNbh_97PeKPcHIqQpQ7mw-JoIvyLM1yOdxLw5uk,204
2659
2659
  invar/shell/claude_hooks.py,sha256=hV4DfG3cVng32f0Rxoo070tliVlYFC5v9slIWEbAD7E,18899
2660
- invar/shell/config.py,sha256=6-kbo6--SxfROXoyU-v7InSLR8f_U1Mar_xEOdCXFkY,17633
2660
+ invar/shell/config.py,sha256=6N4AvhYPSUzd3YGXPIc8edF6Lp492W-cS8wwnHUJotI,18119
2661
2661
  invar/shell/contract_coverage.py,sha256=81OQkQqUVYUKytG5aiJyRK62gwh9UzbSG926vkvFTc8,12088
2662
2662
  invar/shell/coverage.py,sha256=m01o898IFIdBztEBQLwwL1Vt5PWrpUntO4lv4nWEkls,11344
2663
2663
  invar/shell/doc_tools.py,sha256=16gvo_ay9-_EK6lX16WkiRGg4OfTAKK_i0ucQkE7lbI,15149
2664
2664
  invar/shell/fs.py,sha256=ctqU-EX0NnKC4txudRCRpbWxWSgBZTInXMeOUnl3IM0,6196
2665
- invar/shell/git.py,sha256=s6RQxEDQuLrmK3mru88EoYP8__4hiFW8AozlcxmY47E,2784
2666
- invar/shell/guard_helpers.py,sha256=QeYgbW0lgUa9Z_RCjAMG7UJdiMzz5cW48Lb2u-qgQi8,15114
2665
+ invar/shell/git.py,sha256=R-ynlYa65xtCdnNjHeu42uPyrqoo9KZDzl7BZUW0oWU,2866
2666
+ invar/shell/guard_helpers.py,sha256=lpaFIe328ZISzim92TAxZHTT8jC4N0_TcQ7PV7u327w,16083
2667
2667
  invar/shell/guard_output.py,sha256=v3gG5P-_47nIFo8eAMKwdA_hLf2KZ0cQ-45Z6JjKp4w,12520
2668
2668
  invar/shell/mcp_config.py,sha256=-hC7Y5BGuVs285b6gBARk7ZyzVxHwPgXSyt_GoN0jfs,4580
2669
2669
  invar/shell/mutation.py,sha256=Lfyk2b8j8-hxAq-iwAgQeOhr7Ci6c5tRF1TXe3CxQCs,8914
2670
2670
  invar/shell/pattern_integration.py,sha256=pRcjfq3NvMW_tvQCnaXZnD1k5AVEWK8CYOE2jN6VTro,7842
2671
2671
  invar/shell/pi_hooks.py,sha256=ulZc1sP8mTRJTBsjwFHQzUgg-h8ajRIMp7iF1Y4UUtw,6885
2672
2672
  invar/shell/pi_tools.py,sha256=a3ACDDXykFV8fUB5UpBmgMvppwkmLvT1k_BWm0IY47k,4068
2673
- invar/shell/property_tests.py,sha256=N9JreyH5PqR89oF5yLcX7ZAV-Koyg5BKo-J05-GUPsA,9109
2673
+ invar/shell/property_tests.py,sha256=SB-3tS5TupYQP1jpEUNpQs6fWCwLWxck0akNsgVwGGM,10533
2674
2674
  invar/shell/py_refs.py,sha256=Vjz50lmt9prDBcBv4nkkODdiJ7_DKu5zO4UPZBjAfmM,4638
2675
2675
  invar/shell/skill_manager.py,sha256=Mr7Mh9rxPSKSAOTJCAM5ZHiG5nfUf6KQVCuD4LBNHSI,12440
2676
- invar/shell/subprocess_env.py,sha256=9oXl3eMEbzLsFEgMHqobEw6oW_wV0qMEP7pklwm58Pw,11453
2676
+ invar/shell/subprocess_env.py,sha256=ToPqGw5bnfJIEk5pdvJBM6dslKxn6gqspDnhr1VRCDk,11554
2677
2677
  invar/shell/template_engine.py,sha256=eNKMz7R8g9Xp3_1TGx-QH137jf52E0u3KaVcnotu1Tg,12056
2678
2678
  invar/shell/templates.py,sha256=ilhGysbUcdkUFqPgv6ySVmKI3imS_cwYNCWTCdyb5cY,15407
2679
- invar/shell/testing.py,sha256=rTNBH0Okh2qtG9ohSXOz487baQ2gXrWT3s_WECW3HJs,11143
2679
+ invar/shell/testing.py,sha256=ig-LVe5k2-qvdz2H85mrY3_7_v440DJJX72Q3TVP5Xk,11472
2680
2680
  invar/shell/ts_compiler.py,sha256=nA8brnOhThj9J_J3vAEGjDsM4NjbWQ_eX8Yf4pHPOgk,6672
2681
2681
  invar/shell/commands/__init__.py,sha256=MEkKwVyjI9DmkvBpJcuumXo2Pg_FFkfEr-Rr3nrAt7A,284
2682
2682
  invar/shell/commands/doc.py,sha256=SOLDoCXXGxx_JU0PKXlAIGEF36PzconHmmAtL-rM6D4,13819
2683
2683
  invar/shell/commands/feedback.py,sha256=lLxEeWW_71US_vlmorFrGXS8IARB9nbV6D0zruLs660,7640
2684
- invar/shell/commands/guard.py,sha256=I1BDqthsAVmz8FnUgwkbhvy2cozmOfjs3rxQwwqFemo,25391
2684
+ invar/shell/commands/guard.py,sha256=ZeiK5FvRlTNHMLS6StIDKl-mDAdyAFGOovkRy0tG4eQ,25703
2685
2685
  invar/shell/commands/hooks.py,sha256=W-SOnT4VQyUvXwipozkJwgEYfiOJGz7wksrbcdWegUg,2356
2686
2686
  invar/shell/commands/init.py,sha256=rtoPFsfq7xRZ6lfTipWT1OejNK5wfzqu1ncXi1kizU0,23634
2687
2687
  invar/shell/commands/merge.py,sha256=nuvKo8m32-OL-SCQlS4SLKmOZxQ3qj-1nGCx1Pgzifw,8183
@@ -2696,7 +2696,7 @@ invar/shell/commands/update.py,sha256=-NNQQScEb_0bV1B8YFxTjpUECk9c8dGAiNVRc64nWh
2696
2696
  invar/shell/prove/__init__.py,sha256=ZqlbmyMFJf6yAle8634jFuPRv8wNvHps8loMlOJyf8A,240
2697
2697
  invar/shell/prove/accept.py,sha256=cnY_6jzU1EBnpLF8-zWUWcXiSXtCwxPsXEYXsSVPG38,3717
2698
2698
  invar/shell/prove/cache.py,sha256=jbNdrvfLjvK7S0iqugErqeabb4YIbQuwIlcSRyCKbcg,4105
2699
- invar/shell/prove/crosshair.py,sha256=XhJDsQWIriX9SuqeflUYvJgp9gJTDH7I7Uka6zjNzZ0,16734
2699
+ invar/shell/prove/crosshair.py,sha256=XDOoLvOrU_6OGYm2rTjWacdvKgpJ3NjUDetMwyT9kIE,16997
2700
2700
  invar/shell/prove/guard_ts.py,sha256=8InEWFd6oqFxBUEaAdlqOvPi43p0VZKxJIrAaF0eReU,37936
2701
2701
  invar/shell/prove/hypothesis.py,sha256=QUclOOUg_VB6wbmHw8O2EPiL5qBOeBRqQeM04AVuLw0,9880
2702
2702
  invar/templates/CLAUDE.md.template,sha256=eaGU3SyRO_NEifw5b26k3srgQH4jyeujjCJ-HbM36_w,4913
@@ -2778,10 +2778,10 @@ invar/templates/skills/invar-reflect/template.md,sha256=Rr5hvbllvmd8jSLf_0ZjyKt6
2778
2778
  invar/templates/skills/investigate/SKILL.md.jinja,sha256=cp6TBEixBYh1rLeeHOR1yqEnFqv1NZYePORMnavLkQI,3231
2779
2779
  invar/templates/skills/propose/SKILL.md.jinja,sha256=6BuKiCqO1AEu3VtzMHy1QWGqr_xqG9eJlhbsKT4jev4,3463
2780
2780
  invar/templates/skills/review/SKILL.md.jinja,sha256=ET5mbdSe_eKgJbi2LbgFC-z1aviKcHOBw7J5Q28fr4U,14105
2781
- invar_tools-1.17.19.dist-info/METADATA,sha256=ecuhzoJZslp81KIMpPROD2LH-owOo4p8viFpMB5uKbw,28596
2782
- invar_tools-1.17.19.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
2783
- invar_tools-1.17.19.dist-info/entry_points.txt,sha256=RwH_EhqgtFPsnO6RcrwrAb70Zyfb8Mh6uUtztWnUxGk,102
2784
- invar_tools-1.17.19.dist-info/licenses/LICENSE,sha256=qeFksp4H4kfTgQxPCIu3OdagXyiZcgBlVfsQ6M5oFyk,10767
2785
- invar_tools-1.17.19.dist-info/licenses/LICENSE-GPL,sha256=IvZfC6ZbP7CLjytoHVzvpDZpD-Z3R_qa1GdMdWlWQ6Q,35157
2786
- invar_tools-1.17.19.dist-info/licenses/NOTICE,sha256=joEyMyFhFY8Vd8tTJ-a3SirI0m2Sd0WjzqYt3sdcglc,2561
2787
- invar_tools-1.17.19.dist-info/RECORD,,
2781
+ invar_tools-1.17.20.dist-info/METADATA,sha256=_RDBs3-pAvqNBOLkNCMctnSLdDn-4Qs33RhgxIoEbok,28582
2782
+ invar_tools-1.17.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
2783
+ invar_tools-1.17.20.dist-info/entry_points.txt,sha256=RwH_EhqgtFPsnO6RcrwrAb70Zyfb8Mh6uUtztWnUxGk,102
2784
+ invar_tools-1.17.20.dist-info/licenses/LICENSE,sha256=qeFksp4H4kfTgQxPCIu3OdagXyiZcgBlVfsQ6M5oFyk,10767
2785
+ invar_tools-1.17.20.dist-info/licenses/LICENSE-GPL,sha256=IvZfC6ZbP7CLjytoHVzvpDZpD-Z3R_qa1GdMdWlWQ6Q,35157
2786
+ invar_tools-1.17.20.dist-info/licenses/NOTICE,sha256=joEyMyFhFY8Vd8tTJ-a3SirI0m2Sd0WjzqYt3sdcglc,2561
2787
+ invar_tools-1.17.20.dist-info/RECORD,,