thailint 0.5.0__py3-none-any.whl → 0.15.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. src/__init__.py +1 -0
  2. src/analyzers/__init__.py +4 -3
  3. src/analyzers/ast_utils.py +54 -0
  4. src/analyzers/rust_base.py +155 -0
  5. src/analyzers/rust_context.py +141 -0
  6. src/analyzers/typescript_base.py +4 -0
  7. src/cli/__init__.py +30 -0
  8. src/cli/__main__.py +22 -0
  9. src/cli/config.py +480 -0
  10. src/cli/config_merge.py +241 -0
  11. src/cli/linters/__init__.py +67 -0
  12. src/cli/linters/code_patterns.py +270 -0
  13. src/cli/linters/code_smells.py +342 -0
  14. src/cli/linters/documentation.py +83 -0
  15. src/cli/linters/performance.py +287 -0
  16. src/cli/linters/shared.py +331 -0
  17. src/cli/linters/structure.py +327 -0
  18. src/cli/linters/structure_quality.py +328 -0
  19. src/cli/main.py +120 -0
  20. src/cli/utils.py +395 -0
  21. src/cli_main.py +37 -0
  22. src/config.py +38 -25
  23. src/core/base.py +7 -2
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +5 -2
  26. src/core/constants.py +54 -0
  27. src/core/linter_utils.py +95 -6
  28. src/core/python_lint_rule.py +101 -0
  29. src/core/registry.py +1 -1
  30. src/core/rule_discovery.py +147 -84
  31. src/core/types.py +13 -0
  32. src/core/violation_builder.py +78 -15
  33. src/core/violation_utils.py +69 -0
  34. src/formatters/__init__.py +22 -0
  35. src/formatters/sarif.py +202 -0
  36. src/linter_config/directive_markers.py +109 -0
  37. src/linter_config/ignore.py +254 -395
  38. src/linter_config/loader.py +45 -12
  39. src/linter_config/pattern_utils.py +65 -0
  40. src/linter_config/rule_matcher.py +89 -0
  41. src/linters/collection_pipeline/__init__.py +90 -0
  42. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  43. src/linters/collection_pipeline/ast_utils.py +40 -0
  44. src/linters/collection_pipeline/config.py +75 -0
  45. src/linters/collection_pipeline/continue_analyzer.py +94 -0
  46. src/linters/collection_pipeline/detector.py +360 -0
  47. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  48. src/linters/collection_pipeline/linter.py +420 -0
  49. src/linters/collection_pipeline/suggestion_builder.py +130 -0
  50. src/linters/cqs/__init__.py +54 -0
  51. src/linters/cqs/config.py +55 -0
  52. src/linters/cqs/function_analyzer.py +201 -0
  53. src/linters/cqs/input_detector.py +139 -0
  54. src/linters/cqs/linter.py +159 -0
  55. src/linters/cqs/output_detector.py +84 -0
  56. src/linters/cqs/python_analyzer.py +54 -0
  57. src/linters/cqs/types.py +82 -0
  58. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  59. src/linters/cqs/typescript_function_analyzer.py +192 -0
  60. src/linters/cqs/typescript_input_detector.py +203 -0
  61. src/linters/cqs/typescript_output_detector.py +117 -0
  62. src/linters/cqs/violation_builder.py +94 -0
  63. src/linters/dry/base_token_analyzer.py +16 -9
  64. src/linters/dry/block_filter.py +120 -20
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +104 -10
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +54 -11
  69. src/linters/dry/constant.py +92 -0
  70. src/linters/dry/constant_matcher.py +223 -0
  71. src/linters/dry/constant_violation_builder.py +98 -0
  72. src/linters/dry/duplicate_storage.py +5 -4
  73. src/linters/dry/file_analyzer.py +4 -2
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +183 -48
  76. src/linters/dry/python_analyzer.py +60 -439
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/token_hasher.py +116 -112
  80. src/linters/dry/typescript_analyzer.py +68 -382
  81. src/linters/dry/typescript_constant_extractor.py +138 -0
  82. src/linters/dry/typescript_statement_detector.py +255 -0
  83. src/linters/dry/typescript_value_extractor.py +70 -0
  84. src/linters/dry/violation_builder.py +4 -0
  85. src/linters/dry/violation_filter.py +5 -4
  86. src/linters/dry/violation_generator.py +71 -14
  87. src/linters/file_header/atemporal_detector.py +68 -50
  88. src/linters/file_header/base_parser.py +93 -0
  89. src/linters/file_header/bash_parser.py +66 -0
  90. src/linters/file_header/config.py +90 -16
  91. src/linters/file_header/css_parser.py +70 -0
  92. src/linters/file_header/field_validator.py +36 -33
  93. src/linters/file_header/linter.py +140 -144
  94. src/linters/file_header/markdown_parser.py +130 -0
  95. src/linters/file_header/python_parser.py +14 -58
  96. src/linters/file_header/typescript_parser.py +73 -0
  97. src/linters/file_header/violation_builder.py +13 -12
  98. src/linters/file_placement/config_loader.py +3 -1
  99. src/linters/file_placement/directory_matcher.py +4 -0
  100. src/linters/file_placement/linter.py +66 -34
  101. src/linters/file_placement/pattern_matcher.py +41 -6
  102. src/linters/file_placement/pattern_validator.py +31 -12
  103. src/linters/file_placement/rule_checker.py +12 -7
  104. src/linters/lazy_ignores/__init__.py +43 -0
  105. src/linters/lazy_ignores/config.py +74 -0
  106. src/linters/lazy_ignores/directive_utils.py +164 -0
  107. src/linters/lazy_ignores/header_parser.py +177 -0
  108. src/linters/lazy_ignores/linter.py +158 -0
  109. src/linters/lazy_ignores/matcher.py +168 -0
  110. src/linters/lazy_ignores/python_analyzer.py +209 -0
  111. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  112. src/linters/lazy_ignores/skip_detector.py +298 -0
  113. src/linters/lazy_ignores/types.py +71 -0
  114. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  115. src/linters/lazy_ignores/violation_builder.py +135 -0
  116. src/linters/lbyl/__init__.py +31 -0
  117. src/linters/lbyl/config.py +63 -0
  118. src/linters/lbyl/linter.py +67 -0
  119. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  120. src/linters/lbyl/pattern_detectors/base.py +63 -0
  121. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  122. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  123. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  124. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  125. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  126. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  127. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  128. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  129. src/linters/lbyl/python_analyzer.py +215 -0
  130. src/linters/lbyl/violation_builder.py +354 -0
  131. src/linters/magic_numbers/context_analyzer.py +227 -225
  132. src/linters/magic_numbers/linter.py +28 -82
  133. src/linters/magic_numbers/python_analyzer.py +4 -16
  134. src/linters/magic_numbers/typescript_analyzer.py +9 -12
  135. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  136. src/linters/method_property/__init__.py +49 -0
  137. src/linters/method_property/config.py +138 -0
  138. src/linters/method_property/linter.py +414 -0
  139. src/linters/method_property/python_analyzer.py +473 -0
  140. src/linters/method_property/violation_builder.py +119 -0
  141. src/linters/nesting/linter.py +24 -16
  142. src/linters/nesting/python_analyzer.py +4 -0
  143. src/linters/nesting/typescript_analyzer.py +6 -12
  144. src/linters/nesting/violation_builder.py +1 -0
  145. src/linters/performance/__init__.py +91 -0
  146. src/linters/performance/config.py +43 -0
  147. src/linters/performance/constants.py +49 -0
  148. src/linters/performance/linter.py +149 -0
  149. src/linters/performance/python_analyzer.py +365 -0
  150. src/linters/performance/regex_analyzer.py +312 -0
  151. src/linters/performance/regex_linter.py +139 -0
  152. src/linters/performance/typescript_analyzer.py +236 -0
  153. src/linters/performance/violation_builder.py +160 -0
  154. src/linters/print_statements/config.py +7 -12
  155. src/linters/print_statements/linter.py +26 -43
  156. src/linters/print_statements/python_analyzer.py +91 -93
  157. src/linters/print_statements/typescript_analyzer.py +15 -25
  158. src/linters/print_statements/violation_builder.py +12 -14
  159. src/linters/srp/class_analyzer.py +11 -7
  160. src/linters/srp/heuristics.py +56 -22
  161. src/linters/srp/linter.py +15 -16
  162. src/linters/srp/python_analyzer.py +55 -20
  163. src/linters/srp/typescript_metrics_calculator.py +110 -50
  164. src/linters/stateless_class/__init__.py +25 -0
  165. src/linters/stateless_class/config.py +58 -0
  166. src/linters/stateless_class/linter.py +349 -0
  167. src/linters/stateless_class/python_analyzer.py +290 -0
  168. src/linters/stringly_typed/__init__.py +36 -0
  169. src/linters/stringly_typed/config.py +189 -0
  170. src/linters/stringly_typed/context_filter.py +451 -0
  171. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  172. src/linters/stringly_typed/ignore_checker.py +100 -0
  173. src/linters/stringly_typed/ignore_utils.py +51 -0
  174. src/linters/stringly_typed/linter.py +376 -0
  175. src/linters/stringly_typed/python/__init__.py +33 -0
  176. src/linters/stringly_typed/python/analyzer.py +348 -0
  177. src/linters/stringly_typed/python/call_tracker.py +175 -0
  178. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  179. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  180. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  181. src/linters/stringly_typed/python/constants.py +21 -0
  182. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  183. src/linters/stringly_typed/python/validation_detector.py +189 -0
  184. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  185. src/linters/stringly_typed/storage.py +620 -0
  186. src/linters/stringly_typed/storage_initializer.py +45 -0
  187. src/linters/stringly_typed/typescript/__init__.py +28 -0
  188. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  189. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  190. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  191. src/linters/stringly_typed/violation_generator.py +419 -0
  192. src/orchestrator/core.py +252 -14
  193. src/orchestrator/language_detector.py +5 -3
  194. src/templates/thailint_config_template.yaml +196 -0
  195. src/utils/project_root.py +3 -0
  196. thailint-0.15.3.dist-info/METADATA +187 -0
  197. thailint-0.15.3.dist-info/RECORD +226 -0
  198. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  199. src/cli.py +0 -1665
  200. thailint-0.5.0.dist-info/METADATA +0 -1286
  201. thailint-0.5.0.dist-info/RECORD +0 -96
  202. thailint-0.5.0.dist-info/entry_points.txt +0 -4
  203. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
  204. {thailint-0.5.0.dist-info → thailint-0.15.3.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)