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.
- invar/core/contracts.py +75 -5
- invar/core/entry_points.py +294 -0
- invar/core/format_specs.py +196 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +27 -4
- invar/core/hypothesis_strategies.py +47 -5
- invar/core/lambda_helpers.py +1 -0
- invar/core/models.py +23 -17
- invar/core/parser.py +6 -2
- invar/core/property_gen.py +81 -40
- invar/core/purity.py +10 -4
- invar/core/review_trigger.py +298 -0
- invar/core/rule_meta.py +61 -2
- invar/core/rules.py +83 -19
- invar/core/shell_analysis.py +252 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/suggestions.py +6 -0
- invar/core/tautology.py +1 -0
- invar/core/utils.py +51 -4
- invar/core/verification_routing.py +158 -0
- invar/invariant.py +1 -0
- invar/mcp/server.py +20 -3
- invar/shell/cli.py +59 -31
- invar/shell/config.py +259 -10
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +78 -3
- invar/shell/guard_output.py +100 -24
- invar/shell/init_cmd.py +27 -7
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutate_cmd.py +184 -0
- invar/shell/mutation.py +314 -0
- invar/shell/perception.py +2 -0
- invar/shell/property_tests.py +17 -2
- invar/shell/prove.py +35 -3
- invar/shell/prove_accept.py +113 -0
- invar/shell/prove_fallback.py +148 -46
- invar/shell/templates.py +34 -0
- invar/shell/test_cmd.py +3 -1
- invar/shell/testing.py +6 -17
- invar/shell/update_cmd.py +2 -0
- invar/templates/CLAUDE.md.template +65 -9
- invar/templates/INVAR.md +96 -23
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/review.md +200 -0
- invar/templates/cursorrules.template +22 -13
- invar/templates/examples/contracts.py +3 -1
- invar/templates/examples/core_shell.py +3 -1
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
- invar_tools-1.2.0.dist-info/RECORD +77 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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.
|