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.
- src/__init__.py +1 -0
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +30 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +480 -0
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +67 -0
- src/cli/linters/code_patterns.py +270 -0
- src/cli/linters/code_smells.py +342 -0
- src/cli/linters/documentation.py +83 -0
- src/cli/linters/performance.py +287 -0
- src/cli/linters/shared.py +331 -0
- src/cli/linters/structure.py +327 -0
- src/cli/linters/structure_quality.py +328 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +37 -0
- src/config.py +38 -25
- src/core/base.py +7 -2
- src/core/cli_utils.py +19 -2
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +95 -6
- src/core/python_lint_rule.py +101 -0
- src/core/registry.py +1 -1
- src/core/rule_discovery.py +147 -84
- src/core/types.py +13 -0
- src/core/violation_builder.py +78 -15
- src/core/violation_utils.py +69 -0
- src/formatters/__init__.py +22 -0
- src/formatters/sarif.py +202 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +254 -395
- src/linter_config/loader.py +45 -12
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +75 -0
- src/linters/collection_pipeline/continue_analyzer.py +94 -0
- src/linters/collection_pipeline/detector.py +360 -0
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +420 -0
- src/linters/collection_pipeline/suggestion_builder.py +130 -0
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +120 -20
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache.py +104 -10
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/config.py +54 -11
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +223 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/duplicate_storage.py +5 -4
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +183 -48
- src/linters/dry/python_analyzer.py +60 -439
- src/linters/dry/python_constant_extractor.py +100 -0
- src/linters/dry/single_statement_detector.py +417 -0
- src/linters/dry/token_hasher.py +116 -112
- src/linters/dry/typescript_analyzer.py +68 -382
- src/linters/dry/typescript_constant_extractor.py +138 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +70 -0
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +5 -4
- src/linters/dry/violation_generator.py +71 -14
- src/linters/file_header/atemporal_detector.py +68 -50
- src/linters/file_header/base_parser.py +93 -0
- src/linters/file_header/bash_parser.py +66 -0
- src/linters/file_header/config.py +90 -16
- src/linters/file_header/css_parser.py +70 -0
- src/linters/file_header/field_validator.py +36 -33
- src/linters/file_header/linter.py +140 -144
- src/linters/file_header/markdown_parser.py +130 -0
- src/linters/file_header/python_parser.py +14 -58
- src/linters/file_header/typescript_parser.py +73 -0
- src/linters/file_header/violation_builder.py +13 -12
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/linter.py +66 -34
- src/linters/file_placement/pattern_matcher.py +41 -6
- src/linters/file_placement/pattern_validator.py +31 -12
- src/linters/file_placement/rule_checker.py +12 -7
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +74 -0
- src/linters/lazy_ignores/directive_utils.py +164 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +168 -0
- src/linters/lazy_ignores/python_analyzer.py +209 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +71 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +135 -0
- src/linters/lbyl/__init__.py +31 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +53 -0
- src/linters/lbyl/pattern_detectors/base.py +63 -0
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/magic_numbers/context_analyzer.py +227 -225
- src/linters/magic_numbers/linter.py +28 -82
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -12
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +138 -0
- src/linters/method_property/linter.py +414 -0
- src/linters/method_property/python_analyzer.py +473 -0
- src/linters/method_property/violation_builder.py +119 -0
- src/linters/nesting/linter.py +24 -16
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/violation_builder.py +1 -0
- src/linters/performance/__init__.py +91 -0
- src/linters/performance/config.py +43 -0
- src/linters/performance/constants.py +49 -0
- src/linters/performance/linter.py +149 -0
- src/linters/performance/python_analyzer.py +365 -0
- src/linters/performance/regex_analyzer.py +312 -0
- src/linters/performance/regex_linter.py +139 -0
- src/linters/performance/typescript_analyzer.py +236 -0
- src/linters/performance/violation_builder.py +160 -0
- src/linters/print_statements/config.py +7 -12
- src/linters/print_statements/linter.py +26 -43
- src/linters/print_statements/python_analyzer.py +91 -93
- src/linters/print_statements/typescript_analyzer.py +15 -25
- src/linters/print_statements/violation_builder.py +12 -14
- src/linters/srp/class_analyzer.py +11 -7
- src/linters/srp/heuristics.py +56 -22
- src/linters/srp/linter.py +15 -16
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +110 -50
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +349 -0
- src/linters/stateless_class/python_analyzer.py +290 -0
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +189 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +135 -0
- src/linters/stringly_typed/ignore_checker.py +100 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +376 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +348 -0
- src/linters/stringly_typed/python/call_tracker.py +175 -0
- src/linters/stringly_typed/python/comparison_tracker.py +257 -0
- src/linters/stringly_typed/python/condition_extractor.py +134 -0
- src/linters/stringly_typed/python/conditional_detector.py +179 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +94 -0
- src/linters/stringly_typed/python/validation_detector.py +189 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +620 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +335 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
- src/linters/stringly_typed/violation_generator.py +419 -0
- src/orchestrator/core.py +252 -14
- src/orchestrator/language_detector.py +5 -3
- src/templates/thailint_config_template.yaml +196 -0
- src/utils/project_root.py +3 -0
- thailint-0.15.3.dist-info/METADATA +187 -0
- thailint-0.15.3.dist-info/RECORD +226 -0
- thailint-0.15.3.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -1665
- thailint-0.5.0.dist-info/METADATA +0 -1286
- thailint-0.5.0.dist-info/RECORD +0 -96
- thailint-0.5.0.dist-info/entry_points.txt +0 -4
- {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
- {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
|