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,257 @@
1
+ """
2
+ Purpose: Detect scattered string comparisons in Python AST
3
+
4
+ Scope: Find equality/inequality comparisons with string literals across Python files
5
+
6
+ Overview: Provides ComparisonTracker class that traverses Python AST to find scattered
7
+ string comparisons like `if env == "production"`. Tracks the variable name, compared
8
+ string value, and operator to enable cross-file aggregation. When a variable is compared
9
+ to multiple unique string values across files, it suggests the variable should be an enum.
10
+ Excludes common false positives like `__name__ == "__main__"` and type name checks.
11
+
12
+ Dependencies: ast module for AST parsing, dataclasses for pattern structure,
13
+ src.core.constants for MAX_ATTRIBUTE_CHAIN_DEPTH
14
+
15
+ Exports: ComparisonTracker class, ComparisonPattern dataclass
16
+
17
+ Interfaces: ComparisonTracker.find_patterns(tree) -> list[ComparisonPattern]
18
+
19
+ Implementation: AST NodeVisitor pattern with Compare node handling for string comparisons
20
+
21
+ Suppressions:
22
+ - invalid-name: visit_Compare follows AST NodeVisitor method naming convention
23
+ - srp: Tracker implements AST visitor pattern with multiple visit methods.
24
+ Methods support single responsibility of comparison pattern detection.
25
+ """
26
+
27
+ import ast
28
+ from dataclasses import dataclass
29
+
30
+ from src.core.constants import MAX_ATTRIBUTE_CHAIN_DEPTH
31
+
32
+
33
+ @dataclass
34
+ class ComparisonPattern:
35
+ """Represents a string comparison found in Python code.
36
+
37
+ Captures information about a comparison like `if (env == "production")` to
38
+ enable cross-file analysis for detecting scattered string comparisons that
39
+ suggest missing enums.
40
+ """
41
+
42
+ variable_name: str
43
+ """Variable name being compared (e.g., 'env' or 'self.status')."""
44
+
45
+ compared_value: str
46
+ """The string literal value being compared to."""
47
+
48
+ operator: str
49
+ """The comparison operator ('==' or '!=')."""
50
+
51
+ line_number: int
52
+ """Line number where the comparison occurs (1-indexed)."""
53
+
54
+ column: int
55
+ """Column number where the comparison starts (0-indexed)."""
56
+
57
+
58
+ # Excluded variable names that are common false positives
59
+ _EXCLUDED_VARIABLES = frozenset(
60
+ {
61
+ "__name__",
62
+ "__class__.__name__",
63
+ }
64
+ )
65
+
66
+ # Excluded values that are common in legitimate comparisons
67
+ _EXCLUDED_VALUES = frozenset(
68
+ {
69
+ "__main__",
70
+ }
71
+ )
72
+
73
+
74
+ class ComparisonTracker(ast.NodeVisitor): # thailint: ignore[srp]
75
+ """Tracks scattered string comparisons in Python AST.
76
+
77
+ Finds patterns like `if env == "production"` and `if status != "deleted"` where
78
+ string literals are used for comparisons that could use enums instead.
79
+
80
+ Note: Method count exceeds SRP limit because AST traversal requires multiple helper
81
+ methods for extracting variable names, attribute names, and pattern filtering. All
82
+ methods support the single responsibility of tracking string comparisons.
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ """Initialize the tracker."""
87
+ self.patterns: list[ComparisonPattern] = []
88
+
89
+ def find_patterns(self, tree: ast.AST) -> list[ComparisonPattern]:
90
+ """Find all string comparisons in the AST.
91
+
92
+ Args:
93
+ tree: The AST to analyze
94
+
95
+ Returns:
96
+ List of ComparisonPattern instances for each detected comparison
97
+ """
98
+ self.patterns = []
99
+ self.visit(tree)
100
+ return self.patterns
101
+
102
+ def visit_Compare(self, node: ast.Compare) -> None: # pylint: disable=invalid-name
103
+ """Visit a Compare node to check for string comparisons.
104
+
105
+ Handles both `var == "string"` and `"string" == var` patterns.
106
+
107
+ Args:
108
+ node: The Compare node to analyze
109
+ """
110
+ self._check_comparison(node)
111
+ self.generic_visit(node)
112
+
113
+ def _check_comparison(self, node: ast.Compare) -> None:
114
+ """Check if comparison is a string comparison to track.
115
+
116
+ Args:
117
+ node: The Compare node to check
118
+ """
119
+ # Only handle simple binary comparisons
120
+ if len(node.ops) != 1 or len(node.comparators) != 1:
121
+ return
122
+
123
+ operator = node.ops[0]
124
+ if not isinstance(operator, (ast.Eq, ast.NotEq)):
125
+ return
126
+
127
+ op_str = "==" if isinstance(operator, ast.Eq) else "!="
128
+ left = node.left
129
+ right = node.comparators[0]
130
+
131
+ # Try both orientations: var == "string" and "string" == var
132
+ self._try_extract_pattern(left, right, op_str, node)
133
+ self._try_extract_pattern(right, left, op_str, node)
134
+
135
+ def _try_extract_pattern(
136
+ self,
137
+ var_side: ast.expr,
138
+ string_side: ast.expr,
139
+ operator: str,
140
+ node: ast.Compare,
141
+ ) -> None:
142
+ """Try to extract a pattern from a comparison.
143
+
144
+ Args:
145
+ var_side: The expression that might be a variable
146
+ string_side: The expression that might be a string literal
147
+ operator: The comparison operator
148
+ node: The original Compare node for location info
149
+ """
150
+ # Check if string_side is a string literal
151
+ if not isinstance(string_side, ast.Constant):
152
+ return
153
+ if not isinstance(string_side.value, str):
154
+ return
155
+
156
+ string_value = string_side.value
157
+
158
+ # Extract variable name
159
+ var_name = self._extract_variable_name(var_side)
160
+ if var_name is None:
161
+ return
162
+
163
+ # Check for excluded patterns
164
+ if self._should_exclude(var_name, string_value):
165
+ return
166
+
167
+ self._add_pattern(var_name, string_value, operator, node)
168
+
169
+ def _extract_variable_name(self, node: ast.expr) -> str | None:
170
+ """Extract variable name from an expression.
171
+
172
+ Handles simple names (var) and attribute access (obj.attr).
173
+
174
+ Args:
175
+ node: The expression to extract from
176
+
177
+ Returns:
178
+ Variable name string or None if not extractable
179
+ """
180
+ if isinstance(node, ast.Name):
181
+ return node.id
182
+ if isinstance(node, ast.Attribute):
183
+ return self._extract_attribute_name(node)
184
+ return None
185
+
186
+ def _extract_attribute_name(self, node: ast.Attribute) -> str | None:
187
+ """Extract attribute name from an attribute access.
188
+
189
+ Builds qualified names like 'obj.attr' or 'a.b.attr'.
190
+
191
+ Args:
192
+ node: The Attribute node
193
+
194
+ Returns:
195
+ Qualified attribute name or None if too complex
196
+ """
197
+ parts: list[str] = [node.attr]
198
+ current = node.value
199
+ depth = 0
200
+
201
+ while depth < MAX_ATTRIBUTE_CHAIN_DEPTH:
202
+ if isinstance(current, ast.Name):
203
+ parts.append(current.id)
204
+ break
205
+ if isinstance(current, ast.Attribute):
206
+ parts.append(current.attr)
207
+ current = current.value
208
+ depth += 1
209
+ else:
210
+ # Complex expression, still return what we have
211
+ parts.append("_")
212
+ break
213
+
214
+ return ".".join(reversed(parts))
215
+
216
+ def _should_exclude(self, var_name: str, string_value: str) -> bool:
217
+ """Check if this comparison should be excluded.
218
+
219
+ Filters out common patterns that are not stringly-typed code:
220
+ - __name__ == "__main__"
221
+ - __class__.__name__ checks
222
+
223
+ Args:
224
+ var_name: The variable name
225
+ string_value: The string value
226
+
227
+ Returns:
228
+ True if the comparison should be excluded
229
+ """
230
+ if var_name in _EXCLUDED_VARIABLES:
231
+ return True
232
+ if string_value in _EXCLUDED_VALUES:
233
+ return True
234
+ # Also exclude if the full qualified name ends with __name__
235
+ if var_name.endswith("__name__"):
236
+ return True
237
+ return False
238
+
239
+ def _add_pattern(
240
+ self, var_name: str, string_value: str, operator: str, node: ast.Compare
241
+ ) -> None:
242
+ """Create and add a comparison pattern to results.
243
+
244
+ Args:
245
+ var_name: The variable name
246
+ string_value: The string value being compared
247
+ operator: The comparison operator
248
+ node: The Compare node for location info
249
+ """
250
+ pattern = ComparisonPattern(
251
+ variable_name=var_name,
252
+ compared_value=string_value,
253
+ operator=operator,
254
+ line_number=node.lineno,
255
+ column=node.col_offset,
256
+ )
257
+ self.patterns.append(pattern)
@@ -0,0 +1,134 @@
1
+ """
2
+ Purpose: Extract string comparisons from Python condition expressions
3
+
4
+ Scope: Parse BoolOp and Compare nodes to extract string equality patterns
5
+
6
+ Overview: Provides functions to extract string comparisons from condition expressions
7
+ in Python AST. Handles simple comparisons, or-combined, and and-combined
8
+ conditions. Updates a collector object with extracted variable names and
9
+ string values. Separated from main detector to reduce complexity.
10
+
11
+ Dependencies: ast module, variable_extractor
12
+
13
+ Exports: extract_from_condition, is_simple_string_equality, get_string_constant
14
+
15
+ Interfaces: Functions for extracting string comparisons from AST nodes
16
+
17
+ Implementation: Recursive traversal of BoolOp nodes with Compare extraction
18
+
19
+ Suppressions:
20
+ - type:ignore[attr-defined]: AST node attribute access varies by node type (value.value)
21
+ """
22
+
23
+ import ast
24
+
25
+ from .variable_extractor import extract_variable_name
26
+
27
+
28
+ def extract_from_condition(
29
+ test: ast.expr,
30
+ collector: object,
31
+ ) -> None:
32
+ """Extract string comparisons from a condition expression.
33
+
34
+ Handles simple comparisons, or-combined, and and-combined comparisons.
35
+
36
+ Args:
37
+ test: The test expression from an if/elif
38
+ collector: Collector to accumulate results into (must have variable_name
39
+ and string_values attributes)
40
+ """
41
+ if isinstance(test, ast.BoolOp):
42
+ _extract_from_bool_op(test, collector)
43
+ elif isinstance(test, ast.Compare):
44
+ _extract_from_compare(test, collector)
45
+
46
+
47
+ def _extract_from_bool_op(node: ast.BoolOp, collector: object) -> None:
48
+ """Extract from BoolOp (And/Or combined comparisons).
49
+
50
+ Args:
51
+ node: BoolOp node
52
+ collector: Collector to accumulate results into
53
+ """
54
+ for value in node.values:
55
+ _handle_bool_op_value(value, collector)
56
+
57
+
58
+ def _handle_bool_op_value(value: ast.expr, collector: object) -> None:
59
+ """Handle a single value from a BoolOp node.
60
+
61
+ Args:
62
+ value: Expression value from BoolOp
63
+ collector: Collector to accumulate results into
64
+ """
65
+ if isinstance(value, ast.Compare):
66
+ _extract_from_compare(value, collector)
67
+ elif isinstance(value, ast.BoolOp):
68
+ _extract_from_bool_op(value, collector)
69
+
70
+
71
+ def _extract_from_compare(node: ast.Compare, collector: object) -> None:
72
+ """Extract string value from a Compare node with Eq/NotEq.
73
+
74
+ Args:
75
+ node: Compare node to analyze
76
+ collector: Collector to accumulate results into
77
+ """
78
+ if not _is_simple_equality(node):
79
+ return
80
+
81
+ string_value = _get_string_constant(node)
82
+ if string_value is None:
83
+ return
84
+
85
+ var_name = extract_variable_name(node.left)
86
+ _update_collector(collector, var_name, string_value)
87
+
88
+
89
+ def _is_simple_equality(node: ast.Compare) -> bool:
90
+ """Check if Compare is a simple equality with one operator.
91
+
92
+ Args:
93
+ node: Compare node to check
94
+
95
+ Returns:
96
+ True if it's a simple x == y or x != y comparison
97
+ """
98
+ if len(node.ops) != 1:
99
+ return False
100
+ return isinstance(node.ops[0], (ast.Eq, ast.NotEq))
101
+
102
+
103
+ def _get_string_constant(node: ast.Compare) -> str | None:
104
+ """Get string constant from the right side of comparison.
105
+
106
+ Args:
107
+ node: Compare node to extract from
108
+
109
+ Returns:
110
+ String value if comparator is a string constant, None otherwise
111
+ """
112
+ comparator = node.comparators[0]
113
+ if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str):
114
+ return comparator.value
115
+ return None
116
+
117
+
118
+ def _update_collector(
119
+ collector: object,
120
+ var_name: str | None,
121
+ string_value: str,
122
+ ) -> None:
123
+ """Update collector with extracted variable and value.
124
+
125
+ Args:
126
+ collector: Collector to update
127
+ var_name: Variable name from comparison
128
+ string_value: String value from comparison
129
+ """
130
+ if collector.variable_name is None: # type: ignore[attr-defined]
131
+ collector.variable_name = var_name # type: ignore[attr-defined]
132
+ # Only add if same variable (or no variable tracking)
133
+ if collector.variable_name == var_name or var_name is None: # type: ignore[attr-defined]
134
+ collector.string_values.add(string_value) # type: ignore[attr-defined]
@@ -0,0 +1,179 @@
1
+ """
2
+ Purpose: Detect equality chain patterns in Python AST
3
+
4
+ Scope: Find 'if x == "a" elif x == "b"', or-combined, and match statement patterns
5
+
6
+ Overview: Provides ConditionalPatternDetector class that traverses Python AST to find
7
+ equality chain patterns where strings are used instead of enums. Detects single
8
+ equality comparisons with string constants, aggregates values from if/elif chains,
9
+ handles or-combined comparisons, and supports Python 3.10+ match statements.
10
+ Returns structured EqualityChainPattern dataclass instances with aggregated
11
+ string values, pattern type, location, and optional variable name.
12
+
13
+ Dependencies: ast module for AST parsing, dataclasses for pattern structure,
14
+ condition_extractor for comparison extraction, match_analyzer for match statements
15
+
16
+ Exports: ConditionalPatternDetector class, EqualityChainPattern dataclass
17
+
18
+ Interfaces: ConditionalPatternDetector.find_patterns(tree) -> list[EqualityChainPattern]
19
+
20
+ Implementation: AST NodeVisitor pattern with If node chain traversal and Match statement handling
21
+
22
+ Suppressions:
23
+ - invalid-name: visit_If, visit_Match follow AST NodeVisitor method naming convention
24
+ """
25
+
26
+ import ast
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING
29
+
30
+ from .condition_extractor import extract_from_condition
31
+ from .constants import MIN_VALUES_FOR_PATTERN
32
+ from .match_analyzer import analyze_match_statement
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Iterator
36
+
37
+
38
+ @dataclass
39
+ class EqualityChainPattern:
40
+ """Represents a detected equality chain pattern.
41
+
42
+ Captures information about stringly-typed equality checks including aggregated
43
+ string values from chains, pattern type, source location, and variable name.
44
+ """
45
+
46
+ string_values: set[str]
47
+ """Set of string values aggregated from the equality chain."""
48
+
49
+ pattern_type: str
50
+ """Type of pattern: 'equality_chain', 'or_combined', or 'match_statement'."""
51
+
52
+ line_number: int
53
+ """Line number where the pattern starts (1-indexed)."""
54
+
55
+ column: int
56
+ """Column number where the pattern starts (0-indexed)."""
57
+
58
+ variable_name: str | None
59
+ """Variable name being compared, if identifiable from a simple expression."""
60
+
61
+
62
+ @dataclass
63
+ class _ChainCollector:
64
+ """Internal collector for aggregating values from if/elif chains."""
65
+
66
+ variable_name: str | None = None
67
+ string_values: set[str] = field(default_factory=set)
68
+ line_number: int = 0
69
+ column: int = 0
70
+
71
+
72
+ class ConditionalPatternDetector(ast.NodeVisitor):
73
+ """Detects equality chain patterns in Python AST.
74
+
75
+ Finds patterns like 'if x == "a" elif x == "b"', or-combined comparisons,
76
+ and match statements where strings are used instead of proper enums.
77
+ """
78
+
79
+ def __init__(self) -> None:
80
+ """Initialize the detector."""
81
+ self.patterns: list[EqualityChainPattern] = []
82
+ self._processed_if_nodes: set[int] = set()
83
+
84
+ def find_patterns(self, tree: ast.AST) -> list[EqualityChainPattern]:
85
+ """Find all equality chain patterns in the AST.
86
+
87
+ Args:
88
+ tree: The AST to analyze
89
+
90
+ Returns:
91
+ List of EqualityChainPattern instances for each detected pattern
92
+ """
93
+ self.patterns = []
94
+ self._processed_if_nodes = set()
95
+ self.visit(tree)
96
+ return self.patterns
97
+
98
+ def visit_If(self, node: ast.If) -> None: # pylint: disable=invalid-name
99
+ """Visit an If node to check for equality chain patterns.
100
+
101
+ Args:
102
+ node: The If node to analyze
103
+ """
104
+ if id(node) not in self._processed_if_nodes:
105
+ self._analyze_if_chain(node)
106
+ self.generic_visit(node)
107
+
108
+ def visit_Match(self, node: ast.Match) -> None: # pylint: disable=invalid-name
109
+ """Visit a Match node to check for string case patterns.
110
+
111
+ Args:
112
+ node: The Match node to analyze
113
+ """
114
+ pattern = analyze_match_statement(node, EqualityChainPattern)
115
+ if pattern is not None:
116
+ self.patterns.append(pattern)
117
+ self.generic_visit(node)
118
+
119
+ def _analyze_if_chain(self, node: ast.If) -> None:
120
+ """Analyze an if/elif chain for equality patterns.
121
+
122
+ Args:
123
+ node: The starting If node of the chain
124
+ """
125
+ collector = _ChainCollector(line_number=node.lineno, column=node.col_offset)
126
+
127
+ for if_node in self._iter_if_chain(node):
128
+ self._processed_if_nodes.add(id(if_node))
129
+ extract_from_condition(if_node.test, collector)
130
+
131
+ self._emit_pattern_if_valid(collector)
132
+
133
+ def _iter_if_chain(self, node: ast.If) -> "Iterator[ast.If]":
134
+ """Iterate through an if/elif chain.
135
+
136
+ Args:
137
+ node: Starting If node
138
+
139
+ Yields:
140
+ Each If node in the chain including elif branches
141
+ """
142
+ yield node
143
+ current: ast.If | None = node
144
+
145
+ while current is not None:
146
+ current = self._get_next_elif(current)
147
+ if current is not None:
148
+ yield current
149
+
150
+ def _get_next_elif(self, node: ast.If) -> ast.If | None:
151
+ """Get the next elif node in a chain.
152
+
153
+ Args:
154
+ node: Current If node
155
+
156
+ Returns:
157
+ Next elif If node, or None if no elif exists
158
+ """
159
+ if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
160
+ return node.orelse[0]
161
+ return None
162
+
163
+ def _emit_pattern_if_valid(self, collector: _ChainCollector) -> None:
164
+ """Emit a pattern if collector has sufficient values.
165
+
166
+ Args:
167
+ collector: Collector with aggregated values
168
+ """
169
+ if len(collector.string_values) < MIN_VALUES_FOR_PATTERN:
170
+ return
171
+
172
+ pattern = EqualityChainPattern(
173
+ string_values=collector.string_values,
174
+ pattern_type="equality_chain",
175
+ line_number=collector.line_number,
176
+ column=collector.column,
177
+ variable_name=collector.variable_name,
178
+ )
179
+ self.patterns.append(pattern)
@@ -0,0 +1,21 @@
1
+ """
2
+ Purpose: Shared constants for stringly-typed Python detection
3
+
4
+ Scope: Common configuration values used across Python pattern detectors
5
+
6
+ Overview: Provides shared constants used by MembershipValidationDetector,
7
+ ConditionalPatternDetector, and other Python detection components.
8
+ Centralizes configuration values to ensure consistency and avoid
9
+ duplication across detector implementations.
10
+
11
+ Dependencies: None
12
+
13
+ Exports: MIN_VALUES_FOR_PATTERN constant
14
+
15
+ Interfaces: Constants only, no function interfaces
16
+
17
+ Implementation: Simple module-level constant definitions
18
+ """
19
+
20
+ # Minimum number of string values to consider as enum candidate
21
+ MIN_VALUES_FOR_PATTERN = 2
@@ -0,0 +1,94 @@
1
+ """
2
+ Purpose: Analyze Python match statements for stringly-typed patterns
3
+
4
+ Scope: Extract string values from match statement cases
5
+
6
+ Overview: Provides MatchStatementAnalyzer class that analyzes Python 3.10+ match
7
+ statements to detect stringly-typed patterns. Extracts string values from
8
+ case patterns and returns structured results. Separated from main detector
9
+ to maintain single responsibility and reduce class complexity.
10
+
11
+ Dependencies: ast module, constants module, variable_extractor
12
+
13
+ Exports: MatchStatementAnalyzer class
14
+
15
+ Interfaces: MatchStatementAnalyzer.analyze(node) -> EqualityChainPattern | None
16
+
17
+ Implementation: AST pattern matching for MatchValue nodes with string constants
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ from typing import TYPE_CHECKING
24
+
25
+ from .constants import MIN_VALUES_FOR_PATTERN
26
+ from .variable_extractor import extract_variable_name
27
+
28
+ if TYPE_CHECKING:
29
+ from .conditional_detector import EqualityChainPattern
30
+
31
+
32
+ def analyze_match_statement(
33
+ node: ast.Match,
34
+ pattern_class: type[EqualityChainPattern],
35
+ ) -> EqualityChainPattern | None:
36
+ """Analyze a match statement for string case patterns.
37
+
38
+ Args:
39
+ node: Match statement node to analyze
40
+ pattern_class: The EqualityChainPattern class to use for results
41
+
42
+ Returns:
43
+ Pattern instance if valid match found, None otherwise
44
+ """
45
+ string_values = _collect_string_cases(node.cases)
46
+
47
+ if len(string_values) < MIN_VALUES_FOR_PATTERN:
48
+ return None
49
+
50
+ var_name = extract_variable_name(node.subject)
51
+ return pattern_class(
52
+ string_values=string_values,
53
+ pattern_type="match_statement",
54
+ line_number=node.lineno,
55
+ column=node.col_offset,
56
+ variable_name=var_name,
57
+ )
58
+
59
+
60
+ def _collect_string_cases(cases: list[ast.match_case]) -> set[str]:
61
+ """Collect string values from match cases.
62
+
63
+ Args:
64
+ cases: List of match_case nodes
65
+
66
+ Returns:
67
+ Set of string values from MatchValue patterns
68
+ """
69
+ string_values: set[str] = set()
70
+
71
+ for case in cases:
72
+ value = _extract_case_string_value(case.pattern)
73
+ if value is not None:
74
+ string_values.add(value)
75
+
76
+ return string_values
77
+
78
+
79
+ def _extract_case_string_value(pattern: ast.pattern) -> str | None:
80
+ """Extract string value from a case pattern.
81
+
82
+ Args:
83
+ pattern: Match case pattern node
84
+
85
+ Returns:
86
+ String value if pattern is a MatchValue with string, None otherwise
87
+ """
88
+ if not isinstance(pattern, ast.MatchValue):
89
+ return None
90
+ if not isinstance(pattern.value, ast.Constant):
91
+ return None
92
+ if not isinstance(pattern.value.value, str):
93
+ return None
94
+ return pattern.value.value