invar-tools 1.0.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 +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- 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 +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- 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.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
@post(lambda result: all(isinstance(line, str) and line.strip() for line in result)) # Non-empty strings
|
|
178
|
+
def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
|
|
179
|
+
"""
|
|
180
|
+
Extract lines matching a format specification.
|
|
181
|
+
|
|
182
|
+
This is a reference implementation showing how FormatSpec
|
|
183
|
+
can be used to create format-aware extraction.
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
>>> spec = CrossHairOutputSpec()
|
|
187
|
+
>>> text = "info\\nfile.py:1: error: Bug\\nok"
|
|
188
|
+
>>> extract_by_format(text, spec)
|
|
189
|
+
['file.py:1: error: Bug']
|
|
190
|
+
"""
|
|
191
|
+
return [
|
|
192
|
+
line.strip()
|
|
193
|
+
for line in text.split("\n")
|
|
194
|
+
if line.strip() and spec.error_marker in line.lower()
|
|
195
|
+
]
|
|
@@ -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
|
@@ -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,12 +199,18 @@ 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:
|
|
204
|
-
def format_guard_agent(report: GuardReport) -> dict:
|
|
202
|
+
@pre(lambda report, combined_status=None: combined_status is None or combined_status in ("passed", "failed"))
|
|
203
|
+
def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
|
|
205
204
|
"""
|
|
206
|
-
Format Guard report for Agent consumption (Phase 8.2).
|
|
205
|
+
Format Guard report for Agent consumption (Phase 8.2 + DX-26).
|
|
207
206
|
|
|
208
207
|
Provides structured output with actionable fix instructions.
|
|
208
|
+
DX-26: status now reflects ALL test phases when combined_status is provided.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
report: Guard analysis report
|
|
212
|
+
combined_status: True guard status including all test phases (DX-26).
|
|
213
|
+
If None, uses report.passed (static-only, deprecated).
|
|
209
214
|
|
|
210
215
|
Examples:
|
|
211
216
|
>>> from invar.core.models import GuardReport, Violation, Severity
|
|
@@ -219,9 +224,26 @@ def format_guard_agent(report: GuardReport) -> dict:
|
|
|
219
224
|
'passed'
|
|
220
225
|
>>> len(d["fixes"])
|
|
221
226
|
1
|
|
227
|
+
>>> # DX-26: combined_status overrides report.passed
|
|
228
|
+
>>> d2 = format_guard_agent(report, combined_status="failed")
|
|
229
|
+
>>> d2["status"]
|
|
230
|
+
'failed'
|
|
231
|
+
>>> d2["static"]["passed"] # Static still shows passed
|
|
232
|
+
True
|
|
222
233
|
"""
|
|
234
|
+
# DX-26: Use combined status if provided, else fall back to static-only
|
|
235
|
+
status = combined_status if combined_status else ("passed" if report.passed else "failed")
|
|
236
|
+
static_passed = report.errors == 0
|
|
237
|
+
|
|
223
238
|
return {
|
|
224
|
-
"status":
|
|
239
|
+
"status": status,
|
|
240
|
+
# DX-26: Separate static results from combined status
|
|
241
|
+
"static": {
|
|
242
|
+
"passed": static_passed,
|
|
243
|
+
"errors": report.errors,
|
|
244
|
+
"warnings": report.warnings,
|
|
245
|
+
"infos": report.infos,
|
|
246
|
+
},
|
|
225
247
|
"summary": {
|
|
226
248
|
"files_checked": report.files_checked,
|
|
227
249
|
"errors": report.errors,
|
|
@@ -232,7 +254,7 @@ def format_guard_agent(report: GuardReport) -> dict:
|
|
|
232
254
|
}
|
|
233
255
|
|
|
234
256
|
|
|
235
|
-
@
|
|
257
|
+
@post(lambda result: "file" in result and "rule" in result and "severity" in result)
|
|
236
258
|
def _violation_to_fix(v: Violation) -> dict:
|
|
237
259
|
"""Convert a Violation to an Agent-friendly fix instruction."""
|
|
238
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,6 +402,50 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
|
|
|
402
402
|
return user_specs
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
@post(lambda result: all("lambda" in s for s in result)) # Only lambda expressions
|
|
406
|
+
def _extract_pre_lambdas_from_source(source: str) -> list[str]:
|
|
407
|
+
"""
|
|
408
|
+
Extract lambda expressions from @pre decorators with balanced parenthesis.
|
|
409
|
+
|
|
410
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x: x > 0)")
|
|
411
|
+
['lambda x: x > 0']
|
|
412
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x: len(x) > 0)")
|
|
413
|
+
['lambda x: len(x) > 0']
|
|
414
|
+
>>> _extract_pre_lambdas_from_source("@pre(lambda x, y: isinstance(x, str))")
|
|
415
|
+
['lambda x, y: isinstance(x, str)']
|
|
416
|
+
>>> _extract_pre_lambdas_from_source("")
|
|
417
|
+
[]
|
|
418
|
+
"""
|
|
419
|
+
results = []
|
|
420
|
+
i = 0
|
|
421
|
+
while i < len(source):
|
|
422
|
+
# Find @pre(
|
|
423
|
+
pre_match = re.search(r"@pre\s*\(", source[i:])
|
|
424
|
+
if not pre_match:
|
|
425
|
+
break
|
|
426
|
+
start = i + pre_match.end()
|
|
427
|
+
|
|
428
|
+
# Find matching closing paren with balance counting
|
|
429
|
+
paren_depth = 1
|
|
430
|
+
j = start
|
|
431
|
+
while j < len(source) and paren_depth > 0:
|
|
432
|
+
if source[j] == "(":
|
|
433
|
+
paren_depth += 1
|
|
434
|
+
elif source[j] == ")":
|
|
435
|
+
paren_depth -= 1
|
|
436
|
+
j += 1
|
|
437
|
+
|
|
438
|
+
if paren_depth == 0:
|
|
439
|
+
# Extract content between @pre( and matching )
|
|
440
|
+
content = source[start : j - 1].strip()
|
|
441
|
+
if content.startswith("lambda"):
|
|
442
|
+
results.append(content)
|
|
443
|
+
|
|
444
|
+
i = j
|
|
445
|
+
|
|
446
|
+
return results
|
|
447
|
+
|
|
448
|
+
|
|
405
449
|
@pre(lambda func: callable(func))
|
|
406
450
|
@post(lambda result: isinstance(result, list))
|
|
407
451
|
def _extract_pre_sources(func: Callable) -> list[str]:
|
|
@@ -413,21 +457,17 @@ def _extract_pre_sources(func: Callable) -> list[str]:
|
|
|
413
457
|
# deal stores contracts in _deal attribute
|
|
414
458
|
pass
|
|
415
459
|
|
|
416
|
-
# Try to extract from source
|
|
460
|
+
# Try to extract from source using balanced parenthesis matching
|
|
417
461
|
try:
|
|
418
462
|
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)
|
|
463
|
+
pre_sources.extend(_extract_pre_lambdas_from_source(source))
|
|
423
464
|
except (OSError, TypeError):
|
|
424
465
|
pass
|
|
425
466
|
|
|
426
467
|
return pre_sources
|
|
427
468
|
|
|
428
469
|
|
|
429
|
-
@
|
|
430
|
-
@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))
|
|
431
471
|
def _bounds_to_strategy_kwargs(bounds: dict[str, Any], strategy_name: str) -> dict[str, Any]:
|
|
432
472
|
"""Convert bound constraints to Hypothesis strategy kwargs."""
|
|
433
473
|
kwargs = {}
|
invar/core/inspect.py
CHANGED
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) and hasattr(tree, '__class__'))
|
|
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.
|
|
@@ -51,8 +52,7 @@ def extract_annotations(signature: str) -> dict[str, str]:
|
|
|
51
52
|
return annotations
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
@
|
|
55
|
-
@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
|
|
56
56
|
def extract_lambda_params(expression: str) -> list[str] | None:
|
|
57
57
|
"""Extract parameter names from a lambda expression.
|
|
58
58
|
|
|
@@ -114,6 +114,7 @@ def extract_func_param_names(signature: str) -> list[str] | None:
|
|
|
114
114
|
return params
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
@pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
|
|
117
118
|
@post(lambda result: isinstance(result, set))
|
|
118
119
|
def extract_used_names(node: ast.expr) -> set[str]:
|
|
119
120
|
"""Extract all variable names used in an expression (Load context).
|