invar-tools 1.2.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 +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- 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 +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- 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.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +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
|
@@ -168,6 +168,25 @@ def copy_commands_directory(dest: Path, console) -> Result[bool, str]:
|
|
|
168
168
|
return Failure(f"Failed to copy commands: {e}")
|
|
169
169
|
|
|
170
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
|
+
|
|
171
190
|
# Agent configuration for multi-agent support (DX-11, DX-17)
|
|
172
191
|
AGENT_CONFIGS = {
|
|
173
192
|
"claude": {
|
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
|
|
|
@@ -113,7 +115,10 @@ def get_available_verifiers() -> list[str]:
|
|
|
113
115
|
|
|
114
116
|
# @shell_complexity: Doctest execution with subprocess and result parsing
|
|
115
117
|
def run_doctests_on_files(
|
|
116
|
-
files: list[Path],
|
|
118
|
+
files: list[Path],
|
|
119
|
+
verbose: bool = False,
|
|
120
|
+
timeout: int = 60,
|
|
121
|
+
collect_coverage: bool = False,
|
|
117
122
|
) -> Result[dict, str]:
|
|
118
123
|
"""
|
|
119
124
|
Run doctests on a list of Python files.
|
|
@@ -121,6 +126,8 @@ def run_doctests_on_files(
|
|
|
121
126
|
Args:
|
|
122
127
|
files: List of Python file paths to test
|
|
123
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
|
|
124
131
|
|
|
125
132
|
Returns:
|
|
126
133
|
Success with test results or Failure with error message
|
|
@@ -129,21 +136,45 @@ def run_doctests_on_files(
|
|
|
129
136
|
return Success({"status": "skipped", "reason": "no files", "files": []})
|
|
130
137
|
|
|
131
138
|
# Filter to Python files only
|
|
132
|
-
|
|
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
|
+
]
|
|
133
147
|
if not py_files:
|
|
134
148
|
return Success({"status": "skipped", "reason": "no Python files", "files": []})
|
|
135
149
|
|
|
136
|
-
# Build
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
]
|
|
141
165
|
cmd.extend(str(f) for f in py_files)
|
|
142
166
|
if verbose:
|
|
143
167
|
cmd.append("-v")
|
|
144
168
|
|
|
145
169
|
try:
|
|
146
|
-
|
|
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
|
+
)
|
|
147
178
|
# Pytest exit codes: 0=passed, 5=no tests collected (also OK)
|
|
148
179
|
is_passed = result.returncode in (0, 5)
|
|
149
180
|
return Success({
|
|
@@ -152,16 +183,17 @@ def run_doctests_on_files(
|
|
|
152
183
|
"exit_code": result.returncode,
|
|
153
184
|
"stdout": result.stdout,
|
|
154
185
|
"stderr": result.stderr,
|
|
186
|
+
"coverage_collected": collect_coverage, # DX-37: Flag for caller
|
|
155
187
|
})
|
|
156
188
|
except subprocess.TimeoutExpired:
|
|
157
|
-
return Failure("Doctest timeout (
|
|
189
|
+
return Failure(f"Doctest timeout ({timeout}s)")
|
|
158
190
|
except Exception as e:
|
|
159
191
|
return Failure(f"Doctest error: {e}")
|
|
160
192
|
|
|
161
193
|
|
|
162
194
|
# @shell_complexity: Property test orchestration with subprocess
|
|
163
195
|
def run_test(
|
|
164
|
-
target: str, json_output: bool = False, verbose: bool = False
|
|
196
|
+
target: str, json_output: bool = False, verbose: bool = False, timeout: int = 300
|
|
165
197
|
) -> Result[dict, str]:
|
|
166
198
|
"""
|
|
167
199
|
Run property-based tests using Hypothesis via deal.cases.
|
|
@@ -170,6 +202,7 @@ def run_test(
|
|
|
170
202
|
target: File path or module to test
|
|
171
203
|
json_output: Output as JSON
|
|
172
204
|
verbose: Show verbose output
|
|
205
|
+
timeout: Maximum time in seconds (default: 300, from RuleConfig.timeout_hypothesis)
|
|
173
206
|
|
|
174
207
|
Returns:
|
|
175
208
|
Success with test results or Failure with error message
|
|
@@ -188,7 +221,14 @@ def run_test(
|
|
|
188
221
|
cmd.append("-v")
|
|
189
222
|
|
|
190
223
|
try:
|
|
191
|
-
|
|
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
|
+
)
|
|
192
232
|
test_result = {
|
|
193
233
|
"status": "passed" if result.returncode == 0 else "failed",
|
|
194
234
|
"target": str(target_path),
|
|
@@ -212,14 +252,17 @@ def run_test(
|
|
|
212
252
|
|
|
213
253
|
return Success(test_result)
|
|
214
254
|
except subprocess.TimeoutExpired:
|
|
215
|
-
return Failure(f"Test timeout (
|
|
255
|
+
return Failure(f"Test timeout ({timeout}s): {target}")
|
|
216
256
|
except Exception as e:
|
|
217
257
|
return Failure(f"Test error: {e}")
|
|
218
258
|
|
|
219
259
|
|
|
220
260
|
# @shell_complexity: CrossHair verification with subprocess
|
|
221
261
|
def run_verify(
|
|
222
|
-
target: str,
|
|
262
|
+
target: str,
|
|
263
|
+
json_output: bool = False,
|
|
264
|
+
total_timeout: int = 300,
|
|
265
|
+
per_condition_timeout: int = 30,
|
|
223
266
|
) -> Result[dict, str]:
|
|
224
267
|
"""
|
|
225
268
|
Run symbolic verification using CrossHair.
|
|
@@ -227,7 +270,8 @@ def run_verify(
|
|
|
227
270
|
Args:
|
|
228
271
|
target: File path or module to verify
|
|
229
272
|
json_output: Output as JSON
|
|
230
|
-
|
|
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)
|
|
231
275
|
|
|
232
276
|
Returns:
|
|
233
277
|
Success with verification results or Failure with error message
|
|
@@ -248,11 +292,18 @@ def run_verify(
|
|
|
248
292
|
|
|
249
293
|
cmd = [
|
|
250
294
|
sys.executable, "-m", "crosshair", "check",
|
|
251
|
-
str(target_path), f"--per_condition_timeout={
|
|
295
|
+
str(target_path), f"--per_condition_timeout={per_condition_timeout}",
|
|
252
296
|
]
|
|
253
297
|
|
|
254
298
|
try:
|
|
255
|
-
|
|
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
|
+
)
|
|
256
307
|
|
|
257
308
|
# CrossHair format: "file:line: error: Err when calling func(...)"
|
|
258
309
|
counterexamples = [
|
|
@@ -281,6 +332,6 @@ def run_verify(
|
|
|
281
332
|
|
|
282
333
|
return Success(verify_result)
|
|
283
334
|
except subprocess.TimeoutExpired:
|
|
284
|
-
return Failure(f"Verification timeout ({
|
|
335
|
+
return Failure(f"Verification timeout ({total_timeout}s): {target}")
|
|
285
336
|
except Exception as e:
|
|
286
337
|
return Failure(f"Verification error: {e}")
|
|
@@ -7,21 +7,24 @@
|
|
|
7
7
|
Your first message MUST display:
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
✓ Check-In:
|
|
10
|
+
✓ Check-In: [project] | [branch] | [clean/dirty]
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Actions:
|
|
14
|
+
1. Read `.invar/context.md` (Key Rules + Current State + Lessons Learned)
|
|
15
|
+
2. Show one-line status
|
|
14
16
|
|
|
15
17
|
Example:
|
|
16
18
|
```
|
|
17
|
-
✓ Check-In:
|
|
19
|
+
✓ Check-In: MyProject | main | clean
|
|
18
20
|
```
|
|
19
21
|
|
|
22
|
+
**Do NOT execute guard or map at Check-In.**
|
|
23
|
+
Guard is for VALIDATE phase and Final only.
|
|
24
|
+
|
|
20
25
|
This is your sign-in. The user sees it immediately.
|
|
21
26
|
No visible check-in = Session not started.
|
|
22
27
|
|
|
23
|
-
Then read `.invar/context.md` for project state and lessons learned.
|
|
24
|
-
|
|
25
28
|
---
|
|
26
29
|
|
|
27
30
|
## Final
|
|
@@ -80,27 +83,45 @@ For complex tasks (3+ functions), show 3 checkpoints in TodoList:
|
|
|
80
83
|
|
|
81
84
|
---
|
|
82
85
|
|
|
83
|
-
##
|
|
86
|
+
## Commands (User-Invokable)
|
|
84
87
|
|
|
85
|
-
| Command |
|
|
86
|
-
|
|
87
|
-
| `/
|
|
88
|
+
| Command | Purpose |
|
|
89
|
+
|---------|---------|
|
|
90
|
+
| `/audit` | Read-only code review (reports issues, no fixes) |
|
|
91
|
+
| `/guard` | Run Invar verification (reports results) |
|
|
88
92
|
|
|
89
|
-
|
|
93
|
+
## Skills (Agent-Invoked)
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
| Skill | Triggers | Purpose |
|
|
96
|
+
|-------|----------|---------|
|
|
97
|
+
| `/investigate` | "why", "explain", vague tasks | Research mode, no code changes |
|
|
98
|
+
| `/propose` | "should we", "compare" | Decision facilitation |
|
|
99
|
+
| `/develop` | "add", "fix", "implement" | USBV implementation workflow |
|
|
100
|
+
| `/review` | After /develop, `review_suggested` | Adversarial review with fix loop |
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|-----------|------|----------|
|
|
95
|
-
| `review_suggested` triggered | **Isolated** | Task tool sub-agent (fresh context) |
|
|
96
|
-
| No trigger | **Quick** | Same-context adversarial review |
|
|
97
|
-
| `--isolated` flag | **Isolated** | Force isolation |
|
|
98
|
-
| `--quick` flag | **Quick** | Force same-context |
|
|
102
|
+
**Note:** Skills are invoked by agent based on context. Use `/audit` for user-initiated review.
|
|
99
103
|
|
|
100
104
|
Guard triggers `review_suggested` for: security-sensitive files, escape hatches >= 3, contract coverage < 50%.
|
|
101
105
|
|
|
102
106
|
---
|
|
103
107
|
|
|
108
|
+
## Workflow Routing (MANDATORY)
|
|
109
|
+
|
|
110
|
+
When user message contains these triggers, you MUST invoke the corresponding skill:
|
|
111
|
+
|
|
112
|
+
| Trigger Words | Skill | Notes |
|
|
113
|
+
|---------------|-------|-------|
|
|
114
|
+
| "review", "review and fix" | `/review` | Adversarial review with fix loop |
|
|
115
|
+
| "implement", "add", "fix", "update" | `/develop` | Unless in review context |
|
|
116
|
+
| "why", "explain", "investigate" | `/investigate` | Research mode, no code changes |
|
|
117
|
+
| "compare", "should we", "design" | `/propose` | Decision facilitation |
|
|
118
|
+
|
|
119
|
+
**Violation check (before writing ANY code):**
|
|
120
|
+
- "Am I in a workflow?"
|
|
121
|
+
- "Did I invoke the correct skill?"
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
104
125
|
## Project-Specific Rules
|
|
105
126
|
|
|
106
127
|
<!-- Add your team conventions below -->
|
|
@@ -14,9 +14,9 @@ system-prompt: |
|
|
|
14
14
|
|
|
15
15
|
## Check-In
|
|
16
16
|
Your first message MUST display:
|
|
17
|
-
✓ Check-In:
|
|
17
|
+
✓ Check-In: [project] | [branch] | [clean/dirty]
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Read .invar/context.md first. Do NOT run guard/map at Check-In.
|
|
20
20
|
This is your sign-in. No visible check-in = Session not started.
|
|
21
21
|
|
|
22
22
|
## Final
|