thailint 0.15.0__py3-none-any.whl → 0.15.1__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/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/cli/config.py +6 -4
- src/cli/linters/code_patterns.py +64 -16
- src/cli/linters/code_smells.py +23 -14
- src/cli/linters/documentation.py +5 -3
- src/cli/linters/performance.py +23 -10
- src/cli/linters/shared.py +22 -6
- src/cli/linters/structure.py +13 -4
- src/cli/linters/structure_quality.py +9 -4
- src/cli/utils.py +4 -4
- src/config.py +34 -21
- src/core/python_lint_rule.py +101 -0
- src/linter_config/ignore.py +2 -1
- 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/typescript_value_extractor.py +2 -1
- src/linters/file_header/linter.py +2 -1
- src/linters/file_placement/linter.py +6 -6
- src/linters/file_placement/pattern_validator.py +6 -5
- src/linters/file_placement/rule_checker.py +10 -5
- src/linters/lazy_ignores/config.py +5 -3
- src/linters/lazy_ignores/python_analyzer.py +5 -1
- src/linters/lazy_ignores/types.py +2 -1
- src/linters/lbyl/__init__.py +3 -1
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +30 -2
- src/linters/lbyl/pattern_detectors/base.py +24 -7
- 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/stringly_typed/ignore_checker.py +4 -6
- src/orchestrator/language_detector.py +5 -3
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
- {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Coordinate LBYL pattern detection and violation generation
|
|
3
|
+
|
|
4
|
+
Scope: Orchestrates multiple pattern detectors and converts results to violations
|
|
5
|
+
|
|
6
|
+
Overview: Provides PythonLBYLAnalyzer class that coordinates all LBYL pattern detectors
|
|
7
|
+
and converts detected patterns into Violation objects. Handles AST parsing with
|
|
8
|
+
graceful syntax error handling, runs enabled detectors based on configuration toggles,
|
|
9
|
+
and aggregates violations from all detectors. Serves as the main analysis engine
|
|
10
|
+
called by LBYLRule.
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, LBYLConfig, pattern detectors, violation_builder functions
|
|
13
|
+
|
|
14
|
+
Exports: PythonLBYLAnalyzer
|
|
15
|
+
|
|
16
|
+
Interfaces: analyze(code: str, file_path: str, config: LBYLConfig) -> list[Violation]
|
|
17
|
+
|
|
18
|
+
Implementation: Detector coordination with config-driven pattern selection using
|
|
19
|
+
generic detector runner for code reuse
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import Any, TypeVar
|
|
25
|
+
|
|
26
|
+
from src.core.types import Violation
|
|
27
|
+
|
|
28
|
+
from .config import LBYLConfig
|
|
29
|
+
from .pattern_detectors.base import BaseLBYLDetector, LBYLPattern
|
|
30
|
+
from .pattern_detectors.dict_key_detector import DictKeyDetector, DictKeyPattern
|
|
31
|
+
from .pattern_detectors.division_check_detector import (
|
|
32
|
+
DivisionCheckDetector,
|
|
33
|
+
DivisionCheckPattern,
|
|
34
|
+
)
|
|
35
|
+
from .pattern_detectors.file_exists_detector import FileExistsDetector, FileExistsPattern
|
|
36
|
+
from .pattern_detectors.hasattr_detector import HasattrDetector, HasattrPattern
|
|
37
|
+
from .pattern_detectors.isinstance_detector import IsinstanceDetector, IsinstancePattern
|
|
38
|
+
from .pattern_detectors.len_check_detector import LenCheckDetector, LenCheckPattern
|
|
39
|
+
from .pattern_detectors.none_check_detector import NoneCheckDetector, NoneCheckPattern
|
|
40
|
+
from .pattern_detectors.string_validator_detector import (
|
|
41
|
+
StringValidatorDetector,
|
|
42
|
+
StringValidatorPattern,
|
|
43
|
+
)
|
|
44
|
+
from .violation_builder import (
|
|
45
|
+
build_dict_key_violation,
|
|
46
|
+
build_division_check_violation,
|
|
47
|
+
build_file_exists_violation,
|
|
48
|
+
build_hasattr_violation,
|
|
49
|
+
build_isinstance_violation,
|
|
50
|
+
build_len_check_violation,
|
|
51
|
+
build_none_check_violation,
|
|
52
|
+
build_string_validator_violation,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
PatternT = TypeVar("PatternT", bound=LBYLPattern)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_python_code(code: str) -> ast.Module | None:
|
|
59
|
+
"""Parse Python code into AST, returning None if empty or invalid."""
|
|
60
|
+
if not code or not code.strip():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return ast.parse(code)
|
|
64
|
+
except SyntaxError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_detector(
|
|
69
|
+
detector: BaseLBYLDetector[PatternT],
|
|
70
|
+
tree: ast.Module,
|
|
71
|
+
file_path: str,
|
|
72
|
+
converter: Callable[[PatternT, str], Violation],
|
|
73
|
+
pattern_type: type[PatternT],
|
|
74
|
+
) -> list[Violation]:
|
|
75
|
+
"""Run a detector and convert patterns to violations."""
|
|
76
|
+
return [
|
|
77
|
+
converter(p, file_path) for p in detector.find_patterns(tree) if isinstance(p, pattern_type)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_dict_key(pattern: DictKeyPattern, file_path: str) -> Violation:
|
|
82
|
+
"""Convert DictKeyPattern to Violation."""
|
|
83
|
+
return build_dict_key_violation(
|
|
84
|
+
file_path=file_path,
|
|
85
|
+
line=pattern.line_number,
|
|
86
|
+
column=pattern.column,
|
|
87
|
+
dict_name=pattern.dict_name,
|
|
88
|
+
key_expression=pattern.key_expression,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_division_check(pattern: DivisionCheckPattern, file_path: str) -> Violation:
|
|
93
|
+
"""Convert DivisionCheckPattern to Violation."""
|
|
94
|
+
return build_division_check_violation(
|
|
95
|
+
file_path=file_path,
|
|
96
|
+
line=pattern.line_number,
|
|
97
|
+
column=pattern.column,
|
|
98
|
+
divisor_name=pattern.divisor_name,
|
|
99
|
+
operation=pattern.operation,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_file_exists(pattern: FileExistsPattern, file_path: str) -> Violation:
|
|
104
|
+
"""Convert FileExistsPattern to Violation."""
|
|
105
|
+
return build_file_exists_violation(
|
|
106
|
+
file_path=file_path,
|
|
107
|
+
line=pattern.line_number,
|
|
108
|
+
column=pattern.column,
|
|
109
|
+
path_expression=pattern.file_path_expression,
|
|
110
|
+
check_type=pattern.check_type,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_hasattr(pattern: HasattrPattern, file_path: str) -> Violation:
|
|
115
|
+
"""Convert HasattrPattern to Violation."""
|
|
116
|
+
return build_hasattr_violation(
|
|
117
|
+
file_path=file_path,
|
|
118
|
+
line=pattern.line_number,
|
|
119
|
+
column=pattern.column,
|
|
120
|
+
object_name=pattern.object_name,
|
|
121
|
+
attribute_name=pattern.attribute_name,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_isinstance(pattern: IsinstancePattern, file_path: str) -> Violation:
|
|
126
|
+
"""Convert IsinstancePattern to Violation."""
|
|
127
|
+
return build_isinstance_violation(
|
|
128
|
+
file_path=file_path,
|
|
129
|
+
line=pattern.line_number,
|
|
130
|
+
column=pattern.column,
|
|
131
|
+
object_name=pattern.object_name,
|
|
132
|
+
type_name=pattern.type_name,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_len_check(pattern: LenCheckPattern, file_path: str) -> Violation:
|
|
137
|
+
"""Convert LenCheckPattern to Violation."""
|
|
138
|
+
return build_len_check_violation(
|
|
139
|
+
file_path=file_path,
|
|
140
|
+
line=pattern.line_number,
|
|
141
|
+
column=pattern.column,
|
|
142
|
+
collection_name=pattern.collection_name,
|
|
143
|
+
index_expression=pattern.index_expression,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_none_check(pattern: NoneCheckPattern, file_path: str) -> Violation:
|
|
148
|
+
"""Convert NoneCheckPattern to Violation."""
|
|
149
|
+
return build_none_check_violation(
|
|
150
|
+
file_path=file_path,
|
|
151
|
+
line=pattern.line_number,
|
|
152
|
+
column=pattern.column,
|
|
153
|
+
variable_name=pattern.variable_name,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _build_string_validator(pattern: StringValidatorPattern, file_path: str) -> Violation:
|
|
158
|
+
"""Convert StringValidatorPattern to Violation."""
|
|
159
|
+
return build_string_validator_violation(
|
|
160
|
+
file_path=file_path,
|
|
161
|
+
line=pattern.line_number,
|
|
162
|
+
column=pattern.column,
|
|
163
|
+
string_name=pattern.string_name,
|
|
164
|
+
validator_method=pattern.validator_method,
|
|
165
|
+
conversion_func=pattern.conversion_func,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PythonLBYLAnalyzer:
|
|
170
|
+
"""Coordinates LBYL pattern detection for Python code."""
|
|
171
|
+
|
|
172
|
+
def __init__(self) -> None:
|
|
173
|
+
"""Initialize the analyzer with pattern detectors."""
|
|
174
|
+
# Each tuple: (detector, converter, pattern_type)
|
|
175
|
+
self._detector_configs: list[
|
|
176
|
+
tuple[BaseLBYLDetector[Any], Callable[..., Violation], type]
|
|
177
|
+
] = [
|
|
178
|
+
(DictKeyDetector(), _build_dict_key, DictKeyPattern),
|
|
179
|
+
(DivisionCheckDetector(), _build_division_check, DivisionCheckPattern),
|
|
180
|
+
(FileExistsDetector(), _build_file_exists, FileExistsPattern),
|
|
181
|
+
(HasattrDetector(), _build_hasattr, HasattrPattern),
|
|
182
|
+
(IsinstanceDetector(), _build_isinstance, IsinstancePattern),
|
|
183
|
+
(LenCheckDetector(), _build_len_check, LenCheckPattern),
|
|
184
|
+
(NoneCheckDetector(), _build_none_check, NoneCheckPattern),
|
|
185
|
+
(StringValidatorDetector(), _build_string_validator, StringValidatorPattern),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def analyze(self, code: str, file_path: str, config: LBYLConfig) -> list[Violation]:
|
|
189
|
+
"""Analyze Python code for LBYL patterns."""
|
|
190
|
+
tree = _parse_python_code(code)
|
|
191
|
+
if tree is None:
|
|
192
|
+
return []
|
|
193
|
+
return self._run_enabled_detectors(tree, file_path, config)
|
|
194
|
+
|
|
195
|
+
def _run_enabled_detectors(
|
|
196
|
+
self, tree: ast.Module, file_path: str, config: LBYLConfig
|
|
197
|
+
) -> list[Violation]:
|
|
198
|
+
"""Run all enabled pattern detectors and collect violations."""
|
|
199
|
+
# Map detector types to their config flags
|
|
200
|
+
enabled_flags = {
|
|
201
|
+
DictKeyDetector: config.detect_dict_key,
|
|
202
|
+
DivisionCheckDetector: config.detect_division_check,
|
|
203
|
+
FileExistsDetector: config.detect_file_exists,
|
|
204
|
+
HasattrDetector: config.detect_hasattr,
|
|
205
|
+
IsinstanceDetector: config.detect_isinstance,
|
|
206
|
+
LenCheckDetector: config.detect_len_check,
|
|
207
|
+
NoneCheckDetector: config.detect_none_check,
|
|
208
|
+
StringValidatorDetector: config.detect_string_validation,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
violations: list[Violation] = []
|
|
212
|
+
for detector, converter, pattern_type in self._detector_configs:
|
|
213
|
+
if enabled_flags.get(type(detector), False):
|
|
214
|
+
violations.extend(_run_detector(detector, tree, file_path, converter, pattern_type))
|
|
215
|
+
return violations
|