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.
Files changed (214) hide show
  1. src/__init__.py +1 -0
  2. src/analyzers/__init__.py +4 -3
  3. src/analyzers/ast_utils.py +54 -0
  4. src/analyzers/rust_base.py +155 -0
  5. src/analyzers/rust_context.py +141 -0
  6. src/analyzers/typescript_base.py +4 -0
  7. src/cli/__init__.py +30 -0
  8. src/cli/__main__.py +22 -0
  9. src/cli/config.py +480 -0
  10. src/cli/config_merge.py +241 -0
  11. src/cli/linters/__init__.py +67 -0
  12. src/cli/linters/code_patterns.py +270 -0
  13. src/cli/linters/code_smells.py +342 -0
  14. src/cli/linters/documentation.py +83 -0
  15. src/cli/linters/performance.py +287 -0
  16. src/cli/linters/shared.py +331 -0
  17. src/cli/linters/structure.py +327 -0
  18. src/cli/linters/structure_quality.py +328 -0
  19. src/cli/main.py +120 -0
  20. src/cli/utils.py +395 -0
  21. src/cli_main.py +37 -0
  22. src/config.py +44 -27
  23. src/core/base.py +95 -5
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +36 -6
  26. src/core/constants.py +54 -0
  27. src/core/linter_utils.py +95 -6
  28. src/core/python_lint_rule.py +101 -0
  29. src/core/registry.py +1 -1
  30. src/core/rule_discovery.py +147 -84
  31. src/core/types.py +13 -0
  32. src/core/violation_builder.py +78 -15
  33. src/core/violation_utils.py +69 -0
  34. src/formatters/__init__.py +22 -0
  35. src/formatters/sarif.py +202 -0
  36. src/linter_config/directive_markers.py +109 -0
  37. src/linter_config/ignore.py +254 -395
  38. src/linter_config/loader.py +45 -12
  39. src/linter_config/pattern_utils.py +65 -0
  40. src/linter_config/rule_matcher.py +89 -0
  41. src/linters/collection_pipeline/__init__.py +90 -0
  42. src/linters/collection_pipeline/any_all_analyzer.py +281 -0
  43. src/linters/collection_pipeline/ast_utils.py +40 -0
  44. src/linters/collection_pipeline/config.py +75 -0
  45. src/linters/collection_pipeline/continue_analyzer.py +94 -0
  46. src/linters/collection_pipeline/detector.py +360 -0
  47. src/linters/collection_pipeline/filter_map_analyzer.py +402 -0
  48. src/linters/collection_pipeline/linter.py +420 -0
  49. src/linters/collection_pipeline/suggestion_builder.py +130 -0
  50. src/linters/cqs/__init__.py +54 -0
  51. src/linters/cqs/config.py +55 -0
  52. src/linters/cqs/function_analyzer.py +201 -0
  53. src/linters/cqs/input_detector.py +139 -0
  54. src/linters/cqs/linter.py +159 -0
  55. src/linters/cqs/output_detector.py +84 -0
  56. src/linters/cqs/python_analyzer.py +54 -0
  57. src/linters/cqs/types.py +82 -0
  58. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  59. src/linters/cqs/typescript_function_analyzer.py +192 -0
  60. src/linters/cqs/typescript_input_detector.py +203 -0
  61. src/linters/cqs/typescript_output_detector.py +117 -0
  62. src/linters/cqs/violation_builder.py +94 -0
  63. src/linters/dry/base_token_analyzer.py +16 -9
  64. src/linters/dry/block_filter.py +125 -22
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +142 -94
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +68 -21
  69. src/linters/dry/constant.py +92 -0
  70. src/linters/dry/constant_matcher.py +223 -0
  71. src/linters/dry/constant_violation_builder.py +98 -0
  72. src/linters/dry/duplicate_storage.py +20 -82
  73. src/linters/dry/file_analyzer.py +15 -50
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +182 -54
  76. src/linters/dry/python_analyzer.py +108 -336
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/storage_initializer.py +9 -18
  80. src/linters/dry/token_hasher.py +129 -71
  81. src/linters/dry/typescript_analyzer.py +68 -380
  82. src/linters/dry/typescript_constant_extractor.py +138 -0
  83. src/linters/dry/typescript_statement_detector.py +255 -0
  84. src/linters/dry/typescript_value_extractor.py +70 -0
  85. src/linters/dry/violation_builder.py +4 -0
  86. src/linters/dry/violation_filter.py +9 -5
  87. src/linters/dry/violation_generator.py +71 -14
  88. src/linters/file_header/__init__.py +24 -0
  89. src/linters/file_header/atemporal_detector.py +105 -0
  90. src/linters/file_header/base_parser.py +93 -0
  91. src/linters/file_header/bash_parser.py +66 -0
  92. src/linters/file_header/config.py +140 -0
  93. src/linters/file_header/css_parser.py +70 -0
  94. src/linters/file_header/field_validator.py +72 -0
  95. src/linters/file_header/linter.py +309 -0
  96. src/linters/file_header/markdown_parser.py +130 -0
  97. src/linters/file_header/python_parser.py +42 -0
  98. src/linters/file_header/typescript_parser.py +73 -0
  99. src/linters/file_header/violation_builder.py +79 -0
  100. src/linters/file_placement/config_loader.py +3 -1
  101. src/linters/file_placement/directory_matcher.py +4 -0
  102. src/linters/file_placement/linter.py +74 -31
  103. src/linters/file_placement/pattern_matcher.py +41 -6
  104. src/linters/file_placement/pattern_validator.py +31 -12
  105. src/linters/file_placement/rule_checker.py +12 -7
  106. src/linters/lazy_ignores/__init__.py +43 -0
  107. src/linters/lazy_ignores/config.py +74 -0
  108. src/linters/lazy_ignores/directive_utils.py +164 -0
  109. src/linters/lazy_ignores/header_parser.py +177 -0
  110. src/linters/lazy_ignores/linter.py +158 -0
  111. src/linters/lazy_ignores/matcher.py +168 -0
  112. src/linters/lazy_ignores/python_analyzer.py +209 -0
  113. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  114. src/linters/lazy_ignores/skip_detector.py +298 -0
  115. src/linters/lazy_ignores/types.py +71 -0
  116. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  117. src/linters/lazy_ignores/violation_builder.py +135 -0
  118. src/linters/lbyl/__init__.py +31 -0
  119. src/linters/lbyl/config.py +63 -0
  120. src/linters/lbyl/linter.py +67 -0
  121. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  122. src/linters/lbyl/pattern_detectors/base.py +63 -0
  123. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  124. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  125. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  126. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  127. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  128. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  129. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  130. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  131. src/linters/lbyl/python_analyzer.py +215 -0
  132. src/linters/lbyl/violation_builder.py +354 -0
  133. src/linters/magic_numbers/__init__.py +48 -0
  134. src/linters/magic_numbers/config.py +82 -0
  135. src/linters/magic_numbers/context_analyzer.py +249 -0
  136. src/linters/magic_numbers/linter.py +462 -0
  137. src/linters/magic_numbers/python_analyzer.py +64 -0
  138. src/linters/magic_numbers/typescript_analyzer.py +215 -0
  139. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  140. src/linters/magic_numbers/violation_builder.py +98 -0
  141. src/linters/method_property/__init__.py +49 -0
  142. src/linters/method_property/config.py +138 -0
  143. src/linters/method_property/linter.py +414 -0
  144. src/linters/method_property/python_analyzer.py +473 -0
  145. src/linters/method_property/violation_builder.py +119 -0
  146. src/linters/nesting/__init__.py +6 -2
  147. src/linters/nesting/config.py +6 -3
  148. src/linters/nesting/linter.py +31 -34
  149. src/linters/nesting/python_analyzer.py +4 -0
  150. src/linters/nesting/typescript_analyzer.py +6 -11
  151. src/linters/nesting/violation_builder.py +1 -0
  152. src/linters/performance/__init__.py +91 -0
  153. src/linters/performance/config.py +43 -0
  154. src/linters/performance/constants.py +49 -0
  155. src/linters/performance/linter.py +149 -0
  156. src/linters/performance/python_analyzer.py +365 -0
  157. src/linters/performance/regex_analyzer.py +312 -0
  158. src/linters/performance/regex_linter.py +139 -0
  159. src/linters/performance/typescript_analyzer.py +236 -0
  160. src/linters/performance/violation_builder.py +160 -0
  161. src/linters/print_statements/__init__.py +53 -0
  162. src/linters/print_statements/config.py +78 -0
  163. src/linters/print_statements/linter.py +413 -0
  164. src/linters/print_statements/python_analyzer.py +153 -0
  165. src/linters/print_statements/typescript_analyzer.py +125 -0
  166. src/linters/print_statements/violation_builder.py +96 -0
  167. src/linters/srp/__init__.py +3 -3
  168. src/linters/srp/class_analyzer.py +11 -7
  169. src/linters/srp/config.py +12 -6
  170. src/linters/srp/heuristics.py +56 -22
  171. src/linters/srp/linter.py +47 -39
  172. src/linters/srp/python_analyzer.py +55 -20
  173. src/linters/srp/typescript_metrics_calculator.py +110 -50
  174. src/linters/stateless_class/__init__.py +25 -0
  175. src/linters/stateless_class/config.py +58 -0
  176. src/linters/stateless_class/linter.py +349 -0
  177. src/linters/stateless_class/python_analyzer.py +290 -0
  178. src/linters/stringly_typed/__init__.py +36 -0
  179. src/linters/stringly_typed/config.py +189 -0
  180. src/linters/stringly_typed/context_filter.py +451 -0
  181. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  182. src/linters/stringly_typed/ignore_checker.py +100 -0
  183. src/linters/stringly_typed/ignore_utils.py +51 -0
  184. src/linters/stringly_typed/linter.py +376 -0
  185. src/linters/stringly_typed/python/__init__.py +33 -0
  186. src/linters/stringly_typed/python/analyzer.py +348 -0
  187. src/linters/stringly_typed/python/call_tracker.py +175 -0
  188. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  189. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  190. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  191. src/linters/stringly_typed/python/constants.py +21 -0
  192. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  193. src/linters/stringly_typed/python/validation_detector.py +189 -0
  194. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  195. src/linters/stringly_typed/storage.py +620 -0
  196. src/linters/stringly_typed/storage_initializer.py +45 -0
  197. src/linters/stringly_typed/typescript/__init__.py +28 -0
  198. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  199. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  200. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  201. src/linters/stringly_typed/violation_generator.py +419 -0
  202. src/orchestrator/core.py +264 -16
  203. src/orchestrator/language_detector.py +5 -3
  204. src/templates/thailint_config_template.yaml +354 -0
  205. src/utils/project_root.py +138 -16
  206. thailint-0.15.3.dist-info/METADATA +187 -0
  207. thailint-0.15.3.dist-info/RECORD +226 -0
  208. {thailint-0.2.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +1 -1
  209. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  210. src/cli.py +0 -1055
  211. thailint-0.2.0.dist-info/METADATA +0 -980
  212. thailint-0.2.0.dist-info/RECORD +0 -75
  213. thailint-0.2.0.dist-info/entry_points.txt +0 -4
  214. {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), calculates lines of code from AST node
8
- positions, and identifies class body nodes. Uses tree-sitter AST node types. Isolates metrics
9
- calculation from class analysis and tree traversal logic.
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
- class_body = self._get_class_body(class_node)
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
- start_line = class_node.start_point[0]
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
+ )