invar-tools 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/shell/fs.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File system operations.
|
|
3
|
+
|
|
4
|
+
Shell module: performs file I/O operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from returns.result import Failure, Result, Success
|
|
12
|
+
|
|
13
|
+
from invar.core.models import FileInfo
|
|
14
|
+
from invar.core.parser import parse_source
|
|
15
|
+
from invar.shell.config import classify_file, get_exclude_paths
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def discover_python_files(
|
|
23
|
+
project_root: Path,
|
|
24
|
+
exclude_patterns: list[str] | None = None,
|
|
25
|
+
) -> Iterator[Path]:
|
|
26
|
+
"""
|
|
27
|
+
Discover all Python files in a project.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
project_root: Root directory to search
|
|
31
|
+
exclude_patterns: Patterns to exclude (uses config defaults if None)
|
|
32
|
+
|
|
33
|
+
Yields:
|
|
34
|
+
Path objects for each Python file found
|
|
35
|
+
"""
|
|
36
|
+
if exclude_patterns is None:
|
|
37
|
+
exclude_result = get_exclude_paths(project_root)
|
|
38
|
+
exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
|
|
39
|
+
|
|
40
|
+
for py_file in project_root.rglob("*.py"):
|
|
41
|
+
# Check exclusions
|
|
42
|
+
relative = py_file.relative_to(project_root)
|
|
43
|
+
relative_str = str(relative)
|
|
44
|
+
|
|
45
|
+
excluded = False
|
|
46
|
+
for pattern in exclude_patterns:
|
|
47
|
+
if relative_str.startswith(pattern) or f"/{pattern}/" in f"/{relative_str}":
|
|
48
|
+
excluded = True
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if not excluded:
|
|
52
|
+
yield py_file
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo, str]:
|
|
56
|
+
"""
|
|
57
|
+
Read a Python file and parse it into FileInfo.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
file_path: Path to the Python file
|
|
61
|
+
project_root: Project root for relative path calculation
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Result containing FileInfo or error message
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
content = file_path.read_text(encoding="utf-8")
|
|
68
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
69
|
+
return Failure(f"Failed to read {file_path}: {e}")
|
|
70
|
+
|
|
71
|
+
relative_path = str(file_path.relative_to(project_root))
|
|
72
|
+
|
|
73
|
+
# Skip empty files (e.g., __init__.py) - return empty FileInfo
|
|
74
|
+
if not content.strip():
|
|
75
|
+
return Success(FileInfo(path=relative_path, lines=0, symbols=[], imports=[], source=""))
|
|
76
|
+
|
|
77
|
+
file_info = parse_source(content, relative_path)
|
|
78
|
+
|
|
79
|
+
if file_info is None:
|
|
80
|
+
return Failure(f"Syntax error in {file_path}")
|
|
81
|
+
|
|
82
|
+
# Classify as Core or Shell based on patterns and paths
|
|
83
|
+
classify_result = classify_file(relative_path, project_root)
|
|
84
|
+
file_info.is_core, file_info.is_shell = (
|
|
85
|
+
classify_result.unwrap() if isinstance(classify_result, Success) else (False, False)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return Success(file_info)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_project(
|
|
92
|
+
project_root: Path,
|
|
93
|
+
only_files: set[Path] | None = None,
|
|
94
|
+
) -> Iterator[Result[FileInfo, str]]:
|
|
95
|
+
"""
|
|
96
|
+
Scan a project and yield FileInfo for each Python file.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
project_root: Root directory of the project
|
|
100
|
+
only_files: If provided, only scan these files (for --changed mode)
|
|
101
|
+
|
|
102
|
+
Yields:
|
|
103
|
+
Result containing FileInfo or error message for each file
|
|
104
|
+
"""
|
|
105
|
+
if only_files is not None:
|
|
106
|
+
# Phase 8.1: --changed mode - only scan specified files
|
|
107
|
+
for py_file in only_files:
|
|
108
|
+
if py_file.exists() and py_file.suffix == ".py":
|
|
109
|
+
yield read_and_parse_file(py_file, project_root)
|
|
110
|
+
else:
|
|
111
|
+
for py_file in discover_python_files(project_root):
|
|
112
|
+
yield read_and_parse_file(py_file, project_root)
|
invar/shell/git.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git operations for Guard (Phase 8).
|
|
3
|
+
|
|
4
|
+
Shell module: handles git I/O for changed file detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from returns.result import Failure, Result, Success
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_git(args: list[str], cwd: Path) -> Result[str, str]:
|
|
19
|
+
"""Run a git command and return stdout."""
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
22
|
+
if result.returncode != 0:
|
|
23
|
+
return Failure(result.stderr.strip() or f"git {args[0]} failed")
|
|
24
|
+
return Success(result.stdout)
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return Failure("git command not found")
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return Failure(f"Git error: {e}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_py_files(output: str, project_root: Path) -> set[Path]:
|
|
32
|
+
"""Parse git output and return Python file paths."""
|
|
33
|
+
files: set[Path] = set()
|
|
34
|
+
for line in output.strip().split("\n"):
|
|
35
|
+
if line and line.endswith(".py"):
|
|
36
|
+
files.add(project_root / line)
|
|
37
|
+
return files
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_changed_files(project_root: Path) -> Result[set[Path], str]:
|
|
41
|
+
"""
|
|
42
|
+
Get Python files modified according to git (staged, unstaged, untracked).
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> from pathlib import Path
|
|
46
|
+
>>> result = get_changed_files(Path("."))
|
|
47
|
+
>>> isinstance(result, (Success, Failure))
|
|
48
|
+
True
|
|
49
|
+
"""
|
|
50
|
+
# Verify git repo
|
|
51
|
+
check = _run_git(["rev-parse", "--git-dir"], project_root)
|
|
52
|
+
if isinstance(check, Failure):
|
|
53
|
+
return Failure(f"Not a git repository: {project_root}")
|
|
54
|
+
|
|
55
|
+
changed: set[Path] = set()
|
|
56
|
+
|
|
57
|
+
# Staged changes
|
|
58
|
+
staged = _run_git(["diff", "--cached", "--name-only"], project_root)
|
|
59
|
+
if isinstance(staged, Success):
|
|
60
|
+
changed.update(_parse_py_files(staged.unwrap(), project_root))
|
|
61
|
+
|
|
62
|
+
# Unstaged changes
|
|
63
|
+
unstaged = _run_git(["diff", "--name-only"], project_root)
|
|
64
|
+
if isinstance(unstaged, Success):
|
|
65
|
+
changed.update(_parse_py_files(unstaged.unwrap(), project_root))
|
|
66
|
+
|
|
67
|
+
# Untracked files
|
|
68
|
+
untracked = _run_git(["ls-files", "--others", "--exclude-standard"], project_root)
|
|
69
|
+
if isinstance(untracked, Success):
|
|
70
|
+
changed.update(_parse_py_files(untracked.unwrap(), project_root))
|
|
71
|
+
|
|
72
|
+
return Success(changed)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_git_repo(path: Path) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if a path is inside a git repository.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> from pathlib import Path
|
|
81
|
+
>>> isinstance(is_git_repo(Path(".")), bool)
|
|
82
|
+
True
|
|
83
|
+
"""
|
|
84
|
+
result = _run_git(["rev-parse", "--git-dir"], path)
|
|
85
|
+
return isinstance(result, Success)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Guard command helper functions.
|
|
3
|
+
|
|
4
|
+
Extracted from cli.py to reduce function sizes and improve maintainability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path # noqa: TC003 - Path used at runtime for path operations
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from returns.result import Failure, Result, Success
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from invar.shell.testing import VerificationLevel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle_changed_mode(
|
|
23
|
+
path: Path,
|
|
24
|
+
) -> Result[tuple[set[Path], list[Path]], str]:
|
|
25
|
+
"""Handle --changed flag: get modified files from git.
|
|
26
|
+
|
|
27
|
+
Returns (only_files set, checked_files list) on success.
|
|
28
|
+
"""
|
|
29
|
+
from invar.shell.git import get_changed_files, is_git_repo
|
|
30
|
+
|
|
31
|
+
if not is_git_repo(path):
|
|
32
|
+
return Failure("--changed requires a git repository")
|
|
33
|
+
|
|
34
|
+
changed_result = get_changed_files(path)
|
|
35
|
+
if isinstance(changed_result, Failure):
|
|
36
|
+
return Failure(changed_result.failure())
|
|
37
|
+
|
|
38
|
+
only_files = changed_result.unwrap()
|
|
39
|
+
if not only_files:
|
|
40
|
+
return Failure("NO_CHANGES") # Special marker for "no changes"
|
|
41
|
+
|
|
42
|
+
return Success((only_files, list(only_files)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_files_to_check(
|
|
46
|
+
path: Path, checked_files: list[Path]
|
|
47
|
+
) -> list[Path]:
|
|
48
|
+
"""Collect Python files to check when not in --changed mode."""
|
|
49
|
+
from invar.shell.config import get_path_classification
|
|
50
|
+
|
|
51
|
+
if checked_files:
|
|
52
|
+
return checked_files
|
|
53
|
+
|
|
54
|
+
result_files: list[Path] = []
|
|
55
|
+
|
|
56
|
+
path_result = get_path_classification(path)
|
|
57
|
+
if isinstance(path_result, Success):
|
|
58
|
+
core_paths, shell_paths = path_result.unwrap()
|
|
59
|
+
else:
|
|
60
|
+
core_paths, shell_paths = ["src/core"], ["src/shell"]
|
|
61
|
+
|
|
62
|
+
# Scan core/shell paths
|
|
63
|
+
for core_path in core_paths:
|
|
64
|
+
full_path = path / core_path
|
|
65
|
+
if full_path.exists():
|
|
66
|
+
result_files.extend(full_path.rglob("*.py"))
|
|
67
|
+
|
|
68
|
+
for shell_path in shell_paths:
|
|
69
|
+
full_path = path / shell_path
|
|
70
|
+
if full_path.exists():
|
|
71
|
+
result_files.extend(full_path.rglob("*.py"))
|
|
72
|
+
|
|
73
|
+
# Fallback: scan path directly
|
|
74
|
+
if not result_files and path.exists():
|
|
75
|
+
result_files.extend(path.rglob("*.py"))
|
|
76
|
+
|
|
77
|
+
return result_files
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_doctests_phase(
|
|
81
|
+
checked_files: list[Path], explain: bool
|
|
82
|
+
) -> tuple[bool, str]:
|
|
83
|
+
"""Run doctests on collected files.
|
|
84
|
+
|
|
85
|
+
Returns (passed, output).
|
|
86
|
+
"""
|
|
87
|
+
from invar.shell.testing import run_doctests_on_files
|
|
88
|
+
|
|
89
|
+
if not checked_files:
|
|
90
|
+
return True, ""
|
|
91
|
+
|
|
92
|
+
doctest_result = run_doctests_on_files(checked_files, verbose=explain)
|
|
93
|
+
if isinstance(doctest_result, Success):
|
|
94
|
+
result_data = doctest_result.unwrap()
|
|
95
|
+
passed = result_data.get("status") in ("passed", "skipped")
|
|
96
|
+
output = result_data.get("stdout", "")
|
|
97
|
+
return passed, output
|
|
98
|
+
|
|
99
|
+
return False, doctest_result.failure()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_crosshair_phase(
|
|
103
|
+
path: Path,
|
|
104
|
+
checked_files: list[Path],
|
|
105
|
+
doctest_passed: bool,
|
|
106
|
+
static_exit_code: int,
|
|
107
|
+
changed_mode: bool = False,
|
|
108
|
+
) -> tuple[bool, dict]:
|
|
109
|
+
"""Run CrossHair verification phase.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path: Project root path
|
|
113
|
+
checked_files: Files to potentially verify
|
|
114
|
+
doctest_passed: Whether doctests passed
|
|
115
|
+
static_exit_code: Exit code from static analysis
|
|
116
|
+
changed_mode: If True, only verify git-changed files (--changed flag)
|
|
117
|
+
|
|
118
|
+
Returns (passed, output_dict).
|
|
119
|
+
"""
|
|
120
|
+
from invar.shell.prove_cache import ProveCache
|
|
121
|
+
from invar.shell.testing import get_files_to_prove, run_crosshair_parallel
|
|
122
|
+
|
|
123
|
+
# Skip if prior failures
|
|
124
|
+
if not doctest_passed or static_exit_code != 0:
|
|
125
|
+
return True, {"status": "skipped", "reason": "prior failures"}
|
|
126
|
+
|
|
127
|
+
if not checked_files:
|
|
128
|
+
return True, {"status": "skipped", "reason": "no files to verify"}
|
|
129
|
+
|
|
130
|
+
# Only verify Core files (pure logic)
|
|
131
|
+
core_files = [f for f in checked_files if "core" in str(f)]
|
|
132
|
+
if not core_files:
|
|
133
|
+
return True, {"status": "skipped", "reason": "no core files found"}
|
|
134
|
+
|
|
135
|
+
# DX-13 fix: Only use git-based incremental when --changed is specified
|
|
136
|
+
# Cache-based incremental still applies in run_crosshair_parallel
|
|
137
|
+
files_to_prove = get_files_to_prove(path, core_files, changed_only=changed_mode)
|
|
138
|
+
|
|
139
|
+
if not files_to_prove:
|
|
140
|
+
return True, {
|
|
141
|
+
"status": "verified",
|
|
142
|
+
"reason": "no changes to verify",
|
|
143
|
+
"files_verified": 0,
|
|
144
|
+
"files_cached": len(core_files),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Create cache and run parallel verification
|
|
148
|
+
cache = ProveCache(path / ".invar" / "cache" / "prove")
|
|
149
|
+
crosshair_result = run_crosshair_parallel(
|
|
150
|
+
files_to_prove,
|
|
151
|
+
max_iterations=5,
|
|
152
|
+
max_workers=None,
|
|
153
|
+
cache=cache,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if isinstance(crosshair_result, Success):
|
|
157
|
+
output = crosshair_result.unwrap()
|
|
158
|
+
passed = output.get("status") in ("verified", "skipped")
|
|
159
|
+
return passed, output
|
|
160
|
+
|
|
161
|
+
return False, {"status": "error", "error": crosshair_result.failure()}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def output_verification_status(
|
|
165
|
+
verification_level: VerificationLevel,
|
|
166
|
+
static_exit_code: int,
|
|
167
|
+
doctest_passed: bool,
|
|
168
|
+
doctest_output: str,
|
|
169
|
+
crosshair_output: dict,
|
|
170
|
+
explain: bool,
|
|
171
|
+
property_output: dict | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Output verification status for human-readable mode.
|
|
174
|
+
|
|
175
|
+
DX-19: Simplified - STANDARD runs all phases (doctests + CrossHair + Hypothesis).
|
|
176
|
+
"""
|
|
177
|
+
from invar.shell.testing import VerificationLevel
|
|
178
|
+
|
|
179
|
+
# STATIC mode: no runtime tests to report
|
|
180
|
+
if verification_level == VerificationLevel.STATIC:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# STANDARD mode: report all test results
|
|
184
|
+
if static_exit_code == 0:
|
|
185
|
+
# Doctest results
|
|
186
|
+
if doctest_passed:
|
|
187
|
+
console.print("[green]✓ Doctests passed[/green]")
|
|
188
|
+
else:
|
|
189
|
+
console.print("[red]✗ Doctests failed[/red]")
|
|
190
|
+
if doctest_output and explain:
|
|
191
|
+
console.print(doctest_output)
|
|
192
|
+
|
|
193
|
+
# CrossHair results
|
|
194
|
+
_output_crosshair_status(
|
|
195
|
+
static_exit_code, doctest_passed, crosshair_output
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Property tests results
|
|
199
|
+
if property_output:
|
|
200
|
+
_output_property_tests_status(
|
|
201
|
+
static_exit_code, doctest_passed, property_output
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
console.print("[dim]⊘ Runtime tests skipped (static errors)[/dim]")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_property_tests_phase(
|
|
208
|
+
checked_files: list[Path],
|
|
209
|
+
doctest_passed: bool,
|
|
210
|
+
static_exit_code: int,
|
|
211
|
+
max_examples: int = 100,
|
|
212
|
+
) -> tuple[bool, dict]:
|
|
213
|
+
"""Run property tests phase (DX-08).
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
checked_files: Files to test
|
|
217
|
+
doctest_passed: Whether doctests passed
|
|
218
|
+
static_exit_code: Exit code from static analysis
|
|
219
|
+
max_examples: Maximum Hypothesis examples per function
|
|
220
|
+
|
|
221
|
+
Returns (passed, output_dict).
|
|
222
|
+
"""
|
|
223
|
+
from invar.shell.property_tests import run_property_tests_on_files
|
|
224
|
+
|
|
225
|
+
# Skip if prior failures
|
|
226
|
+
if not doctest_passed or static_exit_code != 0:
|
|
227
|
+
return True, {"status": "skipped", "reason": "prior failures"}
|
|
228
|
+
|
|
229
|
+
if not checked_files:
|
|
230
|
+
return True, {"status": "skipped", "reason": "no files"}
|
|
231
|
+
|
|
232
|
+
# Only test Core files (with contracts)
|
|
233
|
+
core_files = [f for f in checked_files if "core" in str(f)]
|
|
234
|
+
if not core_files:
|
|
235
|
+
return True, {"status": "skipped", "reason": "no core files"}
|
|
236
|
+
|
|
237
|
+
result = run_property_tests_on_files(core_files, max_examples)
|
|
238
|
+
|
|
239
|
+
if isinstance(result, Success):
|
|
240
|
+
report = result.unwrap()
|
|
241
|
+
return report.all_passed(), {
|
|
242
|
+
"status": "passed" if report.all_passed() else "failed",
|
|
243
|
+
"functions_tested": report.functions_tested,
|
|
244
|
+
"functions_passed": report.functions_passed,
|
|
245
|
+
"functions_failed": report.functions_failed,
|
|
246
|
+
"total_examples": report.total_examples,
|
|
247
|
+
"errors": report.errors,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return False, {"status": "error", "error": result.failure()}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _output_property_tests_status(
|
|
254
|
+
static_exit_code: int,
|
|
255
|
+
doctest_passed: bool,
|
|
256
|
+
property_output: dict,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Output property tests status (DX-08)."""
|
|
259
|
+
if static_exit_code != 0 or not doctest_passed:
|
|
260
|
+
console.print("[dim]⊘ Property tests skipped (prior failures)[/dim]")
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
status = property_output.get("status", "unknown")
|
|
264
|
+
|
|
265
|
+
if status == "passed":
|
|
266
|
+
tested = property_output.get("functions_tested", 0)
|
|
267
|
+
examples = property_output.get("total_examples", 0)
|
|
268
|
+
console.print(
|
|
269
|
+
f"[green]✓ Property tests passed[/green] "
|
|
270
|
+
f"[dim]({tested} functions, {examples} examples)[/dim]"
|
|
271
|
+
)
|
|
272
|
+
elif status == "skipped":
|
|
273
|
+
reason = property_output.get("reason", "no contracted functions")
|
|
274
|
+
console.print(f"[dim]⊘ Property tests skipped ({reason})[/dim]")
|
|
275
|
+
elif status == "failed":
|
|
276
|
+
failed = property_output.get("functions_failed", 0)
|
|
277
|
+
console.print(f"[red]✗ Property tests failed ({failed} functions)[/red]")
|
|
278
|
+
for error in property_output.get("errors", [])[:5]:
|
|
279
|
+
console.print(f" {error}")
|
|
280
|
+
else:
|
|
281
|
+
console.print(f"[yellow]! Property tests: {status}[/yellow]")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _output_crosshair_status(
|
|
285
|
+
static_exit_code: int,
|
|
286
|
+
doctest_passed: bool,
|
|
287
|
+
crosshair_output: dict,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Output CrossHair verification status."""
|
|
290
|
+
if static_exit_code != 0 or not doctest_passed:
|
|
291
|
+
console.print("[dim]⊘ CrossHair skipped (prior failures)[/dim]")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
status = crosshair_output.get("status", "unknown")
|
|
295
|
+
|
|
296
|
+
if status == "verified":
|
|
297
|
+
verified_count = crosshair_output.get("files_verified", 0)
|
|
298
|
+
cached_count = crosshair_output.get("files_cached", 0)
|
|
299
|
+
time_ms = crosshair_output.get("total_time_ms", 0)
|
|
300
|
+
workers = crosshair_output.get("workers", 1)
|
|
301
|
+
|
|
302
|
+
if verified_count == 0 and cached_count > 0:
|
|
303
|
+
reason = crosshair_output.get("reason", "cached")
|
|
304
|
+
console.print(f"[green]✓ CrossHair verified ({reason})[/green]")
|
|
305
|
+
elif time_ms > 0:
|
|
306
|
+
time_sec = time_ms / 1000
|
|
307
|
+
stats = f"{verified_count} verified"
|
|
308
|
+
if cached_count > 0:
|
|
309
|
+
stats += f", {cached_count} cached"
|
|
310
|
+
if workers > 1:
|
|
311
|
+
stats += f", {workers} workers"
|
|
312
|
+
console.print(
|
|
313
|
+
f"[green]✓ CrossHair verified[/green] "
|
|
314
|
+
f"[dim]({stats}, {time_sec:.1f}s)[/dim]"
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
console.print("[green]✓ CrossHair verified[/green]")
|
|
318
|
+
elif status == "skipped":
|
|
319
|
+
reason = crosshair_output.get("reason", "no files")
|
|
320
|
+
console.print(f"[dim]⊘ CrossHair skipped ({reason})[/dim]")
|
|
321
|
+
else:
|
|
322
|
+
console.print("[yellow]! CrossHair found counterexamples[/yellow]")
|
|
323
|
+
for ce in crosshair_output.get("counterexamples", [])[:5]:
|
|
324
|
+
console.print(f" {ce}")
|