thailint 0.12.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.
Files changed (121) hide show
  1. src/analyzers/__init__.py +4 -3
  2. src/analyzers/ast_utils.py +54 -0
  3. src/analyzers/typescript_base.py +4 -0
  4. src/cli/__init__.py +3 -0
  5. src/cli/config.py +12 -12
  6. src/cli/config_merge.py +241 -0
  7. src/cli/linters/__init__.py +3 -0
  8. src/cli/linters/code_patterns.py +113 -5
  9. src/cli/linters/code_smells.py +4 -0
  10. src/cli/linters/documentation.py +3 -0
  11. src/cli/linters/structure.py +3 -0
  12. src/cli/linters/structure_quality.py +3 -0
  13. src/cli_main.py +3 -0
  14. src/config.py +2 -1
  15. src/core/base.py +3 -2
  16. src/core/cli_utils.py +3 -1
  17. src/core/config_parser.py +5 -2
  18. src/core/constants.py +54 -0
  19. src/core/linter_utils.py +4 -0
  20. src/core/rule_discovery.py +5 -1
  21. src/core/violation_builder.py +3 -0
  22. src/linter_config/directive_markers.py +109 -0
  23. src/linter_config/ignore.py +225 -383
  24. src/linter_config/pattern_utils.py +65 -0
  25. src/linter_config/rule_matcher.py +89 -0
  26. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  27. src/linters/collection_pipeline/ast_utils.py +40 -0
  28. src/linters/collection_pipeline/config.py +12 -0
  29. src/linters/collection_pipeline/continue_analyzer.py +2 -8
  30. src/linters/collection_pipeline/detector.py +262 -32
  31. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  32. src/linters/collection_pipeline/linter.py +18 -35
  33. src/linters/collection_pipeline/suggestion_builder.py +68 -1
  34. src/linters/dry/base_token_analyzer.py +16 -9
  35. src/linters/dry/block_filter.py +7 -4
  36. src/linters/dry/cache.py +7 -2
  37. src/linters/dry/config.py +7 -1
  38. src/linters/dry/constant_matcher.py +34 -25
  39. src/linters/dry/file_analyzer.py +4 -2
  40. src/linters/dry/inline_ignore.py +7 -16
  41. src/linters/dry/linter.py +48 -25
  42. src/linters/dry/python_analyzer.py +18 -10
  43. src/linters/dry/python_constant_extractor.py +51 -52
  44. src/linters/dry/single_statement_detector.py +14 -12
  45. src/linters/dry/token_hasher.py +115 -115
  46. src/linters/dry/typescript_analyzer.py +11 -6
  47. src/linters/dry/typescript_constant_extractor.py +4 -0
  48. src/linters/dry/typescript_statement_detector.py +208 -208
  49. src/linters/dry/typescript_value_extractor.py +3 -0
  50. src/linters/dry/violation_filter.py +1 -4
  51. src/linters/dry/violation_generator.py +1 -4
  52. src/linters/file_header/atemporal_detector.py +4 -0
  53. src/linters/file_header/base_parser.py +4 -0
  54. src/linters/file_header/bash_parser.py +4 -0
  55. src/linters/file_header/field_validator.py +5 -8
  56. src/linters/file_header/linter.py +19 -12
  57. src/linters/file_header/markdown_parser.py +6 -0
  58. src/linters/file_placement/config_loader.py +3 -1
  59. src/linters/file_placement/linter.py +22 -8
  60. src/linters/file_placement/pattern_matcher.py +21 -4
  61. src/linters/file_placement/pattern_validator.py +21 -7
  62. src/linters/file_placement/rule_checker.py +2 -2
  63. src/linters/lazy_ignores/__init__.py +43 -0
  64. src/linters/lazy_ignores/config.py +66 -0
  65. src/linters/lazy_ignores/directive_utils.py +121 -0
  66. src/linters/lazy_ignores/header_parser.py +177 -0
  67. src/linters/lazy_ignores/linter.py +158 -0
  68. src/linters/lazy_ignores/matcher.py +135 -0
  69. src/linters/lazy_ignores/python_analyzer.py +201 -0
  70. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  71. src/linters/lazy_ignores/skip_detector.py +298 -0
  72. src/linters/lazy_ignores/types.py +67 -0
  73. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  74. src/linters/lazy_ignores/violation_builder.py +131 -0
  75. src/linters/lbyl/__init__.py +29 -0
  76. src/linters/lbyl/config.py +63 -0
  77. src/linters/lbyl/pattern_detectors/__init__.py +25 -0
  78. src/linters/lbyl/pattern_detectors/base.py +46 -0
  79. src/linters/magic_numbers/context_analyzer.py +227 -229
  80. src/linters/magic_numbers/linter.py +20 -15
  81. src/linters/magic_numbers/python_analyzer.py +4 -16
  82. src/linters/magic_numbers/typescript_analyzer.py +9 -16
  83. src/linters/method_property/config.py +4 -0
  84. src/linters/method_property/linter.py +5 -4
  85. src/linters/method_property/python_analyzer.py +5 -4
  86. src/linters/method_property/violation_builder.py +3 -0
  87. src/linters/nesting/typescript_analyzer.py +6 -12
  88. src/linters/nesting/typescript_function_extractor.py +0 -4
  89. src/linters/print_statements/linter.py +6 -4
  90. src/linters/print_statements/python_analyzer.py +85 -81
  91. src/linters/print_statements/typescript_analyzer.py +6 -15
  92. src/linters/srp/heuristics.py +4 -4
  93. src/linters/srp/linter.py +12 -12
  94. src/linters/srp/violation_builder.py +0 -4
  95. src/linters/stateless_class/linter.py +30 -36
  96. src/linters/stateless_class/python_analyzer.py +11 -20
  97. src/linters/stringly_typed/config.py +4 -5
  98. src/linters/stringly_typed/context_filter.py +410 -410
  99. src/linters/stringly_typed/function_call_violation_builder.py +93 -95
  100. src/linters/stringly_typed/linter.py +48 -16
  101. src/linters/stringly_typed/python/analyzer.py +5 -1
  102. src/linters/stringly_typed/python/call_tracker.py +8 -5
  103. src/linters/stringly_typed/python/comparison_tracker.py +10 -5
  104. src/linters/stringly_typed/python/condition_extractor.py +3 -0
  105. src/linters/stringly_typed/python/conditional_detector.py +4 -1
  106. src/linters/stringly_typed/python/match_analyzer.py +8 -2
  107. src/linters/stringly_typed/python/validation_detector.py +3 -0
  108. src/linters/stringly_typed/storage.py +14 -14
  109. src/linters/stringly_typed/typescript/call_tracker.py +9 -3
  110. src/linters/stringly_typed/typescript/comparison_tracker.py +9 -3
  111. src/linters/stringly_typed/violation_generator.py +288 -259
  112. src/orchestrator/core.py +13 -4
  113. src/templates/thailint_config_template.yaml +166 -0
  114. src/utils/project_root.py +3 -0
  115. thailint-0.13.0.dist-info/METADATA +184 -0
  116. thailint-0.13.0.dist-info/RECORD +189 -0
  117. thailint-0.12.0.dist-info/METADATA +0 -1667
  118. thailint-0.12.0.dist-info/RECORD +0 -164
  119. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/WHEEL +0 -0
  120. {thailint-0.12.0.dist-info → thailint-0.13.0.dist-info}/entry_points.txt +0 -0
  121. {thailint-0.12.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 == "python" and context.file_content is not None
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 10 lines for ignore-file directive
181
- lines = context.file_content.splitlines()[:10]
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
- rule_id_lower = self.rule_id.lower()
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
- violations: list[Violation] = []
288
- for match in matches:
289
- violation = self._process_match(match, config, context)
290
- if violation:
291
- violations.append(violation)
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, "ignore")
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: TokenHasher, CodeBlock, DRYConfig, pathlib.Path
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
- def __init__(self) -> None:
32
- """Initialize analyzer with token hasher."""
33
- self._hasher = TokenHasher()
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 = self._hasher.tokenize(content)
47
- windows = self._hasher.rolling_hash(lines, config.min_duplicate_lines)
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:
@@ -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
- for node in ast.walk(tree):
119
- if isinstance(node, ast.Call) and self._check_multiline_containment(node, block):
120
- return True
121
- return False
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 == "memory":
70
+ if storage_mode == StorageMode.MEMORY:
66
71
  self.db = sqlite3.connect(":memory:")
67
- elif storage_mode == "tempfile":
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.