thailint 0.14.0__py3-none-any.whl → 0.15.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/cli/config.py +6 -4
- src/cli/linters/code_patterns.py +64 -16
- src/cli/linters/code_smells.py +23 -14
- src/cli/linters/documentation.py +5 -3
- src/cli/linters/performance.py +23 -10
- src/cli/linters/shared.py +22 -6
- src/cli/linters/structure.py +13 -4
- src/cli/linters/structure_quality.py +9 -4
- src/cli/utils.py +4 -4
- src/config.py +34 -21
- src/core/python_lint_rule.py +101 -0
- src/linter_config/ignore.py +2 -1
- src/linters/cqs/__init__.py +54 -0
- src/linters/cqs/config.py +55 -0
- src/linters/cqs/function_analyzer.py +201 -0
- src/linters/cqs/input_detector.py +139 -0
- src/linters/cqs/linter.py +159 -0
- src/linters/cqs/output_detector.py +84 -0
- src/linters/cqs/python_analyzer.py +54 -0
- src/linters/cqs/types.py +82 -0
- src/linters/cqs/typescript_cqs_analyzer.py +61 -0
- src/linters/cqs/typescript_function_analyzer.py +192 -0
- src/linters/cqs/typescript_input_detector.py +203 -0
- src/linters/cqs/typescript_output_detector.py +117 -0
- src/linters/cqs/violation_builder.py +94 -0
- src/linters/dry/typescript_value_extractor.py +2 -1
- src/linters/file_header/linter.py +2 -1
- src/linters/file_placement/linter.py +6 -6
- src/linters/file_placement/pattern_validator.py +6 -5
- src/linters/file_placement/rule_checker.py +10 -5
- src/linters/lazy_ignores/config.py +5 -3
- src/linters/lazy_ignores/python_analyzer.py +5 -1
- src/linters/lazy_ignores/types.py +2 -1
- src/linters/lbyl/__init__.py +3 -1
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +30 -2
- src/linters/lbyl/pattern_detectors/base.py +24 -7
- src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
- src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
- src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
- src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
- src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
- src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
- src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
- src/linters/lbyl/python_analyzer.py +215 -0
- src/linters/lbyl/violation_builder.py +354 -0
- src/linters/stringly_typed/ignore_checker.py +4 -6
- src/orchestrator/language_detector.py +5 -3
- {thailint-0.14.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
- {thailint-0.14.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
- {thailint-0.14.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
- {thailint-0.14.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
- {thailint-0.14.0.dist-info → thailint-0.15.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Configuration dataclass for CQS (Command-Query Separation) linter
|
|
3
|
+
|
|
4
|
+
Scope: Pattern toggles, ignore patterns, and YAML configuration loading
|
|
5
|
+
|
|
6
|
+
Overview: Provides CQSConfig dataclass with configurable options for CQS violation
|
|
7
|
+
detection. Controls minimum operation thresholds, methods to ignore (constructors
|
|
8
|
+
by default), decorators to ignore (property-like by default), and fluent interface
|
|
9
|
+
detection. Configuration can be loaded from dictionary (YAML) with sensible defaults.
|
|
10
|
+
|
|
11
|
+
Dependencies: dataclasses, typing
|
|
12
|
+
|
|
13
|
+
Exports: CQSConfig
|
|
14
|
+
|
|
15
|
+
Interfaces: CQSConfig.from_dict() for YAML configuration loading
|
|
16
|
+
|
|
17
|
+
Implementation: Dataclass with factory defaults and conservative default settings
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CQSConfig:
|
|
26
|
+
"""Configuration for CQS linter."""
|
|
27
|
+
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
min_operations: int = 1
|
|
30
|
+
ignore_methods: list[str] = field(default_factory=lambda: ["__init__", "__new__"])
|
|
31
|
+
ignore_decorators: list[str] = field(default_factory=lambda: ["property", "cached_property"])
|
|
32
|
+
ignore_patterns: list[str] = field(default_factory=list)
|
|
33
|
+
detect_fluent_interface: bool = True
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, config: dict[str, Any], language: str | None = None) -> "CQSConfig":
|
|
37
|
+
"""Load configuration from dictionary (YAML).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config: Dictionary containing configuration values.
|
|
41
|
+
language: Reserved for future multi-language support.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
CQSConfig instance with values from dictionary or defaults.
|
|
45
|
+
"""
|
|
46
|
+
# Language parameter reserved for future multi-language support
|
|
47
|
+
_ = language
|
|
48
|
+
return cls(
|
|
49
|
+
enabled=config.get("enabled", True),
|
|
50
|
+
min_operations=config.get("min_operations", 1),
|
|
51
|
+
ignore_methods=config.get("ignore_methods", ["__init__", "__new__"]),
|
|
52
|
+
ignore_decorators=config.get("ignore_decorators", ["property", "cached_property"]),
|
|
53
|
+
ignore_patterns=config.get("ignore_patterns", []),
|
|
54
|
+
detect_fluent_interface=config.get("detect_fluent_interface", True),
|
|
55
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST visitor that builds CQSPattern objects for each function in Python code
|
|
3
|
+
|
|
4
|
+
Scope: Per-function CQS analysis with config-driven filtering
|
|
5
|
+
|
|
6
|
+
Overview: Provides FunctionAnalyzer class that traverses Python AST to analyze each function
|
|
7
|
+
for CQS patterns. Builds CQSPattern objects containing INPUT and OUTPUT operations for
|
|
8
|
+
each function/method. Applies configuration filtering including ignore_methods for
|
|
9
|
+
constructor exclusion, ignore_decorators for property-like methods, and detect_fluent_interface
|
|
10
|
+
for return self patterns. Tracks class context via stack for proper method detection.
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, InputDetector, OutputDetector, CQSConfig, CQSPattern
|
|
13
|
+
|
|
14
|
+
Exports: FunctionAnalyzer
|
|
15
|
+
|
|
16
|
+
Interfaces: FunctionAnalyzer.analyze(tree: ast.Module) -> list[CQSPattern]
|
|
17
|
+
|
|
18
|
+
Implementation: AST NodeVisitor with class context tracking and config-based filtering
|
|
19
|
+
|
|
20
|
+
Suppressions:
|
|
21
|
+
- N802: visit_ClassDef, visit_FunctionDef, visit_AsyncFunctionDef follow Python AST
|
|
22
|
+
visitor naming convention (camelCase required by ast.NodeVisitor)
|
|
23
|
+
- invalid-name: AST visitor methods follow required camelCase naming convention
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
from .config import CQSConfig
|
|
30
|
+
from .input_detector import InputDetector
|
|
31
|
+
from .output_detector import OutputDetector
|
|
32
|
+
from .types import CQSPattern, InputOperation, OutputOperation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_name_from_decorator(dec: ast.expr) -> str | None:
|
|
36
|
+
"""Extract decorator name from a single decorator expression."""
|
|
37
|
+
if isinstance(dec, ast.Name):
|
|
38
|
+
return dec.id
|
|
39
|
+
if isinstance(dec, ast.Attribute):
|
|
40
|
+
return dec.attr
|
|
41
|
+
if isinstance(dec, ast.Call):
|
|
42
|
+
return _get_name_from_call_decorator(dec.func)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_name_from_call_decorator(func: ast.expr) -> str | None:
|
|
47
|
+
"""Extract name from Call decorator's func attribute."""
|
|
48
|
+
if isinstance(func, ast.Name):
|
|
49
|
+
return func.id
|
|
50
|
+
if isinstance(func, ast.Attribute):
|
|
51
|
+
return func.attr
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_decorator_names(decorators: list[ast.expr]) -> list[str]:
|
|
56
|
+
"""Extract decorator names from decorator list."""
|
|
57
|
+
names = [_get_name_from_decorator(dec) for dec in decorators]
|
|
58
|
+
return [name for name in names if name is not None]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_return_self(body: Sequence[ast.stmt]) -> bool:
|
|
62
|
+
"""Check if function body ends with 'return self'."""
|
|
63
|
+
if not body:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
last_stmt = body[-1]
|
|
67
|
+
if not isinstance(last_stmt, ast.Return):
|
|
68
|
+
return False
|
|
69
|
+
if last_stmt.value is None:
|
|
70
|
+
return False
|
|
71
|
+
return isinstance(last_stmt.value, ast.Name) and last_stmt.value.id == "self"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_fluent_interface(node: ast.FunctionDef | ast.AsyncFunctionDef, config: CQSConfig) -> bool:
|
|
75
|
+
"""Check if function uses fluent interface pattern (return self)."""
|
|
76
|
+
if not config.detect_fluent_interface:
|
|
77
|
+
return False
|
|
78
|
+
return _has_return_self(node.body)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_function_definition(stmt: ast.stmt) -> bool:
|
|
82
|
+
"""Check if statement is a function definition."""
|
|
83
|
+
return isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _detect_inputs_in_body(
|
|
87
|
+
body: Sequence[ast.stmt], input_detector: InputDetector
|
|
88
|
+
) -> list[InputOperation]:
|
|
89
|
+
"""Detect INPUT operations in function body statements."""
|
|
90
|
+
inputs: list[InputOperation] = []
|
|
91
|
+
stmts = [stmt for stmt in body if not _is_function_definition(stmt)]
|
|
92
|
+
for stmt in stmts:
|
|
93
|
+
mini_module = ast.Module(body=[stmt], type_ignores=[])
|
|
94
|
+
stmt_inputs = input_detector.find_inputs(mini_module)
|
|
95
|
+
inputs.extend(stmt_inputs)
|
|
96
|
+
return inputs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _detect_outputs_in_body(
|
|
100
|
+
body: Sequence[ast.stmt], output_detector: OutputDetector
|
|
101
|
+
) -> list[OutputOperation]:
|
|
102
|
+
"""Detect OUTPUT operations in function body statements."""
|
|
103
|
+
outputs: list[OutputOperation] = []
|
|
104
|
+
stmts = [stmt for stmt in body if not _is_function_definition(stmt)]
|
|
105
|
+
for stmt in stmts:
|
|
106
|
+
mini_module = ast.Module(body=[stmt], type_ignores=[])
|
|
107
|
+
stmt_outputs = output_detector.find_outputs(mini_module)
|
|
108
|
+
outputs.extend(stmt_outputs)
|
|
109
|
+
return outputs
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FunctionAnalyzer(ast.NodeVisitor):
|
|
113
|
+
"""Analyzes Python AST to build CQSPattern objects for each function."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, file_path: str, config: CQSConfig) -> None:
|
|
116
|
+
"""Initialize the analyzer."""
|
|
117
|
+
self._file_path = file_path
|
|
118
|
+
self._config = config
|
|
119
|
+
self._input_detector = InputDetector()
|
|
120
|
+
self._output_detector = OutputDetector()
|
|
121
|
+
self._patterns: list[CQSPattern] = []
|
|
122
|
+
self._class_stack: list[str] = []
|
|
123
|
+
|
|
124
|
+
def analyze(self, tree: ast.Module) -> list[CQSPattern]:
|
|
125
|
+
"""Analyze AST and return CQSPattern for each function."""
|
|
126
|
+
self._patterns = []
|
|
127
|
+
self._class_stack = []
|
|
128
|
+
self.visit(tree)
|
|
129
|
+
return list(self._patterns)
|
|
130
|
+
|
|
131
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
132
|
+
"""Visit class definition to track class context."""
|
|
133
|
+
self._class_stack.append(node.name)
|
|
134
|
+
self.generic_visit(node)
|
|
135
|
+
self._class_stack.pop()
|
|
136
|
+
|
|
137
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
138
|
+
"""Visit function definition to analyze for CQS patterns."""
|
|
139
|
+
self._analyze_function(node, is_async=False)
|
|
140
|
+
|
|
141
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
142
|
+
"""Visit async function definition to analyze for CQS patterns."""
|
|
143
|
+
self._analyze_function(node, is_async=True)
|
|
144
|
+
|
|
145
|
+
def _analyze_function(
|
|
146
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Analyze a function/method for CQS patterns."""
|
|
149
|
+
# Check if function should be ignored
|
|
150
|
+
if self._should_ignore_function(node):
|
|
151
|
+
self.generic_visit(node)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Check for fluent interface pattern
|
|
155
|
+
if _is_fluent_interface(node, self._config):
|
|
156
|
+
self.generic_visit(node)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Detect INPUTs and OUTPUTs in function body only (not nested functions)
|
|
160
|
+
inputs = _detect_inputs_in_body(node.body, self._input_detector)
|
|
161
|
+
outputs = _detect_outputs_in_body(node.body, self._output_detector)
|
|
162
|
+
|
|
163
|
+
# Build pattern
|
|
164
|
+
pattern = self._build_pattern(node, is_async, inputs, outputs)
|
|
165
|
+
self._patterns.append(pattern)
|
|
166
|
+
|
|
167
|
+
# Continue visiting nested functions
|
|
168
|
+
self.generic_visit(node)
|
|
169
|
+
|
|
170
|
+
def _build_pattern(
|
|
171
|
+
self,
|
|
172
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
173
|
+
is_async: bool,
|
|
174
|
+
inputs: list[InputOperation],
|
|
175
|
+
outputs: list[OutputOperation],
|
|
176
|
+
) -> CQSPattern:
|
|
177
|
+
"""Build CQSPattern from function node and detected operations."""
|
|
178
|
+
is_method = len(self._class_stack) > 0
|
|
179
|
+
class_name = self._class_stack[-1] if self._class_stack else None
|
|
180
|
+
|
|
181
|
+
return CQSPattern(
|
|
182
|
+
function_name=node.name,
|
|
183
|
+
line=node.lineno,
|
|
184
|
+
column=node.col_offset,
|
|
185
|
+
file_path=self._file_path,
|
|
186
|
+
inputs=inputs,
|
|
187
|
+
outputs=outputs,
|
|
188
|
+
is_method=is_method,
|
|
189
|
+
is_async=is_async,
|
|
190
|
+
class_name=class_name,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _should_ignore_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
194
|
+
"""Check if function should be ignored based on config."""
|
|
195
|
+
# Check ignore_methods (e.g., __init__, __new__)
|
|
196
|
+
if node.name in self._config.ignore_methods:
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
# Check ignore_decorators (e.g., @property, @cached_property)
|
|
200
|
+
decorator_names = _get_decorator_names(node.decorator_list)
|
|
201
|
+
return any(name in self._config.ignore_decorators for name in decorator_names)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for INPUT (query) operations in CQS analysis
|
|
3
|
+
|
|
4
|
+
Scope: Detects assignment patterns where function call results are captured
|
|
5
|
+
|
|
6
|
+
Overview: Provides InputDetector class that uses AST traversal to find INPUT operations
|
|
7
|
+
which are query-like assignments that capture function call return values. Detects
|
|
8
|
+
patterns including simple assignments (x = func()), tuple unpacking (x, y = func()),
|
|
9
|
+
async assignments (x = await func()), attribute assignments (self.x = func()),
|
|
10
|
+
subscript assignments (cache[key] = func()), annotated assignments (result: int = func()),
|
|
11
|
+
and walrus operator patterns ((x := func())). Excludes non-call assignments like
|
|
12
|
+
literals, variable copies, and expression results.
|
|
13
|
+
|
|
14
|
+
Dependencies: ast module for Python AST traversal
|
|
15
|
+
|
|
16
|
+
Exports: InputDetector
|
|
17
|
+
|
|
18
|
+
Interfaces: InputDetector.find_inputs(tree: ast.AST) -> list[InputOperation]
|
|
19
|
+
|
|
20
|
+
Implementation: AST NodeVisitor pattern with visit_Assign, visit_AnnAssign, visit_NamedExpr
|
|
21
|
+
|
|
22
|
+
Suppressions:
|
|
23
|
+
- N802: visit_Assign, visit_AnnAssign, visit_NamedExpr follow Python AST visitor
|
|
24
|
+
naming convention (camelCase required by ast.NodeVisitor)
|
|
25
|
+
- invalid-name: visit_Assign, visit_AnnAssign, visit_NamedExpr follow Python AST visitor
|
|
26
|
+
naming convention (camelCase required by ast.NodeVisitor)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
|
|
31
|
+
from .types import InputOperation
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_call_expression(node: ast.expr) -> bool:
|
|
35
|
+
"""Check if expression is a Call or Await(Call)."""
|
|
36
|
+
if isinstance(node, ast.Call):
|
|
37
|
+
return True
|
|
38
|
+
if isinstance(node, ast.Await) and isinstance(node.value, ast.Call):
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unwrap_await(node: ast.expr) -> ast.expr:
|
|
44
|
+
"""Unwrap Await to get inner expression."""
|
|
45
|
+
if isinstance(node, ast.Await):
|
|
46
|
+
return node.value
|
|
47
|
+
return node
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_target_name(target: ast.expr) -> str:
|
|
51
|
+
"""Extract string representation of assignment target."""
|
|
52
|
+
if isinstance(target, ast.Name):
|
|
53
|
+
return target.id
|
|
54
|
+
if isinstance(target, ast.Tuple):
|
|
55
|
+
return ", ".join(_extract_target_name(elt) for elt in target.elts)
|
|
56
|
+
return ast.unparse(target)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InputDetector(ast.NodeVisitor):
|
|
60
|
+
"""Detects INPUT (query) operations that capture function call results."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
"""Initialize the detector."""
|
|
64
|
+
self._inputs: list[InputOperation] = []
|
|
65
|
+
|
|
66
|
+
def find_inputs(self, tree: ast.AST) -> list[InputOperation]:
|
|
67
|
+
"""Find INPUT operations in AST.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tree: Python AST to analyze
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of detected InputOperation objects
|
|
74
|
+
"""
|
|
75
|
+
self._inputs = []
|
|
76
|
+
self.visit(tree)
|
|
77
|
+
return list(self._inputs)
|
|
78
|
+
|
|
79
|
+
def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
80
|
+
"""Visit assignment to check for INPUT pattern.
|
|
81
|
+
|
|
82
|
+
Detects: x = func(), x, y = func(), self.x = func(), x[key] = func()
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
node: AST Assign node to analyze
|
|
86
|
+
"""
|
|
87
|
+
if _is_call_expression(node.value):
|
|
88
|
+
call_node = _unwrap_await(node.value)
|
|
89
|
+
for target in node.targets:
|
|
90
|
+
self._inputs.append(
|
|
91
|
+
InputOperation(
|
|
92
|
+
line=node.lineno,
|
|
93
|
+
column=node.col_offset,
|
|
94
|
+
expression=ast.unparse(call_node),
|
|
95
|
+
target=_extract_target_name(target),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
self.generic_visit(node)
|
|
99
|
+
|
|
100
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
101
|
+
"""Visit annotated assignment to check for INPUT pattern.
|
|
102
|
+
|
|
103
|
+
Detects: result: int = func()
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: AST AnnAssign node to analyze
|
|
107
|
+
"""
|
|
108
|
+
if node.value is not None and node.target is not None:
|
|
109
|
+
if _is_call_expression(node.value):
|
|
110
|
+
call_node = _unwrap_await(node.value)
|
|
111
|
+
self._inputs.append(
|
|
112
|
+
InputOperation(
|
|
113
|
+
line=node.lineno,
|
|
114
|
+
column=node.col_offset,
|
|
115
|
+
expression=ast.unparse(call_node),
|
|
116
|
+
target=_extract_target_name(node.target),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
self.generic_visit(node)
|
|
120
|
+
|
|
121
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
122
|
+
"""Visit named expression (walrus operator) to check for INPUT pattern.
|
|
123
|
+
|
|
124
|
+
Detects: (x := func())
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
node: AST NamedExpr node to analyze
|
|
128
|
+
"""
|
|
129
|
+
if _is_call_expression(node.value):
|
|
130
|
+
call_node = _unwrap_await(node.value)
|
|
131
|
+
self._inputs.append(
|
|
132
|
+
InputOperation(
|
|
133
|
+
line=node.lineno,
|
|
134
|
+
column=node.col_offset,
|
|
135
|
+
expression=ast.unparse(call_node),
|
|
136
|
+
target=node.target.id,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Main CQS linter rule implementing MultiLanguageLintRule interface
|
|
3
|
+
|
|
4
|
+
Scope: Entry point for CQS (Command-Query Separation) violation detection in Python and TypeScript
|
|
5
|
+
|
|
6
|
+
Overview: Provides CQSRule class that implements the MultiLanguageLintRule interface for
|
|
7
|
+
detecting functions that violate Command-Query Separation by mixing queries (INPUTs)
|
|
8
|
+
with commands (OUTPUTs). Supports both Python (via AST) and TypeScript (via tree-sitter).
|
|
9
|
+
Returns violations for functions that mix operations according to min_operations threshold.
|
|
10
|
+
Supports file path ignore patterns via glob matching.
|
|
11
|
+
|
|
12
|
+
Dependencies: MultiLanguageLintRule, PythonCQSAnalyzer, TypeScriptCQSAnalyzer, CQSConfig,
|
|
13
|
+
build_cqs_violation
|
|
14
|
+
|
|
15
|
+
Exports: CQSRule
|
|
16
|
+
|
|
17
|
+
Interfaces: check(context: BaseLintContext) -> list[Violation]
|
|
18
|
+
|
|
19
|
+
Implementation: Multi-language analysis with config-driven filtering and min_operations threshold
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from fnmatch import fnmatch
|
|
23
|
+
|
|
24
|
+
from src.core.base import BaseLintContext, MultiLanguageLintRule
|
|
25
|
+
from src.core.linter_utils import load_linter_config
|
|
26
|
+
from src.core.types import Violation
|
|
27
|
+
|
|
28
|
+
from .config import CQSConfig
|
|
29
|
+
from .python_analyzer import PythonCQSAnalyzer
|
|
30
|
+
from .types import CQSPattern
|
|
31
|
+
from .typescript_cqs_analyzer import TypeScriptCQSAnalyzer
|
|
32
|
+
from .violation_builder import build_cqs_violation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CQSRule(MultiLanguageLintRule):
|
|
36
|
+
"""Detects CQS (Command-Query Separation) violations in Python and TypeScript code."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: CQSConfig | None = None) -> None:
|
|
39
|
+
"""Initialize the CQS rule.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: Optional configuration override for testing
|
|
43
|
+
"""
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._config_override = config
|
|
46
|
+
self._python_analyzer = PythonCQSAnalyzer()
|
|
47
|
+
self._typescript_analyzer = TypeScriptCQSAnalyzer()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def rule_id(self) -> str:
|
|
51
|
+
"""Unique identifier for this rule."""
|
|
52
|
+
return "cqs"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def rule_name(self) -> str:
|
|
56
|
+
"""Human-readable name for this rule."""
|
|
57
|
+
return "Command-Query Separation"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def description(self) -> str:
|
|
61
|
+
"""Description of what this rule checks."""
|
|
62
|
+
return (
|
|
63
|
+
"Detects functions that violate Command-Query Separation by mixing "
|
|
64
|
+
"queries (functions that return values) with commands (functions that "
|
|
65
|
+
"perform side effects). Functions should either query state and return "
|
|
66
|
+
"a value, or command a change without returning data."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _load_config(self, context: BaseLintContext) -> CQSConfig:
|
|
70
|
+
"""Load configuration from context.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
context: Lint context
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
CQSConfig object
|
|
77
|
+
"""
|
|
78
|
+
if self._config_override is not None:
|
|
79
|
+
return self._config_override
|
|
80
|
+
return load_linter_config(context, "cqs", CQSConfig)
|
|
81
|
+
|
|
82
|
+
def _check_python(self, context: BaseLintContext, config: CQSConfig) -> list[Violation]:
|
|
83
|
+
"""Check Python code for CQS violations.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
context: Lint context with Python file information
|
|
87
|
+
config: Loaded configuration
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of violations found in Python code
|
|
91
|
+
"""
|
|
92
|
+
file_path = str(context.file_path) if context.file_path else "unknown"
|
|
93
|
+
|
|
94
|
+
if self._matches_ignore_pattern(file_path, config):
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
patterns = self._python_analyzer.analyze(context.file_content or "", file_path, config)
|
|
98
|
+
return self._patterns_to_violations(patterns, config)
|
|
99
|
+
|
|
100
|
+
def _check_typescript(self, context: BaseLintContext, config: CQSConfig) -> list[Violation]:
|
|
101
|
+
"""Check TypeScript/JavaScript code for CQS violations.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
context: Lint context with TypeScript/JavaScript file information
|
|
105
|
+
config: Loaded configuration
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of violations found in TypeScript/JavaScript code
|
|
109
|
+
"""
|
|
110
|
+
file_path = str(context.file_path) if context.file_path else "unknown"
|
|
111
|
+
|
|
112
|
+
if self._matches_ignore_pattern(file_path, config):
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
patterns = self._typescript_analyzer.analyze(context.file_content or "", file_path, config)
|
|
116
|
+
return self._patterns_to_violations(patterns, config)
|
|
117
|
+
|
|
118
|
+
def _patterns_to_violations(
|
|
119
|
+
self, patterns: list[CQSPattern], config: CQSConfig
|
|
120
|
+
) -> list[Violation]:
|
|
121
|
+
"""Convert CQSPatterns to Violations, applying thresholds.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
patterns: List of CQSPattern objects
|
|
125
|
+
config: CQS configuration
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of Violation objects
|
|
129
|
+
"""
|
|
130
|
+
violating_patterns = [p for p in patterns if self._is_violation(p, config)]
|
|
131
|
+
return [build_cqs_violation(p) for p in violating_patterns]
|
|
132
|
+
|
|
133
|
+
def _matches_ignore_pattern(self, file_path: str, config: CQSConfig) -> bool:
|
|
134
|
+
"""Check if file path matches any ignore pattern.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
file_path: Path to check
|
|
138
|
+
config: CQS configuration
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
True if path matches an ignore pattern
|
|
142
|
+
"""
|
|
143
|
+
return any(fnmatch(file_path, pattern) for pattern in config.ignore_patterns)
|
|
144
|
+
|
|
145
|
+
def _is_violation(self, pattern: CQSPattern, config: CQSConfig) -> bool:
|
|
146
|
+
"""Check if pattern represents a violation based on config.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
pattern: CQSPattern to check
|
|
150
|
+
config: CQS configuration
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if pattern is a violation
|
|
154
|
+
"""
|
|
155
|
+
if not pattern.has_violation():
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
min_ops = config.min_operations
|
|
159
|
+
return len(pattern.inputs) >= min_ops and len(pattern.outputs) >= min_ops
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: AST-based detector for OUTPUT (command) operations in CQS analysis
|
|
3
|
+
|
|
4
|
+
Scope: Detects statement-level calls where return values are discarded
|
|
5
|
+
|
|
6
|
+
Overview: Provides OutputDetector class that uses AST traversal to find OUTPUT operations
|
|
7
|
+
which are command-like statement-level function calls that discard return values.
|
|
8
|
+
Detects patterns including statement calls (func()), async statements (await func()),
|
|
9
|
+
method calls (obj.method()), and chained calls (obj.method().method2()). Only ast.Expr
|
|
10
|
+
nodes containing Call or Await(Call) are detected as OUTPUT. All other constructs
|
|
11
|
+
(return, if, while, for, with, assert, raise, yield, assignments, comprehensions)
|
|
12
|
+
are naturally excluded because they use different AST node types.
|
|
13
|
+
|
|
14
|
+
Dependencies: ast module for Python AST traversal
|
|
15
|
+
|
|
16
|
+
Exports: OutputDetector
|
|
17
|
+
|
|
18
|
+
Interfaces: OutputDetector.find_outputs(tree: ast.AST) -> list[OutputOperation]
|
|
19
|
+
|
|
20
|
+
Implementation: AST NodeVisitor pattern with visit_Expr to detect statement-level calls
|
|
21
|
+
|
|
22
|
+
Suppressions:
|
|
23
|
+
- N802: visit_Expr follows Python AST visitor naming convention
|
|
24
|
+
(camelCase required by ast.NodeVisitor)
|
|
25
|
+
- invalid-name: visit_Expr follows Python AST visitor naming convention
|
|
26
|
+
(camelCase required by ast.NodeVisitor)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
|
|
31
|
+
from .types import OutputOperation
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_call_expression(node: ast.expr) -> ast.Call | None:
|
|
35
|
+
"""Extract Call from expression, unwrapping Await if present."""
|
|
36
|
+
if isinstance(node, ast.Call):
|
|
37
|
+
return node
|
|
38
|
+
if isinstance(node, ast.Await) and isinstance(node.value, ast.Call):
|
|
39
|
+
return node.value
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutputDetector(ast.NodeVisitor):
|
|
44
|
+
"""Detects OUTPUT (command) operations that discard function call results."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
"""Initialize the detector."""
|
|
48
|
+
self._outputs: list[OutputOperation] = []
|
|
49
|
+
|
|
50
|
+
def find_outputs(self, tree: ast.AST) -> list[OutputOperation]:
|
|
51
|
+
"""Find OUTPUT operations in AST.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
tree: Python AST to analyze
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of detected OutputOperation objects
|
|
58
|
+
"""
|
|
59
|
+
self._outputs = []
|
|
60
|
+
self.visit(tree)
|
|
61
|
+
return list(self._outputs)
|
|
62
|
+
|
|
63
|
+
def visit_Expr(self, node: ast.Expr) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
64
|
+
"""Visit expression statement to check for OUTPUT pattern.
|
|
65
|
+
|
|
66
|
+
Only statement-level expressions (ast.Expr) are OUTPUT. This naturally
|
|
67
|
+
excludes return statements, conditionals, assignments, comprehensions,
|
|
68
|
+
and other constructs that use the call result.
|
|
69
|
+
|
|
70
|
+
Detects: func(), await func(), obj.method(), obj.method().method2()
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
node: AST Expr node to analyze
|
|
74
|
+
"""
|
|
75
|
+
call_node = _extract_call_expression(node.value)
|
|
76
|
+
if call_node is not None:
|
|
77
|
+
self._outputs.append(
|
|
78
|
+
OutputOperation(
|
|
79
|
+
line=node.lineno,
|
|
80
|
+
column=node.col_offset,
|
|
81
|
+
expression=ast.unparse(call_node),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Coordinator for Python CQS analysis returning per-function CQSPattern objects
|
|
3
|
+
|
|
4
|
+
Scope: High-level analyzer that orchestrates FunctionAnalyzer for function-level detection
|
|
5
|
+
|
|
6
|
+
Overview: Provides PythonCQSAnalyzer class that coordinates CQS pattern detection in Python
|
|
7
|
+
code. Handles AST parsing with proper SyntaxError handling, returning empty results for
|
|
8
|
+
unparseable code rather than raising exceptions. Delegates to FunctionAnalyzer to build
|
|
9
|
+
CQSPattern objects for each function/method, which contain INPUT and OUTPUT operations
|
|
10
|
+
along with function metadata (name, class context, async status).
|
|
11
|
+
|
|
12
|
+
Dependencies: ast module, FunctionAnalyzer, CQSConfig, CQSPattern
|
|
13
|
+
|
|
14
|
+
Exports: PythonCQSAnalyzer
|
|
15
|
+
|
|
16
|
+
Interfaces: PythonCQSAnalyzer.analyze(code, file_path, config) -> list[CQSPattern]
|
|
17
|
+
|
|
18
|
+
Implementation: Coordinates FunctionAnalyzer with error handling for AST parsing failures
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
|
|
23
|
+
from .config import CQSConfig
|
|
24
|
+
from .function_analyzer import FunctionAnalyzer
|
|
25
|
+
from .types import CQSPattern
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PythonCQSAnalyzer:
|
|
29
|
+
"""Analyzes Python code for CQS patterns, returning per-function results."""
|
|
30
|
+
|
|
31
|
+
def analyze(
|
|
32
|
+
self,
|
|
33
|
+
code: str,
|
|
34
|
+
file_path: str,
|
|
35
|
+
config: CQSConfig,
|
|
36
|
+
) -> list[CQSPattern]:
|
|
37
|
+
"""Analyze Python code for CQS patterns in each function.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
code: Python source code to analyze
|
|
41
|
+
file_path: Path to the source file (for error context)
|
|
42
|
+
config: CQS configuration settings
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of CQSPattern objects, one per function/method.
|
|
46
|
+
Returns empty list if code cannot be parsed due to SyntaxError.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
tree = ast.parse(code, filename=file_path)
|
|
50
|
+
except SyntaxError:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
analyzer = FunctionAnalyzer(file_path, config)
|
|
54
|
+
return analyzer.analyze(tree)
|