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,75 @@
1
+ """
2
+ Purpose: Configuration dataclass for collection-pipeline linter
3
+
4
+ Scope: Define configurable options for embedded filtering pattern detection
5
+
6
+ Overview: Provides CollectionPipelineConfig for customizing linter behavior including
7
+ minimum number of continue patterns to flag, enable/disable toggle, and ignore
8
+ patterns. Integrates with the orchestrator's configuration system to allow users
9
+ to customize collection-pipeline detection via .thailint.yaml configuration files.
10
+ Follows the same configuration pattern as other thai-lint linters.
11
+
12
+ Dependencies: dataclasses, typing
13
+
14
+ Exports: CollectionPipelineConfig dataclass, DEFAULT_MIN_CONTINUES constant
15
+
16
+ Interfaces: CollectionPipelineConfig.from_dict() class method for configuration loading
17
+
18
+ Implementation: Dataclass with sensible defaults and config loading from dictionary
19
+ """
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ # Default threshold for minimum continue guards to flag
25
+ DEFAULT_MIN_CONTINUES = 1
26
+
27
+
28
+ @dataclass
29
+ class CollectionPipelineConfig:
30
+ """Configuration for collection-pipeline linter."""
31
+
32
+ enabled: bool = True
33
+ """Whether the linter is enabled."""
34
+
35
+ min_continues: int = DEFAULT_MIN_CONTINUES
36
+ """Minimum number of if/continue patterns required to flag a violation."""
37
+
38
+ ignore: list[str] = field(default_factory=list)
39
+ """File patterns to ignore."""
40
+
41
+ detect_any_all: bool = True
42
+ """Whether to detect any()/all() pattern anti-patterns."""
43
+
44
+ detect_filter_map: bool = True
45
+ """Whether to detect filter-map and takewhile pattern anti-patterns."""
46
+
47
+ use_walrus_operator: bool = True
48
+ """Whether to suggest walrus operator (:=) in filter-map suggestions (Python 3.8+)."""
49
+
50
+ def __post_init__(self) -> None:
51
+ """Validate configuration values."""
52
+ if self.min_continues < 1:
53
+ raise ValueError(f"min_continues must be at least 1, got {self.min_continues}")
54
+
55
+ @classmethod
56
+ def from_dict(
57
+ cls, config: dict[str, Any], language: str | None = None
58
+ ) -> "CollectionPipelineConfig":
59
+ """Load configuration from dictionary.
60
+
61
+ Args:
62
+ config: Dictionary containing configuration values
63
+ language: Programming language (unused, for interface compatibility)
64
+
65
+ Returns:
66
+ CollectionPipelineConfig instance with values from dictionary
67
+ """
68
+ return cls(
69
+ enabled=config.get("enabled", True),
70
+ min_continues=config.get("min_continues", DEFAULT_MIN_CONTINUES),
71
+ ignore=config.get("ignore", []),
72
+ detect_any_all=config.get("detect_any_all", True),
73
+ detect_filter_map=config.get("detect_filter_map", True),
74
+ use_walrus_operator=config.get("use_walrus_operator", True),
75
+ )
@@ -0,0 +1,94 @@
1
+ """
2
+ Purpose: Analyze continue guard patterns in for loops
3
+
4
+ Scope: Extract and validate if/continue patterns from loop bodies
5
+
6
+ Overview: Provides helper functions for analyzing continue guard patterns in for loop
7
+ bodies. Handles extraction of sequential if/continue statements, validation of
8
+ simple continue-only patterns, and detection of side effects in conditions.
9
+ Separates pattern analysis logic from main detection for better maintainability.
10
+
11
+ Dependencies: ast module for Python AST processing
12
+
13
+ Exports: extract_continue_patterns, is_continue_only, has_side_effects, has_body_after_continues
14
+
15
+ Interfaces: Functions for analyzing continue patterns in AST structures
16
+
17
+ Implementation: AST-based pattern matching for continue guard identification
18
+ """
19
+
20
+ import ast
21
+
22
+
23
+ def extract_continue_patterns(body: list[ast.stmt]) -> list[ast.If]:
24
+ """Extract leading if statements that only contain continue.
25
+
26
+ Args:
27
+ body: List of statements in for loop body
28
+
29
+ Returns:
30
+ List of ast.If nodes that are continue guards
31
+ """
32
+ continues: list[ast.If] = []
33
+ for stmt in body:
34
+ if not isinstance(stmt, ast.If):
35
+ break
36
+ if not is_continue_only(stmt):
37
+ break
38
+ continues.append(stmt)
39
+ return continues
40
+
41
+
42
+ def is_continue_only(if_node: ast.If) -> bool:
43
+ """Check if an if statement only contains continue.
44
+
45
+ Args:
46
+ if_node: AST If node to check
47
+
48
+ Returns:
49
+ True if the if statement is a simple continue guard
50
+ """
51
+ if len(if_node.body) != 1:
52
+ return False
53
+ if not isinstance(if_node.body[0], ast.Continue):
54
+ return False
55
+ if if_node.orelse:
56
+ return False
57
+ return True
58
+
59
+
60
+ def has_side_effects(continues: list[ast.If]) -> bool:
61
+ """Check if any condition has side effects.
62
+
63
+ Args:
64
+ continues: List of continue guard if statements
65
+
66
+ Returns:
67
+ True if any condition has side effects (e.g., walrus operator)
68
+ """
69
+ return any(_condition_has_side_effects(if_node.test) for if_node in continues)
70
+
71
+
72
+ def _condition_has_side_effects(node: ast.expr) -> bool:
73
+ """Check if expression has side effects.
74
+
75
+ Args:
76
+ node: AST expression node to check
77
+
78
+ Returns:
79
+ True if expression has side effects
80
+ """
81
+ return any(isinstance(child, ast.NamedExpr) for child in ast.walk(node))
82
+
83
+
84
+ def has_body_after_continues(body: list[ast.stmt], num_continues: int) -> bool:
85
+ """Check if there are statements after continue guards.
86
+
87
+ Args:
88
+ body: List of statements in for loop body
89
+ num_continues: Number of continue guards detected
90
+
91
+ Returns:
92
+ True if there are statements after the continue guards
93
+ """
94
+ return len(body) > num_continues
@@ -0,0 +1,360 @@
1
+ """
2
+ Purpose: AST-based detection of collection pipeline anti-patterns
3
+
4
+ Scope: Pattern matching for for loops with embedded filtering via if/continue
5
+
6
+ Overview: Implements the core detection logic for identifying imperative loop patterns
7
+ that use if/continue for filtering instead of collection pipelines. Uses Python's
8
+ AST module to analyze code structure and identify refactoring opportunities. Detects
9
+ patterns like 'for x in iter: if not cond: continue; action(x)' and suggests
10
+ refactoring to generator expressions or filter(). Handles edge cases like walrus
11
+ operators (side effects), else branches, and empty loop bodies.
12
+
13
+ Dependencies: ast module, continue_analyzer, suggestion_builder
14
+
15
+ Exports: PipelinePatternDetector class, PatternMatch dataclass, PatternType enum
16
+
17
+ Interfaces: PipelinePatternDetector.detect_patterns() -> list[PatternMatch]
18
+
19
+ Implementation: AST visitor pattern with delegated pattern matching and suggestion generation
20
+
21
+ Suppressions:
22
+ - invalid-name: AST NodeVisitor visit_* methods follow convention, not PEP8
23
+ """
24
+
25
+ import ast
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+
29
+ from . import any_all_analyzer, continue_analyzer, filter_map_analyzer, suggestion_builder
30
+
31
+
32
+ class PatternType(Enum):
33
+ """Type of collection pipeline anti-pattern detected."""
34
+
35
+ EMBEDDED_FILTER = "embedded-filter"
36
+ """for x: if not cond: continue; action(x) -> generator expression"""
37
+
38
+ ANY_PATTERN = "any-pattern"
39
+ """for x: if cond: return True; return False -> any()"""
40
+
41
+ ALL_PATTERN = "all-pattern"
42
+ """for x: if not cond: return False; return True -> all()"""
43
+
44
+ FILTER_MAP = "filter-map"
45
+ """result=[]; for x: y=f(x); if y: result.append(y) -> list comprehension"""
46
+
47
+ TAKEWHILE = "takewhile"
48
+ """result=[]; for x: if not cond: break; result.append(x) -> takewhile()"""
49
+
50
+
51
+ @dataclass
52
+ class PatternMatch:
53
+ """Represents a detected anti-pattern."""
54
+
55
+ line_number: int
56
+ """Line number where the for loop starts (1-indexed)."""
57
+
58
+ loop_var: str
59
+ """Name of the loop variable."""
60
+
61
+ iterable: str
62
+ """Source representation of the iterable."""
63
+
64
+ conditions: list[str]
65
+ """List of filter conditions (inverted from continue guards)."""
66
+
67
+ has_side_effects: bool
68
+ """Whether any condition has side effects."""
69
+
70
+ suggestion: str
71
+ """Refactoring suggestion as a code snippet."""
72
+
73
+ pattern_type: PatternType = field(default=PatternType.EMBEDDED_FILTER)
74
+ """Type of anti-pattern detected (default: EMBEDDED_FILTER for backward compat)."""
75
+
76
+
77
+ # Module-level pattern match factory functions (extracted from class to reduce SRP violations)
78
+
79
+
80
+ def create_any_match(match: any_all_analyzer.AnyAllMatch) -> PatternMatch:
81
+ """Create PatternMatch for any() pattern.
82
+
83
+ Args:
84
+ match: AnyAllMatch from analyzer
85
+
86
+ Returns:
87
+ PatternMatch for the any() pattern
88
+ """
89
+ loop_var = suggestion_builder.get_target_name(match.for_node.target)
90
+ iterable = ast.unparse(match.for_node.iter)
91
+ condition = ast.unparse(match.condition)
92
+ suggestion = suggestion_builder.build_any_suggestion(loop_var, iterable, condition)
93
+
94
+ return PatternMatch(
95
+ line_number=match.for_node.lineno,
96
+ loop_var=loop_var,
97
+ iterable=iterable,
98
+ conditions=[condition],
99
+ has_side_effects=False,
100
+ suggestion=suggestion,
101
+ pattern_type=PatternType.ANY_PATTERN,
102
+ )
103
+
104
+
105
+ def create_all_match(match: any_all_analyzer.AnyAllMatch) -> PatternMatch:
106
+ """Create PatternMatch for all() pattern.
107
+
108
+ Args:
109
+ match: AnyAllMatch from analyzer
110
+
111
+ Returns:
112
+ PatternMatch for the all() pattern
113
+ """
114
+ loop_var = suggestion_builder.get_target_name(match.for_node.target)
115
+ iterable = ast.unparse(match.for_node.iter)
116
+ condition = ast.unparse(match.condition)
117
+ suggestion = suggestion_builder.build_all_suggestion(loop_var, iterable, condition)
118
+
119
+ return PatternMatch(
120
+ line_number=match.for_node.lineno,
121
+ loop_var=loop_var,
122
+ iterable=iterable,
123
+ conditions=[condition],
124
+ has_side_effects=False,
125
+ suggestion=suggestion,
126
+ pattern_type=PatternType.ALL_PATTERN,
127
+ )
128
+
129
+
130
+ def create_filter_map_match(match: filter_map_analyzer.FilterMapMatch) -> PatternMatch:
131
+ """Create PatternMatch for filter-map pattern.
132
+
133
+ Args:
134
+ match: FilterMapMatch from analyzer
135
+
136
+ Returns:
137
+ PatternMatch for the filter-map pattern
138
+ """
139
+ loop_var = suggestion_builder.get_target_name(match.for_node.target)
140
+ iterable = ast.unparse(match.for_node.iter)
141
+ suggestion = suggestion_builder.build_filter_map_suggestion(
142
+ loop_var, iterable, match.transform_var, match.transform_expr
143
+ )
144
+
145
+ return PatternMatch(
146
+ line_number=match.for_node.lineno,
147
+ loop_var=loop_var,
148
+ iterable=iterable,
149
+ conditions=[match.transform_expr],
150
+ has_side_effects=False,
151
+ suggestion=suggestion,
152
+ pattern_type=PatternType.FILTER_MAP,
153
+ )
154
+
155
+
156
+ def create_takewhile_match(match: filter_map_analyzer.TakewhileMatch) -> PatternMatch:
157
+ """Create PatternMatch for takewhile pattern.
158
+
159
+ Args:
160
+ match: TakewhileMatch from analyzer
161
+
162
+ Returns:
163
+ PatternMatch for the takewhile pattern
164
+ """
165
+ loop_var = suggestion_builder.get_target_name(match.for_node.target)
166
+ iterable = ast.unparse(match.for_node.iter)
167
+ condition = ast.unparse(match.condition)
168
+ suggestion = suggestion_builder.build_takewhile_suggestion(loop_var, iterable, condition)
169
+
170
+ return PatternMatch(
171
+ line_number=match.for_node.lineno,
172
+ loop_var=loop_var,
173
+ iterable=iterable,
174
+ conditions=[condition],
175
+ has_side_effects=False,
176
+ suggestion=suggestion,
177
+ pattern_type=PatternType.TAKEWHILE,
178
+ )
179
+
180
+
181
+ def create_embedded_filter_match(for_node: ast.For, continues: list[ast.If]) -> PatternMatch:
182
+ """Create a PatternMatch for embedded filter pattern.
183
+
184
+ Args:
185
+ for_node: AST For node
186
+ continues: List of continue guard if statements
187
+
188
+ Returns:
189
+ PatternMatch object with detection information
190
+ """
191
+ loop_var = suggestion_builder.get_target_name(for_node.target)
192
+ iterable = ast.unparse(for_node.iter)
193
+ conditions = [suggestion_builder.invert_condition(c.test) for c in continues]
194
+ suggestion = suggestion_builder.build_suggestion(loop_var, iterable, conditions)
195
+
196
+ return PatternMatch(
197
+ line_number=for_node.lineno,
198
+ loop_var=loop_var,
199
+ iterable=iterable,
200
+ conditions=conditions,
201
+ has_side_effects=False,
202
+ suggestion=suggestion,
203
+ pattern_type=PatternType.EMBEDDED_FILTER,
204
+ )
205
+
206
+
207
+ def analyze_for_loop(node: ast.For) -> PatternMatch | None:
208
+ """Analyze a for loop for embedded filtering patterns.
209
+
210
+ Args:
211
+ node: AST For node to analyze
212
+
213
+ Returns:
214
+ PatternMatch if pattern detected, None otherwise
215
+ """
216
+ continues = continue_analyzer.extract_continue_patterns(node.body)
217
+ if not continues:
218
+ return None
219
+
220
+ if continue_analyzer.has_side_effects(continues):
221
+ return None
222
+
223
+ if not continue_analyzer.has_body_after_continues(node.body, len(continues)):
224
+ return None
225
+
226
+ return create_embedded_filter_match(node, continues)
227
+
228
+
229
+ class PipelinePatternDetector(ast.NodeVisitor):
230
+ """Detects for loops with embedded filtering via if/continue patterns."""
231
+
232
+ def __init__(self, source_code: str) -> None:
233
+ """Initialize detector with source code.
234
+
235
+ Args:
236
+ source_code: Python source code to analyze
237
+ """
238
+ self.source_code = source_code
239
+ self.matches: list[PatternMatch] = []
240
+ self._func_body_stack: list[list[ast.stmt]] = []
241
+
242
+ def detect_patterns(self) -> list[PatternMatch]:
243
+ """Analyze source code and return detected patterns.
244
+
245
+ Returns:
246
+ List of PatternMatch objects for each detected anti-pattern
247
+ """
248
+ try:
249
+ tree = ast.parse(self.source_code)
250
+ self.visit(tree)
251
+ except SyntaxError:
252
+ pass # Invalid Python, return empty list
253
+ return self.matches
254
+
255
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # pylint: disable=invalid-name
256
+ """Visit function and track body for any/all pattern detection.
257
+
258
+ Args:
259
+ node: AST FunctionDef node
260
+ """
261
+ self._func_body_stack.append(node.body)
262
+ self.generic_visit(node)
263
+ self._func_body_stack.pop()
264
+
265
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # pylint: disable=invalid-name
266
+ """Visit async function and track body for any/all pattern detection.
267
+
268
+ Args:
269
+ node: AST AsyncFunctionDef node
270
+ """
271
+ self._func_body_stack.append(node.body)
272
+ self.generic_visit(node)
273
+ self._func_body_stack.pop()
274
+
275
+ def visit_For(self, node: ast.For) -> None: # pylint: disable=invalid-name
276
+ """Visit for loop and check for filtering patterns.
277
+
278
+ Args:
279
+ node: AST For node to analyze
280
+ """
281
+ match = self._find_pattern_match(node)
282
+ if match is not None:
283
+ self.matches.append(match)
284
+ self.generic_visit(node)
285
+
286
+ def _find_pattern_match(self, node: ast.For) -> PatternMatch | None:
287
+ """Find the first matching anti-pattern for a for loop.
288
+
289
+ Checks patterns in priority order: any/all, filter-map/takewhile, embedded filter.
290
+
291
+ Args:
292
+ node: AST For node to analyze
293
+
294
+ Returns:
295
+ PatternMatch if any pattern detected, None otherwise
296
+ """
297
+ # Check for any/all patterns (requires function context)
298
+ any_all_match = self._analyze_any_all_pattern(node)
299
+ if any_all_match is not None:
300
+ return any_all_match
301
+
302
+ # Check for filter-map/takewhile patterns
303
+ filter_map_match = self._analyze_filter_map_pattern(node)
304
+ if filter_map_match is not None:
305
+ return filter_map_match
306
+
307
+ # Check for embedded filter patterns
308
+ return analyze_for_loop(node)
309
+
310
+ def _analyze_any_all_pattern(self, node: ast.For) -> PatternMatch | None:
311
+ """Analyze a for loop for any()/all() patterns.
312
+
313
+ Args:
314
+ node: AST For node to analyze
315
+
316
+ Returns:
317
+ PatternMatch if any/all pattern detected, None otherwise
318
+ """
319
+ if not self._func_body_stack:
320
+ return None
321
+
322
+ func_body = self._func_body_stack[-1]
323
+
324
+ # Try any() pattern first
325
+ any_match = any_all_analyzer.extract_any_pattern(func_body, node)
326
+ if any_match is not None:
327
+ return create_any_match(any_match)
328
+
329
+ # Try all() pattern
330
+ all_match = any_all_analyzer.extract_all_pattern(func_body, node)
331
+ if all_match is not None:
332
+ return create_all_match(all_match)
333
+
334
+ return None
335
+
336
+ def _analyze_filter_map_pattern(self, node: ast.For) -> PatternMatch | None:
337
+ """Analyze a for loop for filter-map/takewhile patterns.
338
+
339
+ Args:
340
+ node: AST For node to analyze
341
+
342
+ Returns:
343
+ PatternMatch if filter-map/takewhile pattern detected, None otherwise
344
+ """
345
+ if not self._func_body_stack:
346
+ return None
347
+
348
+ func_body = self._func_body_stack[-1]
349
+
350
+ # Try filter-map pattern first
351
+ fm_match = filter_map_analyzer.extract_filter_map_pattern(func_body, node)
352
+ if fm_match is not None:
353
+ return create_filter_map_match(fm_match)
354
+
355
+ # Try takewhile pattern
356
+ tw_match = filter_map_analyzer.extract_takewhile_pattern(func_body, node)
357
+ if tw_match is not None:
358
+ return create_takewhile_match(tw_match)
359
+
360
+ return None