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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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