thailint 0.2.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.
- src/__init__.py +1 -0
- src/analyzers/__init__.py +4 -3
- src/analyzers/ast_utils.py +54 -0
- src/analyzers/rust_base.py +155 -0
- src/analyzers/rust_context.py +141 -0
- src/analyzers/typescript_base.py +4 -0
- src/cli/__init__.py +30 -0
- src/cli/__main__.py +22 -0
- src/cli/config.py +480 -0
- src/cli/config_merge.py +241 -0
- src/cli/linters/__init__.py +67 -0
- src/cli/linters/code_patterns.py +270 -0
- src/cli/linters/code_smells.py +342 -0
- src/cli/linters/documentation.py +83 -0
- src/cli/linters/performance.py +287 -0
- src/cli/linters/shared.py +331 -0
- src/cli/linters/structure.py +327 -0
- src/cli/linters/structure_quality.py +328 -0
- src/cli/main.py +120 -0
- src/cli/utils.py +395 -0
- src/cli_main.py +37 -0
- src/config.py +44 -27
- src/core/base.py +95 -5
- src/core/cli_utils.py +19 -2
- src/core/config_parser.py +36 -6
- src/core/constants.py +54 -0
- src/core/linter_utils.py +95 -6
- src/core/python_lint_rule.py +101 -0
- src/core/registry.py +1 -1
- src/core/rule_discovery.py +147 -84
- src/core/types.py +13 -0
- src/core/violation_builder.py +78 -15
- src/core/violation_utils.py +69 -0
- src/formatters/__init__.py +22 -0
- src/formatters/sarif.py +202 -0
- src/linter_config/directive_markers.py +109 -0
- src/linter_config/ignore.py +254 -395
- src/linter_config/loader.py +45 -12
- src/linter_config/pattern_utils.py +65 -0
- src/linter_config/rule_matcher.py +89 -0
- src/linters/collection_pipeline/__init__.py +90 -0
- src/linters/collection_pipeline/any_all_analyzer.py +281 -0
- src/linters/collection_pipeline/ast_utils.py +40 -0
- src/linters/collection_pipeline/config.py +75 -0
- src/linters/collection_pipeline/continue_analyzer.py +94 -0
- src/linters/collection_pipeline/detector.py +360 -0
- src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
- src/linters/collection_pipeline/linter.py +420 -0
- src/linters/collection_pipeline/suggestion_builder.py +130 -0
- 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/base_token_analyzer.py +16 -9
- src/linters/dry/block_filter.py +125 -22
- src/linters/dry/block_grouper.py +4 -0
- src/linters/dry/cache.py +142 -94
- src/linters/dry/cache_query.py +4 -0
- src/linters/dry/config.py +68 -21
- src/linters/dry/constant.py +92 -0
- src/linters/dry/constant_matcher.py +223 -0
- src/linters/dry/constant_violation_builder.py +98 -0
- src/linters/dry/duplicate_storage.py +20 -82
- src/linters/dry/file_analyzer.py +15 -50
- src/linters/dry/inline_ignore.py +7 -16
- src/linters/dry/linter.py +182 -54
- src/linters/dry/python_analyzer.py +108 -336
- src/linters/dry/python_constant_extractor.py +100 -0
- src/linters/dry/single_statement_detector.py +417 -0
- src/linters/dry/storage_initializer.py +9 -18
- src/linters/dry/token_hasher.py +129 -71
- src/linters/dry/typescript_analyzer.py +68 -380
- src/linters/dry/typescript_constant_extractor.py +138 -0
- src/linters/dry/typescript_statement_detector.py +255 -0
- src/linters/dry/typescript_value_extractor.py +70 -0
- src/linters/dry/violation_builder.py +4 -0
- src/linters/dry/violation_filter.py +9 -5
- src/linters/dry/violation_generator.py +71 -14
- src/linters/file_header/__init__.py +24 -0
- src/linters/file_header/atemporal_detector.py +105 -0
- src/linters/file_header/base_parser.py +93 -0
- src/linters/file_header/bash_parser.py +66 -0
- src/linters/file_header/config.py +140 -0
- src/linters/file_header/css_parser.py +70 -0
- src/linters/file_header/field_validator.py +72 -0
- src/linters/file_header/linter.py +309 -0
- src/linters/file_header/markdown_parser.py +130 -0
- src/linters/file_header/python_parser.py +42 -0
- src/linters/file_header/typescript_parser.py +73 -0
- src/linters/file_header/violation_builder.py +79 -0
- src/linters/file_placement/config_loader.py +3 -1
- src/linters/file_placement/directory_matcher.py +4 -0
- src/linters/file_placement/linter.py +74 -31
- src/linters/file_placement/pattern_matcher.py +41 -6
- src/linters/file_placement/pattern_validator.py +31 -12
- src/linters/file_placement/rule_checker.py +12 -7
- src/linters/lazy_ignores/__init__.py +43 -0
- src/linters/lazy_ignores/config.py +74 -0
- src/linters/lazy_ignores/directive_utils.py +164 -0
- src/linters/lazy_ignores/header_parser.py +177 -0
- src/linters/lazy_ignores/linter.py +158 -0
- src/linters/lazy_ignores/matcher.py +168 -0
- src/linters/lazy_ignores/python_analyzer.py +209 -0
- src/linters/lazy_ignores/rule_id_utils.py +180 -0
- src/linters/lazy_ignores/skip_detector.py +298 -0
- src/linters/lazy_ignores/types.py +71 -0
- src/linters/lazy_ignores/typescript_analyzer.py +146 -0
- src/linters/lazy_ignores/violation_builder.py +135 -0
- src/linters/lbyl/__init__.py +31 -0
- src/linters/lbyl/config.py +63 -0
- src/linters/lbyl/linter.py +67 -0
- src/linters/lbyl/pattern_detectors/__init__.py +53 -0
- src/linters/lbyl/pattern_detectors/base.py +63 -0
- 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/magic_numbers/__init__.py +48 -0
- src/linters/magic_numbers/config.py +82 -0
- src/linters/magic_numbers/context_analyzer.py +249 -0
- src/linters/magic_numbers/linter.py +462 -0
- src/linters/magic_numbers/python_analyzer.py +64 -0
- src/linters/magic_numbers/typescript_analyzer.py +215 -0
- src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
- src/linters/magic_numbers/violation_builder.py +98 -0
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +138 -0
- src/linters/method_property/linter.py +414 -0
- src/linters/method_property/python_analyzer.py +473 -0
- src/linters/method_property/violation_builder.py +119 -0
- src/linters/nesting/__init__.py +6 -2
- src/linters/nesting/config.py +6 -3
- src/linters/nesting/linter.py +31 -34
- src/linters/nesting/python_analyzer.py +4 -0
- src/linters/nesting/typescript_analyzer.py +6 -11
- src/linters/nesting/violation_builder.py +1 -0
- src/linters/performance/__init__.py +91 -0
- src/linters/performance/config.py +43 -0
- src/linters/performance/constants.py +49 -0
- src/linters/performance/linter.py +149 -0
- src/linters/performance/python_analyzer.py +365 -0
- src/linters/performance/regex_analyzer.py +312 -0
- src/linters/performance/regex_linter.py +139 -0
- src/linters/performance/typescript_analyzer.py +236 -0
- src/linters/performance/violation_builder.py +160 -0
- src/linters/print_statements/__init__.py +53 -0
- src/linters/print_statements/config.py +78 -0
- src/linters/print_statements/linter.py +413 -0
- src/linters/print_statements/python_analyzer.py +153 -0
- src/linters/print_statements/typescript_analyzer.py +125 -0
- src/linters/print_statements/violation_builder.py +96 -0
- src/linters/srp/__init__.py +3 -3
- src/linters/srp/class_analyzer.py +11 -7
- src/linters/srp/config.py +12 -6
- src/linters/srp/heuristics.py +56 -22
- src/linters/srp/linter.py +47 -39
- src/linters/srp/python_analyzer.py +55 -20
- src/linters/srp/typescript_metrics_calculator.py +110 -50
- src/linters/stateless_class/__init__.py +25 -0
- src/linters/stateless_class/config.py +58 -0
- src/linters/stateless_class/linter.py +349 -0
- src/linters/stateless_class/python_analyzer.py +290 -0
- src/linters/stringly_typed/__init__.py +36 -0
- src/linters/stringly_typed/config.py +189 -0
- src/linters/stringly_typed/context_filter.py +451 -0
- src/linters/stringly_typed/function_call_violation_builder.py +135 -0
- src/linters/stringly_typed/ignore_checker.py +100 -0
- src/linters/stringly_typed/ignore_utils.py +51 -0
- src/linters/stringly_typed/linter.py +376 -0
- src/linters/stringly_typed/python/__init__.py +33 -0
- src/linters/stringly_typed/python/analyzer.py +348 -0
- src/linters/stringly_typed/python/call_tracker.py +175 -0
- src/linters/stringly_typed/python/comparison_tracker.py +257 -0
- src/linters/stringly_typed/python/condition_extractor.py +134 -0
- src/linters/stringly_typed/python/conditional_detector.py +179 -0
- src/linters/stringly_typed/python/constants.py +21 -0
- src/linters/stringly_typed/python/match_analyzer.py +94 -0
- src/linters/stringly_typed/python/validation_detector.py +189 -0
- src/linters/stringly_typed/python/variable_extractor.py +96 -0
- src/linters/stringly_typed/storage.py +620 -0
- src/linters/stringly_typed/storage_initializer.py +45 -0
- src/linters/stringly_typed/typescript/__init__.py +28 -0
- src/linters/stringly_typed/typescript/analyzer.py +157 -0
- src/linters/stringly_typed/typescript/call_tracker.py +335 -0
- src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
- src/linters/stringly_typed/violation_generator.py +419 -0
- src/orchestrator/core.py +264 -16
- src/orchestrator/language_detector.py +5 -3
- src/templates/thailint_config_template.yaml +354 -0
- src/utils/project_root.py +138 -16
- thailint-0.15.3.dist-info/METADATA +187 -0
- thailint-0.15.3.dist-info/RECORD +226 -0
- {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +1 -1
- thailint-0.15.3.dist-info/entry_points.txt +4 -0
- src/cli.py +0 -1055
- thailint-0.2.0.dist-info/METADATA +0 -980
- thailint-0.2.0.dist-info/RECORD +0 -75
- thailint-0.2.0.dist-info/entry_points.txt +0 -4
- {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info/licenses}/LICENSE +0 -0
|
@@ -4,13 +4,14 @@ Purpose: TypeScript class metrics calculation for SRP analysis
|
|
|
4
4
|
Scope: Calculates method count and lines of code for TypeScript classes
|
|
5
5
|
|
|
6
6
|
Overview: Provides metrics calculation functionality for TypeScript classes in SRP analysis. Counts
|
|
7
|
-
public methods in class bodies (excludes constructors
|
|
8
|
-
positions, and identifies class body nodes. Uses
|
|
9
|
-
calculation from class analysis and tree
|
|
7
|
+
public methods in class bodies (excludes constructors and private methods starting with _),
|
|
8
|
+
calculates lines of code from AST node positions, and identifies class body nodes. Uses
|
|
9
|
+
tree-sitter AST node types. Isolates metrics calculation from class analysis and tree
|
|
10
|
+
traversal logic.
|
|
10
11
|
|
|
11
12
|
Dependencies: typing
|
|
12
13
|
|
|
13
|
-
Exports: TypeScriptMetricsCalculator
|
|
14
|
+
Exports: count_methods function, count_loc function, TypeScriptMetricsCalculator class (compat)
|
|
14
15
|
|
|
15
16
|
Interfaces: count_methods(class_node), count_loc(class_node, source)
|
|
16
17
|
|
|
@@ -20,8 +21,110 @@ Implementation: Tree-sitter node type matching, AST position arithmetic
|
|
|
20
21
|
from typing import Any
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def count_methods(class_node: Any) -> int:
|
|
25
|
+
"""Count number of methods in a TypeScript class.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
class_node: Class declaration tree-sitter node
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Number of public methods (excludes constructor)
|
|
32
|
+
"""
|
|
33
|
+
class_body = _get_class_body(class_node)
|
|
34
|
+
if not class_body:
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
method_count = 0
|
|
38
|
+
for child in class_body.children:
|
|
39
|
+
if _is_countable_method(child):
|
|
40
|
+
method_count += 1
|
|
41
|
+
|
|
42
|
+
return method_count
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def count_loc(class_node: Any, source: str) -> int:
|
|
46
|
+
"""Count lines of code in a TypeScript class.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
class_node: Class declaration tree-sitter node
|
|
50
|
+
source: Full source code string
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Number of lines in class definition
|
|
54
|
+
"""
|
|
55
|
+
start_line = class_node.start_point[0]
|
|
56
|
+
end_line = class_node.end_point[0]
|
|
57
|
+
return end_line - start_line + 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_class_body(class_node: Any) -> Any:
|
|
61
|
+
"""Get the class_body node from a class declaration.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
class_node: Class declaration node
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Class body node or None
|
|
68
|
+
"""
|
|
69
|
+
for child in class_node.children:
|
|
70
|
+
if child.type == "class_body":
|
|
71
|
+
return child
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_countable_method(node: Any) -> bool:
|
|
76
|
+
"""Check if node is a public method that should be counted.
|
|
77
|
+
|
|
78
|
+
Excludes constructors and private methods (names starting with _).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
node: Tree-sitter node to check
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if node is a countable public method
|
|
85
|
+
"""
|
|
86
|
+
if node.type != "method_definition":
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
method_name = _get_method_name(node)
|
|
90
|
+
|
|
91
|
+
# Don't count constructors
|
|
92
|
+
if method_name == "constructor":
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# Don't count private methods (underscore prefix convention)
|
|
96
|
+
if method_name and method_name.startswith("_"):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_method_name(node: Any) -> str | None:
|
|
103
|
+
"""Extract method name from method_definition node.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: Method definition tree-sitter node
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Method name or None if not found
|
|
110
|
+
"""
|
|
111
|
+
for child in node.children:
|
|
112
|
+
if child.type == "property_identifier":
|
|
113
|
+
return child.text.decode()
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Legacy class wrapper for backward compatibility
|
|
23
118
|
class TypeScriptMetricsCalculator:
|
|
24
|
-
"""Calculates metrics for TypeScript classes.
|
|
119
|
+
"""Calculates metrics for TypeScript classes.
|
|
120
|
+
|
|
121
|
+
Note: This class is a thin wrapper around module-level functions
|
|
122
|
+
for backward compatibility.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
"""Initialize the metrics calculator."""
|
|
127
|
+
pass # No state needed
|
|
25
128
|
|
|
26
129
|
def count_methods(self, class_node: Any) -> int:
|
|
27
130
|
"""Count number of methods in a TypeScript class.
|
|
@@ -32,16 +135,7 @@ class TypeScriptMetricsCalculator:
|
|
|
32
135
|
Returns:
|
|
33
136
|
Number of public methods (excludes constructor)
|
|
34
137
|
"""
|
|
35
|
-
|
|
36
|
-
if not class_body:
|
|
37
|
-
return 0
|
|
38
|
-
|
|
39
|
-
method_count = 0
|
|
40
|
-
for child in class_body.children:
|
|
41
|
-
if self._is_countable_method(child):
|
|
42
|
-
method_count += 1
|
|
43
|
-
|
|
44
|
-
return method_count
|
|
138
|
+
return count_methods(class_node)
|
|
45
139
|
|
|
46
140
|
def count_loc(self, class_node: Any, source: str) -> int:
|
|
47
141
|
"""Count lines of code in a TypeScript class.
|
|
@@ -53,38 +147,4 @@ class TypeScriptMetricsCalculator:
|
|
|
53
147
|
Returns:
|
|
54
148
|
Number of lines in class definition
|
|
55
149
|
"""
|
|
56
|
-
|
|
57
|
-
end_line = class_node.end_point[0]
|
|
58
|
-
return end_line - start_line + 1
|
|
59
|
-
|
|
60
|
-
def _get_class_body(self, class_node: Any) -> Any:
|
|
61
|
-
"""Get the class_body node from a class declaration.
|
|
62
|
-
|
|
63
|
-
Args:
|
|
64
|
-
class_node: Class declaration node
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
Class body node or None
|
|
68
|
-
"""
|
|
69
|
-
for child in class_node.children:
|
|
70
|
-
if child.type == "class_body":
|
|
71
|
-
return child
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
def _is_countable_method(self, node: Any) -> bool:
|
|
75
|
-
"""Check if node is a method that should be counted.
|
|
76
|
-
|
|
77
|
-
Args:
|
|
78
|
-
node: Tree-sitter node to check
|
|
79
|
-
|
|
80
|
-
Returns:
|
|
81
|
-
True if node is a countable method
|
|
82
|
-
"""
|
|
83
|
-
if node.type != "method_definition":
|
|
84
|
-
return False
|
|
85
|
-
|
|
86
|
-
# Check if it's a constructor
|
|
87
|
-
return all(
|
|
88
|
-
not (child.type == "property_identifier" and child.text.decode() == "constructor")
|
|
89
|
-
for child in node.children
|
|
90
|
-
)
|
|
150
|
+
return count_loc(class_node, source)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Stateless class linter package for detecting classes without state
|
|
3
|
+
|
|
4
|
+
Scope: Python classes that should be refactored to module-level functions
|
|
5
|
+
|
|
6
|
+
Overview: Package for detecting Python classes that have no constructor (__init__
|
|
7
|
+
or __new__) and no instance state (self.attr assignments), indicating they should
|
|
8
|
+
be refactored to module-level functions. Identifies a common anti-pattern in
|
|
9
|
+
AI-generated code where classes are used as namespaces rather than for object-
|
|
10
|
+
oriented encapsulation.
|
|
11
|
+
|
|
12
|
+
Dependencies: Python AST module, base linter framework
|
|
13
|
+
|
|
14
|
+
Exports: StatelessClassRule - main rule for detecting stateless classes
|
|
15
|
+
|
|
16
|
+
Interfaces: StatelessClassRule.check(context) -> list[Violation]
|
|
17
|
+
|
|
18
|
+
Implementation: AST-based analysis checking for constructor methods and instance
|
|
19
|
+
attribute assignments while excluding legitimate patterns (ABC, Protocol, decorators)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .linter import StatelessClassRule
|
|
23
|
+
from .python_analyzer import ClassInfo, StatelessClassAnalyzer
|
|
24
|
+
|
|
25
|
+
__all__ = ["StatelessClassRule", "StatelessClassAnalyzer", "ClassInfo"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Configuration schema for stateless-class linter
|
|
3
|
+
|
|
4
|
+
Scope: Stateless class linter configuration for Python files
|
|
5
|
+
|
|
6
|
+
Overview: Defines configuration schema for stateless-class linter. Provides
|
|
7
|
+
StatelessClassConfig dataclass with enabled flag, min_methods threshold (default 2)
|
|
8
|
+
for determining minimum methods required to flag a class as stateless, and ignore
|
|
9
|
+
patterns list for excluding specific files or directories. Supports per-file and
|
|
10
|
+
per-directory config overrides through from_dict class method. Integrates with
|
|
11
|
+
orchestrator's configuration system via .thailint.yaml.
|
|
12
|
+
|
|
13
|
+
Dependencies: dataclasses module for configuration structure, typing module for type hints
|
|
14
|
+
|
|
15
|
+
Exports: StatelessClassConfig dataclass
|
|
16
|
+
|
|
17
|
+
Interfaces: from_dict(config, language) -> StatelessClassConfig for configuration loading
|
|
18
|
+
|
|
19
|
+
Implementation: Dataclass with defaults matching stateless class detection conventions
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class StatelessClassConfig:
|
|
28
|
+
"""Configuration for stateless-class linter."""
|
|
29
|
+
|
|
30
|
+
enabled: bool = True
|
|
31
|
+
min_methods: int = 2
|
|
32
|
+
ignore: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(
|
|
36
|
+
cls, config: dict[str, Any] | None, language: str | None = None
|
|
37
|
+
) -> "StatelessClassConfig":
|
|
38
|
+
"""Load configuration from dictionary.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
config: Dictionary containing configuration values, or None
|
|
42
|
+
language: Programming language (unused, for interface compatibility)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
StatelessClassConfig instance with values from dictionary
|
|
46
|
+
"""
|
|
47
|
+
if config is None:
|
|
48
|
+
return cls()
|
|
49
|
+
|
|
50
|
+
ignore_patterns = config.get("ignore", [])
|
|
51
|
+
if not isinstance(ignore_patterns, list):
|
|
52
|
+
ignore_patterns = []
|
|
53
|
+
|
|
54
|
+
return cls(
|
|
55
|
+
enabled=config.get("enabled", True),
|
|
56
|
+
min_methods=config.get("min_methods", 2),
|
|
57
|
+
ignore=ignore_patterns,
|
|
58
|
+
)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Main stateless class linter rule implementation
|
|
3
|
+
|
|
4
|
+
Scope: StatelessClassRule class implementing BaseLintRule interface
|
|
5
|
+
|
|
6
|
+
Overview: Implements stateless class linter rule following BaseLintRule interface.
|
|
7
|
+
Detects Python classes that have no constructor (__init__ or __new__), no instance
|
|
8
|
+
state (self.attr assignments), and 2+ methods - indicating they should be refactored
|
|
9
|
+
to module-level functions. Delegates AST analysis to StatelessClassAnalyzer. Supports
|
|
10
|
+
configuration via .thailint.yaml and comprehensive 5-level ignore system including
|
|
11
|
+
project-level patterns, linter-specific ignore patterns, file-level directives,
|
|
12
|
+
line-level directives, and block-level directives.
|
|
13
|
+
|
|
14
|
+
Dependencies: BaseLintRule, BaseLintContext, Violation, StatelessClassAnalyzer,
|
|
15
|
+
IgnoreDirectiveParser, StatelessClassConfig
|
|
16
|
+
|
|
17
|
+
Exports: StatelessClassRule class
|
|
18
|
+
|
|
19
|
+
Interfaces: StatelessClassRule.check(context) -> list[Violation]
|
|
20
|
+
|
|
21
|
+
Implementation: Composition pattern delegating analysis to specialized analyzer with
|
|
22
|
+
config loading and comprehensive ignore checking
|
|
23
|
+
|
|
24
|
+
Suppressions:
|
|
25
|
+
- B101: Type narrowing assertion after _should_analyze guard (can't fail)
|
|
26
|
+
- srp,dry: Rule class coordinates analyzer, config, and ignore checking. Method count
|
|
27
|
+
exceeds limit due to comprehensive 5-level ignore system support.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from src.core.base import BaseLintContext, BaseLintRule
|
|
33
|
+
from src.core.constants import HEADER_SCAN_LINES, IgnoreDirective, Language
|
|
34
|
+
from src.core.types import Severity, Violation
|
|
35
|
+
from src.linter_config.ignore import get_ignore_parser
|
|
36
|
+
from src.linter_config.rule_matcher import rule_matches
|
|
37
|
+
|
|
38
|
+
from .config import StatelessClassConfig
|
|
39
|
+
from .python_analyzer import ClassInfo, StatelessClassAnalyzer
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
|
|
43
|
+
"""Detects stateless classes that should be module-level functions."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
"""Initialize the rule with analyzer and ignore parser."""
|
|
47
|
+
self._ignore_parser = get_ignore_parser()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def rule_id(self) -> str:
|
|
51
|
+
"""Unique identifier for this rule."""
|
|
52
|
+
return "stateless-class.violation"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def rule_name(self) -> str:
|
|
56
|
+
"""Human-readable name for this rule."""
|
|
57
|
+
return "Stateless Class Detection"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def description(self) -> str:
|
|
61
|
+
"""Description of what this rule checks."""
|
|
62
|
+
return "Classes without state should be refactored to module-level functions"
|
|
63
|
+
|
|
64
|
+
def check(self, context: BaseLintContext) -> list[Violation]:
|
|
65
|
+
"""Check for stateless class violations.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
context: Lint context with file information
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of violations found
|
|
72
|
+
"""
|
|
73
|
+
if not self._should_analyze(context):
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
config = self._load_config(context)
|
|
77
|
+
if not config.enabled or self._should_skip_file(context, config):
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
# _should_analyze ensures file_content is set
|
|
81
|
+
assert context.file_content is not None # nosec B101
|
|
82
|
+
|
|
83
|
+
analyzer = StatelessClassAnalyzer(min_methods=config.min_methods)
|
|
84
|
+
stateless_classes = analyzer.analyze(context.file_content)
|
|
85
|
+
|
|
86
|
+
return self._filter_ignored_violations(stateless_classes, context)
|
|
87
|
+
|
|
88
|
+
def _should_skip_file(self, context: BaseLintContext, config: StatelessClassConfig) -> bool:
|
|
89
|
+
"""Check if file should be skipped due to ignore patterns or directives.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
context: Lint context
|
|
93
|
+
config: Configuration
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if file should be skipped
|
|
97
|
+
"""
|
|
98
|
+
return self._is_file_ignored(context, config) or self._has_file_level_ignore(context)
|
|
99
|
+
|
|
100
|
+
def _should_analyze(self, context: BaseLintContext) -> bool:
|
|
101
|
+
"""Check if context should be analyzed.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
context: Lint context
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if should analyze
|
|
108
|
+
"""
|
|
109
|
+
return context.language == Language.PYTHON and context.file_content is not None
|
|
110
|
+
|
|
111
|
+
def _load_config(self, context: BaseLintContext) -> StatelessClassConfig:
|
|
112
|
+
"""Load configuration from context.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
context: Lint context
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
StatelessClassConfig instance
|
|
119
|
+
"""
|
|
120
|
+
if not hasattr(context, "config") or context.config is None:
|
|
121
|
+
return StatelessClassConfig()
|
|
122
|
+
|
|
123
|
+
config_dict = context.config
|
|
124
|
+
if not isinstance(config_dict, dict):
|
|
125
|
+
return StatelessClassConfig()
|
|
126
|
+
|
|
127
|
+
# Check for stateless-class specific config
|
|
128
|
+
linter_config = config_dict.get("stateless-class", config_dict)
|
|
129
|
+
return StatelessClassConfig.from_dict(linter_config)
|
|
130
|
+
|
|
131
|
+
def _is_file_ignored(self, context: BaseLintContext, config: StatelessClassConfig) -> bool:
|
|
132
|
+
"""Check if file matches ignore patterns.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
context: Lint context
|
|
136
|
+
config: Configuration
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if file should be ignored
|
|
140
|
+
"""
|
|
141
|
+
if not config.ignore:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
if not context.file_path:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
file_path = Path(context.file_path)
|
|
148
|
+
return any(self._matches_pattern(file_path, pattern) for pattern in config.ignore)
|
|
149
|
+
|
|
150
|
+
def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
|
|
151
|
+
"""Check if file path matches a glob pattern.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
file_path: Path to check
|
|
155
|
+
pattern: Glob pattern
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if path matches pattern
|
|
159
|
+
"""
|
|
160
|
+
if file_path.match(pattern):
|
|
161
|
+
return True
|
|
162
|
+
if pattern in str(file_path):
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
def _has_file_level_ignore(self, context: BaseLintContext) -> bool:
|
|
167
|
+
"""Check if file has file-level ignore directive.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
context: Lint context
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if file should be ignored at file level
|
|
174
|
+
"""
|
|
175
|
+
if not context.file_content:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
# Check first lines for ignore-file directive
|
|
179
|
+
lines = context.file_content.splitlines()[:HEADER_SCAN_LINES]
|
|
180
|
+
return any(self._is_file_ignore_directive(line) for line in lines)
|
|
181
|
+
|
|
182
|
+
def _is_file_ignore_directive(self, line: str) -> bool:
|
|
183
|
+
"""Check if line is a file-level ignore directive.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
line: Line to check
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True if line has file-level ignore for this rule
|
|
190
|
+
"""
|
|
191
|
+
line_lower = line.lower()
|
|
192
|
+
if "thailint: ignore-file" not in line_lower:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
# Check for general ignore-file (no rule specified)
|
|
196
|
+
if "ignore-file[" not in line_lower:
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
# Check for rule-specific ignore
|
|
200
|
+
return self._matches_rule_ignore(line_lower, "ignore-file")
|
|
201
|
+
|
|
202
|
+
def _matches_rule_ignore(self, line: str, directive: str) -> bool:
|
|
203
|
+
"""Check if line matches rule-specific ignore.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
line: Line to check (lowercase)
|
|
207
|
+
directive: Directive name (ignore-file or ignore)
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if ignore applies to this rule
|
|
211
|
+
"""
|
|
212
|
+
import re
|
|
213
|
+
|
|
214
|
+
pattern = rf"{directive}\[([^\]]+)\]"
|
|
215
|
+
match = re.search(pattern, line)
|
|
216
|
+
if not match:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
rules = [r.strip().lower() for r in match.group(1).split(",")]
|
|
220
|
+
return any(self._rule_matches(r) for r in rules)
|
|
221
|
+
|
|
222
|
+
def _rule_matches(self, rule_pattern: str) -> bool:
|
|
223
|
+
"""Check if rule pattern matches this rule.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
rule_pattern: Rule pattern to check
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if pattern matches this rule
|
|
230
|
+
"""
|
|
231
|
+
return rule_matches(self.rule_id, rule_pattern)
|
|
232
|
+
|
|
233
|
+
def _filter_ignored_violations(
|
|
234
|
+
self, classes: list[ClassInfo], context: BaseLintContext
|
|
235
|
+
) -> list[Violation]:
|
|
236
|
+
"""Filter out violations that should be ignored.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
classes: List of stateless classes found
|
|
240
|
+
context: Lint context
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
List of violations after filtering ignored ones
|
|
244
|
+
"""
|
|
245
|
+
violations = []
|
|
246
|
+
for info in classes:
|
|
247
|
+
violation = self._create_violation(info, context)
|
|
248
|
+
if not self._should_ignore_violation(violation, info, context):
|
|
249
|
+
violations.append(violation)
|
|
250
|
+
return violations
|
|
251
|
+
|
|
252
|
+
def _should_ignore_violation(
|
|
253
|
+
self, violation: Violation, info: ClassInfo, context: BaseLintContext
|
|
254
|
+
) -> bool:
|
|
255
|
+
"""Check if violation should be ignored.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
violation: Violation to check
|
|
259
|
+
info: Class info
|
|
260
|
+
context: Lint context
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if violation should be ignored
|
|
264
|
+
"""
|
|
265
|
+
if not context.file_content:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
# Check using IgnoreDirectiveParser for comprehensive ignore checking
|
|
269
|
+
if self._ignore_parser.should_ignore_violation(violation, context.file_content):
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
# Also check inline ignore on class line
|
|
273
|
+
return self._has_inline_ignore(info.line, context)
|
|
274
|
+
|
|
275
|
+
def _has_inline_ignore(self, line_num: int, context: BaseLintContext) -> bool:
|
|
276
|
+
"""Check for inline ignore directive on class line.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
line_num: Line number to check
|
|
280
|
+
context: Lint context
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if line has ignore directive
|
|
284
|
+
"""
|
|
285
|
+
line = self._get_line_text(line_num, context)
|
|
286
|
+
if not line:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
return self._is_ignore_directive(line.lower())
|
|
290
|
+
|
|
291
|
+
def _get_line_text(self, line_num: int, context: BaseLintContext) -> str | None:
|
|
292
|
+
"""Get text of a specific line.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
line_num: Line number (1-indexed)
|
|
296
|
+
context: Lint context
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Line text or None if invalid
|
|
300
|
+
"""
|
|
301
|
+
if not context.file_content:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
lines = context.file_content.splitlines()
|
|
305
|
+
if line_num <= 0 or line_num > len(lines):
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
return lines[line_num - 1]
|
|
309
|
+
|
|
310
|
+
def _is_ignore_directive(self, line: str) -> bool:
|
|
311
|
+
"""Check if line contains ignore directive for this rule.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
line: Line text (lowercase)
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
True if line has applicable ignore directive
|
|
318
|
+
"""
|
|
319
|
+
if "thailint:" not in line or "ignore" not in line:
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
# General ignore (no rule specified)
|
|
323
|
+
if "ignore[" not in line:
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
# Rule-specific ignore
|
|
327
|
+
return self._matches_rule_ignore(line, IgnoreDirective.IGNORE)
|
|
328
|
+
|
|
329
|
+
def _create_violation(self, info: ClassInfo, context: BaseLintContext) -> Violation:
|
|
330
|
+
"""Create violation from class info.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
info: Detected stateless class info
|
|
334
|
+
context: Lint context
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Violation instance
|
|
338
|
+
"""
|
|
339
|
+
message = (
|
|
340
|
+
f"Class '{info.name}' has no state and should be refactored to module-level functions"
|
|
341
|
+
)
|
|
342
|
+
return Violation(
|
|
343
|
+
rule_id=self.rule_id,
|
|
344
|
+
message=message,
|
|
345
|
+
file_path=str(context.file_path),
|
|
346
|
+
line=info.line,
|
|
347
|
+
column=info.column,
|
|
348
|
+
severity=Severity.ERROR,
|
|
349
|
+
)
|