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.
@@ -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
+ )