invar-tools 1.0.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 +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hypothesis strategy generation from type annotations and @pre contracts.
|
|
3
|
+
|
|
4
|
+
Core module: converts Python types and @pre bounds to Hypothesis strategies.
|
|
5
|
+
Part of DX-12: Hypothesis as CrossHair fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any, get_args, get_origin, get_type_hints
|
|
14
|
+
|
|
15
|
+
from deal import post, pre
|
|
16
|
+
|
|
17
|
+
# Note: inspect and re are still used by _extract_pre_sources
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
# Lazy import to avoid dependency issues
|
|
23
|
+
_hypothesis_available = False
|
|
24
|
+
_numpy_available = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@post(lambda result: isinstance(result, bool))
|
|
28
|
+
def _ensure_hypothesis() -> bool:
|
|
29
|
+
"""Check if hypothesis is available."""
|
|
30
|
+
global _hypothesis_available
|
|
31
|
+
try:
|
|
32
|
+
import hypothesis # noqa: F401
|
|
33
|
+
|
|
34
|
+
_hypothesis_available = True
|
|
35
|
+
return True
|
|
36
|
+
except ImportError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@post(lambda result: isinstance(result, bool))
|
|
41
|
+
def _ensure_numpy() -> bool:
|
|
42
|
+
"""Check if numpy is available."""
|
|
43
|
+
global _numpy_available
|
|
44
|
+
try:
|
|
45
|
+
import numpy # noqa: F401
|
|
46
|
+
|
|
47
|
+
_numpy_available = True
|
|
48
|
+
return True
|
|
49
|
+
except ImportError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Re-export timeout inference for backwards compatibility
|
|
54
|
+
from invar.core.timeout_inference import ( # noqa: F401
|
|
55
|
+
LIBRARY_BLACKLIST,
|
|
56
|
+
TIMEOUT_TIERS,
|
|
57
|
+
TimeoutTier,
|
|
58
|
+
infer_timeout,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# ============================================================
|
|
62
|
+
# Type-Based Strategy Generation
|
|
63
|
+
# ============================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class StrategySpec:
|
|
68
|
+
"""Specification for a Hypothesis strategy.
|
|
69
|
+
|
|
70
|
+
DX-12-B: Added raw_code field for user-defined strategies.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
strategy_name: str
|
|
74
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
description: str = ""
|
|
76
|
+
raw_code: str | None = None # DX-12-B: For custom @strategy decorator
|
|
77
|
+
|
|
78
|
+
@post(lambda result: isinstance(result, str) and result.startswith("st."))
|
|
79
|
+
def to_code(self) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Generate Hypothesis strategy code.
|
|
82
|
+
|
|
83
|
+
>>> spec = StrategySpec("integers", {"min_value": 0, "max_value": 100})
|
|
84
|
+
>>> spec.to_code()
|
|
85
|
+
'st.integers(min_value=0, max_value=100)'
|
|
86
|
+
|
|
87
|
+
>>> custom = StrategySpec("custom", raw_code="st.floats(min_value=0)")
|
|
88
|
+
>>> custom.to_code()
|
|
89
|
+
'st.floats(min_value=0)'
|
|
90
|
+
"""
|
|
91
|
+
# DX-12-B: Return raw code if provided and valid (user-defined strategy)
|
|
92
|
+
if self.raw_code and self.raw_code.startswith("st."):
|
|
93
|
+
return self.raw_code
|
|
94
|
+
if not self.kwargs:
|
|
95
|
+
return f"st.{self.strategy_name}()"
|
|
96
|
+
args = ", ".join(f"{k}={v!r}" for k, v in self.kwargs.items())
|
|
97
|
+
return f"st.{self.strategy_name}({args})"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Type to strategy mapping
|
|
101
|
+
TYPE_STRATEGIES: dict[type, StrategySpec] = {
|
|
102
|
+
int: StrategySpec("integers", {}, "Any integer"),
|
|
103
|
+
float: StrategySpec(
|
|
104
|
+
"floats",
|
|
105
|
+
{"allow_nan": False, "allow_infinity": False},
|
|
106
|
+
"Finite floats",
|
|
107
|
+
),
|
|
108
|
+
str: StrategySpec("text", {"max_size": 100}, "Text up to 100 chars"),
|
|
109
|
+
bool: StrategySpec("booleans", {}, "True or False"),
|
|
110
|
+
bytes: StrategySpec("binary", {"max_size": 100}, "Bytes up to 100"),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@post(lambda result: isinstance(result, StrategySpec))
|
|
115
|
+
def _strategy_for_list(args: tuple, strategy_fn: Callable) -> StrategySpec:
|
|
116
|
+
"""Generate strategy for list type."""
|
|
117
|
+
element_type = args[0] if args else int
|
|
118
|
+
element_strategy = strategy_fn(element_type)
|
|
119
|
+
type_name = element_type.__name__ if hasattr(element_type, "__name__") else element_type
|
|
120
|
+
return StrategySpec("lists", {"elements": element_strategy.to_code()}, f"Lists of {type_name}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@post(lambda result: isinstance(result, StrategySpec))
|
|
124
|
+
def _strategy_for_dict(args: tuple, strategy_fn: Callable) -> StrategySpec:
|
|
125
|
+
"""Generate strategy for dict type."""
|
|
126
|
+
key_type = args[0] if len(args) > 0 else str
|
|
127
|
+
val_type = args[1] if len(args) > 1 else int
|
|
128
|
+
return StrategySpec(
|
|
129
|
+
"dictionaries",
|
|
130
|
+
{"keys": strategy_fn(key_type).to_code(), "values": strategy_fn(val_type).to_code()},
|
|
131
|
+
f"Dict[{key_type}, {val_type}]",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@post(lambda result: isinstance(result, StrategySpec))
|
|
136
|
+
def _strategy_for_set(args: tuple, strategy_fn: Callable) -> StrategySpec:
|
|
137
|
+
"""Generate strategy for set type."""
|
|
138
|
+
element_type = args[0] if args else int
|
|
139
|
+
return StrategySpec("frozensets", {"elements": strategy_fn(element_type).to_code()}, f"Sets of {element_type}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@post(lambda result: result is None or isinstance(result, StrategySpec))
|
|
143
|
+
def _strategy_for_numpy(hint: type) -> StrategySpec | None:
|
|
144
|
+
"""Generate strategy for numpy array type, or None if not numpy."""
|
|
145
|
+
if not _ensure_numpy():
|
|
146
|
+
return None
|
|
147
|
+
import numpy as np
|
|
148
|
+
|
|
149
|
+
if hint is np.ndarray or (hasattr(hint, "__name__") and "ndarray" in str(hint)):
|
|
150
|
+
return StrategySpec(
|
|
151
|
+
"arrays",
|
|
152
|
+
{"dtype": "np.float64", "shape": "st.integers(1, 100)", "elements": "st.floats(-1e6, 1e6, allow_nan=False)"},
|
|
153
|
+
"NumPy float64 array",
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pre(lambda hint: hint is not None)
|
|
159
|
+
@post(lambda result: isinstance(result, StrategySpec))
|
|
160
|
+
def strategy_from_type(hint: type) -> StrategySpec:
|
|
161
|
+
"""
|
|
162
|
+
Generate Hypothesis strategy specification from type annotation.
|
|
163
|
+
|
|
164
|
+
>>> strategy_from_type(int).strategy_name
|
|
165
|
+
'integers'
|
|
166
|
+
|
|
167
|
+
>>> strategy_from_type(float).kwargs['allow_nan']
|
|
168
|
+
False
|
|
169
|
+
|
|
170
|
+
>>> strategy_from_type(list).strategy_name
|
|
171
|
+
'lists'
|
|
172
|
+
"""
|
|
173
|
+
# Direct type match
|
|
174
|
+
if hint in TYPE_STRATEGIES:
|
|
175
|
+
return TYPE_STRATEGIES[hint]
|
|
176
|
+
|
|
177
|
+
# Handle generic types
|
|
178
|
+
origin = get_origin(hint)
|
|
179
|
+
args = get_args(hint)
|
|
180
|
+
|
|
181
|
+
# Handle bare container types (without type args)
|
|
182
|
+
if hint is list:
|
|
183
|
+
return StrategySpec("lists", {"elements": "st.integers()"}, "Lists of int")
|
|
184
|
+
if hint is dict:
|
|
185
|
+
return StrategySpec("dictionaries", {"keys": "st.text()", "values": "st.integers()"}, "Dict")
|
|
186
|
+
if hint is tuple:
|
|
187
|
+
return StrategySpec("tuples", {}, "Tuple")
|
|
188
|
+
if hint is set:
|
|
189
|
+
return StrategySpec("frozensets", {"elements": "st.integers()"}, "Set of int")
|
|
190
|
+
|
|
191
|
+
# Handle generic container types
|
|
192
|
+
if origin is list:
|
|
193
|
+
return _strategy_for_list(args, strategy_from_type)
|
|
194
|
+
if origin is dict:
|
|
195
|
+
return _strategy_for_dict(args, strategy_from_type)
|
|
196
|
+
if origin is set:
|
|
197
|
+
return _strategy_for_set(args, strategy_from_type)
|
|
198
|
+
if origin is tuple:
|
|
199
|
+
if args:
|
|
200
|
+
element_specs = [strategy_from_type(a).to_code() for a in args]
|
|
201
|
+
return StrategySpec("tuples", {"*args": element_specs}, f"Tuple{args}")
|
|
202
|
+
return StrategySpec("tuples", {}, "Empty tuple")
|
|
203
|
+
|
|
204
|
+
# Check for numpy array
|
|
205
|
+
if (numpy_spec := _strategy_for_numpy(hint)) is not None:
|
|
206
|
+
return numpy_spec
|
|
207
|
+
|
|
208
|
+
# Fallback to nothing for unknown types
|
|
209
|
+
return StrategySpec("nothing", {}, f"Unknown type: {hint}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pre(lambda func: callable(func))
|
|
213
|
+
@post(lambda result: isinstance(result, dict))
|
|
214
|
+
def strategies_from_signature(func: Callable) -> dict[str, StrategySpec]:
|
|
215
|
+
"""
|
|
216
|
+
Generate strategies for all parameters from function signature.
|
|
217
|
+
|
|
218
|
+
>>> def example(x: int, y: float) -> bool: return x > y
|
|
219
|
+
>>> specs = strategies_from_signature(example)
|
|
220
|
+
>>> specs['x'].strategy_name
|
|
221
|
+
'integers'
|
|
222
|
+
>>> specs['y'].strategy_name
|
|
223
|
+
'floats'
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
hints = get_type_hints(func)
|
|
227
|
+
except Exception:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
result = {}
|
|
231
|
+
for name, hint in hints.items():
|
|
232
|
+
if name == "return":
|
|
233
|
+
continue
|
|
234
|
+
result[name] = strategy_from_type(hint)
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ============================================================
|
|
240
|
+
# Bound Refinement
|
|
241
|
+
# ============================================================
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@post(lambda result: isinstance(result, StrategySpec))
|
|
245
|
+
def refine_strategy(base: StrategySpec, **kwargs: Any) -> StrategySpec:
|
|
246
|
+
"""
|
|
247
|
+
Refine a base strategy with additional constraints.
|
|
248
|
+
|
|
249
|
+
>>> base = StrategySpec("floats", {"allow_nan": False})
|
|
250
|
+
>>> refined = refine_strategy(base, min_value=0, max_value=1)
|
|
251
|
+
>>> refined.kwargs['min_value']
|
|
252
|
+
0
|
|
253
|
+
>>> refined.kwargs['max_value']
|
|
254
|
+
1
|
|
255
|
+
"""
|
|
256
|
+
merged_kwargs = {**base.kwargs, **kwargs}
|
|
257
|
+
|
|
258
|
+
# Handle exclude_min/exclude_max for integers (not supported)
|
|
259
|
+
if base.strategy_name == "integers":
|
|
260
|
+
if merged_kwargs.pop("exclude_min", False) and "min_value" in merged_kwargs:
|
|
261
|
+
merged_kwargs["min_value"] += 1
|
|
262
|
+
if merged_kwargs.pop("exclude_max", False) and "max_value" in merged_kwargs:
|
|
263
|
+
merged_kwargs["max_value"] -= 1
|
|
264
|
+
|
|
265
|
+
return StrategySpec(
|
|
266
|
+
strategy_name=base.strategy_name,
|
|
267
|
+
kwargs=merged_kwargs,
|
|
268
|
+
description=f"{base.description} (refined)",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ============================================================
|
|
273
|
+
# Integration with existing strategies.py
|
|
274
|
+
# ============================================================
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@pre(lambda func: callable(func))
|
|
278
|
+
@post(lambda result: isinstance(result, dict))
|
|
279
|
+
def infer_strategies_for_function(func: Callable) -> dict[str, StrategySpec]:
|
|
280
|
+
"""
|
|
281
|
+
Infer complete strategies for a function from types and @pre contracts.
|
|
282
|
+
|
|
283
|
+
This combines:
|
|
284
|
+
1. User-defined @strategy decorator (DX-12-B) - highest priority
|
|
285
|
+
2. Type-based strategy generation
|
|
286
|
+
3. @pre contract bound extraction (via strategies.infer_from_lambda)
|
|
287
|
+
|
|
288
|
+
>>> def constrained(x: float) -> float:
|
|
289
|
+
... '''Requires x > 0.'''
|
|
290
|
+
... return x ** 0.5
|
|
291
|
+
>>> specs = infer_strategies_for_function(constrained)
|
|
292
|
+
>>> specs['x'].strategy_name
|
|
293
|
+
'floats'
|
|
294
|
+
"""
|
|
295
|
+
type_specs = strategies_from_signature(func)
|
|
296
|
+
user_strategies = _get_user_strategies(func)
|
|
297
|
+
pre_sources = _extract_pre_sources(func)
|
|
298
|
+
|
|
299
|
+
if not pre_sources and not user_strategies:
|
|
300
|
+
return type_specs
|
|
301
|
+
|
|
302
|
+
# Refine each parameter strategy
|
|
303
|
+
result = _refine_all_strategies(func, type_specs, user_strategies, pre_sources)
|
|
304
|
+
|
|
305
|
+
# Add any user strategies for params not in type_specs
|
|
306
|
+
for param_name, spec in user_strategies.items():
|
|
307
|
+
if param_name not in result:
|
|
308
|
+
result[param_name] = spec
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@pre(lambda func, type_specs, user_strategies, pre_sources: callable(func))
|
|
314
|
+
@post(lambda result: isinstance(result, dict))
|
|
315
|
+
def _refine_all_strategies(
|
|
316
|
+
func: Callable,
|
|
317
|
+
type_specs: dict[str, StrategySpec],
|
|
318
|
+
user_strategies: dict[str, StrategySpec],
|
|
319
|
+
pre_sources: list[str],
|
|
320
|
+
) -> dict[str, StrategySpec]:
|
|
321
|
+
"""Refine type-based strategies with @pre bounds and user overrides."""
|
|
322
|
+
from invar.core.strategies import infer_from_lambda
|
|
323
|
+
|
|
324
|
+
result: dict[str, StrategySpec] = {}
|
|
325
|
+
for param_name, spec in type_specs.items():
|
|
326
|
+
# DX-12-B: User-defined strategy takes highest priority
|
|
327
|
+
if param_name in user_strategies:
|
|
328
|
+
result[param_name] = user_strategies[param_name]
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
# Infer bounds from @pre sources
|
|
332
|
+
param_type = _get_param_type(func, param_name)
|
|
333
|
+
all_bounds: dict[str, Any] = {}
|
|
334
|
+
for source in pre_sources:
|
|
335
|
+
hint = infer_from_lambda(source, param_name, param_type)
|
|
336
|
+
all_bounds.update(hint.constraints)
|
|
337
|
+
|
|
338
|
+
if all_bounds:
|
|
339
|
+
strategy_kwargs = _bounds_to_strategy_kwargs(all_bounds, spec.strategy_name)
|
|
340
|
+
result[param_name] = refine_strategy(spec, **strategy_kwargs)
|
|
341
|
+
else:
|
|
342
|
+
result[param_name] = spec
|
|
343
|
+
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pre(lambda func, param_name: callable(func) and isinstance(param_name, str))
|
|
348
|
+
@post(lambda result: result is None or isinstance(result, type))
|
|
349
|
+
def _get_param_type(func: Callable, param_name: str) -> type | None:
|
|
350
|
+
"""Get parameter type from function type hints."""
|
|
351
|
+
try:
|
|
352
|
+
hints = get_type_hints(func)
|
|
353
|
+
return hints.get(param_name)
|
|
354
|
+
except Exception:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@pre(lambda func: callable(func))
|
|
359
|
+
@post(lambda result: isinstance(result, dict))
|
|
360
|
+
def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
|
|
361
|
+
"""
|
|
362
|
+
Extract user-defined strategies from @strategy decorator.
|
|
363
|
+
|
|
364
|
+
DX-12-B: Supports both strategy objects and string representations.
|
|
365
|
+
|
|
366
|
+
>>> from invar.decorators import strategy
|
|
367
|
+
>>> @strategy(x="floats(min_value=0)")
|
|
368
|
+
... def sqrt(x: float) -> float:
|
|
369
|
+
... return x ** 0.5
|
|
370
|
+
>>> specs = _get_user_strategies(sqrt)
|
|
371
|
+
>>> 'x' in specs
|
|
372
|
+
True
|
|
373
|
+
>>> specs['x'].to_code()
|
|
374
|
+
'st.floats(min_value=0)'
|
|
375
|
+
"""
|
|
376
|
+
if not hasattr(func, "__invar_strategies__"):
|
|
377
|
+
return {}
|
|
378
|
+
|
|
379
|
+
user_specs: dict[str, StrategySpec] = {}
|
|
380
|
+
raw_strategies = func.__invar_strategies__ # type: ignore[attr-defined]
|
|
381
|
+
|
|
382
|
+
for param_name, strat in raw_strategies.items():
|
|
383
|
+
if isinstance(strat, str):
|
|
384
|
+
# String representation: "floats(min_value=0)"
|
|
385
|
+
raw_code = f"st.{strat}" if not strat.startswith("st.") else strat
|
|
386
|
+
user_specs[param_name] = StrategySpec(
|
|
387
|
+
strategy_name="custom",
|
|
388
|
+
description=f"User-defined: {strat}",
|
|
389
|
+
raw_code=raw_code,
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
# Actual strategy object - convert to code representation
|
|
393
|
+
strat_repr = repr(strat)
|
|
394
|
+
# Ensure it starts with st. for the postcondition
|
|
395
|
+
raw_code = strat_repr if strat_repr.startswith("st.") else f"st.{strat_repr}"
|
|
396
|
+
user_specs[param_name] = StrategySpec(
|
|
397
|
+
strategy_name="custom",
|
|
398
|
+
description="User-defined strategy object",
|
|
399
|
+
raw_code=raw_code,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
return user_specs
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@pre(lambda func: callable(func))
|
|
406
|
+
@post(lambda result: isinstance(result, list))
|
|
407
|
+
def _extract_pre_sources(func: Callable) -> list[str]:
|
|
408
|
+
"""Extract @pre contract source strings from a function."""
|
|
409
|
+
pre_sources: list[str] = []
|
|
410
|
+
|
|
411
|
+
# Check for deal contracts
|
|
412
|
+
if hasattr(func, "__wrapped__"):
|
|
413
|
+
# deal stores contracts in _deal attribute
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
# Try to extract from source
|
|
417
|
+
try:
|
|
418
|
+
source = inspect.getsource(func)
|
|
419
|
+
# Find @pre decorators
|
|
420
|
+
pre_pattern = r"@pre\s*\(\s*(lambda[^)]+)\s*\)"
|
|
421
|
+
matches = re.findall(pre_pattern, source)
|
|
422
|
+
pre_sources.extend(matches)
|
|
423
|
+
except (OSError, TypeError):
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
return pre_sources
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@pre(lambda bounds, strategy_name: isinstance(bounds, dict) and isinstance(strategy_name, str))
|
|
430
|
+
@post(lambda result: isinstance(result, dict))
|
|
431
|
+
def _bounds_to_strategy_kwargs(bounds: dict[str, Any], strategy_name: str) -> dict[str, Any]:
|
|
432
|
+
"""Convert bound constraints to Hypothesis strategy kwargs."""
|
|
433
|
+
kwargs = {}
|
|
434
|
+
|
|
435
|
+
# Numeric bounds
|
|
436
|
+
if "min_value" in bounds:
|
|
437
|
+
kwargs["min_value"] = bounds["min_value"]
|
|
438
|
+
if "max_value" in bounds:
|
|
439
|
+
kwargs["max_value"] = bounds["max_value"]
|
|
440
|
+
|
|
441
|
+
# Size bounds (for collections)
|
|
442
|
+
if "min_size" in bounds:
|
|
443
|
+
kwargs["min_size"] = bounds["min_size"]
|
|
444
|
+
if "max_size" in bounds:
|
|
445
|
+
kwargs["max_size"] = bounds["max_size"]
|
|
446
|
+
|
|
447
|
+
# Exclusion flags for floats
|
|
448
|
+
if strategy_name == "floats":
|
|
449
|
+
if bounds.get("exclude_min"):
|
|
450
|
+
kwargs["exclude_min"] = True
|
|
451
|
+
if bounds.get("exclude_max"):
|
|
452
|
+
kwargs["exclude_max"] = True
|
|
453
|
+
|
|
454
|
+
return kwargs
|
invar/core/inspect.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File inspection for ICIDIV Inspect step (Phase 9.2 P14).
|
|
3
|
+
|
|
4
|
+
Provides context about a file to help agents understand existing patterns
|
|
5
|
+
before making changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from deal import post, pre
|
|
14
|
+
|
|
15
|
+
from invar.core.models import SymbolKind
|
|
16
|
+
from invar.core.parser import parse_source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FileContext:
|
|
21
|
+
"""
|
|
22
|
+
Context information about a file for inspection.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> ctx = FileContext(
|
|
26
|
+
... path="src/core/calc.py",
|
|
27
|
+
... lines=150,
|
|
28
|
+
... max_lines=500,
|
|
29
|
+
... functions_total=5,
|
|
30
|
+
... functions_with_contracts=3,
|
|
31
|
+
... contract_examples=["@pre(lambda x: x > 0)", "@post(lambda result: result is not None)"]
|
|
32
|
+
... )
|
|
33
|
+
>>> ctx.percentage
|
|
34
|
+
30
|
|
35
|
+
>>> ctx.has_patterns
|
|
36
|
+
True
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
path: str
|
|
40
|
+
lines: int
|
|
41
|
+
max_lines: int
|
|
42
|
+
functions_total: int
|
|
43
|
+
functions_with_contracts: int
|
|
44
|
+
contract_examples: list[str]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@post(lambda result: result >= 0)
|
|
48
|
+
def percentage(self) -> int:
|
|
49
|
+
"""Percentage of max lines used.
|
|
50
|
+
|
|
51
|
+
>>> ctx = FileContext("x.py", 400, 500, 10, 5, [])
|
|
52
|
+
>>> ctx.percentage
|
|
53
|
+
80
|
|
54
|
+
"""
|
|
55
|
+
if self.max_lines <= 0 or self.lines < 0:
|
|
56
|
+
return 0
|
|
57
|
+
return int(self.lines / self.max_lines * 100)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@post(lambda result: isinstance(result, bool))
|
|
61
|
+
def has_patterns(self) -> bool:
|
|
62
|
+
"""Whether there are contract patterns to show.
|
|
63
|
+
|
|
64
|
+
>>> FileContext("x.py", 100, 500, 5, 2, ["@pre"]).has_patterns
|
|
65
|
+
True
|
|
66
|
+
>>> FileContext("x.py", 100, 500, 5, 2, []).has_patterns
|
|
67
|
+
False
|
|
68
|
+
"""
|
|
69
|
+
return len(self.contract_examples) > 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pre(lambda source, path, max_lines: isinstance(source, str) and len(path) > 0 and max_lines > 0)
|
|
73
|
+
def analyze_file_context(source: str, path: str, max_lines: int = 500) -> FileContext:
|
|
74
|
+
"""
|
|
75
|
+
Analyze a source file to extract context for inspection.
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
>>> source = '''
|
|
79
|
+
... from deal import pre
|
|
80
|
+
... @pre(lambda x: x > 0)
|
|
81
|
+
... def positive(x: int) -> int:
|
|
82
|
+
... return x * 2
|
|
83
|
+
... def no_contract(y):
|
|
84
|
+
... return y
|
|
85
|
+
... '''
|
|
86
|
+
>>> ctx = analyze_file_context(source.strip(), "test.py", 500)
|
|
87
|
+
>>> ctx.functions_total
|
|
88
|
+
2
|
|
89
|
+
>>> ctx.functions_with_contracts
|
|
90
|
+
1
|
|
91
|
+
>>> "@pre(lambda x: x > 0)" in ctx.contract_examples
|
|
92
|
+
True
|
|
93
|
+
"""
|
|
94
|
+
lines = source.count("\n") + 1
|
|
95
|
+
|
|
96
|
+
# Parse to find functions (handle malformed input gracefully)
|
|
97
|
+
try:
|
|
98
|
+
file_info = parse_source(source, path)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
file_info = None
|
|
101
|
+
|
|
102
|
+
if file_info is None:
|
|
103
|
+
return FileContext(
|
|
104
|
+
path=path,
|
|
105
|
+
lines=lines,
|
|
106
|
+
max_lines=max_lines,
|
|
107
|
+
functions_total=0,
|
|
108
|
+
functions_with_contracts=0,
|
|
109
|
+
contract_examples=[],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
functions = [s for s in file_info.symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)]
|
|
113
|
+
|
|
114
|
+
# Find contract patterns in source
|
|
115
|
+
contract_examples = _extract_contract_patterns(source)
|
|
116
|
+
|
|
117
|
+
# Count functions with contracts (Symbol.contracts is non-empty)
|
|
118
|
+
functions_with_contracts = sum(1 for f in functions if len(f.contracts) > 0)
|
|
119
|
+
|
|
120
|
+
return FileContext(
|
|
121
|
+
path=path,
|
|
122
|
+
lines=lines,
|
|
123
|
+
max_lines=max_lines,
|
|
124
|
+
functions_total=len(functions),
|
|
125
|
+
functions_with_contracts=functions_with_contracts,
|
|
126
|
+
contract_examples=contract_examples[:3], # Limit to 3 examples
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@post(lambda result: isinstance(result, list))
|
|
131
|
+
def _extract_contract_patterns(source: str) -> list[str]:
|
|
132
|
+
"""
|
|
133
|
+
Extract @pre/@post patterns from source.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> src = "@pre(lambda x: x > 0)\\n@post(lambda r: r is not None)"
|
|
137
|
+
>>> patterns = _extract_contract_patterns(src)
|
|
138
|
+
>>> len(patterns)
|
|
139
|
+
2
|
|
140
|
+
>>> "@pre(lambda x: x > 0)" in patterns
|
|
141
|
+
True
|
|
142
|
+
"""
|
|
143
|
+
patterns = []
|
|
144
|
+
|
|
145
|
+
# Match @pre(...) and @post(...) decorators
|
|
146
|
+
for match in re.finditer(r"@(pre|post)\([^)]+\)", source):
|
|
147
|
+
pattern = match.group(0)
|
|
148
|
+
# Simplify if too long
|
|
149
|
+
if len(pattern) > 60:
|
|
150
|
+
pattern = pattern[:57] + "..."
|
|
151
|
+
if pattern not in patterns:
|
|
152
|
+
patterns.append(pattern)
|
|
153
|
+
|
|
154
|
+
return patterns
|