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.
Files changed (56) hide show
  1. src/analyzers/rust_base.py +155 -0
  2. src/analyzers/rust_context.py +141 -0
  3. src/cli/config.py +6 -4
  4. src/cli/linters/code_patterns.py +64 -16
  5. src/cli/linters/code_smells.py +23 -14
  6. src/cli/linters/documentation.py +5 -3
  7. src/cli/linters/performance.py +23 -10
  8. src/cli/linters/shared.py +22 -6
  9. src/cli/linters/structure.py +13 -4
  10. src/cli/linters/structure_quality.py +9 -4
  11. src/cli/utils.py +4 -4
  12. src/config.py +34 -21
  13. src/core/python_lint_rule.py +101 -0
  14. src/linter_config/ignore.py +2 -1
  15. src/linters/cqs/__init__.py +54 -0
  16. src/linters/cqs/config.py +55 -0
  17. src/linters/cqs/function_analyzer.py +201 -0
  18. src/linters/cqs/input_detector.py +139 -0
  19. src/linters/cqs/linter.py +159 -0
  20. src/linters/cqs/output_detector.py +84 -0
  21. src/linters/cqs/python_analyzer.py +54 -0
  22. src/linters/cqs/types.py +82 -0
  23. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  24. src/linters/cqs/typescript_function_analyzer.py +192 -0
  25. src/linters/cqs/typescript_input_detector.py +203 -0
  26. src/linters/cqs/typescript_output_detector.py +117 -0
  27. src/linters/cqs/violation_builder.py +94 -0
  28. src/linters/dry/typescript_value_extractor.py +2 -1
  29. src/linters/file_header/linter.py +2 -1
  30. src/linters/file_placement/linter.py +6 -6
  31. src/linters/file_placement/pattern_validator.py +6 -5
  32. src/linters/file_placement/rule_checker.py +10 -5
  33. src/linters/lazy_ignores/config.py +5 -3
  34. src/linters/lazy_ignores/python_analyzer.py +5 -1
  35. src/linters/lazy_ignores/types.py +2 -1
  36. src/linters/lbyl/__init__.py +3 -1
  37. src/linters/lbyl/linter.py +67 -0
  38. src/linters/lbyl/pattern_detectors/__init__.py +30 -2
  39. src/linters/lbyl/pattern_detectors/base.py +24 -7
  40. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  41. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  42. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  43. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  44. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  45. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  46. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  47. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  48. src/linters/lbyl/python_analyzer.py +215 -0
  49. src/linters/lbyl/violation_builder.py +354 -0
  50. src/linters/stringly_typed/ignore_checker.py +4 -6
  51. src/orchestrator/language_detector.py +5 -3
  52. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
  53. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
  54. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
  55. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
  56. {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