thailint 0.1.5__py3-none-any.whl → 0.5.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/__init__.py +7 -2
- src/analyzers/__init__.py +23 -0
- src/analyzers/typescript_base.py +148 -0
- src/api.py +1 -1
- src/cli.py +1111 -144
- src/config.py +12 -33
- src/core/base.py +102 -5
- src/core/cli_utils.py +206 -0
- src/core/config_parser.py +126 -0
- src/core/linter_utils.py +168 -0
- src/core/registry.py +17 -92
- src/core/rule_discovery.py +132 -0
- src/core/violation_builder.py +122 -0
- src/linter_config/ignore.py +112 -40
- src/linter_config/loader.py +3 -13
- src/linters/dry/__init__.py +23 -0
- src/linters/dry/base_token_analyzer.py +76 -0
- src/linters/dry/block_filter.py +265 -0
- src/linters/dry/block_grouper.py +59 -0
- src/linters/dry/cache.py +172 -0
- src/linters/dry/cache_query.py +61 -0
- src/linters/dry/config.py +134 -0
- src/linters/dry/config_loader.py +44 -0
- src/linters/dry/deduplicator.py +120 -0
- src/linters/dry/duplicate_storage.py +63 -0
- src/linters/dry/file_analyzer.py +90 -0
- src/linters/dry/inline_ignore.py +140 -0
- src/linters/dry/linter.py +163 -0
- src/linters/dry/python_analyzer.py +668 -0
- src/linters/dry/storage_initializer.py +42 -0
- src/linters/dry/token_hasher.py +169 -0
- src/linters/dry/typescript_analyzer.py +592 -0
- src/linters/dry/violation_builder.py +74 -0
- src/linters/dry/violation_filter.py +94 -0
- src/linters/dry/violation_generator.py +174 -0
- src/linters/file_header/__init__.py +24 -0
- src/linters/file_header/atemporal_detector.py +87 -0
- src/linters/file_header/config.py +66 -0
- src/linters/file_header/field_validator.py +69 -0
- src/linters/file_header/linter.py +313 -0
- src/linters/file_header/python_parser.py +86 -0
- src/linters/file_header/violation_builder.py +78 -0
- src/linters/file_placement/config_loader.py +86 -0
- src/linters/file_placement/directory_matcher.py +80 -0
- src/linters/file_placement/linter.py +262 -471
- src/linters/file_placement/path_resolver.py +61 -0
- src/linters/file_placement/pattern_matcher.py +55 -0
- src/linters/file_placement/pattern_validator.py +106 -0
- src/linters/file_placement/rule_checker.py +229 -0
- src/linters/file_placement/violation_factory.py +177 -0
- src/linters/magic_numbers/__init__.py +48 -0
- src/linters/magic_numbers/config.py +82 -0
- src/linters/magic_numbers/context_analyzer.py +247 -0
- src/linters/magic_numbers/linter.py +516 -0
- src/linters/magic_numbers/python_analyzer.py +76 -0
- src/linters/magic_numbers/typescript_analyzer.py +218 -0
- src/linters/magic_numbers/violation_builder.py +98 -0
- src/linters/nesting/__init__.py +6 -2
- src/linters/nesting/config.py +17 -4
- src/linters/nesting/linter.py +81 -168
- src/linters/nesting/typescript_analyzer.py +39 -102
- src/linters/nesting/typescript_function_extractor.py +130 -0
- src/linters/nesting/violation_builder.py +139 -0
- src/linters/print_statements/__init__.py +53 -0
- src/linters/print_statements/config.py +83 -0
- src/linters/print_statements/linter.py +430 -0
- src/linters/print_statements/python_analyzer.py +155 -0
- src/linters/print_statements/typescript_analyzer.py +135 -0
- src/linters/print_statements/violation_builder.py +98 -0
- src/linters/srp/__init__.py +99 -0
- src/linters/srp/class_analyzer.py +113 -0
- src/linters/srp/config.py +82 -0
- src/linters/srp/heuristics.py +89 -0
- src/linters/srp/linter.py +234 -0
- src/linters/srp/metrics_evaluator.py +47 -0
- src/linters/srp/python_analyzer.py +72 -0
- src/linters/srp/typescript_analyzer.py +75 -0
- src/linters/srp/typescript_metrics_calculator.py +90 -0
- src/linters/srp/violation_builder.py +117 -0
- src/orchestrator/core.py +54 -9
- src/templates/thailint_config_template.yaml +158 -0
- src/utils/__init__.py +4 -0
- src/utils/project_root.py +203 -0
- thailint-0.5.0.dist-info/METADATA +1286 -0
- thailint-0.5.0.dist-info/RECORD +96 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
- src/.ai/layout.yaml +0 -48
- thailint-0.1.5.dist-info/METADATA +0 -629
- thailint-0.1.5.dist-info/RECORD +0 -28
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: src/linters/print_statements/python_analyzer.py
|
|
3
|
+
|
|
4
|
+
Purpose: Python AST analysis for finding print() call nodes
|
|
5
|
+
|
|
6
|
+
Exports: PythonPrintStatementAnalyzer class
|
|
7
|
+
|
|
8
|
+
Depends: ast module for AST parsing and node types
|
|
9
|
+
|
|
10
|
+
Implements: PythonPrintStatementAnalyzer.find_print_calls(tree) -> list[tuple],
|
|
11
|
+
PythonPrintStatementAnalyzer.is_in_main_block(node) -> bool
|
|
12
|
+
|
|
13
|
+
Related: src/linters/magic_numbers/python_analyzer.py
|
|
14
|
+
|
|
15
|
+
Overview: Provides PythonPrintStatementAnalyzer class that traverses Python AST to find all
|
|
16
|
+
print() function calls. Uses ast.walk() to traverse the syntax tree and collect
|
|
17
|
+
Call nodes where the function is 'print'. Tracks parent nodes to detect if print calls
|
|
18
|
+
are within __main__ blocks (if __name__ == "__main__":) for allow_in_scripts filtering.
|
|
19
|
+
Returns structured data about each print call including the AST node, parent context,
|
|
20
|
+
and line number for violation reporting.
|
|
21
|
+
|
|
22
|
+
Usage: analyzer = PythonPrintStatementAnalyzer()
|
|
23
|
+
print_calls = analyzer.find_print_calls(ast.parse(code))
|
|
24
|
+
|
|
25
|
+
Notes: AST walk pattern with parent tracking for context detection
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PythonPrintStatementAnalyzer: # thailint: ignore[srp]
|
|
32
|
+
"""Analyzes Python AST to find print() calls."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Initialize the analyzer."""
|
|
36
|
+
self.print_calls: list[tuple[ast.Call, ast.AST | None, int]] = []
|
|
37
|
+
self.parent_map: dict[ast.AST, ast.AST] = {}
|
|
38
|
+
|
|
39
|
+
def find_print_calls(self, tree: ast.AST) -> list[tuple[ast.Call, ast.AST | None, int]]:
|
|
40
|
+
"""Find all print() calls in the AST.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
tree: The AST to analyze
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of tuples (node, parent, line_number)
|
|
47
|
+
"""
|
|
48
|
+
self.print_calls = []
|
|
49
|
+
self.parent_map = {}
|
|
50
|
+
self._build_parent_map(tree)
|
|
51
|
+
self._collect_print_calls(tree)
|
|
52
|
+
return self.print_calls
|
|
53
|
+
|
|
54
|
+
def _build_parent_map(self, node: ast.AST, parent: ast.AST | None = None) -> None:
|
|
55
|
+
"""Build a map of nodes to their parents.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
node: Current AST node
|
|
59
|
+
parent: Parent of current node
|
|
60
|
+
"""
|
|
61
|
+
if parent is not None:
|
|
62
|
+
self.parent_map[node] = parent
|
|
63
|
+
|
|
64
|
+
for child in ast.iter_child_nodes(node):
|
|
65
|
+
self._build_parent_map(child, node)
|
|
66
|
+
|
|
67
|
+
def _collect_print_calls(self, tree: ast.AST) -> None:
|
|
68
|
+
"""Walk tree and collect all print() calls.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tree: AST to traverse
|
|
72
|
+
"""
|
|
73
|
+
for node in ast.walk(tree):
|
|
74
|
+
if isinstance(node, ast.Call) and self._is_print_call(node):
|
|
75
|
+
parent = self.parent_map.get(node)
|
|
76
|
+
line_number = node.lineno if hasattr(node, "lineno") else 0
|
|
77
|
+
self.print_calls.append((node, parent, line_number))
|
|
78
|
+
|
|
79
|
+
def _is_print_call(self, node: ast.Call) -> bool:
|
|
80
|
+
"""Check if a Call node is calling print().
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
node: The Call node to check
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if this is a print() call
|
|
87
|
+
"""
|
|
88
|
+
return self._is_simple_print(node) or self._is_builtins_print(node)
|
|
89
|
+
|
|
90
|
+
def _is_simple_print(self, node: ast.Call) -> bool:
|
|
91
|
+
"""Check for simple print() call."""
|
|
92
|
+
return isinstance(node.func, ast.Name) and node.func.id == "print"
|
|
93
|
+
|
|
94
|
+
def _is_builtins_print(self, node: ast.Call) -> bool:
|
|
95
|
+
"""Check for builtins.print() call."""
|
|
96
|
+
if not isinstance(node.func, ast.Attribute):
|
|
97
|
+
return False
|
|
98
|
+
if node.func.attr != "print":
|
|
99
|
+
return False
|
|
100
|
+
return isinstance(node.func.value, ast.Name) and node.func.value.id == "builtins"
|
|
101
|
+
|
|
102
|
+
def is_in_main_block(self, node: ast.AST) -> bool:
|
|
103
|
+
"""Check if node is within `if __name__ == "__main__":` block.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: AST node to check
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if node is inside a __main__ block
|
|
110
|
+
"""
|
|
111
|
+
current = node
|
|
112
|
+
while current in self.parent_map:
|
|
113
|
+
parent = self.parent_map[current]
|
|
114
|
+
if self._is_main_if_block(parent):
|
|
115
|
+
return True
|
|
116
|
+
current = parent
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def _is_main_if_block(self, node: ast.AST) -> bool:
|
|
120
|
+
"""Check if node is an `if __name__ == "__main__":` statement.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
node: AST node to check
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if this is a __main__ if block
|
|
127
|
+
"""
|
|
128
|
+
if not isinstance(node, ast.If):
|
|
129
|
+
return False
|
|
130
|
+
if not isinstance(node.test, ast.Compare):
|
|
131
|
+
return False
|
|
132
|
+
return self._is_main_comparison(node.test)
|
|
133
|
+
|
|
134
|
+
def _is_main_comparison(self, test: ast.Compare) -> bool:
|
|
135
|
+
"""Check if comparison is __name__ == '__main__'."""
|
|
136
|
+
if not self._is_name_identifier(test.left):
|
|
137
|
+
return False
|
|
138
|
+
if not self._has_single_eq_operator(test):
|
|
139
|
+
return False
|
|
140
|
+
return self._compares_to_main(test)
|
|
141
|
+
|
|
142
|
+
def _is_name_identifier(self, node: ast.expr) -> bool:
|
|
143
|
+
"""Check if node is the __name__ identifier."""
|
|
144
|
+
return isinstance(node, ast.Name) and node.id == "__name__"
|
|
145
|
+
|
|
146
|
+
def _has_single_eq_operator(self, test: ast.Compare) -> bool:
|
|
147
|
+
"""Check if comparison has single == operator."""
|
|
148
|
+
return len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)
|
|
149
|
+
|
|
150
|
+
def _compares_to_main(self, test: ast.Compare) -> bool:
|
|
151
|
+
"""Check if comparison is to '__main__' string."""
|
|
152
|
+
if len(test.comparators) != 1:
|
|
153
|
+
return False
|
|
154
|
+
comparator = test.comparators[0]
|
|
155
|
+
return isinstance(comparator, ast.Constant) and comparator.value == "__main__"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: src/linters/print_statements/typescript_analyzer.py
|
|
3
|
+
|
|
4
|
+
Purpose: TypeScript/JavaScript console.* call detection using Tree-sitter AST analysis
|
|
5
|
+
|
|
6
|
+
Exports: TypeScriptPrintStatementAnalyzer class
|
|
7
|
+
|
|
8
|
+
Depends: TypeScriptBaseAnalyzer for tree-sitter parsing, tree-sitter Node type
|
|
9
|
+
|
|
10
|
+
Implements: find_console_calls(root_node, methods) -> list[tuple]
|
|
11
|
+
|
|
12
|
+
Related: src/linters/magic_numbers/typescript_analyzer.py, src/analyzers/typescript_base.py
|
|
13
|
+
|
|
14
|
+
Overview: Analyzes TypeScript and JavaScript code to detect console.* method calls that should
|
|
15
|
+
be replaced with proper logging. Uses Tree-sitter parser to traverse TypeScript/JavaScript
|
|
16
|
+
AST and identify call expressions where the callee is console.log, console.warn, console.error,
|
|
17
|
+
console.debug, or console.info (configurable). Returns structured data with the node, method
|
|
18
|
+
name, and line number for each detected console call. Supports both TypeScript and JavaScript
|
|
19
|
+
files with shared detection logic.
|
|
20
|
+
|
|
21
|
+
Usage: analyzer = TypeScriptPrintStatementAnalyzer()
|
|
22
|
+
root = analyzer.parse_typescript(code)
|
|
23
|
+
calls = analyzer.find_console_calls(root, {"log", "warn", "error"})
|
|
24
|
+
|
|
25
|
+
Notes: Tree-sitter node traversal with call_expression and member_expression pattern matching
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from src.analyzers.typescript_base import TypeScriptBaseAnalyzer
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# dry: ignore-block - tree-sitter import pattern (common across TypeScript analyzers)
|
|
36
|
+
try:
|
|
37
|
+
from tree_sitter import Node
|
|
38
|
+
|
|
39
|
+
TREE_SITTER_AVAILABLE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
TREE_SITTER_AVAILABLE = False
|
|
42
|
+
Node = Any # type: ignore
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TypeScriptPrintStatementAnalyzer(TypeScriptBaseAnalyzer):
|
|
46
|
+
"""Analyzes TypeScript/JavaScript code for console.* calls using Tree-sitter."""
|
|
47
|
+
|
|
48
|
+
def find_console_calls(self, root_node: Node, methods: set[str]) -> list[tuple[Node, str, int]]:
|
|
49
|
+
"""Find all console.* calls matching the specified methods.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
root_node: Root tree-sitter node to search from
|
|
53
|
+
methods: Set of console method names to detect (e.g., {"log", "warn"})
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of (node, method_name, line_number) tuples for each console call
|
|
57
|
+
"""
|
|
58
|
+
logger.debug(
|
|
59
|
+
"find_console_calls: TREE_SITTER_AVAILABLE=%s, root_node=%s",
|
|
60
|
+
TREE_SITTER_AVAILABLE,
|
|
61
|
+
root_node is not None,
|
|
62
|
+
)
|
|
63
|
+
if not TREE_SITTER_AVAILABLE or root_node is None:
|
|
64
|
+
logger.debug("Early return: tree-sitter not available or root_node is None")
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
calls: list[tuple[Node, str, int]] = []
|
|
68
|
+
self._collect_console_calls(root_node, methods, calls)
|
|
69
|
+
logger.debug("find_console_calls: found %d calls", len(calls))
|
|
70
|
+
return calls
|
|
71
|
+
|
|
72
|
+
def _collect_console_calls(
|
|
73
|
+
self, node: Node, methods: set[str], calls: list[tuple[Node, str, int]]
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Recursively collect console.* calls from AST.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
node: Current tree-sitter node
|
|
79
|
+
methods: Set of console method names to detect
|
|
80
|
+
calls: List to accumulate found calls
|
|
81
|
+
"""
|
|
82
|
+
if node.type == "call_expression":
|
|
83
|
+
method_name = self._extract_console_method(node, methods)
|
|
84
|
+
if method_name is not None:
|
|
85
|
+
line_number = node.start_point[0] + 1
|
|
86
|
+
calls.append((node, method_name, line_number))
|
|
87
|
+
|
|
88
|
+
for child in node.children:
|
|
89
|
+
self._collect_console_calls(child, methods, calls)
|
|
90
|
+
|
|
91
|
+
def _extract_console_method(self, node: Node, methods: set[str]) -> str | None:
|
|
92
|
+
"""Extract console method name if this is a console.* call.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
node: Tree-sitter call_expression node
|
|
96
|
+
methods: Set of console method names to detect
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Method name if this is a matching console call, None otherwise
|
|
100
|
+
"""
|
|
101
|
+
func_node = self.find_child_by_type(node, "member_expression")
|
|
102
|
+
if func_node is None:
|
|
103
|
+
return None
|
|
104
|
+
if not self._is_console_object(func_node):
|
|
105
|
+
return None
|
|
106
|
+
return self._get_matching_method(func_node, methods)
|
|
107
|
+
|
|
108
|
+
def _is_console_object(self, func_node: Node) -> bool:
|
|
109
|
+
"""Check if the member expression is on 'console' object."""
|
|
110
|
+
object_node = self._find_object_node(func_node)
|
|
111
|
+
if object_node is None:
|
|
112
|
+
return False
|
|
113
|
+
return self.extract_node_text(object_node) == "console"
|
|
114
|
+
|
|
115
|
+
def _get_matching_method(self, func_node: Node, methods: set[str]) -> str | None:
|
|
116
|
+
"""Get method name if it matches the configured methods."""
|
|
117
|
+
method_node = self.find_child_by_type(func_node, "property_identifier")
|
|
118
|
+
if method_node is None:
|
|
119
|
+
return None
|
|
120
|
+
method_name = self.extract_node_text(method_node)
|
|
121
|
+
return method_name if method_name in methods else None
|
|
122
|
+
|
|
123
|
+
def _find_object_node(self, member_expr: Node) -> Node | None:
|
|
124
|
+
"""Find the object node in a member expression.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
member_expr: Tree-sitter member_expression node
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Object node (identifier) or None
|
|
131
|
+
"""
|
|
132
|
+
for child in member_expr.children:
|
|
133
|
+
if child.type == "identifier":
|
|
134
|
+
return child
|
|
135
|
+
return None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: src/linters/print_statements/violation_builder.py
|
|
3
|
+
|
|
4
|
+
Purpose: Builds Violation objects for print statement detection
|
|
5
|
+
|
|
6
|
+
Exports: ViolationBuilder class
|
|
7
|
+
|
|
8
|
+
Depends: ast, pathlib.Path, src.core.types.Violation
|
|
9
|
+
|
|
10
|
+
Implements: ViolationBuilder.create_python_violation(node, line, file_path) -> Violation,
|
|
11
|
+
ViolationBuilder.create_typescript_violation(method, line, file_path) -> Violation
|
|
12
|
+
|
|
13
|
+
Related: src/linters/magic_numbers/violation_builder.py, src/core/types.py
|
|
14
|
+
|
|
15
|
+
Overview: Provides ViolationBuilder class that creates Violation objects for print statement
|
|
16
|
+
detections. Generates descriptive messages suggesting the use of proper logging instead of
|
|
17
|
+
print/console statements. Constructs complete Violation instances with rule_id, file_path,
|
|
18
|
+
line number, column, message, and suggestions. Provides separate methods for Python print()
|
|
19
|
+
violations and TypeScript/JavaScript console.* violations with language-appropriate messages.
|
|
20
|
+
|
|
21
|
+
Usage: builder = ViolationBuilder("print-statements.detected")
|
|
22
|
+
violation = builder.create_python_violation(node, line, file_path)
|
|
23
|
+
|
|
24
|
+
Notes: Message templates suggest logging as alternative, consistent with other linter patterns
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from src.core.types import Violation
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ViolationBuilder:
|
|
34
|
+
"""Builds violations for print statement detections."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, rule_id: str) -> None:
|
|
37
|
+
"""Initialize the violation builder.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
rule_id: The rule ID to use in violations
|
|
41
|
+
"""
|
|
42
|
+
self.rule_id = rule_id
|
|
43
|
+
|
|
44
|
+
def create_python_violation(
|
|
45
|
+
self,
|
|
46
|
+
node: ast.Call,
|
|
47
|
+
line: int,
|
|
48
|
+
file_path: Path | None,
|
|
49
|
+
) -> Violation:
|
|
50
|
+
"""Create a violation for a Python print() call.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
node: The AST Call node containing the print statement
|
|
54
|
+
line: Line number where the violation occurs
|
|
55
|
+
file_path: Path to the file
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Violation object with details about the print statement
|
|
59
|
+
"""
|
|
60
|
+
message = "print() statement should be replaced with proper logging"
|
|
61
|
+
suggestion = "Use logging.info(), logging.debug(), or similar instead of print()"
|
|
62
|
+
|
|
63
|
+
return Violation(
|
|
64
|
+
rule_id=self.rule_id,
|
|
65
|
+
file_path=str(file_path) if file_path else "",
|
|
66
|
+
line=line,
|
|
67
|
+
column=node.col_offset if hasattr(node, "col_offset") else 0,
|
|
68
|
+
message=message,
|
|
69
|
+
suggestion=suggestion,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def create_typescript_violation(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
line: int,
|
|
76
|
+
file_path: Path | None,
|
|
77
|
+
) -> Violation:
|
|
78
|
+
"""Create a violation for a TypeScript/JavaScript console.* call.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
method: The console method name (log, warn, error, etc.)
|
|
82
|
+
line: Line number where the violation occurs
|
|
83
|
+
file_path: Path to the file
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Violation object with details about the console statement
|
|
87
|
+
"""
|
|
88
|
+
message = f"console.{method}() should be replaced with proper logging"
|
|
89
|
+
suggestion = f"Use a logging library instead of console.{method}()"
|
|
90
|
+
|
|
91
|
+
return Violation(
|
|
92
|
+
rule_id=self.rule_id,
|
|
93
|
+
file_path=str(file_path) if file_path else "",
|
|
94
|
+
line=line,
|
|
95
|
+
column=0, # Tree-sitter nodes don't provide easy column access
|
|
96
|
+
message=message,
|
|
97
|
+
suggestion=suggestion,
|
|
98
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: SRP linter package initialization
|
|
3
|
+
|
|
4
|
+
Scope: Exports for Single Responsibility Principle linter module
|
|
5
|
+
|
|
6
|
+
Overview: Initializes the SRP linter package and exposes the main rule class for external use.
|
|
7
|
+
Exports SRPRule as the primary interface for the SRP linter, allowing the orchestrator to
|
|
8
|
+
discover and instantiate the rule. Also exports configuration and analyzer classes for
|
|
9
|
+
advanced use cases. Provides a convenience lint() function for direct usage without
|
|
10
|
+
orchestrator setup. This module serves as the entry point for the SRP linter functionality
|
|
11
|
+
within the thai-lint framework, enabling detection of classes with too many responsibilities.
|
|
12
|
+
|
|
13
|
+
Dependencies: SRPRule, SRPConfig, PythonSRPAnalyzer, TypeScriptSRPAnalyzer
|
|
14
|
+
|
|
15
|
+
Exports: SRPRule (primary), SRPConfig, PythonSRPAnalyzer, TypeScriptSRPAnalyzer, lint
|
|
16
|
+
|
|
17
|
+
Interfaces: Standard Python package initialization with __all__ for explicit exports, lint() convenience function
|
|
18
|
+
|
|
19
|
+
Implementation: Simple re-export pattern for package interface, convenience function wraps orchestrator
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .config import DEFAULT_MAX_LOC_PER_CLASS, DEFAULT_MAX_METHODS_PER_CLASS, SRPConfig
|
|
26
|
+
from .linter import SRPRule
|
|
27
|
+
from .python_analyzer import PythonSRPAnalyzer
|
|
28
|
+
from .typescript_analyzer import TypeScriptSRPAnalyzer
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SRPRule",
|
|
32
|
+
"SRPConfig",
|
|
33
|
+
"PythonSRPAnalyzer",
|
|
34
|
+
"TypeScriptSRPAnalyzer",
|
|
35
|
+
"lint",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def lint(
|
|
40
|
+
path: Path | str,
|
|
41
|
+
config: dict[str, Any] | None = None,
|
|
42
|
+
max_methods: int = DEFAULT_MAX_METHODS_PER_CLASS,
|
|
43
|
+
max_loc: int = DEFAULT_MAX_LOC_PER_CLASS,
|
|
44
|
+
) -> list:
|
|
45
|
+
"""Lint a file or directory for SRP violations.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
path: Path to file or directory to lint
|
|
49
|
+
config: Configuration dict (optional, uses defaults if not provided)
|
|
50
|
+
max_methods: Maximum allowed methods per class (default: 7)
|
|
51
|
+
max_loc: Maximum allowed lines of code per class (default: 200)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of violations found
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> from src.linters.srp import lint
|
|
58
|
+
>>> violations = lint('src/my_module.py', max_methods=5)
|
|
59
|
+
>>> for v in violations:
|
|
60
|
+
... print(f"{v.file_path}:{v.line} - {v.message}")
|
|
61
|
+
"""
|
|
62
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
63
|
+
project_root = path_obj if path_obj.is_dir() else path_obj.parent
|
|
64
|
+
|
|
65
|
+
orchestrator = _setup_srp_orchestrator(project_root, config, max_methods, max_loc)
|
|
66
|
+
violations = _execute_srp_lint(orchestrator, path_obj)
|
|
67
|
+
|
|
68
|
+
return [v for v in violations if "srp" in v.rule_id]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _setup_srp_orchestrator(
|
|
72
|
+
project_root: Path,
|
|
73
|
+
config: dict[str, Any] | None,
|
|
74
|
+
max_methods: int,
|
|
75
|
+
max_loc: int,
|
|
76
|
+
) -> Any:
|
|
77
|
+
"""Set up orchestrator with SRP config."""
|
|
78
|
+
from src.orchestrator.core import Orchestrator
|
|
79
|
+
|
|
80
|
+
orchestrator = Orchestrator(project_root=project_root)
|
|
81
|
+
|
|
82
|
+
if config:
|
|
83
|
+
orchestrator.config["srp"] = config
|
|
84
|
+
else:
|
|
85
|
+
orchestrator.config["srp"] = {
|
|
86
|
+
"max_methods": max_methods,
|
|
87
|
+
"max_loc": max_loc,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return orchestrator
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _execute_srp_lint(orchestrator: Any, path_obj: Path) -> list:
|
|
94
|
+
"""Execute linting on file or directory."""
|
|
95
|
+
if path_obj.is_file():
|
|
96
|
+
return orchestrator.lint_file(path_obj)
|
|
97
|
+
if path_obj.is_dir():
|
|
98
|
+
return orchestrator.lint_directory(path_obj)
|
|
99
|
+
return []
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Class analysis coordination for SRP linter
|
|
3
|
+
|
|
4
|
+
Scope: Coordinates Python and TypeScript class analysis
|
|
5
|
+
|
|
6
|
+
Overview: Provides unified class analysis interface for the SRP linter. Delegates to language-
|
|
7
|
+
specific analyzers (PythonSRPAnalyzer, TypeScriptSRPAnalyzer) based on language type.
|
|
8
|
+
Handles syntax error gracefully and extracts class metrics for SRP evaluation. Isolates
|
|
9
|
+
language-specific analysis logic from rule checking and violation building.
|
|
10
|
+
|
|
11
|
+
Dependencies: ast, PythonSRPAnalyzer, TypeScriptSRPAnalyzer, BaseLintContext, SRPConfig
|
|
12
|
+
|
|
13
|
+
Exports: ClassAnalyzer
|
|
14
|
+
|
|
15
|
+
Interfaces: analyze_python(context, config) -> list[dict], analyze_typescript(context, config) -> list[dict]
|
|
16
|
+
|
|
17
|
+
Implementation: Delegates to language-specific analyzers, returns normalized metrics dicts
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from src.core.base import BaseLintContext
|
|
24
|
+
from src.core.types import Severity, Violation
|
|
25
|
+
|
|
26
|
+
from .config import SRPConfig
|
|
27
|
+
from .python_analyzer import PythonSRPAnalyzer
|
|
28
|
+
from .typescript_analyzer import TypeScriptSRPAnalyzer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClassAnalyzer:
|
|
32
|
+
"""Coordinates class analysis for Python and TypeScript."""
|
|
33
|
+
|
|
34
|
+
def analyze_python(
|
|
35
|
+
self, context: BaseLintContext, config: SRPConfig
|
|
36
|
+
) -> list[dict[str, Any]] | list[Violation]:
|
|
37
|
+
"""Analyze Python classes and return metrics or syntax errors.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
context: Lint context with file information
|
|
41
|
+
config: SRP configuration
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of class metrics dicts, or list of syntax error violations
|
|
45
|
+
"""
|
|
46
|
+
tree = self._parse_python_safely(context)
|
|
47
|
+
if isinstance(tree, list): # Syntax error violations
|
|
48
|
+
return tree
|
|
49
|
+
|
|
50
|
+
analyzer = PythonSRPAnalyzer()
|
|
51
|
+
classes = analyzer.find_all_classes(tree)
|
|
52
|
+
return [
|
|
53
|
+
analyzer.analyze_class(class_node, context.file_content or "", config)
|
|
54
|
+
for class_node in classes
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def analyze_typescript(
|
|
58
|
+
self, context: BaseLintContext, config: SRPConfig
|
|
59
|
+
) -> list[dict[str, Any]]:
|
|
60
|
+
"""Analyze TypeScript classes and return metrics.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
context: Lint context with file information
|
|
64
|
+
config: SRP configuration
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of class metrics dicts
|
|
68
|
+
"""
|
|
69
|
+
analyzer = TypeScriptSRPAnalyzer()
|
|
70
|
+
root_node = analyzer.parse_typescript(context.file_content or "")
|
|
71
|
+
if not root_node:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
classes = analyzer.find_all_classes(root_node)
|
|
75
|
+
return [
|
|
76
|
+
analyzer.analyze_class(class_node, context.file_content or "", config)
|
|
77
|
+
for class_node in classes
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def _parse_python_safely(self, context: BaseLintContext) -> ast.AST | list[Violation]:
|
|
81
|
+
"""Parse Python code and return AST or syntax error violations.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
context: Lint context with file information
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
AST if successful, list of syntax error violations otherwise
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return ast.parse(context.file_content or "")
|
|
91
|
+
except SyntaxError as exc:
|
|
92
|
+
return [self._create_syntax_error_violation(exc, context)]
|
|
93
|
+
|
|
94
|
+
def _create_syntax_error_violation(
|
|
95
|
+
self, exc: SyntaxError, context: BaseLintContext
|
|
96
|
+
) -> Violation:
|
|
97
|
+
"""Create syntax error violation.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
exc: SyntaxError exception
|
|
101
|
+
context: Lint context
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Syntax error violation
|
|
105
|
+
"""
|
|
106
|
+
return Violation(
|
|
107
|
+
rule_id="srp.syntax-error",
|
|
108
|
+
file_path=str(context.file_path or ""),
|
|
109
|
+
line=exc.lineno or 1,
|
|
110
|
+
column=exc.offset or 0,
|
|
111
|
+
message=f"Syntax error: {exc.msg}",
|
|
112
|
+
severity=Severity.ERROR,
|
|
113
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Configuration schema for Single Responsibility Principle linter
|
|
3
|
+
|
|
4
|
+
Scope: SRPConfig dataclass with max_methods, max_loc, and keyword settings
|
|
5
|
+
|
|
6
|
+
Overview: Defines configuration schema for SRP linter. Provides SRPConfig dataclass with
|
|
7
|
+
max_methods field (default 7), max_loc field (default 200), and check_keywords flag
|
|
8
|
+
(default True) with configurable responsibility keywords. Supports per-file and
|
|
9
|
+
per-directory config overrides. Validates that thresholds are positive integers.
|
|
10
|
+
Integrates with the orchestrator's configuration system to allow users to customize
|
|
11
|
+
SRP thresholds via .thailint.yaml configuration files. Keywords list identifies
|
|
12
|
+
generic class names that often indicate SRP violations (Manager, Handler, etc.).
|
|
13
|
+
|
|
14
|
+
Dependencies: dataclasses, typing
|
|
15
|
+
|
|
16
|
+
Exports: SRPConfig dataclass
|
|
17
|
+
|
|
18
|
+
Interfaces: SRPConfig(max_methods, max_loc, check_keywords, keywords), from_dict class method
|
|
19
|
+
|
|
20
|
+
Implementation: Dataclass with validation and defaults, heuristic-based SRP detection thresholds
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# Default SRP threshold constants
|
|
27
|
+
DEFAULT_MAX_METHODS_PER_CLASS = 7
|
|
28
|
+
DEFAULT_MAX_LOC_PER_CLASS = 200
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SRPConfig:
|
|
33
|
+
"""Configuration for SRP linter."""
|
|
34
|
+
|
|
35
|
+
max_methods: int = DEFAULT_MAX_METHODS_PER_CLASS # Maximum methods per class
|
|
36
|
+
max_loc: int = DEFAULT_MAX_LOC_PER_CLASS # Maximum lines of code per class
|
|
37
|
+
enabled: bool = True
|
|
38
|
+
check_keywords: bool = True
|
|
39
|
+
keywords: list[str] = field(
|
|
40
|
+
default_factory=lambda: ["Manager", "Handler", "Processor", "Utility", "Helper"]
|
|
41
|
+
)
|
|
42
|
+
ignore: list[str] = field(default_factory=list) # Path patterns to ignore
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
"""Validate configuration values."""
|
|
46
|
+
if self.max_methods <= 0:
|
|
47
|
+
raise ValueError(f"max_methods must be positive, got {self.max_methods}")
|
|
48
|
+
if self.max_loc <= 0:
|
|
49
|
+
raise ValueError(f"max_loc must be positive, got {self.max_loc}")
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, config: dict[str, Any], language: str | None = None) -> "SRPConfig":
|
|
53
|
+
"""Load configuration from dictionary with language-specific overrides.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config: Dictionary containing configuration values
|
|
57
|
+
language: Programming language (python, typescript, javascript) for language-specific thresholds
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
SRPConfig instance with values from dictionary
|
|
61
|
+
"""
|
|
62
|
+
# Get language-specific config if available
|
|
63
|
+
if language and language in config:
|
|
64
|
+
lang_config = config[language]
|
|
65
|
+
max_methods = lang_config.get(
|
|
66
|
+
"max_methods", config.get("max_methods", DEFAULT_MAX_METHODS_PER_CLASS)
|
|
67
|
+
)
|
|
68
|
+
max_loc = lang_config.get("max_loc", config.get("max_loc", DEFAULT_MAX_LOC_PER_CLASS))
|
|
69
|
+
else:
|
|
70
|
+
max_methods = config.get("max_methods", DEFAULT_MAX_METHODS_PER_CLASS)
|
|
71
|
+
max_loc = config.get("max_loc", DEFAULT_MAX_LOC_PER_CLASS)
|
|
72
|
+
|
|
73
|
+
return cls(
|
|
74
|
+
max_methods=max_methods,
|
|
75
|
+
max_loc=max_loc,
|
|
76
|
+
enabled=config.get("enabled", True),
|
|
77
|
+
check_keywords=config.get("check_keywords", True),
|
|
78
|
+
keywords=config.get(
|
|
79
|
+
"keywords", ["Manager", "Handler", "Processor", "Utility", "Helper"]
|
|
80
|
+
),
|
|
81
|
+
ignore=config.get("ignore", []),
|
|
82
|
+
)
|