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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hypothesis fallback for proof verification.
|
|
3
|
+
|
|
4
|
+
DX-12: Provides Hypothesis as automatic fallback when CrossHair
|
|
5
|
+
is unavailable, times out, or skips files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from returns.result import Failure, Result, Success
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_hypothesis_fallback(
|
|
18
|
+
files: list[Path],
|
|
19
|
+
max_examples: int = 100,
|
|
20
|
+
) -> Result[dict, str]:
|
|
21
|
+
"""
|
|
22
|
+
Run Hypothesis property tests as fallback when CrossHair skips/times out.
|
|
23
|
+
|
|
24
|
+
DX-12: Uses inferred strategies from type hints and @pre contracts.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
files: List of Python file paths to test
|
|
28
|
+
max_examples: Maximum examples per test
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Success with test results or Failure with error message
|
|
32
|
+
"""
|
|
33
|
+
# Import CrossHairStatus here to avoid circular import
|
|
34
|
+
from invar.shell.prove import CrossHairStatus
|
|
35
|
+
|
|
36
|
+
# Check if hypothesis is available
|
|
37
|
+
try:
|
|
38
|
+
import hypothesis # noqa: F401
|
|
39
|
+
except ImportError:
|
|
40
|
+
return Success(
|
|
41
|
+
{
|
|
42
|
+
"status": CrossHairStatus.SKIPPED,
|
|
43
|
+
"reason": "Hypothesis not installed (pip install hypothesis)",
|
|
44
|
+
"files": [],
|
|
45
|
+
"tool": "hypothesis",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not files:
|
|
50
|
+
return Success(
|
|
51
|
+
{
|
|
52
|
+
"status": CrossHairStatus.SKIPPED,
|
|
53
|
+
"reason": "no files",
|
|
54
|
+
"files": [],
|
|
55
|
+
"tool": "hypothesis",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Filter to Python files only
|
|
60
|
+
py_files = [f for f in files if f.suffix == ".py" and f.exists()]
|
|
61
|
+
if not py_files:
|
|
62
|
+
return Success(
|
|
63
|
+
{
|
|
64
|
+
"status": CrossHairStatus.SKIPPED,
|
|
65
|
+
"reason": "no Python files",
|
|
66
|
+
"files": [],
|
|
67
|
+
"tool": "hypothesis",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Use pytest with hypothesis
|
|
72
|
+
cmd = [
|
|
73
|
+
sys.executable,
|
|
74
|
+
"-m",
|
|
75
|
+
"pytest",
|
|
76
|
+
"--hypothesis-show-statistics",
|
|
77
|
+
"--hypothesis-seed=0", # Reproducible
|
|
78
|
+
"-x", # Stop on first failure
|
|
79
|
+
"--tb=short",
|
|
80
|
+
]
|
|
81
|
+
cmd.extend(str(f) for f in py_files)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
85
|
+
# Pytest exit codes: 0=passed, 5=no tests collected
|
|
86
|
+
is_passed = result.returncode in (0, 5)
|
|
87
|
+
return Success(
|
|
88
|
+
{
|
|
89
|
+
"status": "passed" if is_passed else "failed",
|
|
90
|
+
"files": [str(f) for f in py_files],
|
|
91
|
+
"exit_code": result.returncode,
|
|
92
|
+
"stdout": result.stdout,
|
|
93
|
+
"stderr": result.stderr,
|
|
94
|
+
"tool": "hypothesis",
|
|
95
|
+
"note": "Fallback from CrossHair",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
except subprocess.TimeoutExpired:
|
|
99
|
+
return Failure("Hypothesis timeout (300s)")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return Failure(f"Hypothesis error: {e}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_prove_with_fallback(
|
|
105
|
+
files: list[Path],
|
|
106
|
+
crosshair_timeout: int = 10,
|
|
107
|
+
hypothesis_max_examples: int = 100,
|
|
108
|
+
use_cache: bool = True,
|
|
109
|
+
cache_dir: Path | None = None,
|
|
110
|
+
) -> Result[dict, str]:
|
|
111
|
+
"""
|
|
112
|
+
Run proof verification with automatic Hypothesis fallback.
|
|
113
|
+
|
|
114
|
+
DX-12 + DX-13: Tries CrossHair first with optimizations, falls back to Hypothesis.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
files: List of Python file paths to verify
|
|
118
|
+
crosshair_timeout: Ignored (kept for backwards compatibility)
|
|
119
|
+
hypothesis_max_examples: Maximum Hypothesis examples
|
|
120
|
+
use_cache: Whether to use verification cache (DX-13)
|
|
121
|
+
cache_dir: Cache directory (default: .invar/cache/prove)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Success with verification results or Failure with error message
|
|
125
|
+
"""
|
|
126
|
+
# Import here to avoid circular import
|
|
127
|
+
from invar.shell.prove import CrossHairStatus, run_crosshair_parallel
|
|
128
|
+
from invar.shell.prove_cache import ProveCache
|
|
129
|
+
|
|
130
|
+
# DX-13: Initialize cache
|
|
131
|
+
cache = None
|
|
132
|
+
if use_cache:
|
|
133
|
+
if cache_dir is None:
|
|
134
|
+
cache_dir = Path(".invar/cache/prove")
|
|
135
|
+
cache = ProveCache(cache_dir=cache_dir)
|
|
136
|
+
|
|
137
|
+
# DX-13: Use parallel CrossHair with caching
|
|
138
|
+
crosshair_result = run_crosshair_parallel(
|
|
139
|
+
files,
|
|
140
|
+
max_iterations=5, # Fast mode
|
|
141
|
+
max_workers=None, # Auto-detect
|
|
142
|
+
cache=cache,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if isinstance(crosshair_result, Failure):
|
|
146
|
+
# CrossHair failed, try Hypothesis
|
|
147
|
+
return run_hypothesis_fallback(files, max_examples=hypothesis_max_examples)
|
|
148
|
+
|
|
149
|
+
result_data = crosshair_result.unwrap()
|
|
150
|
+
status = result_data.get("status", "")
|
|
151
|
+
|
|
152
|
+
# Check if we need fallback
|
|
153
|
+
needs_fallback = (
|
|
154
|
+
status == CrossHairStatus.SKIPPED
|
|
155
|
+
or status == CrossHairStatus.TIMEOUT
|
|
156
|
+
or "not installed" in result_data.get("reason", "")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if needs_fallback:
|
|
160
|
+
# Run Hypothesis as fallback
|
|
161
|
+
hypothesis_result = run_hypothesis_fallback(
|
|
162
|
+
files, max_examples=hypothesis_max_examples
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if isinstance(hypothesis_result, Success):
|
|
166
|
+
hyp_data = hypothesis_result.unwrap()
|
|
167
|
+
# Merge results
|
|
168
|
+
return Success(
|
|
169
|
+
{
|
|
170
|
+
"status": hyp_data.get("status", "unknown"),
|
|
171
|
+
"primary_tool": "hypothesis",
|
|
172
|
+
"crosshair_status": status,
|
|
173
|
+
"crosshair_reason": result_data.get("reason", ""),
|
|
174
|
+
"hypothesis_result": hyp_data,
|
|
175
|
+
"files": [str(f) for f in files],
|
|
176
|
+
"note": "CrossHair skipped/unavailable, used Hypothesis fallback",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return hypothesis_result
|
|
180
|
+
|
|
181
|
+
# CrossHair succeeded (verified or found counterexample)
|
|
182
|
+
result_data["primary_tool"] = "crosshair"
|
|
183
|
+
return Success(result_data)
|
invar/shell/templates.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Template management for invar init.
|
|
3
|
+
|
|
4
|
+
Shell module: handles file I/O for template operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.resources as resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from returns.result import Failure, Result, Success
|
|
13
|
+
|
|
14
|
+
_DEFAULT_PYPROJECT_CONFIG = """\n# Invar Configuration
|
|
15
|
+
[tool.invar.guard]
|
|
16
|
+
core_paths = ["src/core"]
|
|
17
|
+
shell_paths = ["src/shell"]
|
|
18
|
+
max_file_lines = 300
|
|
19
|
+
max_function_lines = 50
|
|
20
|
+
require_contracts = true
|
|
21
|
+
require_doctests = true
|
|
22
|
+
forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
|
|
23
|
+
exclude_paths = ["tests", "scripts", ".venv"]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_DEFAULT_INVAR_TOML = """# Invar Configuration
|
|
27
|
+
# For projects without pyproject.toml
|
|
28
|
+
|
|
29
|
+
[guard]
|
|
30
|
+
core_paths = ["src/core"]
|
|
31
|
+
shell_paths = ["src/shell"]
|
|
32
|
+
max_file_lines = 300
|
|
33
|
+
max_function_lines = 50
|
|
34
|
+
require_contracts = true
|
|
35
|
+
require_doctests = true
|
|
36
|
+
forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
|
|
37
|
+
exclude_paths = ["tests", "scripts", ".venv"]
|
|
38
|
+
|
|
39
|
+
# Pattern-based classification (optional, takes priority over paths)
|
|
40
|
+
# core_patterns = ["**/domain/**", "**/models/**"]
|
|
41
|
+
# shell_patterns = ["**/api/**", "**/cli/**"]
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_template_path(name: str) -> Result[Path, str]:
|
|
46
|
+
"""Get path to a template file."""
|
|
47
|
+
try:
|
|
48
|
+
path = Path(str(resources.files("invar.templates").joinpath(name)))
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return Failure(f"Template '{name}' not found")
|
|
51
|
+
return Success(path)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return Failure(f"Failed to get template path: {e}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def copy_template(
|
|
57
|
+
template_name: str, dest: Path, dest_name: str | None = None
|
|
58
|
+
) -> Result[bool, str]:
|
|
59
|
+
"""Copy a template file to destination. Returns Success(True) if copied, Success(False) if skipped."""
|
|
60
|
+
if dest_name is None:
|
|
61
|
+
dest_name = template_name.replace(".template", "")
|
|
62
|
+
dest_file = dest / dest_name
|
|
63
|
+
if dest_file.exists():
|
|
64
|
+
return Success(False)
|
|
65
|
+
template_result = get_template_path(template_name)
|
|
66
|
+
if isinstance(template_result, Failure):
|
|
67
|
+
return template_result
|
|
68
|
+
template_path = template_result.unwrap()
|
|
69
|
+
try:
|
|
70
|
+
dest_file.write_text(template_path.read_text())
|
|
71
|
+
return Success(True)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
return Failure(f"Failed to copy template: {e}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def add_config(path: Path, console) -> Result[bool, str]:
|
|
77
|
+
"""Add configuration to project. Returns Success(True) if added, Success(False) if skipped."""
|
|
78
|
+
pyproject = path / "pyproject.toml"
|
|
79
|
+
invar_toml = path / "invar.toml"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if pyproject.exists():
|
|
83
|
+
content = pyproject.read_text()
|
|
84
|
+
if "[tool.invar]" not in content:
|
|
85
|
+
with pyproject.open("a") as f:
|
|
86
|
+
f.write(_DEFAULT_PYPROJECT_CONFIG)
|
|
87
|
+
console.print("[green]Added[/green] [tool.invar.guard] to pyproject.toml")
|
|
88
|
+
return Success(True)
|
|
89
|
+
return Success(False)
|
|
90
|
+
|
|
91
|
+
if not invar_toml.exists():
|
|
92
|
+
invar_toml.write_text(_DEFAULT_INVAR_TOML)
|
|
93
|
+
console.print("[green]Created[/green] invar.toml")
|
|
94
|
+
return Success(True)
|
|
95
|
+
|
|
96
|
+
return Success(False)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
return Failure(f"Failed to add config: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_directories(path: Path, console) -> None:
|
|
102
|
+
"""Create src/core and src/shell directories."""
|
|
103
|
+
core_path = path / "src" / "core"
|
|
104
|
+
shell_path = path / "src" / "shell"
|
|
105
|
+
|
|
106
|
+
if not core_path.exists():
|
|
107
|
+
core_path.mkdir(parents=True)
|
|
108
|
+
(core_path / "__init__.py").touch()
|
|
109
|
+
console.print("[green]Created[/green] src/core/")
|
|
110
|
+
|
|
111
|
+
if not shell_path.exists():
|
|
112
|
+
shell_path.mkdir(parents=True)
|
|
113
|
+
(shell_path / "__init__.py").touch()
|
|
114
|
+
console.print("[green]Created[/green] src/shell/")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
|
|
118
|
+
"""Copy examples directory to .invar/examples/. Returns Success(True) if copied."""
|
|
119
|
+
import shutil
|
|
120
|
+
|
|
121
|
+
examples_dest = dest / ".invar" / "examples"
|
|
122
|
+
if examples_dest.exists():
|
|
123
|
+
return Success(False)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
examples_src = Path(str(resources.files("invar.templates").joinpath("examples")))
|
|
127
|
+
if not examples_src.exists():
|
|
128
|
+
return Failure("Examples template directory not found")
|
|
129
|
+
|
|
130
|
+
# Create .invar if needed
|
|
131
|
+
invar_dir = dest / ".invar"
|
|
132
|
+
if not invar_dir.exists():
|
|
133
|
+
invar_dir.mkdir()
|
|
134
|
+
|
|
135
|
+
shutil.copytree(examples_src, examples_dest)
|
|
136
|
+
console.print("[green]Created[/green] .invar/examples/ (reference examples)")
|
|
137
|
+
return Success(True)
|
|
138
|
+
except OSError as e:
|
|
139
|
+
return Failure(f"Failed to copy examples: {e}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Agent configuration for multi-agent support (DX-11, DX-17)
|
|
143
|
+
AGENT_CONFIGS = {
|
|
144
|
+
"claude": {
|
|
145
|
+
"file": "CLAUDE.md",
|
|
146
|
+
"template": "CLAUDE.md.template",
|
|
147
|
+
"reference": '> **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.\n',
|
|
148
|
+
"check_pattern": "INVAR.md",
|
|
149
|
+
},
|
|
150
|
+
"cursor": {
|
|
151
|
+
"file": ".cursorrules",
|
|
152
|
+
"template": "cursorrules.template",
|
|
153
|
+
"reference": "Follow the Invar Protocol in INVAR.md.\n\n",
|
|
154
|
+
"check_pattern": "INVAR.md",
|
|
155
|
+
},
|
|
156
|
+
"aider": {
|
|
157
|
+
"file": ".aider.conf.yml",
|
|
158
|
+
"template": "aider.conf.yml.template",
|
|
159
|
+
"reference": "# Follow the Invar Protocol in INVAR.md\nread:\n - INVAR.md\n",
|
|
160
|
+
"check_pattern": "INVAR.md",
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
|
|
166
|
+
"""
|
|
167
|
+
Detect existing agent configuration files.
|
|
168
|
+
|
|
169
|
+
Returns dict of agent -> status where status is one of:
|
|
170
|
+
- "configured": File exists and contains Invar reference
|
|
171
|
+
- "found": File exists but no Invar reference
|
|
172
|
+
- "not_found": File does not exist
|
|
173
|
+
|
|
174
|
+
>>> from pathlib import Path
|
|
175
|
+
>>> import tempfile
|
|
176
|
+
>>> with tempfile.TemporaryDirectory() as tmp:
|
|
177
|
+
... result = detect_agent_configs(Path(tmp))
|
|
178
|
+
... result.unwrap()["claude"]
|
|
179
|
+
'not_found'
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
results = {}
|
|
183
|
+
for agent, config in AGENT_CONFIGS.items():
|
|
184
|
+
config_path = path / config["file"]
|
|
185
|
+
if config_path.exists():
|
|
186
|
+
content = config_path.read_text()
|
|
187
|
+
if config["check_pattern"] in content:
|
|
188
|
+
results[agent] = "configured"
|
|
189
|
+
else:
|
|
190
|
+
results[agent] = "found"
|
|
191
|
+
else:
|
|
192
|
+
results[agent] = "not_found"
|
|
193
|
+
return Success(results)
|
|
194
|
+
except OSError as e:
|
|
195
|
+
return Failure(f"Failed to detect agent configs: {e}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
|
|
199
|
+
"""Add Invar reference to an existing agent config file."""
|
|
200
|
+
if agent not in AGENT_CONFIGS:
|
|
201
|
+
return Failure(f"Unknown agent: {agent}")
|
|
202
|
+
|
|
203
|
+
config = AGENT_CONFIGS[agent]
|
|
204
|
+
config_path = path / config["file"]
|
|
205
|
+
|
|
206
|
+
if not config_path.exists():
|
|
207
|
+
return Failure(f"Config file not found: {config['file']}")
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
content = config_path.read_text()
|
|
211
|
+
if config["check_pattern"] in content:
|
|
212
|
+
return Success(False) # Already configured
|
|
213
|
+
|
|
214
|
+
# Prepend reference
|
|
215
|
+
new_content = config["reference"] + content
|
|
216
|
+
config_path.write_text(new_content)
|
|
217
|
+
console.print(f"[green]Updated[/green] {config['file']} (added Invar reference)")
|
|
218
|
+
return Success(True)
|
|
219
|
+
except OSError as e:
|
|
220
|
+
return Failure(f"Failed to update {config['file']}: {e}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
|
|
224
|
+
"""
|
|
225
|
+
Create agent config from template (DX-17).
|
|
226
|
+
|
|
227
|
+
Creates full template file for agents that don't have an existing config.
|
|
228
|
+
"""
|
|
229
|
+
if agent not in AGENT_CONFIGS:
|
|
230
|
+
return Failure(f"Unknown agent: {agent}")
|
|
231
|
+
|
|
232
|
+
config = AGENT_CONFIGS[agent]
|
|
233
|
+
config_path = path / config["file"]
|
|
234
|
+
|
|
235
|
+
if config_path.exists():
|
|
236
|
+
return Success(False) # Already exists
|
|
237
|
+
|
|
238
|
+
# Use template if available
|
|
239
|
+
template_name = config.get("template")
|
|
240
|
+
if template_name:
|
|
241
|
+
result = copy_template(template_name, path, config["file"])
|
|
242
|
+
if isinstance(result, Success) and result.unwrap():
|
|
243
|
+
console.print(f"[green]Created[/green] {config['file']} (Invar workflow enforcement)")
|
|
244
|
+
return Success(True)
|
|
245
|
+
elif isinstance(result, Failure):
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
return Success(False)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def configure_mcp_server(path: Path, console) -> Result[list[str], str]:
|
|
252
|
+
"""
|
|
253
|
+
Configure MCP server for AI agents (DX-16).
|
|
254
|
+
|
|
255
|
+
Creates:
|
|
256
|
+
- .invar/mcp-server.json (universal config)
|
|
257
|
+
- .invar/mcp-setup.md (manual setup instructions)
|
|
258
|
+
- Updates .claude/settings.json if .claude/ exists
|
|
259
|
+
|
|
260
|
+
Returns list of configured agents.
|
|
261
|
+
"""
|
|
262
|
+
import json
|
|
263
|
+
|
|
264
|
+
configured: list[str] = []
|
|
265
|
+
invar_dir = path / ".invar"
|
|
266
|
+
|
|
267
|
+
# Ensure .invar exists
|
|
268
|
+
if not invar_dir.exists():
|
|
269
|
+
invar_dir.mkdir()
|
|
270
|
+
|
|
271
|
+
# MCP config using current Python (the one that has invar installed)
|
|
272
|
+
import sys
|
|
273
|
+
|
|
274
|
+
mcp_config = {
|
|
275
|
+
"name": "invar",
|
|
276
|
+
"command": sys.executable,
|
|
277
|
+
"args": ["-m", "invar.mcp"],
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# 1. Create .mcp.json at project root (Claude Code standard)
|
|
281
|
+
mcp_json_path = path / ".mcp.json"
|
|
282
|
+
if not mcp_json_path.exists():
|
|
283
|
+
mcp_json_content = {
|
|
284
|
+
"mcpServers": {
|
|
285
|
+
"invar": {
|
|
286
|
+
"command": mcp_config["command"],
|
|
287
|
+
"args": mcp_config["args"],
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
mcp_json_path.write_text(json.dumps(mcp_json_content, indent=2))
|
|
292
|
+
console.print("[green]Created[/green] .mcp.json (MCP server config)")
|
|
293
|
+
configured.append("Claude Code")
|
|
294
|
+
else:
|
|
295
|
+
# Check if invar is already configured
|
|
296
|
+
try:
|
|
297
|
+
existing = json.loads(mcp_json_path.read_text())
|
|
298
|
+
if "mcpServers" in existing and "invar" in existing.get("mcpServers", {}):
|
|
299
|
+
console.print("[dim]Skipped[/dim] .mcp.json (invar already configured)")
|
|
300
|
+
else:
|
|
301
|
+
# Add invar to existing config
|
|
302
|
+
if "mcpServers" not in existing:
|
|
303
|
+
existing["mcpServers"] = {}
|
|
304
|
+
existing["mcpServers"]["invar"] = {
|
|
305
|
+
"command": mcp_config["command"],
|
|
306
|
+
"args": mcp_config["args"],
|
|
307
|
+
}
|
|
308
|
+
mcp_json_path.write_text(json.dumps(existing, indent=2))
|
|
309
|
+
console.print("[green]Updated[/green] .mcp.json (added invar)")
|
|
310
|
+
configured.append("Claude Code")
|
|
311
|
+
except (OSError, json.JSONDecodeError):
|
|
312
|
+
console.print("[yellow]Warning[/yellow] .mcp.json exists but couldn't update")
|
|
313
|
+
|
|
314
|
+
# 2. Create setup instructions (for reference)
|
|
315
|
+
mcp_setup = invar_dir / "mcp-setup.md"
|
|
316
|
+
if not mcp_setup.exists():
|
|
317
|
+
mcp_setup.write_text(_MCP_SETUP_TEMPLATE)
|
|
318
|
+
console.print("[green]Created[/green] .invar/mcp-setup.md (setup guide)")
|
|
319
|
+
|
|
320
|
+
return Success(configured)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
_MCP_SETUP_TEMPLATE = """\
|
|
324
|
+
# Invar MCP Server Setup
|
|
325
|
+
|
|
326
|
+
This project includes an MCP server that provides Invar tools to AI agents.
|
|
327
|
+
|
|
328
|
+
## Available Tools
|
|
329
|
+
|
|
330
|
+
| Tool | Replaces | Purpose |
|
|
331
|
+
|------|----------|---------|
|
|
332
|
+
| `invar_guard` | `pytest`, `crosshair` | Smart Guard verification |
|
|
333
|
+
| `invar_sig` | `Read` entire file | Show contracts and signatures |
|
|
334
|
+
| `invar_map` | `Grep` for functions | Symbol map with reference counts |
|
|
335
|
+
|
|
336
|
+
## Configuration
|
|
337
|
+
|
|
338
|
+
`invar init` automatically creates `.mcp.json` with smart detection of available methods.
|
|
339
|
+
|
|
340
|
+
### Recommended: uvx (isolated environment)
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{
|
|
344
|
+
"mcpServers": {
|
|
345
|
+
"invar": {
|
|
346
|
+
"command": "uvx",
|
|
347
|
+
"args": ["invar-tools", "mcp"]
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Alternative: invar command
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{
|
|
357
|
+
"mcpServers": {
|
|
358
|
+
"invar": {
|
|
359
|
+
"command": "invar",
|
|
360
|
+
"args": ["mcp"]
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Fallback: Python path
|
|
367
|
+
|
|
368
|
+
```json
|
|
369
|
+
{
|
|
370
|
+
"mcpServers": {
|
|
371
|
+
"invar": {
|
|
372
|
+
"command": "/path/to/your/.venv/bin/python",
|
|
373
|
+
"args": ["-m", "invar.mcp"]
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Find your Python path: `python -c "import sys; print(sys.executable)"`
|
|
380
|
+
|
|
381
|
+
## Installation
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
# Recommended: use uvx (no installation needed)
|
|
385
|
+
uvx invar-tools guard
|
|
386
|
+
|
|
387
|
+
# Or install globally
|
|
388
|
+
pip install invar-tools
|
|
389
|
+
|
|
390
|
+
# Or install in project
|
|
391
|
+
pip install invar-tools
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Testing
|
|
395
|
+
|
|
396
|
+
Run the MCP server directly:
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
# Using uvx
|
|
400
|
+
uvx invar-tools mcp
|
|
401
|
+
|
|
402
|
+
# Or if installed
|
|
403
|
+
invar mcp
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The server communicates via stdio and should be managed by your AI agent.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def install_hooks(path: Path, console) -> Result[bool, str]:
|
|
411
|
+
"""Install pre-commit hooks configuration and activate them."""
|
|
412
|
+
import subprocess
|
|
413
|
+
|
|
414
|
+
pre_commit_config = path / ".pre-commit-config.yaml"
|
|
415
|
+
|
|
416
|
+
if pre_commit_config.exists():
|
|
417
|
+
console.print("[yellow]Skipped[/yellow] .pre-commit-config.yaml (already exists)")
|
|
418
|
+
return Success(False)
|
|
419
|
+
|
|
420
|
+
result = copy_template("pre-commit-config.yaml.template", path, ".pre-commit-config.yaml")
|
|
421
|
+
if isinstance(result, Failure):
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
if result.unwrap():
|
|
425
|
+
console.print("[green]Created[/green] .pre-commit-config.yaml")
|
|
426
|
+
|
|
427
|
+
# Auto-install hooks (Automatic > Opt-in)
|
|
428
|
+
try:
|
|
429
|
+
subprocess.run(
|
|
430
|
+
["pre-commit", "install"],
|
|
431
|
+
cwd=path,
|
|
432
|
+
check=True,
|
|
433
|
+
capture_output=True,
|
|
434
|
+
)
|
|
435
|
+
console.print("[green]Installed[/green] pre-commit hooks")
|
|
436
|
+
except FileNotFoundError:
|
|
437
|
+
console.print("[dim]Run: pre-commit install (pre-commit not in PATH)[/dim]")
|
|
438
|
+
except subprocess.CalledProcessError:
|
|
439
|
+
console.print("[dim]Run: pre-commit install (not a git repo?)[/dim]")
|
|
440
|
+
|
|
441
|
+
return Success(True)
|
|
442
|
+
|
|
443
|
+
return Success(False)
|