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.
- invar/core/models.py +7 -3
- invar/core/rules.py +50 -15
- invar/mcp/handlers.py +58 -2
- invar/node_tools/eslint-plugin/cli.js +105 -31
- invar/node_tools/eslint-plugin/rules/require-schema-validation.js +80 -66
- invar/shell/commands/guard.py +46 -6
- invar/shell/config.py +64 -21
- invar/shell/git.py +10 -11
- invar/shell/guard_helpers.py +105 -43
- invar/shell/property_tests.py +129 -41
- invar/shell/prove/crosshair.py +147 -13
- invar/shell/prove/guard_ts.py +39 -17
- invar/shell/subprocess_env.py +58 -5
- invar/shell/testing.py +59 -31
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/METADATA +3 -3
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/RECORD +21 -21
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/WHEEL +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.17.12.dist-info → invar_tools-1.17.24.dist-info}/licenses/NOTICE +0 -0
invar/shell/property_tests.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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(
|
|
126
|
-
errors=["Hypothesis not installed (pip install hypothesis)"]
|
|
127
|
-
)
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 =
|
|
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 =
|
|
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]")
|
invar/shell/prove/crosshair.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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),
|
|
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
|
-
|
|
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":
|
|
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
|
# ============================================================
|
invar/shell/prove/guard_ts.py
CHANGED
|
@@ -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.
|
|
383
|
-
2.
|
|
384
|
-
3.
|
|
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
|
-
#
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|