invar-tools 1.2.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 (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
invar/__init__.py CHANGED
@@ -9,6 +9,7 @@ For runtime contracts only, use invar-runtime instead.
9
9
  """
10
10
 
11
11
  __version__ = "1.0.0"
12
+ __protocol_version__ = "5.0" # Protocol/spec version (separate from package version)
12
13
 
13
14
  # Re-export from invar-runtime for backwards compatibility
14
15
  from invar_runtime import (
invar/core/contracts.py CHANGED
@@ -23,7 +23,7 @@ from invar.core.tautology import check_semantic_tautology as check_semantic_taut
23
23
  from invar.core.tautology import is_semantic_tautology as is_semantic_tautology
24
24
 
25
25
 
26
- @pre(lambda expression: isinstance(expression, str))
26
+ @post(lambda result: isinstance(result, bool))
27
27
  def is_empty_contract(expression: str) -> bool:
28
28
  """Check if a contract expression is always True (tautological).
29
29
 
@@ -51,7 +51,7 @@ def is_empty_contract(expression: str) -> bool:
51
51
  return False
52
52
 
53
53
 
54
- @pre(lambda expression, annotations: isinstance(expression, str))
54
+ @post(lambda result: isinstance(result, bool))
55
55
  def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
56
56
  """Check if a contract only checks types already in annotations.
57
57
 
@@ -76,7 +76,7 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
76
76
  return False
77
77
 
78
78
 
79
- @pre(lambda node: isinstance(node, ast.expr))
79
+ @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
80
80
  @post(lambda result: result is None or isinstance(result, list))
81
81
  def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
82
82
  """Extract isinstance checks. Returns None if other logic present.
@@ -137,7 +137,7 @@ def _types_match(annotation: str, type_name: str) -> bool:
137
137
  # Phase 8.3: Parameter mismatch detection
138
138
 
139
139
 
140
- @pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
140
+ @post(lambda result: len(result) == 3 and isinstance(result[0], bool))
141
141
  def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
142
142
  """
143
143
  Check if lambda has params it doesn't use (P28: Partial Contract Detection).
@@ -189,7 +189,7 @@ def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str],
189
189
  return (len(unused_params) > 0, unused_params, used_params)
190
190
 
191
191
 
192
- @pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
192
+ @post(lambda result: len(result) == 2 and isinstance(result[0], bool))
193
193
  def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
194
194
  """
195
195
  Check if lambda params don't match function params.
@@ -227,7 +227,7 @@ def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
227
227
  # Rule checking functions
228
228
 
229
229
 
230
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
230
+ @post(lambda result: all(v.rule == "empty_contract" for v in result))
231
231
  def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
232
232
  """Check for empty/tautological contracts. Core files only.
233
233
 
@@ -260,7 +260,7 @@ def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Viola
260
260
  return violations
261
261
 
262
262
 
263
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
263
+ @post(lambda result: all(v.rule == "redundant_type_contract" for v in result))
264
264
  def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
265
265
  """Check for contracts that only check types in annotations. Core files only. INFO severity.
266
266
 
@@ -298,7 +298,7 @@ def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> l
298
298
  return violations
299
299
 
300
300
 
301
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
301
+ @post(lambda result: all(v.rule == "param_mismatch" for v in result))
302
302
  def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
303
303
  """Check @pre lambda params match function params. Core files only. ERROR severity.
304
304
 
@@ -342,7 +342,7 @@ def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violat
342
342
  return violations
343
343
 
344
344
 
345
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
345
+ @post(lambda result: all(v.rule == "partial_contract" for v in result))
346
346
  def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
347
347
  """Check @pre contracts that don't use all declared params (P28). Core files only. WARN severity.
348
348
 
@@ -393,7 +393,7 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
393
393
  return violations
394
394
 
395
395
 
396
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
396
+ @post(lambda result: all(v.rule == "skip_without_reason" for v in result))
397
397
  def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
398
398
  """
399
399
  Check that @skip_property_test decorators have a reason.
@@ -9,7 +9,9 @@ Core module: pure logic, no I/O.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import ast
12
13
  import re
14
+ import tokenize
13
15
  from typing import TYPE_CHECKING
14
16
 
15
17
  from deal import post, pre
@@ -68,12 +70,12 @@ ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
68
70
  INVAR_ALLOW_PATTERN = re.compile(r"#\s*@invar:allow\s+(\w+)\s*:\s*(.+)")
69
71
 
70
72
 
71
- @pre(lambda source: isinstance(source, str))
72
- @post(lambda result: isinstance(result, int) and result >= 0)
73
+ @post(lambda result: result >= 0) # Escape hatch count is non-negative
73
74
  def count_escape_hatches(source: str) -> int:
74
75
  """
75
76
  Count @invar:allow markers in source code (DX-31).
76
77
 
78
+ Uses tokenize to only match real comments, not strings/docstrings (DX-33 Option C).
77
79
  Used by check_review_suggested to trigger review when escape count >= 3.
78
80
 
79
81
  Examples:
@@ -91,8 +93,50 @@ def count_escape_hatches(source: str) -> int:
91
93
  2
92
94
  >>> count_escape_hatches("regular comment # no marker")
93
95
  0
96
+ >>> # DX-33 Option C: Strings containing the pattern should NOT match
97
+ >>> count_escape_hatches('s = "# @invar:allow rule: reason"')
98
+ 0
99
+ """
100
+ return len(extract_escape_hatches(source))
101
+
102
+
103
+ @post(lambda result: all(len(t) == 2 for t in result)) # Returns (rule, reason) tuples
104
+ def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
105
+ """
106
+ Extract @invar:allow markers with their reasons (DX-33 Option E).
107
+
108
+ Uses tokenize to only match real comments, not strings/docstrings.
109
+ Returns list of (rule, reason) tuples for cross-file analysis.
110
+
111
+ Examples:
112
+ >>> extract_escape_hatches("")
113
+ []
114
+ >>> extract_escape_hatches("# @invar:allow shell_result: API boundary")
115
+ [('shell_result', 'API boundary')]
116
+ >>> source = '''
117
+ ... # @invar:allow rule1: same reason
118
+ ... # @invar:allow rule2: different reason
119
+ ... '''
120
+ >>> extract_escape_hatches(source)
121
+ [('rule1', 'same reason'), ('rule2', 'different reason')]
122
+ >>> # DX-33 Option C: Strings containing the pattern should NOT match
123
+ >>> extract_escape_hatches('suggestion = "# @invar:allow rule: reason"')
124
+ []
94
125
  """
95
- return len(INVAR_ALLOW_PATTERN.findall(source))
126
+ results: list[tuple[str, str]] = []
127
+ try:
128
+ # Use iterator-based readline to avoid io.StringIO (forbidden in Core)
129
+ lines = iter(source.splitlines(keepends=True))
130
+ tokens = tokenize.generate_tokens(lambda: next(lines, ""))
131
+ for tok in tokens:
132
+ if tok.type == tokenize.COMMENT:
133
+ match = INVAR_ALLOW_PATTERN.search(tok.string)
134
+ if match:
135
+ results.append((match.group(1), match.group(2)))
136
+ except Exception:
137
+ # Fall back to regex if tokenization fails (invalid syntax, non-printable chars, etc.)
138
+ return INVAR_ALLOW_PATTERN.findall(source)
139
+ return results
96
140
 
97
141
 
98
142
  @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
@@ -107,7 +151,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
107
151
 
108
152
  Examples:
109
153
  >>> from invar.core.models import Symbol, SymbolKind
110
- >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=5, end_line=10)
154
+ >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=3, end_line=5)
111
155
  >>> source = '''
112
156
  ... @app.route("/")
113
157
  ... def index():
@@ -116,7 +160,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
116
160
  >>> is_entry_point(sym, source)
117
161
  True
118
162
 
119
- >>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=1, end_line=5)
163
+ >>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=2, end_line=4)
120
164
  >>> source2 = '''
121
165
  ... def load_file(path: str) -> Result[str, str]:
122
166
  ... return Success(path.read_text())
@@ -125,7 +169,7 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
125
169
  False
126
170
 
127
171
  >>> # Explicit marker
128
- >>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=8)
172
+ >>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=5)
129
173
  >>> source3 = '''
130
174
  ... # @shell:entry - Legacy callback
131
175
  ... def handler(data):
@@ -142,50 +186,79 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
142
186
  return _has_entry_marker(symbol, source)
143
187
 
144
188
 
189
+
190
+ @post(lambda result: isinstance(result, str))
191
+ def _decorator_to_string(decorator: ast.AST) -> str:
192
+ """
193
+ Convert AST decorator node to string representation for matching.
194
+
195
+ Examples:
196
+ >>> import ast
197
+ >>> tree = ast.parse("@app.route('/')\\ndef f(): pass")
198
+ >>> func = tree.body[0]
199
+ >>> _decorator_to_string(func.decorator_list[0])
200
+ 'app.route'
201
+ """
202
+ if isinstance(decorator, ast.Name):
203
+ return decorator.id
204
+ elif isinstance(decorator, ast.Attribute):
205
+ parts = []
206
+ node = decorator
207
+ while isinstance(node, ast.Attribute):
208
+ parts.append(node.attr)
209
+ node = node.value
210
+ if isinstance(node, ast.Name):
211
+ parts.append(node.id)
212
+ return ".".join(reversed(parts))
213
+ elif isinstance(decorator, ast.Call):
214
+ return _decorator_to_string(decorator.func)
215
+ return ""
216
+
145
217
  @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
146
218
  @post(lambda result: isinstance(result, bool))
147
219
  def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
148
220
  """
149
221
  Check if symbol has a framework entry point decorator.
150
222
 
151
- Looks at the source code above the function definition.
223
+ Uses AST to check decorator nodes, avoiding false matches in strings.
224
+ DX-33 Option C: Migrated from string matching to AST-based detection.
152
225
 
153
226
  Examples:
154
227
  >>> from invar.core.models import Symbol, SymbolKind
155
- >>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=3, end_line=6)
228
+ >>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=2, end_line=4)
156
229
  >>> source = '''@app.route("/")
157
230
  ... def home():
158
231
  ... pass
159
232
  ... '''
160
233
  >>> _has_entry_decorator(sym, source)
161
234
  True
235
+ >>> # DX-33: Decorators in strings should NOT match
236
+ >>> sym2 = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=2, end_line=4)
237
+ >>> source2 = '''x = "@app.route('/')"
238
+ ... def foo():
239
+ ... pass
240
+ ... '''
241
+ >>> _has_entry_decorator(sym2, source2)
242
+ False
162
243
  """
163
- # Get source lines
164
- lines = source.splitlines()
165
- if not lines:
244
+ try:
245
+ tree = ast.parse(source)
246
+ except SyntaxError:
166
247
  return False
167
248
 
168
- # Look at lines before the function definition (decorators are above)
169
- # We check up to 5 lines above the function for decorators
170
- start_line = max(0, symbol.line - 6)
171
- end_line = symbol.line # Line numbers are 1-indexed, so line-1 = index
172
-
173
- context_lines = lines[start_line:end_line]
174
- context = "\n".join(context_lines)
175
-
176
- # Check each known decorator pattern
177
- # Note: String matching may match decorators in string literals (rare edge case).
178
- # AST-based detection would be more robust but adds complexity for a heuristic check.
179
- for pattern in ENTRY_POINT_DECORATORS:
180
- # Match @pattern or @something.pattern
181
- if f"@{pattern}" in context:
182
- return True
183
- # Also match partial patterns (e.g., "route" matches "app.route")
184
- if "." in pattern:
185
- base = pattern.split(".")[-1]
186
- if f".{base}(" in context or f".{base}\n" in context:
187
- return True
188
-
249
+ # Find the function definition at the symbol's line
250
+ for node in ast.walk(tree):
251
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
252
+ if node.lineno == symbol.line and node.name == symbol.name:
253
+ # Check decorators
254
+ for decorator in node.decorator_list:
255
+ decorator_str = _decorator_to_string(decorator)
256
+ if decorator_str:
257
+ for pattern in ENTRY_POINT_DECORATORS:
258
+ if pattern in decorator_str or decorator_str.endswith(
259
+ "." + pattern.split(".")[-1]
260
+ ):
261
+ return True
189
262
  return False
190
263
 
191
264
 
invar/core/extraction.py CHANGED
@@ -11,8 +11,7 @@ from deal import post, pre
11
11
  from invar.core.models import FileInfo, Symbol, SymbolKind
12
12
 
13
13
 
14
- @pre(lambda funcs: isinstance(funcs, dict))
15
- @post(lambda result: isinstance(result, dict))
14
+ @post(lambda result: all(k in result for k in result)) # Bidirectional graph
16
15
  def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
17
16
  """Build bidirectional call graph for function grouping.
18
17
 
@@ -33,8 +32,8 @@ def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
33
32
  return graph
34
33
 
35
34
 
36
- @pre(lambda start, graph, visited: start and isinstance(graph, dict) and start in graph)
37
- @post(lambda result: isinstance(result, list))
35
+ @pre(lambda start, graph, visited: start and start in graph) # Start must exist in graph
36
+ @post(lambda result: len(result) >= 1 or not result) # At least 1 if found, else empty
38
37
  def _find_connected_component(start: str, graph: dict[str, set[str]], visited: set[str]) -> list[str]:
39
38
  """BFS to find all functions connected to start.
40
39
 
@@ -55,7 +54,7 @@ def _find_connected_component(start: str, graph: dict[str, set[str]], visited: s
55
54
  return component
56
55
 
57
56
 
58
- @pre(lambda file_info: isinstance(file_info, FileInfo))
57
+ @post(lambda result: all("functions" in g and "lines" in g for g in result))
59
58
  def find_extractable_groups(file_info: FileInfo) -> list[dict]:
60
59
  """
61
60
  Find groups of related functions that could be extracted together.
@@ -136,7 +135,7 @@ def _get_group_dependencies(
136
135
  return deps.intersection(set(file_imports)) if file_imports else deps
137
136
 
138
137
 
139
- @pre(lambda file_info, max_groups=3: isinstance(file_info, FileInfo))
138
+ @pre(lambda file_info, max_groups=3: max_groups >= 1) # At least 1 group
140
139
  def format_extraction_hint(file_info: FileInfo, max_groups: int = 3) -> str:
141
140
  """
142
141
  Format extraction suggestions for file_size_warning.
@@ -174,8 +174,7 @@ CROSSHAIR_SPEC = CrossHairOutputSpec()
174
174
  PYTEST_SPEC = PytestOutputSpec()
175
175
 
176
176
 
177
- @pre(lambda text, spec: isinstance(text, str) and isinstance(spec, CrossHairOutputSpec))
178
- @post(lambda result: isinstance(result, list))
177
+ @post(lambda result: all(isinstance(line, str) and line.strip() for line in result)) # Non-empty strings
179
178
  def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
180
179
  """
181
180
  Extract lines matching a format specification.
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,7 +199,7 @@ 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, combined_status=None: isinstance(report, GuardReport))
202
+ @pre(lambda report, combined_status=None: combined_status is None or combined_status in ("passed", "failed"))
204
203
  def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
205
204
  """
206
205
  Format Guard report for Agent consumption (Phase 8.2 + DX-26).
@@ -255,7 +254,7 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
255
254
  }
256
255
 
257
256
 
258
- @pre(lambda v: isinstance(v, Violation))
257
+ @post(lambda result: "file" in result and "rule" in result and "severity" in result)
259
258
  def _violation_to_fix(v: Violation) -> dict:
260
259
  """Convert a Violation to an Agent-friendly fix instruction."""
261
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,8 +402,7 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
402
402
  return user_specs
403
403
 
404
404
 
405
- @pre(lambda source: isinstance(source, str))
406
- @post(lambda result: isinstance(result, list))
405
+ @post(lambda result: all("lambda" in s for s in result)) # Only lambda expressions
407
406
  def _extract_pre_lambdas_from_source(source: str) -> list[str]:
408
407
  """
409
408
  Extract lambda expressions from @pre decorators with balanced parenthesis.
@@ -468,8 +467,7 @@ def _extract_pre_sources(func: Callable) -> list[str]:
468
467
  return pre_sources
469
468
 
470
469
 
471
- @pre(lambda bounds, strategy_name: isinstance(bounds, dict) and isinstance(strategy_name, str))
472
- @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))
473
471
  def _bounds_to_strategy_kwargs(bounds: dict[str, Any], strategy_name: str) -> dict[str, Any]:
474
472
  """Convert bound constraints to Hypothesis strategy kwargs."""
475
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,7 +8,7 @@ import re
8
8
  from deal import post, pre
9
9
 
10
10
 
11
- @pre(lambda tree: isinstance(tree, ast.AST))
11
+ @pre(lambda tree: isinstance(tree, ast.AST) and hasattr(tree, '__class__'))
12
12
  @post(lambda result: result is None or isinstance(result, ast.Lambda))
13
13
  def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
14
14
  """Find the lambda node in an expression tree.
@@ -52,8 +52,7 @@ def extract_annotations(signature: str) -> dict[str, str]:
52
52
  return annotations
53
53
 
54
54
 
55
- @pre(lambda expression: isinstance(expression, str))
56
- @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
57
56
  def extract_lambda_params(expression: str) -> list[str] | None:
58
57
  """Extract parameter names from a lambda expression.
59
58
 
@@ -115,6 +114,7 @@ def extract_func_param_names(signature: str) -> list[str] | None:
115
114
  return params
116
115
 
117
116
 
117
+ @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
118
118
  @post(lambda result: isinstance(result, set))
119
119
  def extract_used_names(node: ast.expr) -> set[str]:
120
120
  """Extract all variable names used in an expression (Load context).
invar/core/models.py CHANGED
@@ -94,7 +94,7 @@ class GuardReport(BaseModel):
94
94
  core_functions_total: int = 0
95
95
  core_functions_with_contracts: int = 0
96
96
 
97
- @pre(lambda self, violation: isinstance(violation, Violation))
97
+ @pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
98
98
  def add_violation(self, violation: Violation) -> None:
99
99
  """
100
100
  Add a violation and update counts.
@@ -257,6 +257,12 @@ class RuleConfig(BaseModel):
257
257
  purity_pure: list[str] = Field(default_factory=list) # Known pure functions
258
258
  purity_impure: list[str] = Field(default_factory=list) # Known impure functions
259
259
 
260
+ # Timeout configuration (seconds) - MAJOR-3 fix
261
+ timeout_doctest: int = Field(default=60, ge=1, le=600) # Doctests should be fast
262
+ timeout_hypothesis: int = Field(default=300, ge=1, le=1800) # Property tests
263
+ timeout_crosshair: int = Field(default=300, ge=1, le=1800) # Symbolic verification total
264
+ timeout_crosshair_per_condition: int = Field(default=30, ge=1, le=300) # Per-contract limit
265
+
260
266
 
261
267
  # Phase 4: Perception models
262
268
 
invar/core/must_use.py CHANGED
@@ -58,6 +58,7 @@ def find_must_use_functions(source: str) -> dict[str, str]:
58
58
  return must_use_funcs
59
59
 
60
60
 
61
+ @pre(lambda decorator: isinstance(decorator, ast.expr) and hasattr(decorator, '__class__'))
61
62
  @post(lambda result: result is None or isinstance(result, str))
62
63
  def _extract_must_use_reason(decorator: ast.expr) -> str | None:
63
64
  """Extract reason from @must_use decorator, or None if not a must_use."""
@@ -136,7 +137,7 @@ def _get_call_name(call: ast.Call) -> str | None:
136
137
  return None
137
138
 
138
139
 
139
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
140
+ @post(lambda result: all(v.rule == "must_use_ignored" for v in result)) # Rule consistency
140
141
  def check_must_use(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
141
142
  """
142
143
  Check for ignored return values of @must_use functions.
invar/core/parser.py CHANGED
@@ -64,7 +64,7 @@ def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
64
64
  )
65
65
 
66
66
 
67
- @pre(lambda tree: isinstance(tree, ast.Module))
67
+ @pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
68
68
  def _extract_symbols(tree: ast.Module) -> list[Symbol]:
69
69
  """
70
70
  Extract function, class, and method symbols from AST.
@@ -190,7 +190,10 @@ def _parse_class(node: ast.ClassDef) -> Symbol:
190
190
  )
191
191
 
192
192
 
193
- @pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef))
193
+ @pre(lambda node: (
194
+ isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
195
+ hasattr(node, 'decorator_list')
196
+ ))
194
197
  @post(lambda result: all(c.kind in ("pre", "post") for c in result))
195
198
  def _extract_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[Contract]:
196
199
  """Extract @pre and @post contracts from function decorators."""
@@ -230,7 +233,7 @@ def _parse_decorator_as_contract(decorator: ast.expr) -> Contract | None:
230
233
  return None
231
234
 
232
235
 
233
- @pre(lambda call: isinstance(call, ast.Call))
236
+ @pre(lambda call: isinstance(call, ast.Call) and hasattr(call, 'args'))
234
237
  def _get_contract_expression(call: ast.Call) -> str:
235
238
  """Extract the expression string from a contract decorator call."""
236
239
  if call.args:
@@ -264,7 +267,7 @@ def _build_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
264
267
  return sig
265
268
 
266
269
 
267
- @pre(lambda tree: isinstance(tree, ast.Module))
270
+ @pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
268
271
  @post(lambda result: all(isinstance(s, str) and s for s in result))
269
272
  def _extract_imports(tree: ast.Module) -> list[str]:
270
273
  """Extract imported module names from AST (top-level only)."""