invar-tools 1.0.0__py3-none-any.whl → 1.2.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 (57) hide show
  1. invar/core/contracts.py +75 -5
  2. invar/core/entry_points.py +294 -0
  3. invar/core/format_specs.py +196 -0
  4. invar/core/format_strategies.py +197 -0
  5. invar/core/formatter.py +27 -4
  6. invar/core/hypothesis_strategies.py +47 -5
  7. invar/core/lambda_helpers.py +1 -0
  8. invar/core/models.py +23 -17
  9. invar/core/parser.py +6 -2
  10. invar/core/property_gen.py +81 -40
  11. invar/core/purity.py +10 -4
  12. invar/core/review_trigger.py +298 -0
  13. invar/core/rule_meta.py +61 -2
  14. invar/core/rules.py +83 -19
  15. invar/core/shell_analysis.py +252 -0
  16. invar/core/shell_architecture.py +171 -0
  17. invar/core/suggestions.py +6 -0
  18. invar/core/tautology.py +1 -0
  19. invar/core/utils.py +51 -4
  20. invar/core/verification_routing.py +158 -0
  21. invar/invariant.py +1 -0
  22. invar/mcp/server.py +20 -3
  23. invar/shell/cli.py +59 -31
  24. invar/shell/config.py +259 -10
  25. invar/shell/fs.py +5 -2
  26. invar/shell/git.py +2 -0
  27. invar/shell/guard_helpers.py +78 -3
  28. invar/shell/guard_output.py +100 -24
  29. invar/shell/init_cmd.py +27 -7
  30. invar/shell/mcp_config.py +3 -0
  31. invar/shell/mutate_cmd.py +184 -0
  32. invar/shell/mutation.py +314 -0
  33. invar/shell/perception.py +2 -0
  34. invar/shell/property_tests.py +17 -2
  35. invar/shell/prove.py +35 -3
  36. invar/shell/prove_accept.py +113 -0
  37. invar/shell/prove_fallback.py +148 -46
  38. invar/shell/templates.py +34 -0
  39. invar/shell/test_cmd.py +3 -1
  40. invar/shell/testing.py +6 -17
  41. invar/shell/update_cmd.py +2 -0
  42. invar/templates/CLAUDE.md.template +65 -9
  43. invar/templates/INVAR.md +96 -23
  44. invar/templates/aider.conf.yml.template +16 -14
  45. invar/templates/commands/review.md +200 -0
  46. invar/templates/cursorrules.template +22 -13
  47. invar/templates/examples/contracts.py +3 -1
  48. invar/templates/examples/core_shell.py +3 -1
  49. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
  50. invar_tools-1.2.0.dist-info/RECORD +77 -0
  51. invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
  52. invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
  53. invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
  54. invar_tools-1.0.0.dist-info/RECORD +0 -64
  55. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  56. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
  57. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,252 @@
1
+ """
2
+ Shell source analysis helpers for DX-22.
3
+
4
+ Helper functions for analyzing Shell layer code:
5
+ - I/O operation detection
6
+ - Marker pattern detection
7
+ - Branch counting
8
+ - Symbol source extraction
9
+
10
+ Core module: pure logic, no I/O.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from typing import TYPE_CHECKING
18
+
19
+ from deal import post, pre
20
+
21
+ if TYPE_CHECKING:
22
+ from invar.core.models import Symbol
23
+
24
+ # I/O indicators that mark a function as legitimately in Shell
25
+ IO_INDICATORS: frozenset[str] = frozenset([
26
+ # File operations
27
+ ".read(",
28
+ ".write(",
29
+ ".read_text(",
30
+ ".write_text(",
31
+ ".read_bytes(",
32
+ ".write_bytes(",
33
+ "open(",
34
+ "Path(",
35
+ ".exists()",
36
+ ".is_file()",
37
+ ".is_dir()",
38
+ ".rglob(",
39
+ ".glob(",
40
+ ".iterdir(",
41
+ ".mkdir(",
42
+ ".unlink(",
43
+ "shutil.",
44
+ "tempfile.",
45
+ # Process operations
46
+ "subprocess.",
47
+ "os.system(",
48
+ "os.popen(",
49
+ "os.getenv(",
50
+ "os.environ",
51
+ # Terminal/System
52
+ "sys.stdout",
53
+ "sys.stderr",
54
+ "sys.stdin",
55
+ ".isatty()",
56
+ # Module loading
57
+ "importlib.",
58
+ "exec_module(",
59
+ # Network operations
60
+ "requests.",
61
+ "aiohttp.",
62
+ "httpx.",
63
+ "urllib.",
64
+ # Console output
65
+ "print(",
66
+ "console.",
67
+ "Console(",
68
+ "typer.",
69
+ "click.",
70
+ "rich.",
71
+ # Result wrapping (Shell's primary job)
72
+ "Success(",
73
+ "Failure(",
74
+ "Result[",
75
+ # Database
76
+ "cursor.",
77
+ "connection.",
78
+ "session.",
79
+ # Logging
80
+ "logger.",
81
+ "logging.",
82
+ # Serialization (often to files)
83
+ "json.dump(",
84
+ "json.load(",
85
+ "toml.load(",
86
+ "yaml.load(",
87
+ ])
88
+
89
+ # Marker pattern to exempt functions from complexity check
90
+ COMPLEXITY_MARKER_PATTERN = re.compile(r"#\s*@shell_complexity\s*:")
91
+
92
+ # Marker pattern to exempt functions from pure logic check (for orchestration functions)
93
+ ORCHESTRATION_MARKER_PATTERN = re.compile(r"#\s*@shell_orchestration\s*:")
94
+
95
+
96
+ @pre(lambda source: isinstance(source, str))
97
+ @post(lambda result: isinstance(result, bool))
98
+ def has_io_operations(source: str) -> bool:
99
+ """
100
+ Check if source code contains I/O operations.
101
+
102
+ Examples:
103
+ >>> has_io_operations("x = Success(value)")
104
+ True
105
+ >>> has_io_operations("return x + y")
106
+ False
107
+ >>> has_io_operations("path.read_text()")
108
+ True
109
+ >>> has_io_operations("print('hello')")
110
+ True
111
+ """
112
+ return any(indicator in source for indicator in IO_INDICATORS)
113
+
114
+
115
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
116
+ @post(lambda result: isinstance(result, bool))
117
+ def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
118
+ """
119
+ Check if symbol has @shell_orchestration marker comment.
120
+
121
+ Examples:
122
+ >>> from invar.core.models import Symbol, SymbolKind
123
+ >>> sym = Symbol(name="run_phase", kind=SymbolKind.FUNCTION, line=3, end_line=10)
124
+ >>> source = '''
125
+ ... # @shell_orchestration: Coordinates shell modules
126
+ ... def run_phase():
127
+ ... pass
128
+ ... '''
129
+ >>> has_orchestration_marker(sym, source)
130
+ True
131
+
132
+ >>> sym2 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=3)
133
+ >>> has_orchestration_marker(sym2, "def calc(): pass")
134
+ False
135
+ """
136
+ lines = source.splitlines()
137
+ if not lines:
138
+ return False
139
+
140
+ start_line = max(0, symbol.line - 4)
141
+ end_line = symbol.line
142
+
143
+ context_lines = lines[start_line:end_line]
144
+ context = "\n".join(context_lines)
145
+
146
+ return bool(ORCHESTRATION_MARKER_PATTERN.search(context))
147
+
148
+
149
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
150
+ @post(lambda result: isinstance(result, bool))
151
+ def has_complexity_marker(symbol: Symbol, source: str) -> bool:
152
+ """
153
+ Check if symbol has @shell_complexity marker comment.
154
+
155
+ Examples:
156
+ >>> from invar.core.models import Symbol, SymbolKind
157
+ >>> sym = Symbol(name="load", kind=SymbolKind.FUNCTION, line=3, end_line=10)
158
+ >>> source = '''
159
+ ... # @shell_complexity: Config cascade with fallbacks
160
+ ... def load():
161
+ ... pass
162
+ ... '''
163
+ >>> has_complexity_marker(sym, source)
164
+ True
165
+
166
+ >>> sym2 = Symbol(name="simple", kind=SymbolKind.FUNCTION, line=1, end_line=3)
167
+ >>> has_complexity_marker(sym2, "def simple(): pass")
168
+ False
169
+ """
170
+ lines = source.splitlines()
171
+ if not lines:
172
+ return False
173
+
174
+ # Look at lines before the function definition
175
+ start_line = max(0, symbol.line - 4)
176
+ end_line = symbol.line
177
+
178
+ context_lines = lines[start_line:end_line]
179
+ context = "\n".join(context_lines)
180
+
181
+ return bool(COMPLEXITY_MARKER_PATTERN.search(context))
182
+
183
+
184
+ @pre(lambda source: isinstance(source, str))
185
+ @post(lambda result: isinstance(result, int) and result >= 0)
186
+ def count_branches(source: str) -> int:
187
+ """
188
+ Count the number of branches in source code.
189
+
190
+ Counts: if, elif, except, for, while, match case, ternary
191
+
192
+ Examples:
193
+ >>> count_branches("if x: pass")
194
+ 1
195
+ >>> count_branches("if x: pass\\nelif y: pass")
196
+ 2
197
+ >>> count_branches("for x in y: pass")
198
+ 1
199
+ >>> count_branches("x = a if b else c")
200
+ 1
201
+ >>> count_branches("pass")
202
+ 0
203
+ >>> count_branches("")
204
+ 0
205
+ """
206
+ try:
207
+ tree = ast.parse(source)
208
+ except SyntaxError:
209
+ return 0
210
+
211
+ count = 0
212
+ for node in ast.walk(tree):
213
+ if isinstance(node, ast.If):
214
+ # ast.walk visits all If nodes including elifs
215
+ count += 1
216
+ elif isinstance(node, ast.For | ast.While | ast.ExceptHandler):
217
+ count += 1
218
+ elif isinstance(node, ast.Match):
219
+ # Count match cases
220
+ count += len(node.cases)
221
+ elif isinstance(node, ast.IfExp):
222
+ # Ternary expression
223
+ count += 1
224
+
225
+ return count
226
+
227
+
228
+ @pre(lambda symbol, file_source: symbol is not None and isinstance(file_source, str))
229
+ @post(lambda result: isinstance(result, str))
230
+ def get_symbol_source(symbol: Symbol, file_source: str) -> str:
231
+ """
232
+ Extract the source code for a specific symbol.
233
+
234
+ Examples:
235
+ >>> from invar.core.models import Symbol, SymbolKind
236
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=2, end_line=4)
237
+ >>> source = '''# comment
238
+ ... def foo():
239
+ ... return 1
240
+ ... '''
241
+ >>> 'def foo' in get_symbol_source(sym, source)
242
+ True
243
+ """
244
+ lines = file_source.splitlines()
245
+ if not lines:
246
+ return ""
247
+
248
+ # Line numbers are 1-indexed
249
+ start = max(0, symbol.line - 1)
250
+ end = min(len(lines), symbol.end_line)
251
+
252
+ return "\n".join(lines[start:end])
@@ -0,0 +1,171 @@
1
+ """
2
+ Shell architecture rules for DX-22.
3
+
4
+ Detects architectural issues in Shell layer:
5
+ - shell_pure_logic: Pure logic that belongs in Core
6
+ - shell_too_complex: Excessive branching complexity
7
+ - shell_complexity_debt: Accumulated complexity warnings
8
+
9
+ Core module: pure logic, no I/O.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from deal import post, pre
15
+
16
+ from invar.core.entry_points import get_symbol_lines, is_entry_point
17
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
18
+ from invar.core.shell_analysis import (
19
+ count_branches,
20
+ get_symbol_source,
21
+ has_complexity_marker,
22
+ has_io_operations,
23
+ has_orchestration_marker,
24
+ )
25
+
26
+
27
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
28
+ def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
29
+ """
30
+ Check that Shell functions contain I/O operations (DX-22).
31
+
32
+ Pure logic belongs in Core where it can be tested with contracts.
33
+ Functions > 5 lines without I/O indicators are flagged as WARNING.
34
+
35
+ Examples:
36
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
37
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=10)
38
+ >>> source = "def calc(x, y):\\n return x + y"
39
+ >>> info = FileInfo(path="shell/util.py", lines=10, symbols=[sym], is_shell=True, source=source)
40
+ >>> violations = check_shell_pure_logic(info, RuleConfig())
41
+ >>> len(violations) >= 0 # May or may not flag based on line count
42
+ True
43
+ """
44
+ violations: list[Violation] = []
45
+ if not file_info.is_shell:
46
+ return violations
47
+
48
+ for symbol in file_info.symbols:
49
+ if symbol.kind != SymbolKind.FUNCTION:
50
+ continue
51
+
52
+ # Skip small functions (wrappers are fine)
53
+ lines = get_symbol_lines(symbol)
54
+ if lines <= 5:
55
+ continue
56
+
57
+ # Skip entry points (they're handled by DX-23)
58
+ if is_entry_point(symbol, file_info.source):
59
+ continue
60
+
61
+ # Skip if marked with @shell_orchestration (coordinates other shell modules)
62
+ if has_orchestration_marker(symbol, file_info.source):
63
+ continue
64
+
65
+ # Get symbol source and check for I/O
66
+ symbol_source = get_symbol_source(symbol, file_info.source)
67
+ if not has_io_operations(symbol_source):
68
+ violations.append(
69
+ Violation(
70
+ rule="shell_pure_logic",
71
+ severity=Severity.WARNING,
72
+ file=file_info.path,
73
+ line=symbol.line,
74
+ message=f"Shell function '{symbol.name}' has no I/O operations - pure logic belongs in Core",
75
+ suggestion="Move to src/*/core/ and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
76
+ )
77
+ )
78
+
79
+ return violations
80
+
81
+
82
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
83
+ def check_shell_too_complex(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
84
+ """
85
+ Check that Shell functions don't have excessive branching (DX-22).
86
+
87
+ Complex logic should be in Core where it can be tested.
88
+ Functions exceeding shell_max_branches are flagged as INFO.
89
+
90
+ Use @shell_complexity marker to exempt justified complexity.
91
+
92
+ Examples:
93
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
94
+ >>> sym = Symbol(name="process", kind=SymbolKind.FUNCTION, line=1, end_line=5)
95
+ >>> source = "def process(x):\\n return x"
96
+ >>> info = FileInfo(path="shell/cli.py", lines=5, symbols=[sym], is_shell=True, source=source)
97
+ >>> check_shell_too_complex(info, RuleConfig())
98
+ []
99
+ """
100
+ violations: list[Violation] = []
101
+ if not file_info.is_shell:
102
+ return violations
103
+
104
+ max_branches = config.shell_max_branches
105
+
106
+ for symbol in file_info.symbols:
107
+ if symbol.kind != SymbolKind.FUNCTION:
108
+ continue
109
+
110
+ # Skip if marked with @shell_complexity
111
+ if has_complexity_marker(symbol, file_info.source):
112
+ continue
113
+
114
+ # Skip entry points
115
+ if is_entry_point(symbol, file_info.source):
116
+ continue
117
+
118
+ # Count branches in symbol source
119
+ symbol_source = get_symbol_source(symbol, file_info.source)
120
+ branches = count_branches(symbol_source)
121
+
122
+ if branches > max_branches:
123
+ violations.append(
124
+ Violation(
125
+ rule="shell_too_complex",
126
+ severity=Severity.INFO,
127
+ file=file_info.path,
128
+ line=symbol.line,
129
+ message=f"Shell function '{symbol.name}' has {branches} branches (max: {max_branches})",
130
+ suggestion="Extract logic to Core, or add: # @shell_complexity: <reason>",
131
+ )
132
+ )
133
+
134
+ return violations
135
+
136
+
137
+ @pre(lambda violations, limit: isinstance(violations, list) and limit > 0)
138
+ @post(lambda result: isinstance(result, list))
139
+ def check_complexity_debt(violations: list[Violation], limit: int = 5) -> list[Violation]:
140
+ """
141
+ Check project-level complexity debt (DX-22 Fix-or-Explain).
142
+
143
+ When the project has too many unaddressed shell_too_complex warnings,
144
+ escalate to ERROR to force resolution.
145
+
146
+ Examples:
147
+ >>> from invar.core.models import Violation, Severity
148
+ >>> v1 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="a.py", message="m")
149
+ >>> v2 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="b.py", message="m")
150
+ >>> check_complexity_debt([v1, v2], limit=5)
151
+ []
152
+ >>> many = [Violation(rule="shell_too_complex", severity=Severity.INFO, file=f"{i}.py", message="m") for i in range(6)]
153
+ >>> result = check_complexity_debt(many, limit=5)
154
+ >>> len(result)
155
+ 1
156
+ >>> result[0].severity == Severity.ERROR
157
+ True
158
+ """
159
+ unaddressed = [v for v in violations if v.rule == "shell_too_complex"]
160
+ if len(unaddressed) >= limit:
161
+ return [
162
+ Violation(
163
+ rule="shell_complexity_debt",
164
+ severity=Severity.ERROR,
165
+ file="<project>",
166
+ line=None,
167
+ message=f"Project has {len(unaddressed)} unaddressed complexity warnings (limit: {limit})",
168
+ suggestion="You must address these before proceeding:\n1. Refactor to reduce branches, OR\n2. Add @shell_complexity: markers with justification",
169
+ )
170
+ ]
171
+ return []
invar/core/suggestions.py CHANGED
@@ -65,6 +65,8 @@ def generate_contract_suggestion(signature: str) -> str:
65
65
  for name, type_hint in params:
66
66
  if not name: # Skip empty names from malformed signatures
67
67
  continue
68
+ if name in ("self", "cls"): # Skip method receiver parameters
69
+ continue
68
70
  param_names.append(name)
69
71
  if not type_hint:
70
72
  continue
@@ -86,6 +88,10 @@ def _extract_params(signature: str) -> list[tuple[str, str | None]]:
86
88
  """
87
89
  Extract parameters and their types from a signature.
88
90
 
91
+ MINOR-2 Limitation: Uses naive comma splitting which breaks for complex types
92
+ like Callable[[int, str], bool] where commas appear inside nested brackets.
93
+ This is acceptable since suggestions are advisory, not strict validation.
94
+
89
95
  Examples:
90
96
  >>> _extract_params("(x: int, y: str) -> bool")
91
97
  [('x', 'int'), ('y', 'str')]
invar/core/tautology.py CHANGED
@@ -48,6 +48,7 @@ def is_semantic_tautology(expression: str) -> tuple[bool, str]:
48
48
  return (False, "")
49
49
 
50
50
 
51
+ @pre(lambda node: isinstance(node, ast.expr))
51
52
  @post(lambda result: isinstance(result, tuple) and len(result) == 2)
52
53
  def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
53
54
  """Check for common tautology patterns in AST node."""
invar/core/utils.py CHANGED
@@ -37,6 +37,52 @@ def get_exit_code(report: GuardReport, strict: bool) -> int:
37
37
  return 0
38
38
 
39
39
 
40
+ @pre(lambda report, strict, doctest_passed=True, crosshair_passed=True, property_passed=True: isinstance(report, GuardReport))
41
+ @post(lambda result: result in ("passed", "failed"))
42
+ def get_combined_status(
43
+ report: GuardReport,
44
+ strict: bool,
45
+ doctest_passed: bool = True,
46
+ crosshair_passed: bool = True,
47
+ property_passed: bool = True,
48
+ ) -> str:
49
+ """
50
+ Calculate true guard status including all test phases (DX-26).
51
+
52
+ Unlike GuardReport.passed which only checks static errors,
53
+ this function combines static analysis with runtime test results.
54
+
55
+ Examples:
56
+ >>> from invar.core.models import GuardReport
57
+ >>> report = GuardReport(files_checked=1)
58
+ >>> get_combined_status(report, strict=False)
59
+ 'passed'
60
+ >>> get_combined_status(report, strict=False, doctest_passed=False)
61
+ 'failed'
62
+ >>> report.errors = 1
63
+ >>> get_combined_status(report, strict=False)
64
+ 'failed'
65
+ >>> report2 = GuardReport(files_checked=1, warnings=1)
66
+ >>> get_combined_status(report2, strict=True)
67
+ 'failed'
68
+ >>> get_combined_status(report2, strict=False)
69
+ 'passed'
70
+ """
71
+ # Static analysis failures
72
+ if report.errors > 0:
73
+ return "failed"
74
+ if strict and report.warnings > 0:
75
+ return "failed"
76
+ # Runtime test failures
77
+ if not doctest_passed:
78
+ return "failed"
79
+ if not crosshair_passed:
80
+ return "failed"
81
+ if not property_passed:
82
+ return "failed"
83
+ return "passed"
84
+
85
+
40
86
  @pre(lambda data, source: isinstance(data, dict) and isinstance(source, str))
41
87
  @post(lambda result: isinstance(result, dict))
42
88
  def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
@@ -188,6 +234,9 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
188
234
  """
189
235
  Parse configuration from guard section.
190
236
 
237
+ DX-22: Removed deprecated options (use_code_lines, exclude_doctest_lines).
238
+ These are now always enabled by default.
239
+
191
240
  Examples:
192
241
  >>> cfg = parse_guard_config({"max_file_lines": 400})
193
242
  >>> cfg.max_file_lines
@@ -200,9 +249,6 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
200
249
  1
201
250
  >>> cfg.rule_exclusions[0].pattern
202
251
  '**/gen/**'
203
- >>> cfg = parse_guard_config({"use_code_lines": "invalid"}) # Invalid type ignored
204
- >>> cfg.use_code_lines # Falls back to model default (False)
205
- False
206
252
  """
207
253
  kwargs: dict[str, Any] = {}
208
254
 
@@ -212,7 +258,8 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
212
258
  kwargs[key] = val
213
259
 
214
260
  # Bool fields
215
- for key in ("require_contracts", "require_doctests", "strict_pure", "use_code_lines", "exclude_doctest_lines"):
261
+ # DX-22: Removed use_code_lines, exclude_doctest_lines (deprecated)
262
+ for key in ("require_contracts", "require_doctests", "strict_pure"):
216
263
  if (val := _get_bool(guard_config, key)) is not None:
217
264
  kwargs[key] = val
218
265
 
@@ -0,0 +1,158 @@
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, pre
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
+ @pre(lambda source: isinstance(source, str))
68
+ @post(lambda result: isinstance(result, bool))
69
+ def has_incompatible_imports(source: str) -> bool:
70
+ """
71
+ Check if source contains imports incompatible with CrossHair.
72
+
73
+ DX-22: Detects C extension libraries that cannot be symbolically executed.
74
+ Used to route code directly to Hypothesis instead of wasting time on CrossHair.
75
+
76
+ Examples:
77
+ >>> has_incompatible_imports("import numpy as np")
78
+ True
79
+ >>> has_incompatible_imports("from pandas import DataFrame")
80
+ True
81
+ >>> has_incompatible_imports("from pathlib import Path")
82
+ False
83
+ >>> has_incompatible_imports("import json")
84
+ False
85
+ >>> has_incompatible_imports("from sklearn.model_selection import train_test_split")
86
+ True
87
+ >>> has_incompatible_imports("import torch.nn as nn")
88
+ True
89
+ >>> has_incompatible_imports("")
90
+ False
91
+ """
92
+ # Early return for empty/whitespace-only strings (avoids regex edge cases)
93
+ if not source or not source.strip():
94
+ return False
95
+ for match in _IMPORT_PATTERN.finditer(source):
96
+ lib = match.group(1) or match.group(2)
97
+ if lib and lib.lower() in CROSSHAIR_INCOMPATIBLE_LIBS:
98
+ return True
99
+ return False
100
+
101
+
102
+ @pre(lambda source: isinstance(source, str))
103
+ @post(lambda result: isinstance(result, set))
104
+ def get_incompatible_imports(source: str) -> set[str]:
105
+ """
106
+ Get the set of incompatible libraries imported in source.
107
+
108
+ Examples:
109
+ >>> sorted(get_incompatible_imports("import numpy\\nfrom pandas import DataFrame"))
110
+ ['numpy', 'pandas']
111
+ >>> get_incompatible_imports("import json")
112
+ set()
113
+ >>> sorted(get_incompatible_imports("import torch\\nimport tensorflow"))
114
+ ['tensorflow', 'torch']
115
+ >>> get_incompatible_imports("")
116
+ set()
117
+ """
118
+ # Early return for empty/whitespace-only strings (avoids regex edge cases)
119
+ if not source or not source.strip():
120
+ return set()
121
+ incompatible: set[str] = set()
122
+ for match in _IMPORT_PATTERN.finditer(source):
123
+ lib = match.group(1) or match.group(2)
124
+ if lib and lib.lower() in CROSSHAIR_INCOMPATIBLE_LIBS:
125
+ incompatible.add(lib.lower())
126
+ return incompatible
127
+
128
+
129
+ @pre(lambda source, has_contracts: isinstance(source, str) and isinstance(has_contracts, bool))
130
+ @post(lambda result: isinstance(result, VerificationTool))
131
+ def select_verification_tool(source: str, has_contracts: bool) -> VerificationTool:
132
+ """
133
+ Select the appropriate verification tool for a source file.
134
+
135
+ DX-22 Smart Routing:
136
+ - No contracts -> SKIP (nothing to verify)
137
+ - Has C extensions -> HYPOTHESIS (CrossHair will fail)
138
+ - Pure Python with contracts -> CROSSHAIR (can prove correctness)
139
+
140
+ Examples:
141
+ >>> select_verification_tool("def foo(): pass", has_contracts=False)
142
+ <VerificationTool.SKIP: 'skip'>
143
+ >>> select_verification_tool("import numpy\\n@pre(lambda x: x > 0)\\ndef foo(x): pass", has_contracts=True)
144
+ <VerificationTool.HYPOTHESIS: 'hypothesis'>
145
+ >>> select_verification_tool("@pre(lambda x: x > 0)\\ndef foo(x): pass", has_contracts=True)
146
+ <VerificationTool.CROSSHAIR: 'crosshair'>
147
+ >>> select_verification_tool("", has_contracts=False)
148
+ <VerificationTool.SKIP: 'skip'>
149
+ >>> select_verification_tool("", has_contracts=True)
150
+ <VerificationTool.CROSSHAIR: 'crosshair'>
151
+ """
152
+ if not has_contracts:
153
+ return VerificationTool.SKIP
154
+
155
+ if has_incompatible_imports(source):
156
+ return VerificationTool.HYPOTHESIS
157
+
158
+ return VerificationTool.CROSSHAIR
invar/invariant.py CHANGED
@@ -20,6 +20,7 @@ class InvariantViolation(Exception):
20
20
  _INVAR_CHECK = os.environ.get("INVAR_CHECK", "1") == "1"
21
21
 
22
22
 
23
+ # @invar:allow entry_point_too_thick: False positive - .get() matches router.get pattern
23
24
  def invariant(condition: bool, message: str = "") -> None:
24
25
  """
25
26
  Assert loop invariant. Checked at runtime when INVAR_CHECK=1.