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.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,155 @@
1
+ """
2
+ Verification routing logic for smart tool selection.
3
+
4
+ DX-22: Automatically routes code to CrossHair or Hypothesis based on imports.
5
+ Core module: Pure logic, no I/O.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from enum import Enum
12
+
13
+ from deal import post
14
+
15
+
16
+ class VerificationTool(Enum):
17
+ """Verification tool selection."""
18
+
19
+ CROSSHAIR = "crosshair"
20
+ HYPOTHESIS = "hypothesis"
21
+ SKIP = "skip"
22
+
23
+
24
+ # C extensions that CrossHair cannot symbolically execute
25
+ # These libraries use native code that breaks symbolic execution
26
+ CROSSHAIR_INCOMPATIBLE_LIBS = frozenset(
27
+ [
28
+ # Scientific computing (C/Fortran extensions)
29
+ "numpy",
30
+ "pandas",
31
+ "scipy",
32
+ "sklearn",
33
+ "scikit-learn",
34
+ # Deep learning (CUDA/C++ backends)
35
+ "torch",
36
+ "tensorflow",
37
+ "keras",
38
+ "jax",
39
+ # Image processing (C extensions)
40
+ "cv2",
41
+ "PIL",
42
+ "pillow",
43
+ "skimage",
44
+ # Network I/O (non-deterministic)
45
+ "requests",
46
+ "aiohttp",
47
+ "httpx",
48
+ "urllib3",
49
+ # System calls (side effects)
50
+ "subprocess",
51
+ "multiprocessing",
52
+ # Database I/O
53
+ "sqlalchemy",
54
+ "psycopg2",
55
+ "pymongo",
56
+ ]
57
+ )
58
+
59
+ # Regex pattern to detect imports
60
+ # Matches: import numpy, from numpy import, import numpy as np
61
+ _IMPORT_PATTERN = re.compile(
62
+ r"^\s*(?:import\s+(\w+)|from\s+(\w+)(?:\.\w+)*\s+import)",
63
+ re.MULTILINE,
64
+ )
65
+
66
+
67
+ # @invar:allow missing_contract: Boolean predicate, empty string returns False
68
+ def has_incompatible_imports(source: str) -> bool:
69
+ """
70
+ Check if source contains imports incompatible with CrossHair.
71
+
72
+ DX-22: Detects C extension libraries that cannot be symbolically executed.
73
+ Used to route code directly to Hypothesis instead of wasting time on CrossHair.
74
+
75
+ Examples:
76
+ >>> has_incompatible_imports("import numpy as np")
77
+ True
78
+ >>> has_incompatible_imports("from pandas import DataFrame")
79
+ True
80
+ >>> has_incompatible_imports("from pathlib import Path")
81
+ False
82
+ >>> has_incompatible_imports("import json")
83
+ False
84
+ >>> has_incompatible_imports("from sklearn.model_selection import train_test_split")
85
+ True
86
+ >>> has_incompatible_imports("import torch.nn as nn")
87
+ True
88
+ >>> has_incompatible_imports("")
89
+ False
90
+ """
91
+ # Early return for empty/whitespace-only strings (avoids regex edge cases)
92
+ if not source or not source.strip():
93
+ return False
94
+ for match in _IMPORT_PATTERN.finditer(source):
95
+ lib = match.group(1) or match.group(2)
96
+ if lib and lib.lower() in CROSSHAIR_INCOMPATIBLE_LIBS:
97
+ return True
98
+ return False
99
+
100
+
101
+ @post(lambda result: all(lib in CROSSHAIR_INCOMPATIBLE_LIBS for lib in result))
102
+ def get_incompatible_imports(source: str) -> set[str]:
103
+ """
104
+ Get the set of incompatible libraries imported in source.
105
+
106
+ Examples:
107
+ >>> sorted(get_incompatible_imports("import numpy\\nfrom pandas import DataFrame"))
108
+ ['numpy', 'pandas']
109
+ >>> get_incompatible_imports("import json")
110
+ set()
111
+ >>> sorted(get_incompatible_imports("import torch\\nimport tensorflow"))
112
+ ['tensorflow', 'torch']
113
+ >>> get_incompatible_imports("")
114
+ set()
115
+ """
116
+ # Early return for empty/whitespace-only strings (avoids regex edge cases)
117
+ if not source or not source.strip():
118
+ return set()
119
+ incompatible: set[str] = set()
120
+ for match in _IMPORT_PATTERN.finditer(source):
121
+ lib = match.group(1) or match.group(2)
122
+ if lib and lib.lower() in CROSSHAIR_INCOMPATIBLE_LIBS:
123
+ incompatible.add(lib.lower())
124
+ return incompatible
125
+
126
+
127
+ @post(lambda result: result in VerificationTool) # Returns valid enum member
128
+ def select_verification_tool(source: str, has_contracts: bool) -> VerificationTool:
129
+ """
130
+ Select the appropriate verification tool for a source file.
131
+
132
+ DX-22 Smart Routing:
133
+ - No contracts -> SKIP (nothing to verify)
134
+ - Has C extensions -> HYPOTHESIS (CrossHair will fail)
135
+ - Pure Python with contracts -> CROSSHAIR (can prove correctness)
136
+
137
+ Examples:
138
+ >>> select_verification_tool("def foo(): pass", has_contracts=False)
139
+ <VerificationTool.SKIP: 'skip'>
140
+ >>> select_verification_tool("import numpy\\n@pre(lambda x: x > 0)\\ndef foo(x): pass", has_contracts=True)
141
+ <VerificationTool.HYPOTHESIS: 'hypothesis'>
142
+ >>> select_verification_tool("@pre(lambda x: x > 0)\\ndef foo(x): pass", has_contracts=True)
143
+ <VerificationTool.CROSSHAIR: 'crosshair'>
144
+ >>> select_verification_tool("", has_contracts=False)
145
+ <VerificationTool.SKIP: 'skip'>
146
+ >>> select_verification_tool("", has_contracts=True)
147
+ <VerificationTool.CROSSHAIR: 'crosshair'>
148
+ """
149
+ if not has_contracts:
150
+ return VerificationTool.SKIP
151
+
152
+ if has_incompatible_imports(source):
153
+ return VerificationTool.HYPOTHESIS
154
+
155
+ return VerificationTool.CROSSHAIR
invar/mcp/server.py CHANGED
@@ -3,19 +3,55 @@ Invar MCP Server implementation.
3
3
 
4
4
  Exposes invar guard, sig, and map as first-class MCP tools.
5
5
  Part of DX-16: Agent Tool Enforcement.
6
+ DX-52: Added Phase 2 smart re-spawn for project Python compatibility.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
11
  import json
12
+ import os
11
13
  import subprocess
12
14
  import sys
15
+ from pathlib import Path
13
16
  from typing import Any
14
17
 
15
18
  from mcp.server import Server
16
19
  from mcp.types import TextContent, Tool
17
20
 
18
- # Strong instructions for agent behavior (DX-16 + DX-17)
21
+ from invar.shell.subprocess_env import should_respawn
22
+
23
+
24
+ # @invar:allow shell_result: Pure validation helper, no I/O, returns tuple not Result
25
+ # @shell_complexity: Security validation requires multiple checks
26
+ def _validate_path(path: str) -> tuple[bool, str]:
27
+ """Validate path argument for safety.
28
+
29
+ Returns (is_valid, error_message).
30
+ Rejects paths that could be interpreted as shell commands or flags.
31
+ """
32
+ if not path:
33
+ return True, "" # Empty path defaults to "." in handlers
34
+
35
+ # Reject if looks like a flag (starts with -)
36
+ if path.startswith("-"):
37
+ return False, f"Invalid path: cannot start with '-': {path}"
38
+
39
+ # Reject shell metacharacters that could cause issues
40
+ dangerous_chars = [";", "&", "|", "$", "`", "\n", "\r"]
41
+ for char in dangerous_chars:
42
+ if char in path:
43
+ return False, f"Invalid path: contains forbidden character: {char!r}"
44
+
45
+ # Try to resolve path - this catches malformed paths
46
+ try:
47
+ Path(path).resolve()
48
+ except (OSError, ValueError) as e:
49
+ return False, f"Invalid path: {e}"
50
+
51
+ return True, ""
52
+
53
+
54
+ # Strong instructions for agent behavior (DX-16 + DX-17 + DX-26)
19
55
  INVAR_INSTRUCTIONS = """
20
56
  ## Invar Tool Usage (MANDATORY)
21
57
 
@@ -83,6 +119,8 @@ the MCP tools and may not follow the correct workflow.
83
119
  """
84
120
 
85
121
 
122
+ # @shell_orchestration: MCP tool factory - creates Tool objects
123
+ # @invar:allow shell_result: MCP tool factory for guard command
86
124
  def _get_guard_tool() -> Tool:
87
125
  """Define the invar_guard tool."""
88
126
  return Tool(
@@ -98,11 +136,14 @@ def _get_guard_tool() -> Tool:
98
136
  "path": {"type": "string", "description": "Project path (default: .)", "default": "."},
99
137
  "changed": {"type": "boolean", "description": "Only verify git-changed files", "default": True},
100
138
  "strict": {"type": "boolean", "description": "Treat warnings as errors", "default": False},
139
+ "coverage": {"type": "boolean", "description": "DX-37: Collect branch coverage from doctest + hypothesis", "default": False},
101
140
  },
102
141
  },
103
142
  )
104
143
 
105
144
 
145
+ # @shell_orchestration: MCP tool factory - creates Tool objects
146
+ # @invar:allow shell_result: MCP tool factory for sig command
106
147
  def _get_sig_tool() -> Tool:
107
148
  """Define the invar_sig tool."""
108
149
  return Tool(
@@ -121,6 +162,8 @@ def _get_sig_tool() -> Tool:
121
162
  )
122
163
 
123
164
 
165
+ # @shell_orchestration: MCP tool factory - creates Tool objects
166
+ # @invar:allow shell_result: MCP tool factory for map command
124
167
  def _get_map_tool() -> Tool:
125
168
  """Define the invar_map tool."""
126
169
  return Tool(
@@ -139,6 +182,8 @@ def _get_map_tool() -> Tool:
139
182
  )
140
183
 
141
184
 
185
+ # @shell_orchestration: MCP server setup - registers handlers with framework
186
+ # @invar:allow shell_result: MCP framework API returns Server
142
187
  def create_server() -> Server:
143
188
  """Create and configure the Invar MCP server."""
144
189
  server = Server(name="invar", version="0.1.0", instructions=INVAR_INSTRUCTIONS)
@@ -158,39 +203,61 @@ def create_server() -> Server:
158
203
  return server
159
204
 
160
205
 
206
+ # @shell_orchestration: MCP handler - subprocess is called inside
207
+ # @shell_complexity: Guard command with multiple optional flags
208
+ # @invar:allow shell_result: MCP handler for guard tool
161
209
  async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
162
210
  """Run invar guard command."""
163
- cmd = [sys.executable, "-m", "invar.shell.cli", "guard"]
164
-
165
211
  path = args.get("path", ".")
212
+ is_valid, error = _validate_path(path)
213
+ if not is_valid:
214
+ return [TextContent(type="text", text=f"Error: {error}")]
215
+
216
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "guard"]
166
217
  cmd.append(path)
167
218
 
168
219
  if args.get("changed", True):
169
220
  cmd.append("--changed")
170
221
  if args.get("strict", False):
171
222
  cmd.append("--strict")
223
+ # DX-37: Optional coverage collection
224
+ if args.get("coverage", False):
225
+ cmd.append("--coverage")
172
226
 
173
- # Always use JSON output for agent consumption
174
- cmd.append("--json")
227
+ # DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
228
+ # No explicit flag needed
175
229
 
176
230
  return await _execute_command(cmd)
177
231
 
178
232
 
233
+ # @shell_orchestration: MCP handler - subprocess is called inside
234
+ # @invar:allow shell_result: MCP handler for sig tool
179
235
  async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
180
236
  """Run invar sig command."""
181
237
  target = args.get("target", "")
182
238
  if not target:
183
239
  return [TextContent(type="text", text="Error: target is required")]
184
240
 
185
- cmd = [sys.executable, "-m", "invar.shell.cli", "sig", target, "--json"]
241
+ # Validate target (can be file path or file::symbol)
242
+ target_path = target.split("::")[0] if "::" in target else target
243
+ is_valid, error = _validate_path(target_path)
244
+ if not is_valid:
245
+ return [TextContent(type="text", text=f"Error: {error}")]
246
+
247
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "sig", target, "--json"]
186
248
  return await _execute_command(cmd)
187
249
 
188
250
 
251
+ # @shell_orchestration: MCP handler - subprocess is called inside
252
+ # @invar:allow shell_result: MCP handler for map tool
189
253
  async def _run_map(args: dict[str, Any]) -> list[TextContent]:
190
254
  """Run invar map command."""
191
- cmd = [sys.executable, "-m", "invar.shell.cli", "map"]
192
-
193
255
  path = args.get("path", ".")
256
+ is_valid, error = _validate_path(path)
257
+ if not is_valid:
258
+ return [TextContent(type="text", text=f"Error: {error}")]
259
+
260
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "map"]
194
261
  cmd.append(path)
195
262
 
196
263
  top = args.get("top", 10)
@@ -200,14 +267,21 @@ async def _run_map(args: dict[str, Any]) -> list[TextContent]:
200
267
  return await _execute_command(cmd)
201
268
 
202
269
 
203
- async def _execute_command(cmd: list[str]) -> list[TextContent]:
204
- """Execute a command and return the result."""
270
+ # @shell_complexity: Command execution with error handling branches
271
+ # @invar:allow shell_result: MCP subprocess wrapper utility
272
+ async def _execute_command(cmd: list[str], timeout: int = 600) -> list[TextContent]:
273
+ """Execute a command and return the result.
274
+
275
+ Args:
276
+ cmd: Command to execute
277
+ timeout: Maximum time in seconds (default: 600, accommodates full Guard cycle)
278
+ """
205
279
  try:
206
280
  result = subprocess.run(
207
281
  cmd,
208
282
  capture_output=True,
209
283
  text=True,
210
- timeout=120,
284
+ timeout=timeout,
211
285
  )
212
286
 
213
287
  output = result.stdout
@@ -224,17 +298,43 @@ async def _execute_command(cmd: list[str]) -> list[TextContent]:
224
298
  return [TextContent(type="text", text=output)]
225
299
 
226
300
  except subprocess.TimeoutExpired:
227
- return [TextContent(type="text", text="Error: Command timed out (120s)")]
301
+ return [TextContent(type="text", text=f"Error: Command timed out ({timeout}s)")]
228
302
  except Exception as e:
229
303
  return [TextContent(type="text", text=f"Error: {e}")]
230
304
 
231
305
 
306
+ # @shell_orchestration: MCP server entry point - runs async server
232
307
  def run_server() -> None:
233
- """Run the Invar MCP server."""
308
+ """Run the Invar MCP server.
309
+
310
+ DX-52 Phase 2: If project has invar installed, re-spawn with project Python
311
+ to ensure C extensions are compatible with project's Python version.
312
+ """
234
313
  import asyncio
235
314
 
236
315
  from mcp.server.stdio import stdio_server
237
316
 
317
+ # DX-52 Phase 2: Smart re-spawn with project Python
318
+ cwd = Path.cwd()
319
+ do_respawn, project_python = should_respawn(cwd)
320
+
321
+ if do_respawn and project_python is not None:
322
+ # Re-spawn with project Python (has both invar AND project deps)
323
+ import subprocess
324
+ import sys
325
+
326
+ if os.name == "nt":
327
+ # Windows: execv doesn't replace process, use subprocess + exit
328
+ result = subprocess.call([str(project_python), "-m", "invar.mcp"])
329
+ sys.exit(result)
330
+ else:
331
+ # Unix: execv replaces current process, does not return
332
+ os.execv(
333
+ str(project_python),
334
+ [str(project_python), "-m", "invar.mcp"],
335
+ )
336
+
337
+ # Phase 1 fallback: Continue with uvx + PYTHONPATH injection
238
338
  async def main():
239
339
  server = create_server()
240
340
  async with stdio_server() as (read_stream, write_stream):
@@ -0,0 +1,11 @@
1
+ """
2
+ CLI commands for Invar.
3
+
4
+ This module contains Typer command implementations:
5
+ - guard: Main verification command
6
+ - init: Project initialization
7
+ - update: Update INVAR.md from template
8
+ - test: Run property tests
9
+ - mutate: Run mutation testing
10
+ - perception: Map and sig commands
11
+ """