thailint 0.9.0__py3-none-any.whl → 0.10.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/cli.py +127 -0
- src/config.py +2 -3
- src/core/rule_discovery.py +43 -10
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/config.py +63 -0
- src/linters/collection_pipeline/continue_analyzer.py +100 -0
- src/linters/collection_pipeline/detector.py +130 -0
- src/linters/collection_pipeline/linter.py +437 -0
- src/linters/collection_pipeline/suggestion_builder.py +63 -0
- src/linters/dry/block_filter.py +2 -8
- src/linters/dry/python_analyzer.py +34 -18
- src/linters/dry/typescript_analyzer.py +61 -31
- src/linters/file_header/linter.py +7 -11
- src/linters/file_placement/linter.py +28 -8
- src/linters/srp/heuristics.py +4 -3
- src/linters/srp/linter.py +2 -3
- {thailint-0.9.0.dist-info → thailint-0.10.0.dist-info}/METADATA +116 -3
- {thailint-0.9.0.dist-info → thailint-0.10.0.dist-info}/RECORD +21 -15
- {thailint-0.9.0.dist-info → thailint-0.10.0.dist-info}/WHEEL +0 -0
- {thailint-0.9.0.dist-info → thailint-0.10.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.9.0.dist-info → thailint-0.10.0.dist-info}/licenses/LICENSE +0 -0
src/cli.py
CHANGED
|
@@ -2010,5 +2010,132 @@ def _execute_stateless_class_lint( # pylint: disable=too-many-arguments,too-man
|
|
|
2010
2010
|
sys.exit(1 if stateless_class_violations else 0)
|
|
2011
2011
|
|
|
2012
2012
|
|
|
2013
|
+
# Collection Pipeline command helper functions
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def _setup_pipeline_orchestrator(
|
|
2017
|
+
path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
|
|
2018
|
+
):
|
|
2019
|
+
"""Set up orchestrator for pipeline command."""
|
|
2020
|
+
from src.orchestrator.core import Orchestrator
|
|
2021
|
+
from src.utils.project_root import get_project_root
|
|
2022
|
+
|
|
2023
|
+
# Use provided project_root or fall back to auto-detection
|
|
2024
|
+
if project_root is None:
|
|
2025
|
+
first_path = path_objs[0] if path_objs else Path.cwd()
|
|
2026
|
+
search_start = first_path if first_path.is_dir() else first_path.parent
|
|
2027
|
+
project_root = get_project_root(search_start)
|
|
2028
|
+
|
|
2029
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
2030
|
+
|
|
2031
|
+
if config_file:
|
|
2032
|
+
_load_config_file(orchestrator, config_file, verbose)
|
|
2033
|
+
|
|
2034
|
+
return orchestrator
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def _apply_pipeline_config_override(orchestrator, min_continues: int | None, verbose: bool):
|
|
2038
|
+
"""Apply min_continues override to orchestrator config."""
|
|
2039
|
+
if min_continues is None:
|
|
2040
|
+
return
|
|
2041
|
+
|
|
2042
|
+
if "collection_pipeline" not in orchestrator.config:
|
|
2043
|
+
orchestrator.config["collection_pipeline"] = {}
|
|
2044
|
+
|
|
2045
|
+
orchestrator.config["collection_pipeline"]["min_continues"] = min_continues
|
|
2046
|
+
if verbose:
|
|
2047
|
+
logger.debug(f"Overriding min_continues to {min_continues}")
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def _run_pipeline_lint(orchestrator, path_objs: list[Path], recursive: bool):
|
|
2051
|
+
"""Execute collection-pipeline lint on files or directories."""
|
|
2052
|
+
all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
|
|
2053
|
+
return [v for v in all_violations if "collection-pipeline" in v.rule_id]
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
@cli.command("pipeline")
|
|
2057
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
2058
|
+
@click.option("--config", "-c", "config_file", type=click.Path(), help="Path to config file")
|
|
2059
|
+
@format_option
|
|
2060
|
+
@click.option("--min-continues", type=int, help="Override min continue guards to flag (default: 1)")
|
|
2061
|
+
@click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
|
|
2062
|
+
@click.pass_context
|
|
2063
|
+
def pipeline( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
2064
|
+
ctx,
|
|
2065
|
+
paths: tuple[str, ...],
|
|
2066
|
+
config_file: str | None,
|
|
2067
|
+
format: str,
|
|
2068
|
+
min_continues: int | None,
|
|
2069
|
+
recursive: bool,
|
|
2070
|
+
):
|
|
2071
|
+
"""Check for collection pipeline anti-patterns in code.
|
|
2072
|
+
|
|
2073
|
+
Detects for loops with embedded if/continue filtering patterns that could
|
|
2074
|
+
be refactored to use collection pipelines (generator expressions, filter()).
|
|
2075
|
+
|
|
2076
|
+
PATHS: Files or directories to lint (defaults to current directory if none provided)
|
|
2077
|
+
|
|
2078
|
+
Examples:
|
|
2079
|
+
|
|
2080
|
+
\b
|
|
2081
|
+
# Check current directory (all Python files recursively)
|
|
2082
|
+
thai-lint pipeline
|
|
2083
|
+
|
|
2084
|
+
\b
|
|
2085
|
+
# Check specific directory
|
|
2086
|
+
thai-lint pipeline src/
|
|
2087
|
+
|
|
2088
|
+
\b
|
|
2089
|
+
# Check single file
|
|
2090
|
+
thai-lint pipeline src/app.py
|
|
2091
|
+
|
|
2092
|
+
\b
|
|
2093
|
+
# Only flag loops with 2+ continue guards
|
|
2094
|
+
thai-lint pipeline --min-continues 2 src/
|
|
2095
|
+
|
|
2096
|
+
\b
|
|
2097
|
+
# Get JSON output
|
|
2098
|
+
thai-lint pipeline --format json .
|
|
2099
|
+
|
|
2100
|
+
\b
|
|
2101
|
+
# Get SARIF output for CI/CD integration
|
|
2102
|
+
thai-lint pipeline --format sarif src/
|
|
2103
|
+
|
|
2104
|
+
\b
|
|
2105
|
+
# Use custom config file
|
|
2106
|
+
thai-lint pipeline --config .thailint.yaml src/
|
|
2107
|
+
"""
|
|
2108
|
+
verbose = ctx.obj.get("verbose", False)
|
|
2109
|
+
project_root = _get_project_root_from_context(ctx)
|
|
2110
|
+
|
|
2111
|
+
if not paths:
|
|
2112
|
+
paths = (".",)
|
|
2113
|
+
|
|
2114
|
+
path_objs = [Path(p) for p in paths]
|
|
2115
|
+
|
|
2116
|
+
try:
|
|
2117
|
+
_execute_pipeline_lint(
|
|
2118
|
+
path_objs, config_file, format, min_continues, recursive, verbose, project_root
|
|
2119
|
+
)
|
|
2120
|
+
except Exception as e:
|
|
2121
|
+
_handle_linting_error(e, verbose)
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _execute_pipeline_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
2125
|
+
path_objs, config_file, format, min_continues, recursive, verbose, project_root=None
|
|
2126
|
+
):
|
|
2127
|
+
"""Execute collection-pipeline lint."""
|
|
2128
|
+
_validate_paths_exist(path_objs)
|
|
2129
|
+
orchestrator = _setup_pipeline_orchestrator(path_objs, config_file, verbose, project_root)
|
|
2130
|
+
_apply_pipeline_config_override(orchestrator, min_continues, verbose)
|
|
2131
|
+
pipeline_violations = _run_pipeline_lint(orchestrator, path_objs, recursive)
|
|
2132
|
+
|
|
2133
|
+
if verbose:
|
|
2134
|
+
logger.info(f"Found {len(pipeline_violations)} collection-pipeline violation(s)")
|
|
2135
|
+
|
|
2136
|
+
format_violations(pipeline_violations, format)
|
|
2137
|
+
sys.exit(1 if pipeline_violations else 0)
|
|
2138
|
+
|
|
2139
|
+
|
|
2013
2140
|
if __name__ == "__main__":
|
|
2014
2141
|
cli()
|
src/config.py
CHANGED
|
@@ -103,9 +103,8 @@ def _load_from_explicit_path(config_path: Path) -> dict[str, Any]:
|
|
|
103
103
|
|
|
104
104
|
def _load_from_default_locations() -> dict[str, Any]:
|
|
105
105
|
"""Load config from default locations."""
|
|
106
|
-
for
|
|
107
|
-
|
|
108
|
-
continue
|
|
106
|
+
existing_locations = (loc for loc in CONFIG_LOCATIONS if loc.exists())
|
|
107
|
+
for location in existing_locations:
|
|
109
108
|
loaded_config = _try_load_from_location(location)
|
|
110
109
|
if loaded_config:
|
|
111
110
|
return loaded_config
|
src/core/rule_discovery.py
CHANGED
|
@@ -20,6 +20,7 @@ Implementation: Package traversal with pkgutil, class introspection with inspect
|
|
|
20
20
|
import importlib
|
|
21
21
|
import inspect
|
|
22
22
|
import pkgutil
|
|
23
|
+
from types import ModuleType
|
|
23
24
|
from typing import Any
|
|
24
25
|
|
|
25
26
|
from .base import BaseLintRule
|
|
@@ -87,19 +88,51 @@ def _discover_from_module(module_path: str) -> list[BaseLintRule]:
|
|
|
87
88
|
Returns:
|
|
88
89
|
List of discovered rule instances
|
|
89
90
|
"""
|
|
91
|
+
module = _try_import_module(module_path)
|
|
92
|
+
if module is None:
|
|
93
|
+
return []
|
|
94
|
+
return _extract_rules_from_module(module)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _try_import_module(module_path: str) -> ModuleType | None:
|
|
98
|
+
"""Try to import a module, returning None on failure.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
module_path: Full module path to import
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Module object or None if import fails
|
|
105
|
+
"""
|
|
90
106
|
try:
|
|
91
|
-
|
|
107
|
+
return importlib.import_module(module_path)
|
|
92
108
|
except (ImportError, AttributeError):
|
|
93
|
-
return
|
|
109
|
+
return None
|
|
94
110
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
|
|
112
|
+
def _extract_rules_from_module(module: ModuleType) -> list[BaseLintRule]:
|
|
113
|
+
"""Extract rule instances from a module.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
module: Imported module to scan
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of discovered rule instances
|
|
120
|
+
"""
|
|
121
|
+
rule_classes = [obj for _name, obj in inspect.getmembers(module) if _is_rule_class(obj)]
|
|
122
|
+
return _instantiate_rules(rule_classes)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _instantiate_rules(rule_classes: list[type[BaseLintRule]]) -> list[BaseLintRule]:
|
|
126
|
+
"""Instantiate a list of rule classes.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
rule_classes: List of rule classes to instantiate
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of successfully instantiated rules
|
|
133
|
+
"""
|
|
134
|
+
instances = (_try_instantiate_rule(cls) for cls in rule_classes)
|
|
135
|
+
return [inst for inst in instances if inst is not None]
|
|
103
136
|
|
|
104
137
|
|
|
105
138
|
def _try_instantiate_rule(rule_class: type[BaseLintRule]) -> BaseLintRule | None:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Collection pipeline linter package initialization
|
|
3
|
+
|
|
4
|
+
Scope: Exports for collection-pipeline linter module
|
|
5
|
+
|
|
6
|
+
Overview: Initializes the collection-pipeline linter package and exposes the main rule class
|
|
7
|
+
for external use. Exports CollectionPipelineRule as the primary interface for the linter,
|
|
8
|
+
allowing the orchestrator to discover and instantiate the rule. Also exports configuration
|
|
9
|
+
and detector classes for advanced use cases. Provides a convenience lint() function for
|
|
10
|
+
direct usage without orchestrator setup. This module serves as the entry point for
|
|
11
|
+
the collection-pipeline linter functionality within the thai-lint framework.
|
|
12
|
+
|
|
13
|
+
Dependencies: CollectionPipelineRule, CollectionPipelineConfig, PipelinePatternDetector
|
|
14
|
+
|
|
15
|
+
Exports: CollectionPipelineRule (primary), CollectionPipelineConfig, PipelinePatternDetector, lint
|
|
16
|
+
|
|
17
|
+
Interfaces: Standard Python package initialization with __all__ for explicit exports
|
|
18
|
+
|
|
19
|
+
Implementation: Simple re-export pattern for package interface, convenience lint function
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .config import DEFAULT_MIN_CONTINUES, CollectionPipelineConfig
|
|
26
|
+
from .detector import PatternMatch, PipelinePatternDetector
|
|
27
|
+
from .linter import CollectionPipelineRule
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CollectionPipelineRule",
|
|
31
|
+
"CollectionPipelineConfig",
|
|
32
|
+
"PipelinePatternDetector",
|
|
33
|
+
"PatternMatch",
|
|
34
|
+
"lint",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def lint(
|
|
39
|
+
path: Path | str,
|
|
40
|
+
config: dict[str, Any] | None = None,
|
|
41
|
+
min_continues: int = DEFAULT_MIN_CONTINUES,
|
|
42
|
+
) -> list:
|
|
43
|
+
"""Lint a file or directory for collection pipeline violations.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Path to file or directory to lint
|
|
47
|
+
config: Configuration dict (optional, uses defaults if not provided)
|
|
48
|
+
min_continues: Minimum if/continue patterns to flag (default: 1)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of violations found
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> from src.linters.collection_pipeline import lint
|
|
55
|
+
>>> violations = lint('src/my_module.py', min_continues=2)
|
|
56
|
+
>>> for v in violations:
|
|
57
|
+
... print(f"{v.file_path}:{v.line} - {v.message}")
|
|
58
|
+
"""
|
|
59
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
60
|
+
project_root = path_obj if path_obj.is_dir() else path_obj.parent
|
|
61
|
+
|
|
62
|
+
orchestrator = _setup_pipeline_orchestrator(project_root, config, min_continues)
|
|
63
|
+
violations = _execute_pipeline_lint(orchestrator, path_obj)
|
|
64
|
+
|
|
65
|
+
return [v for v in violations if "collection-pipeline" in v.rule_id]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _setup_pipeline_orchestrator(
|
|
69
|
+
project_root: Path, config: dict[str, Any] | None, min_continues: int
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Set up orchestrator with collection-pipeline config."""
|
|
72
|
+
from src.orchestrator.core import Orchestrator
|
|
73
|
+
|
|
74
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
75
|
+
|
|
76
|
+
if config:
|
|
77
|
+
orchestrator.config["collection-pipeline"] = config
|
|
78
|
+
else:
|
|
79
|
+
orchestrator.config["collection-pipeline"] = {"min_continues": min_continues}
|
|
80
|
+
|
|
81
|
+
return orchestrator
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _execute_pipeline_lint(orchestrator: Any, path_obj: Path) -> list:
|
|
85
|
+
"""Execute linting on file or directory."""
|
|
86
|
+
if path_obj.is_file():
|
|
87
|
+
return orchestrator.lint_file(path_obj)
|
|
88
|
+
if path_obj.is_dir():
|
|
89
|
+
return orchestrator.lint_directory(path_obj)
|
|
90
|
+
return []
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
def __post_init__(self) -> None:
|
|
42
|
+
"""Validate configuration values."""
|
|
43
|
+
if self.min_continues < 1:
|
|
44
|
+
raise ValueError(f"min_continues must be at least 1, got {self.min_continues}")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(
|
|
48
|
+
cls, config: dict[str, Any], language: str | None = None
|
|
49
|
+
) -> "CollectionPipelineConfig":
|
|
50
|
+
"""Load configuration from dictionary.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config: Dictionary containing configuration values
|
|
54
|
+
language: Programming language (unused, for interface compatibility)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
CollectionPipelineConfig instance with values from dictionary
|
|
58
|
+
"""
|
|
59
|
+
return cls(
|
|
60
|
+
enabled=config.get("enabled", True),
|
|
61
|
+
min_continues=config.get("min_continues", DEFAULT_MIN_CONTINUES),
|
|
62
|
+
ignore=config.get("ignore", []),
|
|
63
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
for if_node in continues:
|
|
70
|
+
if _condition_has_side_effects(if_node.test):
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _condition_has_side_effects(node: ast.expr) -> bool:
|
|
76
|
+
"""Check if expression has side effects.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
node: AST expression node to check
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if expression has side effects
|
|
83
|
+
"""
|
|
84
|
+
for child in ast.walk(node):
|
|
85
|
+
if isinstance(child, ast.NamedExpr):
|
|
86
|
+
return True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def has_body_after_continues(body: list[ast.stmt], num_continues: int) -> bool:
|
|
91
|
+
"""Check if there are statements after continue guards.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
body: List of statements in for loop body
|
|
95
|
+
num_continues: Number of continue guards detected
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if there are statements after the continue guards
|
|
99
|
+
"""
|
|
100
|
+
return len(body) > num_continues
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
|
16
|
+
|
|
17
|
+
Interfaces: PipelinePatternDetector.detect_patterns() -> list[PatternMatch]
|
|
18
|
+
|
|
19
|
+
Implementation: AST visitor pattern with delegated pattern matching and suggestion generation
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from . import continue_analyzer, suggestion_builder
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PatternMatch:
|
|
30
|
+
"""Represents a detected anti-pattern."""
|
|
31
|
+
|
|
32
|
+
line_number: int
|
|
33
|
+
"""Line number where the for loop starts (1-indexed)."""
|
|
34
|
+
|
|
35
|
+
loop_var: str
|
|
36
|
+
"""Name of the loop variable."""
|
|
37
|
+
|
|
38
|
+
iterable: str
|
|
39
|
+
"""Source representation of the iterable."""
|
|
40
|
+
|
|
41
|
+
conditions: list[str]
|
|
42
|
+
"""List of filter conditions (inverted from continue guards)."""
|
|
43
|
+
|
|
44
|
+
has_side_effects: bool
|
|
45
|
+
"""Whether any condition has side effects."""
|
|
46
|
+
|
|
47
|
+
suggestion: str
|
|
48
|
+
"""Refactoring suggestion as a code snippet."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PipelinePatternDetector(ast.NodeVisitor):
|
|
52
|
+
"""Detects for loops with embedded filtering via if/continue patterns."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, source_code: str) -> None:
|
|
55
|
+
"""Initialize detector with source code.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
source_code: Python source code to analyze
|
|
59
|
+
"""
|
|
60
|
+
self.source_code = source_code
|
|
61
|
+
self.matches: list[PatternMatch] = []
|
|
62
|
+
|
|
63
|
+
def detect_patterns(self) -> list[PatternMatch]:
|
|
64
|
+
"""Analyze source code and return detected patterns.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of PatternMatch objects for each detected anti-pattern
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
tree = ast.parse(self.source_code)
|
|
71
|
+
self.visit(tree)
|
|
72
|
+
except SyntaxError:
|
|
73
|
+
pass # Invalid Python, return empty list
|
|
74
|
+
return self.matches
|
|
75
|
+
|
|
76
|
+
def visit_For(self, node: ast.For) -> None: # pylint: disable=invalid-name
|
|
77
|
+
"""Visit for loop and check for filtering patterns.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
node: AST For node to analyze
|
|
81
|
+
"""
|
|
82
|
+
match = self._analyze_for_loop(node)
|
|
83
|
+
if match is not None:
|
|
84
|
+
self.matches.append(match)
|
|
85
|
+
self.generic_visit(node)
|
|
86
|
+
|
|
87
|
+
def _analyze_for_loop(self, node: ast.For) -> PatternMatch | None:
|
|
88
|
+
"""Analyze a for loop for embedded filtering patterns.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
node: AST For node to analyze
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
PatternMatch if pattern detected, None otherwise
|
|
95
|
+
"""
|
|
96
|
+
continues = continue_analyzer.extract_continue_patterns(node.body)
|
|
97
|
+
if not continues:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if continue_analyzer.has_side_effects(continues):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if not continue_analyzer.has_body_after_continues(node.body, len(continues)):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
return self._create_match(node, continues)
|
|
107
|
+
|
|
108
|
+
def _create_match(self, for_node: ast.For, continues: list[ast.If]) -> PatternMatch:
|
|
109
|
+
"""Create a PatternMatch from detected pattern.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
for_node: AST For node
|
|
113
|
+
continues: List of continue guard if statements
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
PatternMatch object with detection information
|
|
117
|
+
"""
|
|
118
|
+
loop_var = suggestion_builder.get_target_name(for_node.target)
|
|
119
|
+
iterable = ast.unparse(for_node.iter)
|
|
120
|
+
conditions = [suggestion_builder.invert_condition(c.test) for c in continues]
|
|
121
|
+
suggestion = suggestion_builder.build_suggestion(loop_var, iterable, conditions)
|
|
122
|
+
|
|
123
|
+
return PatternMatch(
|
|
124
|
+
line_number=for_node.lineno,
|
|
125
|
+
loop_var=loop_var,
|
|
126
|
+
iterable=iterable,
|
|
127
|
+
conditions=conditions,
|
|
128
|
+
has_side_effects=False,
|
|
129
|
+
suggestion=suggestion,
|
|
130
|
+
)
|