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,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hypothesis strategies for format-driven property testing.
|
|
3
|
+
|
|
4
|
+
DX-28: Strategies that generate realistic test data matching
|
|
5
|
+
production formats, enabling property tests to catch semantic bugs.
|
|
6
|
+
|
|
7
|
+
These strategies are optional - they require Hypothesis to be installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from deal import post, pre
|
|
15
|
+
from invar_runtime import skip_property_test
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from hypothesis.strategies import SearchStrategy
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from hypothesis import strategies as st
|
|
22
|
+
|
|
23
|
+
HYPOTHESIS_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
HYPOTHESIS_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@skip_property_test("no_params: Zero-parameter function, property testing cannot vary inputs")
|
|
29
|
+
@post(lambda result: result is not None)
|
|
30
|
+
def crosshair_line() -> SearchStrategy[str]:
|
|
31
|
+
"""
|
|
32
|
+
Generate a CrossHair counterexample line.
|
|
33
|
+
|
|
34
|
+
Format: `file.py:line: error: ErrorType when calling func(args)`
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> if HYPOTHESIS_AVAILABLE:
|
|
38
|
+
... line = crosshair_line().example()
|
|
39
|
+
... assert ": error:" in line.lower()
|
|
40
|
+
"""
|
|
41
|
+
if not HYPOTHESIS_AVAILABLE:
|
|
42
|
+
raise ImportError("Hypothesis required: pip install hypothesis")
|
|
43
|
+
|
|
44
|
+
filenames = st.from_regex(r"[a-z_]{1,20}\.py", fullmatch=True)
|
|
45
|
+
line_nums = st.integers(min_value=1, max_value=1000)
|
|
46
|
+
error_types = st.sampled_from(
|
|
47
|
+
[
|
|
48
|
+
"IndexError",
|
|
49
|
+
"KeyError",
|
|
50
|
+
"ValueError",
|
|
51
|
+
"TypeError",
|
|
52
|
+
"AssertionError",
|
|
53
|
+
"ZeroDivisionError",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
func_names = st.from_regex(r"[a-z_]{1,15}", fullmatch=True)
|
|
57
|
+
args = st.from_regex(r"[a-z]=-?\d{1,5}", fullmatch=True)
|
|
58
|
+
|
|
59
|
+
return st.builds(
|
|
60
|
+
lambda f, ln, e, fn, a: f"{f}:{ln}: error: {e} when calling {fn}({a})",
|
|
61
|
+
filenames,
|
|
62
|
+
line_nums,
|
|
63
|
+
error_types,
|
|
64
|
+
func_names,
|
|
65
|
+
args,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pre(lambda min_errors, max_errors: min_errors >= 0 and max_errors >= min_errors)
|
|
70
|
+
@post(lambda result: result is not None)
|
|
71
|
+
def crosshair_output(
|
|
72
|
+
min_errors: int = 0,
|
|
73
|
+
max_errors: int = 5,
|
|
74
|
+
) -> SearchStrategy[str]:
|
|
75
|
+
"""
|
|
76
|
+
Generate complete CrossHair output with multiple lines.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
min_errors: Minimum number of error lines
|
|
80
|
+
max_errors: Maximum number of error lines
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> if HYPOTHESIS_AVAILABLE:
|
|
84
|
+
... output = crosshair_output(min_errors=1, max_errors=3).example()
|
|
85
|
+
... assert ": error:" in output.lower()
|
|
86
|
+
"""
|
|
87
|
+
if not HYPOTHESIS_AVAILABLE:
|
|
88
|
+
raise ImportError("Hypothesis required: pip install hypothesis")
|
|
89
|
+
|
|
90
|
+
headers = st.sampled_from(
|
|
91
|
+
[
|
|
92
|
+
"Checking module...",
|
|
93
|
+
"Running crosshair check...",
|
|
94
|
+
"",
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
error_lines = st.lists(crosshair_line(), min_size=min_errors, max_size=max_errors)
|
|
99
|
+
|
|
100
|
+
footers = st.sampled_from(
|
|
101
|
+
[
|
|
102
|
+
"",
|
|
103
|
+
"Done.",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return st.builds(
|
|
108
|
+
lambda h, errors, f: "\n".join([h, *errors, f]),
|
|
109
|
+
headers,
|
|
110
|
+
error_lines,
|
|
111
|
+
footers,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pre(lambda pattern, min_occurrences, max_occurrences: len(pattern) > 0 and min_occurrences >= 0)
|
|
116
|
+
@post(lambda result: result is not None)
|
|
117
|
+
def text_with_pattern(
|
|
118
|
+
pattern: str,
|
|
119
|
+
min_occurrences: int = 1,
|
|
120
|
+
max_occurrences: int = 5,
|
|
121
|
+
) -> SearchStrategy[str]:
|
|
122
|
+
"""
|
|
123
|
+
Generate text that contains a specific pattern.
|
|
124
|
+
|
|
125
|
+
Useful for testing extraction functions that should find
|
|
126
|
+
occurrences of a pattern in text.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
pattern: The pattern that must appear in the output
|
|
130
|
+
min_occurrences: Minimum times pattern appears
|
|
131
|
+
max_occurrences: Maximum times pattern appears
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
>>> if HYPOTHESIS_AVAILABLE:
|
|
135
|
+
... text = text_with_pattern(": error:", 2, 5).example()
|
|
136
|
+
... assert text.count(": error:") >= 2
|
|
137
|
+
"""
|
|
138
|
+
if not HYPOTHESIS_AVAILABLE:
|
|
139
|
+
raise ImportError("Hypothesis required: pip install hypothesis")
|
|
140
|
+
|
|
141
|
+
# Generate lines that contain the pattern
|
|
142
|
+
prefix = st.from_regex(r"[a-z0-9_.]{1,30}", fullmatch=True)
|
|
143
|
+
suffix = st.from_regex(r"[a-zA-Z0-9 ]{1,50}", fullmatch=True)
|
|
144
|
+
|
|
145
|
+
pattern_line = st.builds(
|
|
146
|
+
lambda p, s: f"{p}{pattern}{s}",
|
|
147
|
+
prefix,
|
|
148
|
+
suffix,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Generate noise lines without the pattern
|
|
152
|
+
noise_line = st.from_regex(r"[a-zA-Z0-9 ]{1,80}", fullmatch=True).filter(
|
|
153
|
+
lambda x: pattern not in x
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Combine into full output
|
|
157
|
+
pattern_lines = st.lists(
|
|
158
|
+
pattern_line, min_size=min_occurrences, max_size=max_occurrences
|
|
159
|
+
)
|
|
160
|
+
noise_lines = st.lists(noise_line, min_size=0, max_size=10)
|
|
161
|
+
|
|
162
|
+
return st.builds(
|
|
163
|
+
lambda p, n: "\n".join(p + n),
|
|
164
|
+
pattern_lines,
|
|
165
|
+
noise_lines,
|
|
166
|
+
).map(lambda x: "\n".join(sorted(x.split("\n"), key=lambda _: __import__("random").random())))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pre(lambda pattern: len(pattern) > 0)
|
|
170
|
+
@post(lambda result: result is not None)
|
|
171
|
+
def extraction_test_case(
|
|
172
|
+
pattern: str,
|
|
173
|
+
) -> SearchStrategy[tuple[str, int]]:
|
|
174
|
+
"""
|
|
175
|
+
Generate (text, expected_count) pairs for testing extraction functions.
|
|
176
|
+
|
|
177
|
+
The expected_count is the number of lines that should be extracted.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
>>> if HYPOTHESIS_AVAILABLE:
|
|
181
|
+
... text, count = extraction_test_case(": error:").example()
|
|
182
|
+
... actual = len([l for l in text.split("\\n") if ": error:" in l])
|
|
183
|
+
... assert actual == count
|
|
184
|
+
"""
|
|
185
|
+
if not HYPOTHESIS_AVAILABLE:
|
|
186
|
+
raise ImportError("Hypothesis required: pip install hypothesis")
|
|
187
|
+
|
|
188
|
+
count = st.integers(min_value=0, max_value=10)
|
|
189
|
+
|
|
190
|
+
return count.flatmap(
|
|
191
|
+
lambda c: st.tuples(
|
|
192
|
+
text_with_pattern(pattern, min_occurrences=c, max_occurrences=c)
|
|
193
|
+
if c > 0
|
|
194
|
+
else st.just("no matches here"),
|
|
195
|
+
st.just(c),
|
|
196
|
+
)
|
|
197
|
+
)
|
invar/core/formatter.py
CHANGED
|
@@ -200,12 +200,18 @@ def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
|
|
|
200
200
|
# Phase 8.2: Agent-mode formatting
|
|
201
201
|
|
|
202
202
|
|
|
203
|
-
@pre(lambda report: isinstance(report, GuardReport))
|
|
204
|
-
def format_guard_agent(report: GuardReport) -> dict:
|
|
203
|
+
@pre(lambda report, combined_status=None: isinstance(report, GuardReport))
|
|
204
|
+
def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
|
|
205
205
|
"""
|
|
206
|
-
Format Guard report for Agent consumption (Phase 8.2).
|
|
206
|
+
Format Guard report for Agent consumption (Phase 8.2 + DX-26).
|
|
207
207
|
|
|
208
208
|
Provides structured output with actionable fix instructions.
|
|
209
|
+
DX-26: status now reflects ALL test phases when combined_status is provided.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
report: Guard analysis report
|
|
213
|
+
combined_status: True guard status including all test phases (DX-26).
|
|
214
|
+
If None, uses report.passed (static-only, deprecated).
|
|
209
215
|
|
|
210
216
|
Examples:
|
|
211
217
|
>>> from invar.core.models import GuardReport, Violation, Severity
|
|
@@ -219,9 +225,26 @@ def format_guard_agent(report: GuardReport) -> dict:
|
|
|
219
225
|
'passed'
|
|
220
226
|
>>> len(d["fixes"])
|
|
221
227
|
1
|
|
228
|
+
>>> # DX-26: combined_status overrides report.passed
|
|
229
|
+
>>> d2 = format_guard_agent(report, combined_status="failed")
|
|
230
|
+
>>> d2["status"]
|
|
231
|
+
'failed'
|
|
232
|
+
>>> d2["static"]["passed"] # Static still shows passed
|
|
233
|
+
True
|
|
222
234
|
"""
|
|
235
|
+
# DX-26: Use combined status if provided, else fall back to static-only
|
|
236
|
+
status = combined_status if combined_status else ("passed" if report.passed else "failed")
|
|
237
|
+
static_passed = report.errors == 0
|
|
238
|
+
|
|
223
239
|
return {
|
|
224
|
-
"status":
|
|
240
|
+
"status": status,
|
|
241
|
+
# DX-26: Separate static results from combined status
|
|
242
|
+
"static": {
|
|
243
|
+
"passed": static_passed,
|
|
244
|
+
"errors": report.errors,
|
|
245
|
+
"warnings": report.warnings,
|
|
246
|
+
"infos": report.infos,
|
|
247
|
+
},
|
|
225
248
|
"summary": {
|
|
226
249
|
"files_checked": report.files_checked,
|
|
227
250
|
"errors": report.errors,
|
|
@@ -402,6 +402,51 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
|
|
|
402
402
|
return user_specs
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
@pre(lambda source: isinstance(source, str))
|
|
406
|
+
@post(lambda result: isinstance(result, list))
|
|
407
|
+
def _extract_pre_lambdas_from_source(source: str) -> list[str]:
|
|
408
|
+
"""
|
|
409
|
+
Extract lambda expressions from @pre decorators with balanced parenthesis.
|
|
410
|
+
|
|
411
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x: x > 0)")
|
|
412
|
+
['lambda x: x > 0']
|
|
413
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x: len(x) > 0)")
|
|
414
|
+
['lambda x: len(x) > 0']
|
|
415
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x, y: isinstance(x, str))")
|
|
416
|
+
['lambda x, y: isinstance(x, str)']
|
|
417
|
+
>>> _extract_pre_lambdas_from_source("")
|
|
418
|
+
[]
|
|
419
|
+
"""
|
|
420
|
+
results = []
|
|
421
|
+
i = 0
|
|
422
|
+
while i < len(source):
|
|
423
|
+
# Find @pre(
|
|
424
|
+
pre_match = re.search(r"@pre\s*\(", source[i:])
|
|
425
|
+
if not pre_match:
|
|
426
|
+
break
|
|
427
|
+
start = i + pre_match.end()
|
|
428
|
+
|
|
429
|
+
# Find matching closing paren with balance counting
|
|
430
|
+
paren_depth = 1
|
|
431
|
+
j = start
|
|
432
|
+
while j < len(source) and paren_depth > 0:
|
|
433
|
+
if source[j] == "(":
|
|
434
|
+
paren_depth += 1
|
|
435
|
+
elif source[j] == ")":
|
|
436
|
+
paren_depth -= 1
|
|
437
|
+
j += 1
|
|
438
|
+
|
|
439
|
+
if paren_depth == 0:
|
|
440
|
+
# Extract content between @pre( and matching )
|
|
441
|
+
content = source[start : j - 1].strip()
|
|
442
|
+
if content.startswith("lambda"):
|
|
443
|
+
results.append(content)
|
|
444
|
+
|
|
445
|
+
i = j
|
|
446
|
+
|
|
447
|
+
return results
|
|
448
|
+
|
|
449
|
+
|
|
405
450
|
@pre(lambda func: callable(func))
|
|
406
451
|
@post(lambda result: isinstance(result, list))
|
|
407
452
|
def _extract_pre_sources(func: Callable) -> list[str]:
|
|
@@ -413,13 +458,10 @@ def _extract_pre_sources(func: Callable) -> list[str]:
|
|
|
413
458
|
# deal stores contracts in _deal attribute
|
|
414
459
|
pass
|
|
415
460
|
|
|
416
|
-
# Try to extract from source
|
|
461
|
+
# Try to extract from source using balanced parenthesis matching
|
|
417
462
|
try:
|
|
418
463
|
source = inspect.getsource(func)
|
|
419
|
-
|
|
420
|
-
pre_pattern = r"@pre\s*\(\s*(lambda[^)]+)\s*\)"
|
|
421
|
-
matches = re.findall(pre_pattern, source)
|
|
422
|
-
pre_sources.extend(matches)
|
|
464
|
+
pre_sources.extend(_extract_pre_lambdas_from_source(source))
|
|
423
465
|
except (OSError, TypeError):
|
|
424
466
|
pass
|
|
425
467
|
|
invar/core/lambda_helpers.py
CHANGED
|
@@ -8,6 +8,7 @@ import re
|
|
|
8
8
|
from deal import post, pre
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
@pre(lambda tree: isinstance(tree, ast.AST))
|
|
11
12
|
@post(lambda result: result is None or isinstance(result, ast.Lambda))
|
|
12
13
|
def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
|
|
13
14
|
"""Find the lambda node in an expression tree.
|
invar/core/models.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from enum import Enum
|
|
11
11
|
from typing import Literal
|
|
12
12
|
|
|
13
|
-
from deal import pre
|
|
13
|
+
from deal import post, pre
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
15
15
|
|
|
16
16
|
|
|
@@ -133,7 +133,7 @@ class GuardReport(BaseModel):
|
|
|
133
133
|
self.core_functions_with_contracts += with_contracts
|
|
134
134
|
|
|
135
135
|
@property
|
|
136
|
-
@
|
|
136
|
+
@post(lambda result: 0 <= result <= 100)
|
|
137
137
|
def contract_coverage_pct(self) -> int:
|
|
138
138
|
"""
|
|
139
139
|
Get contract coverage percentage (P24).
|
|
@@ -150,7 +150,7 @@ class GuardReport(BaseModel):
|
|
|
150
150
|
return int(self.core_functions_with_contracts / self.core_functions_total * 100)
|
|
151
151
|
|
|
152
152
|
@property
|
|
153
|
-
@
|
|
153
|
+
@post(lambda result: all(k in result for k in ("tautology", "empty", "partial", "type_only")))
|
|
154
154
|
def contract_issue_counts(self) -> dict[str, int]:
|
|
155
155
|
"""
|
|
156
156
|
Count contract quality issues by type (P24).
|
|
@@ -178,7 +178,7 @@ class GuardReport(BaseModel):
|
|
|
178
178
|
return counts
|
|
179
179
|
|
|
180
180
|
@property
|
|
181
|
-
@
|
|
181
|
+
@post(lambda result: isinstance(result, bool))
|
|
182
182
|
def passed(self) -> bool:
|
|
183
183
|
"""
|
|
184
184
|
Check if guard passed (no errors).
|
|
@@ -218,10 +218,18 @@ class RuleConfig(BaseModel):
|
|
|
218
218
|
500
|
|
219
219
|
>>> config.strict_pure # Phase 9 P12: Default ON for agents
|
|
220
220
|
True
|
|
221
|
+
>>> # MINOR-6: Value ranges validated
|
|
222
|
+
>>> RuleConfig(max_file_lines=0) # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
223
|
+
Traceback (most recent call last):
|
|
224
|
+
pydantic_core._pydantic_core.ValidationError: ...
|
|
221
225
|
"""
|
|
222
226
|
|
|
223
|
-
|
|
224
|
-
|
|
227
|
+
# MINOR-6: Added ge=1 constraints for numeric fields
|
|
228
|
+
max_file_lines: int = Field(default=500, ge=1) # Phase 9 P1: Raised from 300
|
|
229
|
+
max_function_lines: int = Field(default=50, ge=1)
|
|
230
|
+
entry_max_lines: int = Field(default=15, ge=1) # DX-23: Entry point max lines
|
|
231
|
+
shell_max_branches: int = Field(default=3, ge=1) # DX-22: Shell function max branches
|
|
232
|
+
shell_complexity_debt_limit: int = Field(default=5, ge=0) # DX-22: 0 = no limit
|
|
225
233
|
forbidden_imports: tuple[str, ...] = (
|
|
226
234
|
"os",
|
|
227
235
|
"sys",
|
|
@@ -236,18 +244,15 @@ class RuleConfig(BaseModel):
|
|
|
236
244
|
require_contracts: bool = True
|
|
237
245
|
require_doctests: bool = True
|
|
238
246
|
strict_pure: bool = True # Phase 9 P12: Default ON for agent-native
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
# DX-22: Removed use_code_lines and exclude_doctest_lines
|
|
248
|
+
# (merged into default behavior - always exclude doctest lines from size calc)
|
|
241
249
|
# Phase 9 P1: Rule exclusions for specific file patterns
|
|
242
250
|
rule_exclusions: list[RuleExclusion] = Field(default_factory=list)
|
|
243
251
|
# Phase 9 P2: Per-rule severity overrides (off, info, warning, error)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
"redundant_type_contract": "off", # Expected behavior when forcing contracts
|
|
247
|
-
}
|
|
248
|
-
)
|
|
252
|
+
# DX-22: Simplified defaults - most rules have correct severity now
|
|
253
|
+
severity_overrides: dict[str, str] = Field(default_factory=dict)
|
|
249
254
|
# Phase 9 P8: File size warning threshold (0 to disable, 0.8 = warn at 80%)
|
|
250
|
-
size_warning_threshold: float = 0.8
|
|
255
|
+
size_warning_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
|
|
251
256
|
# B4: User-declared purity (override heuristics)
|
|
252
257
|
purity_pure: list[str] = Field(default_factory=list) # Known pure functions
|
|
253
258
|
purity_impure: list[str] = Field(default_factory=list) # Known impure functions
|
|
@@ -283,7 +288,8 @@ class PerceptionMap(BaseModel):
|
|
|
283
288
|
5
|
|
284
289
|
"""
|
|
285
290
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
291
|
+
# MINOR-7: Added field validators
|
|
292
|
+
project_root: str = Field(min_length=1)
|
|
293
|
+
total_files: int = Field(ge=0)
|
|
294
|
+
total_symbols: int = Field(ge=0)
|
|
289
295
|
symbols: list[SymbolRefs] = Field(default_factory=list)
|
invar/core/parser.py
CHANGED
|
@@ -21,13 +21,13 @@ from invar.core.purity import (
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
@pre(lambda source, path="<string>": isinstance(source, str) and len(source) > 0)
|
|
24
|
+
@pre(lambda source, path="<string>": isinstance(source, str) and len(source.strip()) > 0)
|
|
25
25
|
def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
|
|
26
26
|
"""
|
|
27
27
|
Parse Python source code and extract symbols.
|
|
28
28
|
|
|
29
29
|
Args:
|
|
30
|
-
source: Python source code as string
|
|
30
|
+
source: Python source code as string (must contain non-whitespace)
|
|
31
31
|
path: Path for reporting (not used for I/O)
|
|
32
32
|
|
|
33
33
|
Returns:
|
|
@@ -41,6 +41,10 @@ def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
|
|
|
41
41
|
1
|
|
42
42
|
>>> info.symbols[0].name
|
|
43
43
|
'foo'
|
|
44
|
+
>>> parse_source(" \\n\\t ") # Whitespace-only returns None via contract
|
|
45
|
+
Traceback (most recent call last):
|
|
46
|
+
...
|
|
47
|
+
deal.PreContractError: ...
|
|
44
48
|
"""
|
|
45
49
|
try:
|
|
46
50
|
tree = ast.parse(source)
|
invar/core/property_gen.py
CHANGED
|
@@ -19,13 +19,18 @@ if TYPE_CHECKING:
|
|
|
19
19
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class PropertyTestResult:
|
|
22
|
-
"""Result of running a property test.
|
|
22
|
+
"""Result of running a property test.
|
|
23
|
+
|
|
24
|
+
DX-26: Added file_path and seed for actionable failure output.
|
|
25
|
+
"""
|
|
23
26
|
|
|
24
27
|
function_name: str
|
|
25
28
|
passed: bool
|
|
26
29
|
examples_run: int = 0
|
|
27
30
|
counterexample: dict[str, Any] | None = None
|
|
28
31
|
error: str | None = None
|
|
32
|
+
file_path: str | None = None # DX-26: For file::function format
|
|
33
|
+
seed: int | None = None # DX-26: Hypothesis seed for reproduction
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
@dataclass
|
|
@@ -300,6 +305,10 @@ def build_test_function(
|
|
|
300
305
|
# Build strategy dict
|
|
301
306
|
strategy_dict = {}
|
|
302
307
|
for param_name, strat_code in strategies.items():
|
|
308
|
+
# Skip functions with nothing() strategy (untestable types)
|
|
309
|
+
if "nothing()" in strat_code:
|
|
310
|
+
return None
|
|
311
|
+
|
|
303
312
|
# Evaluate the strategy code
|
|
304
313
|
try:
|
|
305
314
|
# strat_code is like "st.integers(min_value=0)"
|
|
@@ -322,16 +331,62 @@ def build_test_function(
|
|
|
322
331
|
return property_test
|
|
323
332
|
|
|
324
333
|
|
|
325
|
-
@pre(lambda
|
|
334
|
+
@pre(lambda error_str: isinstance(error_str, str))
|
|
335
|
+
@post(lambda result: result is None or isinstance(result, int))
|
|
336
|
+
def _extract_hypothesis_seed(error_str: str) -> int | None:
|
|
337
|
+
"""Extract Hypothesis seed from error message (DX-26).
|
|
338
|
+
|
|
339
|
+
Hypothesis includes seed in output like: @seed(336048909179393285647920446708996038674)
|
|
340
|
+
|
|
341
|
+
>>> _extract_hypothesis_seed("@seed(123456)")
|
|
342
|
+
123456
|
|
343
|
+
>>> _extract_hypothesis_seed("no seed here") is None
|
|
344
|
+
True
|
|
345
|
+
"""
|
|
346
|
+
import re
|
|
347
|
+
|
|
348
|
+
match = re.search(r"@seed\((\d+)\)", error_str)
|
|
349
|
+
if match:
|
|
350
|
+
try:
|
|
351
|
+
return int(match.group(1))
|
|
352
|
+
except ValueError:
|
|
353
|
+
pass
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@pre(lambda name, reason: isinstance(name, str) and isinstance(reason, str))
|
|
358
|
+
@post(lambda result: isinstance(result, PropertyTestResult) and result.passed)
|
|
359
|
+
def _skip_result(name: str, reason: str) -> PropertyTestResult:
|
|
360
|
+
"""Create a skip result (passed=True, 0 examples)."""
|
|
361
|
+
return PropertyTestResult(function_name=name, passed=True, examples_run=0, error=reason)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Skip patterns for untestable error detection
|
|
365
|
+
_SKIP_PATTERNS = (
|
|
366
|
+
"Nothing", "NoSuchExample", "filter_too_much", "Could not resolve",
|
|
367
|
+
"validation error", "missing", "positional argument", "Unable to satisfy",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@pre(lambda err_str, func_name, max_examples: isinstance(err_str, str))
|
|
326
372
|
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
327
|
-
def
|
|
328
|
-
|
|
329
|
-
max_examples: int = 100,
|
|
373
|
+
def _handle_test_exception(
|
|
374
|
+
err_str: str, func_name: str, max_examples: int
|
|
330
375
|
) -> PropertyTestResult:
|
|
376
|
+
"""Handle exception from property test, returning skip or failure result."""
|
|
377
|
+
if any(p in err_str for p in _SKIP_PATTERNS):
|
|
378
|
+
return _skip_result(func_name, "Skipped: untestable types")
|
|
379
|
+
seed = _extract_hypothesis_seed(err_str)
|
|
380
|
+
return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pre(lambda func, max_examples: callable(func) and max_examples > 0)
|
|
384
|
+
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
385
|
+
def run_property_test(func: Callable, max_examples: int = 100) -> PropertyTestResult:
|
|
331
386
|
"""
|
|
332
387
|
Run a property test on a single function.
|
|
333
388
|
|
|
334
|
-
|
|
389
|
+
Uses deal.cases() which respects @pre conditions and generates valid inputs.
|
|
335
390
|
|
|
336
391
|
>>> from deal import pre, post
|
|
337
392
|
>>> @pre(lambda x: x >= 0)
|
|
@@ -344,40 +399,26 @@ def run_property_test(
|
|
|
344
399
|
"""
|
|
345
400
|
func_name = getattr(func, "__name__", "unknown")
|
|
346
401
|
|
|
347
|
-
# Generate test
|
|
348
|
-
generated = generate_property_test(func)
|
|
349
|
-
if generated is None:
|
|
350
|
-
return PropertyTestResult(
|
|
351
|
-
function_name=func_name,
|
|
352
|
-
passed=True, # No test generated = skip, not fail
|
|
353
|
-
examples_run=0,
|
|
354
|
-
error="Could not generate test (no contracts or unparseable)",
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
# Build executable test
|
|
358
|
-
test_fn = build_test_function(func, generated.strategies, max_examples)
|
|
359
|
-
if test_fn is None:
|
|
360
|
-
return PropertyTestResult(
|
|
361
|
-
function_name=func_name,
|
|
362
|
-
passed=True,
|
|
363
|
-
examples_run=0,
|
|
364
|
-
error="Could not build test (hypothesis not available or strategy error)",
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
# Run the test
|
|
368
402
|
try:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
403
|
+
import deal
|
|
404
|
+
from hypothesis import HealthCheck, Verbosity, settings
|
|
405
|
+
|
|
406
|
+
# DX-26: Suppress Hypothesis output (seed messages) for clean JSON
|
|
407
|
+
test_settings = settings(
|
|
408
|
+
max_examples=max_examples,
|
|
409
|
+
suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow],
|
|
410
|
+
verbosity=Verbosity.quiet,
|
|
374
411
|
)
|
|
412
|
+
test_case = deal.cases(func, count=max_examples, settings=test_settings)
|
|
413
|
+
test_case()
|
|
414
|
+
return PropertyTestResult(func_name, passed=True, examples_run=max_examples)
|
|
415
|
+
except deal.PreContractError:
|
|
416
|
+
return _skip_result(func_name, "Skipped: could not generate valid inputs")
|
|
417
|
+
except deal.PostContractError as e:
|
|
418
|
+
err_str = str(e)
|
|
419
|
+
seed = _extract_hypothesis_seed(err_str)
|
|
420
|
+
return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
|
|
421
|
+
except ImportError:
|
|
422
|
+
pass # Fall through to custom strategy approach
|
|
375
423
|
except Exception as e:
|
|
376
|
-
|
|
377
|
-
error_str = str(e)
|
|
378
|
-
return PropertyTestResult(
|
|
379
|
-
function_name=func_name,
|
|
380
|
-
passed=False,
|
|
381
|
-
examples_run=max_examples,
|
|
382
|
-
error=error_str,
|
|
383
|
-
)
|
|
424
|
+
return _handle_test_exception(str(e), func_name, max_examples)
|
invar/core/purity.py
CHANGED
|
@@ -18,11 +18,13 @@ from deal import pre
|
|
|
18
18
|
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
19
19
|
|
|
20
20
|
# Known impure functions and method patterns
|
|
21
|
+
# MINOR-3: "time" matches `from time import time; time()` which IS impure.
|
|
22
|
+
# May false positive on local functions named `time`, but this is rare.
|
|
21
23
|
IMPURE_FUNCTIONS: set[str] = {
|
|
22
24
|
"now",
|
|
23
25
|
"today",
|
|
24
26
|
"utcnow",
|
|
25
|
-
"time",
|
|
27
|
+
"time", # from time import time
|
|
26
28
|
"random",
|
|
27
29
|
"randint",
|
|
28
30
|
"randrange",
|
|
@@ -155,7 +157,12 @@ def extract_function_calls(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list
|
|
|
155
157
|
|
|
156
158
|
@pre(lambda call: isinstance(call, ast.Call) and hasattr(call, "func"))
|
|
157
159
|
def _get_call_name(call: ast.Call) -> str | None:
|
|
158
|
-
"""Get the name of a function call as a string.
|
|
160
|
+
"""Get the name of a function call as a string.
|
|
161
|
+
|
|
162
|
+
MINOR-4 Limitation: Only handles one level of attribute access (obj.method).
|
|
163
|
+
Chained access like a.b.method() returns None. This is acceptable since
|
|
164
|
+
IMPURE_PATTERNS only contains two-level patterns like ("datetime", "now").
|
|
165
|
+
"""
|
|
159
166
|
func = call.func
|
|
160
167
|
|
|
161
168
|
# Simple name: print(), open()
|
|
@@ -268,8 +275,7 @@ def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
|
268
275
|
count += 1 # Continuation line
|
|
269
276
|
elif in_doctest and stripped and not stripped.startswith(">>>"):
|
|
270
277
|
count += 1 # Expected output line
|
|
271
|
-
|
|
272
|
-
in_doctest = False
|
|
278
|
+
# Note: Empty line ends doctest, handled by else branch below
|
|
273
279
|
else:
|
|
274
280
|
in_doctest = False
|
|
275
281
|
return count
|