invar-tools 1.4.0__py3-none-any.whl → 1.6.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 (34) hide show
  1. invar/__init__.py +7 -1
  2. invar/core/entry_points.py +12 -10
  3. invar/core/formatter.py +21 -1
  4. invar/core/models.py +98 -0
  5. invar/core/patterns/__init__.py +53 -0
  6. invar/core/patterns/detector.py +249 -0
  7. invar/core/patterns/p0_exhaustive.py +207 -0
  8. invar/core/patterns/p0_literal.py +307 -0
  9. invar/core/patterns/p0_newtype.py +211 -0
  10. invar/core/patterns/p0_nonempty.py +307 -0
  11. invar/core/patterns/p0_validation.py +278 -0
  12. invar/core/patterns/registry.py +234 -0
  13. invar/core/patterns/types.py +167 -0
  14. invar/core/trivial_detection.py +189 -0
  15. invar/mcp/server.py +4 -0
  16. invar/shell/commands/guard.py +100 -8
  17. invar/shell/config.py +46 -0
  18. invar/shell/contract_coverage.py +358 -0
  19. invar/shell/guard_output.py +15 -0
  20. invar/shell/pattern_integration.py +234 -0
  21. invar/shell/testing.py +13 -2
  22. invar/templates/CLAUDE.md.template +18 -10
  23. invar/templates/config/CLAUDE.md.jinja +52 -30
  24. invar/templates/config/context.md.jinja +14 -0
  25. invar/templates/protocol/INVAR.md +1 -0
  26. invar/templates/skills/develop/SKILL.md.jinja +51 -1
  27. invar/templates/skills/review/SKILL.md.jinja +196 -31
  28. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
  29. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
  30. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
  31. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
  32. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
  33. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
  34. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,211 @@
1
+ """
2
+ NewType Pattern Detector (DX-61, P0).
3
+
4
+ Detects opportunities to use NewType for semantic clarity when
5
+ multiple parameters share the same primitive type.
6
+ """
7
+
8
+ import ast
9
+ from typing import ClassVar
10
+
11
+ from deal import post, pre
12
+
13
+ from invar.core.patterns.detector import BaseDetector
14
+ from invar.core.patterns.types import (
15
+ Confidence,
16
+ PatternID,
17
+ PatternSuggestion,
18
+ Priority,
19
+ )
20
+
21
+
22
+ class NewTypeDetector(BaseDetector):
23
+ """
24
+ Detect functions with 3+ parameters of the same primitive type.
25
+
26
+ These are candidates for NewType to prevent parameter confusion.
27
+
28
+ Detection logic:
29
+ - Find functions with 3+ str/int/float params of same type
30
+ - Exclude common patterns (e.g., *args, **kwargs)
31
+ - Suggest NewType for semantic differentiation
32
+
33
+ >>> import ast
34
+ >>> detector = NewTypeDetector()
35
+ >>> code = '''
36
+ ... def process(user_id: str, order_id: str, product_id: str):
37
+ ... pass
38
+ ... '''
39
+ >>> tree = ast.parse(code)
40
+ >>> suggestions = detector.detect(tree, "test.py")
41
+ >>> len(suggestions) > 0
42
+ True
43
+ >>> suggestions[0].pattern_id == PatternID.NEWTYPE
44
+ True
45
+ """
46
+
47
+ PRIMITIVE_TYPES: ClassVar[set[str]] = {"str", "int", "float", "bool", "bytes"}
48
+ MIN_SAME_TYPE_PARAMS: ClassVar[int] = 3
49
+
50
+ @property
51
+ @post(lambda result: result == PatternID.NEWTYPE)
52
+ def pattern_id(self) -> PatternID:
53
+ """Unique identifier for this pattern."""
54
+ return PatternID.NEWTYPE
55
+
56
+ @property
57
+ @post(lambda result: result == Priority.P0)
58
+ def priority(self) -> Priority:
59
+ """Priority tier."""
60
+ return Priority.P0
61
+
62
+ @property
63
+ @post(lambda result: len(result) > 0)
64
+ def description(self) -> str:
65
+ """Human-readable description."""
66
+ return "Use NewType for semantic clarity with multiple same-type parameters"
67
+
68
+ @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
69
+ def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
70
+ """
71
+ Find functions with multiple parameters of the same primitive type.
72
+
73
+ >>> import ast
74
+ >>> detector = NewTypeDetector()
75
+ >>> code = '''
76
+ ... def good(a: str, b: int):
77
+ ... pass
78
+ ... def bad(user_id: str, order_id: str, product_id: str):
79
+ ... pass
80
+ ... '''
81
+ >>> tree = ast.parse(code)
82
+ >>> suggestions = detector.detect(tree, "test.py")
83
+ >>> len(suggestions)
84
+ 1
85
+ >>> "bad" in suggestions[0].current_code
86
+ True
87
+ """
88
+ suggestions = []
89
+
90
+ for node in ast.walk(tree):
91
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
92
+ suggestion = self._check_function(node, file_path)
93
+ if suggestion:
94
+ suggestions.append(suggestion)
95
+
96
+ return suggestions
97
+
98
+ @pre(lambda self, node, file_path: len(file_path) > 0)
99
+ def _check_function(
100
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
101
+ ) -> PatternSuggestion | None:
102
+ """
103
+ Check if function has multiple params of same primitive type.
104
+
105
+ >>> import ast
106
+ >>> detector = NewTypeDetector()
107
+ >>> code = "def f(user_id: str, order_id: str, product_id: str): pass"
108
+ >>> tree = ast.parse(code)
109
+ >>> func = tree.body[0]
110
+ >>> suggestion = detector._check_function(func, "test.py")
111
+ >>> suggestion is not None
112
+ True
113
+ >>> suggestion.confidence == Confidence.HIGH
114
+ True
115
+ """
116
+ params = self.get_function_params(node)
117
+
118
+ # Skip if too few parameters
119
+ if len(params) < self.MIN_SAME_TYPE_PARAMS:
120
+ return None
121
+
122
+ # Count occurrences of each primitive type
123
+ for prim_type in self.PRIMITIVE_TYPES:
124
+ count = self.count_type_occurrences(params, prim_type)
125
+ if count >= self.MIN_SAME_TYPE_PARAMS:
126
+ # Found opportunity
127
+ matching_params = [name for name, t in params if t == prim_type]
128
+ confidence = self._calculate_confidence(matching_params, node)
129
+
130
+ return self.make_suggestion(
131
+ pattern_id=self.pattern_id,
132
+ priority=self.priority,
133
+ file_path=file_path,
134
+ line=node.lineno,
135
+ message=f"{count} '{prim_type}' params - consider NewType for semantic clarity",
136
+ current_code=self._format_signature(node),
137
+ suggested_pattern=self._suggest_newtypes(matching_params, prim_type),
138
+ confidence=confidence,
139
+ reference_pattern="Pattern 1: NewType for Semantic Clarity",
140
+ )
141
+
142
+ return None
143
+
144
+ @pre(lambda self, param_names, _node: len(param_names) > 0)
145
+ @post(lambda result: result in Confidence)
146
+ def _calculate_confidence(
147
+ self, param_names: list[str], _node: ast.FunctionDef | ast.AsyncFunctionDef
148
+ ) -> Confidence:
149
+ """
150
+ Calculate confidence based on parameter naming patterns.
151
+
152
+ Higher confidence if names suggest distinct entities (e.g., *_id patterns).
153
+
154
+ >>> detector = NewTypeDetector()
155
+ >>> import ast
156
+ >>> func = ast.parse("def f(user_id, order_id, product_id): pass").body[0]
157
+ >>> detector._calculate_confidence(["user_id", "order_id", "product_id"], func)
158
+ <Confidence.HIGH: 'high'>
159
+ """
160
+ # High confidence if names follow *_id, *_name, or *_code patterns
161
+ id_pattern = sum(1 for n in param_names if n.endswith(("_id", "_name", "_code", "_key")))
162
+ if id_pattern >= 2:
163
+ return Confidence.HIGH
164
+
165
+ # Medium confidence for descriptive names
166
+ if all(len(n) > 3 for n in param_names):
167
+ return Confidence.MEDIUM
168
+
169
+ # Low confidence for short/generic names
170
+ return Confidence.LOW
171
+
172
+ @post(lambda result: len(result) > 0 and "def " in result)
173
+ def _format_signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
174
+ """
175
+ Format function signature for display.
176
+
177
+ >>> import ast
178
+ >>> detector = NewTypeDetector()
179
+ >>> func = ast.parse("def process(a: str, b: str): pass").body[0]
180
+ >>> sig = detector._format_signature(func)
181
+ >>> "process" in sig
182
+ True
183
+ """
184
+ params = self.get_function_params(node)
185
+ param_str = ", ".join(
186
+ f"{name}: {t}" if t else name
187
+ for name, t in params[:5] # Limit for readability
188
+ )
189
+ if len(params) > 5:
190
+ param_str += ", ..."
191
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
192
+ return f"{prefix} {node.name}({param_str})"
193
+
194
+ @pre(lambda self, param_names, base_type: len(param_names) > 0 and len(base_type) > 0)
195
+ @post(lambda result: "NewType" in result)
196
+ def _suggest_newtypes(self, param_names: list[str], base_type: str) -> str:
197
+ """
198
+ Generate NewType suggestion for parameters.
199
+
200
+ >>> detector = NewTypeDetector()
201
+ >>> detector._suggest_newtypes(["user_id", "order_id"], "str")
202
+ "NewType('UserId', str), NewType('OrderId', str)"
203
+ """
204
+ newtypes = []
205
+ for name in param_names[:3]: # Limit suggestions
206
+ # Convert snake_case to PascalCase
207
+ pascal = "".join(word.capitalize() for word in name.split("_"))
208
+ newtypes.append(f"NewType('{pascal}', {base_type})")
209
+ if len(param_names) > 3:
210
+ newtypes.append("...")
211
+ return ", ".join(newtypes)
@@ -0,0 +1,307 @@
1
+ """
2
+ NonEmpty Pattern Detector (DX-61, P0).
3
+
4
+ Detects runtime empty-collection checks that could benefit from
5
+ compile-time NonEmpty type safety.
6
+ """
7
+
8
+ import ast
9
+
10
+ from deal import post, pre
11
+
12
+ from invar.core.patterns.detector import BaseDetector
13
+ from invar.core.patterns.types import (
14
+ Confidence,
15
+ PatternID,
16
+ PatternSuggestion,
17
+ Priority,
18
+ )
19
+
20
+
21
+ class NonEmptyDetector(BaseDetector):
22
+ """
23
+ Detect runtime checks for empty collections.
24
+
25
+ These are candidates for NonEmpty type to guarantee non-emptiness
26
+ at compile time instead of runtime.
27
+
28
+ Detection logic:
29
+ - Find 'if not items:' or 'if len(items) == 0:' patterns
30
+ - Look for raises or early returns after such checks
31
+ - Suggest NonEmpty type for the parameter
32
+
33
+ >>> import ast
34
+ >>> detector = NonEmptyDetector()
35
+ >>> code = '''
36
+ ... def summarize(items: list[str]) -> str:
37
+ ... if not items:
38
+ ... raise ValueError("Cannot summarize empty list")
39
+ ... return f"First: {items[0]}"
40
+ ... '''
41
+ >>> tree = ast.parse(code)
42
+ >>> suggestions = detector.detect(tree, "test.py")
43
+ >>> len(suggestions) > 0
44
+ True
45
+ """
46
+
47
+ @property
48
+ @post(lambda result: result == PatternID.NONEMPTY)
49
+ def pattern_id(self) -> PatternID:
50
+ """Unique identifier for this pattern."""
51
+ return PatternID.NONEMPTY
52
+
53
+ @property
54
+ @post(lambda result: result == Priority.P0)
55
+ def priority(self) -> Priority:
56
+ """Priority tier."""
57
+ return Priority.P0
58
+
59
+ @property
60
+ @post(lambda result: len(result) > 0)
61
+ def description(self) -> str:
62
+ """Human-readable description."""
63
+ return "Use NonEmpty type for compile-time non-empty guarantees"
64
+
65
+ @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
66
+ def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
67
+ """
68
+ Find functions with runtime empty-collection checks.
69
+
70
+ >>> import ast
71
+ >>> detector = NonEmptyDetector()
72
+ >>> code = '''
73
+ ... def process(data: list[int]):
74
+ ... if len(data) == 0:
75
+ ... raise ValueError("Empty data")
76
+ ... return data[0]
77
+ ... '''
78
+ >>> tree = ast.parse(code)
79
+ >>> suggestions = detector.detect(tree, "test.py")
80
+ >>> len(suggestions) > 0
81
+ True
82
+ """
83
+ suggestions = []
84
+
85
+ for node in ast.walk(tree):
86
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
87
+ suggestion = self._check_function(node, file_path)
88
+ if suggestion:
89
+ suggestions.append(suggestion)
90
+
91
+ return suggestions
92
+
93
+ @pre(lambda self, node, file_path: len(file_path) > 0)
94
+ def _check_function(
95
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
96
+ ) -> PatternSuggestion | None:
97
+ """
98
+ Check if function has empty-collection guard patterns.
99
+
100
+ >>> import ast
101
+ >>> detector = NonEmptyDetector()
102
+ >>> code = '''
103
+ ... def f(items):
104
+ ... if not items:
105
+ ... raise ValueError("Empty")
106
+ ... return items[0]
107
+ ... '''
108
+ >>> tree = ast.parse(code)
109
+ >>> func = tree.body[0]
110
+ >>> suggestion = detector._check_function(func, "test.py")
111
+ >>> suggestion is not None
112
+ True
113
+ """
114
+ empty_checks = self._find_empty_checks(node)
115
+
116
+ if empty_checks:
117
+ var_name, check_line = empty_checks[0]
118
+ param_type = self._get_param_type(node, var_name)
119
+ confidence = self._calculate_confidence(node, var_name, param_type)
120
+
121
+ return self.make_suggestion(
122
+ pattern_id=self.pattern_id,
123
+ priority=self.priority,
124
+ file_path=file_path,
125
+ line=check_line,
126
+ message=f"Runtime empty check on '{var_name}' - consider NonEmpty type",
127
+ current_code=self._format_check(var_name, param_type),
128
+ suggested_pattern=f"NonEmpty[{param_type or 'T'}] guarantees non-empty at compile time",
129
+ confidence=confidence,
130
+ reference_pattern="Pattern 3: NonEmpty for Compile-Time Safety",
131
+ )
132
+
133
+ return None
134
+
135
+ @post(lambda result: all(isinstance(v, str) and line > 0 for v, line in result))
136
+ def _find_empty_checks(
137
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef
138
+ ) -> list[tuple[str, int]]:
139
+ """
140
+ Find 'if not x' or 'if len(x) == 0' patterns with raise/return.
141
+
142
+ Only checks if statements at the function level, not nested functions.
143
+
144
+ >>> import ast
145
+ >>> detector = NonEmptyDetector()
146
+ >>> code = '''
147
+ ... def f(items):
148
+ ... if not items:
149
+ ... raise ValueError("Empty")
150
+ ... '''
151
+ >>> tree = ast.parse(code)
152
+ >>> func = tree.body[0]
153
+ >>> checks = detector._find_empty_checks(func)
154
+ >>> len(checks) > 0
155
+ True
156
+ >>> checks[0][0]
157
+ 'items'
158
+ """
159
+ checks: list[tuple[str, int]] = []
160
+ self._collect_empty_checks(node.body, checks)
161
+ return checks
162
+
163
+ @pre(lambda self, stmts, checks: stmts is not None and checks is not None)
164
+ def _collect_empty_checks(
165
+ self, stmts: list[ast.stmt], checks: list[tuple[str, int]]
166
+ ) -> None:
167
+ """
168
+ Recursively collect empty checks, avoiding nested functions.
169
+
170
+ >>> import ast
171
+ >>> detector = NonEmptyDetector()
172
+ >>> stmts = ast.parse("if not x: raise ValueError('e')").body
173
+ >>> checks: list[tuple[str, int]] = []
174
+ >>> detector._collect_empty_checks(stmts, checks)
175
+ >>> len(checks)
176
+ 1
177
+ """
178
+ for stmt in stmts:
179
+ # Skip nested functions
180
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
181
+ continue
182
+
183
+ if isinstance(stmt, ast.If):
184
+ var_name = self._extract_empty_check_var(stmt.test)
185
+ if var_name and self._has_raise_or_return(stmt.body):
186
+ checks.append((var_name, stmt.lineno))
187
+ # Recurse into if body and else
188
+ self._collect_empty_checks(stmt.body, checks)
189
+ self._collect_empty_checks(stmt.orelse, checks)
190
+
191
+ @post(lambda result: result is None or (isinstance(result, str) and len(result) > 0))
192
+ def _extract_empty_check_var(self, test: ast.expr) -> str | None:
193
+ """
194
+ Extract variable name from empty-check condition.
195
+
196
+ Handles:
197
+ - 'not items' -> 'items'
198
+ - 'len(items) == 0' -> 'items'
199
+ - 'len(items) < 1' -> 'items'
200
+
201
+ >>> import ast
202
+ >>> detector = NonEmptyDetector()
203
+ >>> test = ast.parse("not items", mode="eval").body
204
+ >>> detector._extract_empty_check_var(test)
205
+ 'items'
206
+ """
207
+ # Handle 'not items'
208
+ if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
209
+ if isinstance(test.operand, ast.Name):
210
+ return test.operand.id
211
+
212
+ # Handle 'len(items) == 0' or 'len(items) < 1'
213
+ if isinstance(test, ast.Compare):
214
+ if len(test.ops) == 1 and len(test.comparators) == 1:
215
+ left = test.left
216
+ op = test.ops[0]
217
+ right = test.comparators[0]
218
+
219
+ # Check for len(x) on left
220
+ if (
221
+ isinstance(left, ast.Call)
222
+ and isinstance(left.func, ast.Name)
223
+ and left.func.id == "len"
224
+ and len(left.args) == 1
225
+ and isinstance(left.args[0], ast.Name)
226
+ ):
227
+ var_name = left.args[0].id
228
+
229
+ # Check for == 0 or < 1
230
+ if isinstance(right, ast.Constant):
231
+ if isinstance(op, ast.Eq) and right.value == 0:
232
+ return var_name
233
+ if isinstance(op, ast.Lt) and right.value == 1:
234
+ return var_name
235
+
236
+ return None
237
+
238
+ @post(lambda result: isinstance(result, bool))
239
+ def _has_raise_or_return(self, body: list[ast.stmt]) -> bool:
240
+ """
241
+ Check if body contains raise or return statement.
242
+
243
+ >>> import ast
244
+ >>> detector = NonEmptyDetector()
245
+ >>> body = ast.parse("raise ValueError('x')").body
246
+ >>> detector._has_raise_or_return(body)
247
+ True
248
+ """
249
+ return any(isinstance(stmt, (ast.Raise, ast.Return)) for stmt in body)
250
+
251
+ @pre(lambda self, node, var_name: len(var_name) > 0)
252
+ def _get_param_type(
253
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, var_name: str
254
+ ) -> str | None:
255
+ """
256
+ Get type annotation for a parameter.
257
+
258
+ >>> import ast
259
+ >>> detector = NonEmptyDetector()
260
+ >>> func = ast.parse("def f(items: list[str]): pass").body[0]
261
+ >>> detector._get_param_type(func, "items")
262
+ 'list[str]'
263
+ """
264
+ for arg in node.args.args:
265
+ if arg.arg == var_name and arg.annotation:
266
+ return self._annotation_to_str(arg.annotation)
267
+ return None
268
+
269
+ @pre(lambda self, _node, var_name, param_type: len(var_name) > 0)
270
+ @post(lambda result: result in Confidence)
271
+ def _calculate_confidence(
272
+ self,
273
+ _node: ast.FunctionDef | ast.AsyncFunctionDef,
274
+ var_name: str,
275
+ param_type: str | None,
276
+ ) -> Confidence:
277
+ """
278
+ Calculate confidence based on context.
279
+
280
+ >>> import ast
281
+ >>> detector = NonEmptyDetector()
282
+ >>> func = ast.parse("def f(items: list[str]): pass").body[0]
283
+ >>> detector._calculate_confidence(func, "items", "list[str]")
284
+ <Confidence.HIGH: 'high'>
285
+ """
286
+ # High confidence if typed as list[T]
287
+ if param_type and param_type.startswith("list["):
288
+ return Confidence.HIGH
289
+
290
+ # Medium confidence if var name suggests collection
291
+ if any(kw in var_name.lower() for kw in ("items", "list", "elements", "data")):
292
+ return Confidence.MEDIUM
293
+
294
+ return Confidence.LOW
295
+
296
+ @pre(lambda self, var_name, param_type: len(var_name) > 0)
297
+ @post(lambda result: len(result) > 0 and "if not" in result)
298
+ def _format_check(self, var_name: str, param_type: str | None) -> str:
299
+ """
300
+ Format the empty check for display.
301
+
302
+ >>> detector = NonEmptyDetector()
303
+ >>> detector._format_check("items", "list[str]")
304
+ 'if not items: raise ... (items: list[str])'
305
+ """
306
+ type_info = f" ({var_name}: {param_type})" if param_type else ""
307
+ return f"if not {var_name}: raise ...{type_info}"