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.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {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: isinstance(perception_map, PerceptionMap))
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: isinstance(symbol, Symbol))
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: isinstance(symbols, list))
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: isinstance(symbols, list))
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: isinstance(report, GuardReport))
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": "passed" if report.passed else "failed",
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
- @pre(lambda v: isinstance(v, Violation))
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
- @post(lambda result: isinstance(result, bool))
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
- @post(lambda result: isinstance(result, bool))
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 invar.decorators import strategy
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
- # Find @pre decorators
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
- @pre(lambda bounds, strategy_name: isinstance(bounds, dict) and isinstance(strategy_name, str))
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
@@ -1,5 +1,5 @@
1
1
  """
2
- File inspection for ICIDIV Inspect step (Phase 9.2 P14).
2
+ File inspection for USBV Understand step (Phase 9.2 P14).
3
3
 
4
4
  Provides context about a file to help agents understand existing patterns
5
5
  before making changes.
@@ -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
- @pre(lambda expression: isinstance(expression, str))
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).