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
@@ -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.*