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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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
@@ -0,0 +1,8 @@
1
+ """
2
+ Core module: Pure logic, no I/O.
3
+
4
+ This module contains:
5
+ - models.py: Pydantic models for symbols, violations, etc.
6
+ - parser.py: AST parsing and symbol extraction
7
+ - rules.py: Rule definitions and checking logic
8
+ """
@@ -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