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.
- invar/__init__.py +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
37
|
-
@post(lambda result:
|
|
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
|
-
@
|
|
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:
|
|
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.
|