thailint 0.11.0__py3-none-any.whl → 0.13.0__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/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +3 -0
- src/cli/config.py +12 -12
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +3 -0
- src/cli/linters/code_patterns.py +113 -5
- src/cli/linters/code_smells.py +118 -7
- src/cli/linters/documentation.py +3 -0
- src/cli/linters/structure.py +3 -0
- src/cli/linters/structure_quality.py +3 -0
- src/cli/utils.py +29 -9
- src/cli_main.py +3 -0
- src/config.py +2 -1
- src/core/base.py +3 -2
- src/core/cli_utils.py +3 -1
- src/core/config_parser.py +5 -2
- src/core/constants.py +54 -0
- src/core/linter_utils.py +4 -0
- src/core/rule_discovery.py +5 -1
- src/core/violation_builder.py +3 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +225 -383
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -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 +12 -0
- src/linters/collection_pipeline/continue_analyzer.py +2 -8
- src/linters/collection_pipeline/detector.py +262 -32
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +18 -35
- src/linters/collection_pipeline/suggestion_builder.py +68 -1
- src/linters/dry/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +7 -4
- src/linters/dry/cache.py +7 -2
- src/linters/dry/config.py +7 -1
- src/linters/dry/constant_matcher.py +34 -25
- src/linters/dry/file_analyzer.py +4 -2
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +48 -25
- src/linters/dry/python_analyzer.py +18 -10
- src/linters/dry/python_constant_extractor.py +51 -52
- src/linters/dry/single_statement_detector.py +14 -12
- src/linters/dry/token_hasher.py +115 -115
- src/linters/dry/typescript_analyzer.py +11 -6
- src/linters/dry/typescript_constant_extractor.py +4 -0
- src/linters/dry/typescript_statement_detector.py +208 -208
- src/linters/dry/typescript_value_extractor.py +3 -0
- src/linters/dry/violation_filter.py +1 -4
- src/linters/dry/violation_generator.py +1 -4
- src/linters/file_header/atemporal_detector.py +4 -0
- src/linters/file_header/base_parser.py +4 -0
- src/linters/file_header/bash_parser.py +4 -0
- src/linters/file_header/field_validator.py +5 -8
- src/linters/file_header/linter.py +19 -12
- src/linters/file_header/markdown_parser.py +6 -0
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/linter.py +22 -8
- src/linters/file_placement/pattern_matcher.py +21 -4
- src/linters/file_placement/pattern_validator.py +21 -7
- src/linters/file_placement/rule_checker.py +2 -2
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +66 -0
- src/linters/lazy_ignores/directive_utils.py +121 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +135 -0
- src/linters/lazy_ignores/python_analyzer.py +201 -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 +67 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +131 -0
- src/linters/lbyl/__init__.py +29 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/pattern_detectors/__init__.py +25 -0
- src/linters/lbyl/pattern_detectors/base.py +46 -0
- src/linters/magic_numbers/context_analyzer.py +227 -229
- src/linters/magic_numbers/linter.py +20 -15
- src/linters/magic_numbers/python_analyzer.py +4 -16
- src/linters/magic_numbers/typescript_analyzer.py +9 -16
- src/linters/method_property/config.py +4 -0
- src/linters/method_property/linter.py +5 -4
- src/linters/method_property/python_analyzer.py +5 -4
- src/linters/method_property/violation_builder.py +3 -0
- src/linters/nesting/typescript_analyzer.py +6 -12
- src/linters/nesting/typescript_function_extractor.py +0 -4
- src/linters/print_statements/linter.py +6 -4
- src/linters/print_statements/python_analyzer.py +85 -81
- src/linters/print_statements/typescript_analyzer.py +6 -15
- src/linters/srp/heuristics.py +4 -4
- src/linters/srp/linter.py +12 -12
- src/linters/srp/violation_builder.py +0 -4
- src/linters/stateless_class/linter.py +30 -36
- src/linters/stateless_class/python_analyzer.py +11 -20
- src/linters/stringly_typed/__init__.py +22 -9
- src/linters/stringly_typed/config.py +32 -8
- 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 +102 -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 +9 -5
- src/linters/stringly_typed/python/analyzer.py +159 -9
- 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 +3 -0
- src/linters/stringly_typed/python/conditional_detector.py +4 -1
- src/linters/stringly_typed/python/match_analyzer.py +8 -2
- src/linters/stringly_typed/python/validation_detector.py +3 -0
- src/linters/stringly_typed/storage.py +630 -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 +405 -0
- src/orchestrator/core.py +13 -4
- src/templates/thailint_config_template.yaml +166 -0
- src/utils/project_root.py +3 -0
- thailint-0.13.0.dist-info/METADATA +184 -0
- thailint-0.13.0.dist-info/RECORD +189 -0
- thailint-0.11.0.dist-info/METADATA +0 -1661
- thailint-0.11.0.dist-info/RECORD +0 -150
- {thailint-0.11.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
- {thailint-0.11.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.11.0.dist-info → thailint-0.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Analyze filter-map and takewhile anti-patterns in for loops
|
|
3
|
+
|
|
4
|
+
Scope: Extract and validate loops that build lists with transform/filter or takewhile
|
|
5
|
+
|
|
6
|
+
Overview: Provides helper functions for analyzing loops that initialize an empty list,
|
|
7
|
+
iterate with a transform and conditional append, then return the list. Detects
|
|
8
|
+
patterns like 'result=[]; for x: y=f(x); if y: result.append(y); return result'
|
|
9
|
+
which can be refactored to list comprehensions with walrus operator. Also handles
|
|
10
|
+
takewhile patterns with break statements.
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module for Python AST processing
|
|
13
|
+
|
|
14
|
+
Exports: extract_filter_map_pattern, extract_takewhile_pattern, FilterMapMatch, TakewhileMatch
|
|
15
|
+
|
|
16
|
+
Interfaces: Functions for analyzing filter-map/takewhile patterns in AST function bodies
|
|
17
|
+
|
|
18
|
+
Implementation: AST-based pattern matching for filter-map/takewhile pattern identification
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from . import ast_utils
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FilterMapMatch:
|
|
29
|
+
"""Information about a detected filter-map pattern."""
|
|
30
|
+
|
|
31
|
+
for_node: ast.For
|
|
32
|
+
"""The for loop AST node."""
|
|
33
|
+
|
|
34
|
+
result_var: str
|
|
35
|
+
"""Name of the result list variable."""
|
|
36
|
+
|
|
37
|
+
transform_var: str
|
|
38
|
+
"""Name of the variable holding transform result."""
|
|
39
|
+
|
|
40
|
+
transform_expr: str
|
|
41
|
+
"""The transform expression as string."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TakewhileMatch:
|
|
46
|
+
"""Information about a detected takewhile pattern."""
|
|
47
|
+
|
|
48
|
+
for_node: ast.For
|
|
49
|
+
"""The for loop AST node."""
|
|
50
|
+
|
|
51
|
+
result_var: str
|
|
52
|
+
"""Name of the result list variable."""
|
|
53
|
+
|
|
54
|
+
condition: ast.expr
|
|
55
|
+
"""The condition expression (inverted from break condition)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_filter_map_pattern(
|
|
59
|
+
func_body: list[ast.stmt], for_node: ast.For
|
|
60
|
+
) -> FilterMapMatch | None:
|
|
61
|
+
"""Extract filter-map pattern from a for loop in a function body.
|
|
62
|
+
|
|
63
|
+
Pattern: result=[]; for x: y=f(x); if y: result.append(y); return result
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
func_body: List of statements in the function body
|
|
67
|
+
for_node: The for loop AST node to analyze
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
FilterMapMatch if pattern detected, None otherwise
|
|
71
|
+
"""
|
|
72
|
+
# Find the for loop position
|
|
73
|
+
for_index = _get_stmt_index(func_body, for_node)
|
|
74
|
+
if for_index is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Find result list initialization before the loop
|
|
78
|
+
result_init = _find_result_init_before(func_body, for_index)
|
|
79
|
+
if result_init is None:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
result_var, _ = result_init
|
|
83
|
+
|
|
84
|
+
# Check loop body has: assign, if, append pattern
|
|
85
|
+
loop_pattern = _extract_assign_if_append(for_node.body, result_var)
|
|
86
|
+
if loop_pattern is None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
transform_var, transform_expr = loop_pattern
|
|
90
|
+
|
|
91
|
+
# Check return result after loop
|
|
92
|
+
if not _is_return_var_after(func_body, for_index, result_var):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
return FilterMapMatch(
|
|
96
|
+
for_node=for_node,
|
|
97
|
+
result_var=result_var,
|
|
98
|
+
transform_var=transform_var,
|
|
99
|
+
transform_expr=transform_expr,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_takewhile_pattern(
|
|
104
|
+
func_body: list[ast.stmt], for_node: ast.For
|
|
105
|
+
) -> TakewhileMatch | None:
|
|
106
|
+
"""Extract takewhile pattern from a for loop in a function body.
|
|
107
|
+
|
|
108
|
+
Pattern: result=[]; for x: if not cond: break; result.append(x); return result
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
func_body: List of statements in the function body
|
|
112
|
+
for_node: The for loop AST node to analyze
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
TakewhileMatch if pattern detected, None otherwise
|
|
116
|
+
"""
|
|
117
|
+
# Find the for loop position
|
|
118
|
+
for_index = _get_stmt_index(func_body, for_node)
|
|
119
|
+
if for_index is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Find result list initialization before the loop
|
|
123
|
+
result_init = _find_result_init_before(func_body, for_index)
|
|
124
|
+
if result_init is None:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
result_var, _ = result_init
|
|
128
|
+
|
|
129
|
+
# Check loop body has: if break, append pattern
|
|
130
|
+
loop_pattern = _extract_if_break_append(for_node.body, result_var)
|
|
131
|
+
if loop_pattern is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
condition = loop_pattern
|
|
135
|
+
|
|
136
|
+
# Check return result after loop
|
|
137
|
+
if not _is_return_var_after(func_body, for_index, result_var):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
return TakewhileMatch(
|
|
141
|
+
for_node=for_node,
|
|
142
|
+
result_var=result_var,
|
|
143
|
+
condition=condition,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_stmt_index(func_body: list[ast.stmt], target: ast.stmt) -> int | None:
|
|
148
|
+
"""Find index of a statement in a function body."""
|
|
149
|
+
for i, stmt in enumerate(func_body):
|
|
150
|
+
if stmt is target:
|
|
151
|
+
return i
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_assign_empty_list_var(stmt: ast.Assign) -> str | None:
|
|
156
|
+
"""Get variable name from simple assignment to empty list."""
|
|
157
|
+
if len(stmt.targets) != 1:
|
|
158
|
+
return None
|
|
159
|
+
if not isinstance(stmt.targets[0], ast.Name):
|
|
160
|
+
return None
|
|
161
|
+
if not _is_empty_list(stmt.value):
|
|
162
|
+
return None
|
|
163
|
+
return stmt.targets[0].id
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_annassign_empty_list_var(stmt: ast.AnnAssign) -> str | None:
|
|
167
|
+
"""Get variable name from annotated assignment to empty list."""
|
|
168
|
+
if not isinstance(stmt.target, ast.Name):
|
|
169
|
+
return None
|
|
170
|
+
if stmt.value is None:
|
|
171
|
+
return None
|
|
172
|
+
if not _is_empty_list(stmt.value):
|
|
173
|
+
return None
|
|
174
|
+
return stmt.target.id
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_empty_list_var(stmt: ast.stmt) -> str | None:
|
|
178
|
+
"""Get variable name if statement is empty list initialization.
|
|
179
|
+
|
|
180
|
+
Handles both: result = [] and result: list[T] = []
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
stmt: Statement to check
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Variable name if empty list init, None otherwise
|
|
187
|
+
"""
|
|
188
|
+
if isinstance(stmt, ast.Assign):
|
|
189
|
+
return _get_assign_empty_list_var(stmt)
|
|
190
|
+
if isinstance(stmt, ast.AnnAssign):
|
|
191
|
+
return _get_annassign_empty_list_var(stmt)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _find_result_init_before(func_body: list[ast.stmt], for_index: int) -> tuple[str, int] | None:
|
|
196
|
+
"""Find empty list initialization before the for loop.
|
|
197
|
+
|
|
198
|
+
Searches backwards from for_index to find: result = [] or result: list[T] = []
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
func_body: List of statements in function body
|
|
202
|
+
for_index: Index of the for loop
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Tuple of (variable_name, init_index) if found, None otherwise
|
|
206
|
+
"""
|
|
207
|
+
for i in range(for_index - 1, -1, -1):
|
|
208
|
+
var_name = _get_empty_list_var(func_body[i])
|
|
209
|
+
if var_name is not None:
|
|
210
|
+
return (var_name, i)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_empty_list(node: ast.expr) -> bool:
|
|
215
|
+
"""Check if expression is an empty list literal."""
|
|
216
|
+
return isinstance(node, ast.List) and len(node.elts) == 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _extract_simple_assignment(stmt: ast.stmt) -> tuple[str, str] | None:
|
|
220
|
+
"""Extract variable and expression from a simple assignment.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
stmt: Statement to check
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Tuple of (var_name, expr_string) if simple assignment, None otherwise
|
|
227
|
+
"""
|
|
228
|
+
if not isinstance(stmt, ast.Assign):
|
|
229
|
+
return None
|
|
230
|
+
if len(stmt.targets) != 1:
|
|
231
|
+
return None
|
|
232
|
+
if not isinstance(stmt.targets[0], ast.Name):
|
|
233
|
+
return None
|
|
234
|
+
return (stmt.targets[0].id, ast.unparse(stmt.value))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_conditional_append(
|
|
238
|
+
if_stmt: ast.If, condition_var: str, result_var: str, appended_var: str
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""Check if statement is: if condition_var: result.append(appended_var).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
if_stmt: The if statement to check
|
|
244
|
+
condition_var: Expected condition variable name
|
|
245
|
+
result_var: Expected result list name
|
|
246
|
+
appended_var: Expected appended variable name
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
True if pattern matches
|
|
250
|
+
"""
|
|
251
|
+
if if_stmt.orelse:
|
|
252
|
+
return False
|
|
253
|
+
if not isinstance(if_stmt.test, ast.Name):
|
|
254
|
+
return False
|
|
255
|
+
if if_stmt.test.id != condition_var:
|
|
256
|
+
return False
|
|
257
|
+
if len(if_stmt.body) != 1:
|
|
258
|
+
return False
|
|
259
|
+
return _is_append_call(if_stmt.body[0], result_var, appended_var)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _extract_assign_if_append(body: list[ast.stmt], result_var: str) -> tuple[str, str] | None:
|
|
263
|
+
"""Extract assign, if, append pattern from loop body.
|
|
264
|
+
|
|
265
|
+
Pattern: y = f(x); if y: result.append(y)
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
body: List of statements in for loop body
|
|
269
|
+
result_var: Name of the result list variable
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Tuple of (transform_var, transform_expr) if pattern matches, None otherwise
|
|
273
|
+
"""
|
|
274
|
+
if len(body) != 2:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
# First statement should be assignment
|
|
278
|
+
assignment = _extract_simple_assignment(body[0])
|
|
279
|
+
if assignment is None:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
transform_var, transform_expr = assignment
|
|
283
|
+
|
|
284
|
+
# Second statement should be: if transform_var: result.append(transform_var)
|
|
285
|
+
if not isinstance(body[1], ast.If):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
if not _is_conditional_append(body[1], transform_var, result_var, transform_var):
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
return (transform_var, transform_expr)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _is_simple_if_break(stmt: ast.stmt) -> ast.If | None:
|
|
295
|
+
"""Check if statement is simple 'if cond: break' with no else.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
stmt: Statement to check
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
The if statement if pattern matches, None otherwise
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(stmt, ast.If):
|
|
304
|
+
return None
|
|
305
|
+
if stmt.orelse:
|
|
306
|
+
return None
|
|
307
|
+
if len(stmt.body) != 1:
|
|
308
|
+
return None
|
|
309
|
+
if not isinstance(stmt.body[0], ast.Break):
|
|
310
|
+
return None
|
|
311
|
+
return stmt
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _extract_if_break_append(body: list[ast.stmt], result_var: str) -> ast.expr | None:
|
|
315
|
+
"""Extract if break, append pattern from loop body.
|
|
316
|
+
|
|
317
|
+
Pattern: if not cond: break; result.append(x)
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
body: List of statements in for loop body
|
|
321
|
+
result_var: Name of the result list variable
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
The condition (inverted for takewhile) if pattern matches, None otherwise
|
|
325
|
+
"""
|
|
326
|
+
if len(body) != 2:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
if_stmt = _is_simple_if_break(body[0])
|
|
330
|
+
if if_stmt is None:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
if not isinstance(body[1], ast.Expr):
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
return _invert_condition(if_stmt.test)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _get_method_call_info(stmt: ast.stmt) -> tuple[str, str, list[ast.expr]] | None:
|
|
340
|
+
"""Extract method call info from statement: obj.method(args).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
stmt: Statement to check
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Tuple of (object_name, method_name, args) if method call, None otherwise
|
|
347
|
+
"""
|
|
348
|
+
if not isinstance(stmt, ast.Expr):
|
|
349
|
+
return None
|
|
350
|
+
if not isinstance(stmt.value, ast.Call):
|
|
351
|
+
return None
|
|
352
|
+
call = stmt.value
|
|
353
|
+
if not isinstance(call.func, ast.Attribute):
|
|
354
|
+
return None
|
|
355
|
+
if not isinstance(call.func.value, ast.Name):
|
|
356
|
+
return None
|
|
357
|
+
return (call.func.value.id, call.func.attr, call.args)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _is_single_name_arg(args: list[ast.expr], expected_name: str) -> bool:
|
|
361
|
+
"""Check if args list is single Name with expected id."""
|
|
362
|
+
if len(args) != 1:
|
|
363
|
+
return False
|
|
364
|
+
if not isinstance(args[0], ast.Name):
|
|
365
|
+
return False
|
|
366
|
+
return args[0].id == expected_name
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _is_append_call(stmt: ast.stmt, result_var: str, appended_var: str) -> bool:
|
|
370
|
+
"""Check if statement is result.append(appended_var)."""
|
|
371
|
+
info = _get_method_call_info(stmt)
|
|
372
|
+
if info is None:
|
|
373
|
+
return False
|
|
374
|
+
obj_name, method_name, args = info
|
|
375
|
+
if obj_name != result_var or method_name != "append":
|
|
376
|
+
return False
|
|
377
|
+
return _is_single_name_arg(args, appended_var)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _is_return_var_after(func_body: list[ast.stmt], for_index: int, var_name: str) -> bool:
|
|
381
|
+
"""Check if the next statement after for loop is return var_name."""
|
|
382
|
+
stmt = ast_utils.get_next_return_stmt(func_body, for_index)
|
|
383
|
+
if stmt is None:
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
if stmt.value is None:
|
|
387
|
+
return False
|
|
388
|
+
if not isinstance(stmt.value, ast.Name):
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
return stmt.value.id == var_name
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _invert_condition(node: ast.expr) -> ast.expr:
|
|
395
|
+
"""Invert a boolean condition.
|
|
396
|
+
|
|
397
|
+
If condition is 'not x', returns 'x'.
|
|
398
|
+
Otherwise wraps in 'not (...)'.
|
|
399
|
+
"""
|
|
400
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
|
401
|
+
return node.operand
|
|
402
|
+
return ast.UnaryOp(op=ast.Not(), operand=node)
|
|
@@ -21,13 +21,19 @@ Interfaces: CollectionPipelineRule.check(context) -> list[Violation], rule metad
|
|
|
21
21
|
|
|
22
22
|
Implementation: Uses PipelinePatternDetector for AST analysis, composition pattern with
|
|
23
23
|
config loading and comprehensive ignore checking via IgnoreDirectiveParser
|
|
24
|
+
|
|
25
|
+
Suppressions:
|
|
26
|
+
- srp,dry: Rule class coordinates detector, config, and comprehensive ignore system.
|
|
27
|
+
Method count exceeds limit due to 5-level ignore pattern support.
|
|
24
28
|
"""
|
|
25
29
|
|
|
26
30
|
from pathlib import Path
|
|
27
31
|
|
|
28
32
|
from src.core.base import BaseLintContext, BaseLintRule
|
|
33
|
+
from src.core.constants import HEADER_SCAN_LINES, IgnoreDirective, Language
|
|
29
34
|
from src.core.types import Severity, Violation
|
|
30
35
|
from src.linter_config.ignore import get_ignore_parser
|
|
36
|
+
from src.linter_config.rule_matcher import rule_matches
|
|
31
37
|
|
|
32
38
|
from .config import CollectionPipelineConfig
|
|
33
39
|
from .detector import PatternMatch, PipelinePatternDetector
|
|
@@ -91,7 +97,7 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
91
97
|
Returns:
|
|
92
98
|
True if should analyze
|
|
93
99
|
"""
|
|
94
|
-
return context.language ==
|
|
100
|
+
return context.language == Language.PYTHON and context.file_content is not None
|
|
95
101
|
|
|
96
102
|
def _get_config_dict(self, context: BaseLintContext) -> dict | None:
|
|
97
103
|
"""Get configuration dictionary from context.
|
|
@@ -144,10 +150,7 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
144
150
|
return False
|
|
145
151
|
|
|
146
152
|
file_path = Path(context.file_path)
|
|
147
|
-
for pattern in config.ignore
|
|
148
|
-
if self._matches_pattern(file_path, pattern):
|
|
149
|
-
return True
|
|
150
|
-
return False
|
|
153
|
+
return any(self._matches_pattern(file_path, pattern) for pattern in config.ignore)
|
|
151
154
|
|
|
152
155
|
def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
|
|
153
156
|
"""Check if file path matches a glob pattern.
|
|
@@ -177,12 +180,9 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
177
180
|
if not context.file_content:
|
|
178
181
|
return False
|
|
179
182
|
|
|
180
|
-
# Check first
|
|
181
|
-
lines = context.file_content.splitlines()[:
|
|
182
|
-
for line in lines
|
|
183
|
-
if self._is_file_ignore_directive(line):
|
|
184
|
-
return True
|
|
185
|
-
return False
|
|
183
|
+
# Check first lines for ignore-file directive
|
|
184
|
+
lines = context.file_content.splitlines()[:HEADER_SCAN_LINES]
|
|
185
|
+
return any(self._is_file_ignore_directive(line) for line in lines)
|
|
186
186
|
|
|
187
187
|
def _is_file_ignore_directive(self, line: str) -> bool:
|
|
188
188
|
"""Check if line is a file-level ignore directive.
|
|
@@ -233,23 +233,7 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
233
233
|
Returns:
|
|
234
234
|
True if pattern matches this rule
|
|
235
235
|
"""
|
|
236
|
-
|
|
237
|
-
pattern_lower = rule_pattern.lower()
|
|
238
|
-
|
|
239
|
-
# Exact match
|
|
240
|
-
if rule_id_lower == pattern_lower:
|
|
241
|
-
return True
|
|
242
|
-
|
|
243
|
-
# Prefix match: collection-pipeline matches collection-pipeline.embedded-filter
|
|
244
|
-
if rule_id_lower.startswith(pattern_lower + "."):
|
|
245
|
-
return True
|
|
246
|
-
|
|
247
|
-
# Wildcard match: collection-pipeline.* matches collection-pipeline.embedded-filter
|
|
248
|
-
if pattern_lower.endswith("*"):
|
|
249
|
-
prefix = pattern_lower[:-1]
|
|
250
|
-
return rule_id_lower.startswith(prefix)
|
|
251
|
-
|
|
252
|
-
return False
|
|
236
|
+
return rule_matches(self.rule_id, rule_pattern)
|
|
253
237
|
|
|
254
238
|
def _analyze_python(
|
|
255
239
|
self, context: BaseLintContext, config: CollectionPipelineConfig
|
|
@@ -284,12 +268,11 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
284
268
|
Returns:
|
|
285
269
|
List of violations after filtering
|
|
286
270
|
"""
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if violation
|
|
291
|
-
|
|
292
|
-
return violations
|
|
271
|
+
return [
|
|
272
|
+
violation
|
|
273
|
+
for match in matches
|
|
274
|
+
if (violation := self._process_match(match, config, context))
|
|
275
|
+
]
|
|
293
276
|
|
|
294
277
|
def _process_match(
|
|
295
278
|
self,
|
|
@@ -391,7 +374,7 @@ class CollectionPipelineRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
|
391
374
|
return True
|
|
392
375
|
|
|
393
376
|
# Rule-specific ignore
|
|
394
|
-
return self._matches_rule_ignore(line,
|
|
377
|
+
return self._matches_rule_ignore(line, IgnoreDirective.IGNORE)
|
|
395
378
|
|
|
396
379
|
def _create_violation(self, match: PatternMatch, context: BaseLintContext) -> Violation:
|
|
397
380
|
"""Create a Violation from a PatternMatch.
|
|
@@ -10,7 +10,8 @@ Overview: Provides helper functions for generating refactoring suggestions when
|
|
|
10
10
|
|
|
11
11
|
Dependencies: ast module for Python AST processing
|
|
12
12
|
|
|
13
|
-
Exports: build_suggestion, invert_condition, get_target_name
|
|
13
|
+
Exports: build_suggestion, invert_condition, get_target_name, build_any_suggestion,
|
|
14
|
+
build_all_suggestion, build_filter_map_suggestion, build_takewhile_suggestion
|
|
14
15
|
|
|
15
16
|
Interfaces: Functions for suggestion generation and condition transformation
|
|
16
17
|
|
|
@@ -61,3 +62,69 @@ def build_suggestion(loop_var: str, iterable: str, conditions: list[str]) -> str
|
|
|
61
62
|
"""
|
|
62
63
|
combined = " and ".join(conditions)
|
|
63
64
|
return f"for {loop_var} in ({loop_var} for {loop_var} in {iterable} if {combined}):"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_any_suggestion(loop_var: str, iterable: str, condition: str) -> str:
|
|
68
|
+
"""Generate any() refactoring suggestion.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
loop_var: Name of the loop variable
|
|
72
|
+
iterable: Source representation of the iterable
|
|
73
|
+
condition: The filter condition
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Code suggestion for refactoring to any()
|
|
77
|
+
"""
|
|
78
|
+
return f"return any({condition} for {loop_var} in {iterable})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_all_suggestion(loop_var: str, iterable: str, condition: str) -> str:
|
|
82
|
+
"""Generate all() refactoring suggestion.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
loop_var: Name of the loop variable
|
|
86
|
+
iterable: Source representation of the iterable
|
|
87
|
+
condition: The filter condition (already inverted to positive form)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Code suggestion for refactoring to all()
|
|
91
|
+
"""
|
|
92
|
+
return f"return all({condition} for {loop_var} in {iterable})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_filter_map_suggestion(
|
|
96
|
+
loop_var: str,
|
|
97
|
+
iterable: str,
|
|
98
|
+
transform_var: str,
|
|
99
|
+
transform_expr: str,
|
|
100
|
+
use_walrus: bool = True,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Generate filter-map list comprehension suggestion.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
loop_var: Name of the loop variable
|
|
106
|
+
iterable: Source representation of the iterable
|
|
107
|
+
transform_var: Name of the transform result variable
|
|
108
|
+
transform_expr: The transform expression
|
|
109
|
+
use_walrus: Whether to use walrus operator (Python 3.8+)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Code suggestion for refactoring to list comprehension
|
|
113
|
+
"""
|
|
114
|
+
if use_walrus:
|
|
115
|
+
return f"return [{transform_var} for {loop_var} in {iterable} if ({transform_var} := {transform_expr})]"
|
|
116
|
+
return f"return [{transform_expr} for {loop_var} in {iterable} if {transform_expr}]"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_takewhile_suggestion(loop_var: str, iterable: str, condition: str) -> str:
|
|
120
|
+
"""Generate takewhile() refactoring suggestion.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
loop_var: Name of the loop variable
|
|
124
|
+
iterable: Source representation of the iterable
|
|
125
|
+
condition: The condition for takewhile (positive form)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Code suggestion for refactoring to takewhile()
|
|
129
|
+
"""
|
|
130
|
+
return f"return list(takewhile(lambda {loop_var}: {condition}, {iterable}))"
|
|
@@ -9,28 +9,35 @@ Overview: Provides shared infrastructure for token-based duplicate code detectio
|
|
|
9
9
|
for TypeScript). Eliminates duplication between PythonDuplicateAnalyzer and TypeScriptDuplicateAnalyzer
|
|
10
10
|
by extracting shared analyze() method pattern and CodeBlock creation logic.
|
|
11
11
|
|
|
12
|
-
Dependencies:
|
|
12
|
+
Dependencies: token_hasher module functions, CodeBlock, DRYConfig, pathlib.Path
|
|
13
13
|
|
|
14
14
|
Exports: BaseTokenAnalyzer class
|
|
15
15
|
|
|
16
16
|
Interfaces: BaseTokenAnalyzer.analyze(file_path: Path, content: str, config: DRYConfig) -> list[CodeBlock]
|
|
17
17
|
|
|
18
18
|
Implementation: Template method pattern with extension point for language-specific block filtering
|
|
19
|
+
|
|
20
|
+
Suppressions:
|
|
21
|
+
- stateless-class: BaseTokenAnalyzer is an intentional template method base class.
|
|
22
|
+
Subclasses (PythonDuplicateAnalyzer, TypeScriptDuplicateAnalyzer) override
|
|
23
|
+
_should_include_block for language-specific filtering. Statelessness is by design
|
|
24
|
+
since state was moved to module-level functions in token_hasher.
|
|
19
25
|
"""
|
|
20
26
|
|
|
21
27
|
from pathlib import Path
|
|
22
28
|
|
|
29
|
+
from . import token_hasher
|
|
23
30
|
from .cache import CodeBlock
|
|
24
31
|
from .config import DRYConfig
|
|
25
|
-
from .token_hasher import TokenHasher
|
|
26
32
|
|
|
27
33
|
|
|
28
|
-
class BaseTokenAnalyzer:
|
|
29
|
-
"""Base analyzer for token-based duplicate detection.
|
|
34
|
+
class BaseTokenAnalyzer: # thailint: ignore[stateless-class] - Template method base class for inheritance
|
|
35
|
+
"""Base analyzer for token-based duplicate detection.
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
This is intentionally a base class for polymorphism. Subclasses
|
|
38
|
+
(PythonDuplicateAnalyzer, TypeScriptDuplicateAnalyzer) override
|
|
39
|
+
_should_include_block for language-specific filtering.
|
|
40
|
+
"""
|
|
34
41
|
|
|
35
42
|
def analyze(self, file_path: Path, content: str, config: DRYConfig) -> list[CodeBlock]:
|
|
36
43
|
"""Analyze file for duplicate code blocks.
|
|
@@ -43,8 +50,8 @@ class BaseTokenAnalyzer:
|
|
|
43
50
|
Returns:
|
|
44
51
|
List of CodeBlock instances with hash values
|
|
45
52
|
"""
|
|
46
|
-
lines =
|
|
47
|
-
windows =
|
|
53
|
+
lines = token_hasher.tokenize(content)
|
|
54
|
+
windows = token_hasher.rolling_hash(lines, config.min_duplicate_lines)
|
|
48
55
|
|
|
49
56
|
blocks = []
|
|
50
57
|
for hash_val, start_line, end_line, snippet in windows:
|
src/linters/dry/block_filter.py
CHANGED
|
@@ -16,6 +16,9 @@ Exports: BaseBlockFilter, BlockFilterRegistry, KeywordArgumentFilter, ImportGrou
|
|
|
16
16
|
Interfaces: BaseBlockFilter.should_filter(code_block, file_content) -> bool
|
|
17
17
|
|
|
18
18
|
Implementation: Strategy pattern with filter registry for extensibility
|
|
19
|
+
|
|
20
|
+
Suppressions:
|
|
21
|
+
- type:ignore[operator]: Tree-sitter Node comparison operations (optional dependency)
|
|
19
22
|
"""
|
|
20
23
|
|
|
21
24
|
import ast
|
|
@@ -115,10 +118,10 @@ class KeywordArgumentFilter(BaseBlockFilter):
|
|
|
115
118
|
return False
|
|
116
119
|
|
|
117
120
|
# Find if any Call node contains the block
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
return any(
|
|
122
|
+
isinstance(node, ast.Call) and self._check_multiline_containment(node, block)
|
|
123
|
+
for node in ast.walk(tree)
|
|
124
|
+
)
|
|
122
125
|
|
|
123
126
|
@staticmethod
|
|
124
127
|
def _check_multiline_containment(node: ast.Call, block: CodeBlock) -> bool:
|
src/linters/dry/cache.py
CHANGED
|
@@ -20,6 +20,9 @@ Interfaces: DRYCache.__init__(storage_mode), add_blocks(file_path, blocks),
|
|
|
20
20
|
|
|
21
21
|
Implementation: SQLite with three tables (files, code_blocks, constants), indexed for performance,
|
|
22
22
|
storage_mode determines :memory: vs tempfile location, ACID transactions for reliability
|
|
23
|
+
|
|
24
|
+
Suppressions:
|
|
25
|
+
- consider-using-with: Tempfile managed by class lifecycle, not context manager
|
|
23
26
|
"""
|
|
24
27
|
|
|
25
28
|
from __future__ import annotations
|
|
@@ -30,6 +33,8 @@ from dataclasses import dataclass
|
|
|
30
33
|
from pathlib import Path
|
|
31
34
|
from typing import TYPE_CHECKING
|
|
32
35
|
|
|
36
|
+
from src.core.constants import StorageMode
|
|
37
|
+
|
|
33
38
|
from .cache_query import CacheQueryService
|
|
34
39
|
|
|
35
40
|
if TYPE_CHECKING:
|
|
@@ -62,9 +67,9 @@ class DRYCache:
|
|
|
62
67
|
self._tempfile = None
|
|
63
68
|
|
|
64
69
|
# Create SQLite connection based on storage mode
|
|
65
|
-
if storage_mode ==
|
|
70
|
+
if storage_mode == StorageMode.MEMORY:
|
|
66
71
|
self.db = sqlite3.connect(":memory:")
|
|
67
|
-
elif storage_mode ==
|
|
72
|
+
elif storage_mode == StorageMode.TEMPFILE:
|
|
68
73
|
# Create temporary file that auto-deletes on close
|
|
69
74
|
# pylint: disable=consider-using-with
|
|
70
75
|
# Justification: tempfile must remain open for SQLite connection lifetime.
|