invar-tools 1.17.12__py3-none-any.whl → 1.17.24__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.
@@ -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, project_root=root)
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))
@@ -180,15 +218,56 @@ def _accumulate_report(
180
218
  combined_report.errors.extend(file_report.errors)
181
219
 
182
220
 
183
- def _import_module_from_path(file_path: Path) -> object | None:
221
+ # @shell_complexity: BUG-57 fix requires package hierarchy setup for relative imports
222
+ def _import_module_from_path(file_path: Path, project_root: Path | None = None) -> object | None:
184
223
  """
185
224
  Import a Python module from a file path.
186
225
 
226
+ BUG-57: Properly handles relative imports by setting up package context.
227
+
187
228
  Returns None if import fails.
188
229
  """
189
230
  try:
190
- module_name = file_path.stem
191
- spec = importlib.util.spec_from_file_location(module_name, file_path)
231
+ # Calculate the full module name from project root
232
+ if project_root and file_path.is_relative_to(project_root):
233
+ # Convert path to module name: my_package/main.py -> my_package.main
234
+ relative = file_path.relative_to(project_root)
235
+ parts = list(relative.with_suffix("").parts)
236
+ module_name = ".".join(parts)
237
+ else:
238
+ module_name = file_path.stem
239
+
240
+ # Ensure project root is in sys.path for relative imports
241
+ if project_root:
242
+ root_str = str(project_root)
243
+ if root_str not in sys.path:
244
+ sys.path.insert(0, root_str)
245
+
246
+ # For packages with relative imports, we need to set up parent packages first
247
+ if "." in module_name:
248
+ # Import parent packages first
249
+ parts = module_name.split(".")
250
+ for i in range(1, len(parts)):
251
+ parent_name = ".".join(parts[:i])
252
+ if parent_name not in sys.modules:
253
+ parent_path = project_root / "/".join(parts[:i]) if project_root else None
254
+ if parent_path and (parent_path / "__init__.py").exists():
255
+ parent_spec = importlib.util.spec_from_file_location(
256
+ parent_name,
257
+ parent_path / "__init__.py",
258
+ submodule_search_locations=[str(parent_path)],
259
+ )
260
+ if parent_spec and parent_spec.loader:
261
+ parent_module = importlib.util.module_from_spec(parent_spec)
262
+ sys.modules[parent_name] = parent_module
263
+ parent_spec.loader.exec_module(parent_module)
264
+
265
+ # Now import the target module
266
+ spec = importlib.util.spec_from_file_location(
267
+ module_name,
268
+ file_path,
269
+ submodule_search_locations=[str(file_path.parent)],
270
+ )
192
271
  if spec is None or spec.loader is None:
193
272
  return None
194
273
 
@@ -222,26 +301,29 @@ def format_property_test_report(
222
301
  import json
223
302
 
224
303
  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)
304
+ return json.dumps(
305
+ {
306
+ "functions_tested": report.functions_tested,
307
+ "functions_passed": report.functions_passed,
308
+ "functions_failed": report.functions_failed,
309
+ "functions_skipped": report.functions_skipped,
310
+ "total_examples": report.total_examples,
311
+ "all_passed": report.all_passed(),
312
+ "results": [
313
+ {
314
+ "function": r.function_name,
315
+ "passed": r.passed,
316
+ "examples": r.examples_run,
317
+ "error": r.error,
318
+ "file_path": r.file_path, # DX-26
319
+ "seed": r.seed, # DX-26
320
+ }
321
+ for r in report.results
322
+ ],
323
+ "errors": report.errors,
324
+ },
325
+ indent=2,
326
+ )
245
327
 
246
328
  # Human-readable format
247
329
  lines = []
@@ -263,10 +345,16 @@ def format_property_test_report(
263
345
  for result in report.results:
264
346
  if not result.passed:
265
347
  # DX-26: file::function format
266
- location = f"{result.file_path}::{result.function_name}" if result.file_path else result.function_name
348
+ location = (
349
+ f"{result.file_path}::{result.function_name}"
350
+ if result.file_path
351
+ else result.function_name
352
+ )
267
353
  lines.append(f" [red]✗[/red] {location}")
268
354
  if result.error:
269
- short_error = result.error[:100] + "..." if len(result.error) > 100 else result.error
355
+ short_error = (
356
+ result.error[:100] + "..." if len(result.error) > 100 else result.error
357
+ )
270
358
  lines.append(f" {short_error}")
271
359
  if result.seed:
272
360
  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,15 +82,94 @@ 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
92
89
 
93
90
 
91
+ # BUG-56: Detect Literal types that CrossHair cannot handle
92
+ # @shell_orchestration: Pre-filter for CrossHair verification
93
+ # @shell_complexity: AST traversal for type annotation analysis
94
+ def has_literal_in_contracted_functions(source: str) -> bool:
95
+ """Check if any contracted function uses Literal types in parameters.
96
+
97
+ CrossHair cannot symbolically execute Literal types and silently skips them.
98
+ This function detects such cases for pre-filtering and warning.
99
+ """
100
+ # Fast path: no Literal import
101
+ if "Literal" not in source:
102
+ return False
103
+
104
+ # Fast path: no contracts
105
+ if "@pre" not in source and "@post" not in source:
106
+ return False
107
+
108
+ try:
109
+ import ast
110
+
111
+ tree = ast.parse(source)
112
+ except SyntaxError:
113
+ return False # Can't analyze, don't skip
114
+
115
+ contract_decorators = {"pre", "post"}
116
+
117
+ def has_contract(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
118
+ for dec in node.decorator_list:
119
+ if isinstance(dec, ast.Call):
120
+ func = dec.func
121
+ if isinstance(func, ast.Name) and func.id in contract_decorators:
122
+ return True
123
+ if isinstance(func, ast.Attribute) and func.attr in contract_decorators:
124
+ return True
125
+ return False
126
+
127
+ def annotation_uses_literal(annotation: ast.expr | None) -> bool:
128
+ """Check if annotation contains Literal type."""
129
+ if annotation is None:
130
+ return False
131
+
132
+ # Direct Literal["..."]
133
+ if isinstance(annotation, ast.Subscript):
134
+ value = annotation.value
135
+ if isinstance(value, ast.Name) and value.id == "Literal":
136
+ return True
137
+ if isinstance(value, ast.Attribute) and value.attr == "Literal":
138
+ return True
139
+ # Check nested (e.g., Optional[Literal["..."]])
140
+ if annotation_uses_literal(annotation.slice):
141
+ return True
142
+ if annotation_uses_literal(value):
143
+ return True
144
+
145
+ # Union types
146
+ if isinstance(annotation, ast.BinOp): # X | Y syntax
147
+ return annotation_uses_literal(annotation.left) or annotation_uses_literal(
148
+ annotation.right
149
+ )
150
+
151
+ # Tuple of types (for Subscript.slice)
152
+ if isinstance(annotation, ast.Tuple):
153
+ return any(annotation_uses_literal(elt) for elt in annotation.elts)
154
+
155
+ return False
156
+
157
+ for node in ast.walk(tree):
158
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
159
+ if has_contract(node):
160
+ # Check all parameter annotations
161
+ for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
162
+ if annotation_uses_literal(arg.annotation):
163
+ return True
164
+ # Check *args and **kwargs
165
+ if node.args.vararg and annotation_uses_literal(node.args.vararg.annotation):
166
+ return True
167
+ if node.args.kwarg and annotation_uses_literal(node.args.kwarg.annotation):
168
+ return True
169
+
170
+ return False
171
+
172
+
94
173
  # ============================================================
95
174
  # DX-13: Single File Verification (for parallel execution)
96
175
  # ============================================================
@@ -102,6 +181,7 @@ def _verify_single_file(
102
181
  max_iterations: int = 5,
103
182
  timeout: int = 300,
104
183
  per_condition_timeout: int = 30,
184
+ project_root: str | None = None,
105
185
  ) -> dict[str, Any]:
106
186
  """
107
187
  Verify a single file with CrossHair.
@@ -133,13 +213,14 @@ def _verify_single_file(
133
213
  ]
134
214
 
135
215
  try:
136
- # DX-52: Inject project venv site-packages for uvx compatibility
216
+ env_root = Path(project_root) if project_root else None
137
217
  result = subprocess.run(
138
218
  cmd,
139
219
  capture_output=True,
140
220
  text=True,
141
221
  timeout=timeout,
142
- env=build_subprocess_env(),
222
+ cwd=project_root,
223
+ env=build_subprocess_env(cwd=env_root),
143
224
  )
144
225
 
145
226
  elapsed_ms = int((time.time() - start_time) * 1000)
@@ -157,6 +238,23 @@ def _verify_single_file(
157
238
  # symbolically execute C extensions like ast.parse()
158
239
  # Check both stdout and stderr for error patterns
159
240
  output = result.stdout + "\n" + result.stderr
241
+
242
+ # BUG-56: Detect Literal type errors (CrossHair limitation)
243
+ literal_errors = [
244
+ "Cannot instantiate typing.Literal",
245
+ "CrosshairUnsupported",
246
+ ]
247
+ is_literal_error = any(err in output for err in literal_errors)
248
+
249
+ if is_literal_error:
250
+ return {
251
+ "file": file_path,
252
+ "status": CrossHairStatus.SKIPPED,
253
+ "time_ms": elapsed_ms,
254
+ "reason": "Literal type not supported (tested by Hypothesis)",
255
+ "stdout": output,
256
+ }
257
+
160
258
  execution_errors = [
161
259
  "TypeError:",
162
260
  "AttributeError:",
@@ -222,6 +320,7 @@ def run_crosshair_parallel(
222
320
  cache: ProveCache | None = None,
223
321
  timeout: int = 300,
224
322
  per_condition_timeout: int = 30,
323
+ project_root: Path | None = None,
225
324
  ) -> Result[dict, str]:
226
325
  """Run CrossHair on multiple files in parallel (DX-13).
227
326
 
@@ -298,6 +397,17 @@ def run_crosshair_parallel(
298
397
  }
299
398
  )
300
399
  continue
400
+
401
+ # BUG-56: Check for Literal types that CrossHair cannot handle
402
+ if has_literal_in_contracted_functions(source):
403
+ cached_results.append(
404
+ {
405
+ "file": str(py_file),
406
+ "status": CrossHairStatus.SKIPPED,
407
+ "reason": "Literal type not supported (tested by Hypothesis)",
408
+ }
409
+ )
410
+ continue
301
411
  except OSError:
302
412
  pass # Include file anyway
303
413
 
@@ -324,6 +434,7 @@ def run_crosshair_parallel(
324
434
  verified_files: list[str] = []
325
435
  failed_files: list[str] = []
326
436
  all_counterexamples: list[str] = []
437
+ skipped_at_runtime: list[dict] = [] # BUG-56: Track runtime skips with reasons
327
438
  total_time_ms = 0
328
439
 
329
440
  if max_workers > 1 and len(files_to_verify) > 1:
@@ -331,7 +442,12 @@ def run_crosshair_parallel(
331
442
  with ProcessPoolExecutor(max_workers=max_workers) as executor:
332
443
  futures = {
333
444
  executor.submit(
334
- _verify_single_file, str(f), max_iterations, timeout, per_condition_timeout
445
+ _verify_single_file,
446
+ str(f.resolve()),
447
+ max_iterations,
448
+ timeout,
449
+ per_condition_timeout,
450
+ str(project_root) if project_root else None,
335
451
  ): f
336
452
  for f in files_to_verify
337
453
  }
@@ -346,6 +462,7 @@ def run_crosshair_parallel(
346
462
  verified_files,
347
463
  failed_files,
348
464
  all_counterexamples,
465
+ skipped_at_runtime,
349
466
  cache,
350
467
  )
351
468
  total_time_ms += result.get("time_ms", 0)
@@ -355,7 +472,11 @@ def run_crosshair_parallel(
355
472
  # Sequential execution (single file or max_workers=1)
356
473
  for py_file in files_to_verify:
357
474
  result = _verify_single_file(
358
- str(py_file), max_iterations, timeout, per_condition_timeout
475
+ str(py_file.resolve()),
476
+ max_iterations,
477
+ timeout,
478
+ per_condition_timeout,
479
+ str(project_root) if project_root else None,
359
480
  )
360
481
  _process_verification_result(
361
482
  result,
@@ -363,14 +484,17 @@ def run_crosshair_parallel(
363
484
  verified_files,
364
485
  failed_files,
365
486
  all_counterexamples,
487
+ skipped_at_runtime,
366
488
  cache,
367
489
  )
368
490
  total_time_ms += result.get("time_ms", 0)
369
491
 
370
492
  # Determine overall status
371
- status = (
372
- CrossHairStatus.VERIFIED if not failed_files else CrossHairStatus.COUNTEREXAMPLE
373
- )
493
+ status = CrossHairStatus.VERIFIED if not failed_files else CrossHairStatus.COUNTEREXAMPLE
494
+
495
+ # BUG-56: Combine pre-filtered skipped and runtime skipped
496
+ all_skipped = [r["file"] for r in cached_results if r.get("status") == "skipped"]
497
+ all_skipped.extend([r["file"] for r in skipped_at_runtime])
374
498
 
375
499
  return Success(
376
500
  {
@@ -378,7 +502,8 @@ def run_crosshair_parallel(
378
502
  "verified": verified_files,
379
503
  "failed": failed_files,
380
504
  "cached": [r["file"] for r in cached_results if r.get("status") == "cached"],
381
- "skipped": [r["file"] for r in cached_results if r.get("status") == "skipped"],
505
+ "skipped": all_skipped,
506
+ "skipped_reasons": skipped_at_runtime, # BUG-56: Include reasons
382
507
  "counterexamples": all_counterexamples,
383
508
  "files": [str(f) for f in py_files],
384
509
  "files_verified": len(files_to_verify),
@@ -397,6 +522,7 @@ def _process_verification_result(
397
522
  verified_files: list[str],
398
523
  failed_files: list[str],
399
524
  all_counterexamples: list[str],
525
+ skipped_at_runtime: list[dict], # BUG-56: Track runtime skips
400
526
  cache: ProveCache | None,
401
527
  ) -> None:
402
528
  """Process a single verification result."""
@@ -418,6 +544,14 @@ def _process_verification_result(
418
544
  failed_files.append(f"{file_path} (timeout)")
419
545
  elif status == CrossHairStatus.ERROR:
420
546
  failed_files.append(f"{file_path} ({result.get('error', 'unknown error')})")
547
+ elif status == CrossHairStatus.SKIPPED:
548
+ # BUG-56: Track skipped files with reasons (e.g., Literal type)
549
+ skipped_at_runtime.append(
550
+ {
551
+ "file": str(file_path),
552
+ "reason": result.get("reason", "unsupported"),
553
+ }
554
+ )
421
555
 
422
556
 
423
557
  # ============================================================
@@ -374,14 +374,28 @@ def _check_tool_available(tool: str, check_args: list[str]) -> bool:
374
374
  # =============================================================================
375
375
 
376
376
 
377
+ def _is_invar_package_dir(package_dir: Path, package_name: str) -> bool:
378
+ package_json = package_dir / "package.json"
379
+ if not package_json.exists():
380
+ return False
381
+
382
+ try:
383
+ data = json.loads(package_json.read_text(encoding="utf-8"))
384
+ except (OSError, json.JSONDecodeError):
385
+ return False
386
+
387
+ return data.get("name") == f"@invar/{package_name}"
388
+
389
+
377
390
  # @shell_complexity: Path discovery with fallback logic
378
391
  def _get_invar_package_cmd(package_name: str, project_path: Path) -> list[str]:
379
392
  """Get command to run an @invar/* package.
380
393
 
381
394
  Priority order:
382
- 1. Embedded tools (pip install invar-tools includes these)
383
- 2. Local development (typescript/packages/*/dist/ in Invar repo)
384
- 3. npx fallback (if published to npm)
395
+ 1. Project-local override (typescript/packages/* or packages/*)
396
+ 2. Embedded tools (pip install invar-tools includes these)
397
+ 3. Local monorepo lookup (walk up)
398
+ 4. npx fallback (if published to npm)
385
399
 
386
400
  Args:
387
401
  package_name: Package name without @invar/ prefix (e.g., "ts-analyzer")
@@ -390,7 +404,18 @@ def _get_invar_package_cmd(package_name: str, project_path: Path) -> list[str]:
390
404
  Returns:
391
405
  Command list for subprocess.run
392
406
  """
393
- # Priority 1: Embedded tools (from pip install)
407
+ # Resolve to absolute path to avoid path doubling issues
408
+ resolved_path = project_path.resolve()
409
+
410
+ local_cli = resolved_path / "typescript" / "packages" / package_name / "dist" / "cli.js"
411
+ if local_cli.exists() and _is_invar_package_dir(local_cli.parent.parent, package_name):
412
+ return ["node", str(local_cli)]
413
+
414
+ local_cli = resolved_path / "packages" / package_name / "dist" / "cli.js"
415
+ if local_cli.exists() and _is_invar_package_dir(local_cli.parent.parent, package_name):
416
+ return ["node", str(local_cli)]
417
+
418
+ # Priority 2: Embedded tools (from pip install)
394
419
  try:
395
420
  from invar.node_tools import get_tool_path
396
421
 
@@ -399,16 +424,13 @@ def _get_invar_package_cmd(package_name: str, project_path: Path) -> list[str]:
399
424
  except ImportError:
400
425
  pass # node_tools module not available
401
426
 
402
- # Priority 2: Local development setup (Invar repo itself)
403
- local_cli = project_path / f"typescript/packages/{package_name}/dist/cli.js"
404
- if local_cli.exists():
405
- return ["node", str(local_cli)]
406
-
407
- # Priority 2b: Walk up to find the Invar root (monorepo setup)
408
- check_path = project_path
427
+ # Priority 3b: Walk up to find the Invar root (monorepo setup)
428
+ # This is intentional for monorepo development - allows running from subdirectories
429
+ # Only searches up to 5 levels to limit exposure
430
+ check_path = resolved_path
409
431
  for _ in range(5): # Max 5 levels up
410
432
  candidate = check_path / f"typescript/packages/{package_name}/dist/cli.js"
411
- if candidate.exists():
433
+ if candidate.exists() and _is_invar_package_dir(candidate.parent.parent, package_name):
412
434
  return ["node", str(candidate)]
413
435
  parent = check_path.parent
414
436
  if parent == check_path:
@@ -439,7 +461,7 @@ def run_ts_analyzer(project_path: Path) -> Result[dict, str]:
439
461
  try:
440
462
  cmd = _get_invar_package_cmd("ts-analyzer", project_path)
441
463
  result = subprocess.run(
442
- [*cmd, str(project_path), "--json"],
464
+ [*cmd, str(project_path.resolve()), "--json"],
443
465
  capture_output=True,
444
466
  text=True,
445
467
  timeout=60,
@@ -456,7 +478,7 @@ def run_ts_analyzer(project_path: Path) -> Result[dict, str]:
456
478
  # Fall back to running without --json flag for human-readable summary
457
479
  try:
458
480
  summary_result = subprocess.run(
459
- [*cmd, str(project_path)],
481
+ [*cmd, str(project_path.resolve())],
460
482
  capture_output=True,
461
483
  text=True,
462
484
  timeout=60,
@@ -553,7 +575,7 @@ def run_quick_check(project_path: Path) -> Result[dict, str]:
553
575
  try:
554
576
  cmd = _get_invar_package_cmd("quick-check", project_path)
555
577
  result = subprocess.run(
556
- [*cmd, str(project_path), "--json"],
578
+ [*cmd, str(project_path.resolve()), "--json"],
557
579
  capture_output=True,
558
580
  text=True,
559
581
  timeout=30, # Quick check should be fast
@@ -750,7 +772,8 @@ def run_eslint(project_path: Path) -> Result[list[TypeScriptViolation], str]:
750
772
  try:
751
773
  # Get command for @invar/eslint-plugin (embedded or local dev)
752
774
  cmd = _get_invar_package_cmd("eslint-plugin", project_path)
753
- cmd.append(str(project_path)) # Add project path as argument
775
+ # Resolve path to absolute to avoid path doubling in subprocess
776
+ cmd.append(str(project_path.resolve())) # Add project path as argument
754
777
 
755
778
  # Use temp file to avoid subprocess 64KB buffer limit
756
779
  # ESLint output can be large for big projects
@@ -805,7 +828,6 @@ def run_eslint(project_path: Path) -> Result[list[TypeScriptViolation], str]:
805
828
  if result.returncode != 0 and result.stderr:
806
829
  return Failure(f"ESLint error: {result.stderr[:200]}")
807
830
  return Failure("ESLint output parsing failed: JSON decode error")
808
- return Failure("ESLint output parsing failed: JSON decode error")
809
831
 
810
832
  return Success(violations)
811
833