invar-tools 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Invar Tools: AI-native software engineering framework.
|
|
3
|
+
|
|
4
|
+
Trade structure for safety. The goal is not to make AI simpler,
|
|
5
|
+
but to make AI output more reliable.
|
|
6
|
+
|
|
7
|
+
This package provides development tools (guard, map, sig).
|
|
8
|
+
For runtime contracts only, use invar-runtime instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
|
|
13
|
+
# Re-export from invar-runtime for backwards compatibility
|
|
14
|
+
from invar_runtime import (
|
|
15
|
+
AllNonNegative,
|
|
16
|
+
AllPositive,
|
|
17
|
+
Contract,
|
|
18
|
+
InRange,
|
|
19
|
+
InvariantViolation,
|
|
20
|
+
MustCloseViolation,
|
|
21
|
+
Negative,
|
|
22
|
+
NonBlank,
|
|
23
|
+
NonEmpty,
|
|
24
|
+
NonNegative,
|
|
25
|
+
NoNone,
|
|
26
|
+
Percentage,
|
|
27
|
+
Positive,
|
|
28
|
+
ResourceWarning,
|
|
29
|
+
Sorted,
|
|
30
|
+
SortedNonEmpty,
|
|
31
|
+
Unique,
|
|
32
|
+
invariant,
|
|
33
|
+
is_must_close,
|
|
34
|
+
must_close,
|
|
35
|
+
must_use,
|
|
36
|
+
post,
|
|
37
|
+
pre,
|
|
38
|
+
skip_property_test,
|
|
39
|
+
strategy,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AllNonNegative",
|
|
44
|
+
"AllPositive",
|
|
45
|
+
"Contract",
|
|
46
|
+
"InRange",
|
|
47
|
+
"InvariantViolation",
|
|
48
|
+
"MustCloseViolation",
|
|
49
|
+
"Negative",
|
|
50
|
+
"NoNone",
|
|
51
|
+
"NonBlank",
|
|
52
|
+
"NonEmpty",
|
|
53
|
+
"NonNegative",
|
|
54
|
+
"Percentage",
|
|
55
|
+
"Positive",
|
|
56
|
+
"ResourceWarning",
|
|
57
|
+
"Sorted",
|
|
58
|
+
"SortedNonEmpty",
|
|
59
|
+
"Unique",
|
|
60
|
+
"invariant",
|
|
61
|
+
"is_must_close",
|
|
62
|
+
"must_close",
|
|
63
|
+
"must_use",
|
|
64
|
+
"post",
|
|
65
|
+
"pre",
|
|
66
|
+
"skip_property_test",
|
|
67
|
+
"strategy",
|
|
68
|
+
]
|
invar/contracts.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Composable contracts for Invar.
|
|
3
|
+
|
|
4
|
+
Provides Contract class with &, |, ~ operators for combining conditions,
|
|
5
|
+
and a standard library of common predicates. Works with deal decorators.
|
|
6
|
+
|
|
7
|
+
Inspired by Idris' dependent types.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import deal
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Contract:
|
|
23
|
+
"""
|
|
24
|
+
Composable contract with &, |, ~ operators.
|
|
25
|
+
|
|
26
|
+
Contracts encapsulate predicates that can be combined and reused.
|
|
27
|
+
Works with deal.pre for runtime checking.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> NonEmpty = Contract(lambda x: len(x) > 0, "non-empty")
|
|
31
|
+
>>> Sorted = Contract(lambda x: list(x) == sorted(x), "sorted")
|
|
32
|
+
>>> combined = NonEmpty & Sorted
|
|
33
|
+
>>> combined.check([1, 2, 3])
|
|
34
|
+
True
|
|
35
|
+
>>> combined.check([])
|
|
36
|
+
False
|
|
37
|
+
>>> combined.check([3, 1, 2])
|
|
38
|
+
False
|
|
39
|
+
>>> (NonEmpty | Sorted).check([]) # Empty but sorted
|
|
40
|
+
True
|
|
41
|
+
>>> (~NonEmpty).check([]) # NOT non-empty
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
predicate: Callable[[Any], bool]
|
|
46
|
+
description: str
|
|
47
|
+
|
|
48
|
+
def check(self, value: Any) -> bool:
|
|
49
|
+
"""Check if value satisfies the contract."""
|
|
50
|
+
return self.predicate(value)
|
|
51
|
+
|
|
52
|
+
def __and__(self, other: Contract) -> Contract:
|
|
53
|
+
"""Combine contracts with AND."""
|
|
54
|
+
return Contract(
|
|
55
|
+
predicate=lambda x: self.check(x) and other.check(x),
|
|
56
|
+
description=f"({self.description} AND {other.description})",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def __or__(self, other: Contract) -> Contract:
|
|
60
|
+
"""Combine contracts with OR."""
|
|
61
|
+
return Contract(
|
|
62
|
+
predicate=lambda x: self.check(x) or other.check(x),
|
|
63
|
+
description=f"({self.description} OR {other.description})",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def __invert__(self) -> Contract:
|
|
67
|
+
"""Negate the contract."""
|
|
68
|
+
return Contract(
|
|
69
|
+
predicate=lambda x: not self.check(x),
|
|
70
|
+
description=f"NOT({self.description})",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
74
|
+
"""Allow using as deal.pre predicate directly."""
|
|
75
|
+
value = args[0] if args else next(iter(kwargs.values()))
|
|
76
|
+
return self.check(value)
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return f"Contract({self.description!r})"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pre(*contracts: Contract) -> Callable[[Callable], Callable]:
|
|
83
|
+
"""
|
|
84
|
+
Decorator accepting Contract objects for preconditions.
|
|
85
|
+
|
|
86
|
+
Works with deal.pre under the hood.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
>>> from invar.contracts import pre, NonEmpty
|
|
90
|
+
>>> @pre(NonEmpty)
|
|
91
|
+
... def first(xs): return xs[0]
|
|
92
|
+
>>> first([1, 2, 3])
|
|
93
|
+
1
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def combined(*args: Any, **kwargs: Any) -> bool:
|
|
97
|
+
value = args[0] if args else next(iter(kwargs.values()))
|
|
98
|
+
return all(c.check(value) for c in contracts)
|
|
99
|
+
|
|
100
|
+
return deal.pre(combined)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def post(*contracts: Contract) -> Callable[[Callable], Callable]:
|
|
104
|
+
"""
|
|
105
|
+
Decorator accepting Contract objects for postconditions.
|
|
106
|
+
|
|
107
|
+
Works with deal.post under the hood.
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> from invar.contracts import post, NonEmpty
|
|
111
|
+
>>> @post(NonEmpty)
|
|
112
|
+
... def get_list(): return [1]
|
|
113
|
+
>>> get_list()
|
|
114
|
+
[1]
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def combined(result: Any) -> bool:
|
|
118
|
+
return all(c.check(result) for c in contracts)
|
|
119
|
+
|
|
120
|
+
return deal.post(combined)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# =============================================================================
|
|
124
|
+
# Standard Library of Contracts
|
|
125
|
+
# =============================================================================
|
|
126
|
+
|
|
127
|
+
# --- Collections ---
|
|
128
|
+
NonEmpty: Contract = Contract(lambda x: len(x) > 0, "non-empty")
|
|
129
|
+
Sorted: Contract = Contract(lambda x: list(x) == sorted(x), "sorted")
|
|
130
|
+
Unique: Contract = Contract(lambda x: len(x) == len(set(x)), "unique")
|
|
131
|
+
SortedNonEmpty: Contract = NonEmpty & Sorted
|
|
132
|
+
|
|
133
|
+
# --- Numbers ---
|
|
134
|
+
Positive: Contract = Contract(lambda x: x > 0, "positive")
|
|
135
|
+
NonNegative: Contract = Contract(lambda x: x >= 0, "non-negative")
|
|
136
|
+
Negative: Contract = Contract(lambda x: x < 0, "negative")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def InRange(lo: float, hi: float) -> Contract:
|
|
140
|
+
"""Create a contract checking value is in [lo, hi]."""
|
|
141
|
+
return Contract(lambda x: lo <= x <= hi, f"[{lo},{hi}]")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
Percentage: Contract = InRange(0, 100)
|
|
145
|
+
|
|
146
|
+
# --- Strings ---
|
|
147
|
+
NonBlank: Contract = Contract(lambda s: bool(s and s.strip()), "non-blank")
|
|
148
|
+
|
|
149
|
+
# --- Lists with elements ---
|
|
150
|
+
AllPositive: Contract = Contract(lambda xs: all(x > 0 for x in xs), "all positive")
|
|
151
|
+
AllNonNegative: Contract = Contract(lambda xs: all(x >= 0 for x in xs), "all non-negative")
|
|
152
|
+
NoNone: Contract = Contract(lambda xs: None not in xs, "no None")
|
invar/core/__init__.py
ADDED
invar/core/contracts.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Contract quality detection for Guard (Phase 7, 8, 11). No I/O operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from deal import post, pre
|
|
9
|
+
|
|
10
|
+
from invar.core.lambda_helpers import (
|
|
11
|
+
extract_annotations,
|
|
12
|
+
extract_func_param_names,
|
|
13
|
+
extract_lambda_params,
|
|
14
|
+
extract_used_names,
|
|
15
|
+
find_lambda,
|
|
16
|
+
generate_lambda_fix,
|
|
17
|
+
)
|
|
18
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
19
|
+
from invar.core.suggestions import format_suggestion_for_violation
|
|
20
|
+
|
|
21
|
+
# Re-export for backward compatibility (extracted to tautology.py)
|
|
22
|
+
from invar.core.tautology import check_semantic_tautology as check_semantic_tautology
|
|
23
|
+
from invar.core.tautology import is_semantic_tautology as is_semantic_tautology
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pre(lambda expression: ("lambda" in expression and ":" in expression) or not expression.strip())
|
|
27
|
+
def is_empty_contract(expression: str) -> bool:
|
|
28
|
+
"""Check if a contract expression is always True (tautological).
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
>>> is_empty_contract("lambda: True"), is_empty_contract("lambda x: True")
|
|
32
|
+
(True, True)
|
|
33
|
+
>>> is_empty_contract("lambda x: x > 0"), is_empty_contract("")
|
|
34
|
+
(False, False)
|
|
35
|
+
"""
|
|
36
|
+
if not expression.strip():
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
tree = ast.parse(expression, mode="eval")
|
|
40
|
+
lambda_node = find_lambda(tree)
|
|
41
|
+
return (
|
|
42
|
+
lambda_node is not None
|
|
43
|
+
and isinstance(lambda_node.body, ast.Constant)
|
|
44
|
+
and lambda_node.body.value is True
|
|
45
|
+
)
|
|
46
|
+
except (SyntaxError, TypeError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pre(lambda expression, annotations: ("lambda" in expression and ":" in expression) or not expression.strip())
|
|
51
|
+
def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
|
|
52
|
+
"""Check if a contract only checks types already in annotations.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
>>> is_redundant_type_contract("lambda x: isinstance(x, int)", {"x": "int"})
|
|
56
|
+
True
|
|
57
|
+
>>> is_redundant_type_contract("lambda x: isinstance(x, int) and x > 0", {"x": "int"})
|
|
58
|
+
False
|
|
59
|
+
"""
|
|
60
|
+
if not expression.strip() or not annotations:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
tree = ast.parse(expression, mode="eval")
|
|
64
|
+
lambda_node = find_lambda(tree)
|
|
65
|
+
if lambda_node is None:
|
|
66
|
+
return False
|
|
67
|
+
checks = _extract_isinstance_checks(lambda_node.body)
|
|
68
|
+
if checks is None:
|
|
69
|
+
return False
|
|
70
|
+
return all(p in annotations and _types_match(annotations[p], t) for p, t in checks)
|
|
71
|
+
except (SyntaxError, TypeError, ValueError):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
76
|
+
@post(lambda result: result is None or isinstance(result, list))
|
|
77
|
+
def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
|
|
78
|
+
"""Extract isinstance checks. Returns None if other logic present."""
|
|
79
|
+
if isinstance(node, ast.Call) and hasattr(node, 'func'):
|
|
80
|
+
check = _parse_isinstance_call(node)
|
|
81
|
+
return [check] if check else None
|
|
82
|
+
if isinstance(node, ast.BoolOp) and hasattr(node, 'op') and isinstance(node.op, ast.And):
|
|
83
|
+
valid_calls = [v for v in node.values if isinstance(v, ast.Call) and hasattr(v, 'func') and hasattr(v, 'args')]
|
|
84
|
+
checks = [_parse_isinstance_call(v) for v in valid_calls]
|
|
85
|
+
return checks if len(checks) == len(node.values) and all(checks) else None
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pre(lambda node: isinstance(node, ast.Call) and hasattr(node, 'func') and hasattr(node, 'args'))
|
|
90
|
+
@post(lambda result: result is None or (isinstance(result, tuple) and len(result) == 2))
|
|
91
|
+
def _parse_isinstance_call(node: ast.Call) -> tuple[str, str] | None:
|
|
92
|
+
"""Parse isinstance(x, Type) call. Returns (param, type) or None."""
|
|
93
|
+
if not (isinstance(node.func, ast.Name) and node.func.id == "isinstance"):
|
|
94
|
+
return None
|
|
95
|
+
if len(node.args) != 2 or not isinstance(node.args[0], ast.Name):
|
|
96
|
+
return None
|
|
97
|
+
param, type_arg = node.args[0].id, node.args[1]
|
|
98
|
+
if isinstance(type_arg, ast.Name):
|
|
99
|
+
return (param, type_arg.id)
|
|
100
|
+
if isinstance(type_arg, ast.Attribute):
|
|
101
|
+
return (param, type_arg.attr)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@post(lambda result: isinstance(result, bool))
|
|
106
|
+
def _types_match(annotation: str, type_name: str) -> bool:
|
|
107
|
+
"""Check if type annotation matches isinstance check.
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> _types_match("int", "int"), _types_match("list[int]", "list")
|
|
111
|
+
(True, True)
|
|
112
|
+
"""
|
|
113
|
+
if annotation == type_name:
|
|
114
|
+
return True
|
|
115
|
+
base_match = re.match(r"^(\w+)\[", annotation)
|
|
116
|
+
return bool(base_match and base_match.group(1) == type_name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Phase 8.3: Parameter mismatch detection
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
|
|
123
|
+
def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
|
|
124
|
+
"""
|
|
125
|
+
Check if lambda has params it doesn't use (P28: Partial Contract Detection).
|
|
126
|
+
|
|
127
|
+
Returns (has_unused, unused_params, used_params).
|
|
128
|
+
|
|
129
|
+
Different from param_mismatch:
|
|
130
|
+
- param_mismatch: lambda param COUNT != function param count (ERROR)
|
|
131
|
+
- unused_params: lambda has all params but doesn't USE all (WARN)
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
>>> has_unused_params("lambda x, y: x > 0", "(x: int, y: int) -> int")
|
|
135
|
+
(True, ['y'], ['x'])
|
|
136
|
+
>>> has_unused_params("lambda x, y: x > 0 and y < 10", "(x: int, y: int) -> int")
|
|
137
|
+
(False, [], ['x', 'y'])
|
|
138
|
+
>>> has_unused_params("lambda x: x > 0", "(x: int, y: int) -> int")
|
|
139
|
+
(False, [], [])
|
|
140
|
+
>>> has_unused_params("lambda items: len(items) > 0", "(items: list) -> int")
|
|
141
|
+
(False, [], ['items'])
|
|
142
|
+
"""
|
|
143
|
+
if not expression.strip() or not signature:
|
|
144
|
+
return (False, [], [])
|
|
145
|
+
|
|
146
|
+
lambda_params = extract_lambda_params(expression)
|
|
147
|
+
func_params = extract_func_param_names(signature)
|
|
148
|
+
|
|
149
|
+
if lambda_params is None or func_params is None:
|
|
150
|
+
return (False, [], [])
|
|
151
|
+
|
|
152
|
+
# Only check when lambda has same param count as function
|
|
153
|
+
# (if different count, that's param_mismatch, not this check)
|
|
154
|
+
if len(lambda_params) != len(func_params):
|
|
155
|
+
return (False, [], [])
|
|
156
|
+
|
|
157
|
+
# Extract used names from lambda body
|
|
158
|
+
try:
|
|
159
|
+
tree = ast.parse(expression, mode="eval")
|
|
160
|
+
lambda_node = find_lambda(tree)
|
|
161
|
+
if lambda_node is None:
|
|
162
|
+
return (False, [], [])
|
|
163
|
+
used_names = extract_used_names(lambda_node.body)
|
|
164
|
+
except SyntaxError:
|
|
165
|
+
return (False, [], [])
|
|
166
|
+
|
|
167
|
+
# Check which params are actually used
|
|
168
|
+
used_params = [p for p in lambda_params if p in used_names]
|
|
169
|
+
unused_params = [p for p in lambda_params if p not in used_names]
|
|
170
|
+
|
|
171
|
+
return (len(unused_params) > 0, unused_params, used_params)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
|
|
175
|
+
def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
|
|
176
|
+
"""
|
|
177
|
+
Check if lambda params don't match function params.
|
|
178
|
+
|
|
179
|
+
Returns (has_mismatch, error_description).
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> has_param_mismatch("lambda x: x > 0", "(x: int, y: int) -> int")
|
|
183
|
+
(True, 'lambda has 1 param(s) but function has 2')
|
|
184
|
+
>>> has_param_mismatch("lambda x, y: x > 0", "(x: int, y: int) -> int")
|
|
185
|
+
(False, '')
|
|
186
|
+
>>> has_param_mismatch("lambda x, y=0: x > 0", "(x: int, y: int = 0) -> int")
|
|
187
|
+
(False, '')
|
|
188
|
+
>>> has_param_mismatch("lambda: True", "() -> bool")
|
|
189
|
+
(False, '')
|
|
190
|
+
"""
|
|
191
|
+
if not expression.strip() or not signature:
|
|
192
|
+
return (False, "")
|
|
193
|
+
|
|
194
|
+
lambda_params = extract_lambda_params(expression)
|
|
195
|
+
func_params = extract_func_param_names(signature)
|
|
196
|
+
|
|
197
|
+
if lambda_params is None or func_params is None:
|
|
198
|
+
return (False, "") # Can't determine, skip
|
|
199
|
+
|
|
200
|
+
if len(lambda_params) != len(func_params):
|
|
201
|
+
return (
|
|
202
|
+
True,
|
|
203
|
+
f"lambda has {len(lambda_params)} param(s) but function has {len(func_params)}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return (False, "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Rule checking functions
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
213
|
+
def check_empty_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
214
|
+
"""Check for empty/tautological contracts. Core files only.
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
218
|
+
>>> c = Contract(kind="pre", expression="lambda x: True", line=1)
|
|
219
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
220
|
+
>>> check_empty_contracts(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())[0].rule
|
|
221
|
+
'empty_contract'
|
|
222
|
+
"""
|
|
223
|
+
violations: list[Violation] = []
|
|
224
|
+
if not file_info.is_core:
|
|
225
|
+
return violations
|
|
226
|
+
for symbol in file_info.symbols:
|
|
227
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
228
|
+
continue
|
|
229
|
+
for contract in symbol.contracts:
|
|
230
|
+
if is_empty_contract(contract.expression):
|
|
231
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
232
|
+
violations.append(
|
|
233
|
+
Violation(
|
|
234
|
+
rule="empty_contract",
|
|
235
|
+
severity=Severity.ERROR,
|
|
236
|
+
file=file_info.path,
|
|
237
|
+
line=contract.line,
|
|
238
|
+
message=f"{kind} '{symbol.name}' has empty contract: @{contract.kind}({contract.expression})",
|
|
239
|
+
suggestion=format_suggestion_for_violation(symbol, "empty_contract"),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return violations
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
246
|
+
def check_redundant_type_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
247
|
+
"""Check for contracts that only check types in annotations. Core files only. INFO severity.
|
|
248
|
+
|
|
249
|
+
Examples:
|
|
250
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
251
|
+
>>> c = Contract(kind="pre", expression="lambda x: isinstance(x, int)", line=1)
|
|
252
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, signature="(x: int) -> int", contracts=[c])
|
|
253
|
+
>>> check_redundant_type_contracts(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())[0].severity.value
|
|
254
|
+
'info'
|
|
255
|
+
"""
|
|
256
|
+
violations: list[Violation] = []
|
|
257
|
+
if not file_info.is_core:
|
|
258
|
+
return violations
|
|
259
|
+
for symbol in file_info.symbols:
|
|
260
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
261
|
+
continue
|
|
262
|
+
annotations = extract_annotations(symbol.signature)
|
|
263
|
+
if not annotations:
|
|
264
|
+
continue
|
|
265
|
+
for contract in symbol.contracts:
|
|
266
|
+
if is_redundant_type_contract(contract.expression, annotations):
|
|
267
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
268
|
+
violations.append(
|
|
269
|
+
Violation(
|
|
270
|
+
rule="redundant_type_contract",
|
|
271
|
+
severity=Severity.INFO,
|
|
272
|
+
file=file_info.path,
|
|
273
|
+
line=contract.line,
|
|
274
|
+
message=f"{kind} '{symbol.name}' contract only checks types already in annotations",
|
|
275
|
+
suggestion=format_suggestion_for_violation(
|
|
276
|
+
symbol, "redundant_type_contract"
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return violations
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
284
|
+
def check_param_mismatch(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
285
|
+
"""Check @pre lambda params match function params. Core files only. ERROR severity.
|
|
286
|
+
|
|
287
|
+
Only checks @pre contracts (@post takes 'result' param, different signature).
|
|
288
|
+
|
|
289
|
+
Examples:
|
|
290
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
291
|
+
>>> c = Contract(kind="pre", expression="lambda x: x > 0", line=1)
|
|
292
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, signature="(x: int, y: int) -> int", contracts=[c])
|
|
293
|
+
>>> v = check_param_mismatch(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())[0]
|
|
294
|
+
>>> v.rule
|
|
295
|
+
'param_mismatch'
|
|
296
|
+
>>> v.suggestion # DX-01: Now includes fix template
|
|
297
|
+
'Fix: @pre(lambda x, y: <condition>)'
|
|
298
|
+
"""
|
|
299
|
+
violations: list[Violation] = []
|
|
300
|
+
if not file_info.is_core:
|
|
301
|
+
return violations
|
|
302
|
+
for symbol in file_info.symbols:
|
|
303
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD) or not symbol.signature:
|
|
304
|
+
continue
|
|
305
|
+
for contract in symbol.contracts:
|
|
306
|
+
# Only check @pre contracts (not @post which takes 'result')
|
|
307
|
+
if contract.kind != "pre":
|
|
308
|
+
continue
|
|
309
|
+
mismatch, desc = has_param_mismatch(contract.expression, symbol.signature)
|
|
310
|
+
if mismatch:
|
|
311
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
312
|
+
# DX-01: Generate copy-pastable lambda fix template
|
|
313
|
+
fix_template = generate_lambda_fix(symbol.signature)
|
|
314
|
+
violations.append(
|
|
315
|
+
Violation(
|
|
316
|
+
rule="param_mismatch",
|
|
317
|
+
severity=Severity.ERROR,
|
|
318
|
+
file=file_info.path,
|
|
319
|
+
line=contract.line,
|
|
320
|
+
message=f"{kind} '{symbol.name}' @pre {desc}",
|
|
321
|
+
suggestion=f"Fix: {fix_template}",
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
return violations
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
328
|
+
def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
329
|
+
"""Check @pre contracts that don't use all declared params (P28). Core files only. WARN severity.
|
|
330
|
+
|
|
331
|
+
P28: Detects hidden formal compliance - lambda declares all params but doesn't use all.
|
|
332
|
+
Forces Agent to think about whether unchecked params need constraints.
|
|
333
|
+
|
|
334
|
+
Different from param_mismatch (P8.3):
|
|
335
|
+
- param_mismatch: lambda param COUNT != function param count (ERROR)
|
|
336
|
+
- partial_contract: lambda has all params but doesn't USE all (WARN)
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
340
|
+
>>> c = Contract(kind="pre", expression="lambda x, y: x > 0", line=1)
|
|
341
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, signature="(x: int, y: int) -> int", contracts=[c])
|
|
342
|
+
>>> vs = check_partial_contract(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())
|
|
343
|
+
>>> vs[0].rule
|
|
344
|
+
'partial_contract'
|
|
345
|
+
>>> vs[0].severity
|
|
346
|
+
<Severity.WARNING: 'warning'>
|
|
347
|
+
>>> "y" in vs[0].message
|
|
348
|
+
True
|
|
349
|
+
"""
|
|
350
|
+
violations: list[Violation] = []
|
|
351
|
+
if not file_info.is_core:
|
|
352
|
+
return violations
|
|
353
|
+
for symbol in file_info.symbols:
|
|
354
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD) or not symbol.signature:
|
|
355
|
+
continue
|
|
356
|
+
for contract in symbol.contracts:
|
|
357
|
+
# Only check @pre contracts (not @post which takes 'result')
|
|
358
|
+
if contract.kind != "pre":
|
|
359
|
+
continue
|
|
360
|
+
has_unused, unused, used = has_unused_params(contract.expression, symbol.signature)
|
|
361
|
+
if has_unused:
|
|
362
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
363
|
+
unused_str = ", ".join(f"'{p}'" for p in unused)
|
|
364
|
+
used_str = ", ".join(f"'{p}'" for p in used) if used else "none"
|
|
365
|
+
violations.append(
|
|
366
|
+
Violation(
|
|
367
|
+
rule="partial_contract",
|
|
368
|
+
severity=Severity.WARNING,
|
|
369
|
+
file=file_info.path,
|
|
370
|
+
line=contract.line,
|
|
371
|
+
message=f"{kind} '{symbol.name}' @pre checks {used_str} but not {unused_str}",
|
|
372
|
+
suggestion=f"Signature: {symbol.signature}\n→ Add constraint for {unused_str} or verify it needs none",
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return violations
|