invar-tools 1.0.0__py3-none-any.whl → 1.3.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 +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""DX-49: Template engine with I/O operations and Jinja2 support.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- File update with region preservation
|
|
5
|
+
- Jinja2 template rendering
|
|
6
|
+
- Manifest loading
|
|
7
|
+
|
|
8
|
+
Pure parsing logic is in core/template_parser.py.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from deal import pre
|
|
17
|
+
from returns.result import Failure, Result, Success
|
|
18
|
+
|
|
19
|
+
# Import pure logic from Core
|
|
20
|
+
from invar.core.template_parser import (
|
|
21
|
+
ParsedFile,
|
|
22
|
+
Region,
|
|
23
|
+
get_syntax_for_command,
|
|
24
|
+
parse_invar_regions,
|
|
25
|
+
reconstruct_file,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Re-export for convenience
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ParsedFile",
|
|
31
|
+
"Region",
|
|
32
|
+
"generate_from_manifest",
|
|
33
|
+
"get_syntax_for_command",
|
|
34
|
+
"get_templates_dir",
|
|
35
|
+
"is_invar_project",
|
|
36
|
+
"load_manifest",
|
|
37
|
+
"parse_invar_regions",
|
|
38
|
+
"reconstruct_file",
|
|
39
|
+
"render_template",
|
|
40
|
+
"render_template_file",
|
|
41
|
+
"update_file_with_regions",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# File Update Logic
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# @shell_complexity: File handling requires branching for exists/has_regions/new_project cases
|
|
51
|
+
@pre(lambda path, new_managed: isinstance(path, Path) and isinstance(new_managed, str))
|
|
52
|
+
def update_file_with_regions(
|
|
53
|
+
path: Path,
|
|
54
|
+
new_managed: str,
|
|
55
|
+
new_project: str | None = None,
|
|
56
|
+
) -> Result[str, str]:
|
|
57
|
+
"""Update file preserving user regions and unmarked content.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: File path to update
|
|
61
|
+
new_managed: New content for managed region
|
|
62
|
+
new_project: New content for project region (sync-self only)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Success with updated content, or Failure with error message
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
>>> from pathlib import Path
|
|
69
|
+
>>> import tempfile
|
|
70
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
|
71
|
+
... _ = f.write('''<!--invar:managed-->
|
|
72
|
+
... old
|
|
73
|
+
... <!--/invar:managed-->
|
|
74
|
+
... <!--invar:user-->
|
|
75
|
+
... keep this
|
|
76
|
+
... <!--/invar:user-->''')
|
|
77
|
+
... path = Path(f.name)
|
|
78
|
+
>>> result = update_file_with_regions(path, "NEW")
|
|
79
|
+
>>> isinstance(result, Success)
|
|
80
|
+
True
|
|
81
|
+
>>> "NEW" in result.unwrap()
|
|
82
|
+
True
|
|
83
|
+
>>> "keep this" in result.unwrap()
|
|
84
|
+
True
|
|
85
|
+
>>> path.unlink() # cleanup
|
|
86
|
+
"""
|
|
87
|
+
if not path.exists():
|
|
88
|
+
# New file - just wrap in managed region
|
|
89
|
+
content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n"
|
|
90
|
+
if new_project:
|
|
91
|
+
content += f"\n<!--invar:project-->\n{new_project}\n<!--/invar:project-->\n"
|
|
92
|
+
content += "\n<!--invar:user-->\n\n<!--/invar:user-->\n"
|
|
93
|
+
return Success(content)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
current = path.read_text()
|
|
97
|
+
except OSError as e:
|
|
98
|
+
return Failure(f"Failed to read {path}: {e}")
|
|
99
|
+
|
|
100
|
+
parsed = parse_invar_regions(current)
|
|
101
|
+
|
|
102
|
+
if not parsed.has_regions:
|
|
103
|
+
# No markers - insert at top, preserve rest
|
|
104
|
+
content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n\n"
|
|
105
|
+
content += current
|
|
106
|
+
return Success(content)
|
|
107
|
+
|
|
108
|
+
# Build updates dict
|
|
109
|
+
updates: dict[str, str] = {"managed": new_managed}
|
|
110
|
+
if new_project is not None and "project" in parsed.regions:
|
|
111
|
+
updates["project"] = new_project
|
|
112
|
+
|
|
113
|
+
result = reconstruct_file(parsed, updates)
|
|
114
|
+
return Success(result)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# Jinja2 Template Rendering
|
|
119
|
+
# =============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_template(
|
|
123
|
+
template_content: str,
|
|
124
|
+
variables: dict[str, str],
|
|
125
|
+
) -> Result[str, str]:
|
|
126
|
+
"""Render a Jinja2 template with given variables.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
template_content: Jinja2 template string
|
|
130
|
+
variables: Variables to inject (syntax, version, project_name, etc.)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Success with rendered content, or Failure with error
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> result = render_template("Hello {{ name }}", {"name": "World"})
|
|
137
|
+
>>> result.unwrap()
|
|
138
|
+
'Hello World'
|
|
139
|
+
|
|
140
|
+
>>> result = render_template("{% if x %}yes{% endif %}", {"x": True})
|
|
141
|
+
>>> result.unwrap()
|
|
142
|
+
'yes'
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
from jinja2 import BaseLoader, Environment, StrictUndefined
|
|
146
|
+
|
|
147
|
+
env = Environment(
|
|
148
|
+
loader=BaseLoader(),
|
|
149
|
+
undefined=StrictUndefined,
|
|
150
|
+
keep_trailing_newline=True,
|
|
151
|
+
)
|
|
152
|
+
template = env.from_string(template_content)
|
|
153
|
+
rendered = template.render(**variables)
|
|
154
|
+
return Success(rendered)
|
|
155
|
+
except ImportError:
|
|
156
|
+
return Failure("Jinja2 not installed. Run: pip install jinja2")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return Failure(f"Template rendering failed: {e}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_template_file(
|
|
162
|
+
template_path: Path,
|
|
163
|
+
variables: dict[str, str],
|
|
164
|
+
) -> Result[str, str]:
|
|
165
|
+
"""Render a Jinja2 template file.
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
>>> from pathlib import Path
|
|
169
|
+
>>> import tempfile
|
|
170
|
+
>>> with tempfile.NamedTemporaryFile(mode='w', suffix='.jinja', delete=False) as f:
|
|
171
|
+
... _ = f.write("Version: {{ version }}")
|
|
172
|
+
... path = Path(f.name)
|
|
173
|
+
>>> result = render_template_file(path, {"version": "5.0"})
|
|
174
|
+
>>> result.unwrap()
|
|
175
|
+
'Version: 5.0'
|
|
176
|
+
>>> path.unlink()
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
content = template_path.read_text()
|
|
180
|
+
except OSError as e:
|
|
181
|
+
return Failure(f"Failed to read template {template_path}: {e}")
|
|
182
|
+
|
|
183
|
+
return render_template(content, variables)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Manifest Loading
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# @shell_complexity: TOML loading with Python version fallback (tomllib vs tomli)
|
|
192
|
+
def load_manifest(templates_dir: Path) -> Result[dict, str]:
|
|
193
|
+
"""Load manifest.toml from templates directory.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
>>> from pathlib import Path
|
|
197
|
+
>>> # Will fail if manifest doesn't exist
|
|
198
|
+
>>> result = load_manifest(Path("/nonexistent"))
|
|
199
|
+
>>> isinstance(result, Failure)
|
|
200
|
+
True
|
|
201
|
+
"""
|
|
202
|
+
manifest_path = templates_dir / "manifest.toml"
|
|
203
|
+
|
|
204
|
+
if not manifest_path.exists():
|
|
205
|
+
return Failure(f"Manifest not found: {manifest_path}")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
import tomllib
|
|
209
|
+
|
|
210
|
+
content = manifest_path.read_text()
|
|
211
|
+
data = tomllib.loads(content)
|
|
212
|
+
return Success(data)
|
|
213
|
+
except ImportError:
|
|
214
|
+
# Python < 3.11
|
|
215
|
+
try:
|
|
216
|
+
import tomli as tomllib # type: ignore
|
|
217
|
+
|
|
218
|
+
content = manifest_path.read_text()
|
|
219
|
+
data = tomllib.loads(content)
|
|
220
|
+
return Success(data)
|
|
221
|
+
except ImportError:
|
|
222
|
+
return Failure("tomllib/tomli not available")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return Failure(f"Failed to parse manifest: {e}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# =============================================================================
|
|
228
|
+
# Template Generation
|
|
229
|
+
# =============================================================================
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_templates_dir() -> Path:
|
|
233
|
+
"""Get the templates directory path."""
|
|
234
|
+
return Path(__file__).parent.parent / "templates"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# @shell_complexity: Template generation has multiple paths for copy/jinja/copy_dir types
|
|
238
|
+
def generate_from_manifest(
|
|
239
|
+
dest_root: Path,
|
|
240
|
+
syntax: str = "cli",
|
|
241
|
+
files_to_generate: list[str] | None = None,
|
|
242
|
+
) -> Result[list[str], str]:
|
|
243
|
+
"""Generate files from manifest.toml templates.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
dest_root: Destination project root
|
|
247
|
+
syntax: "cli" or "mcp" for command syntax
|
|
248
|
+
files_to_generate: Optional list of files to generate (None = all from manifest)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Success with list of generated files, or Failure with error
|
|
252
|
+
"""
|
|
253
|
+
templates_dir = get_templates_dir()
|
|
254
|
+
manifest_result = load_manifest(templates_dir)
|
|
255
|
+
if isinstance(manifest_result, Failure):
|
|
256
|
+
return manifest_result
|
|
257
|
+
|
|
258
|
+
manifest = manifest_result.unwrap()
|
|
259
|
+
templates = manifest.get("templates", {})
|
|
260
|
+
# Copy to avoid mutating cached manifest
|
|
261
|
+
variables = {**manifest.get("variables", {}), "syntax": syntax}
|
|
262
|
+
|
|
263
|
+
generated: list[str] = []
|
|
264
|
+
|
|
265
|
+
for dest_path, config in templates.items():
|
|
266
|
+
# Skip if not in files_to_generate list
|
|
267
|
+
if files_to_generate is not None and dest_path not in files_to_generate:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
src = config.get("src", "")
|
|
271
|
+
template_type = config.get("type", "copy")
|
|
272
|
+
src_path = templates_dir / src
|
|
273
|
+
|
|
274
|
+
# Resolve destination
|
|
275
|
+
full_dest = dest_root / dest_path
|
|
276
|
+
|
|
277
|
+
# Ensure parent directories exist
|
|
278
|
+
full_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
if template_type == "copy":
|
|
281
|
+
# Direct file copy
|
|
282
|
+
if not src_path.exists():
|
|
283
|
+
continue
|
|
284
|
+
if full_dest.exists():
|
|
285
|
+
continue # Don't overwrite existing
|
|
286
|
+
try:
|
|
287
|
+
full_dest.write_text(src_path.read_text())
|
|
288
|
+
generated.append(dest_path)
|
|
289
|
+
except OSError as e:
|
|
290
|
+
print(f"Warning: Failed to copy {dest_path}: {e}", file=sys.stderr)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
elif template_type == "jinja":
|
|
294
|
+
# Jinja2 template rendering
|
|
295
|
+
if not src_path.exists():
|
|
296
|
+
continue
|
|
297
|
+
if full_dest.exists():
|
|
298
|
+
continue # Don't overwrite existing
|
|
299
|
+
result = render_template_file(src_path, variables)
|
|
300
|
+
if isinstance(result, Success):
|
|
301
|
+
try:
|
|
302
|
+
full_dest.write_text(result.unwrap())
|
|
303
|
+
generated.append(dest_path)
|
|
304
|
+
except OSError as e:
|
|
305
|
+
print(f"Warning: Failed to write {dest_path}: {e}", file=sys.stderr)
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
elif template_type == "copy_dir":
|
|
309
|
+
# Directory copy
|
|
310
|
+
if not src_path.exists() or not src_path.is_dir():
|
|
311
|
+
continue
|
|
312
|
+
if full_dest.exists():
|
|
313
|
+
continue # Don't overwrite existing directory
|
|
314
|
+
try:
|
|
315
|
+
import shutil
|
|
316
|
+
shutil.copytree(src_path, full_dest)
|
|
317
|
+
generated.append(dest_path)
|
|
318
|
+
except OSError as e:
|
|
319
|
+
print(f"Warning: Failed to copy directory {dest_path}: {e}", file=sys.stderr)
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
return Success(generated)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# =============================================================================
|
|
326
|
+
# Utility Functions
|
|
327
|
+
# =============================================================================
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def is_invar_project(project_root: Path) -> bool:
|
|
331
|
+
"""Check if this is the Invar project itself.
|
|
332
|
+
|
|
333
|
+
The Invar project has special handling via sync-self.
|
|
334
|
+
|
|
335
|
+
Examples:
|
|
336
|
+
>>> from pathlib import Path
|
|
337
|
+
>>> is_invar_project(Path("/some/random/project"))
|
|
338
|
+
False
|
|
339
|
+
"""
|
|
340
|
+
# Check for Invar-specific markers
|
|
341
|
+
invar_markers = [
|
|
342
|
+
project_root / "src" / "invar" / "__init__.py",
|
|
343
|
+
project_root / "runtime" / "src" / "invar_runtime" / "__init__.py",
|
|
344
|
+
]
|
|
345
|
+
return all(marker.exists() for marker in invar_markers)
|
invar/shell/templates.py
CHANGED
|
@@ -53,6 +53,7 @@ def get_template_path(name: str) -> Result[Path, str]:
|
|
|
53
53
|
return Failure(f"Failed to get template path: {e}")
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
# @shell_complexity: Template copy with path resolution
|
|
56
57
|
def copy_template(
|
|
57
58
|
template_name: str, dest: Path, dest_name: str | None = None
|
|
58
59
|
) -> Result[bool, str]:
|
|
@@ -73,6 +74,7 @@ def copy_template(
|
|
|
73
74
|
return Failure(f"Failed to copy template: {e}")
|
|
74
75
|
|
|
75
76
|
|
|
77
|
+
# @shell_complexity: Config addition with existing file detection
|
|
76
78
|
def add_config(path: Path, console) -> Result[bool, str]:
|
|
77
79
|
"""Add configuration to project. Returns Success(True) if added, Success(False) if skipped."""
|
|
78
80
|
pyproject = path / "pyproject.toml"
|
|
@@ -114,6 +116,7 @@ def create_directories(path: Path, console) -> None:
|
|
|
114
116
|
console.print("[green]Created[/green] src/shell/")
|
|
115
117
|
|
|
116
118
|
|
|
119
|
+
# @shell_complexity: Directory copy with file filtering
|
|
117
120
|
def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
|
|
118
121
|
"""Copy examples directory to .invar/examples/. Returns Success(True) if copied."""
|
|
119
122
|
import shutil
|
|
@@ -139,6 +142,51 @@ def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
|
|
|
139
142
|
return Failure(f"Failed to copy examples: {e}")
|
|
140
143
|
|
|
141
144
|
|
|
145
|
+
# @shell_complexity: Directory copy for Claude commands (DX-32)
|
|
146
|
+
def copy_commands_directory(dest: Path, console) -> Result[bool, str]:
|
|
147
|
+
"""Copy commands directory to .claude/commands/. Returns Success(True) if copied."""
|
|
148
|
+
import shutil
|
|
149
|
+
|
|
150
|
+
commands_dest = dest / ".claude" / "commands"
|
|
151
|
+
if commands_dest.exists():
|
|
152
|
+
return Success(False)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
commands_src = Path(str(resources.files("invar.templates").joinpath("commands")))
|
|
156
|
+
if not commands_src.exists():
|
|
157
|
+
return Failure("Commands template directory not found")
|
|
158
|
+
|
|
159
|
+
# Create .claude if needed
|
|
160
|
+
claude_dir = dest / ".claude"
|
|
161
|
+
if not claude_dir.exists():
|
|
162
|
+
claude_dir.mkdir()
|
|
163
|
+
|
|
164
|
+
shutil.copytree(commands_src, commands_dest)
|
|
165
|
+
console.print("[green]Created[/green] .claude/commands/ (Claude Code skills)")
|
|
166
|
+
return Success(True)
|
|
167
|
+
except OSError as e:
|
|
168
|
+
return Failure(f"Failed to copy commands: {e}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# @shell_complexity: Directory copy for Claude skills (DX-36)
|
|
172
|
+
def copy_skills_directory(dest: Path, console) -> Result[bool, str]:
|
|
173
|
+
"""Copy skills directory to .claude/skills/. Returns Success(True) if copied."""
|
|
174
|
+
import shutil
|
|
175
|
+
skills_dest = dest / ".claude" / "skills"
|
|
176
|
+
if skills_dest.exists():
|
|
177
|
+
return Success(False)
|
|
178
|
+
try:
|
|
179
|
+
skills_src = Path(str(resources.files("invar.templates").joinpath("skills")))
|
|
180
|
+
if not skills_src.exists():
|
|
181
|
+
return Failure("Skills template directory not found")
|
|
182
|
+
(dest / ".claude").mkdir(exist_ok=True)
|
|
183
|
+
shutil.copytree(skills_src, skills_dest)
|
|
184
|
+
console.print("[green]Created[/green] .claude/skills/ (workflow skills)")
|
|
185
|
+
return Success(True)
|
|
186
|
+
except OSError as e:
|
|
187
|
+
return Failure(f"Failed to copy skills: {e}")
|
|
188
|
+
|
|
189
|
+
|
|
142
190
|
# Agent configuration for multi-agent support (DX-11, DX-17)
|
|
143
191
|
AGENT_CONFIGS = {
|
|
144
192
|
"claude": {
|
|
@@ -162,6 +210,7 @@ AGENT_CONFIGS = {
|
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
|
|
213
|
+
# @shell_complexity: Agent config detection across multiple locations
|
|
165
214
|
def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
|
|
166
215
|
"""
|
|
167
216
|
Detect existing agent configuration files.
|
|
@@ -195,6 +244,7 @@ def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
|
|
|
195
244
|
return Failure(f"Failed to detect agent configs: {e}")
|
|
196
245
|
|
|
197
246
|
|
|
247
|
+
# @shell_complexity: Reference addition with existing check
|
|
198
248
|
def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
|
|
199
249
|
"""Add Invar reference to an existing agent config file."""
|
|
200
250
|
if agent not in AGENT_CONFIGS:
|
|
@@ -220,6 +270,7 @@ def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
|
|
|
220
270
|
return Failure(f"Failed to update {config['file']}: {e}")
|
|
221
271
|
|
|
222
272
|
|
|
273
|
+
# @shell_complexity: Config creation with template selection
|
|
223
274
|
def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
|
|
224
275
|
"""
|
|
225
276
|
Create agent config from template (DX-17).
|
|
@@ -248,6 +299,7 @@ def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
|
|
|
248
299
|
return Success(False)
|
|
249
300
|
|
|
250
301
|
|
|
302
|
+
# @shell_complexity: MCP server config with JSON manipulation
|
|
251
303
|
def configure_mcp_server(path: Path, console) -> Result[list[str], str]:
|
|
252
304
|
"""
|
|
253
305
|
Configure MCP server for AI agents (DX-16).
|
|
@@ -407,6 +459,7 @@ The server communicates via stdio and should be managed by your AI agent.
|
|
|
407
459
|
"""
|
|
408
460
|
|
|
409
461
|
|
|
462
|
+
# @shell_complexity: Git hooks installation with backup
|
|
410
463
|
def install_hooks(path: Path, console) -> Result[bool, str]:
|
|
411
464
|
"""Install pre-commit hooks configuration and activate them."""
|
|
412
465
|
import subprocess
|
invar/shell/testing.py
CHANGED
|
@@ -18,10 +18,12 @@ from pathlib import Path
|
|
|
18
18
|
from returns.result import Failure, Result, Success
|
|
19
19
|
from rich.console import Console
|
|
20
20
|
|
|
21
|
+
from invar.shell.prove.cache import ProveCache
|
|
22
|
+
|
|
21
23
|
# DX-12: Import from prove module
|
|
22
24
|
# DX-13: Added get_files_to_prove, run_crosshair_parallel
|
|
23
|
-
# DX-
|
|
24
|
-
from invar.shell.prove import (
|
|
25
|
+
# DX-48b: Relocated to shell/prove/
|
|
26
|
+
from invar.shell.prove.crosshair import (
|
|
25
27
|
CrossHairStatus,
|
|
26
28
|
get_files_to_prove,
|
|
27
29
|
run_crosshair_on_files,
|
|
@@ -29,7 +31,7 @@ from invar.shell.prove import (
|
|
|
29
31
|
run_hypothesis_fallback,
|
|
30
32
|
run_prove_with_fallback,
|
|
31
33
|
)
|
|
32
|
-
from invar.shell.
|
|
34
|
+
from invar.shell.subprocess_env import build_subprocess_env
|
|
33
35
|
|
|
34
36
|
console = Console()
|
|
35
37
|
|
|
@@ -40,7 +42,6 @@ __all__ = [
|
|
|
40
42
|
"ProveCache",
|
|
41
43
|
"VerificationLevel",
|
|
42
44
|
"VerificationResult",
|
|
43
|
-
"detect_verification_context",
|
|
44
45
|
"get_available_verifiers",
|
|
45
46
|
"get_files_to_prove",
|
|
46
47
|
"run_crosshair_on_files",
|
|
@@ -80,6 +81,7 @@ class VerificationResult:
|
|
|
80
81
|
errors: list[str] = field(default_factory=list)
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
# @shell_orchestration: Verifier discovery helper
|
|
83
85
|
def get_available_verifiers() -> list[str]:
|
|
84
86
|
"""
|
|
85
87
|
Detect installed verification tools.
|
|
@@ -111,23 +113,12 @@ def get_available_verifiers() -> list[str]:
|
|
|
111
113
|
return available
|
|
112
114
|
|
|
113
115
|
|
|
114
|
-
|
|
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
|
-
|
|
116
|
+
# @shell_complexity: Doctest execution with subprocess and result parsing
|
|
129
117
|
def run_doctests_on_files(
|
|
130
|
-
files: list[Path],
|
|
118
|
+
files: list[Path],
|
|
119
|
+
verbose: bool = False,
|
|
120
|
+
timeout: int = 60,
|
|
121
|
+
collect_coverage: bool = False,
|
|
131
122
|
) -> Result[dict, str]:
|
|
132
123
|
"""
|
|
133
124
|
Run doctests on a list of Python files.
|
|
@@ -135,6 +126,8 @@ def run_doctests_on_files(
|
|
|
135
126
|
Args:
|
|
136
127
|
files: List of Python file paths to test
|
|
137
128
|
verbose: Show verbose output
|
|
129
|
+
timeout: Maximum time in seconds (default: 60, from RuleConfig.timeout_doctest)
|
|
130
|
+
collect_coverage: DX-37: If True, run with coverage.py and return coverage data
|
|
138
131
|
|
|
139
132
|
Returns:
|
|
140
133
|
Success with test results or Failure with error message
|
|
@@ -143,21 +136,45 @@ def run_doctests_on_files(
|
|
|
143
136
|
return Success({"status": "skipped", "reason": "no files", "files": []})
|
|
144
137
|
|
|
145
138
|
# Filter to Python files only
|
|
146
|
-
|
|
139
|
+
# Exclude: conftest.py (pytest config), templates/examples/ (source templates, not user examples)
|
|
140
|
+
py_files = [
|
|
141
|
+
f for f in files
|
|
142
|
+
if f.suffix == ".py"
|
|
143
|
+
and f.exists()
|
|
144
|
+
and f.name != "conftest.py"
|
|
145
|
+
and "templates/examples" not in str(f)
|
|
146
|
+
]
|
|
147
147
|
if not py_files:
|
|
148
148
|
return Success({"status": "skipped", "reason": "no Python files", "files": []})
|
|
149
149
|
|
|
150
|
-
# Build
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
# DX-37: Build command with optional coverage
|
|
151
|
+
if collect_coverage:
|
|
152
|
+
# Use coverage run to wrap pytest
|
|
153
|
+
cmd = [
|
|
154
|
+
sys.executable, "-m", "coverage", "run",
|
|
155
|
+
"--branch", # Enable branch coverage
|
|
156
|
+
"--parallel-mode", # For merging with hypothesis later
|
|
157
|
+
"-m", "pytest",
|
|
158
|
+
"--doctest-modules", "-x", "--tb=short",
|
|
159
|
+
]
|
|
160
|
+
else:
|
|
161
|
+
cmd = [
|
|
162
|
+
sys.executable, "-m", "pytest",
|
|
163
|
+
"--doctest-modules", "-x", "--tb=short",
|
|
164
|
+
]
|
|
155
165
|
cmd.extend(str(f) for f in py_files)
|
|
156
166
|
if verbose:
|
|
157
167
|
cmd.append("-v")
|
|
158
168
|
|
|
159
169
|
try:
|
|
160
|
-
|
|
170
|
+
# DX-52: Inject project venv site-packages for uvx compatibility
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
cmd,
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
timeout=timeout,
|
|
176
|
+
env=build_subprocess_env(),
|
|
177
|
+
)
|
|
161
178
|
# Pytest exit codes: 0=passed, 5=no tests collected (also OK)
|
|
162
179
|
is_passed = result.returncode in (0, 5)
|
|
163
180
|
return Success({
|
|
@@ -166,15 +183,17 @@ def run_doctests_on_files(
|
|
|
166
183
|
"exit_code": result.returncode,
|
|
167
184
|
"stdout": result.stdout,
|
|
168
185
|
"stderr": result.stderr,
|
|
186
|
+
"coverage_collected": collect_coverage, # DX-37: Flag for caller
|
|
169
187
|
})
|
|
170
188
|
except subprocess.TimeoutExpired:
|
|
171
|
-
return Failure("Doctest timeout (
|
|
189
|
+
return Failure(f"Doctest timeout ({timeout}s)")
|
|
172
190
|
except Exception as e:
|
|
173
191
|
return Failure(f"Doctest error: {e}")
|
|
174
192
|
|
|
175
193
|
|
|
194
|
+
# @shell_complexity: Property test orchestration with subprocess
|
|
176
195
|
def run_test(
|
|
177
|
-
target: str, json_output: bool = False, verbose: bool = False
|
|
196
|
+
target: str, json_output: bool = False, verbose: bool = False, timeout: int = 300
|
|
178
197
|
) -> Result[dict, str]:
|
|
179
198
|
"""
|
|
180
199
|
Run property-based tests using Hypothesis via deal.cases.
|
|
@@ -183,6 +202,7 @@ def run_test(
|
|
|
183
202
|
target: File path or module to test
|
|
184
203
|
json_output: Output as JSON
|
|
185
204
|
verbose: Show verbose output
|
|
205
|
+
timeout: Maximum time in seconds (default: 300, from RuleConfig.timeout_hypothesis)
|
|
186
206
|
|
|
187
207
|
Returns:
|
|
188
208
|
Success with test results or Failure with error message
|
|
@@ -201,7 +221,14 @@ def run_test(
|
|
|
201
221
|
cmd.append("-v")
|
|
202
222
|
|
|
203
223
|
try:
|
|
204
|
-
|
|
224
|
+
# DX-52: Inject project venv site-packages for uvx compatibility
|
|
225
|
+
result = subprocess.run(
|
|
226
|
+
cmd,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
text=True,
|
|
229
|
+
timeout=timeout,
|
|
230
|
+
env=build_subprocess_env(),
|
|
231
|
+
)
|
|
205
232
|
test_result = {
|
|
206
233
|
"status": "passed" if result.returncode == 0 else "failed",
|
|
207
234
|
"target": str(target_path),
|
|
@@ -225,13 +252,17 @@ def run_test(
|
|
|
225
252
|
|
|
226
253
|
return Success(test_result)
|
|
227
254
|
except subprocess.TimeoutExpired:
|
|
228
|
-
return Failure(f"Test timeout (
|
|
255
|
+
return Failure(f"Test timeout ({timeout}s): {target}")
|
|
229
256
|
except Exception as e:
|
|
230
257
|
return Failure(f"Test error: {e}")
|
|
231
258
|
|
|
232
259
|
|
|
260
|
+
# @shell_complexity: CrossHair verification with subprocess
|
|
233
261
|
def run_verify(
|
|
234
|
-
target: str,
|
|
262
|
+
target: str,
|
|
263
|
+
json_output: bool = False,
|
|
264
|
+
total_timeout: int = 300,
|
|
265
|
+
per_condition_timeout: int = 30,
|
|
235
266
|
) -> Result[dict, str]:
|
|
236
267
|
"""
|
|
237
268
|
Run symbolic verification using CrossHair.
|
|
@@ -239,7 +270,8 @@ def run_verify(
|
|
|
239
270
|
Args:
|
|
240
271
|
target: File path or module to verify
|
|
241
272
|
json_output: Output as JSON
|
|
242
|
-
|
|
273
|
+
total_timeout: Total timeout in seconds (default: 300, from RuleConfig.timeout_crosshair)
|
|
274
|
+
per_condition_timeout: Per-contract timeout (default: 30, from RuleConfig.timeout_crosshair_per_condition)
|
|
243
275
|
|
|
244
276
|
Returns:
|
|
245
277
|
Success with verification results or Failure with error message
|
|
@@ -260,15 +292,23 @@ def run_verify(
|
|
|
260
292
|
|
|
261
293
|
cmd = [
|
|
262
294
|
sys.executable, "-m", "crosshair", "check",
|
|
263
|
-
str(target_path), f"--per_condition_timeout={
|
|
295
|
+
str(target_path), f"--per_condition_timeout={per_condition_timeout}",
|
|
264
296
|
]
|
|
265
297
|
|
|
266
298
|
try:
|
|
267
|
-
|
|
299
|
+
# DX-52: Inject project venv site-packages for uvx compatibility
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
cmd,
|
|
302
|
+
capture_output=True,
|
|
303
|
+
text=True,
|
|
304
|
+
timeout=total_timeout,
|
|
305
|
+
env=build_subprocess_env(),
|
|
306
|
+
)
|
|
268
307
|
|
|
308
|
+
# CrossHair format: "file:line: error: Err when calling func(...)"
|
|
269
309
|
counterexamples = [
|
|
270
310
|
line.strip() for line in result.stdout.split("\n")
|
|
271
|
-
if "error" in line.lower() or "counterexample" in line.lower()
|
|
311
|
+
if ": error:" in line.lower() or "counterexample" in line.lower()
|
|
272
312
|
]
|
|
273
313
|
|
|
274
314
|
verify_result = {
|
|
@@ -292,6 +332,6 @@ def run_verify(
|
|
|
292
332
|
|
|
293
333
|
return Success(verify_result)
|
|
294
334
|
except subprocess.TimeoutExpired:
|
|
295
|
-
return Failure(f"Verification timeout ({
|
|
335
|
+
return Failure(f"Verification timeout ({total_timeout}s): {target}")
|
|
296
336
|
except Exception as e:
|
|
297
337
|
return Failure(f"Verification error: {e}")
|