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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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}")