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/test_cmd.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test and verify CLI commands.
|
|
3
|
+
|
|
4
|
+
Extracted from cli.py to manage file size.
|
|
5
|
+
DX-08: Updated to use contract-driven property testing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from returns.result import Failure
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from invar.shell.git import get_changed_files, is_git_repo
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _detect_agent_mode() -> bool:
|
|
22
|
+
"""Detect agent context: INVAR_MODE=agent OR non-TTY (pipe/redirect)."""
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
return os.getenv("INVAR_MODE") == "agent" or not sys.stdout.isatty()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test(
|
|
30
|
+
target: str = typer.Argument(None, help="File to test (optional with --changed)"),
|
|
31
|
+
verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose output"),
|
|
32
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
33
|
+
changed: bool = typer.Option(False, "--changed", help="Test git-modified files only"),
|
|
34
|
+
max_examples: int = typer.Option(100, "--max-examples", help="Maximum Hypothesis examples per function"),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Run property-based tests using Hypothesis on contracted functions (DX-08)."""
|
|
37
|
+
from invar.shell.property_tests import (
|
|
38
|
+
format_property_test_report,
|
|
39
|
+
run_property_tests_on_files,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
use_json = json_output or _detect_agent_mode()
|
|
43
|
+
|
|
44
|
+
# Get files to test
|
|
45
|
+
if changed:
|
|
46
|
+
if not is_git_repo(Path()):
|
|
47
|
+
console.print("[red]Error:[/red] --changed requires a git repository")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
changed_result = get_changed_files(Path())
|
|
50
|
+
if isinstance(changed_result, Failure):
|
|
51
|
+
console.print(f"[red]Error:[/red] {changed_result.failure()}")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
files = list(changed_result.unwrap())
|
|
54
|
+
if not files:
|
|
55
|
+
console.print("[green]No changed Python files.[/green]")
|
|
56
|
+
raise typer.Exit(0)
|
|
57
|
+
elif target:
|
|
58
|
+
files = [Path(target)]
|
|
59
|
+
else:
|
|
60
|
+
console.print("[red]Error:[/red] Either provide a file or use --changed")
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
|
|
63
|
+
# DX-08: Run property tests on files
|
|
64
|
+
result = run_property_tests_on_files(files, max_examples, verbose)
|
|
65
|
+
|
|
66
|
+
if isinstance(result, Failure):
|
|
67
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
report = result.unwrap()
|
|
71
|
+
output = format_property_test_report(report, use_json)
|
|
72
|
+
console.print(output)
|
|
73
|
+
|
|
74
|
+
if not report.all_passed():
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify(
|
|
79
|
+
target: str = typer.Argument(None, help="File to verify (optional with --changed)"),
|
|
80
|
+
timeout: int = typer.Option(30, "--timeout", help="Timeout per function (seconds)"),
|
|
81
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
82
|
+
changed: bool = typer.Option(False, "--changed", help="Verify git-modified files only"),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Run symbolic verification using CrossHair."""
|
|
85
|
+
from invar.shell.testing import run_verify
|
|
86
|
+
|
|
87
|
+
use_json = json_output or _detect_agent_mode()
|
|
88
|
+
|
|
89
|
+
# Get files to verify
|
|
90
|
+
if changed:
|
|
91
|
+
if not is_git_repo(Path()):
|
|
92
|
+
console.print("[red]Error:[/red] --changed requires a git repository")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
changed_result = get_changed_files(Path())
|
|
95
|
+
if isinstance(changed_result, Failure):
|
|
96
|
+
console.print(f"[red]Error:[/red] {changed_result.failure()}")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
files = list(changed_result.unwrap())
|
|
99
|
+
if not files:
|
|
100
|
+
console.print("[green]No changed Python files.[/green]")
|
|
101
|
+
raise typer.Exit(0)
|
|
102
|
+
elif target:
|
|
103
|
+
files = [Path(target)]
|
|
104
|
+
else:
|
|
105
|
+
console.print("[red]Error:[/red] Either provide a file or use --changed")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
# Run verification on all files
|
|
109
|
+
all_passed = True
|
|
110
|
+
for file_path in files:
|
|
111
|
+
result = run_verify(str(file_path), use_json, timeout)
|
|
112
|
+
if isinstance(result, Failure):
|
|
113
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
114
|
+
all_passed = False
|
|
115
|
+
|
|
116
|
+
if not all_passed:
|
|
117
|
+
raise typer.Exit(1)
|
invar/shell/testing.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing commands for Invar.
|
|
3
|
+
|
|
4
|
+
Shell module: handles I/O for testing operations.
|
|
5
|
+
Includes Smart Guard verification (DX-06).
|
|
6
|
+
DX-12: Hypothesis as CrossHair fallback (see prove.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json as json_lib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from returns.result import Failure, Result, Success
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
# DX-12: Import from prove module
|
|
22
|
+
# DX-13: Added get_files_to_prove, run_crosshair_parallel
|
|
23
|
+
# DX-13: ProveCache extracted to prove_cache.py
|
|
24
|
+
from invar.shell.prove import (
|
|
25
|
+
CrossHairStatus,
|
|
26
|
+
get_files_to_prove,
|
|
27
|
+
run_crosshair_on_files,
|
|
28
|
+
run_crosshair_parallel,
|
|
29
|
+
run_hypothesis_fallback,
|
|
30
|
+
run_prove_with_fallback,
|
|
31
|
+
)
|
|
32
|
+
from invar.shell.prove_cache import ProveCache
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
# Re-export for backwards compatibility
|
|
37
|
+
# DX-13: Added get_files_to_prove, run_crosshair_parallel, ProveCache
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CrossHairStatus",
|
|
40
|
+
"ProveCache",
|
|
41
|
+
"VerificationLevel",
|
|
42
|
+
"VerificationResult",
|
|
43
|
+
"detect_verification_context",
|
|
44
|
+
"get_available_verifiers",
|
|
45
|
+
"get_files_to_prove",
|
|
46
|
+
"run_crosshair_on_files",
|
|
47
|
+
"run_crosshair_parallel",
|
|
48
|
+
"run_doctests_on_files",
|
|
49
|
+
"run_hypothesis_fallback",
|
|
50
|
+
"run_prove_with_fallback",
|
|
51
|
+
"run_test",
|
|
52
|
+
"run_verify",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class VerificationLevel(IntEnum):
|
|
57
|
+
"""Verification depth levels for Smart Guard.
|
|
58
|
+
|
|
59
|
+
DX-19: Simplified to 2 levels (Agent-Native: Zero decisions).
|
|
60
|
+
- STATIC: Quick debug mode (~0.5s)
|
|
61
|
+
- STANDARD: Full verification including CrossHair + Hypothesis (~5s)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
STATIC = 0 # Static analysis only (--static, quick debug)
|
|
65
|
+
STANDARD = 1 # Full: static + doctests + CrossHair + Hypothesis (default) # Static + doctests + property tests (--thorough, DX-08)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class VerificationResult:
|
|
70
|
+
"""Results from Smart Guard verification."""
|
|
71
|
+
|
|
72
|
+
static_passed: bool = True
|
|
73
|
+
doctest_passed: bool | None = None
|
|
74
|
+
hypothesis_passed: bool | None = None
|
|
75
|
+
crosshair_passed: bool | None = None
|
|
76
|
+
doctest_output: str = ""
|
|
77
|
+
hypothesis_output: str = ""
|
|
78
|
+
crosshair_output: str = ""
|
|
79
|
+
files_tested: list[str] = field(default_factory=list)
|
|
80
|
+
errors: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_available_verifiers() -> list[str]:
|
|
84
|
+
"""
|
|
85
|
+
Detect installed verification tools.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of available verifier names.
|
|
89
|
+
|
|
90
|
+
>>> "static" in get_available_verifiers()
|
|
91
|
+
True
|
|
92
|
+
>>> "doctest" in get_available_verifiers()
|
|
93
|
+
True
|
|
94
|
+
"""
|
|
95
|
+
available = ["static", "doctest"] # Always available
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
import hypothesis # noqa: F401
|
|
99
|
+
|
|
100
|
+
available.append("hypothesis")
|
|
101
|
+
except ImportError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
import crosshair # noqa: F401
|
|
106
|
+
|
|
107
|
+
available.append("crosshair")
|
|
108
|
+
except ImportError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
return available
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def detect_verification_context() -> VerificationLevel:
|
|
115
|
+
"""
|
|
116
|
+
Auto-detect appropriate verification depth based on context.
|
|
117
|
+
|
|
118
|
+
DX-19: Simplified to 2 levels. Always returns STANDARD (full verification).
|
|
119
|
+
STATIC is only used when explicitly requested via --static flag.
|
|
120
|
+
|
|
121
|
+
>>> detect_verification_context() == VerificationLevel.STANDARD
|
|
122
|
+
True
|
|
123
|
+
"""
|
|
124
|
+
# DX-19: Always use STANDARD (full verification) by default
|
|
125
|
+
# STATIC is only for explicit --static flag
|
|
126
|
+
return VerificationLevel.STANDARD
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_doctests_on_files(
|
|
130
|
+
files: list[Path], verbose: bool = False
|
|
131
|
+
) -> Result[dict, str]:
|
|
132
|
+
"""
|
|
133
|
+
Run doctests on a list of Python files.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
files: List of Python file paths to test
|
|
137
|
+
verbose: Show verbose output
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Success with test results or Failure with error message
|
|
141
|
+
"""
|
|
142
|
+
if not files:
|
|
143
|
+
return Success({"status": "skipped", "reason": "no files", "files": []})
|
|
144
|
+
|
|
145
|
+
# Filter to Python files only
|
|
146
|
+
py_files = [f for f in files if f.suffix == ".py" and f.exists()]
|
|
147
|
+
if not py_files:
|
|
148
|
+
return Success({"status": "skipped", "reason": "no Python files", "files": []})
|
|
149
|
+
|
|
150
|
+
# Build pytest command
|
|
151
|
+
cmd = [
|
|
152
|
+
sys.executable, "-m", "pytest",
|
|
153
|
+
"--doctest-modules", "-x", "--tb=short",
|
|
154
|
+
]
|
|
155
|
+
cmd.extend(str(f) for f in py_files)
|
|
156
|
+
if verbose:
|
|
157
|
+
cmd.append("-v")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
161
|
+
# Pytest exit codes: 0=passed, 5=no tests collected (also OK)
|
|
162
|
+
is_passed = result.returncode in (0, 5)
|
|
163
|
+
return Success({
|
|
164
|
+
"status": "passed" if is_passed else "failed",
|
|
165
|
+
"files": [str(f) for f in py_files],
|
|
166
|
+
"exit_code": result.returncode,
|
|
167
|
+
"stdout": result.stdout,
|
|
168
|
+
"stderr": result.stderr,
|
|
169
|
+
})
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
return Failure("Doctest timeout (120s)")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
return Failure(f"Doctest error: {e}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_test(
|
|
177
|
+
target: str, json_output: bool = False, verbose: bool = False
|
|
178
|
+
) -> Result[dict, str]:
|
|
179
|
+
"""
|
|
180
|
+
Run property-based tests using Hypothesis via deal.cases.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
target: File path or module to test
|
|
184
|
+
json_output: Output as JSON
|
|
185
|
+
verbose: Show verbose output
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Success with test results or Failure with error message
|
|
189
|
+
"""
|
|
190
|
+
target_path = Path(target)
|
|
191
|
+
if not target_path.exists():
|
|
192
|
+
return Failure(f"Target not found: {target}")
|
|
193
|
+
if target_path.suffix != ".py":
|
|
194
|
+
return Failure(f"Target must be a Python file: {target}")
|
|
195
|
+
|
|
196
|
+
cmd = [
|
|
197
|
+
sys.executable, "-m", "pytest",
|
|
198
|
+
str(target_path), "--doctest-modules", "-x", "--tb=short",
|
|
199
|
+
]
|
|
200
|
+
if verbose:
|
|
201
|
+
cmd.append("-v")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
205
|
+
test_result = {
|
|
206
|
+
"status": "passed" if result.returncode == 0 else "failed",
|
|
207
|
+
"target": str(target_path),
|
|
208
|
+
"exit_code": result.returncode,
|
|
209
|
+
"stdout": result.stdout,
|
|
210
|
+
"stderr": result.stderr,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if json_output:
|
|
214
|
+
console.print(json_lib.dumps(test_result, indent=2))
|
|
215
|
+
else:
|
|
216
|
+
if result.returncode == 0:
|
|
217
|
+
console.print(f"[green]✓[/green] Tests passed: {target}")
|
|
218
|
+
if verbose:
|
|
219
|
+
console.print(result.stdout)
|
|
220
|
+
else:
|
|
221
|
+
console.print(f"[red]✗[/red] Tests failed: {target}")
|
|
222
|
+
console.print(result.stdout)
|
|
223
|
+
if result.stderr:
|
|
224
|
+
console.print(f"[red]{result.stderr}[/red]")
|
|
225
|
+
|
|
226
|
+
return Success(test_result)
|
|
227
|
+
except subprocess.TimeoutExpired:
|
|
228
|
+
return Failure(f"Test timeout (300s): {target}")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
return Failure(f"Test error: {e}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_verify(
|
|
234
|
+
target: str, json_output: bool = False, timeout: int = 30
|
|
235
|
+
) -> Result[dict, str]:
|
|
236
|
+
"""
|
|
237
|
+
Run symbolic verification using CrossHair.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
target: File path or module to verify
|
|
241
|
+
json_output: Output as JSON
|
|
242
|
+
timeout: Timeout per function in seconds
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Success with verification results or Failure with error message
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
import crosshair # noqa: F401
|
|
249
|
+
except ImportError:
|
|
250
|
+
return Failure(
|
|
251
|
+
"CrossHair not installed. Run: pip install crosshair-tool\n"
|
|
252
|
+
"Note: CrossHair requires Python 3.8-3.12 (not 3.14)"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
target_path = Path(target)
|
|
256
|
+
if not target_path.exists():
|
|
257
|
+
return Failure(f"Target not found: {target}")
|
|
258
|
+
if target_path.suffix != ".py":
|
|
259
|
+
return Failure(f"Target must be a Python file: {target}")
|
|
260
|
+
|
|
261
|
+
cmd = [
|
|
262
|
+
sys.executable, "-m", "crosshair", "check",
|
|
263
|
+
str(target_path), f"--per_condition_timeout={timeout}",
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * 10)
|
|
268
|
+
|
|
269
|
+
counterexamples = [
|
|
270
|
+
line.strip() for line in result.stdout.split("\n")
|
|
271
|
+
if "error" in line.lower() or "counterexample" in line.lower()
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
verify_result = {
|
|
275
|
+
"status": "verified" if result.returncode == 0 else "counterexample_found",
|
|
276
|
+
"target": str(target_path),
|
|
277
|
+
"exit_code": result.returncode,
|
|
278
|
+
"counterexamples": counterexamples,
|
|
279
|
+
"stdout": result.stdout,
|
|
280
|
+
"stderr": result.stderr,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if json_output:
|
|
284
|
+
console.print(json_lib.dumps(verify_result, indent=2))
|
|
285
|
+
else:
|
|
286
|
+
if result.returncode == 0:
|
|
287
|
+
console.print(f"[green]✓[/green] Verified: {target}")
|
|
288
|
+
else:
|
|
289
|
+
console.print(f"[yellow]![/yellow] Counterexamples found: {target}")
|
|
290
|
+
for ce in counterexamples:
|
|
291
|
+
console.print(f" {ce}")
|
|
292
|
+
|
|
293
|
+
return Success(verify_result)
|
|
294
|
+
except subprocess.TimeoutExpired:
|
|
295
|
+
return Failure(f"Verification timeout ({timeout * 10}s): {target}")
|
|
296
|
+
except Exception as e:
|
|
297
|
+
return Failure(f"Verification error: {e}")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Update command for Invar.
|
|
3
|
+
|
|
4
|
+
Shell module: handles updating Invar-managed files to latest version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from returns.result import Failure, Result, Success
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from invar.shell.templates import copy_examples_directory, get_template_path
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Version pattern: matches "v3.23" or "v3.23.1"
|
|
21
|
+
VERSION_PATTERN = re.compile(r"v(\d+)\.(\d+)(?:\.(\d+))?")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_version(text: str) -> tuple[int, int, int] | None:
|
|
25
|
+
"""
|
|
26
|
+
Parse version string from text.
|
|
27
|
+
|
|
28
|
+
>>> parse_version("Protocol v3.23")
|
|
29
|
+
(3, 23, 0)
|
|
30
|
+
>>> parse_version("v3.23.1")
|
|
31
|
+
(3, 23, 1)
|
|
32
|
+
>>> parse_version("no version here")
|
|
33
|
+
"""
|
|
34
|
+
match = VERSION_PATTERN.search(text)
|
|
35
|
+
if match:
|
|
36
|
+
major = int(match.group(1))
|
|
37
|
+
minor = int(match.group(2))
|
|
38
|
+
patch = int(match.group(3)) if match.group(3) else 0
|
|
39
|
+
return (major, minor, patch)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_current_version(path: Path) -> Result[tuple[int, int, int], str]:
|
|
44
|
+
"""Get version from current INVAR.md file."""
|
|
45
|
+
invar_md = path / "INVAR.md"
|
|
46
|
+
if not invar_md.exists():
|
|
47
|
+
return Failure("INVAR.md not found. Run 'invar init' first.")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
content = invar_md.read_text()
|
|
51
|
+
version = parse_version(content)
|
|
52
|
+
if version is None:
|
|
53
|
+
return Failure("Could not parse version from INVAR.md")
|
|
54
|
+
return Success(version)
|
|
55
|
+
except OSError as e:
|
|
56
|
+
return Failure(f"Failed to read INVAR.md: {e}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_template_version() -> Result[tuple[int, int, int], str]:
|
|
60
|
+
"""Get version from template INVAR.md."""
|
|
61
|
+
template_result = get_template_path("INVAR.md")
|
|
62
|
+
if isinstance(template_result, Failure):
|
|
63
|
+
return template_result
|
|
64
|
+
|
|
65
|
+
template_path = template_result.unwrap()
|
|
66
|
+
try:
|
|
67
|
+
content = template_path.read_text()
|
|
68
|
+
version = parse_version(content)
|
|
69
|
+
if version is None:
|
|
70
|
+
return Failure("Could not parse version from template")
|
|
71
|
+
return Success(version)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
return Failure(f"Failed to read template: {e}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_version(version: tuple[int, int, int]) -> str:
|
|
77
|
+
"""Format version tuple as string."""
|
|
78
|
+
if version[2] == 0:
|
|
79
|
+
return f"v{version[0]}.{version[1]}"
|
|
80
|
+
return f"v{version[0]}.{version[1]}.{version[2]}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def update_invar_md(path: Path, console: Console) -> Result[bool, str]:
|
|
84
|
+
"""Update INVAR.md by overwriting with template."""
|
|
85
|
+
template_result = get_template_path("INVAR.md")
|
|
86
|
+
if isinstance(template_result, Failure):
|
|
87
|
+
return template_result
|
|
88
|
+
|
|
89
|
+
template_path = template_result.unwrap()
|
|
90
|
+
dest_file = path / "INVAR.md"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
dest_file.write_text(template_path.read_text())
|
|
94
|
+
return Success(True)
|
|
95
|
+
except OSError as e:
|
|
96
|
+
return Failure(f"Failed to update INVAR.md: {e}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def update_examples(path: Path, console: Console) -> Result[bool, str]:
|
|
100
|
+
"""Update .invar/examples/ directory."""
|
|
101
|
+
import shutil
|
|
102
|
+
|
|
103
|
+
examples_dest = path / ".invar" / "examples"
|
|
104
|
+
|
|
105
|
+
# Remove existing examples
|
|
106
|
+
if examples_dest.exists():
|
|
107
|
+
try:
|
|
108
|
+
shutil.rmtree(examples_dest)
|
|
109
|
+
except OSError as e:
|
|
110
|
+
return Failure(f"Failed to remove old examples: {e}")
|
|
111
|
+
|
|
112
|
+
# Copy new examples
|
|
113
|
+
return copy_examples_directory(path, console)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update(
|
|
117
|
+
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
118
|
+
force: bool = typer.Option(
|
|
119
|
+
False, "--force", "-f", help="Update even if already at latest version"
|
|
120
|
+
),
|
|
121
|
+
check: bool = typer.Option(
|
|
122
|
+
False, "--check", help="Check for updates without applying"
|
|
123
|
+
),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Update Invar-managed files to latest version.
|
|
127
|
+
|
|
128
|
+
Updates INVAR.md and .invar/examples/ from the installed python-invar package.
|
|
129
|
+
User-managed files (CLAUDE.md, .invar/context.md) are never modified.
|
|
130
|
+
|
|
131
|
+
Use --check to see if updates are available without applying them.
|
|
132
|
+
Use --force to update even if already at latest version.
|
|
133
|
+
"""
|
|
134
|
+
# Get current version
|
|
135
|
+
current_result = get_current_version(path)
|
|
136
|
+
if isinstance(current_result, Failure):
|
|
137
|
+
console.print(f"[red]Error:[/red] {current_result.failure()}")
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
current_version = current_result.unwrap()
|
|
140
|
+
|
|
141
|
+
# Get template version
|
|
142
|
+
template_result = get_template_version()
|
|
143
|
+
if isinstance(template_result, Failure):
|
|
144
|
+
console.print(f"[red]Error:[/red] {template_result.failure()}")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
template_version = template_result.unwrap()
|
|
147
|
+
|
|
148
|
+
current_str = format_version(current_version)
|
|
149
|
+
template_str = format_version(template_version)
|
|
150
|
+
|
|
151
|
+
# Compare versions
|
|
152
|
+
needs_update = template_version > current_version
|
|
153
|
+
|
|
154
|
+
if check:
|
|
155
|
+
# Check mode: just report status
|
|
156
|
+
if needs_update:
|
|
157
|
+
console.print(f"[yellow]Update available:[/yellow] {current_str} → {template_str}")
|
|
158
|
+
else:
|
|
159
|
+
console.print(f"[green]Up to date:[/green] {current_str}")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if not needs_update and not force:
|
|
163
|
+
console.print(f"[green]Already at latest version:[/green] {current_str}")
|
|
164
|
+
console.print("[dim]Use --force to update anyway[/dim]")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Perform update
|
|
168
|
+
console.print("\n[bold]Updating Invar files...[/bold]")
|
|
169
|
+
console.print(f" Version: {current_str} → {template_str}")
|
|
170
|
+
console.print()
|
|
171
|
+
|
|
172
|
+
# Update INVAR.md
|
|
173
|
+
result = update_invar_md(path, console)
|
|
174
|
+
if isinstance(result, Failure):
|
|
175
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
console.print(f"[green]Updated[/green] INVAR.md ({template_str})")
|
|
178
|
+
|
|
179
|
+
# Update examples
|
|
180
|
+
result = update_examples(path, console)
|
|
181
|
+
if isinstance(result, Failure):
|
|
182
|
+
console.print(f"[yellow]Warning:[/yellow] {result.failure()}")
|
|
183
|
+
else:
|
|
184
|
+
console.print("[green]Updated[/green] .invar/examples/")
|
|
185
|
+
|
|
186
|
+
# Remind about user-managed files
|
|
187
|
+
console.print()
|
|
188
|
+
console.print("[dim]User-managed files unchanged:[/dim]")
|
|
189
|
+
console.print("[dim] ○ CLAUDE.md[/dim]")
|
|
190
|
+
console.print("[dim] ○ .invar/context.md[/dim]")
|
|
191
|
+
console.print("[dim] ○ pyproject.toml [tool.invar][/dim]")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Project Development Guide
|
|
2
|
+
|
|
3
|
+
> **Protocol:** Follow [INVAR.md](./INVAR.md) — includes Session Start, ICIDIV workflow, and Task Completion requirements.
|
|
4
|
+
|
|
5
|
+
## Claude-Specific: Entry Verification
|
|
6
|
+
|
|
7
|
+
Your **first message** for any implementation task MUST include actual output from:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
invar_guard(changed=true) # or: invar guard --changed
|
|
11
|
+
invar_map(top=10) # or: invar map --top 10
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Use MCP tools if available**, otherwise use CLI commands.
|
|
15
|
+
|
|
16
|
+
No output = Session not started correctly. Stop, execute tools, restart.
|
|
17
|
+
|
|
18
|
+
This ensures you've followed the Session Start requirements in INVAR.md.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Project Structure
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/{project}/
|
|
26
|
+
├── core/ # Pure logic (@pre/@post, doctests, no I/O)
|
|
27
|
+
└── shell/ # I/O operations (Result[T, E] return type)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Key insight:** Core receives data (strings), Shell handles I/O (paths, files).
|
|
31
|
+
|
|
32
|
+
## Quick Reference
|
|
33
|
+
|
|
34
|
+
| Zone | Requirements |
|
|
35
|
+
|------|-------------|
|
|
36
|
+
| Core | `@pre`/`@post` + doctests, pure (no I/O) |
|
|
37
|
+
| Shell | Returns `Result[T, E]` from `returns` library |
|
|
38
|
+
|
|
39
|
+
## Documentation Structure
|
|
40
|
+
|
|
41
|
+
| File | Owner | Edit? | Purpose |
|
|
42
|
+
|------|-------|-------|---------|
|
|
43
|
+
| INVAR.md | Invar | No | Protocol (`invar update` to sync) |
|
|
44
|
+
| CLAUDE.md | User | Yes | Project customization (this file) |
|
|
45
|
+
| .invar/context.md | User | Yes | Project state, lessons learned |
|
|
46
|
+
| .invar/examples/ | Invar | No | **Must read:** Core/Shell patterns |
|
|
47
|
+
|
|
48
|
+
## Project-Specific Rules
|
|
49
|
+
|
|
50
|
+
<!-- Add your team conventions below -->
|
|
51
|
+
|
|
52
|
+
## Overrides
|
|
53
|
+
|
|
54
|
+
<!-- Document any exceptions to INVAR.md rules -->
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
*Generated by `invar init`. Customize freely.*
|