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
invar/core/contracts.py
CHANGED
|
@@ -23,15 +23,19 @@ 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
|
-
@pre(lambda expression: (
|
|
26
|
+
@pre(lambda expression: isinstance(expression, str))
|
|
27
27
|
def is_empty_contract(expression: str) -> bool:
|
|
28
28
|
"""Check if a contract expression is always True (tautological).
|
|
29
29
|
|
|
30
|
+
Handles any string input - non-lambda expressions return False.
|
|
31
|
+
|
|
30
32
|
Examples:
|
|
31
33
|
>>> is_empty_contract("lambda: True"), is_empty_contract("lambda x: True")
|
|
32
34
|
(True, True)
|
|
33
35
|
>>> is_empty_contract("lambda x: x > 0"), is_empty_contract("")
|
|
34
36
|
(False, False)
|
|
37
|
+
>>> is_empty_contract("not a lambda") # Non-lambda returns False
|
|
38
|
+
False
|
|
35
39
|
"""
|
|
36
40
|
if not expression.strip():
|
|
37
41
|
return False
|
|
@@ -47,7 +51,7 @@ def is_empty_contract(expression: str) -> bool:
|
|
|
47
51
|
return False
|
|
48
52
|
|
|
49
53
|
|
|
50
|
-
@pre(lambda expression, annotations: (
|
|
54
|
+
@pre(lambda expression, annotations: isinstance(expression, str))
|
|
51
55
|
def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
|
|
52
56
|
"""Check if a contract only checks types already in annotations.
|
|
53
57
|
|
|
@@ -75,7 +79,11 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
|
|
|
75
79
|
@pre(lambda node: isinstance(node, ast.expr))
|
|
76
80
|
@post(lambda result: result is None or isinstance(result, list))
|
|
77
81
|
def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
|
|
78
|
-
"""Extract isinstance checks. Returns None if other logic present.
|
|
82
|
+
"""Extract isinstance checks. Returns None if other logic present.
|
|
83
|
+
|
|
84
|
+
Conservative: returns None for complex expressions (nested BoolOp, etc.)
|
|
85
|
+
to avoid false positives when detecting redundant type contracts.
|
|
86
|
+
"""
|
|
79
87
|
if isinstance(node, ast.Call) and hasattr(node, 'func'):
|
|
80
88
|
check = _parse_isinstance_call(node)
|
|
81
89
|
return [check] if check else None
|
|
@@ -106,9 +114,19 @@ def _parse_isinstance_call(node: ast.Call) -> tuple[str, str] | None:
|
|
|
106
114
|
def _types_match(annotation: str, type_name: str) -> bool:
|
|
107
115
|
"""Check if type annotation matches isinstance check.
|
|
108
116
|
|
|
117
|
+
Handles simple cases like 'int' matching 'int' and 'list[int]' matching 'list'.
|
|
118
|
+
|
|
119
|
+
MINOR-1 Limitation: Does not handle complex generics:
|
|
120
|
+
- Optional[T] / Union[T, None] → doesn't match 'T' or 'NoneType'
|
|
121
|
+
- Union[A, B] → doesn't match 'A' or 'B'
|
|
122
|
+
- Capitalized builtins → 'List[int]' won't match 'list'
|
|
123
|
+
This is acceptable for detecting obvious redundant type checks.
|
|
124
|
+
|
|
109
125
|
Examples:
|
|
110
126
|
>>> _types_match("int", "int"), _types_match("list[int]", "list")
|
|
111
127
|
(True, True)
|
|
128
|
+
>>> _types_match("Optional[int]", "int") # Limitation: returns False
|
|
129
|
+
False
|
|
112
130
|
"""
|
|
113
131
|
if annotation == type_name:
|
|
114
132
|
return True
|
|
@@ -119,7 +137,7 @@ def _types_match(annotation: str, type_name: str) -> bool:
|
|
|
119
137
|
# Phase 8.3: Parameter mismatch detection
|
|
120
138
|
|
|
121
139
|
|
|
122
|
-
@pre(lambda expression, signature: (
|
|
140
|
+
@pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
|
|
123
141
|
def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
|
|
124
142
|
"""
|
|
125
143
|
Check if lambda has params it doesn't use (P28: Partial Contract Detection).
|
|
@@ -171,7 +189,7 @@ def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str],
|
|
|
171
189
|
return (len(unused_params) > 0, unused_params, used_params)
|
|
172
190
|
|
|
173
191
|
|
|
174
|
-
@pre(lambda expression, signature: (
|
|
192
|
+
@pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
|
|
175
193
|
def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
|
|
176
194
|
"""
|
|
177
195
|
Check if lambda params don't match function params.
|
|
@@ -373,3 +391,55 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
373
391
|
)
|
|
374
392
|
)
|
|
375
393
|
return violations
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
397
|
+
def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
398
|
+
"""
|
|
399
|
+
Check that @skip_property_test decorators have a reason.
|
|
400
|
+
|
|
401
|
+
DX-28: Prevent abuse of skip by requiring justification.
|
|
402
|
+
|
|
403
|
+
Examples:
|
|
404
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
405
|
+
>>> sym = Symbol(name="f", kind=SymbolKind.FUNCTION, line=2, end_line=5)
|
|
406
|
+
>>> info = FileInfo(path="test.py", lines=10, symbols=[sym], source="@skip_property_test\\ndef f(): pass")
|
|
407
|
+
>>> vs = check_skip_without_reason(info, RuleConfig())
|
|
408
|
+
>>> len(vs) > 0
|
|
409
|
+
True
|
|
410
|
+
>>> vs[0].rule
|
|
411
|
+
'skip_without_reason'
|
|
412
|
+
>>> # MAJOR-10: Also detects empty string reasons
|
|
413
|
+
>>> info2 = FileInfo(path="t.py", lines=5, symbols=[sym], source='@skip_property_test("")\\ndef f(): pass')
|
|
414
|
+
>>> len(check_skip_without_reason(info2, RuleConfig())) > 0
|
|
415
|
+
True
|
|
416
|
+
"""
|
|
417
|
+
violations: list[Violation] = []
|
|
418
|
+
|
|
419
|
+
source = file_info.source or ""
|
|
420
|
+
if "@skip_property_test" not in source:
|
|
421
|
+
return violations
|
|
422
|
+
|
|
423
|
+
# Pattern matches @skip_property_test at start of line (not in strings)
|
|
424
|
+
# Uses ^ to ensure we're matching decorator position, not string literals
|
|
425
|
+
bare_pattern = re.compile(r"^\s*@skip_property_test\s*$")
|
|
426
|
+
no_reason_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*\)\s*$")
|
|
427
|
+
# MAJOR-10: Also detect empty/whitespace-only string reasons
|
|
428
|
+
empty_string_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*['\"][\s]*['\"]\s*\)\s*$")
|
|
429
|
+
|
|
430
|
+
for line_num, line in enumerate(source.split("\n"), 1):
|
|
431
|
+
# Check for bare @skip_property_test, empty parens, or empty string reason
|
|
432
|
+
if bare_pattern.match(line) or no_reason_pattern.match(line) or empty_string_pattern.match(line):
|
|
433
|
+
violations.append(
|
|
434
|
+
Violation(
|
|
435
|
+
rule="skip_without_reason",
|
|
436
|
+
severity=Severity.WARNING,
|
|
437
|
+
file=file_info.path,
|
|
438
|
+
line=line_num,
|
|
439
|
+
message="@skip_property_test used without reason",
|
|
440
|
+
suggestion='Add reason: @skip_property_test("category: explanation")\n'
|
|
441
|
+
"Valid categories: no_params, strategy_factory, external_io, non_deterministic",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return violations
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point detection for framework callbacks.
|
|
3
|
+
|
|
4
|
+
DX-23: Detects entry points (Flask routes, Typer commands, pytest fixtures, etc.)
|
|
5
|
+
that are exempt from Result[T, E] requirement but must remain thin.
|
|
6
|
+
|
|
7
|
+
Core module: pure logic, no I/O.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from deal import post, pre
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from invar.core.models import Symbol
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Decorator patterns that indicate framework entry points
|
|
22
|
+
# These functions interface with external frameworks and cannot return Result
|
|
23
|
+
ENTRY_POINT_DECORATORS: frozenset[str] = frozenset([
|
|
24
|
+
# Web frameworks - Flask
|
|
25
|
+
"app.route",
|
|
26
|
+
"app.get",
|
|
27
|
+
"app.post",
|
|
28
|
+
"app.put",
|
|
29
|
+
"app.delete",
|
|
30
|
+
"app.patch",
|
|
31
|
+
"blueprint.route",
|
|
32
|
+
"bp.route",
|
|
33
|
+
# Web frameworks - FastAPI
|
|
34
|
+
"router.get",
|
|
35
|
+
"router.post",
|
|
36
|
+
"router.put",
|
|
37
|
+
"router.delete",
|
|
38
|
+
"router.patch",
|
|
39
|
+
"api_router.get",
|
|
40
|
+
"api_router.post",
|
|
41
|
+
"api_router.put",
|
|
42
|
+
"api_router.delete",
|
|
43
|
+
# CLI frameworks - Typer
|
|
44
|
+
"app.command",
|
|
45
|
+
"app.callback",
|
|
46
|
+
"typer.command",
|
|
47
|
+
# CLI frameworks - Click
|
|
48
|
+
"click.command",
|
|
49
|
+
"click.group",
|
|
50
|
+
"cli.command",
|
|
51
|
+
# Testing - pytest
|
|
52
|
+
"pytest.fixture",
|
|
53
|
+
"fixture",
|
|
54
|
+
# Event handlers
|
|
55
|
+
"on_event",
|
|
56
|
+
"app.on_event",
|
|
57
|
+
"middleware",
|
|
58
|
+
"app.middleware",
|
|
59
|
+
# Django
|
|
60
|
+
"admin.register",
|
|
61
|
+
"receiver",
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
# Explicit marker comment for edge cases
|
|
65
|
+
ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
|
|
66
|
+
|
|
67
|
+
# DX-22: Unified escape hatch pattern: # @invar:allow <rule>: <reason>
|
|
68
|
+
INVAR_ALLOW_PATTERN = re.compile(r"#\s*@invar:allow\s+(\w+)\s*:\s*(.+)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pre(lambda source: isinstance(source, str))
|
|
72
|
+
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
73
|
+
def count_escape_hatches(source: str) -> int:
|
|
74
|
+
"""
|
|
75
|
+
Count @invar:allow markers in source code (DX-31).
|
|
76
|
+
|
|
77
|
+
Used by check_review_suggested to trigger review when escape count >= 3.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> count_escape_hatches("")
|
|
81
|
+
0
|
|
82
|
+
>>> count_escape_hatches("# @invar:allow rule: reason")
|
|
83
|
+
1
|
|
84
|
+
>>> source = '''
|
|
85
|
+
... # @invar:allow rule1: reason1
|
|
86
|
+
... def foo(): pass
|
|
87
|
+
... # @invar:allow rule2: reason2
|
|
88
|
+
... def bar(): pass
|
|
89
|
+
... '''
|
|
90
|
+
>>> count_escape_hatches(source)
|
|
91
|
+
2
|
|
92
|
+
>>> count_escape_hatches("regular comment # no marker")
|
|
93
|
+
0
|
|
94
|
+
"""
|
|
95
|
+
return len(INVAR_ALLOW_PATTERN.findall(source))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pre(lambda symbol, source: symbol is not None and isinstance(source, str))
|
|
99
|
+
@post(lambda result: isinstance(result, bool))
|
|
100
|
+
def is_entry_point(symbol: Symbol, source: str) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
Check if a symbol is a framework entry point.
|
|
103
|
+
|
|
104
|
+
Entry points are functions decorated with framework-specific decorators
|
|
105
|
+
(Flask routes, Typer commands, etc.) that cannot return Result[T, E]
|
|
106
|
+
because the framework expects specific return types.
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
110
|
+
>>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=5, end_line=10)
|
|
111
|
+
>>> source = '''
|
|
112
|
+
... @app.route("/")
|
|
113
|
+
... def index():
|
|
114
|
+
... return "Hello"
|
|
115
|
+
... '''
|
|
116
|
+
>>> is_entry_point(sym, source)
|
|
117
|
+
True
|
|
118
|
+
|
|
119
|
+
>>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
120
|
+
>>> source2 = '''
|
|
121
|
+
... def load_file(path: str) -> Result[str, str]:
|
|
122
|
+
... return Success(path.read_text())
|
|
123
|
+
... '''
|
|
124
|
+
>>> is_entry_point(sym2, source2)
|
|
125
|
+
False
|
|
126
|
+
|
|
127
|
+
>>> # Explicit marker
|
|
128
|
+
>>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=8)
|
|
129
|
+
>>> source3 = '''
|
|
130
|
+
... # @shell:entry - Legacy callback
|
|
131
|
+
... def handler(data):
|
|
132
|
+
... return process(data)
|
|
133
|
+
... '''
|
|
134
|
+
>>> is_entry_point(sym3, source3)
|
|
135
|
+
True
|
|
136
|
+
"""
|
|
137
|
+
# Check decorator patterns
|
|
138
|
+
if _has_entry_decorator(symbol, source):
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
# Check explicit marker
|
|
142
|
+
return _has_entry_marker(symbol, source)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pre(lambda symbol, source: symbol is not None and isinstance(source, str))
|
|
146
|
+
@post(lambda result: isinstance(result, bool))
|
|
147
|
+
def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Check if symbol has a framework entry point decorator.
|
|
150
|
+
|
|
151
|
+
Looks at the source code above the function definition.
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
155
|
+
>>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=3, end_line=6)
|
|
156
|
+
>>> source = '''@app.route("/")
|
|
157
|
+
... def home():
|
|
158
|
+
... pass
|
|
159
|
+
... '''
|
|
160
|
+
>>> _has_entry_decorator(sym, source)
|
|
161
|
+
True
|
|
162
|
+
"""
|
|
163
|
+
# Get source lines
|
|
164
|
+
lines = source.splitlines()
|
|
165
|
+
if not lines:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
# Look at lines before the function definition (decorators are above)
|
|
169
|
+
# We check up to 5 lines above the function for decorators
|
|
170
|
+
start_line = max(0, symbol.line - 6)
|
|
171
|
+
end_line = symbol.line # Line numbers are 1-indexed, so line-1 = index
|
|
172
|
+
|
|
173
|
+
context_lines = lines[start_line:end_line]
|
|
174
|
+
context = "\n".join(context_lines)
|
|
175
|
+
|
|
176
|
+
# Check each known decorator pattern
|
|
177
|
+
# Note: String matching may match decorators in string literals (rare edge case).
|
|
178
|
+
# AST-based detection would be more robust but adds complexity for a heuristic check.
|
|
179
|
+
for pattern in ENTRY_POINT_DECORATORS:
|
|
180
|
+
# Match @pattern or @something.pattern
|
|
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
|
+
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pre(lambda symbol, source: symbol is not None and isinstance(source, str))
|
|
193
|
+
@post(lambda result: isinstance(result, bool))
|
|
194
|
+
def _has_entry_marker(symbol: Symbol, source: str) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Check if symbol has an explicit entry point marker comment.
|
|
197
|
+
|
|
198
|
+
Looks for: # @shell:entry
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
202
|
+
>>> sym = Symbol(name="callback", kind=SymbolKind.FUNCTION, line=3, end_line=6)
|
|
203
|
+
>>> source = '''
|
|
204
|
+
... # @shell:entry - Custom framework callback
|
|
205
|
+
... def callback():
|
|
206
|
+
... pass
|
|
207
|
+
... '''
|
|
208
|
+
>>> _has_entry_marker(sym, source)
|
|
209
|
+
True
|
|
210
|
+
|
|
211
|
+
>>> sym2 = Symbol(name="regular", kind=SymbolKind.FUNCTION, line=1, end_line=3)
|
|
212
|
+
>>> source2 = '''def regular(): pass'''
|
|
213
|
+
>>> _has_entry_marker(sym2, source2)
|
|
214
|
+
False
|
|
215
|
+
"""
|
|
216
|
+
lines = source.splitlines()
|
|
217
|
+
if not lines:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
# Look at lines before the function definition
|
|
221
|
+
start_line = max(0, symbol.line - 4)
|
|
222
|
+
end_line = symbol.line
|
|
223
|
+
|
|
224
|
+
context_lines = lines[start_line:end_line]
|
|
225
|
+
context = "\n".join(context_lines)
|
|
226
|
+
|
|
227
|
+
return bool(ENTRY_MARKER_PATTERN.search(context))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pre(lambda symbol: symbol is not None)
|
|
231
|
+
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
232
|
+
def get_symbol_lines(symbol: Symbol) -> int:
|
|
233
|
+
"""
|
|
234
|
+
Get the number of lines in a symbol.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
238
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
|
|
239
|
+
>>> get_symbol_lines(sym)
|
|
240
|
+
10
|
|
241
|
+
>>> sym2 = Symbol(name="bar", kind=SymbolKind.FUNCTION, line=5, end_line=5)
|
|
242
|
+
>>> get_symbol_lines(sym2)
|
|
243
|
+
1
|
|
244
|
+
"""
|
|
245
|
+
return max(1, symbol.end_line - symbol.line + 1)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pre(lambda symbol, source, rule: symbol is not None and isinstance(rule, str))
|
|
249
|
+
@post(lambda result: isinstance(result, bool))
|
|
250
|
+
def has_allow_marker(symbol: Symbol, source: str, rule: str) -> bool:
|
|
251
|
+
"""
|
|
252
|
+
Check if symbol has an @invar:allow marker for a specific rule.
|
|
253
|
+
|
|
254
|
+
DX-22: Unified escape hatch mechanism. Format:
|
|
255
|
+
# @invar:allow <rule>: <reason>
|
|
256
|
+
|
|
257
|
+
Examples:
|
|
258
|
+
>>> from invar.core.models import Symbol, SymbolKind
|
|
259
|
+
>>> sym = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=20)
|
|
260
|
+
>>> source = '''
|
|
261
|
+
... # @invar:allow entry_point_too_thick: Complex CLI parsing
|
|
262
|
+
... def handler():
|
|
263
|
+
... pass
|
|
264
|
+
... '''
|
|
265
|
+
>>> has_allow_marker(sym, source, "entry_point_too_thick")
|
|
266
|
+
True
|
|
267
|
+
>>> has_allow_marker(sym, source, "shell_result")
|
|
268
|
+
False
|
|
269
|
+
|
|
270
|
+
>>> sym2 = Symbol(name="api", kind=SymbolKind.FUNCTION, line=3, end_line=10)
|
|
271
|
+
>>> source2 = '''
|
|
272
|
+
... # @invar:allow shell_result: Returns raw JSON for legacy API
|
|
273
|
+
... def api():
|
|
274
|
+
... pass
|
|
275
|
+
... '''
|
|
276
|
+
>>> has_allow_marker(sym2, source2, "shell_result")
|
|
277
|
+
True
|
|
278
|
+
"""
|
|
279
|
+
lines = source.splitlines()
|
|
280
|
+
if not lines:
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
# Look at lines before the function definition (up to 4 lines)
|
|
284
|
+
start_line = max(0, symbol.line - 5)
|
|
285
|
+
end_line = symbol.line
|
|
286
|
+
|
|
287
|
+
context_lines = lines[start_line:end_line]
|
|
288
|
+
|
|
289
|
+
for line in context_lines:
|
|
290
|
+
match = INVAR_ALLOW_PATTERN.search(line)
|
|
291
|
+
if match and match.group(1) == rule:
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
return False
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Format-driven property testing specifications.
|
|
3
|
+
|
|
4
|
+
DX-28: Format specifications for generating realistic test data
|
|
5
|
+
that matches production formats, enabling property tests to catch
|
|
6
|
+
semantic bugs like inverted filter conditions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from deal import post, pre
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FormatSpec:
|
|
18
|
+
"""Base specification for a data format.
|
|
19
|
+
|
|
20
|
+
A FormatSpec describes the structure of data used in a function,
|
|
21
|
+
enabling Hypothesis to generate realistic test cases.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
>>> spec = FormatSpec(name="simple")
|
|
25
|
+
>>> spec.name
|
|
26
|
+
'simple'
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
description: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class LineFormat(FormatSpec):
|
|
35
|
+
"""Specification for line-based text formats.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
>>> spec = LineFormat(
|
|
39
|
+
... name="log_line",
|
|
40
|
+
... prefix_pattern="<timestamp>",
|
|
41
|
+
... separator=": ",
|
|
42
|
+
... keywords=["error", "warning", "info"]
|
|
43
|
+
... )
|
|
44
|
+
>>> spec.separator
|
|
45
|
+
': '
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
prefix_pattern: str = ""
|
|
49
|
+
separator: str = ""
|
|
50
|
+
keywords: list[str] = field(default_factory=list)
|
|
51
|
+
suffix_pattern: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CrossHairOutputSpec(FormatSpec):
|
|
56
|
+
"""Specification for CrossHair verification output format.
|
|
57
|
+
|
|
58
|
+
CrossHair outputs counterexamples in a specific format:
|
|
59
|
+
`file.py:line: error: ErrorType when calling func(args)`
|
|
60
|
+
|
|
61
|
+
This spec enables generating realistic CrossHair output for testing
|
|
62
|
+
counterexample extraction logic.
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
>>> spec = CrossHairOutputSpec()
|
|
66
|
+
>>> spec.name
|
|
67
|
+
'crosshair_output'
|
|
68
|
+
>>> ": error:" in spec.error_marker
|
|
69
|
+
True
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
name: str = "crosshair_output"
|
|
73
|
+
description: str = "CrossHair symbolic verification output"
|
|
74
|
+
|
|
75
|
+
# Format components
|
|
76
|
+
file_pattern: str = r"[a-z_]+\.py"
|
|
77
|
+
line_pattern: str = r"\\d+"
|
|
78
|
+
error_marker: str = ": error:"
|
|
79
|
+
error_types: list[str] = field(
|
|
80
|
+
default_factory=lambda: [
|
|
81
|
+
"IndexError",
|
|
82
|
+
"KeyError",
|
|
83
|
+
"ValueError",
|
|
84
|
+
"TypeError",
|
|
85
|
+
"AssertionError",
|
|
86
|
+
"ZeroDivisionError",
|
|
87
|
+
"AttributeError",
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@pre(lambda self, filename, line, error_type, function, args: self.error_marker and line > 0)
|
|
92
|
+
@post(lambda result: isinstance(result, str) and len(result) > 0)
|
|
93
|
+
def format_counterexample(
|
|
94
|
+
self,
|
|
95
|
+
filename: str = "test.py",
|
|
96
|
+
line: int = 1,
|
|
97
|
+
error_type: str = "AssertionError",
|
|
98
|
+
function: str = "func",
|
|
99
|
+
args: str = "x=0",
|
|
100
|
+
) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Generate a counterexample line in CrossHair format.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
>>> spec = CrossHairOutputSpec()
|
|
106
|
+
>>> line = spec.format_counterexample("foo.py", 42, "ValueError", "bar", "x=-1")
|
|
107
|
+
>>> line
|
|
108
|
+
'foo.py:42: error: ValueError when calling bar(x=-1)'
|
|
109
|
+
>>> ": error:" in line
|
|
110
|
+
True
|
|
111
|
+
"""
|
|
112
|
+
return f"{filename}:{line}: error: {error_type} when calling {function}({args})"
|
|
113
|
+
|
|
114
|
+
@pre(lambda self, count, include_success, include_errors: count >= 0 and (not include_errors or (len(self.error_types) > 0 and bool(self.error_marker))))
|
|
115
|
+
@post(lambda result: isinstance(result, list))
|
|
116
|
+
def generate_output(
|
|
117
|
+
self,
|
|
118
|
+
count: int = 3,
|
|
119
|
+
include_success: bool = True,
|
|
120
|
+
include_errors: bool = True,
|
|
121
|
+
) -> list[str]:
|
|
122
|
+
"""
|
|
123
|
+
Generate sample CrossHair output with counterexamples.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> spec = CrossHairOutputSpec()
|
|
127
|
+
>>> output = spec.generate_output(count=2, include_success=False, include_errors=True)
|
|
128
|
+
>>> len([l for l in output if ": error:" in l])
|
|
129
|
+
2
|
|
130
|
+
>>> all(": error:" in line for line in output if line)
|
|
131
|
+
True
|
|
132
|
+
"""
|
|
133
|
+
lines: list[str] = []
|
|
134
|
+
|
|
135
|
+
if include_success:
|
|
136
|
+
lines.append("Checking module...")
|
|
137
|
+
|
|
138
|
+
if include_errors:
|
|
139
|
+
for i in range(count):
|
|
140
|
+
error_type = self.error_types[i % len(self.error_types)]
|
|
141
|
+
lines.append(
|
|
142
|
+
self.format_counterexample(
|
|
143
|
+
f"file{i}.py", i + 1, error_type, f"func{i}", f"x={i}"
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if include_success:
|
|
148
|
+
lines.append("") # Empty line at end
|
|
149
|
+
|
|
150
|
+
return lines
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class PytestOutputSpec(FormatSpec):
|
|
155
|
+
"""Specification for pytest output format.
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
>>> spec = PytestOutputSpec()
|
|
159
|
+
>>> spec.name
|
|
160
|
+
'pytest_output'
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
name: str = "pytest_output"
|
|
164
|
+
description: str = "pytest test runner output"
|
|
165
|
+
|
|
166
|
+
passed_marker: str = "PASSED"
|
|
167
|
+
failed_marker: str = "FAILED"
|
|
168
|
+
error_marker: str = "ERROR"
|
|
169
|
+
separator: str = "::"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Pre-built specs for common formats
|
|
173
|
+
CROSSHAIR_SPEC = CrossHairOutputSpec()
|
|
174
|
+
PYTEST_SPEC = PytestOutputSpec()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pre(lambda text, spec: isinstance(text, str) and isinstance(spec, CrossHairOutputSpec))
|
|
178
|
+
@post(lambda result: isinstance(result, list))
|
|
179
|
+
def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
|
|
180
|
+
"""
|
|
181
|
+
Extract lines matching a format specification.
|
|
182
|
+
|
|
183
|
+
This is a reference implementation showing how FormatSpec
|
|
184
|
+
can be used to create format-aware extraction.
|
|
185
|
+
|
|
186
|
+
Examples:
|
|
187
|
+
>>> spec = CrossHairOutputSpec()
|
|
188
|
+
>>> text = "info\\nfile.py:1: error: Bug\\nok"
|
|
189
|
+
>>> extract_by_format(text, spec)
|
|
190
|
+
['file.py:1: error: Bug']
|
|
191
|
+
"""
|
|
192
|
+
return [
|
|
193
|
+
line.strip()
|
|
194
|
+
for line in text.split("\n")
|
|
195
|
+
if line.strip() and spec.error_marker in line.lower()
|
|
196
|
+
]
|