strict-module 0.5.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.
- strict_module/__init__.py +7 -0
- strict_module/checkers.py +806 -0
- strict_module/cli.py +152 -0
- strict_module/config/__init__.py +6 -0
- strict_module/config/_config.py +97 -0
- strict_module/config/_version.py +3 -0
- strict_module/linter.py +212 -0
- strict_module/loc_cap.py +228 -0
- strict_module/py.typed +0 -0
- strict_module/rules.py +362 -0
- strict_module-0.5.0.dist-info/METADATA +740 -0
- strict_module-0.5.0.dist-info/RECORD +15 -0
- strict_module-0.5.0.dist-info/WHEEL +4 -0
- strict_module-0.5.0.dist-info/entry_points.txt +3 -0
- strict_module-0.5.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""AST checker implementations for each rule."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .rules import (
|
|
8
|
+
RuleSeverity,
|
|
9
|
+
Violation,
|
|
10
|
+
is_dict_str_any,
|
|
11
|
+
is_bare_collection,
|
|
12
|
+
contains_any,
|
|
13
|
+
has_noqa_comment,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseChecker(ast.NodeVisitor):
|
|
18
|
+
"""Base class for AST checkers."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, file_path: Path, source: str, config: Config):
|
|
21
|
+
self.file_path = file_path
|
|
22
|
+
self.source = source
|
|
23
|
+
self.config = config
|
|
24
|
+
self.violations: list[Violation] = []
|
|
25
|
+
self.lines = source.splitlines()
|
|
26
|
+
|
|
27
|
+
def get_comment_text(self, line_num: int) -> str:
|
|
28
|
+
"""Get comment text for a line."""
|
|
29
|
+
if line_num <= 0 or line_num > len(self.lines):
|
|
30
|
+
return ""
|
|
31
|
+
line = self.lines[line_num - 1]
|
|
32
|
+
if "#" in line:
|
|
33
|
+
return line.split("#", 1)[1].strip()
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
def is_suppressed(self, node: ast.AST, rule_id: str) -> bool:
|
|
37
|
+
"""Check if node has a noqa comment suppressing the given rule."""
|
|
38
|
+
return has_noqa_comment(node, rule_id, self.lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class R001Checker(BaseChecker):
|
|
42
|
+
"""Check for Dict[str, Any] or bare collections in service-layer function signatures."""
|
|
43
|
+
|
|
44
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
45
|
+
"""Visit function definitions."""
|
|
46
|
+
from .rules import is_service_path
|
|
47
|
+
|
|
48
|
+
if not is_service_path(self.file_path, self.config.service_paths):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Check if rule is suppressed on this node
|
|
52
|
+
if self.is_suppressed(node, "R001"):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Check parameters
|
|
56
|
+
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
|
|
57
|
+
if arg.annotation:
|
|
58
|
+
if is_dict_str_any(arg.annotation):
|
|
59
|
+
self.violations.append(
|
|
60
|
+
Violation(
|
|
61
|
+
rule_id="R001",
|
|
62
|
+
severity=RuleSeverity.HIGH,
|
|
63
|
+
file=str(self.file_path),
|
|
64
|
+
line=node.lineno,
|
|
65
|
+
col=node.col_offset,
|
|
66
|
+
message=f"Dict[str, Any] in signature: {node.name}",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return
|
|
70
|
+
elif self.config.strict_collections and is_bare_collection(
|
|
71
|
+
arg.annotation
|
|
72
|
+
):
|
|
73
|
+
self.violations.append(
|
|
74
|
+
Violation(
|
|
75
|
+
rule_id="R001",
|
|
76
|
+
severity=RuleSeverity.HIGH,
|
|
77
|
+
file=str(self.file_path),
|
|
78
|
+
line=node.lineno,
|
|
79
|
+
col=node.col_offset,
|
|
80
|
+
message=f"Bare collection type in signature: {node.name} (use strict_collections=False to disable)",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Check varargs and kwargs
|
|
86
|
+
if node.args.vararg and node.args.vararg.annotation:
|
|
87
|
+
if is_dict_str_any(node.args.vararg.annotation):
|
|
88
|
+
self.violations.append(
|
|
89
|
+
Violation(
|
|
90
|
+
rule_id="R001",
|
|
91
|
+
severity=RuleSeverity.HIGH,
|
|
92
|
+
file=str(self.file_path),
|
|
93
|
+
line=node.lineno,
|
|
94
|
+
col=node.col_offset,
|
|
95
|
+
message=f"Dict[str, Any] in signature: {node.name}",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
elif self.config.strict_collections and is_bare_collection(
|
|
100
|
+
node.args.vararg.annotation
|
|
101
|
+
):
|
|
102
|
+
self.violations.append(
|
|
103
|
+
Violation(
|
|
104
|
+
rule_id="R001",
|
|
105
|
+
severity=RuleSeverity.HIGH,
|
|
106
|
+
file=str(self.file_path),
|
|
107
|
+
line=node.lineno,
|
|
108
|
+
col=node.col_offset,
|
|
109
|
+
message=f"Bare collection type in signature: {node.name} (use strict_collections=False to disable)",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if node.args.kwarg and node.args.kwarg.annotation:
|
|
115
|
+
if is_dict_str_any(node.args.kwarg.annotation):
|
|
116
|
+
self.violations.append(
|
|
117
|
+
Violation(
|
|
118
|
+
rule_id="R001",
|
|
119
|
+
severity=RuleSeverity.HIGH,
|
|
120
|
+
file=str(self.file_path),
|
|
121
|
+
line=node.lineno,
|
|
122
|
+
col=node.col_offset,
|
|
123
|
+
message=f"Dict[str, Any] in signature: {node.name}",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
elif self.config.strict_collections and is_bare_collection(
|
|
128
|
+
node.args.kwarg.annotation
|
|
129
|
+
):
|
|
130
|
+
self.violations.append(
|
|
131
|
+
Violation(
|
|
132
|
+
rule_id="R001",
|
|
133
|
+
severity=RuleSeverity.HIGH,
|
|
134
|
+
file=str(self.file_path),
|
|
135
|
+
line=node.lineno,
|
|
136
|
+
col=node.col_offset,
|
|
137
|
+
message=f"Bare collection type in signature: {node.name} (use strict_collections=False to disable)",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Check return type
|
|
143
|
+
if node.returns:
|
|
144
|
+
if is_dict_str_any(node.returns):
|
|
145
|
+
self.violations.append(
|
|
146
|
+
Violation(
|
|
147
|
+
rule_id="R001",
|
|
148
|
+
severity=RuleSeverity.HIGH,
|
|
149
|
+
file=str(self.file_path),
|
|
150
|
+
line=node.lineno,
|
|
151
|
+
col=node.col_offset,
|
|
152
|
+
message=f"Dict[str, Any] in signature: {node.name}",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
elif self.config.strict_collections and is_bare_collection(node.returns):
|
|
156
|
+
self.violations.append(
|
|
157
|
+
Violation(
|
|
158
|
+
rule_id="R001",
|
|
159
|
+
severity=RuleSeverity.HIGH,
|
|
160
|
+
file=str(self.file_path),
|
|
161
|
+
line=node.lineno,
|
|
162
|
+
col=node.col_offset,
|
|
163
|
+
message=f"Bare collection type in signature: {node.name} (use strict_collections=False to disable)",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self.generic_visit(node)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class R002Checker(BaseChecker):
|
|
171
|
+
"""Check for inline dict literals with 3+ string keys."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, file_path: Path, source: str, config: Config):
|
|
174
|
+
super().__init__(file_path, source, config)
|
|
175
|
+
self.in_service_file = False
|
|
176
|
+
self.tag_count = 0
|
|
177
|
+
self.current_function = None
|
|
178
|
+
self.current_class = None # Track current class for to_* serializer detection
|
|
179
|
+
self.function_comments = {}
|
|
180
|
+
self.dict_parents = {} # Map dict node id to parent node
|
|
181
|
+
self.current_class = None # Track current class for to_* serializer detection
|
|
182
|
+
from .rules import is_service_path
|
|
183
|
+
|
|
184
|
+
self.in_service_file = is_service_path(file_path, config.service_paths)
|
|
185
|
+
|
|
186
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
187
|
+
"""Track class definitions to detect to_* serializer methods."""
|
|
188
|
+
old_class = self.current_class
|
|
189
|
+
self.current_class = node
|
|
190
|
+
self._track_parents(node)
|
|
191
|
+
self.generic_visit(node)
|
|
192
|
+
self.current_class = old_class
|
|
193
|
+
|
|
194
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
195
|
+
"""Track function definitions to associate comments."""
|
|
196
|
+
self.current_function = node
|
|
197
|
+
comment = self.get_comment_text(node.lineno)
|
|
198
|
+
self.function_comments[id(node)] = comment
|
|
199
|
+
self.generic_visit(node)
|
|
200
|
+
|
|
201
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
202
|
+
"""Visit module to track parent relationships for dicts."""
|
|
203
|
+
self._track_parents(node)
|
|
204
|
+
self.generic_visit(node)
|
|
205
|
+
|
|
206
|
+
def _track_parents(self, node: ast.AST) -> None:
|
|
207
|
+
"""Recursively track parent relationships for all dict nodes."""
|
|
208
|
+
for child in ast.walk(node):
|
|
209
|
+
for grandchild in ast.iter_child_nodes(child):
|
|
210
|
+
if isinstance(grandchild, ast.Dict):
|
|
211
|
+
self.dict_parents[id(grandchild)] = child
|
|
212
|
+
|
|
213
|
+
def _is_annotated_constant(self, dict_node: ast.Dict) -> bool:
|
|
214
|
+
"""Check if dict is inside an annotated assignment (AnnAssign).
|
|
215
|
+
|
|
216
|
+
Returns True if the dict is the value of an ast.AnnAssign node
|
|
217
|
+
at module or class level. This indicates a typed constant declaration,
|
|
218
|
+
not an inline business-shape literal.
|
|
219
|
+
"""
|
|
220
|
+
parent = self.dict_parents.get(id(dict_node))
|
|
221
|
+
if parent is None:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
# Check if parent is AnnAssign (annotated assignment)
|
|
225
|
+
if isinstance(parent, ast.AnnAssign):
|
|
226
|
+
# Check if dict is the assigned value
|
|
227
|
+
if parent.value is dict_node:
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def _is_dataclass_decorator(self, node: ast.expr) -> bool:
|
|
233
|
+
"""Check if decorator is @dataclass (any form)."""
|
|
234
|
+
if isinstance(node, ast.Name):
|
|
235
|
+
return node.id == "dataclass"
|
|
236
|
+
elif isinstance(node, ast.Call):
|
|
237
|
+
if isinstance(node.func, ast.Name):
|
|
238
|
+
return node.func.id == "dataclass"
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def _is_class_decorated_with_dataclass(self, class_node: ast.ClassDef) -> bool:
|
|
242
|
+
"""Check if class is decorated with @dataclass."""
|
|
243
|
+
for decorator in class_node.decorator_list:
|
|
244
|
+
if self._is_dataclass_decorator(decorator):
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def _is_serializer_return_dict(self, dict_node: ast.Dict) -> bool:
|
|
249
|
+
"""Check if dict is returned from a to_* method in a dataclass.
|
|
250
|
+
|
|
251
|
+
Returns True if:
|
|
252
|
+
- The dict is inside a return statement
|
|
253
|
+
- The method name starts with 'to_'
|
|
254
|
+
- The method is defined in a class decorated with @dataclass
|
|
255
|
+
"""
|
|
256
|
+
if self.current_class is None:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
# Must be in a dataclass
|
|
260
|
+
if not self._is_class_decorated_with_dataclass(self.current_class):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Must be in a to_* method
|
|
264
|
+
if self.current_function is None or not self.current_function.name.startswith(
|
|
265
|
+
"to_"
|
|
266
|
+
):
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
# Check if the dict is inside a return statement of the current function
|
|
270
|
+
for stmt in ast.walk(self.current_function):
|
|
271
|
+
if isinstance(stmt, ast.Return) and stmt.value:
|
|
272
|
+
# Check if dict_node is inside the return value
|
|
273
|
+
for child in ast.walk(stmt.value):
|
|
274
|
+
if child is dict_node:
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
def visit_Dict(self, node: ast.Dict) -> None:
|
|
280
|
+
"""Visit dict literals."""
|
|
281
|
+
if not self.in_service_file:
|
|
282
|
+
self.generic_visit(node)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Check if rule is suppressed on this node
|
|
286
|
+
if self.is_suppressed(node, "R002"):
|
|
287
|
+
self.generic_visit(node)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# SKIP: Annotated module/class-level constants (typed declarations)
|
|
291
|
+
# These are like: DISPLAY_LABELS: dict[str, str] = {...}
|
|
292
|
+
# The explicit type annotation signals intent ('typed static data'),
|
|
293
|
+
# distinct from inline business-shape literals in function bodies.
|
|
294
|
+
if self._is_annotated_constant(node):
|
|
295
|
+
self.generic_visit(node)
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# SKIP: Dict literals returned from to_* methods in dataclasses
|
|
299
|
+
# These are serializer outputs, not unconverted business shapes.
|
|
300
|
+
# Example: a DTO's to_dict() method returns a 3+-key dict for serialization.
|
|
301
|
+
if self._is_serializer_return_dict(node):
|
|
302
|
+
self.generic_visit(node)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# Count string keys
|
|
306
|
+
string_keys = sum(
|
|
307
|
+
1
|
|
308
|
+
for key in node.keys
|
|
309
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Skip trivial dicts and unpacking dicts (use configurable threshold)
|
|
313
|
+
if (
|
|
314
|
+
string_keys >= self.config.min_dict_keys
|
|
315
|
+
and len(node.keys) >= self.config.min_dict_keys
|
|
316
|
+
):
|
|
317
|
+
# Check for exception tag on dict line or function line
|
|
318
|
+
comment = self.get_comment_text(node.lineno)
|
|
319
|
+
if not comment and self.current_function:
|
|
320
|
+
comment = self.function_comments.get(id(self.current_function), "")
|
|
321
|
+
|
|
322
|
+
has_tag = any(tag in comment for tag in self.config.exception_tags)
|
|
323
|
+
|
|
324
|
+
if has_tag:
|
|
325
|
+
# Validate justification if required
|
|
326
|
+
if self.config.exception_tag_requires_justification:
|
|
327
|
+
# Tag must include a colon followed by justification
|
|
328
|
+
has_justification = False
|
|
329
|
+
for tag in self.config.exception_tags:
|
|
330
|
+
if tag in comment:
|
|
331
|
+
# Check if there's a colon and text after the tag
|
|
332
|
+
tag_idx = comment.find(tag)
|
|
333
|
+
after_tag = comment[tag_idx + len(tag) :].strip()
|
|
334
|
+
if after_tag.startswith(":") and len(after_tag) > 1:
|
|
335
|
+
has_justification = True
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if not has_justification:
|
|
339
|
+
self.violations.append(
|
|
340
|
+
Violation(
|
|
341
|
+
rule_id="R002",
|
|
342
|
+
severity=RuleSeverity.MEDIUM,
|
|
343
|
+
file=str(self.file_path),
|
|
344
|
+
line=node.lineno,
|
|
345
|
+
col=node.col_offset,
|
|
346
|
+
message="Exception tag missing justification. Format: 'tag: explanation' (e.g., 'facade — celery schedule: transient event payload')",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
self.generic_visit(node)
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# Track tag usage if max_exception_tags_per_file is set
|
|
353
|
+
if self.config.max_exception_tags_per_file is not None:
|
|
354
|
+
self.tag_count += 1
|
|
355
|
+
if self.tag_count > self.config.max_exception_tags_per_file:
|
|
356
|
+
self.violations.append(
|
|
357
|
+
Violation(
|
|
358
|
+
rule_id="R002",
|
|
359
|
+
severity=RuleSeverity.MEDIUM,
|
|
360
|
+
file=str(self.file_path),
|
|
361
|
+
line=node.lineno,
|
|
362
|
+
col=node.col_offset,
|
|
363
|
+
message=f"Exceeded max exception tags ({self.config.max_exception_tags_per_file}) in file",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
# No tag: flag the dict
|
|
368
|
+
self.violations.append(
|
|
369
|
+
Violation(
|
|
370
|
+
rule_id="R002",
|
|
371
|
+
severity=RuleSeverity.MEDIUM,
|
|
372
|
+
file=str(self.file_path),
|
|
373
|
+
line=node.lineno,
|
|
374
|
+
col=node.col_offset,
|
|
375
|
+
message=f"Inline dict literal with {string_keys} keys (likely business-shape; convert to DTO)",
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
self.generic_visit(node)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class R003Checker(BaseChecker):
|
|
383
|
+
"""Check for dataclass decorator without frozen+slots+repr=False."""
|
|
384
|
+
|
|
385
|
+
def __init__(self, file_path: Path, source: str, config: Config):
|
|
386
|
+
super().__init__(file_path, source, config)
|
|
387
|
+
self.in_dto_file = False
|
|
388
|
+
from .rules import is_dto_path
|
|
389
|
+
|
|
390
|
+
self.in_dto_file = is_dto_path(file_path, config.dto_paths)
|
|
391
|
+
|
|
392
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
393
|
+
"""Visit class definitions."""
|
|
394
|
+
if not self.in_dto_file:
|
|
395
|
+
self.generic_visit(node)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# Check if rule is suppressed on this node
|
|
399
|
+
if self.is_suppressed(node, "R003"):
|
|
400
|
+
self.generic_visit(node)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
# Look for @dataclass decorator
|
|
404
|
+
for decorator in node.decorator_list:
|
|
405
|
+
if self._is_dataclass_decorator(decorator):
|
|
406
|
+
kwargs = self._extract_decorator_kwargs(decorator)
|
|
407
|
+
|
|
408
|
+
# In canonical mode (v0.2 default): flag repr=False (anti-canonical) if strict_repr=True
|
|
409
|
+
if self.config.r003_mode == "canonical":
|
|
410
|
+
if self.config.r003_strict_repr:
|
|
411
|
+
has_repr_false = kwargs.get("repr") == "False"
|
|
412
|
+
if has_repr_false:
|
|
413
|
+
self.violations.append(
|
|
414
|
+
Violation(
|
|
415
|
+
rule_id="R003",
|
|
416
|
+
severity=RuleSeverity.MEDIUM,
|
|
417
|
+
file=str(self.file_path),
|
|
418
|
+
line=node.lineno,
|
|
419
|
+
col=node.col_offset,
|
|
420
|
+
message=f"@dataclass uses anti-canonical repr=False: {node.name}. Remove it.",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
# Relaxed mode: only check frozen+slots, ignore repr=False
|
|
425
|
+
has_frozen = kwargs.get("frozen") == "True"
|
|
426
|
+
has_slots = kwargs.get("slots") == "True"
|
|
427
|
+
|
|
428
|
+
if not (has_frozen and has_slots):
|
|
429
|
+
missing = []
|
|
430
|
+
if not has_frozen:
|
|
431
|
+
missing.append("frozen=True")
|
|
432
|
+
if not has_slots:
|
|
433
|
+
missing.append("slots=True")
|
|
434
|
+
|
|
435
|
+
self.violations.append(
|
|
436
|
+
Violation(
|
|
437
|
+
rule_id="R003",
|
|
438
|
+
severity=RuleSeverity.MEDIUM,
|
|
439
|
+
file=str(self.file_path),
|
|
440
|
+
line=node.lineno,
|
|
441
|
+
col=node.col_offset,
|
|
442
|
+
message=f"@dataclass missing {', '.join(missing)}: {node.name}",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
# In legacy mode (v0.1): flag missing repr=False
|
|
446
|
+
else:
|
|
447
|
+
has_frozen = kwargs.get("frozen") == "True"
|
|
448
|
+
has_slots = kwargs.get("slots") == "True"
|
|
449
|
+
has_repr_false = kwargs.get("repr") == "False"
|
|
450
|
+
|
|
451
|
+
if not (has_frozen and has_slots and has_repr_false):
|
|
452
|
+
missing = []
|
|
453
|
+
if not has_frozen:
|
|
454
|
+
missing.append("frozen=True")
|
|
455
|
+
if not has_slots:
|
|
456
|
+
missing.append("slots=True")
|
|
457
|
+
if not has_repr_false:
|
|
458
|
+
missing.append("repr=False")
|
|
459
|
+
|
|
460
|
+
self.violations.append(
|
|
461
|
+
Violation(
|
|
462
|
+
rule_id="R003",
|
|
463
|
+
severity=RuleSeverity.MEDIUM,
|
|
464
|
+
file=str(self.file_path),
|
|
465
|
+
line=node.lineno,
|
|
466
|
+
col=node.col_offset,
|
|
467
|
+
message=f"@dataclass missing {', '.join(missing)}: {node.name}",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
self.generic_visit(node)
|
|
472
|
+
|
|
473
|
+
def _is_dataclass_decorator(self, decorator: ast.expr) -> bool:
|
|
474
|
+
"""Check if decorator is @dataclass."""
|
|
475
|
+
if isinstance(decorator, ast.Name):
|
|
476
|
+
return decorator.id == "dataclass"
|
|
477
|
+
elif isinstance(decorator, ast.Call):
|
|
478
|
+
if isinstance(decorator.func, ast.Name):
|
|
479
|
+
return decorator.func.id == "dataclass"
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
def _extract_decorator_kwargs(self, decorator: ast.expr) -> dict[str, str]:
|
|
483
|
+
"""Extract kwargs from @dataclass(...) decorator."""
|
|
484
|
+
kwargs = {}
|
|
485
|
+
if isinstance(decorator, ast.Call):
|
|
486
|
+
for keyword in decorator.keywords:
|
|
487
|
+
if isinstance(keyword.value, ast.Constant):
|
|
488
|
+
kwargs[keyword.arg] = str(keyword.value.value)
|
|
489
|
+
elif isinstance(keyword.value, ast.NameConstant):
|
|
490
|
+
kwargs[keyword.arg] = str(keyword.value.value)
|
|
491
|
+
elif isinstance(keyword.value, ast.Name):
|
|
492
|
+
kwargs[keyword.arg] = keyword.value.id
|
|
493
|
+
return kwargs
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class R004Checker(BaseChecker):
|
|
497
|
+
"""Check for module-level functions without exception tags (auto-detects class-method wrappers)."""
|
|
498
|
+
|
|
499
|
+
def __init__(self, file_path: Path, source: str, config: Config):
|
|
500
|
+
super().__init__(file_path, source, config)
|
|
501
|
+
self.in_service_file = False
|
|
502
|
+
from .rules import is_service_path
|
|
503
|
+
|
|
504
|
+
self.in_service_file = is_service_path(file_path, config.service_paths)
|
|
505
|
+
|
|
506
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
507
|
+
"""Visit module to find top-level function defs."""
|
|
508
|
+
if not self.in_service_file:
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
for item in node.body:
|
|
512
|
+
if isinstance(item, ast.FunctionDef):
|
|
513
|
+
# Check if rule is suppressed on this node
|
|
514
|
+
if self.is_suppressed(item, "R004"):
|
|
515
|
+
continue
|
|
516
|
+
if not self._has_exception_tag(
|
|
517
|
+
item
|
|
518
|
+
) and not self._is_class_method_wrapper(item):
|
|
519
|
+
self.violations.append(
|
|
520
|
+
Violation(
|
|
521
|
+
rule_id="R004",
|
|
522
|
+
severity=RuleSeverity.HIGH,
|
|
523
|
+
file=str(self.file_path),
|
|
524
|
+
line=item.lineno,
|
|
525
|
+
col=item.col_offset,
|
|
526
|
+
message=f"Module-level def without exception tag: {item.name}",
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def _is_class_method_wrapper(self, node: ast.FunctionDef) -> bool:
|
|
531
|
+
"""Check if function body delegates to class methods (auto-detected wrapper).
|
|
532
|
+
|
|
533
|
+
Returns True if body has >=1 class-method delegation like:
|
|
534
|
+
- return _singleton.method(...)
|
|
535
|
+
- return ClassName.method(...)
|
|
536
|
+
- return _instance.attr.method(...)
|
|
537
|
+
|
|
538
|
+
Otherwise False (legitimate utility function, not a facade).
|
|
539
|
+
"""
|
|
540
|
+
for stmt in node.body:
|
|
541
|
+
if isinstance(stmt, ast.Return) and stmt.value:
|
|
542
|
+
# Check if return value is a call to a class/instance method
|
|
543
|
+
if self._is_method_delegation(stmt.value):
|
|
544
|
+
return True
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
def _is_method_delegation(self, node: ast.expr) -> bool:
|
|
548
|
+
"""Check if node is a method call (attribute access followed by call)."""
|
|
549
|
+
if isinstance(node, ast.Call):
|
|
550
|
+
func = node.func
|
|
551
|
+
# Pattern: obj.method(...) or obj.attr.method(...)
|
|
552
|
+
if isinstance(func, ast.Attribute):
|
|
553
|
+
return True
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
def _has_exception_tag(self, node: ast.FunctionDef) -> bool:
|
|
557
|
+
"""Check if function has a documented exception tag."""
|
|
558
|
+
# Check inline comment on function line
|
|
559
|
+
comment = self.get_comment_text(node.lineno)
|
|
560
|
+
for tag in self.config.exception_tags:
|
|
561
|
+
if tag in comment:
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
# Check docstring first line
|
|
565
|
+
if (
|
|
566
|
+
node.body
|
|
567
|
+
and isinstance(node.body[0], ast.Expr)
|
|
568
|
+
and isinstance(node.body[0].value, ast.Constant)
|
|
569
|
+
and isinstance(node.body[0].value.value, str)
|
|
570
|
+
):
|
|
571
|
+
docstring = node.body[0].value.value
|
|
572
|
+
for tag in self.config.exception_tags:
|
|
573
|
+
if tag in docstring:
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class R005Checker(BaseChecker):
|
|
580
|
+
"""Check for validators using DTO.from_dict() pattern."""
|
|
581
|
+
|
|
582
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
583
|
+
"""Visit function definitions."""
|
|
584
|
+
# Only check functions starting with validate_
|
|
585
|
+
if not node.name.startswith("validate_"):
|
|
586
|
+
self.generic_visit(node)
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
# Check if rule is suppressed on this node
|
|
590
|
+
if self.is_suppressed(node, "R005"):
|
|
591
|
+
self.generic_visit(node)
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
# Check if it has a 'payload' parameter
|
|
595
|
+
has_payload_param = any(
|
|
596
|
+
arg.arg == "payload" for arg in node.args.args + node.args.kwonlyargs
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
if not has_payload_param:
|
|
600
|
+
self.generic_visit(node)
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
# Check for DTO.from_dict() or ValidationError in function body
|
|
604
|
+
has_from_dict = self._has_from_dict_call(node)
|
|
605
|
+
has_validation_error = self._has_validation_error(node)
|
|
606
|
+
|
|
607
|
+
if not (has_from_dict or has_validation_error):
|
|
608
|
+
self.violations.append(
|
|
609
|
+
Violation(
|
|
610
|
+
rule_id="R005",
|
|
611
|
+
severity=RuleSeverity.LOW,
|
|
612
|
+
file=str(self.file_path),
|
|
613
|
+
line=node.lineno,
|
|
614
|
+
col=node.col_offset,
|
|
615
|
+
message=f"Validator does not use DTO.from_dict() pattern: {node.name}",
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
self.generic_visit(node)
|
|
620
|
+
|
|
621
|
+
def _has_from_dict_call(self, node: ast.FunctionDef) -> bool:
|
|
622
|
+
"""Check if function body calls .from_dict()."""
|
|
623
|
+
for item in ast.walk(node):
|
|
624
|
+
if isinstance(item, ast.Call):
|
|
625
|
+
if isinstance(item.func, ast.Attribute):
|
|
626
|
+
if item.func.attr == "from_dict":
|
|
627
|
+
return True
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
def _has_validation_error(self, node: ast.FunctionDef) -> bool:
|
|
631
|
+
"""Check if function raises ValidationError."""
|
|
632
|
+
for item in ast.walk(node):
|
|
633
|
+
if isinstance(item, ast.Raise):
|
|
634
|
+
if isinstance(item.exc, ast.Call):
|
|
635
|
+
if isinstance(item.exc.func, ast.Name):
|
|
636
|
+
if "ValidationError" in item.exc.func.id:
|
|
637
|
+
return True
|
|
638
|
+
return False
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class R006Checker(BaseChecker):
|
|
642
|
+
"""Check for typing.Any in service-layer function signatures."""
|
|
643
|
+
|
|
644
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
645
|
+
"""Visit function definitions."""
|
|
646
|
+
from .rules import is_service_path
|
|
647
|
+
|
|
648
|
+
# Test files are always exempt from R006
|
|
649
|
+
if not is_service_path(self.file_path, self.config.r006_paths):
|
|
650
|
+
self.generic_visit(node)
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Check if rule is suppressed on this node
|
|
654
|
+
if self.is_suppressed(node, "R006"):
|
|
655
|
+
self.generic_visit(node)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
# Check parameters
|
|
659
|
+
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
|
|
660
|
+
if arg.annotation and contains_any(arg.annotation):
|
|
661
|
+
self.violations.append(
|
|
662
|
+
Violation(
|
|
663
|
+
rule_id="R006",
|
|
664
|
+
severity=RuleSeverity.HIGH,
|
|
665
|
+
file=str(self.file_path),
|
|
666
|
+
line=node.lineno,
|
|
667
|
+
col=node.col_offset,
|
|
668
|
+
message=f"typing.Any in parameter: {node.name}",
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
# Check varargs and kwargs
|
|
674
|
+
if node.args.vararg and node.args.vararg.annotation:
|
|
675
|
+
if contains_any(node.args.vararg.annotation):
|
|
676
|
+
self.violations.append(
|
|
677
|
+
Violation(
|
|
678
|
+
rule_id="R006",
|
|
679
|
+
severity=RuleSeverity.HIGH,
|
|
680
|
+
file=str(self.file_path),
|
|
681
|
+
line=node.lineno,
|
|
682
|
+
col=node.col_offset,
|
|
683
|
+
message=f"typing.Any in *args: {node.name}",
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
if node.args.kwarg and node.args.kwarg.annotation:
|
|
689
|
+
if contains_any(node.args.kwarg.annotation):
|
|
690
|
+
self.violations.append(
|
|
691
|
+
Violation(
|
|
692
|
+
rule_id="R006",
|
|
693
|
+
severity=RuleSeverity.HIGH,
|
|
694
|
+
file=str(self.file_path),
|
|
695
|
+
line=node.lineno,
|
|
696
|
+
col=node.col_offset,
|
|
697
|
+
message=f"typing.Any in **kwargs: {node.name}",
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Check return type
|
|
703
|
+
if node.returns and contains_any(node.returns):
|
|
704
|
+
self.violations.append(
|
|
705
|
+
Violation(
|
|
706
|
+
rule_id="R006",
|
|
707
|
+
severity=RuleSeverity.HIGH,
|
|
708
|
+
file=str(self.file_path),
|
|
709
|
+
line=node.lineno,
|
|
710
|
+
col=node.col_offset,
|
|
711
|
+
message=f"typing.Any in return type: {node.name}",
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
self.generic_visit(node)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class R007Checker(BaseChecker):
|
|
719
|
+
"""Check for pytest fixtures defined outside conftest.py."""
|
|
720
|
+
|
|
721
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
722
|
+
"""Visit function definitions to find pytest fixtures."""
|
|
723
|
+
from .rules import is_test_file
|
|
724
|
+
|
|
725
|
+
# Only check test files
|
|
726
|
+
if not is_test_file(self.file_path):
|
|
727
|
+
self.generic_visit(node)
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
# conftest.py is allowed to have fixtures
|
|
731
|
+
if self.file_path.name == "conftest.py":
|
|
732
|
+
self.generic_visit(node)
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
# Check if function has @pytest.fixture decorator
|
|
736
|
+
for decorator in node.decorator_list:
|
|
737
|
+
if self._is_pytest_fixture_decorator(decorator):
|
|
738
|
+
# Check if rule is suppressed on this node
|
|
739
|
+
if self.is_suppressed(node, "R007"):
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
self.violations.append(
|
|
743
|
+
Violation(
|
|
744
|
+
rule_id="R007",
|
|
745
|
+
severity=RuleSeverity.MEDIUM,
|
|
746
|
+
file=str(self.file_path),
|
|
747
|
+
line=node.lineno,
|
|
748
|
+
col=node.col_offset,
|
|
749
|
+
message=f"Pytest fixture defined outside conftest.py: {node.name}. Move to conftest.py.",
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
self.generic_visit(node)
|
|
754
|
+
|
|
755
|
+
def _is_pytest_fixture_decorator(self, decorator: ast.expr) -> bool:
|
|
756
|
+
"""Check if decorator is @pytest.fixture."""
|
|
757
|
+
# Match: @pytest.fixture or @fixture (if imported)
|
|
758
|
+
if isinstance(decorator, ast.Name):
|
|
759
|
+
return decorator.id == "fixture"
|
|
760
|
+
elif isinstance(decorator, ast.Attribute):
|
|
761
|
+
# Match: @pytest.fixture
|
|
762
|
+
if decorator.attr == "fixture":
|
|
763
|
+
if isinstance(decorator.value, ast.Name):
|
|
764
|
+
if decorator.value.id == "pytest":
|
|
765
|
+
return True
|
|
766
|
+
elif isinstance(decorator, ast.Call):
|
|
767
|
+
# Match: @pytest.fixture() or @fixture()
|
|
768
|
+
if isinstance(decorator.func, ast.Name):
|
|
769
|
+
return decorator.func.id == "fixture"
|
|
770
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
771
|
+
if decorator.func.attr == "fixture":
|
|
772
|
+
if isinstance(decorator.func.value, ast.Name):
|
|
773
|
+
if decorator.func.value.id == "pytest":
|
|
774
|
+
return True
|
|
775
|
+
return False
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class R008Checker(BaseChecker):
|
|
779
|
+
"""Check for bare module-level test functions."""
|
|
780
|
+
|
|
781
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
782
|
+
"""Visit module to find top-level test functions."""
|
|
783
|
+
from .rules import is_test_file
|
|
784
|
+
|
|
785
|
+
# Only check test files
|
|
786
|
+
if not is_test_file(self.file_path):
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
for item in node.body:
|
|
790
|
+
if isinstance(item, ast.FunctionDef):
|
|
791
|
+
# Check if function name starts with test_
|
|
792
|
+
if item.name.startswith("test_"):
|
|
793
|
+
# Check if rule is suppressed on this node
|
|
794
|
+
if self.is_suppressed(item, "R008"):
|
|
795
|
+
continue
|
|
796
|
+
|
|
797
|
+
self.violations.append(
|
|
798
|
+
Violation(
|
|
799
|
+
rule_id="R008",
|
|
800
|
+
severity=RuleSeverity.MEDIUM,
|
|
801
|
+
file=str(self.file_path),
|
|
802
|
+
line=item.lineno,
|
|
803
|
+
col=item.col_offset,
|
|
804
|
+
message=f"Module-level test function: {item.name}. Move into Test<Concern> class.",
|
|
805
|
+
)
|
|
806
|
+
)
|