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
invar/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ For runtime contracts only, use invar-runtime instead.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
__version__ = "1.0.0"
|
|
12
|
+
__protocol_version__ = "5.0" # Protocol/spec version (separate from package version)
|
|
12
13
|
|
|
13
14
|
# Re-export from invar-runtime for backwards compatibility
|
|
14
15
|
from invar_runtime import (
|
invar/core/contracts.py
CHANGED
|
@@ -23,7 +23,7 @@ from invar.core.tautology import check_semantic_tautology as check_semantic_taut
|
|
|
23
23
|
from invar.core.tautology import is_semantic_tautology as is_semantic_tautology
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
@
|
|
26
|
+
@post(lambda result: isinstance(result, bool))
|
|
27
27
|
def is_empty_contract(expression: str) -> bool:
|
|
28
28
|
"""Check if a contract expression is always True (tautological).
|
|
29
29
|
|
|
@@ -51,7 +51,7 @@ def is_empty_contract(expression: str) -> bool:
|
|
|
51
51
|
return False
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
@
|
|
54
|
+
@post(lambda result: isinstance(result, bool))
|
|
55
55
|
def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
|
|
56
56
|
"""Check if a contract only checks types already in annotations.
|
|
57
57
|
|
|
@@ -76,7 +76,7 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
|
|
|
76
76
|
return False
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
@pre(lambda node: isinstance(node, ast.expr))
|
|
79
|
+
@pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
|
|
80
80
|
@post(lambda result: result is None or isinstance(result, list))
|
|
81
81
|
def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
|
|
82
82
|
"""Extract isinstance checks. Returns None if other logic present.
|
|
@@ -137,7 +137,7 @@ def _types_match(annotation: str, type_name: str) -> bool:
|
|
|
137
137
|
# Phase 8.3: Parameter mismatch detection
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
@
|
|
140
|
+
@post(lambda result: len(result) == 3 and isinstance(result[0], bool))
|
|
141
141
|
def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
|
|
142
142
|
"""
|
|
143
143
|
Check if lambda has params it doesn't use (P28: Partial Contract Detection).
|
|
@@ -189,7 +189,7 @@ def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str],
|
|
|
189
189
|
return (len(unused_params) > 0, unused_params, used_params)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
-
@
|
|
192
|
+
@post(lambda result: len(result) == 2 and isinstance(result[0], bool))
|
|
193
193
|
def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
|
|
194
194
|
"""
|
|
195
195
|
Check if lambda params don't match function params.
|
|
@@ -227,7 +227,7 @@ def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
|
|
|
227
227
|
# Rule checking functions
|
|
228
228
|
|
|
229
229
|
|
|
230
|
-
@
|
|
230
|
+
@post(lambda result: all(v.rule == "empty_contract" for v in result))
|
|
231
231
|
def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
232
232
|
"""Check for empty/tautological contracts. Core files only.
|
|
233
233
|
|
|
@@ -260,7 +260,7 @@ def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Viola
|
|
|
260
260
|
return violations
|
|
261
261
|
|
|
262
262
|
|
|
263
|
-
@
|
|
263
|
+
@post(lambda result: all(v.rule == "redundant_type_contract" for v in result))
|
|
264
264
|
def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
265
265
|
"""Check for contracts that only check types in annotations. Core files only. INFO severity.
|
|
266
266
|
|
|
@@ -298,7 +298,7 @@ def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> l
|
|
|
298
298
|
return violations
|
|
299
299
|
|
|
300
300
|
|
|
301
|
-
@
|
|
301
|
+
@post(lambda result: all(v.rule == "param_mismatch" for v in result))
|
|
302
302
|
def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
303
303
|
"""Check @pre lambda params match function params. Core files only. ERROR severity.
|
|
304
304
|
|
|
@@ -342,7 +342,7 @@ def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violat
|
|
|
342
342
|
return violations
|
|
343
343
|
|
|
344
344
|
|
|
345
|
-
@
|
|
345
|
+
@post(lambda result: all(v.rule == "partial_contract" for v in result))
|
|
346
346
|
def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
347
347
|
"""Check @pre contracts that don't use all declared params (P28). Core files only. WARN severity.
|
|
348
348
|
|
|
@@ -393,7 +393,7 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
393
393
|
return violations
|
|
394
394
|
|
|
395
395
|
|
|
396
|
-
@
|
|
396
|
+
@post(lambda result: all(v.rule == "skip_without_reason" for v in result))
|
|
397
397
|
def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
398
398
|
"""
|
|
399
399
|
Check that @skip_property_test decorators have a reason.
|
invar/core/entry_points.py
CHANGED
|
@@ -9,7 +9,9 @@ Core module: pure logic, no I/O.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import ast
|
|
12
13
|
import re
|
|
14
|
+
import tokenize
|
|
13
15
|
from typing import TYPE_CHECKING
|
|
14
16
|
|
|
15
17
|
from deal import post, pre
|
|
@@ -68,12 +70,12 @@ ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
|
|
|
68
70
|
INVAR_ALLOW_PATTERN = re.compile(r"#\s*@invar:allow\s+(\w+)\s*:\s*(.+)")
|
|
69
71
|
|
|
70
72
|
|
|
71
|
-
@
|
|
72
|
-
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
73
|
+
@post(lambda result: result >= 0) # Escape hatch count is non-negative
|
|
73
74
|
def count_escape_hatches(source: str) -> int:
|
|
74
75
|
"""
|
|
75
76
|
Count @invar:allow markers in source code (DX-31).
|
|
76
77
|
|
|
78
|
+
Uses tokenize to only match real comments, not strings/docstrings (DX-33 Option C).
|
|
77
79
|
Used by check_review_suggested to trigger review when escape count >= 3.
|
|
78
80
|
|
|
79
81
|
Examples:
|
|
@@ -91,8 +93,50 @@ def count_escape_hatches(source: str) -> int:
|
|
|
91
93
|
2
|
|
92
94
|
>>> count_escape_hatches("regular comment # no marker")
|
|
93
95
|
0
|
|
96
|
+
>>> # DX-33 Option C: Strings containing the pattern should NOT match
|
|
97
|
+
>>> count_escape_hatches('s = "# @invar:allow rule: reason"')
|
|
98
|
+
0
|
|
99
|
+
"""
|
|
100
|
+
return len(extract_escape_hatches(source))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@post(lambda result: all(len(t) == 2 for t in result)) # Returns (rule, reason) tuples
|
|
104
|
+
def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
|
|
105
|
+
"""
|
|
106
|
+
Extract @invar:allow markers with their reasons (DX-33 Option E).
|
|
107
|
+
|
|
108
|
+
Uses tokenize to only match real comments, not strings/docstrings.
|
|
109
|
+
Returns list of (rule, reason) tuples for cross-file analysis.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
>>> extract_escape_hatches("")
|
|
113
|
+
[]
|
|
114
|
+
>>> extract_escape_hatches("# @invar:allow shell_result: API boundary")
|
|
115
|
+
[('shell_result', 'API boundary')]
|
|
116
|
+
>>> source = '''
|
|
117
|
+
... # @invar:allow rule1: same reason
|
|
118
|
+
... # @invar:allow rule2: different reason
|
|
119
|
+
... '''
|
|
120
|
+
>>> extract_escape_hatches(source)
|
|
121
|
+
[('rule1', 'same reason'), ('rule2', 'different reason')]
|
|
122
|
+
>>> # DX-33 Option C: Strings containing the pattern should NOT match
|
|
123
|
+
>>> extract_escape_hatches('suggestion = "# @invar:allow rule: reason"')
|
|
124
|
+
[]
|
|
94
125
|
"""
|
|
95
|
-
|
|
126
|
+
results: list[tuple[str, str]] = []
|
|
127
|
+
try:
|
|
128
|
+
# Use iterator-based readline to avoid io.StringIO (forbidden in Core)
|
|
129
|
+
lines = iter(source.splitlines(keepends=True))
|
|
130
|
+
tokens = tokenize.generate_tokens(lambda: next(lines, ""))
|
|
131
|
+
for tok in tokens:
|
|
132
|
+
if tok.type == tokenize.COMMENT:
|
|
133
|
+
match = INVAR_ALLOW_PATTERN.search(tok.string)
|
|
134
|
+
if match:
|
|
135
|
+
results.append((match.group(1), match.group(2)))
|
|
136
|
+
except Exception:
|
|
137
|
+
# Fall back to regex if tokenization fails (invalid syntax, non-printable chars, etc.)
|
|
138
|
+
return INVAR_ALLOW_PATTERN.findall(source)
|
|
139
|
+
return results
|
|
96
140
|
|
|
97
141
|
|
|
98
142
|
@pre(lambda symbol, source: symbol is not None and isinstance(source, str))
|
|
@@ -107,7 +151,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
|
|
|
107
151
|
|
|
108
152
|
Examples:
|
|
109
153
|
>>> from invar.core.models import Symbol, SymbolKind
|
|
110
|
-
>>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=
|
|
154
|
+
>>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=3, end_line=5)
|
|
111
155
|
>>> source = '''
|
|
112
156
|
... @app.route("/")
|
|
113
157
|
... def index():
|
|
@@ -116,7 +160,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
|
|
|
116
160
|
>>> is_entry_point(sym, source)
|
|
117
161
|
True
|
|
118
162
|
|
|
119
|
-
>>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=
|
|
163
|
+
>>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=2, end_line=4)
|
|
120
164
|
>>> source2 = '''
|
|
121
165
|
... def load_file(path: str) -> Result[str, str]:
|
|
122
166
|
... return Success(path.read_text())
|
|
@@ -125,7 +169,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
|
|
|
125
169
|
False
|
|
126
170
|
|
|
127
171
|
>>> # Explicit marker
|
|
128
|
-
>>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=
|
|
172
|
+
>>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=5)
|
|
129
173
|
>>> source3 = '''
|
|
130
174
|
... # @shell:entry - Legacy callback
|
|
131
175
|
... def handler(data):
|
|
@@ -142,50 +186,79 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
|
|
|
142
186
|
return _has_entry_marker(symbol, source)
|
|
143
187
|
|
|
144
188
|
|
|
189
|
+
|
|
190
|
+
@post(lambda result: isinstance(result, str))
|
|
191
|
+
def _decorator_to_string(decorator: ast.AST) -> str:
|
|
192
|
+
"""
|
|
193
|
+
Convert AST decorator node to string representation for matching.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
>>> import ast
|
|
197
|
+
>>> tree = ast.parse("@app.route('/')\\ndef f(): pass")
|
|
198
|
+
>>> func = tree.body[0]
|
|
199
|
+
>>> _decorator_to_string(func.decorator_list[0])
|
|
200
|
+
'app.route'
|
|
201
|
+
"""
|
|
202
|
+
if isinstance(decorator, ast.Name):
|
|
203
|
+
return decorator.id
|
|
204
|
+
elif isinstance(decorator, ast.Attribute):
|
|
205
|
+
parts = []
|
|
206
|
+
node = decorator
|
|
207
|
+
while isinstance(node, ast.Attribute):
|
|
208
|
+
parts.append(node.attr)
|
|
209
|
+
node = node.value
|
|
210
|
+
if isinstance(node, ast.Name):
|
|
211
|
+
parts.append(node.id)
|
|
212
|
+
return ".".join(reversed(parts))
|
|
213
|
+
elif isinstance(decorator, ast.Call):
|
|
214
|
+
return _decorator_to_string(decorator.func)
|
|
215
|
+
return ""
|
|
216
|
+
|
|
145
217
|
@pre(lambda symbol, source: symbol is not None and isinstance(source, str))
|
|
146
218
|
@post(lambda result: isinstance(result, bool))
|
|
147
219
|
def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
|
|
148
220
|
"""
|
|
149
221
|
Check if symbol has a framework entry point decorator.
|
|
150
222
|
|
|
151
|
-
|
|
223
|
+
Uses AST to check decorator nodes, avoiding false matches in strings.
|
|
224
|
+
DX-33 Option C: Migrated from string matching to AST-based detection.
|
|
152
225
|
|
|
153
226
|
Examples:
|
|
154
227
|
>>> from invar.core.models import Symbol, SymbolKind
|
|
155
|
-
>>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=
|
|
228
|
+
>>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=2, end_line=4)
|
|
156
229
|
>>> source = '''@app.route("/")
|
|
157
230
|
... def home():
|
|
158
231
|
... pass
|
|
159
232
|
... '''
|
|
160
233
|
>>> _has_entry_decorator(sym, source)
|
|
161
234
|
True
|
|
235
|
+
>>> # DX-33: Decorators in strings should NOT match
|
|
236
|
+
>>> sym2 = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=2, end_line=4)
|
|
237
|
+
>>> source2 = '''x = "@app.route('/')"
|
|
238
|
+
... def foo():
|
|
239
|
+
... pass
|
|
240
|
+
... '''
|
|
241
|
+
>>> _has_entry_decorator(sym2, source2)
|
|
242
|
+
False
|
|
162
243
|
"""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
244
|
+
try:
|
|
245
|
+
tree = ast.parse(source)
|
|
246
|
+
except SyntaxError:
|
|
166
247
|
return False
|
|
167
248
|
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if f"@{pattern}" in context:
|
|
182
|
-
return True
|
|
183
|
-
# Also match partial patterns (e.g., "route" matches "app.route")
|
|
184
|
-
if "." in pattern:
|
|
185
|
-
base = pattern.split(".")[-1]
|
|
186
|
-
if f".{base}(" in context or f".{base}\n" in context:
|
|
187
|
-
return True
|
|
188
|
-
|
|
249
|
+
# Find the function definition at the symbol's line
|
|
250
|
+
for node in ast.walk(tree):
|
|
251
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
252
|
+
if node.lineno == symbol.line and node.name == symbol.name:
|
|
253
|
+
# Check decorators
|
|
254
|
+
for decorator in node.decorator_list:
|
|
255
|
+
decorator_str = _decorator_to_string(decorator)
|
|
256
|
+
if decorator_str:
|
|
257
|
+
for pattern in ENTRY_POINT_DECORATORS:
|
|
258
|
+
if pattern in decorator_str or decorator_str.endswith(
|
|
259
|
+
"." + pattern.split(".")[-1]
|
|
260
|
+
):
|
|
261
|
+
return True
|
|
189
262
|
return False
|
|
190
263
|
|
|
191
264
|
|
invar/core/extraction.py
CHANGED
|
@@ -11,8 +11,7 @@ from deal import post, pre
|
|
|
11
11
|
from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
@
|
|
15
|
-
@post(lambda result: isinstance(result, dict))
|
|
14
|
+
@post(lambda result: all(k in result for k in result)) # Bidirectional graph
|
|
16
15
|
def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
|
|
17
16
|
"""Build bidirectional call graph for function grouping.
|
|
18
17
|
|
|
@@ -33,8 +32,8 @@ def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
|
|
|
33
32
|
return graph
|
|
34
33
|
|
|
35
34
|
|
|
36
|
-
@pre(lambda start, graph, visited: start and
|
|
37
|
-
@post(lambda result:
|
|
35
|
+
@pre(lambda start, graph, visited: start and start in graph) # Start must exist in graph
|
|
36
|
+
@post(lambda result: len(result) >= 1 or not result) # At least 1 if found, else empty
|
|
38
37
|
def _find_connected_component(start: str, graph: dict[str, set[str]], visited: set[str]) -> list[str]:
|
|
39
38
|
"""BFS to find all functions connected to start.
|
|
40
39
|
|
|
@@ -55,7 +54,7 @@ def _find_connected_component(start: str, graph: dict[str, set[str]], visited: s
|
|
|
55
54
|
return component
|
|
56
55
|
|
|
57
56
|
|
|
58
|
-
@
|
|
57
|
+
@post(lambda result: all("functions" in g and "lines" in g for g in result))
|
|
59
58
|
def find_extractable_groups(file_info: FileInfo) -> list[dict]:
|
|
60
59
|
"""
|
|
61
60
|
Find groups of related functions that could be extracted together.
|
|
@@ -136,7 +135,7 @@ def _get_group_dependencies(
|
|
|
136
135
|
return deps.intersection(set(file_imports)) if file_imports else deps
|
|
137
136
|
|
|
138
137
|
|
|
139
|
-
@pre(lambda file_info, max_groups=3:
|
|
138
|
+
@pre(lambda file_info, max_groups=3: max_groups >= 1) # At least 1 group
|
|
140
139
|
def format_extraction_hint(file_info: FileInfo, max_groups: int = 3) -> str:
|
|
141
140
|
"""
|
|
142
141
|
Format extraction suggestions for file_size_warning.
|
invar/core/format_specs.py
CHANGED
|
@@ -174,8 +174,7 @@ CROSSHAIR_SPEC = CrossHairOutputSpec()
|
|
|
174
174
|
PYTEST_SPEC = PytestOutputSpec()
|
|
175
175
|
|
|
176
176
|
|
|
177
|
-
@
|
|
178
|
-
@post(lambda result: isinstance(result, list))
|
|
177
|
+
@post(lambda result: all(isinstance(line, str) and line.strip() for line in result)) # Non-empty strings
|
|
179
178
|
def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
|
|
180
179
|
"""
|
|
181
180
|
Extract lines matching a format specification.
|
invar/core/formatter.py
CHANGED
|
@@ -15,7 +15,7 @@ from invar.core.models import GuardReport, PerceptionMap, Symbol, SymbolRefs, Vi
|
|
|
15
15
|
from invar.core.rule_meta import get_rule_meta
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
@pre(lambda perception_map, top_n=0:
|
|
18
|
+
@pre(lambda perception_map, top_n=0: top_n >= 0)
|
|
19
19
|
def format_map_text(perception_map: PerceptionMap, top_n: int = 0) -> str:
|
|
20
20
|
"""
|
|
21
21
|
Format perception map as plain text.
|
|
@@ -121,7 +121,6 @@ def format_map_json(perception_map: PerceptionMap, top_n: int = 0) -> dict:
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
|
|
124
|
-
@pre(lambda sr: isinstance(sr, SymbolRefs))
|
|
125
124
|
@post(lambda result: "name" in result and "ref_count" in result)
|
|
126
125
|
def _symbol_refs_to_dict(sr: SymbolRefs) -> dict:
|
|
127
126
|
"""Convert SymbolRefs to dict."""
|
|
@@ -138,7 +137,7 @@ def _symbol_refs_to_dict(sr: SymbolRefs) -> dict:
|
|
|
138
137
|
}
|
|
139
138
|
|
|
140
139
|
|
|
141
|
-
@pre(lambda symbol, file_path:
|
|
140
|
+
@pre(lambda symbol, file_path: len(file_path) > 0)
|
|
142
141
|
def format_signature(symbol: Symbol, file_path: str) -> str:
|
|
143
142
|
"""
|
|
144
143
|
Format a single symbol signature.
|
|
@@ -154,7 +153,7 @@ def format_signature(symbol: Symbol, file_path: str) -> str:
|
|
|
154
153
|
return f"{file_path}::{symbol.name}{sig}"
|
|
155
154
|
|
|
156
155
|
|
|
157
|
-
@pre(lambda symbols, file_path:
|
|
156
|
+
@pre(lambda symbols, file_path: len(file_path) > 0)
|
|
158
157
|
def format_signatures_text(symbols: list[Symbol], file_path: str) -> str:
|
|
159
158
|
"""
|
|
160
159
|
Format multiple signatures as text.
|
|
@@ -169,7 +168,7 @@ def format_signatures_text(symbols: list[Symbol], file_path: str) -> str:
|
|
|
169
168
|
return "\n".join(lines)
|
|
170
169
|
|
|
171
170
|
|
|
172
|
-
@pre(lambda symbols, file_path:
|
|
171
|
+
@pre(lambda symbols, file_path: len(file_path) > 0)
|
|
173
172
|
def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
|
|
174
173
|
"""
|
|
175
174
|
Format signatures as JSON-serializable dict.
|
|
@@ -200,7 +199,7 @@ def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
|
|
|
200
199
|
# Phase 8.2: Agent-mode formatting
|
|
201
200
|
|
|
202
201
|
|
|
203
|
-
@pre(lambda report, combined_status=None:
|
|
202
|
+
@pre(lambda report, combined_status=None: combined_status is None or combined_status in ("passed", "failed"))
|
|
204
203
|
def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
|
|
205
204
|
"""
|
|
206
205
|
Format Guard report for Agent consumption (Phase 8.2 + DX-26).
|
|
@@ -255,7 +254,7 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
|
|
|
255
254
|
}
|
|
256
255
|
|
|
257
256
|
|
|
258
|
-
@
|
|
257
|
+
@post(lambda result: "file" in result and "rule" in result and "severity" in result)
|
|
259
258
|
def _violation_to_fix(v: Violation) -> dict:
|
|
260
259
|
"""Convert a Violation to an Agent-friendly fix instruction."""
|
|
261
260
|
fix_info = _parse_suggestion(v.suggestion, v.rule) if v.suggestion else None
|
|
@@ -24,7 +24,7 @@ _hypothesis_available = False
|
|
|
24
24
|
_numpy_available = False
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
@
|
|
27
|
+
# @invar:allow missing_contract: Boolean availability check, no meaningful contract
|
|
28
28
|
def _ensure_hypothesis() -> bool:
|
|
29
29
|
"""Check if hypothesis is available."""
|
|
30
30
|
global _hypothesis_available
|
|
@@ -37,7 +37,7 @@ def _ensure_hypothesis() -> bool:
|
|
|
37
37
|
return False
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
@
|
|
40
|
+
# @invar:allow missing_contract: Boolean availability check, no meaningful contract
|
|
41
41
|
def _ensure_numpy() -> bool:
|
|
42
42
|
"""Check if numpy is available."""
|
|
43
43
|
global _numpy_available
|
|
@@ -363,7 +363,7 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
|
|
|
363
363
|
|
|
364
364
|
DX-12-B: Supports both strategy objects and string representations.
|
|
365
365
|
|
|
366
|
-
>>> from
|
|
366
|
+
>>> from invar_runtime import strategy
|
|
367
367
|
>>> @strategy(x="floats(min_value=0)")
|
|
368
368
|
... def sqrt(x: float) -> float:
|
|
369
369
|
... return x ** 0.5
|
|
@@ -402,8 +402,7 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
|
|
|
402
402
|
return user_specs
|
|
403
403
|
|
|
404
404
|
|
|
405
|
-
@
|
|
406
|
-
@post(lambda result: isinstance(result, list))
|
|
405
|
+
@post(lambda result: all("lambda" in s for s in result)) # Only lambda expressions
|
|
407
406
|
def _extract_pre_lambdas_from_source(source: str) -> list[str]:
|
|
408
407
|
"""
|
|
409
408
|
Extract lambda expressions from @pre decorators with balanced parenthesis.
|
|
@@ -468,8 +467,7 @@ def _extract_pre_sources(func: Callable) -> list[str]:
|
|
|
468
467
|
return pre_sources
|
|
469
468
|
|
|
470
469
|
|
|
471
|
-
@
|
|
472
|
-
@post(lambda result: isinstance(result, dict))
|
|
470
|
+
@post(lambda result: all(k in ("min_value", "max_value", "min_size", "max_size", "exclude_min", "exclude_max") for k in result))
|
|
473
471
|
def _bounds_to_strategy_kwargs(bounds: dict[str, Any], strategy_name: str) -> dict[str, Any]:
|
|
474
472
|
"""Convert bound constraints to Hypothesis strategy kwargs."""
|
|
475
473
|
kwargs = {}
|
invar/core/inspect.py
CHANGED
invar/core/lambda_helpers.py
CHANGED
|
@@ -8,7 +8,7 @@ import re
|
|
|
8
8
|
from deal import post, pre
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
@pre(lambda tree: isinstance(tree, ast.AST))
|
|
11
|
+
@pre(lambda tree: isinstance(tree, ast.AST) and hasattr(tree, '__class__'))
|
|
12
12
|
@post(lambda result: result is None or isinstance(result, ast.Lambda))
|
|
13
13
|
def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
|
|
14
14
|
"""Find the lambda node in an expression tree.
|
|
@@ -52,8 +52,7 @@ def extract_annotations(signature: str) -> dict[str, str]:
|
|
|
52
52
|
return annotations
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
@
|
|
56
|
-
@post(lambda result: result is None or isinstance(result, list))
|
|
55
|
+
@post(lambda result: result is None or all(isinstance(p, str) for p in result)) # Valid params
|
|
57
56
|
def extract_lambda_params(expression: str) -> list[str] | None:
|
|
58
57
|
"""Extract parameter names from a lambda expression.
|
|
59
58
|
|
|
@@ -115,6 +114,7 @@ def extract_func_param_names(signature: str) -> list[str] | None:
|
|
|
115
114
|
return params
|
|
116
115
|
|
|
117
116
|
|
|
117
|
+
@pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
|
|
118
118
|
@post(lambda result: isinstance(result, set))
|
|
119
119
|
def extract_used_names(node: ast.expr) -> set[str]:
|
|
120
120
|
"""Extract all variable names used in an expression (Load context).
|
invar/core/models.py
CHANGED
|
@@ -94,7 +94,7 @@ class GuardReport(BaseModel):
|
|
|
94
94
|
core_functions_total: int = 0
|
|
95
95
|
core_functions_with_contracts: int = 0
|
|
96
96
|
|
|
97
|
-
@pre(lambda self, violation:
|
|
97
|
+
@pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
|
|
98
98
|
def add_violation(self, violation: Violation) -> None:
|
|
99
99
|
"""
|
|
100
100
|
Add a violation and update counts.
|
|
@@ -257,6 +257,12 @@ class RuleConfig(BaseModel):
|
|
|
257
257
|
purity_pure: list[str] = Field(default_factory=list) # Known pure functions
|
|
258
258
|
purity_impure: list[str] = Field(default_factory=list) # Known impure functions
|
|
259
259
|
|
|
260
|
+
# Timeout configuration (seconds) - MAJOR-3 fix
|
|
261
|
+
timeout_doctest: int = Field(default=60, ge=1, le=600) # Doctests should be fast
|
|
262
|
+
timeout_hypothesis: int = Field(default=300, ge=1, le=1800) # Property tests
|
|
263
|
+
timeout_crosshair: int = Field(default=300, ge=1, le=1800) # Symbolic verification total
|
|
264
|
+
timeout_crosshair_per_condition: int = Field(default=30, ge=1, le=300) # Per-contract limit
|
|
265
|
+
|
|
260
266
|
|
|
261
267
|
# Phase 4: Perception models
|
|
262
268
|
|
invar/core/must_use.py
CHANGED
|
@@ -58,6 +58,7 @@ def find_must_use_functions(source: str) -> dict[str, str]:
|
|
|
58
58
|
return must_use_funcs
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
@pre(lambda decorator: isinstance(decorator, ast.expr) and hasattr(decorator, '__class__'))
|
|
61
62
|
@post(lambda result: result is None or isinstance(result, str))
|
|
62
63
|
def _extract_must_use_reason(decorator: ast.expr) -> str | None:
|
|
63
64
|
"""Extract reason from @must_use decorator, or None if not a must_use."""
|
|
@@ -136,7 +137,7 @@ def _get_call_name(call: ast.Call) -> str | None:
|
|
|
136
137
|
return None
|
|
137
138
|
|
|
138
139
|
|
|
139
|
-
@
|
|
140
|
+
@post(lambda result: all(v.rule == "must_use_ignored" for v in result)) # Rule consistency
|
|
140
141
|
def check_must_use(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
141
142
|
"""
|
|
142
143
|
Check for ignored return values of @must_use functions.
|
invar/core/parser.py
CHANGED
|
@@ -64,7 +64,7 @@ def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
67
|
+
@pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
|
|
68
68
|
def _extract_symbols(tree: ast.Module) -> list[Symbol]:
|
|
69
69
|
"""
|
|
70
70
|
Extract function, class, and method symbols from AST.
|
|
@@ -190,7 +190,10 @@ def _parse_class(node: ast.ClassDef) -> Symbol:
|
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
|
|
193
|
-
@pre(lambda node:
|
|
193
|
+
@pre(lambda node: (
|
|
194
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
|
|
195
|
+
hasattr(node, 'decorator_list')
|
|
196
|
+
))
|
|
194
197
|
@post(lambda result: all(c.kind in ("pre", "post") for c in result))
|
|
195
198
|
def _extract_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[Contract]:
|
|
196
199
|
"""Extract @pre and @post contracts from function decorators."""
|
|
@@ -230,7 +233,7 @@ def _parse_decorator_as_contract(decorator: ast.expr) -> Contract | None:
|
|
|
230
233
|
return None
|
|
231
234
|
|
|
232
235
|
|
|
233
|
-
@pre(lambda call: isinstance(call, ast.Call))
|
|
236
|
+
@pre(lambda call: isinstance(call, ast.Call) and hasattr(call, 'args'))
|
|
234
237
|
def _get_contract_expression(call: ast.Call) -> str:
|
|
235
238
|
"""Extract the expression string from a contract decorator call."""
|
|
236
239
|
if call.args:
|
|
@@ -264,7 +267,7 @@ def _build_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
|
264
267
|
return sig
|
|
265
268
|
|
|
266
269
|
|
|
267
|
-
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
270
|
+
@pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
|
|
268
271
|
@post(lambda result: all(isinstance(s, str) and s for s in result))
|
|
269
272
|
def _extract_imports(tree: ast.Module) -> list[str]:
|
|
270
273
|
"""Extract imported module names from AST (top-level only)."""
|