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
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,15 +23,19 @@ 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: ("lambda" in expression and ":" in expression) or not expression.strip())
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
 
30
+ Handles any string input - non-lambda expressions return False.
31
+
30
32
  Examples:
31
33
  >>> is_empty_contract("lambda: True"), is_empty_contract("lambda x: True")
32
34
  (True, True)
33
35
  >>> is_empty_contract("lambda x: x > 0"), is_empty_contract("")
34
36
  (False, False)
37
+ >>> is_empty_contract("not a lambda") # Non-lambda returns False
38
+ False
35
39
  """
36
40
  if not expression.strip():
37
41
  return False
@@ -47,7 +51,7 @@ def is_empty_contract(expression: str) -> bool:
47
51
  return False
48
52
 
49
53
 
50
- @pre(lambda expression, annotations: ("lambda" in expression and ":" in expression) or not expression.strip())
54
+ @post(lambda result: isinstance(result, bool))
51
55
  def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
52
56
  """Check if a contract only checks types already in annotations.
53
57
 
@@ -72,10 +76,14 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
72
76
  return False
73
77
 
74
78
 
75
- @pre(lambda node: isinstance(node, ast.expr))
79
+ @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
76
80
  @post(lambda result: result is None or isinstance(result, list))
77
81
  def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
78
- """Extract isinstance checks. Returns None if other logic present."""
82
+ """Extract isinstance checks. Returns None if other logic present.
83
+
84
+ Conservative: returns None for complex expressions (nested BoolOp, etc.)
85
+ to avoid false positives when detecting redundant type contracts.
86
+ """
79
87
  if isinstance(node, ast.Call) and hasattr(node, 'func'):
80
88
  check = _parse_isinstance_call(node)
81
89
  return [check] if check else None
@@ -106,9 +114,19 @@ def _parse_isinstance_call(node: ast.Call) -> tuple[str, str] | None:
106
114
  def _types_match(annotation: str, type_name: str) -> bool:
107
115
  """Check if type annotation matches isinstance check.
108
116
 
117
+ Handles simple cases like 'int' matching 'int' and 'list[int]' matching 'list'.
118
+
119
+ MINOR-1 Limitation: Does not handle complex generics:
120
+ - Optional[T] / Union[T, None] → doesn't match 'T' or 'NoneType'
121
+ - Union[A, B] → doesn't match 'A' or 'B'
122
+ - Capitalized builtins → 'List[int]' won't match 'list'
123
+ This is acceptable for detecting obvious redundant type checks.
124
+
109
125
  Examples:
110
126
  >>> _types_match("int", "int"), _types_match("list[int]", "list")
111
127
  (True, True)
128
+ >>> _types_match("Optional[int]", "int") # Limitation: returns False
129
+ False
112
130
  """
113
131
  if annotation == type_name:
114
132
  return True
@@ -119,7 +137,7 @@ def _types_match(annotation: str, type_name: str) -> bool:
119
137
  # Phase 8.3: Parameter mismatch detection
120
138
 
121
139
 
122
- @pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
140
+ @post(lambda result: len(result) == 3 and isinstance(result[0], bool))
123
141
  def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
124
142
  """
125
143
  Check if lambda has params it doesn't use (P28: Partial Contract Detection).
@@ -171,7 +189,7 @@ def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str],
171
189
  return (len(unused_params) > 0, unused_params, used_params)
172
190
 
173
191
 
174
- @pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
192
+ @post(lambda result: len(result) == 2 and isinstance(result[0], bool))
175
193
  def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
176
194
  """
177
195
  Check if lambda params don't match function params.
@@ -209,7 +227,7 @@ def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
209
227
  # Rule checking functions
210
228
 
211
229
 
212
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
230
+ @post(lambda result: all(v.rule == "empty_contract" for v in result))
213
231
  def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
214
232
  """Check for empty/tautological contracts. Core files only.
215
233
 
@@ -242,7 +260,7 @@ def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Viola
242
260
  return violations
243
261
 
244
262
 
245
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
263
+ @post(lambda result: all(v.rule == "redundant_type_contract" for v in result))
246
264
  def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
247
265
  """Check for contracts that only check types in annotations. Core files only. INFO severity.
248
266
 
@@ -280,7 +298,7 @@ def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> l
280
298
  return violations
281
299
 
282
300
 
283
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
301
+ @post(lambda result: all(v.rule == "param_mismatch" for v in result))
284
302
  def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
285
303
  """Check @pre lambda params match function params. Core files only. ERROR severity.
286
304
 
@@ -324,7 +342,7 @@ def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violat
324
342
  return violations
325
343
 
326
344
 
327
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
345
+ @post(lambda result: all(v.rule == "partial_contract" for v in result))
328
346
  def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
329
347
  """Check @pre contracts that don't use all declared params (P28). Core files only. WARN severity.
330
348
 
@@ -373,3 +391,55 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
373
391
  )
374
392
  )
375
393
  return violations
394
+
395
+
396
+ @post(lambda result: all(v.rule == "skip_without_reason" for v in result))
397
+ def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
398
+ """
399
+ Check that @skip_property_test decorators have a reason.
400
+
401
+ DX-28: Prevent abuse of skip by requiring justification.
402
+
403
+ Examples:
404
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
405
+ >>> sym = Symbol(name="f", kind=SymbolKind.FUNCTION, line=2, end_line=5)
406
+ >>> info = FileInfo(path="test.py", lines=10, symbols=[sym], source="@skip_property_test\\ndef f(): pass")
407
+ >>> vs = check_skip_without_reason(info, RuleConfig())
408
+ >>> len(vs) > 0
409
+ True
410
+ >>> vs[0].rule
411
+ 'skip_without_reason'
412
+ >>> # MAJOR-10: Also detects empty string reasons
413
+ >>> info2 = FileInfo(path="t.py", lines=5, symbols=[sym], source='@skip_property_test("")\\ndef f(): pass')
414
+ >>> len(check_skip_without_reason(info2, RuleConfig())) > 0
415
+ True
416
+ """
417
+ violations: list[Violation] = []
418
+
419
+ source = file_info.source or ""
420
+ if "@skip_property_test" not in source:
421
+ return violations
422
+
423
+ # Pattern matches @skip_property_test at start of line (not in strings)
424
+ # Uses ^ to ensure we're matching decorator position, not string literals
425
+ bare_pattern = re.compile(r"^\s*@skip_property_test\s*$")
426
+ no_reason_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*\)\s*$")
427
+ # MAJOR-10: Also detect empty/whitespace-only string reasons
428
+ empty_string_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*['\"][\s]*['\"]\s*\)\s*$")
429
+
430
+ for line_num, line in enumerate(source.split("\n"), 1):
431
+ # Check for bare @skip_property_test, empty parens, or empty string reason
432
+ if bare_pattern.match(line) or no_reason_pattern.match(line) or empty_string_pattern.match(line):
433
+ violations.append(
434
+ Violation(
435
+ rule="skip_without_reason",
436
+ severity=Severity.WARNING,
437
+ file=file_info.path,
438
+ line=line_num,
439
+ message="@skip_property_test used without reason",
440
+ suggestion='Add reason: @skip_property_test("category: explanation")\n'
441
+ "Valid categories: no_params, strategy_factory, external_io, non_deterministic",
442
+ )
443
+ )
444
+
445
+ return violations
@@ -0,0 +1,367 @@
1
+ """
2
+ Entry point detection for framework callbacks.
3
+
4
+ DX-23: Detects entry points (Flask routes, Typer commands, pytest fixtures, etc.)
5
+ that are exempt from Result[T, E] requirement but must remain thin.
6
+
7
+ Core module: pure logic, no I/O.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import re
14
+ import tokenize
15
+ from typing import TYPE_CHECKING
16
+
17
+ from deal import post, pre
18
+
19
+ if TYPE_CHECKING:
20
+ from invar.core.models import Symbol
21
+
22
+
23
+ # Decorator patterns that indicate framework entry points
24
+ # These functions interface with external frameworks and cannot return Result
25
+ ENTRY_POINT_DECORATORS: frozenset[str] = frozenset([
26
+ # Web frameworks - Flask
27
+ "app.route",
28
+ "app.get",
29
+ "app.post",
30
+ "app.put",
31
+ "app.delete",
32
+ "app.patch",
33
+ "blueprint.route",
34
+ "bp.route",
35
+ # Web frameworks - FastAPI
36
+ "router.get",
37
+ "router.post",
38
+ "router.put",
39
+ "router.delete",
40
+ "router.patch",
41
+ "api_router.get",
42
+ "api_router.post",
43
+ "api_router.put",
44
+ "api_router.delete",
45
+ # CLI frameworks - Typer
46
+ "app.command",
47
+ "app.callback",
48
+ "typer.command",
49
+ # CLI frameworks - Click
50
+ "click.command",
51
+ "click.group",
52
+ "cli.command",
53
+ # Testing - pytest
54
+ "pytest.fixture",
55
+ "fixture",
56
+ # Event handlers
57
+ "on_event",
58
+ "app.on_event",
59
+ "middleware",
60
+ "app.middleware",
61
+ # Django
62
+ "admin.register",
63
+ "receiver",
64
+ ])
65
+
66
+ # Explicit marker comment for edge cases
67
+ ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
68
+
69
+ # DX-22: Unified escape hatch pattern: # @invar:allow <rule>: <reason>
70
+ INVAR_ALLOW_PATTERN = re.compile(r"#\s*@invar:allow\s+(\w+)\s*:\s*(.+)")
71
+
72
+
73
+ @post(lambda result: result >= 0) # Escape hatch count is non-negative
74
+ def count_escape_hatches(source: str) -> int:
75
+ """
76
+ Count @invar:allow markers in source code (DX-31).
77
+
78
+ Uses tokenize to only match real comments, not strings/docstrings (DX-33 Option C).
79
+ Used by check_review_suggested to trigger review when escape count >= 3.
80
+
81
+ Examples:
82
+ >>> count_escape_hatches("")
83
+ 0
84
+ >>> count_escape_hatches("# @invar:allow rule: reason")
85
+ 1
86
+ >>> source = '''
87
+ ... # @invar:allow rule1: reason1
88
+ ... def foo(): pass
89
+ ... # @invar:allow rule2: reason2
90
+ ... def bar(): pass
91
+ ... '''
92
+ >>> count_escape_hatches(source)
93
+ 2
94
+ >>> count_escape_hatches("regular comment # no marker")
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
+ []
125
+ """
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
140
+
141
+
142
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
143
+ @post(lambda result: isinstance(result, bool))
144
+ def is_entry_point(symbol: Symbol, source: str) -> bool:
145
+ """
146
+ Check if a symbol is a framework entry point.
147
+
148
+ Entry points are functions decorated with framework-specific decorators
149
+ (Flask routes, Typer commands, etc.) that cannot return Result[T, E]
150
+ because the framework expects specific return types.
151
+
152
+ Examples:
153
+ >>> from invar.core.models import Symbol, SymbolKind
154
+ >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=3, end_line=5)
155
+ >>> source = '''
156
+ ... @app.route("/")
157
+ ... def index():
158
+ ... return "Hello"
159
+ ... '''
160
+ >>> is_entry_point(sym, source)
161
+ True
162
+
163
+ >>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=2, end_line=4)
164
+ >>> source2 = '''
165
+ ... def load_file(path: str) -> Result[str, str]:
166
+ ... return Success(path.read_text())
167
+ ... '''
168
+ >>> is_entry_point(sym2, source2)
169
+ False
170
+
171
+ >>> # Explicit marker
172
+ >>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=5)
173
+ >>> source3 = '''
174
+ ... # @shell:entry - Legacy callback
175
+ ... def handler(data):
176
+ ... return process(data)
177
+ ... '''
178
+ >>> is_entry_point(sym3, source3)
179
+ True
180
+ """
181
+ # Check decorator patterns
182
+ if _has_entry_decorator(symbol, source):
183
+ return True
184
+
185
+ # Check explicit marker
186
+ return _has_entry_marker(symbol, source)
187
+
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
+
217
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
218
+ @post(lambda result: isinstance(result, bool))
219
+ def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
220
+ """
221
+ Check if symbol has a framework entry point decorator.
222
+
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.
225
+
226
+ Examples:
227
+ >>> from invar.core.models import Symbol, SymbolKind
228
+ >>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=2, end_line=4)
229
+ >>> source = '''@app.route("/")
230
+ ... def home():
231
+ ... pass
232
+ ... '''
233
+ >>> _has_entry_decorator(sym, source)
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
243
+ """
244
+ try:
245
+ tree = ast.parse(source)
246
+ except SyntaxError:
247
+ return False
248
+
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
262
+ return False
263
+
264
+
265
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
266
+ @post(lambda result: isinstance(result, bool))
267
+ def _has_entry_marker(symbol: Symbol, source: str) -> bool:
268
+ """
269
+ Check if symbol has an explicit entry point marker comment.
270
+
271
+ Looks for: # @shell:entry
272
+
273
+ Examples:
274
+ >>> from invar.core.models import Symbol, SymbolKind
275
+ >>> sym = Symbol(name="callback", kind=SymbolKind.FUNCTION, line=3, end_line=6)
276
+ >>> source = '''
277
+ ... # @shell:entry - Custom framework callback
278
+ ... def callback():
279
+ ... pass
280
+ ... '''
281
+ >>> _has_entry_marker(sym, source)
282
+ True
283
+
284
+ >>> sym2 = Symbol(name="regular", kind=SymbolKind.FUNCTION, line=1, end_line=3)
285
+ >>> source2 = '''def regular(): pass'''
286
+ >>> _has_entry_marker(sym2, source2)
287
+ False
288
+ """
289
+ lines = source.splitlines()
290
+ if not lines:
291
+ return False
292
+
293
+ # Look at lines before the function definition
294
+ start_line = max(0, symbol.line - 4)
295
+ end_line = symbol.line
296
+
297
+ context_lines = lines[start_line:end_line]
298
+ context = "\n".join(context_lines)
299
+
300
+ return bool(ENTRY_MARKER_PATTERN.search(context))
301
+
302
+
303
+ @pre(lambda symbol: symbol is not None)
304
+ @post(lambda result: isinstance(result, int) and result >= 0)
305
+ def get_symbol_lines(symbol: Symbol) -> int:
306
+ """
307
+ Get the number of lines in a symbol.
308
+
309
+ Examples:
310
+ >>> from invar.core.models import Symbol, SymbolKind
311
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
312
+ >>> get_symbol_lines(sym)
313
+ 10
314
+ >>> sym2 = Symbol(name="bar", kind=SymbolKind.FUNCTION, line=5, end_line=5)
315
+ >>> get_symbol_lines(sym2)
316
+ 1
317
+ """
318
+ return max(1, symbol.end_line - symbol.line + 1)
319
+
320
+
321
+ @pre(lambda symbol, source, rule: symbol is not None and isinstance(rule, str))
322
+ @post(lambda result: isinstance(result, bool))
323
+ def has_allow_marker(symbol: Symbol, source: str, rule: str) -> bool:
324
+ """
325
+ Check if symbol has an @invar:allow marker for a specific rule.
326
+
327
+ DX-22: Unified escape hatch mechanism. Format:
328
+ # @invar:allow <rule>: <reason>
329
+
330
+ Examples:
331
+ >>> from invar.core.models import Symbol, SymbolKind
332
+ >>> sym = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=20)
333
+ >>> source = '''
334
+ ... # @invar:allow entry_point_too_thick: Complex CLI parsing
335
+ ... def handler():
336
+ ... pass
337
+ ... '''
338
+ >>> has_allow_marker(sym, source, "entry_point_too_thick")
339
+ True
340
+ >>> has_allow_marker(sym, source, "shell_result")
341
+ False
342
+
343
+ >>> sym2 = Symbol(name="api", kind=SymbolKind.FUNCTION, line=3, end_line=10)
344
+ >>> source2 = '''
345
+ ... # @invar:allow shell_result: Returns raw JSON for legacy API
346
+ ... def api():
347
+ ... pass
348
+ ... '''
349
+ >>> has_allow_marker(sym2, source2, "shell_result")
350
+ True
351
+ """
352
+ lines = source.splitlines()
353
+ if not lines:
354
+ return False
355
+
356
+ # Look at lines before the function definition (up to 4 lines)
357
+ start_line = max(0, symbol.line - 5)
358
+ end_line = symbol.line
359
+
360
+ context_lines = lines[start_line:end_line]
361
+
362
+ for line in context_lines:
363
+ match = INVAR_ALLOW_PATTERN.search(line)
364
+ if match and match.group(1) == rule:
365
+ return True
366
+
367
+ return False
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.