thailint 0.5.0__py3-none-any.whl → 0.15.3__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 (204) hide show
  1. src/__init__.py +1 -0
  2. src/analyzers/__init__.py +4 -3
  3. src/analyzers/ast_utils.py +54 -0
  4. src/analyzers/rust_base.py +155 -0
  5. src/analyzers/rust_context.py +141 -0
  6. src/analyzers/typescript_base.py +4 -0
  7. src/cli/__init__.py +30 -0
  8. src/cli/__main__.py +22 -0
  9. src/cli/config.py +480 -0
  10. src/cli/config_merge.py +241 -0
  11. src/cli/linters/__init__.py +67 -0
  12. src/cli/linters/code_patterns.py +270 -0
  13. src/cli/linters/code_smells.py +342 -0
  14. src/cli/linters/documentation.py +83 -0
  15. src/cli/linters/performance.py +287 -0
  16. src/cli/linters/shared.py +331 -0
  17. src/cli/linters/structure.py +327 -0
  18. src/cli/linters/structure_quality.py +328 -0
  19. src/cli/main.py +120 -0
  20. src/cli/utils.py +395 -0
  21. src/cli_main.py +37 -0
  22. src/config.py +38 -25
  23. src/core/base.py +7 -2
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +5 -2
  26. src/core/constants.py +54 -0
  27. src/core/linter_utils.py +95 -6
  28. src/core/python_lint_rule.py +101 -0
  29. src/core/registry.py +1 -1
  30. src/core/rule_discovery.py +147 -84
  31. src/core/types.py +13 -0
  32. src/core/violation_builder.py +78 -15
  33. src/core/violation_utils.py +69 -0
  34. src/formatters/__init__.py +22 -0
  35. src/formatters/sarif.py +202 -0
  36. src/linter_config/directive_markers.py +109 -0
  37. src/linter_config/ignore.py +254 -395
  38. src/linter_config/loader.py +45 -12
  39. src/linter_config/pattern_utils.py +65 -0
  40. src/linter_config/rule_matcher.py +89 -0
  41. src/linters/collection_pipeline/__init__.py +90 -0
  42. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  43. src/linters/collection_pipeline/ast_utils.py +40 -0
  44. src/linters/collection_pipeline/config.py +75 -0
  45. src/linters/collection_pipeline/continue_analyzer.py +94 -0
  46. src/linters/collection_pipeline/detector.py +360 -0
  47. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  48. src/linters/collection_pipeline/linter.py +420 -0
  49. src/linters/collection_pipeline/suggestion_builder.py +130 -0
  50. src/linters/cqs/__init__.py +54 -0
  51. src/linters/cqs/config.py +55 -0
  52. src/linters/cqs/function_analyzer.py +201 -0
  53. src/linters/cqs/input_detector.py +139 -0
  54. src/linters/cqs/linter.py +159 -0
  55. src/linters/cqs/output_detector.py +84 -0
  56. src/linters/cqs/python_analyzer.py +54 -0
  57. src/linters/cqs/types.py +82 -0
  58. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  59. src/linters/cqs/typescript_function_analyzer.py +192 -0
  60. src/linters/cqs/typescript_input_detector.py +203 -0
  61. src/linters/cqs/typescript_output_detector.py +117 -0
  62. src/linters/cqs/violation_builder.py +94 -0
  63. src/linters/dry/base_token_analyzer.py +16 -9
  64. src/linters/dry/block_filter.py +120 -20
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +104 -10
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +54 -11
  69. src/linters/dry/constant.py +92 -0
  70. src/linters/dry/constant_matcher.py +223 -0
  71. src/linters/dry/constant_violation_builder.py +98 -0
  72. src/linters/dry/duplicate_storage.py +5 -4
  73. src/linters/dry/file_analyzer.py +4 -2
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +183 -48
  76. src/linters/dry/python_analyzer.py +60 -439
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/token_hasher.py +116 -112
  80. src/linters/dry/typescript_analyzer.py +68 -382
  81. src/linters/dry/typescript_constant_extractor.py +138 -0
  82. src/linters/dry/typescript_statement_detector.py +255 -0
  83. src/linters/dry/typescript_value_extractor.py +70 -0
  84. src/linters/dry/violation_builder.py +4 -0
  85. src/linters/dry/violation_filter.py +5 -4
  86. src/linters/dry/violation_generator.py +71 -14
  87. src/linters/file_header/atemporal_detector.py +68 -50
  88. src/linters/file_header/base_parser.py +93 -0
  89. src/linters/file_header/bash_parser.py +66 -0
  90. src/linters/file_header/config.py +90 -16
  91. src/linters/file_header/css_parser.py +70 -0
  92. src/linters/file_header/field_validator.py +36 -33
  93. src/linters/file_header/linter.py +140 -144
  94. src/linters/file_header/markdown_parser.py +130 -0
  95. src/linters/file_header/python_parser.py +14 -58
  96. src/linters/file_header/typescript_parser.py +73 -0
  97. src/linters/file_header/violation_builder.py +13 -12
  98. src/linters/file_placement/config_loader.py +3 -1
  99. src/linters/file_placement/directory_matcher.py +4 -0
  100. src/linters/file_placement/linter.py +66 -34
  101. src/linters/file_placement/pattern_matcher.py +41 -6
  102. src/linters/file_placement/pattern_validator.py +31 -12
  103. src/linters/file_placement/rule_checker.py +12 -7
  104. src/linters/lazy_ignores/__init__.py +43 -0
  105. src/linters/lazy_ignores/config.py +74 -0
  106. src/linters/lazy_ignores/directive_utils.py +164 -0
  107. src/linters/lazy_ignores/header_parser.py +177 -0
  108. src/linters/lazy_ignores/linter.py +158 -0
  109. src/linters/lazy_ignores/matcher.py +168 -0
  110. src/linters/lazy_ignores/python_analyzer.py +209 -0
  111. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  112. src/linters/lazy_ignores/skip_detector.py +298 -0
  113. src/linters/lazy_ignores/types.py +71 -0
  114. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  115. src/linters/lazy_ignores/violation_builder.py +135 -0
  116. src/linters/lbyl/__init__.py +31 -0
  117. src/linters/lbyl/config.py +63 -0
  118. src/linters/lbyl/linter.py +67 -0
  119. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  120. src/linters/lbyl/pattern_detectors/base.py +63 -0
  121. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  122. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  123. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  124. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  125. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  126. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  127. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  128. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  129. src/linters/lbyl/python_analyzer.py +215 -0
  130. src/linters/lbyl/violation_builder.py +354 -0
  131. src/linters/magic_numbers/context_analyzer.py +227 -225
  132. src/linters/magic_numbers/linter.py +28 -82
  133. src/linters/magic_numbers/python_analyzer.py +4 -16
  134. src/linters/magic_numbers/typescript_analyzer.py +9 -12
  135. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  136. src/linters/method_property/__init__.py +49 -0
  137. src/linters/method_property/config.py +138 -0
  138. src/linters/method_property/linter.py +414 -0
  139. src/linters/method_property/python_analyzer.py +473 -0
  140. src/linters/method_property/violation_builder.py +119 -0
  141. src/linters/nesting/linter.py +24 -16
  142. src/linters/nesting/python_analyzer.py +4 -0
  143. src/linters/nesting/typescript_analyzer.py +6 -12
  144. src/linters/nesting/violation_builder.py +1 -0
  145. src/linters/performance/__init__.py +91 -0
  146. src/linters/performance/config.py +43 -0
  147. src/linters/performance/constants.py +49 -0
  148. src/linters/performance/linter.py +149 -0
  149. src/linters/performance/python_analyzer.py +365 -0
  150. src/linters/performance/regex_analyzer.py +312 -0
  151. src/linters/performance/regex_linter.py +139 -0
  152. src/linters/performance/typescript_analyzer.py +236 -0
  153. src/linters/performance/violation_builder.py +160 -0
  154. src/linters/print_statements/config.py +7 -12
  155. src/linters/print_statements/linter.py +26 -43
  156. src/linters/print_statements/python_analyzer.py +91 -93
  157. src/linters/print_statements/typescript_analyzer.py +15 -25
  158. src/linters/print_statements/violation_builder.py +12 -14
  159. src/linters/srp/class_analyzer.py +11 -7
  160. src/linters/srp/heuristics.py +56 -22
  161. src/linters/srp/linter.py +15 -16
  162. src/linters/srp/python_analyzer.py +55 -20
  163. src/linters/srp/typescript_metrics_calculator.py +110 -50
  164. src/linters/stateless_class/__init__.py +25 -0
  165. src/linters/stateless_class/config.py +58 -0
  166. src/linters/stateless_class/linter.py +349 -0
  167. src/linters/stateless_class/python_analyzer.py +290 -0
  168. src/linters/stringly_typed/__init__.py +36 -0
  169. src/linters/stringly_typed/config.py +189 -0
  170. src/linters/stringly_typed/context_filter.py +451 -0
  171. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  172. src/linters/stringly_typed/ignore_checker.py +100 -0
  173. src/linters/stringly_typed/ignore_utils.py +51 -0
  174. src/linters/stringly_typed/linter.py +376 -0
  175. src/linters/stringly_typed/python/__init__.py +33 -0
  176. src/linters/stringly_typed/python/analyzer.py +348 -0
  177. src/linters/stringly_typed/python/call_tracker.py +175 -0
  178. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  179. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  180. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  181. src/linters/stringly_typed/python/constants.py +21 -0
  182. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  183. src/linters/stringly_typed/python/validation_detector.py +189 -0
  184. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  185. src/linters/stringly_typed/storage.py +620 -0
  186. src/linters/stringly_typed/storage_initializer.py +45 -0
  187. src/linters/stringly_typed/typescript/__init__.py +28 -0
  188. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  189. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  190. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  191. src/linters/stringly_typed/violation_generator.py +419 -0
  192. src/orchestrator/core.py +252 -14
  193. src/orchestrator/language_detector.py +5 -3
  194. src/templates/thailint_config_template.yaml +196 -0
  195. src/utils/project_root.py +3 -0
  196. thailint-0.15.3.dist-info/METADATA +187 -0
  197. thailint-0.15.3.dist-info/RECORD +226 -0
  198. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  199. src/cli.py +0 -1665
  200. thailint-0.5.0.dist-info/METADATA +0 -1286
  201. thailint-0.5.0.dist-info/RECORD +0 -96
  202. thailint-0.5.0.dist-info/entry_points.txt +0 -4
  203. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
  204. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,119 @@
1
+ """
2
+ Purpose: AST-based detector for isinstance LBYL patterns
3
+
4
+ Scope: Detects 'if isinstance(x, Type): x.method()' patterns in Python code
5
+
6
+ Overview: Provides IsinstanceDetector class that uses AST traversal to find LBYL anti-patterns
7
+ involving isinstance checking. Identifies patterns where code checks if an object is an
8
+ instance of a type before performing type-specific operations. Returns IsinstancePattern
9
+ objects containing the object name, type name, and location. This detector is disabled by
10
+ default in config because many isinstance checks are valid type narrowing.
11
+
12
+ Dependencies: ast module, base detector classes from pattern_detectors.base
13
+
14
+ Exports: IsinstancePattern, IsinstanceDetector
15
+
16
+ Interfaces: IsinstanceDetector.find_patterns(tree: ast.AST) -> list[IsinstancePattern]
17
+
18
+ Implementation: AST NodeVisitor pattern with visit_If to detect isinstance check followed by
19
+ operations on the checked object
20
+
21
+ Suppressions:
22
+ - N802: visit_If follows Python AST visitor naming convention (camelCase required)
23
+ - invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
24
+ """
25
+
26
+ import ast
27
+ from dataclasses import dataclass
28
+
29
+ from .base import BaseLBYLDetector, LBYLPattern
30
+
31
+
32
+ @dataclass
33
+ class IsinstancePattern(LBYLPattern):
34
+ """Pattern data for isinstance LBYL anti-pattern."""
35
+
36
+ object_name: str
37
+ type_name: str
38
+
39
+
40
+ def _try_extract_isinstance_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
41
+ """Try to extract isinstance call arguments.
42
+
43
+ Returns:
44
+ Tuple of (object_expr, type_name) or (None, None) if not a valid isinstance call.
45
+ """
46
+ if not isinstance(node, ast.Call):
47
+ return None, None
48
+ if not isinstance(node.func, ast.Name) or node.func.id != "isinstance":
49
+ return None, None
50
+ return _extract_isinstance_args(node)
51
+
52
+
53
+ def _extract_isinstance_args(call: ast.Call) -> tuple[ast.expr | None, str | None]:
54
+ """Extract object and type name from isinstance call args.
55
+
56
+ Returns:
57
+ Tuple of (object_expr, type_name_str) or (None, None) if not valid.
58
+ """
59
+ if len(call.args) != 2:
60
+ return None, None
61
+ return call.args[0], ast.unparse(call.args[1])
62
+
63
+
64
+ class IsinstanceDetector(BaseLBYLDetector[IsinstancePattern]):
65
+ """Detects 'if isinstance(x, Type): x.method()' LBYL patterns."""
66
+
67
+ def __init__(self) -> None:
68
+ """Initialize the detector."""
69
+ self._patterns: list[IsinstancePattern] = []
70
+
71
+ def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
72
+ """Visit if statement to check for isinstance LBYL pattern.
73
+
74
+ Args:
75
+ node: AST If node to analyze
76
+ """
77
+ self._check_isinstance_pattern(node)
78
+ self.generic_visit(node)
79
+
80
+ def _check_isinstance_pattern(self, node: ast.If) -> None:
81
+ """Check if node is an isinstance LBYL pattern and record it."""
82
+ obj_expr, type_name = _try_extract_isinstance_call(node.test)
83
+ if obj_expr is None or type_name is None:
84
+ return
85
+
86
+ if self._body_has_object_operation(node.body, obj_expr):
87
+ self._patterns.append(self._create_pattern(node, obj_expr, type_name))
88
+
89
+ def _body_has_object_operation(self, body: list[ast.stmt], obj_expr: ast.expr) -> bool:
90
+ """Check if body contains operations on the isinstance-checked object."""
91
+ expected_obj = ast.dump(obj_expr)
92
+ return any(
93
+ self._node_uses_object(node, expected_obj) for stmt in body for node in ast.walk(stmt)
94
+ )
95
+
96
+ def _node_uses_object(self, node: ast.AST, expected_obj: str) -> bool:
97
+ """Check if AST node uses the expected object."""
98
+ if isinstance(node, ast.Attribute):
99
+ return ast.dump(node.value) == expected_obj
100
+ if isinstance(node, ast.Subscript):
101
+ return ast.dump(node.value) == expected_obj
102
+ if isinstance(node, ast.BinOp):
103
+ return self._binop_uses_object(node, expected_obj)
104
+ return False
105
+
106
+ def _binop_uses_object(self, node: ast.BinOp, expected_obj: str) -> bool:
107
+ """Check if binary operation uses the checked object."""
108
+ return ast.dump(node.left) == expected_obj or ast.dump(node.right) == expected_obj
109
+
110
+ def _create_pattern(
111
+ self, node: ast.If, obj_expr: ast.expr, type_name: str
112
+ ) -> IsinstancePattern:
113
+ """Create IsinstancePattern from detected pattern."""
114
+ return IsinstancePattern(
115
+ line_number=node.lineno,
116
+ column=node.col_offset,
117
+ object_name=ast.unparse(obj_expr),
118
+ type_name=type_name,
119
+ )
@@ -0,0 +1,173 @@
1
+ """
2
+ Purpose: AST-based detector for len check LBYL patterns
3
+
4
+ Scope: Detects 'if len(lst) > i: lst[i]' patterns in Python code
5
+
6
+ Overview: Provides LenCheckDetector class that uses AST traversal to find LBYL anti-patterns
7
+ involving length checking before index access. Identifies patterns where code checks if
8
+ a collection's length is sufficient before accessing an element by index. Handles various
9
+ comparison operators (>, >=, <, <=) and operand orderings. Returns LenCheckPattern objects
10
+ containing the collection name, index expression, and location. Avoids false positives
11
+ for different collection/index combinations.
12
+
13
+ Dependencies: ast module, base detector classes from pattern_detectors.base
14
+
15
+ Exports: LenCheckPattern, LenCheckDetector
16
+
17
+ Interfaces: LenCheckDetector.find_patterns(tree: ast.AST) -> list[LenCheckPattern]
18
+
19
+ Implementation: AST NodeVisitor pattern with visit_If to detect len check followed by
20
+ subscript access
21
+
22
+ Suppressions:
23
+ - N802: visit_If follows Python AST visitor naming convention (camelCase required)
24
+ - invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
25
+ """
26
+
27
+ import ast
28
+ from dataclasses import dataclass
29
+
30
+ from .base import BaseLBYLDetector, LBYLPattern
31
+
32
+
33
+ @dataclass
34
+ class LenCheckPattern(LBYLPattern):
35
+ """Pattern data for len check LBYL anti-pattern."""
36
+
37
+ collection_name: str
38
+ index_expression: str
39
+
40
+
41
+ def _extract_len_call_collection(node: ast.expr) -> ast.expr | None:
42
+ """Extract collection from len(collection) call, or None if not len call."""
43
+ if not isinstance(node, ast.Call):
44
+ return None
45
+ if not isinstance(node.func, ast.Name) or node.func.id != "len":
46
+ return None
47
+ if len(node.args) != 1:
48
+ return None
49
+ return node.args[0]
50
+
51
+
52
+ def _extract_len_from_binop(node: ast.expr) -> ast.expr | None:
53
+ """Extract collection from len(lst) - 1 or similar BinOp."""
54
+ if isinstance(node, ast.BinOp):
55
+ # len(lst) - 1 or 1 + len(lst)
56
+ collection = _extract_len_call_collection(node.left)
57
+ if collection is not None:
58
+ return collection
59
+ return _extract_len_call_collection(node.right)
60
+ return _extract_len_call_collection(node)
61
+
62
+
63
+ def _is_valid_compare(test: ast.expr) -> bool:
64
+ """Check if test is a simple comparison with one operator."""
65
+ if not isinstance(test, ast.Compare):
66
+ return False
67
+ return len(test.ops) == 1 and len(test.comparators) == 1
68
+
69
+
70
+ def _is_unary_constant(node: ast.expr) -> bool:
71
+ """Check if node is unary minus on constant (e.g., -1)."""
72
+ return isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant)
73
+
74
+
75
+ def _is_constant_index(node: ast.expr) -> bool:
76
+ """Check if node is a constant (literal number or simple expression).
77
+
78
+ We don't flag len checks with constant indices as LBYL because they're
79
+ typically local validation patterns (e.g., if len(lst) >= 2: lst[0], lst[1])
80
+ rather than race-condition-prone LBYL anti-patterns.
81
+ """
82
+ if isinstance(node, ast.Constant) or _is_unary_constant(node):
83
+ return True
84
+ if isinstance(node, ast.BinOp):
85
+ return _is_constant_index(node.left) and _is_constant_index(node.right)
86
+ return False
87
+
88
+
89
+ def _check_len_greater_than(
90
+ op: ast.cmpop, left: ast.expr, right: ast.expr
91
+ ) -> tuple[ast.expr | None, ast.expr | None]:
92
+ """Check for len(lst) > i or len(lst) >= i pattern."""
93
+ if not isinstance(op, (ast.Gt, ast.GtE)):
94
+ return None, None
95
+ collection = _extract_len_from_binop(left)
96
+ if collection is not None:
97
+ return collection, right
98
+ return None, None
99
+
100
+
101
+ def _check_index_less_than(
102
+ op: ast.cmpop, left: ast.expr, right: ast.expr
103
+ ) -> tuple[ast.expr | None, ast.expr | None]:
104
+ """Check for i < len(lst) or i <= len(lst) pattern."""
105
+ if not isinstance(op, (ast.Lt, ast.LtE)):
106
+ return None, None
107
+ collection = _extract_len_from_binop(right)
108
+ if collection is not None:
109
+ return collection, left
110
+ return None, None
111
+
112
+
113
+ def _extract_len_check(test: ast.expr) -> tuple[ast.expr | None, ast.expr | None]:
114
+ """Extract (collection, index) from len comparison, or (None, None)."""
115
+ if not isinstance(test, ast.Compare):
116
+ return None, None
117
+ if not _is_valid_compare(test):
118
+ return None, None
119
+
120
+ op = test.ops[0]
121
+ left = test.left
122
+ right = test.comparators[0]
123
+
124
+ result = _check_len_greater_than(op, left, right)
125
+ if result[0] is not None:
126
+ return result
127
+ return _check_index_less_than(op, left, right)
128
+
129
+
130
+ class LenCheckDetector(BaseLBYLDetector[LenCheckPattern]):
131
+ """Detects 'if len(lst) > i: lst[i]' LBYL patterns."""
132
+
133
+ def __init__(self) -> None:
134
+ """Initialize the detector."""
135
+ self._patterns: list[LenCheckPattern] = []
136
+
137
+ def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
138
+ """Visit if statement to check for len check LBYL pattern."""
139
+ self._check_len_pattern(node)
140
+ self.generic_visit(node)
141
+
142
+ def _check_len_pattern(self, node: ast.If) -> None:
143
+ """Check if node matches len check LBYL pattern."""
144
+ collection_expr, index_expr = _extract_len_check(node.test)
145
+ if collection_expr is None or index_expr is None:
146
+ return
147
+
148
+ # Skip constant index checks - they're local validation, not LBYL
149
+ if _is_constant_index(index_expr):
150
+ return
151
+
152
+ if self._body_has_subscript_match(node.body, collection_expr):
153
+ self._patterns.append(self._create_pattern(node, collection_expr, index_expr))
154
+
155
+ def _body_has_subscript_match(self, body: list[ast.stmt], collection_expr: ast.expr) -> bool:
156
+ """Check if body contains collection[index] access matching the len check."""
157
+ expected_collection = ast.dump(collection_expr)
158
+ return any(
159
+ isinstance(node, ast.Subscript) and ast.dump(node.value) == expected_collection
160
+ for stmt in body
161
+ for node in ast.walk(stmt)
162
+ )
163
+
164
+ def _create_pattern(
165
+ self, node: ast.If, collection_expr: ast.expr, index_expr: ast.expr
166
+ ) -> LenCheckPattern:
167
+ """Create LenCheckPattern from detected pattern."""
168
+ return LenCheckPattern(
169
+ line_number=node.lineno,
170
+ column=node.col_offset,
171
+ collection_name=ast.unparse(collection_expr),
172
+ index_expression=ast.unparse(index_expr),
173
+ )
@@ -0,0 +1,146 @@
1
+ """
2
+ Purpose: AST-based detector for None check LBYL patterns
3
+
4
+ Scope: Detects 'if x is not None: x.method()' patterns in Python code
5
+
6
+ Overview: Provides NoneCheckDetector class that uses AST traversal to find LBYL anti-patterns
7
+ involving None checking. Identifies patterns where code checks if a variable is not None
8
+ before using it (e.g., 'if x is not None: x.method()'). Also detects inverse patterns
9
+ with else branches. Returns NoneCheckPattern objects containing the variable name and
10
+ location. Avoids false positives for different variables, walrus operator assignments.
11
+
12
+ Dependencies: ast module, base detector classes from pattern_detectors.base
13
+
14
+ Exports: NoneCheckPattern, NoneCheckDetector
15
+
16
+ Interfaces: NoneCheckDetector.find_patterns(tree: ast.AST) -> list[NoneCheckPattern]
17
+
18
+ Implementation: AST NodeVisitor pattern with visit_If to detect None comparison followed by
19
+ variable usage
20
+
21
+ Suppressions:
22
+ - N802: visit_If follows Python AST visitor naming convention (camelCase required)
23
+ - invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
24
+ """
25
+
26
+ import ast
27
+ from dataclasses import dataclass
28
+
29
+ from .base import BaseLBYLDetector, LBYLPattern
30
+
31
+
32
+ @dataclass
33
+ class NoneCheckPattern(LBYLPattern):
34
+ """Pattern data for None check LBYL anti-pattern."""
35
+
36
+ variable_name: str
37
+
38
+
39
+ def _try_extract_none_check(node: ast.expr) -> tuple[ast.expr | None, bool]:
40
+ """Try to extract variable from None comparison.
41
+
42
+ Returns:
43
+ Tuple of (variable_expr, is_not_none_check) or (None, False) if not valid.
44
+ is_not_none_check is True for 'is not None', False for 'is None'.
45
+ """
46
+ if not isinstance(node, ast.Compare):
47
+ return None, False
48
+ if len(node.ops) != 1 or len(node.comparators) != 1:
49
+ return None, False
50
+ return _extract_none_comparison(node)
51
+
52
+
53
+ def _is_none_constant(node: ast.expr) -> bool:
54
+ """Check if node is a None constant."""
55
+ return isinstance(node, ast.Constant) and node.value is None
56
+
57
+
58
+ def _extract_var_from_none_check(var_side: ast.expr, op: ast.cmpop) -> tuple[ast.expr | None, bool]:
59
+ """Extract variable from None comparison if valid."""
60
+ if _is_walrus_expression(var_side):
61
+ return None, False
62
+ return var_side, isinstance(op, ast.IsNot)
63
+
64
+
65
+ def _extract_none_comparison(node: ast.Compare) -> tuple[ast.expr | None, bool]:
66
+ """Extract variable from 'x is None' or 'x is not None' comparison.
67
+
68
+ Returns:
69
+ Tuple of (variable_expr, is_not_none) or (None, False) if not valid.
70
+ """
71
+ op = node.ops[0]
72
+ if not isinstance(op, (ast.Is, ast.IsNot)):
73
+ return None, False
74
+
75
+ left, right = node.left, node.comparators[0]
76
+
77
+ # Check for 'x is None', 'x is not None'
78
+ if _is_none_constant(right):
79
+ return _extract_var_from_none_check(left, op)
80
+ # Check for 'None is x', 'None is not x'
81
+ if _is_none_constant(left):
82
+ return _extract_var_from_none_check(right, op)
83
+
84
+ return None, False
85
+
86
+
87
+ def _is_walrus_expression(node: ast.expr) -> bool:
88
+ """Check if expression contains walrus operator (assignment expression)."""
89
+ return isinstance(node, ast.NamedExpr)
90
+
91
+
92
+ class NoneCheckDetector(BaseLBYLDetector[NoneCheckPattern]):
93
+ """Detects 'if x is not None: x.method()' LBYL patterns."""
94
+
95
+ def __init__(self) -> None:
96
+ """Initialize the detector."""
97
+ self._patterns: list[NoneCheckPattern] = []
98
+
99
+ def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
100
+ """Visit if statement to check for None check LBYL pattern.
101
+
102
+ Args:
103
+ node: AST If node to analyze
104
+ """
105
+ self._check_none_pattern(node)
106
+ self.generic_visit(node)
107
+
108
+ def _check_none_pattern(self, node: ast.If) -> None:
109
+ """Check if node is a None check LBYL pattern and record it."""
110
+ var_expr, is_not_none = _try_extract_none_check(node.test)
111
+ if var_expr is None:
112
+ return
113
+
114
+ # For 'is not None', check body for variable usage
115
+ # For 'is None', check else branch for variable usage
116
+ body_to_check = node.body if is_not_none else node.orelse
117
+ if not body_to_check:
118
+ return
119
+
120
+ if self._body_has_variable_usage(body_to_check, var_expr):
121
+ self._patterns.append(self._create_pattern(node, var_expr))
122
+
123
+ def _body_has_variable_usage(self, body: list[ast.stmt], var_expr: ast.expr) -> bool:
124
+ """Check if body contains usage of the None-checked variable."""
125
+ expected_var = ast.dump(var_expr)
126
+ return any(
127
+ self._is_variable_usage(node, expected_var) for stmt in body for node in ast.walk(stmt)
128
+ )
129
+
130
+ def _is_variable_usage(self, node: ast.AST, expected_var: str) -> bool:
131
+ """Check if node represents usage of the expected variable."""
132
+ if isinstance(node, ast.Attribute):
133
+ return ast.dump(node.value) == expected_var
134
+ if isinstance(node, ast.Subscript):
135
+ return ast.dump(node.value) == expected_var
136
+ if isinstance(node, ast.Call):
137
+ return ast.dump(node.func) == expected_var
138
+ return False
139
+
140
+ def _create_pattern(self, node: ast.If, var_expr: ast.expr) -> NoneCheckPattern:
141
+ """Create NoneCheckPattern from detected pattern."""
142
+ return NoneCheckPattern(
143
+ line_number=node.lineno,
144
+ column=node.col_offset,
145
+ variable_name=ast.unparse(var_expr),
146
+ )
@@ -0,0 +1,145 @@
1
+ """
2
+ Purpose: AST-based detector for string validator LBYL patterns
3
+
4
+ Scope: Detects 'if s.isnumeric(): int(s)' patterns in Python code
5
+
6
+ Overview: Provides StringValidatorDetector class that uses AST traversal to find LBYL
7
+ anti-patterns involving string validation before conversion. Identifies patterns where
8
+ code checks string content (isnumeric, isdigit, isdecimal) before calling conversion
9
+ functions (int, float). Returns StringValidatorPattern objects containing the string
10
+ name, validator method, conversion function, and location.
11
+
12
+ Dependencies: ast module, base detector classes from pattern_detectors.base
13
+
14
+ Exports: StringValidatorPattern, StringValidatorDetector
15
+
16
+ Interfaces: StringValidatorDetector.find_patterns(tree: ast.AST) -> list[StringValidatorPattern]
17
+
18
+ Implementation: AST NodeVisitor pattern with visit_If to detect string validation followed
19
+ by conversion call
20
+
21
+ Suppressions:
22
+ - N802: visit_If follows Python AST visitor naming convention (camelCase required)
23
+ - invalid-name: visit_If follows Python AST visitor naming convention (camelCase required)
24
+ """
25
+
26
+ import ast
27
+ from collections.abc import Iterator
28
+ from dataclasses import dataclass
29
+
30
+ from .base import BaseLBYLDetector, LBYLPattern
31
+
32
+ # Validator methods that check numeric content
33
+ NUMERIC_VALIDATORS = frozenset({"isnumeric", "isdigit", "isdecimal"})
34
+
35
+ # Conversion functions that convert strings to numbers
36
+ NUMERIC_CONVERSIONS = frozenset({"int", "float"})
37
+
38
+
39
+ @dataclass
40
+ class StringValidatorPattern(LBYLPattern):
41
+ """Pattern data for string validator LBYL anti-pattern."""
42
+
43
+ string_name: str
44
+ validator_method: str
45
+ conversion_func: str
46
+
47
+
48
+ def _try_extract_validator_call(node: ast.expr) -> tuple[ast.expr | None, str | None]:
49
+ """Try to extract string validator call.
50
+
51
+ Returns:
52
+ Tuple of (string_expr, validator_method) or (None, None) if not valid.
53
+ """
54
+ if not isinstance(node, ast.Call):
55
+ return None, None
56
+ if not isinstance(node.func, ast.Attribute):
57
+ return None, None
58
+ if node.func.attr not in NUMERIC_VALIDATORS:
59
+ return None, None
60
+ return node.func.value, node.func.attr
61
+
62
+
63
+ def _get_string_expression_key(expr: ast.expr) -> str:
64
+ """Get a normalized key for comparing string expressions."""
65
+ return ast.dump(expr)
66
+
67
+
68
+ def _get_conversion_func_name(node: ast.AST) -> str | None:
69
+ """Get conversion function name if node is a numeric conversion call."""
70
+ if not isinstance(node, ast.Call):
71
+ return None
72
+ if not isinstance(node.func, ast.Name):
73
+ return None
74
+ if node.func.id not in NUMERIC_CONVERSIONS:
75
+ return None
76
+ return node.func.id
77
+
78
+
79
+ def _iter_ast_nodes(body: list[ast.stmt]) -> Iterator[ast.AST]:
80
+ """Iterate over all AST nodes in a list of statements."""
81
+ for stmt in body:
82
+ yield from ast.walk(stmt)
83
+
84
+
85
+ def _is_matching_call(node: ast.AST, expected_key: str) -> str | None:
86
+ """Check if node is a matching conversion call."""
87
+ if not isinstance(node, ast.Call):
88
+ return None
89
+ func_name = _get_conversion_func_name(node)
90
+ if func_name and node.args and ast.dump(node.args[0]) == expected_key:
91
+ return func_name
92
+ return None
93
+
94
+
95
+ def _find_conversion(body: list[ast.stmt], expected_key: str) -> str | None:
96
+ """Find first matching conversion in body."""
97
+ for node in _iter_ast_nodes(body):
98
+ result = _is_matching_call(node, expected_key)
99
+ if result:
100
+ return result
101
+ return None
102
+
103
+
104
+ class StringValidatorDetector(BaseLBYLDetector[StringValidatorPattern]):
105
+ """Detects 'if s.isnumeric(): int(s)' LBYL patterns."""
106
+
107
+ def __init__(self) -> None:
108
+ """Initialize the detector."""
109
+ self._patterns: list[StringValidatorPattern] = []
110
+
111
+ def visit_If(self, node: ast.If) -> None: # noqa: N802 # pylint: disable=invalid-name
112
+ """Visit if statement to check for string validator LBYL pattern.
113
+
114
+ Args:
115
+ node: AST If node to analyze
116
+ """
117
+ self._check_validator_pattern(node)
118
+ self.generic_visit(node)
119
+
120
+ def _check_validator_pattern(self, node: ast.If) -> None:
121
+ """Check if node is a string validator LBYL pattern and record it."""
122
+ string_expr, validator = _try_extract_validator_call(node.test)
123
+ if string_expr is None or validator is None:
124
+ return
125
+
126
+ expected_key = _get_string_expression_key(string_expr)
127
+ conversion = _find_conversion(node.body, expected_key)
128
+ if conversion:
129
+ self._patterns.append(self._create_pattern(node, string_expr, validator, conversion))
130
+
131
+ def _create_pattern(
132
+ self,
133
+ node: ast.If,
134
+ string_expr: ast.expr,
135
+ validator: str,
136
+ conversion: str,
137
+ ) -> StringValidatorPattern:
138
+ """Create StringValidatorPattern from detected pattern."""
139
+ return StringValidatorPattern(
140
+ line_number=node.lineno,
141
+ column=node.col_offset,
142
+ string_name=ast.unparse(string_expr),
143
+ validator_method=validator,
144
+ conversion_func=conversion,
145
+ )